diff --git a/README.md b/README.md index 310957b..4fba459 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,14 @@ The suite is the two halves of that: colonists commit crimes and a policing laye Arrest is the hinge. Nothing hard-codes "prisoner" — every held-person system keys off a **secured context**: prisoner, ward patient, slave, or secure-area occupant. +## Documentation + +The **complete suite wiki lives in this repo** — [**Wiki/Home.md**](Wiki/Home.md) is the front door: +the full gameplay loop, all five layers, and per-layer deep dives, aggregated in one place under +[`Wiki/`](Wiki/) (`core/`, `contraband/`, `justice/`, `gangs/`, `ward/`). Each layer repo carries its +own copy for a standalone install; [`Tools/sync-wiki.sh`](Tools/sync-wiki.sh) refreshes the +aggregated view here. + ## The mods | Mod | What it is | Needs | Status | diff --git a/Tools/sync-wiki.sh b/Tools/sync-wiki.sh new file mode 100755 index 0000000..08ffb1c --- /dev/null +++ b/Tools/sync-wiki.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Consolidate every layer's Wiki/ into THIS repo's Wiki/, so the Institution page is the single place +# to read the whole suite's documentation. Each layer's pages land under Wiki// with their own +# internal links intact. The layer repos stay the canonical source (each ships its own wiki for a +# standalone install); re-run this whenever a layer's wiki changes to re-sync the aggregated copy. +# +# Wiki/Home.md (the suite index) is hand-written and lives at the top level -- this script never +# touches it, only the per-layer subfolders. +# +# Usage: Tools/sync-wiki.sh (assumes siblings at ../rimworld-) +set -euo pipefail + +HERE="$(cd "$(dirname "$0")/.." && pwd)" +SIB="$(dirname "$HERE")" + +# layer folder -> sibling repo +LAYERS=( + "core:rimworld-core" + "contraband:rimworld-contraband" + "justice:rimworld-justice" + "gangs:rimworld-gangs" + "ward:rimworld-ward" +) + +echo "==> mirroring layer wikis into $HERE/Wiki/" +for entry in "${LAYERS[@]}"; do + layer="${entry%%:*}" + repo="${entry##*:}" + src="$SIB/$repo/Wiki" + dst="$HERE/Wiki/$layer" + if [[ -d "$src" ]]; then + rm -rf "$dst" + mkdir -p "$dst" + cp "$src"/*.md "$dst"/ + echo " $layer: $(ls "$dst" | wc -l | tr -d ' ') pages" + else + echo " $layer: no Wiki/ in $repo -- skipped" + fi +done + +echo "==> done. The Institution wiki now mirrors every layer under Wiki//; Wiki/Home.md is the index." diff --git a/Wiki/Home.md b/Wiki/Home.md index a00d9cf..ccf15a3 100644 --- a/Wiki/Home.md +++ b/Wiki/Home.md @@ -53,12 +53,12 @@ one off disables its systems in place without uninstalling anything. | Layer | Toggle | What it adds | Wiki | |---|---|---|---| -| **Core** | *(always on — the engine)* | propensity, the criminal record, secured context, the shared **treatment engine**, the deterrence seam | [Core](https://git.onetick.ninja/flan/rimworld-core/wiki) | -| **Contraband** | Contraband | concealment & intake, improvised shivs, Prison-Architect tunnels, warden search, warden corruption | [Contraband](https://git.onetick.ninja/flan/rimworld-contraband/wiki) | -| **Justice** | Justice | classification, deterrence feedback, discipline & reform, parole, regime (prisoner recreation) | [Justice](https://git.onetick.ninja/flan/rimworld-justice/wiki) | -| **Justice · Policing** | Policing | colony crime on the spectrum, witnesses, a constable, the weighed arrest, prison riots, recidivism alerts | [Policing](https://git.onetick.ninja/flan/rimworld-justice/wiki/Policing) | -| **Gangs** | Gangs | gangs as contraband economies — joining, smuggling networks, rivalry, fights-as-crime | [Gangs](https://git.onetick.ninja/flan/rimworld-gangs/wiki) | -| **Ward** | Ward | psychiatric care — treatment, recovery, discharge, ward neglect, sedation | [Ward](https://git.onetick.ninja/flan/rimworld-ward/wiki) | +| **Core** | *(always on — the engine)* | propensity, the criminal record, secured context, the shared **treatment engine**, the deterrence seam | [Core](core/Home.md) | +| **Contraband** | Contraband | concealment & intake, improvised shivs, Prison-Architect tunnels, warden search, warden corruption | [Contraband](contraband/Home.md) | +| **Justice** | Justice | classification, deterrence feedback, discipline & reform, parole, regime (prisoner recreation) | [Justice](justice/Home.md) | +| **Justice · Policing** | Policing | colony crime on the spectrum, witnesses, a constable, the weighed arrest, prison riots, recidivism alerts | [Policing](justice/Policing.md) | +| **Gangs** | Gangs | gangs as contraband economies — joining, smuggling networks, rivalry, fights-as-crime | [Gangs](gangs/Home.md) | +| **Ward** | Ward | psychiatric care — treatment, recovery, discharge, ward neglect, sedation | [Ward](ward/Home.md) | **Policing** has its own toggle inside Justice because colony crime is a large behavioural change — a player can run the rest of the corrections layer without their own colonists committing crimes. diff --git a/Wiki/contraband/Compatibility.md b/Wiki/contraband/Compatibility.md new file mode 100644 index 0000000..426b652 --- /dev/null +++ b/Wiki/contraband/Compatibility.md @@ -0,0 +1,128 @@ +# Compatibility + +*Contraband ships zero Harmony patches. It adds defs, subclasses a work-giver, mutates two duty +trees at startup, and polls. That is why it composes instead of colliding.* + +## Requires: Institution: Core + +Contraband declares a hard `modDependency` on **`flan.institution.core`** and loads after it. This +is not optional — after the split, the entire substrate Contraband reads lives in Core: + +| Core provides | Contraband uses it for | +|---|---| +| `SecuredContext` / `SecuredContexts` / `CanConceal` / `IsHeld` | who counts as a concealer; who can be intaken, whittle, or dig | +| `Propensity.Would(pawn, base, salt)` | every disposition roll — intake (0.6), improvise (0.10), dig (0.08), all seeded and scaled by nature × nurture | +| `CriminalRecord` + `GameComponent_CriminalRecords` | `timesSearched`, `timesCaught`, `escapeAttempts`, `contrabandMade` — written by search and acquisition, read by priority | + +Install Contraband without Core and it will not run. Everything else on this page is optional +interplay that degrades gracefully when the other mod is absent. + +## Zero Harmony patches + +Post-split, the mod's one Harmony patch (the regime that forces Joy for prisoners) moved to +**Institution: Justice**, so Contraband is Harmony-free again. Instead of patching, it: + +- **Adds a new `WorkGiver`** (`WorkGiver_Warden_Search`) rather than patching a vanilla one. +- **Subclasses `WorkGiver_Warden`**, inheriting its behaviour rather than overriding it. +- **Mutates two `DutyDef` think trees at startup** via `StaticConstructorOnStartup` (not Harmony) — + an XML patch on a duty think tree silently no-ops in 1.6, so it is done in C#, deterministically. +- **Polls** every held pawn from a `MapComponent` for intake, improvisation, and tunnels — no hook + on the capture event is needed because the tick already visits every held pawn. + +The net effect: the surfaces other prison mods touch are mostly untouched here, so conflicts are the +exception, not the rule. + +## Foul Play — the carboy bridge + +**Foul Play** (the "Piss Nuke" mod) stays its own mod with its own front door, and yet its **carboy +is a first-class contraband item.** This is the flagship demonstration of Contraband's *"items are +defs, subsystems are classes, mods are audiences"* principle, and it works through two seams: + +1. **Cross-assembly worker resolution.** `CompProperties_Contraband`'s `useWorker` / `escapeWorker` + fields are plain `Type`s, and RimWorld resolves def `Type` fields with + `GenTypes.GetTypeInAnyAssembly` — searching **every loaded assembly**. So the carboy declares + `CompProperties_Contraband` and points `useWorker` at a class that lives in *Foul Play's* assembly + (e.g. a throw-the-carboy worker). Contraband never has to know that class exists. +2. **The opaque content tag.** A carboy is not just "a jar" — it is a jar *of* a fermented blend, and + that blend has to survive being concealed. Foul Play sets Contraband's two delegates: + + ```csharp + ContrabandUtility.ContentTagOf = thing => /* serialise the carboy's blend */; + ContrabandUtility.ApplyContent = (thing, tag) => /* pour that blend back in on redeem */; + ``` + + When a carboy is intaken or smuggled, Contraband captures the tag blind; on redemption it hands + the tag back to Foul Play to restore the contents. **Contraband knows nothing about substances or + fermentation** — that framework lives entirely in Foul Play. If Foul Play is absent, both delegates + are null, no content is captured, and nothing breaks: a jar is just a jar. See + [Concealment and Intake](Concealment-and-Intake.md). + +The dependency runs one way only: Foul Play bridges *to* Contraband (and to Core) if they are +present; Contraband takes **no** dependency on Foul Play. + +## Institution: Justice — the record is the shared bus + +Contraband and **Justice** never call each other. They meet on Core's `CriminalRecord`: + +``` +Contraband writes ──▶ CriminalRecord ──▶ Justice reads + timesSearched (in Core) Classification (security grade) + timesCaught Deterrence (colony-wide signal) + escapeAttempts Discipline / Parole (reform) + contrabandMade +``` + +- **Catching contraband feeds classification.** A prisoner repeatedly caught with shivs or foiled + mid-tunnel accrues `timesCaught` and `escapeAttempts`, which Justice's classification weighs into a + higher security grade. +- **And it feeds back.** Justice's deterrence signal flows *back* through Core's propensity seam + (`Nurture`), nudging every future intake / improvise / dig roll a Contraband pawn makes. A prison + that catches and disciplines becomes a prison where fewer prisoners bother trying — without either + mod referencing the other. Run Contraband alone and the deterrence factor is a neutral 1.0; the + record fields still route the warden. + +## Institution: Gangs — the contraband economy + +**Gangs** depends on Core, **Contraband**, and Justice, because a gang *is* a contraband economy: + +- Gang smuggling moves contraband through the same public `Conceal` door intake and corruption use. +- A gang's outside members are the natural caller for the **reach-in corruption** primitive + (`Corruption.Smuggle`) — paying a bent warden to make a delivery. That primitive is exposed in + Contraband but has no in-repo trigger; Gangs is what drives it. See [Corruption](Corruption.md). +- Gang fights route to Justice's crime record, which then raises search priority here. The loop + closes through Core again. + +## Prison mods the suite is tested against + +| Mod | Interaction | Verdict | +|---|---|---| +| **Prison Commons** | postfixes `WorkGiver_Warden.ShouldSkip` on the **base** class; Contraband's search subclasses it and does not override, so searches respect prison-commons/allowed areas **for free** | works by inheritance | +| **Custom Prisoner Interactions** | patches only the *named* vanilla warden givers (`_Chat`, `_Convert`, `_Enslave`, `_ReleasePrisoner`); it cannot see a brand-new giver | no conflict | +| **Prisoner Realism** | its escapes are for "mood prisoners" mid-break; a tunnel breakout hands off to vanilla `PrisonBreakUtility.StartPrisonBreak`, which Prisoner Realism layers on. Tunnels **complement** it — they let ward patients and slaves who would never mood-break still, patiently, dig out | complementary | +| **Prison Labor** | prisoners working outside the cell are still held pawns and still tracked; Contraband adds no patches to labor jobs. A work assignment is simply another place a disposed prisoner might come by materials | no conflict | +| **Prisoner Recreation** | overlaps the *regime* feature — which now lives in **Institution: Justice**, not here, and is written to be idempotent with Prisoner Recreation | not Contraband's concern post-split | + +## Load order + +``` +Ludeon.RimWorld +flan.institution.core ← required, loads before Contraband +flan.institution.contraband ← this mod +(Foul Play / Justice / Gangs — any order after Core; each bridges if present) +``` + +## The general rule + +If a mod adds prisoner behaviour by **patching vanilla warden givers or the escape duty tree by +name**, it will not see Contraband's additions, and Contraband will not see its — they pass each +other. If a mod adds a *new* `WorkGiver` or duty node of its own, it stacks. The one place to watch is +another mod that *also* rewrites the same two duty think trees (`PrisonerEscape`, +`PrisonerAssaultColony`) wholesale; Contraband inserts its nodes at a specific anchor and logs a +warning if that anchor is missing, so a load-order or conflict problem is visible in the log rather +than silent. + +--- + +*Part of the **Institution** suite — Core · Contraband · Justice · Gangs, alongside Foul Play and +Ward. Each stands alone; together they interlock.* +*Developed with substantial assistance from Claude (Anthropic).* diff --git a/Wiki/contraband/Concealment-and-Intake.md b/Wiki/contraband/Concealment-and-Intake.md new file mode 100644 index 0000000..740136c --- /dev/null +++ b/Wiki/contraband/Concealment-and-Intake.md @@ -0,0 +1,230 @@ +# Concealment and Intake + +*How a hidden item exists without existing, how it gets onto a prisoner in the first place, and how +it becomes real again.* + +This is the spine of the whole mod. Everything else — shivs, tunnels, searches, corruption — is a +different way to add to, or remove from, the private list this page describes. + +## While it is concealed, there is no Thing + +The single most important rule, and the source states it plainly: + +> `MapComponent_Contraband` tracks what each pawn is hiding. **Concealed contraband is not a Thing. +> It is a secret.** It is not in the gear tab, there is no icon, and the player is told nothing — +> because an item you can see in a UI is not contraband, it is inventory, and there would be nothing +> for a warden to discover and no reason to ever order a search. + +Concretely, a concealed item lives as a `ConcealedItem` row in a `List` on the map component. It +carries the def, a `progress` value, the material it ended up made of, an optional source it is +being harvested from, tunnel state if it is a pick, and an opaque `contentTag`. It holds **no +spawned object** anywhere in the world until one of two things happens: + +| Moment | What happens | Method | +|---|---|---| +| The prisoner **uses** it | It materialises into their inventory as a real `Thing` | `TryRedeem` | +| A warden **finds** it | It is deleted from the list; it never becomes a `Thing` at all | `Confiscate` | + +A corollary the search page leans on hard: **an empty search must still cost time.** If the +`Search` job only ever appeared when there was genuinely something to find, the mere *offer* of the +job would be the discovery. You have to be able to toss a clean cell and come up with nothing. + +## Where a thing can hide: `ConcealSite` + +Every contraband def declares where it can be hidden. This is not decoration — it decides **who can +ever find it**. + +```csharp +[Flags] enum ConcealSite { None = 0, OnBody = 1, InCell = 2 } +``` + +| Site | Holds | Found by | +|---|---|---| +| `OnBody` | small: a shiv, pills, a phone | searching the **pawn** | +| `InCell` | bulky: a rifle, a carboy, a crude pick | tossing the **room** | + +A warden turning over a prisoner and their cell reaches both (`OnBody | InCell`). The reason the +enum is split at all: a **constable** in Justice's now-shipping policing layer, frisking a colonist +named in a street crime, reaches only `OnBody`, so whatever is under their floorboards stays there +until somebody has grounds to search the *room*. The split between "who may search" and "what a +search reaches" is deliberate — see [Warden Search](Warden-Search.md). + +## Three sources of contraband, and no list of items + +At startup, `ContrabandUtility` scans every `ThingDef` and decides whether it is contraband. There +is no hardcoded list of item names — there are three recognisers: + +1. **Explicit.** Any def carrying `CompProperties_Contraband`. This is the opt-in, and it is how a + mod plugs in its own `acquireWorker` / `useWorker` / `escapeWorker` behaviour. +2. **Every drug** (`ThingDef.IsDrug`) — inferred automatically. +3. **Every weapon** (`ThingDef.IsWeapon`) that is not `destroyOnDrop` — inferred from **mass**. + +The inference (`ContrabandUtility.Infer`) fills in concealability and hiding place so a modder never +has to: + +| Kind | Test | `concealability` | `hideIn` | +|---|---|---|---| +| Hard drug | `drugCategory == Hard` | **0.75** | `OnBody \| InCell` | +| Other drug | any other `IsDrug` | **0.5** | `OnBody \| InCell` | +| Light weapon | `Mass ≤ 2.0` | **0.7** | `OnBody \| InCell` | +| Heavy weapon | `2.0 < Mass ≤ 10.0` | **0.35** | `InCell` only | +| Very heavy weapon | `Mass > 10.0` | — | **not contraband** (a minigun hides nowhere) | + +`concealability` runs 0..1 where **0 = a warden finds it every time and 1 = never**; higher is +harder to find. Drugs and inferred weapons get **no `acquireWorker`** (you cannot synthesise yayo in +a bare cell, and you cannot conjure a rifle) and **no `useWorker`** — because vanilla already knows +what to do with them. `JobGiver_SatisfyChemicalNeed` takes a drug; an armed prisoner *is* the +payload. Getting the item into the cell is the entire problem, and that is all these routes solve. + +Nothing here mutates another mod's defs. It is a lookup keyed on the def, computed once, held in a +dictionary. Modded drugs and weapons are covered without ever being named. + +## The poll: no Harmony + +`MapComponent_Contraband` runs on a heartbeat. Every **250 ticks** (`CheckIntervalTicks`) it visits +every pawn who could be concealing something and does all the ongoing work: intake, tunnel progress, +and improvisation. If nothing on the map is self-acquirable and there are no escape tools registered, +it returns immediately and costs nothing. + +Polling — rather than patching — is a deliberate architecture choice. **The tick already visits +every held pawn**, so intake needs no Harmony hook on the capture event: a pawn who is in custody but +not yet in the `intakeProcessed` set is, by definition, one who was newly taken. The whole mod ships +zero Harmony patches, and this is a large part of why. + +### Who counts as a concealer + +`ContrabandUtility.Concealers(map)` is **not** just prisoners. It delegates to Core's +secured-context predicate and yields every pawn whose belongings are anyone's business — prisoners, +slaves, **and free colonists**: + +> A free colonist forbidden their drug by a drug policy or an ideoligion is precisely the pawn who +> keeps a private stash — and vanilla already denies them, with no valve. + +Whether anyone may *search* a given concealer is a separate question with a separate answer (the +warden's authority reaches prisoners; a future cop's reaches colonists). Tracking is pawn-agnostic; +authority is not. + +## Intake: the moment of capture + +Vanilla never strips a captive *on capture* — a downed pawn keeps whatever they walked in with, and +the warden path just hauls them to a cell. Intake is Contraband's answer to that gap. + +`RunIntake(pawn)` runs from the poll, on the first pass that sees a newly-held pawn: + +1. **Held only.** The pawn must be humanlike and `IsHeld` (prisoner, slave, ward patient). A free + colonist is a concealer, but nobody frisks and processes *them* on the way into a cell, so their + carried gear is never intaken. +2. **Once.** The pawn's `thingIDNumber` is added to a `HashSet`. The set stores an `int`, not a + pawn reference, so it holds no ghost of a captive who has since died. A second pass finds them + already processed and does nothing. +3. **Even while downed.** Intake runs *before* the "must be awake" gate. The stash a captive walked + in with is hidden the moment they are in custody, not when they wake — everything *after* intake + (whittling, digging) needs them awake, but hiding what is already on you does not. +4. **Only what can ride on a body.** `IsIntakeConcealable` accepts a thing only if its contraband + props include the `OnBody` flag. A shiv up a sleeve is intaken; a rifle or a suit of armour is + too bulky to palm and stays as ordinary strippable gear the player confiscates for free. +5. **Per-item disposition.** For each concealable thing the captive carries, the mod rolls whether + *this* pawn would hide *this* item: + + ``` + Propensity.Would(pawn, 0.6, SaltIntake ^ item.thingIDNumber) + ``` + + Base chance 0.6, then scaled by the pawn's nature × nurture (Core's `Propensity`). The roll is + **seeded per (pawn, item)** — a fixed fact, not a fresh coin each tick. The frightened cook + surrenders the knife; the disposed lifer palms it, and always would have. + +When a pawn does hide an item, the real `Thing` is **destroyed** (equipment via +`DestroyEquipment`, inventory via `Destroy(Vanish)`) and a concealed secret is created in its place, +preserving what the item was made of (`stuff`) and — critically — its `contentTag`. + +## The content tag: preserving contents without understanding them + +A concealed vessel is not just "a jar" — it is a jar *of* something (hooch, piss, a fermented +blend), and that something has to survive being hidden and come back on redemption. Contraband +stores a single opaque string, `contentTag`, and **has no idea what it means**: + +```csharp +// A bridge mod (Foul Play) fills these in. Null (no bridge) = items have no contents. +ContrabandUtility.ContentTagOf; // Func — read on conceal +ContrabandUtility.ApplyContent; // Action — applied on redeem +``` + +On concealment, `ContentTagOf(thing)` captures the tag (e.g. "a jar of a piss+hooch cocktail"). On +redemption, `ApplyContent(newThing, tag)` pours that content back into the freshly-made thing. This +is the exact seam **Foul Play** uses to keep a smuggled carboy's contents intact. Without a bridge, +both delegates are null, no content is captured, and nothing is lost — a shiv is just a shiv. + +Fermentation itself lives in Foul Play, not here. A concealed vessel materialises **fresh** and +ferments afterward like any other, which suits the theme: fresh-brewed piss is weak, and time is +what makes it a weapon. (`concealedTick` records how long it was hidden, for any bridge that wants to +restore pre-aging — Contraband keeps the number but never acts on it.) See +[Compatibility](Compatibility.md). + +## Redemption: the secret becomes a Thing + +`TryRedeem(pawn, def)` is where a secret turns real: + +1. Choose the material — the harvested `stuff`, or the def's default if it was made from stuff. +2. `ThingMaker.MakeThing(def, stuff)`. +3. If there was a `contentTag`, call `ApplyContent` to restore the contents. +4. `TryAdd` it to the pawn's inventory. If that fails, the thing is vanished and nothing is lost. +5. Remove the secret from the list. + +The trigger for redemption is the breakout duty tree. `ThinkTreeInjection` runs once at startup and +mutates two duty defs directly (an XML patch on a `DutyDef` think tree silently no-ops in 1.6, so it +is done in C#): + +| Duty | Injected | Where | +|---|---|---| +| `PrisonerEscape` | `JobGiver_ArmSelf`, then `JobGiver_UseContraband` | **after** `JobGiver_TakeCombatEnhancingDrug` | +| `PrisonerAssaultColony` | `JobGiver_ArmSelf`, then `JobGiver_UseContraband` | **before** `JobGiver_AIFightEnemies` | + +`JobGiver_UseContraband` sits right beside `JobGiver_TakeCombatEnhancingDrug` — the one contraband +hook Ludeon wrote and then left reading an always-empty inventory. For each thing a pawn is hiding: + +- **Inert contraband** (a drug, a stashed rifle — no `useWorker`): just materialise it and get out + of the way. Vanilla takes the drug via `JobGiver_SatisfyChemicalNeed`; a weapon in inventory is a + weapon. But a stashed **weapon** only comes out for someone who would actually use it + (`Disposition.WouldArmSelf`) — the same person who would not stop to pick a rifle off the floor + does not become a fighter because the knife was in their sock, and a pawn incapable of violence + never does at all. If they qualify, the weapon is moved from inventory into their equipment slot; + otherwise it stays hidden and they run. See [Improvised Weapons](Improvised-Weapons.md). +- **Active contraband** (a `useWorker` — e.g. Foul Play's throw-the-carboy): materialise it, then + hand off to the worker's `TryGiveJob`, which produces the actual use job. + +## Confiscation and the door every route comes through + +`Confiscate(pawn, def)` simply removes the secret. It **never becomes a Thing** — the warden found +it, and there is nothing to drop on the floor. (This is why searching is not a way for the colony to +*acquire* a prisoner's drugs; it is a way to *deny* them.) + +At the other end, `Conceal(prisoner, def, ...)` is the public door **every** supply route comes +through — intake calls it, a bent warden calls it ([Corruption](Corruption.md)), and a smuggling +gangmate ([Institution: Gangs](Compatibility.md)) calls it. It hands a prisoner something already +finished and hidden, optionally with a `contentTag`. One door, many keys. + +## Persistence + +`MapComponent_Contraband.ExposeData` deep-saves the secret list, the per-pawn search cooldowns, and +the `intakeProcessed` set. On load it prunes any row whose pawn or def failed to resolve — prisoners +who died or left take their secrets with them, and a null key would throw on the next tick. + +## Quick reference + +| Constant | Value | Meaning | +|---|---|---| +| `CheckIntervalTicks` | 250 | poll cadence for intake / tunnels / improvisation | +| Intake base chance | 0.60 | per-item, before nature × nurture | +| Intake gate | `IsHeld` + `OnBody` | only held pawns, only body-hideable items | +| Hard-drug concealability | 0.75 | inferred | +| Other-drug concealability | 0.50 | inferred | +| Light-weapon concealability | 0.70 | `Mass ≤ 2.0`, hides on body | +| Heavy-weapon concealability | 0.35 | `2.0 < Mass ≤ 10.0`, `InCell` only | +| Unhideable mass | > 10.0 | not contraband | + +--- + +*Part of the **Institution** suite — Core · Contraband · Justice · Gangs, alongside Foul Play and +Ward. Each stands alone; together they interlock.* +*Developed with substantial assistance from Claude (Anthropic).* diff --git a/Wiki/contraband/Corruption.md b/Wiki/contraband/Corruption.md new file mode 100644 index 0000000..32d66ed --- /dev/null +++ b/Wiki/contraband/Corruption.md @@ -0,0 +1,141 @@ +# Corruption + +*A search is only as thorough as the warden running it, and a warden is a person with their own +diligence and their own price.* + +The counter to contraband is the [warden search](Warden-Search.md) — but a search is not a machine. +It is a human being with traits and a mood, and Contraband models that human being on **two axes, +both read off the same vanilla traits, mood, and relationships — no new stat to maintain**: + +| Axis | Question | Effect on a search | +|---|---|---| +| **Diligence** | How hard do they look? | multiplies the find chance | +| **Corruption** | How bent are they? | a flat chance to look away — and a route *into* the prison | + +This is *"what turns 'the game vs the player' into 'the player's own guards, each with an angle.'"* +The prisoner you can control. The guard you assigned to watch them, you cannot. + +## Diligence — how hard they look + +`WardenDisposition.Diligence(warden)` returns 0..1 and multiplies the whole find chance in +`JobDriver_SearchPawn`. A lax or miserable warden misses things a dutiful one would turn up. + +``` +d = 0.55 // baseline +d += 0.1 × degreeOf(Industriousness) // Lazy lowers, Industrious raises (per degree) +d += 0.15 if Abrasive // no qualms turning a cell over +d -= 0.20 if Kind // a gentle hand +d += 0.10 if Psychopath // does not care how it feels +d ×= Lerp(0.6, 1.1, mood) // a miserable warden phones it in +d = clamp01(d) +``` + +| Trait / factor | Δ Diligence | Reasoning | +|---|---|---| +| Industrious | +0.1 per degree | a hard worker searches hard | +| Lazy | −0.1 per degree | a lazy one frisks gently | +| Abrasive | +0.15 | happy to toss a cell | +| Psychopath | +0.10 | thoroughness without squeamishness | +| Kind | −0.20 | reluctant to be rough | +| Mood | ×0.6 (miserable) .. ×1.1 (content) | morale is thoroughness | + +## Corruption — their price + +`WardenDisposition.Corruption(warden)` returns 0..1. In a search, a bent warden gets a flat chance +to **look away** on each item they would otherwise have a shot at finding: + +``` +lookAwayChance = Corruption(warden) × 0.6 // rolled per hidden item, before the find roll +``` + +``` +c = 0.04 // baseline — most guards are mostly honest +c += 0.40 if Greedy // has a price +c += 0.15 if Kind // bends the rules out of sympathy +c -= 0.10 if Ascetic // wants nothing, cannot be bought +c -= 0.05 if Abrasive +c ×= Lerp(1.6, 0.7, mood) // a desperate, miserable warden is more temptable +c = clamp01(c) +``` + +| Trait / factor | Δ Corruption | Reasoning | +|---|---|---| +| Greedy | +0.40 | the classic bent guard — everyone has a number | +| Kind | +0.15 | looks the other way out of sympathy, not money | +| Ascetic | −0.10 | wants nothing, so cannot be bought | +| Abrasive | −0.05 | too surly to do anyone a favour | +| Mood | ×1.6 (miserable) .. ×0.7 (content) | a happy guard is harder to tempt | + +## The Kind warden is a double liability + +Read the two formulas together and one trait jumps out. **Kind** *lowers* Diligence (−0.20) **and** +*raises* Corruption (+0.15). The gentle guard both frisks softly and bends the rules out of pity — +the worst warden you can put on a contraband detail, and the least obvious one, because "kind" reads +as a virtue everywhere else in the game. Meanwhile **mood** cuts the same way on both axes: a +miserable warden looks *less* hard and is *more* temptable. A depressed guard is a sieve at both +ends. + +## Worked examples + +Find chance for a hidden item is `baseFind × Diligence`, with a separate `lookAway = Corruption × +0.6` chance to ignore it entirely. Assume a mid-skill warden whose raw find chance on a shiv is +around 0.30 before disposition: + +| Warden | Diligence | Corruption | Look-away/item | Net on a 0.30 shiv | +|---|---|---|---|---| +| Neutral, content | ~0.61 | ~0.03 | ~2% | thorough-ish: ~0.18 find | +| Abrasive + Psychopath, content | ~0.88 | ~0.02 | ~1% | a nightmare for prisoners: ~0.26 find | +| Greedy, content | ~0.61 | ~0.31 | ~18% | looks capable, pockets bribes | +| Greedy, **miserable** | ~0.37 | ~0.62 | ~37% | ignores contraband over a third of the time | +| Kind + **miserable** | ~0.25 | ~0.27 | ~16% | a sieve at both ends: ~0.07 find | + +(Numbers rounded; mood taken as content ≈ 100% or miserable ≈ 20%.) The takeaway is that **who you +assign to search matters as much as whether you search at all.** A greedy, miserable warden is not a +guard — he is a supply line you are paying to pretend otherwise. + +## The bent warden as a supply route *in* + +Corruption is not only "looks away." The `Corruption.Smuggle` primitive is the reaching-*in* half — a +guard who slips a prisoner contraband through the very same `Conceal` door a smuggler or a gangmate +uses: + +```csharp +ThingDef Corruption.Smuggle(warden, prisoner, tracker) +// picks a random OnBody-hideable contraband and Conceal()s it onto the prisoner; returns the def +``` + +Only what can ride on a body gets slipped across a handshake (`OnBody`). The method is the **act +itself**; the caller decides *who* and *when*, gated on `WardenDisposition.Corruption`. In Contraband +on its own, this primitive is exposed but not yet wired to an in-game trigger — it is the seam +**Institution: Gangs** drives when a gang's outside members pay a bent warden to make a delivery, and +the thing a planned secure-zone checkpoint that frisks *guards* is meant to catch. Search-side +corruption (looking away) is fully live today; reach-in corruption is the hook the rest of the suite +plugs into. See [Compatibility](Compatibility.md). + +## How to play with it + +- **Vet your wardens.** A Greedy or Kind pawn on prison duty is a liability the trait tooltip won't + warn you about. Ascetic and Industrious make the best searchers; Psychopath and Abrasive are + brutally effective if you can stomach them. +- **Watch mood.** A prison-duty warden in a slump is failing at both jobs — searching *and* not + smuggling. Keep your guards content or rotate them out. +- **Redundancy beats brilliance.** Because each search is one shot per prisoner per day and even a + good warden misses a well-hidden shiv, a *second* diligent warden over time matters more than a + single perfect search. + +## Quick reference + +| Constant | Value | Meaning | +|---|---|---| +| Diligence baseline | 0.55 | before traits and mood | +| Corruption baseline | 0.04 | most guards are mostly honest | +| Diligence mood scale | ×0.6 .. ×1.1 | miserable → content | +| Corruption mood scale | ×1.6 .. ×0.7 | miserable → content | +| Look-away multiplier | ×0.6 | applied to Corruption, per item | +| Smuggle payload | `OnBody` contraband only | what fits in a handshake | + +--- + +*Part of the **Institution** suite — Core · Contraband · Justice · Gangs, alongside Foul Play and +Ward. Each stands alone; together they interlock.* +*Developed with substantial assistance from Claude (Anthropic).* diff --git a/Wiki/contraband/Home.md b/Wiki/contraband/Home.md new file mode 100644 index 0000000..8ac5660 --- /dev/null +++ b/Wiki/contraband/Home.md @@ -0,0 +1,130 @@ +# Institution: Contraband + +*Prisoners get hold of things they should not have. Wardens turn the cell over looking for them.* + +This is the player-facing wiki for **Institution: Contraband**, a RimWorld 1.6 mod. It documents +every mechanic with the real numbers pulled from the source, explains why each one works the way it +does, and tells you how to play with them — and against them. + +> **Note on scope.** Contraband was recently *split*. It is now one thing: the **physical smuggling +> loop** of a prison. The engine it used to carry — propensity, the criminal record, the +> secured-context predicate — now lives in **Institution: Core**, which this mod requires. The +> response systems (classification, deterrence, discipline, parole, regime) went to **Institution: +> Justice**, and the gang network to **Institution: Gangs**. If this page mentions "the record" or +> "a pawn's propensity," those are Core's; Contraband reads them. + +## The one idea + +The whole Institution suite rests on a single sentence: + +> **Every pawn has a criminal propensity on a spectrum — nature (traits) × nurture (circumstance, +> mood, treatment) — and the colony can police it.** + +Contraband is the half of that sentence you can *hold in your hand*. A propensity is abstract until +a disposed prisoner files a blade off his bunk, palms a twist of yayo on the way into a cell, or +starts digging. Contraband is where the spectrum becomes an object with edges. + +## Vanilla built the demand and forgot the supply chain + +RimWorld already ships a fully-built junkie. Raiders spawn addicted (`chemicalAddictionChance`, +`forcedAddictions`) and carrying combat drugs (`combatEnhancingDrugsChance`). You down one, capture +strips everything (`DropAndForbidEverything`), and now withdrawal grips them in the cell +(`Need_Chemical`, `Hediff_Addiction`). A prisoner already *seeks* a drug on their own — +`JobGiver_SatisfyChemicalNeed` is in the prisoner think tree — and already *ignores* the forbidden +flag inside their own cell (`ForbidUtility.CaresAboutForbidden`). + +Put a drug within reach of an addicted prisoner and the base game makes them take it. Today. With no +new code. The consumer, the craving, the reach, the ignore-forbidden — all of it ships. + +What does **not** ship is any answer to two questions: + +| Vanilla has | Vanilla lacks | +|---|---| +| A prisoner who wants contraband | Any route for contraband to reach the cell | +| A warden with fifteen jobs | A sixteenth called *search* | + +Search `Assembly-CSharp.dll` for `Contraband`, `Search`, `Confiscate`, `Smuggle`, `Frisk`, +`Shakedown` and you get **zero types**. The warden can chat, convert, feed, execute, enslave, +release, suppress — and cannot look in a pocket. Contraband builds exactly the missing two halves: +**supply** and **search**. It does not need to build the junkie, because Ludeon already did. + +## Concealment is a secret, not an item + +This is the load-bearing design decision, inherited from Foul Play (the Piss Nuke) and kept: + +> **While it is concealed, there is no `Thing`.** + +A hidden shiv is not in the gear tab. There is no icon. There is no stack on a shelf. The player is +told **nothing**. The item exists only as a row in `MapComponent_Contraband`'s private list, and it +becomes a real `Thing` at exactly two moments: + +- when the prisoner **uses** it (the secret is "redeemed" into their inventory), and +- when a warden **finds** it in a search (it is confiscated and never becomes a thing at all). + +Why go to the trouble? Because *an item you can see in a UI is not contraband* — it's inventory, and +there would be nothing to discover and no reason to ever order a search. The whole tension of the +system is that **you never know**. A warden you send to toss a cell might find a blade, might foil a +tunnel, or might waste twenty minutes on a prisoner who was hiding nothing — and that empty search +still has to cost, or the mere *offer* of the job would be the discovery. + +Read the full lifecycle in **Concealment and Intake**. + +## The four supply routes and the one counter + +| Route | What it is | Page | +|---|---|---| +| **Intake** | A captive hides the small contraband they walked in with | [Concealment and Intake](Concealment-and-Intake.md) | +| **Improvise** | Whittle a shiv from the cell's own furniture | [Improvised Weapons](Improvised-Weapons.md) | +| **Tunnel** | Dig out under the perimeter with a filed pick | [Tunnels](Tunnels.md) | +| **A bent warden** | A greedy or over-kind guard smuggles *for* a prisoner | [Corruption](Corruption.md) | +| **The counter** | A prioritized cell toss that reads the tells | [Warden Search](Warden-Search.md) | + +And because every guard is a person with their own diligence and their own price, the search is only +as good as who you send — see **Corruption**. + +## How everything is contraband without a list + +Contraband names no items. It recognises three sources at load and logs the count: + +1. **Anything carrying `CompProperties_Contraband`** — the explicit opt-in, with pluggable behaviour. +2. **Every drug** (`ThingDef.IsDrug`) — vanilla's, Vanilla Expanded's, mods that don't exist yet. + Hard drugs conceal better (0.75 vs 0.5). +3. **Every weapon** (`ThingDef.IsWeapon`) — concealability and hiding place derived from **mass**. A + knife rides on the body; a heavy gun goes under the floor; a minigun (over 10 kg) hides nowhere. + +It is a lookup, not a patch. Nothing mutates another mod's defs. Details in +[Concealment and Intake](Concealment-and-Intake.md) and [Compatibility](Compatibility.md). + +## Index + +- **[Concealment and Intake](Concealment-and-Intake.md)** — the secret model, `ConcealSite`, the + three sources, intake on capture, redemption, and the opaque content-tag bridge. +- **[Improvised Weapons](Improvised-Weapons.md)** — the shiv whittled from furniture, material + carry-through, the damage tell, and who reaches for it in a breakout. +- **[Tunnels](Tunnels.md)** — the Prison-Architect dig under the perimeter, which pawns dig, and how + a search uncovers the shaft. +- **[Warden Search](Warden-Search.md)** — the prioritized search, how tells and the record drive + priority, what a search does, and how it writes back. +- **[Corruption](Corruption.md)** — Diligence and Corruption from traits and mood, how they scale a + find, and the bent warden who looks away or smuggles in. +- **[Compatibility](Compatibility.md)** — the Core dependency, the Foul Play bridge, Justice/Gangs + interplay, and the prison mods the suite is tested against. + +## The suite + +Each mod stands alone as an install; together they form one system. + +| Mod | Owns | Depends on | +|---|---|---| +| **Institution: Core** | propensity, criminal record, secured context | base game | +| **Institution: Contraband** (this) | concealment, intake, shivs, tunnels, search, corruption | Core | +| **Institution: Justice** | classification, deterrence, discipline, parole, regime | Core | +| **Institution: Gangs** | gangs as contraband economies | Core, Contraband, Justice | +| **Foul Play** | vessel + substance + throw framework; the Piss Nuke; bridges its carboy in as contraband | standalone | +| **Ward** | a ward prison mode + the suite's test harness | — | + +--- + +*Part of the **Institution** suite — Core · Contraband · Justice · Gangs, alongside Foul Play and +Ward. Each stands alone; together they interlock.* +*Developed with substantial assistance from Claude (Anthropic).* diff --git a/Wiki/contraband/Improvised-Weapons.md b/Wiki/contraband/Improvised-Weapons.md new file mode 100644 index 0000000..796560b --- /dev/null +++ b/Wiki/contraband/Improvised-Weapons.md @@ -0,0 +1,203 @@ +# Improvised Weapons + +*The acquisition route you cannot deny — because you are obliged to furnish the cell, and the +furniture is the raw material.* + +You have to give a prisoner a bed. RimWorld will nag you until you do. That bed is a frame of wood, +or steel, or — if you built a nice prison — plasteel. Give a disposed prisoner a season of +unsupervised time with it and they will file a blade off it. **The prison's own furnishings are the +arsenal, and better furniture is worse.** + +## The shiv + +`CB_Shiv` is a crude stabbing weapon: *"A blade filed off a bed frame and wrapped in cloth for a +grip. Barely a weapon — but a barely-a-weapon in a cell you thought was empty is a dead guard."* + +| Stat | Value | +|---|---| +| Tech level | Neolithic | +| Mass | 0.4 kg | +| `concealability` | **0.75** (hides very well — it rides on the body) | +| `hideIn` | `OnBody \| InCell` | +| Point (Stab) | power 7, cooldown 1.5 s | +| Handle (Blunt) | power 5, cooldown 1.6 s | +| `acquireWorker` | `AcquireWorker_Improvise` | +| `useWorker` | none — an armed prisoner *is* the payload | + +It is **stuffable** (`stuffCategories`: Metallic, Woody, Stony), which is the entire point of the +harvest mechanic: a shiv's stats come from its material for free. A wood shiv is feeble; a plasteel +one is vicious. You never furnished a cell thinking of it as an armoury, but that is what it is. + +## Who whittles: the disposition gate + +`AcquireWorker_Improvise.CanAcquire` is not "any prisoner." Three things must all be true: + +1. **Held.** The pawn is a prisoner, slave, or ward patient (`IsHeld`). Free colonists do not whittle + shivs in the commons — *yet* (the concealment layer already tracks them; only the authority to act + on them is missing). +2. **Disposed.** A seeded propensity roll: + + ``` + Propensity.Would(pawn, 0.10, SaltImprovise) + ``` + + Base chance **0.10**, scaled by Core's nature × nurture. It is seeded per pawn, so it is a *fixed + fact* about them — the mistreated, high-propensity lifer chews his bed apart; the frightened cook + never does, and never would have, no matter how many times you reload. +3. **Material in reach.** There is a workable source (see below). + +The base is deliberately low. Most prisoners are not making weapons. When one is, that is a fact +about *who they are and how you keep them*, and the tell it leaves is meant to point you at exactly +that pawn. + +## The source: what counts as raw material + +`FindSource` looks for the nearest reachable, damageable, usefully-stuffed piece of furniture: + +- **Their own assigned bed first** — it is right there in the cell. +- Otherwise the **closest artificial building within 12 tiles** that qualifies. + +`IsWorkableSource` requires all of: + +| Requirement | Why | +|---|---| +| Made of `Stuff` | you cannot whittle a blade out of nothing | +| `useHitPoints` | it has to be damageable | +| Stuff category is **Metallic, Woody, or Stony** | cloth and leather cannot be filed into a blade | +| Reachable at `Touch` through `Danger.Deadly` | they have to get to it | + +This is also the counter-play. A cell furnished only in cloth and hydroponics offers nothing to file. +A steel bed offers a steel shiv. **What you build the cell out of decides whether it can become a +weapon, and how nasty that weapon is.** + +## Harvest by damage: the tell *is* the mechanic + +Each poll the pawn makes progress, `OnHarvestTick` chips at the source: + +``` +damage = max(1, round(source.MaxHitPoints × 0.012)) // Blunt, ~1.2% of max HP per worked poll +``` + +The damage is not a side effect — it is the point. A gnawed bed is **probable cause**. The same +poll that advances the shiv damages the furniture, and that visible wear is what +[Warden Search](Warden-Search.md) reads to send a guard to the *right* prisoner first: a badly +damaged bed shoots a prisoner to the top of the search priority list. Whittling a shiv is loud, in +the only sense that matters — it leaves a mark you can act on. + +Because the source takes real damage, a determined prisoner can **reduce a piece of furniture to +wreckage and move on to the next**: if the source is destroyed before the shiv is finished, +`FindSource` looks for another, and if there is none, the route **stalls** (progress is kept, not +lost). A materially poor cell is a real defence — the prisoner cannot make progress with nothing to +file. + +## How long, and the material it becomes + +Progress uses the default rate — roughly **1.5 unsupervised days** of active work — jittered ±25% +each poll so no two shivs finish on the same schedule: + +``` +progressPerPoll = 250 / (1.5 × 60000) × Rand.Range(0.75, 1.25) +``` + +When progress reaches 1, the shiv is finished, **silently** — the player is told nothing. Two things +happen: + +- **Material carry-through.** `item.stuff = source.Stuff`. A wood bed yields a wood shiv, a plasteel + bunk a plasteel one. When the shiv is later redeemed into a real `Thing`, it is made of exactly + that material, and its combat stats follow. +- **The record.** `CriminalRecord.contrabandMade` is incremented (Core's record). That number feeds + the warden's future suspicion of this pawn and, if you run **Institution: Justice**, their security + classification. + +## The shiv comes out in a break — and who reaches for it + +A hidden shiv is inert until a breakout. When a prisoner enters the `PrisonerEscape` or +`PrisonerAssaultColony` duty, `JobGiver_UseContraband` offers to materialise what they are hiding — +but a **weapon** only comes out for someone who would actually use it. That test is +`Disposition.WouldArmSelf`, and it is the same gate `JobGiver_ArmSelf` uses to decide whether an +escapee even bothers to pick a weapon off the floor. + +This matters because **vanilla escapees never arm themselves at all.** +`JobGiver_PickUpOpportunisticWeapon` exists and is simply absent from the escape duty tree, so a +base-game prisoner walks out unarmed, always. Arming is genuinely new behaviour, so it is kept rare +enough that when it happens you recognise *who* did it. + +### `WouldArmSelf` + +First a **hard gate**: a pawn who `WorkTagIsDisabled(Violent)` never arms, ever — a pacifist who +picks up a rifle is not a rare event, it is a bug. Otherwise: + +``` +chance = ArmDisposition(pawn) × ArmCircumstance(pawn) +armed = WouldSeeded(pawn, min(chance, 0.85), SaltArm) // seeded — a fixed fact per pawn +``` + +**Disposition** — who they are, fixed, nothing about today changes it: + +``` +ArmDisposition = 0.10 (base) × trait × skill +``` + +| Factor | Value | +|---|---| +| Bloodlust | ×2.5 (highest applicable trait only — not a stack) | +| Psychopath | ×2.0 | +| Brawler | ×1.8 | +| Wimp | ×0.3 | +| Kind | ×0.4 | +| skill | `0.5 + max(Shooting, Melee)/20 × 1.5` → range **0.5 .. 2.0** | + +*Being captured in a raid does not make someone a fighter* — plenty of prisoners are cooks and +conscripts. A weapon is only worth grabbing if you know what to do with it, hence the skill term. + +**Circumstance** — what is happening right now, live, not seeded: + +| Condition | Effect | Reasoning | +|---|---|---| +| Health < 50% | ×0.4 | badly hurt people run or crawl; they don't seek a fight | +| ≥ 2 other prisoners escaping within 12 tiles | ×2.0 | a crowd emboldens; one man slips out, six men riot | +| An armed colonist within 20 tiles | ×1.8 | now a weapon is not bravado, it's the plan | + +### Which weapon they reach for + +`JobGiver_ArmSelf` prefers, in order: + +1. **Their own dropped weapon** — the gun they carried when you downed them, still lying where it + fell within 45 tiles. Vanilla remembers it in `Pawn_MindState.droppedWeapon` and then never uses + the field for prisoners. *"A man walking back to the spot where his own rifle fell is a better + story than a man rummaging through your stockpile."* A prisoner does not care that you marked it + forbidden. +2. **Anything to hand** within 18 tiles — but only for a pawn over a higher disposition floor + (`WouldSeeded(pawn, 0.35, …)`). Rummaging a stockpile is a further step than grabbing your own gun + back. + +A concealed shiv slots into this the natural way: a prisoner who has been sitting on a blade and who +is the sort to use it does not leave it in his pocket. `JobGiver_UseContraband` redeems it and moves +it straight from inventory into the equipment slot. The pawn who would *not* arm keeps it hidden and +runs — and the shiv survives to be found in a later search, or used in a later break. + +## Not only shivs: the crude pick + +`AcquireWorker_Improvise` is shared. The **crude pick** (`CB_DiggingTool`) is filed off furniture +exactly the same way — same disposition gate, same damage tell — but it carries an `escapeWorker` +instead of weapon stats, and its purpose is a tunnel rather than a fight. See [Tunnels](Tunnels.md). + +## Quick reference + +| Constant | Value | Meaning | +|---|---|---| +| Improvise base chance | 0.10 | before nature × nurture; seeded per pawn | +| Damage per worked poll | ~1.2% of source max HP | Blunt; the visible tell | +| Source search radius | 12 tiles | own bed first, then nearest workable furniture | +| Time to finish a shiv | ~1.5 active days | jittered ±25% per poll | +| Workable stuff | Metallic / Woody / Stony | cloth and leather cannot be whittled | +| Shiv `concealability` | 0.75 | hard to find on the body | +| Arm chance cap | 0.85 | after disposition × circumstance | +| Own-weapon reach | 45 tiles | their dropped weapon | +| Any-weapon reach | 18 tiles | scavenge floor 0.35 disposition | + +--- + +*Part of the **Institution** suite — Core · Contraband · Justice · Gangs, alongside Foul Play and +Ward. Each stands alone; together they interlock.* +*Developed with substantial assistance from Claude (Anthropic).* diff --git a/Wiki/contraband/Tunnels.md b/Wiki/contraband/Tunnels.md new file mode 100644 index 0000000..9afeb63 --- /dev/null +++ b/Wiki/contraband/Tunnels.md @@ -0,0 +1,178 @@ +# Tunnels + +*The Prison-Architect dig: a disposed pawn goes down, not through — under the ground and everything +built on it, out past the last wall you were relying on.* + +A locked door stops a prisoner who wants to walk out. It does nothing at all about a prisoner who +digs. This is the escape that ignores your perimeter, because it does not cross it — it goes beneath +it. It is slow, it is quiet, and it works for pawns who would never join a mood-driven riot. + +## The crude pick + +The dig needs a tool. `CB_DiggingTool` is a crude pick, filed off the cell's own furniture exactly +the way a shiv is (`AcquireWorker_Improvise` — same disposition gate, same damage-to-furniture +tell; see [Improvised Weapons](Improvised-Weapons.md)). Where the shiv is a weapon, this is a *way +out*: it carries no combat stats and is never equipped. + +| Stat | Value | +|---|---| +| Description | *"Useless as a weapon and useless above ground — but pointed downward, patiently, it is a way out that no lock and no guard can see."* | +| Max HP | 60 | +| Mass | 1.2 kg | +| `concealability` | **0.5** | +| `hideIn` | `InCell` only (too bulky to ride on a body) | +| `acquireWorker` | `AcquireWorker_Improvise` | +| `escapeWorker` | `EscapeTunnelWorker` | +| Deterioration | 2.0 | + +Because it hides `InCell` only, a warden turning over the *room* can find the pick itself — and, far +more easily, the hole it is digging. + +## A second secret riding on the first + +A tunnel is *"the third thing a contraband item can be, after 'made' and 'used in a break': a thing +you dig your way OUT with, quietly, over days."* It is deliberately **not a Job**. There is no +supervised task a warden could walk in on and interrupt — only a secret that grows and a wall that +eventually gives. Progress is driven from `MapComponent_Contraband`'s poll (`TickTunnels`), the same +250-tick heartbeat that makes contraband, so *the same search that finds a shiv can find the tunnel*. + +The pick must first be **made** (or smuggled). Only once it is finished (`Ready`) does the dig begin, +as a second, longer secret carried on the same `ConcealedItem` (`escapeProgress`, `escapeWall`). + +## Who digs + +`EscapeTunnelWorker.WouldDig` gates on three things: + +1. **Humanlike and held** — prisoner, slave, or ward patient (`IsHeld`, via Core's secured context). +2. **The disposition to dig for weeks:** + + ``` + Propensity.Would(holder, 0.08, SaltTunnel) + ``` + + Base chance **0.08** — deliberately below the shiv's 0.10. *"Fewer pawns will dig for weeks than + will palm a shiv."* Seeded per pawn, so it is a fixed fact about them. +3. **A viable plan exists** — there has to be somewhere to surface (see below). + +This is the entire reason tunnels live in Contraband and not in a prisoner-only escape mod: + +> It works for prisoners **and ward patients** alike. Prisoner Realism's escapes are for "mood +> prisoners" mid-break bashing a door, and a committed ward patient is never that pawn. A patient +> with a filed-down pick and the disposition to use it can still, slowly, dig out — and nothing else +> in the ecosystem lets them. + +## Planning the dig: aim for the wilderness + +The target is **not** the cell wall. *"You do not go through the wall, you go under it."* `TryPlan` +finds the **nearest wilderness** — the first standable cell, scanning radially outward from the +holder up to **60 tiles** (`MaxSearchRadius`), whose room *touches the map edge* and is not the room +they are held in. + +Two consequences fall straight out of that: + +- **Depth is your defence, and a single wall is not.** A tunnel from a deep bunker is a long dig; a + tunnel from a shack against the map edge is a short one. Wrapping one more wall around a cell does + almost nothing — the dig already ignores intervening walls entirely. +- **A fully enclosed, map-locked base with no reachable edge has nowhere to surface.** `TryPlan` + fails, `ProgressPerCheck` returns 0, and the tunnel **stalls** — progress is kept, not lost. Seal + the map and the shaft simply waits. + +### How long + +Dig time scales with how deep the cell sits: + +``` +requiredDays = clamp(distanceToWilderness / 6, 3, 15) // CellsPerDay = 6 +progressPerPoll = 250 / (requiredDays × 60000) × Rand.Range(0.75, 1.25) +``` + +| | Days | +|---|---| +| Minimum (shallow cell near the edge) | **3** | +| Maximum (deep bunker) | **15** | +| Per day of digging | ~6 cells of depth | + +The floor of 3 days keeps even the shortest tunnel a *real threat window* you have time to catch; the +ceiling of 15 keeps the deepest from being effectively forever. + +## The tell: spoil + +A dig has to go somewhere, and the dirt is where you notice it. Each dig tick, with a 50% seeded +chance (`OnDigTick`), the worker drops one tile of `Filth_Dirt` at the holder's position: + +> A dirt floor swallows it; a paved cell shows it — exactly where a player might notice. + +It is cheap and occasional on purpose: enough that a paved cell slowly accumulates a suspicious mess, +not so much that it spams filth or litters a clean cell every tick. If you floor your cells, spoil is +a genuine visual cue that someone is digging. + +## Finding a tunnel in a search + +A tunnel is *"a second, far less hideable secret."* The pick has `concealability` 0.5, but the +**shaft** gives itself away in proportion to how far along it is. When a warden search reaches the +`InCell` site, `ExtraDiscoverChance` is added on top of the tool's normal find chance: + +``` +extraChance = clamp01(escapeProgress) × 0.4 // up to +0.40 at a nearly-finished tunnel +``` + +So a routine cell toss that finds a shiv can also catch a dig in progress — *"the same guard patrol +that finds a shiv finds the hole"* — and the deeper the shaft, the harder it is to miss. A foiled +dig is logged the same as one that ran: on discovery, `CriminalRecord.escapeAttempts` is +incremented, which raises this pawn's future search priority sharply (a known tunneller is the +prisoner a warden checks first — see [Warden Search](Warden-Search.md)). The full find math is on the +[Warden Search](Warden-Search.md) page. + +## Breakout + +When `escapeProgress` reaches 1, `OnBreakout` fires: + +1. **Re-plan the exit** so it is correct even if the base changed during the weeks of digging. +2. **Breach the outer wall or fence** the tunnel comes up beside (`LastContainmentOnLine` — the + *outermost* impassable edifice or fence between the cell and the surfacing point, walking inward + from the exit). It is destroyed with `KillFinalize` — a visible hole the colony must repair, and + proof of how they got out. An open perimeter needs no breach. +3. **Record the attempt** (`escapeAttempts++`) — it happened whether or not they get clear of the + map. +4. **Surface beyond the perimeter.** They dug all the way out, so they emerge *outside*, not in the + yard: the pawn is teleported to the exit cell, with a little spoil dirt to mark where they came + up. +5. **Hand off to vanilla** (`PrisonBreakUtility.StartPrisonBreak`). Now that they are loose and + outside, the base game's prison-break flow — and anything layered on it, e.g. **Prisoner + Realism** — takes it from there. A pawn already outside who no longer qualifies as an in-prison + escapee simply flees on their own; the breach and the emergence are the real outcome regardless. + +The player gets one message: *"{pawn} has tunnelled out under the perimeter and escaped."* The tunnel +and the pick are spent together — the `ConcealedItem` is removed. + +## How to defend against it + +| Defence | Effect | +|---|---| +| **Floor the cells** | spoil dirt becomes visible; you can *see* a dig | +| **Search known offenders** | a logged escape attempt is the heaviest single term in search priority | +| **Deny the pick's raw material** | no workable furniture, no pick — same starve as the shiv | +| **Depth** | a deeper cell is a longer dig and a wider window to catch it | +| **Seal the map** | no reachable edge means the tunnel stalls indefinitely | + +Note what does **not** help much: adding perimeter walls. The dig goes under them and only breaches +the *last* one on the way out. + +## Quick reference + +| Constant | Value | Meaning | +|---|---|---| +| Dig base chance | 0.08 | before nature × nurture; seeded per pawn | +| Wilderness search radius | 60 tiles | how far out it looks for a surfacing point | +| Cells per day | 6 | dig speed vs. cell depth | +| Min / max dig time | 3 / 15 days | clamps on `distance / 6` | +| Spoil chance per dig tick | 50% | one tile of `Filth_Dirt` | +| Extra find chance | up to +0.40 | scales with `escapeProgress` | +| Pick `concealability` | 0.5 | `InCell` only | +| On discovery / breakout | `escapeAttempts++` | logged either way | + +--- + +*Part of the **Institution** suite — Core · Contraband · Justice · Gangs, alongside Foul Play and +Ward. Each stands alone; together they interlock.* +*Developed with substantial assistance from Claude (Anthropic).* diff --git a/Wiki/contraband/Warden-Search.md b/Wiki/contraband/Warden-Search.md new file mode 100644 index 0000000..8e90257 --- /dev/null +++ b/Wiki/contraband/Warden-Search.md @@ -0,0 +1,198 @@ +# Warden Search + +*The sixteenth warden job vanilla never wrote. A guard turns the cell over, and might find nothing — +which is exactly why it has to cost.* + +The warden can chat, convert, feed, execute, enslave, release, suppress, and eleven other things. +Searching a prisoner is not one of them, and no such verb exists anywhere in the base game. +Contraband adds it as a **new** `WorkGiver`, not a patch on a vanilla one, and builds it around a +single hard rule: **the search must be able to come up empty.** + +## Authority vs. act + +The design splits one job into two classes: + +| Class | Question it answers | Notes | +|---|---|---| +| `WorkGiver_Warden_Search` | **Who** may be searched? | subclasses `WorkGiver_Warden` — a warden already has the run of the prison, so no new authority is invented | +| `JobDriver_SearchPawn` | **What** is a search? | takes any pawn, searches any pawn | + +> The WorkGiver is the authority. This driver is just the act. + +Why bother splitting them? Because the enforcement layer is meant to grow. A future **Institution: +Police** module adds a *second* authority — a cop who may stop and frisk a **free colonist** — as a +new WorkGiver that reuses the same driver with a **narrower `Reach`** (a street frisk gets what is +`OnBody`, not what is under the floorboards). That module is an *addition*, not a rewrite, precisely +because "who may search" and "what a search reaches" were never welded together. + +## The WorkGiver: who gets searched + +`Contraband_WardenSearch` is a `Warden` work-type giver: + +| Field | Value | Why | +|---|---|---| +| `workType` | Warden | it is warden work | +| `priorityInType` | 40 | **below feeding** — a starving prisoner matters more than a hidden shiv | +| `requiredCapacities` | Manipulation, Sight | you search with your hands and eyes | +| `Prioritized` | **true** (in C#) | rank candidates by suspicion, not by distance | + +`Prioritized` has to be set in code: in 1.6 it is a `WorkGiver_Scanner` property, **not** a +`WorkGiverDef` field, and an XML `` silently errors on load. That flag is what lets a +gnawed bed and a thick record send the warden to the *right* prisoner first instead of the nearest. + +`JobOnThing` will offer a search only if all of these hold: the warden should take care of this +prisoner, the target is a `Pawn`, the per-prisoner cooldown is up (`CanSearchNow`), the prisoner is +**awake, not downed, and not in a mental state**, and the warden can reserve them. + +Note what is **not** checked — and the source is emphatic about it: + +> Whether the prisoner is actually hiding anything. **The warden does not know. Nobody knows until +> the cell is turned over.** If the job were only offered when there was something to find, the mere +> appearance of the job would BE the discovery, and searching would stop being a gamble the player +> pays for in warden-hours. An empty search has to be possible, and has to cost. + +### The cooldown + +`MapComponent_Contraband` won't let the same prisoner be turned over more than **once per day**: + +``` +SearchCooldownTicks = 60000 // one in-game day +``` + +You cannot brute-force a well-hidden shiv by re-searching the same prisoner ten times in an hour. +Every search is one shot, and then that prisoner is off the list for a day. + +## Priority: the search reads the tells + +`GetPriority` is where suspicion becomes routing. Everyone is worth a routine toss; the tells push a +prisoner up the queue: + +``` +priority = 4 // baseline — everyone gets frisked + + 12 × bedDamageFraction // chewed furniture = probable cause + + min(8, timesCaught×2 + contrabandMade×0.5 + escapeAttempts×3) // the record +``` + +| Term | Weight | Meaning | +|---|---|---| +| Base | 4 | a routine toss for every prisoner | +| Gnawed bed | up to +12 | `1 − HP/maxHP` of their owned bed — a badly damaged bed shoots to the top | +| `timesCaught` | ×2 | already caught with contraband before | +| `contrabandMade` | ×0.5 | has finished contraband in the past | +| `escapeAttempts` | ×3 | **the heaviest term** — a known tunneller is checked first, every time | +| (record cap) | +8 max | the record cannot completely swamp fresh probable cause | + +The record is read with `PeekFor` (a read-only lookup that never creates a record). The base of 4 for +everyone is not an accident — **innocent prisoners still get frisked**, because the search must be +able to come up empty or its mere offer would be the discovery. + +**Worked example.** A prisoner whose bed sits at 40% HP (`missing = 0.6`), with 2 prior catches and +1 foiled tunnel: + +``` +priority = 4 + 12×0.6 + min(8, 2×2 + 0 + 1×3) + = 4 + 7.2 + min(8, 7) + = 4 + 7.2 + 7 = 18.2 +``` + +versus **4** for a clean prisoner with an intact bed. The warden walks past the quiet one and goes +straight for the one who has been busy. This closes the loop [Improvised Weapons](Improvised-Weapons.md) +opens: whittling a shiv chews the bed, the damage is probable cause, and the warden's *suspicion* — +not the player's eye — sends them to search. + +## The act: what a search does + +`JobDriver_SearchPawn` walks the searcher to the subject, runs a **900-tick** wait toil with a +progress bar, then resolves. Resolution always does two things, hit or miss: + +1. `MarkSearched(subject)` — starts the daily cooldown. +2. `timesSearched++` on the record — *"classification and the guard's future suspicion read how often + this pawn has been turned over."* + +Then it gathers everything the subject is hiding that this search can **reach** (`Reach` defaults to +`OnBody | InCell`; a subclass can narrow it). If nothing is reachable, the job ends here — the time +was still spent, and the player is told nothing. + +### Find math, per hidden item + +For each reachable secret: + +``` +conceal = item.concealability // 0 = always found, 1 = never +chance = clamp( (0.35 + 0.03 × socialSkill) × (1 − conceal) × 2 , 0.02 , 0.95 ) + +if item is a tunnel in progress: + chance = clamp( chance + tunnelExtra , 0.02 , 0.98 ) // tunnelExtra up to +0.40 + +chance ×= WardenDisposition.Diligence(searcher) // the guard's thoroughness + +if Rand.Chance( WardenDisposition.Corruption(searcher) × 0.6 ): + continue // the warden saw it and said nothing — looked away + +found = Rand.Chance(chance) +``` + +Reading it in plain terms: + +- **Skill raises the ceiling; concealability lowers it.** `BaseFindChance` is 0.35; each level of the + warden's **Social** skill adds 0.03. The `(1 − conceal) × 2` factor pivots at concealability 0.5 + (neutral, ×1.0): a drug at 0.5 is average, a shiv at 0.75 is halved (×0.5), a heavy weapon at 0.35 + is boosted (×1.3). +- **A good warden still misses a well-hidden shiv more often than not.** Worked: a level-8 Social + warden searching for a 0.75-concealability shiv gets `(0.35 + 0.24) × 0.5 = 0.295` *before* + Diligence scales it down further. Well under a coin-flip. +- **A tunnel is a different story.** The pick hides at 0.5, but a nearly-finished shaft adds up to + +0.40 and the cap rises to 0.98 — the deeper the dig, the harder it is to miss. A routine cell toss + catches a dig in progress. +- **The warden's own thoroughness and price** enter last: `Diligence` multiplies the whole chance + down for a lax, kind, or miserable guard, and `Corruption` gives a bent guard a flat chance to look + the other way even on an item they would otherwise have found. Both are on the + [Corruption](Corruption.md) page. + +### On a find + +``` +Confiscate(subject, item.def) // it never becomes a Thing — nothing drops on the floor +timesCaught++ // raises future suspicion, feeds classification +if it was a tunnel: escapeAttempts++ // a foiled dig is a logged attempt, same as one that ran +``` + +and the player gets a message — *"{warden} searched {prisoner} and found hidden {item}"*, or the +tunnel variant. If nothing is found the player is told **nothing**: they do not learn there *was* +something, only that the warden's time was spent. + +## Writing back to the record + +Every outcome updates Core's `CriminalRecord`, which is what makes the search a *loop* rather than a +one-off dice roll: + +| Field | Written when | Read by | +|---|---|---| +| `timesSearched` | every search, hit or miss | priority, suspicion, classification | +| `timesCaught` | a find | priority (×2), classification | +| `escapeAttempts` | a foiled tunnel | priority (×3, heaviest), classification | + +If you run **Institution: Justice**, those same fields drive a prisoner's security classification and +feed the deterrence signal — a prison that catches contraband becomes a prison that classifies its +troublemakers correctly and deters the next attempt. If you run Contraband alone, the fields still +route the warden. See [Compatibility](Compatibility.md). + +## Quick reference + +| Constant | Value | Meaning | +|---|---|---| +| Base find chance | 0.35 | before skill and concealability | +| Per Social level | +0.03 | skill raises the ceiling | +| Find chance clamp | 0.02 .. 0.95 | (0.98 for a tunnel) | +| Search duration | 900 ticks | one wait toil, hit or miss | +| Search cooldown | 60000 ticks (1 day) | per prisoner | +| Priority base | 4 | every prisoner is worth a routine toss | +| Gnawed-bed weight | up to +12 | `1 − HP/maxHP` | +| Record weight | up to +8 | `timesCaught×2 + contrabandMade×0.5 + escapeAttempts×3` | +| Default `Reach` | `OnBody \| InCell` | narrowed by a future street-frisk subclass | + +--- + +*Part of the **Institution** suite — Core · Contraband · Justice · Gangs, alongside Foul Play and +Ward. Each stands alone; together they interlock.* +*Developed with substantial assistance from Claude (Anthropic).* diff --git a/Wiki/core/Criminal-Record.md b/Wiki/core/Criminal-Record.md new file mode 100644 index 0000000..1b1f394 --- /dev/null +++ b/Wiki/core/Criminal-Record.md @@ -0,0 +1,165 @@ +# Criminal Record — the history of fact + +If [Propensity](Propensity.md) is *suspicion* — "this pawn seems dangerous" — the criminal record is +*fact*: "this pawn **has** escaped twice and shanked a guard." The two are deliberately different +kinds of thing. Propensity is computed fresh from who a pawn is and how they are treated; a record is +a durable log of what they have actually done, written once when it happens and read forever after. + +`CriminalRecord` is the single per-pawn history the **whole suite** writes to and reads from. +Classification grades on it, parole gates on it, the deterrence loop reacts to it, and reintegration +remembers it. Because there is exactly one record per pawn and everyone shares it, no two modules can +disagree about a pawn's past. + +Everything here is verified against `Source/Core/CriminalRecord.cs`. + +--- + +## The fields + +`CriminalRecord` is a plain `IExposable` bag of counters plus two special values. Core defines the +fields and the storage; it does **not** write most of them — the modules that own each event do. + +| Field | Type | Default | What it records | Who writes it | Who reads it | +|---|---|---:|---|---|---| +| `crimesCommitted` | `int` | `0` | count of committed crimes | Justice `RecordCrime` (also on gang fights) | classification risk score, deterrence | +| `escapeAttempts` | `int` | `0` | breakout attempts | Contraband (escape/tunnel logic) | classification risk score | +| `contrabandMade` | `int` | `0` | items brewed / whittled | Contraband (shivs, vessels) | classification risk score | +| `timesSearched` | `int` | `0` | how often searched | Contraband (warden search) | search prioritisation, audit | +| `timesCaught` | `int` | `0` | searches that found something | Contraband (warden search) | classification risk score | +| `lastCrimeTick` | `int` | `−1` | game tick of the most recent crime | Justice `RecordCrime` | recency / cooldown checks | +| `reform` | `float` | `0` | rehabilitation score (see below) | Justice `Discipline`, `Parole` | **Propensity.Nurture**, parole gate, reintegration | +| `pardoned` | `bool` | `false` | granted a clean slate after genuine reform | Justice `Parole` (on release) | parole gate, reintegration | + +`lastCrimeTick` defaults to `−1` — the sentinel for "never," distinct from tick `0`. Everything else +defaults to zero/false. + +> **Note on ownership.** Core is the *vault*, not the *clerk*. It hands out records and persists them; +> the write logic ("a crime just happened, bump the counter") lives in the module where the event +> occurs, above Core. The columns above name where that logic lives in the suite — Core itself only +> defines the fields. + +## `reform` — the pivot of the suite + +Most fields are inert tallies. `reform` is the one that feeds back into behaviour, and it is worth +its own section. + +``` +reform : float, nominally 0..1 but can go negative + raised by good treatment and good conduct + decays without either + read by Propensity.Nurture, the parole gate, and reintegration +``` + +The source is blunt about its role: *"it is what makes reform MEAN something."* A record is where a +sentence's *outcome* is stored. Three systems lean on that one float: + +- **[Propensity](Propensity.md)'s Nurture** multiplies disposition by `Clamp(1 − reform×0.5, 0.4, 2)` + — so a genuinely rehabilitated pawn (`reform > 0`) is calmer *for good*, and a prisonized one + (`reform < 0`) is inflamed *for good*. This is how institutionalization and recidivism enter the + model without a separate mechanic bolted on. +- **Parole** (Justice) gates release on it: a pawn is only releasable once `reform` clears a + threshold. It is the number that says "this one is ready." +- **Reintegration** reads it to decide when a clean slate is earned — which is what `pardoned` marks. + +Although the field is documented as `0..1`, Justice's `Discipline` can drive it negative (harsh +punishment subtracts from it). That negative range is not a bug — it is the "hardened" end of the +spectrum, and `Nurture`'s clamp is written to expect it. + +## `pardoned` and `IsBlank` + +`pardoned` flips to `true` exactly once, after genuine reform, so reintegration can grant a clean +slate without erasing the history that earned it — the counters stay, but the pawn is marked +forgiven. + +`IsBlank` is a computed convenience: + +```csharp +public bool IsBlank => crimesCommitted == 0 && escapeAttempts == 0 && contrabandMade == 0 + && timesCaught == 0 && reform == 0f && !pardoned; +``` + +A blank record is one nothing has ever been written to. Note what `IsBlank` **omits**: `timesSearched` +and `lastCrimeTick` are not in the test. A pawn who was searched and found clean — searched but never +caught, never a crime — is still "blank" and will be pruned. That is intentional: being *checked* is +not a mark against you, only being *found* is. + +--- + +## `For` vs `PeekFor` — the distinction that matters + +Records are handed out by `GameComponent_CriminalRecords`, and there are two ways to ask for one. The +difference is not cosmetic. + +```csharp +// Creates a blank record on first ask. Use on a WRITE path. +public static CriminalRecord For(Pawn p); + +// Returns null if no record exists yet. Use on a READ path that must not create. +public static CriminalRecord PeekFor(Pawn p); +``` + +- **`For(pawn)`** guarantees a record — if the pawn has none, it makes a blank one and stores it. Call + this when you are about to *write* something ("record a crime"), because you need a record to write + to. +- **`PeekFor(pawn)`** returns the existing record or `null`. Call this on a *read* path that should + not leave a trail. The archetypal caller is `Propensity.Nurture`: it wants to *read* `reform` if a + record exists, but merely asking about a pawn's disposition must not conjure a criminal file for an + innocent. If `Nurture` used `For`, every mood check would stamp a record onto every pawn on the map. + +Both are `static` and both are null-safe — a null pawn (or a game with no component yet) returns +`null` rather than throwing. + +> The rule of thumb: **write with `For`, read with `PeekFor`.** The only reason `Nurture` can be +> called on all pawns constantly without littering the save with empty records is that it peeks. + +--- + +## Persistence + +`GameComponent_CriminalRecords` is the single source of truth for the whole game. The design note is +explicit: *"modules never keep their own per-pawn crime state, they read and write here, which is what +keeps them agreeing with each other."* + +It stores records in a `Dictionary` and serialises them with RimWorld's Scribe: + +```csharp +Scribe_Collections.Look(ref records, "records", + LookMode.Reference, LookMode.Deep, ref tmpPawns, ref tmpRecords); +``` + +The keys save as **references** (a pawn is saved elsewhere; the record just points at them) and the +values save **deep** (the record's fields are written inline). Each `CriminalRecord.ExposeData` +scribes its own eight fields with their defaults, so a save omits any field still at its default. + +### Post-load cleanup + +On `PostLoadInit`, the component prunes the dictionary: + +```csharp +records.RemoveAll(kv => kv.Key == null || kv.Value == null || kv.Value.IsBlank); +``` + +Three things get dropped: + +1. **Null keys** — a pawn who has been removed or garbage-collected. *"Dead/removed pawns take their + records with them; a null key would throw later."* This is defensive: a stale key would crash a + later lookup. +2. **Null values** — corruption guard. +3. **Blank records** — anything `IsBlank` is true for. There is no reason to persist a file that + records nothing, and pruning them keeps the save from accumulating one empty record per pawn ever + examined. + +The upshot: **records are cheap and self-cleaning.** You can `For(pawn)` freely on a write path +without worrying about bloat, because anything that never got a real mark written to it evaporates on +the next load. + +### The singleton + +The component keeps a private `static instance` set both in its constructor and re-set on +`PostLoadInit`, which is what lets `For` and `PeekFor` be static entry points reachable from anywhere +without threading a reference through every caller. If no game is loaded (no component), both return +`null` safely. + +--- + +*Part of the **Institution** suite. AI disclosure: developed with substantial assistance from Claude (Anthropic).* diff --git a/Wiki/core/Home.md b/Wiki/core/Home.md new file mode 100644 index 0000000..b517ed1 --- /dev/null +++ b/Wiki/core/Home.md @@ -0,0 +1,88 @@ +# Institution: Core — Wiki + +*The shared foundation of the **Institution** suite: a set of RimWorld 1.6 mods about what an +institution does to the people inside it, and the crime, policing, and justice that put them there.* + +Core is the engine every other Institution mod runs on. **On its own it changes nothing you can +see** — no items, no jobs, no UI, no XML. It ships one small assembly and is completely inert until +another Institution mod is installed on top of it. You install Core only because something else asks +for it. + +This wiki documents what that engine actually computes, down to the exact numbers. + +--- + +## The one idea + +> **Every pawn has a criminal propensity on a spectrum — nature (traits) × nurture (circumstance, +> mood, treatment) — and the colony can police it.** + +That single sentence is the whole suite. Colonists, prisoners, and slaves drift toward crime on a +continuous spectrum; a policing and justice layer discovers, catches, punishes, reforms, or paroles +them. Core is where the *spectrum* lives as code — before anyone is caught, before there is a record +to read, before there is a warden to search them. + +## Why a shared engine + +The propensity idea did not start abstract. It was written twice, concretely, in two different mods: +the piss spree's `WouldFoulPeople` (which drunk, miserable pawn starts a mess) and the breakout's +`WouldArmSelf` (which prisoner whittles a shiv when the door opens). Both asked the same real +question — *would **this** pawn do it?* — and both answered it their own way. + +Core exists so they stop disagreeing. When the contraband system, the justice system, and the gang +system all read one disposition engine, they agree about who is dangerous instead of each computing +it from scratch and drifting apart. One pawn who is "the scary one" is the scary one to every module +at once. That coherence — not any single number — is the point of a shared substrate. + +Core carries exactly the three things every Institution mod reads and none should own alone, and +nothing else: + +| Pillar | Class | Answers | Page | +|---|---|---|---| +| **Propensity** | `Propensity` | *Would this pawn do it?* (nature × nurture, seeded) | [Propensity](Propensity.md) | +| **Criminal record** | `CriminalRecord` | *What has this pawn actually done?* (one per-pawn history) | [Criminal Record](Criminal-Record.md) | +| **Secured context** | `SecuredContexts` | *What kind of hold is this pawn under, and who may search them?* | [Secured Context](Secured-Context.md) | + +The distinction between the first two is the spine of the whole design: **propensity is suspicion, a +record is fact.** Propensity says "this pawn *seems* dangerous"; a record says "this pawn *has* +escaped twice and shanked a guard." Keeping them separate is what lets the colony act on evidence +instead of on vibes. + +## The pages of this wiki + +- **[Propensity](Propensity.md)** — nature × nurture in full: the trait-weight table, the nurture + multiplier ladder, the `Would()` formula with worked examples, and why the roll is seeded per pawn. +- **[Criminal Record](Criminal-Record.md)** — every field, who writes and reads each, the + `For` vs `PeekFor` distinction, how it persists, and why `reform` is the pivot of the suite. +- **[Secured Context](Secured-Context.md)** — the Free / Prisoner / Slave kinds, `Of()` and + `OnMap()`, `IsHeld` vs `CanConceal`, and why the suite keys off this instead of "prisoner." +- **[Treatment Engine](Treatment-Engine.md)** — the shared rehabilitation maths: mark a condition + treatable, reduce it by skill × facility quality per session, and build a recovery track toward + discharge. Ward's psychiatric care and Justice's reform share this one engine. +- **[Modder API](Modder-API.md)** — the public surface any mod can call, and the one seam + (`Propensity.DeterrenceFactor`) that lets a justice layer feed back into disposition without Core + ever depending on it. + +## The suite + +Each mod stands alone as an install; together they form one system. Core is the leaf everything else +depends on. + +| Mod | What it adds | Needs | +|---|---|---| +| **Institution: Core** (this) | the propensity / record / context engine | base game only | +| **Institution: Contraband** | concealment, improvised shivs, tunnels, warden search, warden corruption | Core | +| **Institution: Justice** | classification, deterrence, discipline, parole, regime, and **policing** (colony crime → weighed arrest) | Core, Harmony | +| **Institution: Gangs** | gangs as contraband economies: joining, smuggling, rivalry, fights-as-crime | Core, Contraband, Justice | +| **Institution: Ward** | psychiatric care — a prisoner treated, not punished, on Core's treatment engine | Core, Harmony | +| **Foul Play** | the vessel + substance + throw framework, home of the "Piss Nuke" | standalone; bridges to Core + Contraband | + +## Requirements & load order + +- **RimWorld 1.6.** Core references only the base game — no Harmony, no DLC, no other mod. +- Load Core **before** any other Institution mod (`loadAfter` Ludeon.RimWorld only). +- `packageId`: `flan.institution.core`. + +--- + +*Part of the **Institution** suite. AI disclosure: developed with substantial assistance from Claude (Anthropic).* diff --git a/Wiki/core/Modder-API.md b/Wiki/core/Modder-API.md new file mode 100644 index 0000000..afdaa7e --- /dev/null +++ b/Wiki/core/Modder-API.md @@ -0,0 +1,233 @@ +# 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: + +```csharp +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` 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` | 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](Propensity.md), [Criminal Record](Criminal-Record.md), +and [Secured Context](Secured-Context.md) 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. + +```csharp +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](Criminal-Record.md) page — **write with `For`, read +with `PeekFor`** — so read paths never litter the save with blank records. + +```csharp +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: + +```csharp +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: + +```csharp +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](Treatment-Engine.md)** 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 + +```csharp +// In Propensity: +public static System.Func 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`: + +```csharp +// 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: + +```csharp +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`, 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. AI disclosure: developed with substantial assistance from Claude (Anthropic).* diff --git a/Wiki/core/Propensity.md b/Wiki/core/Propensity.md new file mode 100644 index 0000000..93c88f6 --- /dev/null +++ b/Wiki/core/Propensity.md @@ -0,0 +1,265 @@ +# Propensity — nature × nurture + +`Propensity` is the suite's disposition engine. It answers one question and only one: *"would +**this** pawn do it?"* — never *"would a prisoner,"* never *"would a colonist."* Every behaviour in +the suite that hinges on a pawn's character routes through here, so the crime system, contraband +brewing, escape arming, and gang recruitment all read the same answer instead of each guessing. + +The model is deliberately old-fashioned: **nature × nurture.** + +- **Nature** is a fixed property of the person — their traits. Almost nobody is strongly disposed. + This does not change over a pawn's life. +- **Nurture** is what the colony has done *to* them — mood, mistreatment, unmet needs, the shadow a + sentence leaves. This is the half **you** control, and the half no other mod models. + +Good conditions pull nurture down; neglect and cruelty push it up. A saint left to rot can cross the +line; a monster kept content and deterred may never act. The engine is built so both of those stories +are possible. + +Everything on this page is verified against `Source/Core/Propensity.cs`. + +--- + +## Nature — who they are + +``` +Nature(pawn) : float in [0, 1] + n = 0.05 // baseline — everyone has a little + for each listed trait the pawn has: + n += weight + return Clamp01(n) // never below 0, never above 1 +``` + +The baseline is `0.05`. That is the floor of the human condition in this model: everyone is capable +of *something*, most people barely. From there, traits add or subtract. + +### Trait weights + +Traits are read **reflectively by defName** via `DefDatabase.GetNamedSilentFail`. If a +trait's mod is not installed, that entry is silently skipped rather than throwing a hard reference — +which is how Core consumes Vanilla Traits Expanded's dark traits without *depending* on VTE. + +| Trait | Weight | Source | Note | +|---|---:|---|---| +| Psychopath | **+0.45** | Vanilla | no empathy; the heaviest single input | +| Kleptomaniac | **+0.40** | Vanilla Traits Expanded | consumed, not rebuilt | +| Bloodlust | **+0.35** | Vanilla | enjoys violence | +| Pyromaniac | **+0.30** | Vanilla Traits Expanded | consumed, not rebuilt | +| Greedy | **+0.25** | Vanilla | wants more than their share | +| Abrasive | **+0.15** | Vanilla | friction with everyone | +| Ascetic | **−0.15** | Vanilla | wants little, takes little | +| Kind | **−0.30** | Vanilla | the strongest pull *down* | + +The design note in the source is explicit: *"We CONSUME Vanilla Traits Expanded's +kleptomaniac/pyromaniac as inputs here rather than rebuild them; vanilla's own dark traits count +too."* Core does not add traits of its own — it reads the ones the ecosystem already has. + +### Worked Nature values + +Because weights simply add and then clamp, Nature is easy to read by hand: + +| Pawn | Arithmetic | Nature | +|---|---|---:| +| Ordinary pawn (none of the above) | `0.05` | **0.05** | +| Kind pawn | `0.05 − 0.30 = −0.25` → clamp | **0.00** | +| A single Greedy trait | `0.05 + 0.25` | **0.30** | +| Psychopath | `0.05 + 0.45` | **0.50** | +| Psychopath **and** Kind | `0.05 + 0.45 − 0.30` | **0.20** | +| Greedy + Abrasive | `0.05 + 0.25 + 0.15` | **0.45** | +| Psychopath + Bloodlust + Kleptomaniac | `0.05 + 0.45 + 0.35 + 0.40 = 1.25` → clamp | **1.00** | + +Two things fall out of this. First, the clamp is not decorative: a Kind pawn floors at exactly `0` +(nature can never make them *disposed*, only never disposed), and a stacked monster ceilings at `1`. +Second, traits genuinely net against each other — a Kind Psychopath is a real, middling `0.20`, not a +contradiction the engine has to resolve by fiat. + +Most rolled pawns sit at or near the `0.05` floor. That is intended: *"Most pawns sit near the floor; +a rare few are strongly inclined."* A colony full of ordinary people is supposed to be mostly safe on +nature alone. What makes them dangerous is nurture. + +--- + +## Nurture — what you have done to them + +``` +Nurture(pawn) : float (a multiplier, >= ~0.5 in normal play) + m = 1.0 // ordinary circumstance + + mood = pawn.needs.mood.CurLevelPercentage (or 1.0 if none) + if mood < 0.20: m *= 2.5 // at the floor of despair + else if mood < 0.35: m *= 1.6 // badly kept + + if IsHeld(pawn) and mood < 0.40: m *= 1.4 // held AND unhappy compounds + + rec = criminal record (peek — does not create one) + if rec != null and rec.reform != 0: + m *= Clamp(1 - rec.reform * 0.5, 0.4, 2.0) // prisonization + + m *= DeterrenceFactor(pawn) // colony climate of order (1.0 in Core alone) + + return m +``` + +`1.0` is an ordinary pawn under ordinary conditions. The number rises as things go wrong and falls as +they go right. Each clause below is one lever. + +### The multiplier ladder + +| Clause | Factor | Applies when | Stacks? | +|---|---|---|---| +| **Despair** | ×2.5 | `mood < 0.20` | mutually exclusive with "badly kept" | +| **Badly kept** | ×1.6 | `0.20 ≤ mood < 0.35` | mutually exclusive with "despair" | +| **Held & unhappy** | ×1.4 | `IsHeld` **and** `mood < 0.40` | on top of the mood clause | +| **Prisonization** | ×`Clamp(1 − reform×0.5, 0.4, 2.0)` | `reform ≠ 0` | on top | +| **Deterrence** | ×`DeterrenceFactor(pawn)` | always (neutral `1.0` in Core alone) | on top | + +The two mood clauses are an `if / else-if`: a pawn is either in despair *or* badly kept, never both. +The "held & unhappy" clause is separate and multiplies again — so a mistreated prisoner in despair +compounds `2.5 × 1.4 = 3.5` before anything else. The source calls that exactly what it is: *"a badly +run cell."* + +### Prisonization — the reform lever + +The `reform` clause deserves its own look, because it is where a sentence leaves a permanent mark. + +``` +reform factor = Clamp(1 - reform * 0.5, 0.4, 2.0) +``` + +| `reform` | Meaning | Factor | +|---:|---|---:| +| **+1.0** | fully rehabilitated | 0.50 | +| +0.5 | improving | 0.75 | +| 0 | untouched (clause skipped) | *1.00* | +| −0.5 | hardening | 1.25 | +| **−1.0** | prisonized | 1.50 | +| ≥ +1.2 | (over-reformed) | clamp floor **0.40** | +| ≤ −2.0 | (utterly broken) | clamp ceiling **2.00** | + +`reform` is nominally documented as `0..1`, but the punishment machinery can and does drive it +**negative** — the source discusses `reform < 0` ("hardened, prisonized") in as many words. Within the +realistic range `[−1, +1]` the factor spans `0.5 … 1.5`; the `0.4 / 2.0` clamps only bite at extremes +outside that, catching a pawn who has been endlessly punished or endlessly rehabilitated. + +The important design property: Core only ever **reads** `reform` here. Nothing in Core moves it. The +Justice layer's `Discipline` and `Parole` are what nudge it up or down — which means +*institutionalization and recidivism become the suite's without a parallel mechanic.* A pawn who was +broken by a brutal prison stays broken (nurture ×1.5) after release; a pawn genuinely reformed stays +calmer (×0.5) for good. The scar is carried by one float. + +### Deterrence — the climate of order + +The final `× DeterrenceFactor(pawn)` is the seam that lets the whole colony's climate feed back into +each pawn's disposition. **In Core alone it is neutral — a flat `1.0`** — because Core does not know +what deterrence is. When Institution: Justice is loaded, it fills this in: a well-policed colony pulls +the factor below `1` and deters everyone a little; a lawless one pushes it above `1` and emboldens +them. That is what makes catching and punishing *one* pawn matter to the disposition of *the rest*. + +The full mechanics of the seam — and how to fill it from your own mod — are on the +[Modder API](Modder-API.md) page. For now: with Core installed by itself, this clause does nothing, +and that is correct. + +--- + +## Would — the full roll + +`Nature` and `Nurture` are ingredients. `Would` is the meal: the actual yes/no for a specific +behaviour at a specific base rate. + +``` +Would(pawn, baseChance, salt, cap = 0.85) : bool + chance = min(cap, baseChance * (0.1 + Nature(pawn) * 2) * Nurture(pawn)) + return Rand.ChanceSeeded( Clamp01(chance), pawn.thingIDNumber ^ salt ) +``` + +Three things are happening in that one line of arithmetic: + +1. **`baseChance`** is the caller's dial — the base rate of *this* behaviour for an average pawn + (a rare act passes a small number, a common one a larger). Core does not decide it; the module + asking the question does. +2. **`(0.1 + Nature × 2)`** is the nature amplifier. It spans **×0.1** (a saint, Nature 0) to + **×2.1** (Nature 1). At the common `0.05` floor it is `×0.2`. So a floor pawn is one-fifth as + likely as the base rate; a maxed monster over twice as likely — *before* nurture. +3. **`× Nurture`** applies circumstance on top, and `min(cap, …)` caps the whole thing at `0.85` by + default. Nobody is ever a dead certainty; there is always slack. A caller who wants a harder or + softer ceiling passes their own `cap`. + +### Worked example 1 — an ordinary colonist, content + +Base rate `0.10`, an ordinary pawn (Nature `0.05`), good mood so Nurture `1.0`: + +``` +nature term = 0.1 + 0.05 * 2 = 0.2 +chance = min(0.85, 0.10 * 0.2 * 1.0) = 0.02 → 2% +``` + +Two percent, and — critically — **seeded**. For this pawn and this question it is a fixed 2% coin +that either comes up or does not; it is not re-flipped every tick. + +### Worked example 2 — a mistreated psychopath prisoner + +A Psychopath + Bloodlust prisoner (Nature `0.85`), mood `0.15` (despair ×2.5), held and unhappy +(×1.4), `reform = −0.5` (hardening → ×1.25), Core-only so deterrence `1.0`. Base rate `0.10`: + +``` +nature term = 0.1 + 0.85 * 2 = 1.8 +nurture = 2.5 * 1.4 * 1.25 = 4.375 +chance = min(0.85, 0.10 * 1.8 * 4.375) = min(0.85, 0.7875) = 0.7875 → ~79% +``` + +Same base rate as example 1, same engine — but nature and a badly-run cell have turned a 2% pawn into +a near-certainty. The player did that, clause by clause. + +### Worked example 3 — hitting the cap + +A fully stacked monster (Nature clamps to `1.0`), in despair (×2.5) and held & unhappy (×1.4), in a +lawless colony where Justice has set deterrence to `1.4`. Base rate `0.10`: + +``` +nature term = 0.1 + 1.0 * 2 = 2.1 +nurture = 2.5 * 1.4 * 1.4 = 4.9 +raw chance = 0.10 * 2.1 * 4.9 = 1.029 +chance = min(0.85, 1.029) = 0.85 → capped at 85% +``` + +The raw product blew past `1.0`; the cap reins it to `0.85`. Even here, a `0.15` sliver of "not +today" survives. That sliver is deliberate — it keeps the worst pawn a character with a bad day +coming, not a scripted event. + +--- + +## The seeded roll — characters, not a dice cup + +The last argument of `Would` is a **salt**, and the seed is: + +``` +seed = pawn.thingIDNumber ^ salt +``` + +`Rand.ChanceSeeded` turns that seed into a *deterministic* pass/fail. This is the single most +important design decision on this page, and it is worth being precise about why. + +**Save/reload stability.** Because the seed is derived from the pawn's stable `thingIDNumber` and a +fixed salt — not from the live RNG stream — asking "would they?" gives the same answer before and +after a save/reload, as long as the inputs (traits, mood, reform) are the same. You cannot scum a +reload to re-roll a pawn into a different person. *"WHO they are does not shift under them on a +save/reload."* + +**A character, not a per-tick lottery.** A naive implementation would flip a fresh coin every tick, +so any pawn eventually does anything if you wait long enough. Seeding instead makes "would this pawn +pocket a shiv?" a *fixed fact* about that pawn under those conditions — *"what makes them a character, +not a dice cup."* + +**Live circumstance still bites.** Seeding freezes the *identity*, not the *situation*. Nurture is +recomputed live, so as mood collapses or reform hardens, the same pawn's `chance` climbs and the same +seeded coin can flip from "no" to "yes." The pawn who would not have acted last month acts now — not +because the dice changed, but because you let their world get worse. + +**Different questions, different salts.** Each behaviour passes its own salt, so the rolls are +independent. The source puts it plainly: *"a man who would pocket a shiv is not therefore a man who +would inform on his cellmate."* One pawn can be reliably one kind of trouble and reliably not another, +and that pattern is stable across the whole game. + +--- + +*Part of the **Institution** suite. AI disclosure: developed with substantial assistance from Claude (Anthropic).* diff --git a/Wiki/core/Secured-Context.md b/Wiki/core/Secured-Context.md new file mode 100644 index 0000000..b8f7bc2 --- /dev/null +++ b/Wiki/core/Secured-Context.md @@ -0,0 +1,153 @@ +# Secured Context — the kind of hold a pawn is under + +The suite has a rule that looks small and is load-bearing: **nothing keys off "is this a prisoner."** +It keys off `SecuredContext` — the *kind of hold* a pawn is under. That indirection is why a ward +patient, a slave, and (later) anyone inside a secure zone get the same concealment, search, schedule, +and needs machinery for free, without any module hard-coding a pawn status. + +`SecuredContext` reduces a pawn to one of three kinds and the question "who, if anyone, may search +them." Everything downstream reads that, so a new module works across every context the day it is +written, and a new *context* works across every existing module the day it is added. + +Verified against `Source/Core/SecuredContext.cs`. + +--- + +## The three kinds + +```csharp +public enum SecuredKind +{ + Free, // a free colonist + Prisoner, // a prisoner of the colony + Slave, // a slave (Ideology) +} +``` + +| Kind | Who it is | May search them | Can conceal? | Held against their will? | +|---|---|---|---|---| +| **Free** | a free colonist | *no one* — until a secure-area or policing layer grants standing | **yes** — they can hoard | no | +| **Prisoner** | a prisoner of the colony | the **warden** | yes | **yes** | +| **Slave** | a slave (Ideology DLC) | the **overseer** | yes | **yes** | + +Two subtleties fall out of this table: + +- **Free colonists can still conceal.** A free colonist "can hoard contraband, but no one has + standing to search them until a secure-area or policing layer grants it." Freedom is not innocence — + it is only the absence of an authority permitted to check. That is why `CanConceal` (below) includes + the free. +- **Prisoner and Slave differ only in who holds the keys.** Both are "held"; the search authority is + the warden for one and the overseer for the other. Downstream modules that only care "is someone + entitled to search this pawn" read `IsHeld` and never branch on which. + +## The struct + +`SecuredContext` is a small `readonly struct` — the kind, the pawn, and two computed predicates. + +```csharp +public readonly struct SecuredContext +{ + public readonly SecuredKind Kind; + public readonly Pawn Pawn; + + // Held against their will -- prisoner or slave. Excludes a free colonist. + public bool IsHeld => Kind == SecuredKind.Prisoner || Kind == SecuredKind.Slave; + + // Anyone with something to hide -- includes free colonists (who may hoard). + public bool CanConceal => Pawn != null; +} +``` + +The two predicates split the world along two different axes, and picking the right one is the whole +skill of using this type: + +| Predicate | True for | Ask it when you care about… | +|---|---|---| +| `IsHeld` | Prisoner, Slave | **custody** — who is under the colony's control, who can be disciplined, whose cell can be searched | +| `CanConceal` | Free, Prisoner, Slave (any real pawn) | **contraband** — who could be *hiding* something, regardless of status | + +For example, [Propensity](Propensity.md)'s Nurture uses `IsHeld` in its "held & unhappy compounds" +clause — it only wants to pile the ×1.4 penalty on a pawn the colony actually holds and mistreats, not +on a grumpy free colonist. A warden-search feature, by contrast, would gate on `CanConceal` to decide +who is even worth checking, and then on standing to decide whether it is *allowed* to. + +--- + +## `Of` — one pawn to a context + +```csharp +public static SecuredContext? Of(Pawn p) +``` + +`Of` maps a pawn to a context, or **`null`** if the pawn has none. The order of the checks matters and +is worth reading literally: + +``` +if p is not humanlike, or p is dead -> null (not one of ours) +if p.IsPrisonerOfColony -> Prisoner +if p.IsSlaveOfColony -> Slave +if p.IsFreeColonist -> Free +otherwise -> null +``` + +- **The guard comes first.** Animals, mechs, and the dead are simply not the suite's business — they + return `null`, and every `OnMap` loop and downstream check skips them without a special case. +- **Prisoner and Slave are tested before Free.** A pawn who is somehow both a colonist and imprisoned + is classified by their *hold* first. The nullable return is the clean signal for "this pawn is + outside the model," so callers pattern-match `HasValue` rather than defaulting to some "Free-ish" + status. + +Returning `SecuredContext?` (nullable) rather than a `Free` fallback is deliberate: there is a real +difference between "a free pawn we track" and "a pawn we do not model at all," and collapsing them +would let non-colony pawns leak into colony machinery. + +## `OnMap` — every context on a map + +```csharp +public static IEnumerable OnMap(Map map) +``` + +`OnMap` yields a context for every spawned pawn that has one — prisoners, slaves, and colonists, +skipping everything `Of` returns `null` for. It is a lazy `yield` iterator, so a caller can enumerate +"everyone the suite cares about on this map" in one loop: + +```csharp +foreach (SecuredContext ctx in SecuredContexts.OnMap(map)) +{ + if (ctx.IsHeld) { /* consider only the held */ } +} +``` + +A `null` map yields nothing (an empty sequence), so callers do not need to null-check before looping. + +--- + +## Why not just "prisoner"? + +This is the design decision the whole page exists to justify, so it is worth stating plainly. If every +module checked `pawn.IsPrisonerOfColony` directly, then: + +- adding **slaves** to a feature would mean editing every module; +- adding a **ward patient** mode would mean editing every module; +- adding a future **secure-zone** concept would mean editing every module. + +By routing all of them through one predicate, a new hold-kind is added *once*, in Core, and every +existing module inherits it. The concealment, search, and needs machinery never has to learn what a +slave is — it only ever asked `IsHeld` and `CanConceal`. + +### The deliberately-absent Ward kind + +There is no `SecuredKind.WardPatient`, and its absence is intentional. From the source: + +> *"a committed patient IS a prisoner in vanilla's sense (Ward rides the prisoner rail), so it is +> `SecuredKind.Prisoner` here and needs no special case. Contraband does not depend on Ward."* + +Because Institution: Ward implements a committed patient *on top of* vanilla's prisoner rail, such a +pawn already returns `Prisoner` from `Of` — no new kind, no dependency from Core (or Contraband) onto +Ward, and every module treats a ward patient exactly as it treats any prisoner. The right amount of +new code to support Ward's hold semantics was zero, and that is the payoff of keying off the context +instead of the label. + +--- + +*Part of the **Institution** suite. AI disclosure: developed with substantial assistance from Claude (Anthropic).* diff --git a/Wiki/core/Treatment-Engine.md b/Wiki/core/Treatment-Engine.md new file mode 100644 index 0000000..1416105 --- /dev/null +++ b/Wiki/core/Treatment-Engine.md @@ -0,0 +1,159 @@ +# The Treatment Engine — shared rehabilitation + +*`TreatmentProgram` + `TreatableConditionExtension`, in `Source/Core/TreatmentProgram.cs`.* + +Core carries one more thing every "fix a held pawn over time" system needs and none should own alone: +the maths of a **treatment programme**. Sustained attention in a secured facility reduces a marked +condition, the room's quality helps or hurts, and once there is nothing left to reduce a **recovery +track** builds toward a dischargeable state. + +It exists for the same reason the propensity engine does. Two systems want the identical loop pointed +at different states — **Ward** treats the mental illness that got a pawn committed; **Justice** wants +to rehabilitate the disposition that got one imprisoned — and without a shared engine each would grow +its own copy and drift. Core owns **only the maths**. Which interaction mode enrols a pawn, which +hediffs and thoughts carry the flavour, which jobs run the sessions, and which alert fires on +discharge all stay in the consuming mod. + +> The engine does something concrete when there is a real condition to work through, and is a +> **no-op** otherwise. A colony that marks nothing treatable never notices it exists. + +--- + +## The two pieces + +### `TreatableConditionExtension` — marking a condition + +A `DefModExtension` you attach to a `HediffDef` to declare it something a programme can reduce. + +```xml + + +
  • + 0.12 +
  • +
    +
    +``` + +| Field | Default | Meaning | +|---|---:|---| +| `reductionPerSession` | `0.12` | Severity removed per **completed** session, *before* the caller's skill / facility / share scale it. | + +A full course is several sessions by design — the programme has to be **sustained**, not run once. The +extension carries no flavour; it says only *how fast this condition yields*. Ward attaches it to +mental illness (via a soft, conditional patch on Rim Disorders' depression, anxiety, PTSD, OCD); +anything a mod or a player marks is treated identically, so opting a hediff in is a one-line patch. + +### `TreatmentProgram` — the maths + +A static, null-safe class. It holds no state of its own — it reads and mutates hediffs on the pawn. + +| Call | Returns | What it does | +|---|---|---| +| `TreatableConditions(pawn)` | `List` | every marked hediff on the pawn that still has severity | +| `HasTreatableCondition(pawn)` | `bool` | is there any condition left to work through? | +| `ReduceConditions(pawn, strength)` | `void` | lower every marked condition by `reductionPerSession × strength`; remove any that reach ~0 | +| `FacilityQuality(pawn)` | `float 0.5..1.5` | how much the pawn's room helps or hurts | +| `AdvanceRecovery(pawn, recoveryDef, amount)` | `float` | advance (creating if absent) a recovery hediff, clamped to `1.0`; returns the new severity | +| `RecoveryLevel(pawn, recoveryDef)` | `float 0..1` | current level of that recovery track, or `0` | + +--- + +## The numbers + +### Facility quality + +``` +room == null OR psychologically outdoors → 0.75 (a poor makeshift facility, not zero) +otherwise → Clamp(0.6 + impressiveness / 120, 0.5, 1.5) +``` + +| Room impressiveness | Factor | +|---|---:| +| 0 (bare) | `0.60` | +| ~48 (decent) | `1.00` | +| ≥108 (impressive) | `1.50` (capped) | +| outdoors / none | `0.75` | + +The clamp is deliberate: a grim, filthy, cramped facility heals worse, a calm clean one better, but a +palace can't trivialise the labour the programme costs (1.5× ceiling), and even nowhere is a poor +makeshift room, not a hard zero. + +### Reducing a condition + +``` +h.Severity -= reductionPerSession × strength // per marked hediff +if h.Severity <= 0.001 → remove it +``` + +`strength` is the **caller's** combined factor — skill × facility quality × this pawn's share of the +session. The engine does not define it; the consumer does, so a one-on-one session at full skill in a +good room reduces far more than a distracted share in a squalid one. + +### Advancing recovery + +``` +recovery = pawn's hediff of recoveryDef (created at 0.001 if absent) +recovery.Severity = min(1.0, recovery.Severity + amount) +``` + +The recovery track is a plain `0..1` severity the consumer supplies the def for (Ward's +`Ward_Recovery`). Core only advances it; the consumer decides what its top stage *unlocks* (Ward: a +"ready for discharge" alert) and — crucially — gives the hediff a **negative `severityPerDay`** so it +**decays without sustained attention**. Recovery you stop maintaining slips back; that decay is what +makes the programme a loop rather than a one-time unlock. + +--- + +## The consumer contract + +Core draws a hard line at "only the maths". A consumer owns everything with flavour: + +| Core owns (the maths) | The consumer owns (the flavour) | +|---|---| +| `TreatableConditions` / `HasTreatableCondition` | which hediffs are marked treatable | +| `ReduceConditions` | the job/interaction that runs a session | +| `FacilityQuality` | the room role that makes a "facility" | +| `AdvanceRecovery` / `RecoveryLevel` | the recovery hediff def + what its stable stage unlocks | +| — | the discharge alert, the thoughts, the decay rate | + +The idiomatic session, drawn from Ward's `JobDriver_PsychiatricCare` (the reference consumer): + +```csharp +using Contraband; + +float skill = 0.7f * social + 0.3f * medicine; // the consumer's own blend +float quality = TreatmentProgram.FacilityQuality(patient); +float strength = (0.5f + skill / 20f * 0.5f) * quality * share; // 1.0 share for the primary patient + +// 1. Reduce the actual condition first. +TreatmentProgram.ReduceConditions(patient, strength); + +// 2. Only once nothing is left to treat does recovery advance -- fix the illness, then stabilise. +if (!TreatmentProgram.HasTreatableCondition(patient)) +{ + TreatmentProgram.AdvanceRecovery(patient, MyDefOf.RecoveryHediff, 0.15f * quality * share); +} +``` + +"Reduce the condition, *then* build recovery" is the shape Ward uses, but it is a convention, not a +rule Core enforces — a rehabilitation consumer with no clinical condition to clear (Justice's reform) +can advance a recovery track from the first session and read `RecoveryLevel` to gate a parole. + +--- + +## The two consumers + +- **[Institution: Ward](https://git.onetick.ninja/flan/rimworld-ward)** — psychiatric care. Marks + mental-illness hediffs treatable, runs warden counselling sessions, builds `Ward_Recovery` toward a + "ready for discharge" alert. The reference implementation. +- **Institution: Justice** — rehabilitation. Its `reform` score is the disposition axis punishment + moves; the recovery track is the natural home for a *sustained rehabilitation programme* that gates + parole on the same shared engine, so the two systems agree on "getting better" instead of each + inventing it. + +The point, exactly as with propensity: one engine, two states, no drift. + +--- + +*Part of the **Institution** suite. AI disclosure: developed with substantial assistance from Claude (Anthropic).* diff --git a/Wiki/gangs/Affiliations-and-Segregation.md b/Wiki/gangs/Affiliations-and-Segregation.md new file mode 100644 index 0000000..fafdf8f --- /dev/null +++ b/Wiki/gangs/Affiliations-and-Segregation.md @@ -0,0 +1,115 @@ +# Affiliations and segregation + +*Gangs grow out of who pawns **already are** — and the counter-play is to keep the wrong pawns apart.* + +A gang in this mod is never invented from nowhere. It crystallises along the bonds a colony already +contains: shared faction, shared faith, or real friendship. This page covers the formation rule — what +`SharesAffiliation` checks and why the *same room* requirement matters — and then the segregation +counter-play that turns those same bonds against the network. + +## The formation rule + +On each 5000-tick check, the gang component walks the eligible `crew` (every spawned humanlike that +currently meets the `Nature × Nurture ≥ 0.9` join bar) and tries to pair each unaffiliated member with +a mate: + +``` +mate = a pawn q in crew such that: + q != p + AND q.GetRoom() == p.GetRoom() -- same room, right now + AND SharesAffiliation(p, q) -- a bond they already have +if mate exists: Enlist(p, mate) +``` + +Two conditions, both required: they must be **in the same room** at the moment of the check, **and** +they must **share an affiliation**. A disposed pawn does not fall in with a stranger across the map; it +falls in with someone it is standing next to *and* already connected to. + +`Enlist` then binds them: if either already runs with a gang, the other joins that gang; otherwise a +fresh gang id is minted. So gangs accrete — a new member pairing with an existing member is absorbed +into the existing crew rather than starting a rival one. + +### What counts as an affiliation + +``` +SharesAffiliation(a, b) is true if ANY of: + a.Faction != null AND a.Faction == b.Faction -- same faction + Ideology active AND a.Ideo == b.Ideo (both non-null) -- same ideoligion + a.relations.OpinionOf(b) >= 20 -- a genuine friendship +``` + +| Bond | Condition | Notes | +|---|---|---| +| **Same faction** | `a.Faction == b.Faction` (non-null) | your colonists share one; captured raiders share theirs | +| **Same ideoligion** | `a.Ideo == b.Ideo`, only if the Ideology DLC is active | guarded by `ModsConfig.IdeologyActive` | +| **Friendship** | `OpinionOf(b) >= 20` | a real positive relationship, not mere acquaintance | + +The design intent, from the source: + +> Gangs grow out of who pawns ALREADY are, not out of nowhere — so a prisoner's gang on the outside is +> their old faction/friends, and rival factions run as rival gangs. + +This is why the system feels coherent rather than arbitrary. A crew is a faction bloc, a congregation, +or a friend group — social lines the colony *already has*. Gangs mesh with them instead of stamping +random groupings over the top. + +## Rival factions become rival gangs + +Follow the rule to its conclusion. Two captured raiders from **different** hostile factions each share +an affiliation with *their own* side but not with each other. Housed in the same wing, each pairs up +along its own faction line — and now you have **two gangs**. By the definition on the +[Rivalry and Fights](Rivalry-and-Fights.md) page, members of two different gangs are automatically +**rivals**, and rival-gang violence between them is booked as a crime. + +So the affiliation rule and the rivalry rule are two ends of one idea: **who bands together** and **who +is opposed** both derive from pre-existing loyalties. Mix hostile factions or clashing ideoligions in +one room and you have not made one big gang — you have made two rival ones and lit the fuse between +them. This is a housing decision with mechanical teeth. + +## Segregation: the counter-play + +Here is where the formation rule and the network rule meet, and where the rest of the suite earns its +keep. A gang can only *do* anything — resupply itself — if its members can **reach** each other. +`MoveWithin` requires `SameGang` **and**, for two co-located pawns, a walkable path +(`CanReach(..., Touch, Danger.Deadly)`). Break the path and you break the network: + +``` +MoveWithin(from, to): + ... + if from.Spawned && to.Spawned && !from.CanReach(to, Touch, Deadly): + return false -- kept apart: the segregation counter-play +``` + +The source calls this out as the intended answer to the whole mod: + +> The counter-play is the rest of the suite: Classification SEGREGATES rivals into different wings, and +> a network whose members cannot REACH each other is starved. + +**Classification** lives in **Institution: Justice**. It grades pawns by risk and lets you sort them +into separate, walled wings. Two gangmates graded into different wings, with no walkable route between +them, fail the reach gate on every network pass. The gang still *exists* on the membership map — they +are still the same crew, still rivals of the other crew — but it can no longer pass a shiv from a +holder to a have-not. **A network that cannot reach itself is a network that cannot supply itself.** + +This is the elegant part of the design: the very bonds that formed the gang (`SharesAffiliation`) tell +you *who to keep apart*, and the reach requirement (`CanReach`) makes keeping them apart actually +starve the supply chain. You do not disband a gang; you **partition the graph** until its edges carry +nothing. + +## How to play it + +- **Read affiliations before you house pawns.** Same-faction and same-ideoligion prisoners will band + together; hostile factions in one wing will form *rival* gangs and fight. Sort deliberately. +- **Segregate by Classification, not by hope.** Putting rivals in "different areas" is not enough — the + reach test cares about a *walkable path*. A shared corridor is a supply line. Use genuinely separate, + walled wings. +- **Starve, don't chase.** You will rarely delete a gang outright. The durable win is to keep its + members unable to reach one another so the network dries up, while keeping mood and deterrence high so + few pawns cross the join bar in the first place (see [Joining](Joining.md)). +- **Watch the outside valve.** Segregation starves *internal* resupply; a bent warden can still inject + new contraband from outside (see [Networks and Smuggling](Networks-and-Smuggling.md)). Partition the + wing *and* clean up your wardens for a network that genuinely goes dark. + +--- +*Part of the **Institution** suite — Core · Contraband · Justice · Gangs (this). Each mod stands alone; together they are one system.* +*AI disclosure: developed with substantial assistance from Claude (Anthropic).* diff --git a/Wiki/gangs/Home.md b/Wiki/gangs/Home.md new file mode 100644 index 0000000..25f278a --- /dev/null +++ b/Wiki/gangs/Home.md @@ -0,0 +1,89 @@ +# Institution: Gangs + +*Gangs as **contraband economies** — not mood. The social capstone of the **Institution** suite of +RimWorld 1.6 mods.* + +--- + +## What Gangs is + +Gangs turns the loose, disposed pawns in your colony — colonists, prisoners, slaves — into a +**network** that moves contraband. When disposition and circumstance line up, pawns who already share +a bond band together, and a member who holds a stash quietly resupplies a member who has none. A +search that turns up nothing on one prisoner has therefore *not* cleaned out the wing: the wing is a +supply chain, and you only searched one link. + +That is the entire point, and it is a deliberately narrow one. Gangs does not do mood. It does not do +a "gang leader buff." It does the one thing nothing else in the ecosystem does — it makes contraband +**flow between pawns**, and then hands you the tools to cut the flow. + +## The gap it fills (and the one it doesn't) + +If you run **Prisoner Realism**, its *Ringleader* system already models a dominant prisoner spreading +unrest and mood contagion through a wing. That system is good, and Gangs does not touch it. Ringleader +owns *influence and mood*. + +Gangs owns the thing Ringleader has no equivalent for: **the network as a logistics graph.** Contraband +physically changes hands along social lines. A stash on one pawn is a stash available to the whole +gang. The two systems are complementary — run both. Ringleader tells you *who stirs the pot*; Gangs +tells you *how the shivs get around*. + +## Why it needs the whole suite + +Gangs is the module built **last**, because it needs every other Institution mod already in place. It +is not a standalone feature; it is the suite working together, and it is honest about that in its +dependencies: + +| It asks... | ...and the answer comes from | +|---|---| +| *Who is disposed enough to join?* | **Institution: Core** — `Nature × Nurture`, the shared propensity engine | +| *What do they move?* | **Institution: Contraband** — concealment, stashes, search, and the bent-warden supply route | +| *Where does a fight get booked, and how are rivals kept apart?* | **Institution: Justice** — the crime/deterrence loop, and Classification's segregation | + +Remove any one of those and Gangs has nothing to stand on. It is the place where Core's spectrum, +Contraband's stashes, and Justice's policing all cash out at once. + +## A healthy colony grows few gangs — or none + +This is the thesis, and it is enforced in the numbers, not just the flavour. Membership is gated at +`Nature × Nurture ≥ 0.9` — a high bar on purpose. A disposed pawn who is **well-kept and +well-policed** does not band up: their nurture multiplier stays low, and deterrence pulls it lower +still. It takes a foul streak (nature) *and* a badly-run situation (nurture) at the same time. + +So a gang problem is a **symptom**. It is the game telling you that a wing is mistreated, under-policed, +or both. The fix is never "fight the gang system" — it is to run a better prison. Feed them, give them +recreation, keep deterrence high, segregate rivals, and the networks starve on their own. + +> A gang is a symptom of a badly-run colony, not furniture. + +## The pages + +- **[Joining](Joining.md)** — `WouldJoin = Nature × Nurture ≥ 0.9`: why the bar is high, why good + treatment and deterrence keep pawns *out*, and how the 5000-tick check works for every kind of pawn. +- **[Networks and Smuggling](Networks-and-Smuggling.md)** — `MoveWithin`: how a holder resupplies a + needy gangmate, the *same-gang + can-reach* rule, why this defeats a single search, and how a bent + warden refills a starved network from outside. +- **[Rivalry and Fights](Rivalry-and-Fights.md)** — `AreRivals` and `RecordFight`: how a gang fight is + booked as a crime through Justice, why inside-the-wire and out-on-the-street are the same offence, + and how it feeds deterrence. +- **[Affiliations and Segregation](Affiliations-and-Segregation.md)** — `SharesAffiliation` plus + *same room* as the formation rule, why rival factions become rival gangs, and how Classification's + segregation is the counter-play that starves a network which cannot reach itself. + +## The suite + +Part of the **Institution** suite of RimWorld 1.6 mods: + +- **Institution: Core** — the propensity engine (`Nature × Nurture`), criminal records, secured context. +- **Institution: Contraband** — concealment, improvised shivs, tunnels, warden search and corruption. +- **Institution: Justice** — Classification, Deterrence, Discipline, Parole, Regime. +- **Institution: Gangs** — this mod: joining, networks/smuggling, rivalry/fights. + +Sibling projects: **Foul Play** (the vessel/substance framework and the "Piss Nuke") and **Ward** (the +test harness and a ward/treatment prison mode). + +Each mod stands alone as an install; together they form one system. + +--- +*Part of the **Institution** suite — Core · Contraband · Justice · Gangs (this). Each mod stands alone; together they are one system.* +*AI disclosure: developed with substantial assistance from Claude (Anthropic).* diff --git a/Wiki/gangs/Joining.md b/Wiki/gangs/Joining.md new file mode 100644 index 0000000..d1cc594 --- /dev/null +++ b/Wiki/gangs/Joining.md @@ -0,0 +1,134 @@ +# Joining a gang + +*How Gangs decides who runs with a crew — and why a well-run colony keeps almost everyone out.* + +Membership is not random, and it is not a mood event. A pawn joins a gang only when their **disposition +and their circumstances line up at once** — the same `Nature × Nurture` engine that drives every other +behaviour in the Institution suite. This page covers the exact test, why the bar is set where it is, +and how you keep pawns on the right side of it. + +## The rule + +``` +WouldJoin(pawn) == Propensity.Nature(pawn) * Propensity.Nurture(pawn) >= JoinThreshold +JoinThreshold = 0.9 +``` + +Two gates before the maths even runs: + +| Guard | Effect | +|---|---| +| `pawn == null` | never joins | +| not `RaceProps.Humanlike` | never joins — animals and mechs are out | + +Then it is one line: multiply the pawn's **Nature** by their **Nurture** and compare to **0.9**. There +is no dice roll here. `WouldJoin` is a deterministic threshold on the two propensity scores, so the +same pawn in the same situation always gives the same answer — join, or don't. (This is different from +Core's `Propensity.Would(...)`, which *is* a seeded random roll used for one-off acts like a piss +spree. Gang membership is a standing condition, so it uses the raw product.) + +### The two halves + +Both terms come from **Institution: Core** — see Core's own documentation for the full tables — but you +need the shape of them to understand the bar: + +- **Nature** (`0..1`) is *who the pawn is*: a base of `0.05`, plus trait weights, clamped to `[0, 1]`. + Psychopath `+0.45`, Bloodlust `+0.35`, Kleptomaniac `+0.40`, Greedy `+0.25`, Abrasive `+0.15`; + and it goes **down** for Kind `−0.30` or Ascetic `−0.15`. This is fixed at pawn creation and barely + moves. +- **Nurture** (a multiplier, `~≥ 0.4`, base `1.0`) is *the situation you put them in*: a floored mood + multiplies it up hard (roughly `×2.5` under 0.20 mood, `×1.6` under 0.35), being held while + miserable adds more (`×1.4`), a hardened record (negative reform) raises it, and — critically — + **deterrence pulls it down** (`×Lerp(1.3, 0.7, deterrence)`, neutral `1.0` at baseline order, so a + high-deterrence colony scales nurture toward `0.7`). This is the half you control. + +## Why the bar is high + +`0.9` is a deliberately steep threshold. From the source, in its own words: + +> A high bar, on purpose: gangs are a symptom of a badly-run colony, not furniture. A healthy Rimworld +> — content pawns, an orderly colony — grows few gangs, if any. + +Because the two terms are **multiplied**, both have to be substantial for the product to clear `0.9`. +A nasty pawn in a happy, policed colony has a low nurture and stays out. A saintly pawn in a hellhole +has a near-zero nature and stays out. You only get a gang when a genuinely disposed pawn is *also* +neglected or unpoliced — foul streak **and** bad situation, together. + +That is the design speaking through the arithmetic: a gang is never bad luck. It is feedback. + +## Worked examples + +All numbers below use Core's documented factors; the intermediate nurture figures are rounded to show +the shape of the decision. + +| Pawn | Nature | Situation → Nurture | Product | Joins? | +|---|---|---|---|---| +| **Kind colonist**, ordinary life | `0.05 − 0.30 → 0.00` (clamped) | content, `≈ 1.0` | `0.00` | **No** — nature floors it | +| **Greedy prisoner**, content, policed | `0.30` | fed & high deterrence, `≈ 0.7` | `≈ 0.21` | **No** | +| **Greedy prisoner**, starved & neglected | `0.30` | mood floored + held, `≈ 3.5` | `≈ 1.05` | **Yes** | +| ...same pawn, but you raise deterrence | `0.30` | `× ≈ 0.7` → `≈ 2.45` | `≈ 0.74` | **No** — deterrence tipped them out | +| **Psychopath + Bloodlust**, mood floored | `≈ 0.85` | mood floored + held, `≈ 3.5` | `≈ 2.9` | **Yes** — nothing short of reform pulls this back | + +The middle rows are the whole game of it. The **same greedy prisoner** joins or abstains purely on how +you run the wing. The Kind colonist and the near-maxed psychopath are the fixed poles: one essentially +cannot join, the other essentially always will if you neglect them. Everyone in between is a policy +choice. + +The mod's own integration test asserts exactly these poles: a psychopath-plus-bloodlust pawn with a +floored mood returns `WouldJoin == true`, and a Kind colonist in ordinary circumstance returns +`WouldJoin == false`. + +## It works for every pawn, everywhere + +There is no "prisoners only" special case. The comment is explicit: + +> Any pawn — colonist, prisoner, slave — can, wherever they are. + +`WouldJoin` reads nothing about a pawn's secured status. A disposed **free colonist** can run with a +crew on the outside; a **prisoner** can run with theirs on the inside; a **slave** likewise. This is +what lets a gang span the wall — an outside member and an inside member of the same crew (see +[Networks and Smuggling](Networks-and-Smuggling.md)). Being held raises nurture (misery), but it is not +a requirement. + +## The cadence: the 5000-tick check + +Membership is re-evaluated on a fixed interval by the gang map component: + +``` +CheckInterval = 5000 ticks +MapComponentTick: run only when TicksGame % 5000 == 0 +``` + +**5000 ticks** is about **2 in-game hours** (a RimWorld day is 60,000 ticks), or roughly **80 seconds** +of real time at normal (1×) speed. On each fire, the component: + +1. Gathers `crew` — every spawned humanlike on the map for whom `WouldJoin` is currently true. +2. Pairs up unaffiliated members of `crew` who **share a room and a bond** into gangs (see + [Affiliations and Segregation](Affiliations-and-Segregation.md)). +3. Runs one round of network resupply inside each gang (see + [Networks and Smuggling](Networks-and-Smuggling.md)). + +Because the check re-reads `WouldJoin` every time, membership is *live*: a pawn whose situation improves +past the point where the product drops back under `0.9` simply stops being eligible to form new bonds. +The bar is not a one-time gate at recruitment — it is a standing condition the colony is continuously +graded against. + +## How to keep pawns out + +You do not fight the gang system. You lower nurture, which lowers the product, which drops pawns below +`0.9`: + +- **Feed them and give them recreation.** Mood is the biggest nurture multiplier by far. A wing above + 0.35 mood loses the `×2.5`/`×1.6` spikes entirely. +- **Keep deterrence high** (Institution: Justice). Deterrence scales nurture down toward `0.7`; a + well-policed colony is *arithmetically* less gang-prone. This is the row in the table above where the + same greedy prisoner flips from *joins* to *abstains*. +- **Reform, don't just punish.** A hardened record (negative reform) *raises* nurture; discipline that + actually reforms (positive reform) lowers it. Harsh punishment that prisonizes a pawn makes the + gang problem worse, not better. +- **Accept the two poles.** A near-maxed psychopath will run with someone the moment you slip; a Kind + pawn essentially never will. Spend your attention on the middle. + +--- +*Part of the **Institution** suite — Core · Contraband · Justice · Gangs (this). Each mod stands alone; together they are one system.* +*AI disclosure: developed with substantial assistance from Claude (Anthropic).* diff --git a/Wiki/gangs/Networks-and-Smuggling.md b/Wiki/gangs/Networks-and-Smuggling.md new file mode 100644 index 0000000..a7fe718 --- /dev/null +++ b/Wiki/gangs/Networks-and-Smuggling.md @@ -0,0 +1,120 @@ +# Networks and smuggling + +*The one thing nothing else models: contraband that flows **between** pawns. A gang is a supply chain.* + +This is the core of the mod. Everything else — who joins, who fights, who is kept apart — exists to +serve or to break the network described here. A gang is not a mood aura; it is a **logistics graph**, +and its edges carry contraband. + +## The move + +The heart of it is one method, `MoveWithin(from, to)`: a gangmate who is holding a stash passes a piece +of it to a gangmate who has none. It returns `true` if something actually moved. + +``` +MoveWithin(from, to): + 1. both non-null, and SameGang(from, to) -- else false + 2. if both are Spawned: from must CanReach(to, -- the reach gate + PathEndMode.Touch, Danger.Deadly) -- else false + 3. a Contraband tracker must exist on the map -- else false + 4. 'from' must have at least one concealed item -- else false + 5. take stash[0]: + tracker.Confiscate(from, item.def) -- leaves the supplier's hands + tracker.Conceal(to, item.def) -- arrives, hidden, in the customer's + return true +``` + +A few things worth reading carefully: + +- **One item per move.** It takes `stash[0]` — the first concealed item on the supplier — and moves + exactly that. It is a redistribution, not a duplication: the item leaves `from` (`Confiscate`) and + arrives concealed on `to` (`Conceal`). The gang's total stash is unchanged; only *who holds it* + changes. +- **Same gang, always.** `SameGang` requires both pawns to hold the same non-zero gang id. There is no + smuggling to a stranger — the network only moves along membership. +- **The reach gate only applies to co-located pawns.** The `CanReach` check is guarded by + `from.Spawned && to.Spawned`. Two pawns both physically on the map must have a walkable path + (`Touch` range, willing to cross `Deadly` danger) between them. If a member is not spawned, the + reach check is skipped — which is what allows a gang to reach across the wall to a member who is off + the active map. + +## Why this defeats a single search + +Consider a wing of four gangmates and one shiv. You search Prisoner A and find nothing — clean. You +tick a box: *cell searched, no contraband.* But the shiv was on Prisoner C the whole time, and on the +next 5000-tick network pass it will move to whoever is out. Search C tomorrow and it may already be on +A again. + +From the source comment, plainly: + +> A gangmate holding a stash supplies one who has none, so a search that turns up nothing on one +> prisoner has not cleaned out the wing. + +A single search is a snapshot of one node in a graph that reshuffles itself. This is the whole reason +contraband-as-a-network is worth modelling: **the unit of contraband is the wing, not the pawn.** To +clean it out you have to either search faster than it moves, or — far better — break the graph. + +## The automatic resupply pass + +You do not call `MoveWithin` by hand; the gang component does it on the 5000-tick check. After forming +gangs for the pass, it runs one resupply round **per gang**: + +``` +for each gang among the eligible crew: + holder = first member who IS hiding contraband + needy = first member who is NOT hiding contraband + if holder and needy both exist: + MoveWithin(holder, needy) +``` + +So on each interval, every gang that has both a haves-member and a have-not-member performs **one** +transfer, moving a single item from a holder to someone empty-handed. Over several ticks the effect is +that a gang tends to keep its members supplied and to spread a stash out — which is exactly what makes +a scattershot search miss it. (Only members who currently meet the join bar participate in this +automatic pass; the resupply loop draws from the same `crew` used for formation.) + +## The cross-wall move + +Because `WouldJoin` and `SameGang` care nothing about a pawn's secured status, a gang can have a +**free** member and a **held** member. If the free member is holding the stash, the network resupplies +*into* the prison — the outside man passes to the inside man. The mod's integration test builds exactly +this: a free psychopath colonist and a prisoner in one gang, the stash on the free member, and +`MoveWithin(free, prisoner)` succeeds — after which the prisoner is hiding contraband and the free +colonist is not. Your prison's contraband problem is not sealed inside your prison. + +## Resupply from outside: the bent warden + +`MoveWithin` only *redistributes* what a gang already has. So what happens when you finally search the +whole wing on the same day and strip every member clean — the graph has no more edges to carry? The +gang is starved. It stays starved until contraband **re-enters** from outside. + +That external source is not part of Gangs; it is **Institution: Contraband's** corruption route. A +warden's honesty varies by personality (a Greedy warden has a price), and a bent warden can *smuggle +contraband in to a prisoner* — `Corruption.Smuggle(warden, prisoner, tracker)` plants a concealed item +directly. That single seeded item is then all the network needs: one holder, and the 5000-tick pass +spreads it back out across everyone who can reach. + +So the two halves of the supply picture are: + +| Mechanism | Owner | What it does | +|---|---|---| +| `MoveWithin` | **Gangs** | moves existing contraband *between* gangmates who can reach each other | +| `Corruption.Smuggle` | **Contraband** | injects *new* contraband from outside via a corruptible warden | + +Cut both and a gang genuinely dries up. Cut only the redistribution and a bent warden re-seeds it; cut +only the warden and any stash you missed keeps circulating. + +## How to break a network + +- **Search the whole wing at once**, not one pawn at a time. A partial sweep is a snapshot the network + routes around. +- **Segregate rivals into different, unreachable wings** (Classification, in Institution: Justice). The + reach gate is the lever — a network whose members cannot walk to each other cannot pass anything. + This is the primary counter-play; see + [Affiliations and Segregation](Affiliations-and-Segregation.md). +- **Clean up your wardens.** If searches never seem to finish the job, you may have a Greedy warden + re-seeding the wing. Corruption is the resupply valve; personality is the fix. + +--- +*Part of the **Institution** suite — Core · Contraband · Justice · Gangs (this). Each mod stands alone; together they are one system.* +*AI disclosure: developed with substantial assistance from Claude (Anthropic).* diff --git a/Wiki/gangs/Rivalry-and-Fights.md b/Wiki/gangs/Rivalry-and-Fights.md new file mode 100644 index 0000000..652d3f2 --- /dev/null +++ b/Wiki/gangs/Rivalry-and-Fights.md @@ -0,0 +1,116 @@ +# Rivalry and fights + +*Two gangs, one grudge. A gang fight is a **crime** — booked, attributed, and fed back into the colony's +climate of order.* + +Gangs do not just supply themselves; they collide. Where the network is about who shares, rivalry is +about who is opposed — and, crucially, about turning gang violence into something the rest of the +suite can *see* and *police*. + +## Who is a rival + +``` +AreRivals(a, b): + ga = GangOf(a); gb = GangOf(b) + return ga != 0 && gb != 0 && ga != gb +``` + +The definition is exactly as blunt as it reads: **two pawns in two different gangs are rivals.** Both +must actually be in a gang (a non-zero id), and the ids must differ. From the source: + +> Two pawns of DIFFERENT gangs are rivals — inside the wire or out on the street. + +Note what is *not* required. There is no separate "hostility" flag, no rival-declaration event, no +threshold to cross. The moment two crews exist, their members are rivals of one another. Rivalry is a +structural fact about the membership map, not a mood or a relationship value. + +Two consequences fall straight out of that: + +- **Non-members are nobody's rival.** A pawn with gang id `0` — everyone in a healthy colony — is never + a rival to anyone, because `AreRivals` requires both ids non-zero. No gangs, no rivalry. +- **Same gang, never rivals.** Members of one crew fail the `ga != gb` test. Within a gang there is no + rivalry to book; there is the network (see [Networks and Smuggling](Networks-and-Smuggling.md)). + +Where do two *different* gangs come from in the first place? From the affiliations pawns already have — +rival factions and rival ideoligions form into separate crews. That is the subject of +[Affiliations and Segregation](Affiliations-and-Segregation.md); the short version is that gangs mesh +with the colony's existing social fault lines, so **rival factions run as rival gangs.** + +## A fight is a crime + +The payoff of tracking rivalry is `RecordFight`. When a rival-gang attack happens, it is not treated as +generic brawling — it is booked as a crime through the same Justice pipeline as any other offence. + +``` +RecordFight(attacker, victim): + if attacker == null || victim == null: return + if !AreRivals(attacker, victim): return -- only rival-gang violence counts + Justice.RecordCrime(attacker) +``` + +So the guard is precise: the two pawns must be **rivals** (different gangs) for anything to be recorded. +A scuffle between gangmates, or between two non-members, is not a *gang* fight and is not booked here. +When it *is* rival-gang violence, the whole event routes through one call — `Justice.RecordCrime`, +against the **attacker** — and that single call does three things at once (it lives in Institution: +Justice, but this is what Gangs is leaning on): + +| Effect of `Justice.RecordCrime(attacker)` | Owner | +|---|---| +| `crimesCommitted` on the attacker's record goes up | Core's `CriminalRecord` | +| `lastCrimeTick` is stamped to now | Core's `CriminalRecord` | +| the colony's **deterrence** nudges **down** (a crime happened; order slipped) | Justice's `MapComponent_Deterrence` | + +The mod's integration test confirms the loop end to end: it builds two rival gangs, records a fight, +and asserts both that the attacker and victim *are* rivals and that the attacker's `crimesCommitted` +went up as a result. + +## Inside the wire and out on the street are the same offence + +This is the design point the source is emphatic about: + +> A gang fight is a CRIME — it goes on the attacker's record and moves the colony's climate through +> the same Justice loop as any other, so it can be investigated, attributed and policed. Rival-gang +> violence inside a prison and out on the street are the same offence. + +There is no separate code path for a prison-yard shanking versus a colonists' brawl in the dining +room. `RecordFight` reads only `AreRivals` and calls `Justice.RecordCrime`. A free colonist who is a +gang member attacking a rival is booked identically to a prisoner doing the same in a cell block. The +gang system does not care which side of the wall the violence is on — only that it was between rivals. + +This is what makes gang violence *legible* to the rest of the suite. A fight is not a one-off flavour +event that scrolls past in the log; it is a record entry with an attributed perpetrator and a timestamp, +which means it can be investigated, blamed on a specific pawn, and answered. + +## How it feeds deterrence — and back into joining + +Here is the loop that closes the whole suite: + +1. A rival-gang fight fires `RecordFight` → `Justice.RecordCrime(attacker)`. +2. That nudges the colony's **deterrence down**. Order has visibly slipped. +3. Lower deterrence *raises* the nurture multiplier for **every** disposed pawn on the map + (deterrence scales nurture via `×Lerp(1.3, 0.7, deterrence)` — less deterrence, bigger multiplier). +4. Higher nurture pushes more pawns over the `Nature × Nurture ≥ 0.9` join bar (see + [Joining](Joining.md)). +5. More members → more rivals → more fights available to be booked. + +Left unanswered, gang violence is self-reinforcing: each booked fight makes the next one likelier. The +brake is the other half of Justice — **punishment raises deterrence back up** (`RecordPunishment` +nudges it up), which lowers nurture, which drops borderline pawns back under the bar. The integration +test checks exactly this seesaw: a punishment raises the deterrence level, and a subsequent crime +lowers it again. + +## How to play it + +- **Respond to booked fights.** A gang fight is real feedback that deterrence has slipped. Punishing the + attributed attacker is not just retribution — it is the mechanical lever that pulls deterrence back + up and cools the whole map's propensity. +- **Don't manufacture two gangs where there was one.** Rivalry needs two different crews. Housing pawns + of hostile factions or clashing ideoligions together is what creates the second gang and the fights + between them — see the next page. +- **Read the record, not the moment.** Because every fight is attributed to a perpetrator, a thick + record of gang violence points you at *who* to segregate, discipline, or parole — the same tools the + rest of the suite already gives you. + +--- +*Part of the **Institution** suite — Core · Contraband · Justice · Gangs (this). Each mod stands alone; together they are one system.* +*AI disclosure: developed with substantial assistance from Claude (Anthropic).* diff --git a/Wiki/justice/Classification.md b/Wiki/justice/Classification.md new file mode 100644 index 0000000..7d2742c --- /dev/null +++ b/Wiki/justice/Classification.md @@ -0,0 +1,148 @@ +# Classification + +*The intelligence layer over the wings you already build by hand.* + +You already build a minimum wing and a max block. You already put the psychopath behind steel and the +teenage pickpocket behind a wooden door. Classification does not replace those walls, cells and locks — +it answers the question vanilla never does: **who belongs where.** It reads the one shared +`CriminalRecord` every module writes to and turns it into a single verdict, so the crime system, the +search, the escape and the classifier all agree on how dangerous a pawn is, because they read the same +number. + +Grade drives everything downstream: search frequency, privileges, escape risk, and — critically — +parole eligibility ([Parole](Parole.md) gates on grade). + +> The isolation *toll* of a max/solitary placement is Prisoner Realism's (good) sim. Justice only +> decides the placement. + +--- + +## The tiers + +`SecurityGrade` is four bands, low to high — the tiers players already build, named: + +| Grade | Risk score | Reading | +|---|---|---| +| **Minimum** | `< 0.35` | Barely a disposition, clean record. Open dorm, light watch. | +| **Medium** | `0.35 – 0.79` | A real record or a real nature. Proper cell, routine search. The parole ceiling. | +| **Maximum** | `0.80 – 1.49` | Repeat offender or proven flight risk. Steel, solitary door, frequent search. | +| **Supermax** | `≥ 1.5` | The headline cases. Escapes plus crimes plus a dark nature. Never let them near a wall. | + +Note the ceiling at **Medium**: [Parole](Parole.md) will only consider a pawn graded Medium or below. +Grade is therefore not just a placement hint — it is the gate a pawn has to fall back through before +they can be released. + +--- + +## The risk score + +Risk is a `0..~3` number: baseline disposition plus everything on the record, minus reform earned. + +``` +risk = Nature(pawn) * 0.5 // who they are, before they have done anything + + escapeAttempts * 0.35 // a proven flight risk is the headline + + crimesCommitted * 0.20 + + timesCaught * 0.15 + + contrabandMade * 0.10 + - max(0, reform) * 0.4 // genuine reform earns a downgrade +risk = max(0, risk) // never negative +``` + +| Term | Weight | Why this weight | +|---|---|---| +| `Nature` (0..1) | `× 0.5` | Disposition alone can carry a pawn to Medium (Nature 0.7+), but never past it on its own. Character is a suspicion, not a conviction. | +| `escapeAttempts` | `× 0.35` | The heaviest per-event term. A pawn who *runs* is the one who ends up armed in your base. One attempt is worth nearly two crimes. | +| `crimesCommitted` | `× 0.20` | The staple. Steady, cumulative. | +| `timesCaught` | `× 0.15` | Caught contraband is worse than merely suspected. | +| `contrabandMade` | `× 0.10` | The lightest term — making a shiv is common; escaping with one is not. | +| `reform` (if > 0) | `− 0.4` | The only term that can *lower* the grade. At most −0.4 (reform capped at 1.0). | + +Two facts fall out of the numbers, and both matter for how you play: + +- **`timesSearched` is not in the formula.** Searching a pawn does not make them more dangerous — + only *finding* something (`timesCaught`) does. Search freely; it costs the prisoner no grade. +- **Reform can shift at most 0.4 of risk.** At `reform = 1.0`, the downgrade is `−0.4`. So a pawn + whose record *without reform* already totals `≥ 1.2` can never fall to Medium (`< 0.8`) no matter + how thoroughly they reform — and therefore can **never be paroled**. A prolific offender is a + life sentence. You will hold them, work them, or execute them; you will not release them. This is a + deliberate property of the weights, not an accident. + +--- + +## Worked example — grading a record + +**Bram**, an Abrasive raider. Nature = `0.05 + 0.15 = 0.20`. He has three crimes, one escape attempt, +was caught twice, made two shivs, and has not been reformed (`reform = 0`). + +``` +risk = 0.20 * 0.5 = 0.100 (nature) + + 1 * 0.35 = 0.350 (escape — the headline) + + 3 * 0.20 = 0.600 (crimes) + + 2 * 0.15 = 0.300 (caught) + + 2 * 0.10 = 0.200 (contraband) + - 0 * 0.4 = 0.000 (no reform) + ───────── + total = 1.550 → ≥ 1.5 → SUPERMAX +``` + +Now treat him. Say patient handling and good conduct lift him to `reform = 0.40`: + +``` +risk = 1.550 - (0.40 * 0.4) = 1.550 - 0.160 = 1.390 → MAXIMUM +``` + +He drops a band. But notice: his reform-less risk is `1.55`, well above `1.2`. Even at perfect +`reform = 1.0` he lands at `1.55 − 0.40 = 1.15` — still Maximum. **Bram is beyond parole for the rest +of his life.** His record decided that, not his disposition. + +Contrast **Cass**, a Kind pickpocket. Nature = `clamp01(0.05 − 0.30) = 0`. One crime, caught once, no +escape, no contraband, `reform = 0`. + +``` +risk = 0 + 0 + (1*0.20) + (1*0.15) + 0 - 0 = 0.350 → MEDIUM (exactly on the line) +``` + +Give Cass the standard treatment to `reform = 0.30`: + +``` +risk = 0.350 - (0.30 * 0.4) = 0.350 - 0.120 = 0.230 → MINIMUM +``` + +Cass is now Minimum, and — being Medium-or-below with `reform ≥ 0.30` — **paroleable**. Same +institution, two futures, and the record told you which was which before you had to guess. + +--- + +## How to play with grades + +- **Segregate by grade, not by vibe.** Sort your cells to the four bands and put a pawn where their + score, not your hunch, says. The whole suite reads the record; you can too. +- **Escapes are the alarm.** One escape attempt (`+0.35`) will jump a middling pawn a band. If a pawn + crosses into Maximum after a break attempt, believe it — treat them as the flight risk the number + says they are. +- **A clean nature is not a clean record.** A Kind pawn who has escaped twice still grades Maximum; + disposition is only half the score. +- **Watch the parole ceiling.** If you intend to release someone, keep their reform-less risk under + `1.2` — mostly that means limiting how deep their record gets *before* you start treating them. A + pawn you let rack up escapes and catches has sentenced themselves. +- **Reform is the only lever that lowers a grade.** See [Discipline & Reform](Discipline-and-Reform.md) + for how to move it, and [Parole](Parole.md) for where the grade gate lands. + +--- + +## For modders + +```csharp +SecurityGrade grade = Classification.Grade(pawn); // the verdict +float score = Classification.Risk(pawn); // the raw 0..~3 number +``` + +Both are pure reads — `Risk` uses `GameComponent_CriminalRecords.PeekFor` and never creates a record. +`SecurityGrade` is an ordered enum (`Minimum < Medium < Maximum < Supermax`), so `grade <= Medium` +comparisons work as written. The type lives in the `Contraband` namespace (Justice was extracted from +Institution: Contraband and kept the shared namespace). + +--- + +*Part of the **Institution** suite for RimWorld 1.6: Core · Contraband · Justice · Gangs. Developed +with substantial assistance from Claude (Anthropic).* diff --git a/Wiki/justice/Deterrence.md b/Wiki/justice/Deterrence.md new file mode 100644 index 0000000..8279a67 --- /dev/null +++ b/Wiki/justice/Deterrence.md @@ -0,0 +1,168 @@ +# Deterrence — the feedback loop + +*The connection that turns the pipeline into a cycle.* + +This is the module that makes policing **govern** rather than merely clean up. Everything else in +Justice reacts to one pawn: this reactor's *state* is the whole colony, and it feeds **back** into +every pawn's disposition. Crime that goes unanswered emboldens everyone a little; visible justice +deters everyone a little. The reason to punish is not just this prisoner — it is the message it sends +the rest, and here that message is a number they all read. + +--- + +## The climate of order + +Every map carries a `MapComponent_Deterrence` holding one value: + +| | | +|---|---| +| **Range** | `0.0` (lawless) … `1.0` (iron) | +| **Baseline** | `0.5` — an ordinary colony | +| **Below baseline** | crime pays; dispositions run **hot** | +| **Above baseline** | order holds; dispositions run **cool** | + +It is nudged by two events and, left alone, forgets. + +### The nudges + +| Event | Source | Nudge | Written by | +|---|---|---|---| +| A crime, unanswered | `Justice.RecordCrime(perp)` | **−0.08** | Justice, Gangs (fights) | +| Visible justice done | `Justice.RecordPunishment(map)` | **+0.12** | Justice (`Discipline.Punish`) | + +Both clamp the result to `[0, 1]`. Note the asymmetry: **punishment (+0.12) outweighs crime (−0.08) +by 1.5×.** A colony that answers every offence does not merely break even — it ratchets *above* +baseline into "order holds" territory. This is the deterrence dividend, and it is deliberate: justice +seen to be done buys more order than the crime cost. + +`RecordCrime` also writes the record — it increments `crimesCommitted` and stamps `lastCrimeTick` on +the perpetrator — so the same event that moves the climate is the event classification and parole +later see. `RecordPunishment` moves only the climate; the *record* side of punishment (the reform +shift) is [Discipline](Discipline-and-Reform.md)'s job. + +### The drift + +``` +every 2500 ticks: level = MoveTowards(level, 0.5, 0.0015) +``` + +Memory fades. With nothing happening, the climate creeps back toward ordinary at **0.0015 per step, +one step every 2500 ticks** — which works out to: + +``` +drift per in-game day = (60000 / 2500) * 0.0015 = 24 * 0.0015 = 0.036 / day +``` + +So a single unanswered crime (`−0.08`) takes roughly `0.08 / 0.036 ≈ 2.2 days` to heal on its own — +or one punishment (`+0.12`) to over-answer instantly. An iron colony you stop maintaining slides back +to baseline over about two weeks; it does not stay stern for free. + +--- + +## The feedback — how it reaches every pawn + +This is the loop. The climate is read back into `Propensity.Nurture` (in Core) as a multiplier: + +``` +DeterrenceFactor(pawn) = Lerp(1.3, 0.7, level) // = 1.3 - 0.6 * level +Nurture(pawn) = ... * DeterrenceFactor(pawn) +``` + +| `level` | Factor | Effect on every pawn's disposition | +|---|---|---| +| 0.00 (lawless) | **1.300** | +30% hotter — crime pays, everyone feels it | +| 0.30 | 1.120 | +12% | +| **0.50 (baseline)** | **1.000** | **neutral — a default colony sits exactly here** | +| 0.70 | 0.880 | −12% | +| 0.90 | 0.760 | −24% | +| 1.00 (iron) | **0.700** | −30% cooler — order holds, everyone calms | + +Two things to notice: + +- **Baseline is the neutral point, by design.** A default colony sits at `0.5`, which is a factor of + exactly `1.0` — ordinary order neither inflames nor calms. That symmetry is deliberate. If baseline + ran hot (as an earlier tuning did), ordinary order would nudge propensity up, breed a little more + crime, drop order, and feed a slow runaway. Neutral-at-baseline means only a colony that lets order + *slide below* ordinary earns the hot multiplier, and only one that pushes *above* it earns the calm. +- **The swing is `0.7×` to `1.3×`, symmetric around `1.0`** — a `±30%` spread applied to *everyone's* + nurture at once. This is the multiplier that makes catching one pawn matter to the disposition of + the rest. + +### JusticeBootstrap — reconnecting the loop across the mod boundary + +Core keeps `Propensity.DeterrenceFactor` as a neutral seam — a `Func` that returns `1f` +until something fills it. Core alone never has to know Justice exists; that one seam is what lets Core +stay a dependency-free leaf while the deterrence loop runs *through* it. + +At startup, `JusticeBootstrap` fills the seam: + +```csharp +Propensity.DeterrenceFactor = pawn => { + var d = pawn?.Map?.GetComponent(); + return d != null ? Mathf.Lerp(1.3f, 0.7f, d.Level) : 1f; // neutral (1.0) at baseline 0.5 +}; +``` + +So **only when Justice is installed** does the climate bend disposition; without it, Core reads a flat +`1.0` and pawns are indifferent to order. This is the deterrence feedback loop, reconnected across the +mod boundary without Core ever depending on the justice layer. (If the pawn is off-map — caravan, in +transit — there is no map component, and the factor falls back to a neutral `1.0`.) + +--- + +## Worked example — a day at the institution + +The colony starts at baseline `0.5` (factor `1.05`). Over one day: + +| Step | Event | `level` | Factor | +|---|---|---|---| +| start | — | 0.50 | 1.050 | +| 1 | Prisoner A shanks a guard (crime, unanswered) | 0.42 | 1.106 | +| 2 | Prisoner B foments trouble (crime, unanswered) | 0.34 | 1.162 | +| 3 | Warden punishes A (`RecordPunishment`) | 0.46 | 1.078 | +| 4 | Warden punishes B (`RecordPunishment`) | 0.58 | 0.994 | +| overnight | nothing happens, drift ≈ −0.036 toward 0.5 | 0.544 | 1.019 | + +Two crimes cost `−0.16`; two punishments returned `+0.24`. The colony ends the day at `0.58` — +**above** where it started — because punishment out-answers crime. Every pawn's disposition dipped +hotter as the crimes landed (`1.05 → 1.16`) and then cooled below neutral once order was reasserted +(`0.994`). By morning the drift has begun erasing the gain, and if nothing keeps the pressure on, the +colony sinks back to its slightly-hot baseline over the next couple of weeks. + +That arc — hot when crime pays, cool when justice is seen, forgetful when neither happens — is the +whole reason discipline is not inert. You are not punishing a pawn; you are setting the temperature of +the room. + +--- + +## How to play with deterrence + +- **Answer crime, visibly and promptly.** Each punishment is worth 1.5 crimes of order. A colony that + reliably punishes climbs *above* baseline and cools everyone; a colony that lets offences slide + sinks below and heats everyone — a spiral, because hotter pawns commit more crime. +- **Don't coast on a past crackdown.** The climate drifts back to baseline at `0.036/day`. Order is a + maintenance cost, not a one-time purchase. +- **Push past baseline if you want calm.** Neutral disposition needs `level ≈ 0.571`; ordinary + (`0.5`) still runs 5% hot. A steady rhythm of caught-and-punished offences is what holds you there. +- **A lawless spell is self-feeding.** At `level 0.3` every pawn is `1.19×` more disposed; more crime + follows, driving the level lower still. Break the cycle with punishments, not patience. + +--- + +## For modders + +```csharp +Justice.RecordCrime(perpetrator); // -0.08 climate, +1 crime, stamps lastCrimeTick +Justice.RecordPunishment(map); // +0.12 climate +float level = map.GetComponent().Level; // raw 0..1 +``` + +`RecordCrime` is the writer any module calls when a pawn does something the colony would police — +Institution: Gangs calls it on rival fights, for instance. `RecordPunishment` is called by +`Discipline.Punish`. The component and statics live in the `Contraband` namespace (shared across the +suite since Justice's extraction from Contraband). + +--- + +*Part of the **Institution** suite for RimWorld 1.6: Core · Contraband · Justice · Gangs. Developed +with substantial assistance from Claude (Anthropic).* diff --git a/Wiki/justice/Discipline-and-Reform.md b/Wiki/justice/Discipline-and-Reform.md new file mode 100644 index 0000000..05ec249 --- /dev/null +++ b/Wiki/justice/Discipline-and-Reform.md @@ -0,0 +1,142 @@ +# Discipline & Reform + +*Punishment as an action, with a self-complete payoff — and the seat of institutionalization.* + +Punishment in Justice does two things at once, on two different axes: + +1. **It deters the colony.** Order seen to be done raises the climate of order for everyone — + `RecordPunishment`, `+0.12`. See [Deterrence](Deterrence.md). +2. **It reshapes the punished.** A harsh hand *prisonizes* — the pawn hardens, `reform` falls, their + disposition climbs for good. A corrective hand *rehabilitates* — `reform` rises, their disposition + cools for good. + +Both effects are Justice's own, so discipline is never inert. Prisoner Realism owns the passive *toll* +of isolation (a good sim of how solitary *feels*); Justice owns the *order* it produces and the +persistent *shift* it leaves on the pawn. When both mods are present, PR's solitary deterioration +deepens the felt experience on top — it does not replace this. + +--- + +## The two hands + +``` +Discipline.Punish(pawn, harsh): + Justice.RecordPunishment(pawn.Map) // +0.12 colony climate (Deterrence) + reform = Clamp(reform + (harsh ? -0.15 : +0.10), -1, +1) // the persistent shift +``` + +| Call | Reform delta | Meaning | +|---|---|---| +| `Punish(pawn, harsh: true)` | **−0.15** | Beatings, deprivation, the hard hand. Hardens — "prisonization." | +| `Punish(pawn, harsh: false)` | **+0.10** | Correction, structure, rewarded good conduct. Rehabilitates. | + +`reform` lives on the shared record, clamped to `[−1, +1]`: + +| `reform` | Reading | +|---|---| +| `+1.0` | Fully rehabilitated | +| `0.0` | Untouched — as they came in | +| `−1.0` | Fully hardened, institutionalized | + +Note the asymmetry the other way from Deterrence: **harsh (−0.15) moves faster than gentle (+0.10).** +It takes three corrective acts to build `+0.30` of reform; two harsh acts to tear down `−0.30`. Damage +is quicker than repair — as it should be. + +--- + +## How reform becomes disposition + +Reform is not a status effect or a mood buff. It is read straight back into `Propensity.Nurture` (in +Core), where it multiplies a pawn's whole disposition: + +``` +if reform != 0: + Nurture *= Clamp(1 - reform * 0.5, 0.4, 2) +``` + +| `reform` | Nurture factor | Effect | +|---|---|---| +| −1.00 (hardened) | **1.500** | +50% more disposed — permanently | +| −0.30 | 1.150 | +15% | +| −0.15 (one harsh act) | 1.075 | +7.5% | +| 0.00 | 1.000 | untouched | +| +0.10 (one gentle act) | 0.950 | −5% | +| +0.30 (parole threshold) | 0.850 | −15% | +| +1.00 (rehabilitated) | **0.500** | −50% less disposed — permanently | + +Given reform is clamped to `[−1, +1]`, the factor spans `0.5×` to `1.5×`; the `0.4`/`2` clamp is a +safety rail that reform alone never reaches. **This is the whole trick.** Institutionalization and +recidivism are not a parallel mechanic bolted on the side — they are *one number the propensity engine +already reads*. A hardened pawn is not flagged "recidivist"; they simply carry a `−reform` that makes +every "would they?" roll for the rest of their life come up hot. Discipline and Parole are what *move* +reform; Nurture only *reads* it — which is how institutionalization becomes Justice's without a second +system. + +--- + +## Worked example — two sentences + +Take a fresh prisoner, `reform = 0`, badly kept: mood `0.30`, held. From Core, before reform, their +Nurture is already `1 × 1.6 (mood) × 1.4 (held & unhappy) = 2.24`, and say the colony sits at baseline +so `DeterrenceFactor = 1.05`, giving Nurture `≈ 2.35`. + +**The hard hand.** You beat them into line — three harsh punishments: + +``` +reform: 0 → -0.15 → -0.30 → -0.45 +Nurture factor at reform -0.45 = 1 - (-0.45 * 0.5) = 1.225 +``` + +Their disposition is now `2.35 × 1.225 ≈ 2.88` — **+22% hotter than when they arrived**, forever, +independent of mood. You have made them more criminal, not less. This is prisonization: the sentence +itself became the cause. (You did buy `+0.36` of colony climate along the way — order today, at the +cost of the pawn's tomorrow.) + +**The corrective hand.** Instead you structure and reward — three gentle punishments *and* you fix +their conditions (recreation, decent cell) so mood recovers to `0.55`: + +``` +reform: 0 → +0.10 → +0.20 → +0.30 +mood 0.55 → no mood multiplier, not held-&-unhappy → those factors drop out +Nurture factor at reform +0.30 = 1 - (0.30 * 0.5) = 0.85 +``` + +Now Nurture is roughly `1 × 0.85 × 1.05 (deterrence) ≈ 0.89` — **below neutral.** The same pawn who +would have been `2.88` disposed is now `0.89`. And at `reform = 0.30` with a low enough grade they +have crossed the [Parole](Parole.md) threshold. Same institution, opposite outcomes — decided by which +hand you used. + +--- + +## How to play with discipline + +- **Harsh is a lever, not a punishment button.** It buys colony order *now* (`+0.12` climate) at the + price of the pawn's disposition *forever* (`−0.15` reform → hotter Nurture). Use it when you need the + deterrence and never intend to release the pawn — a Supermax lifer you are only ever going to hold. +- **Gentle is how anyone gets out.** Parole needs `reform ≥ 0.30`; only the corrective hand builds it. + Three gentle acts is the floor. +- **Damage compounds; repair is slow.** `−0.15` vs `+0.10` means a pawn you brutalize early is + expensive to bring back — and a hardened pawn commits more crime, which sinks the climate, which + makes *everyone* worse. Don't harden pawns you might want later. +- **Conditions and discipline stack.** Reform shifts disposition permanently; mood, neglect and + deterrence shift it live (see [Regime](Regime.md) and [Deterrence](Deterrence.md)). A reformed pawn + in a well-run wing is calm on two axes at once. + +--- + +## For modders + +```csharp +Discipline.Punish(pawn, harsh: true); // -0.15 reform, +0.12 climate +Discipline.Punish(pawn, harsh: false); // +0.10 reform, +0.12 climate +``` + +`Punish` uses `GameComponent_CriminalRecords.For` (creates the record if absent) and clamps `reform` +to `[−1, +1]`. It always fires `RecordPunishment` on the pawn's map, so *any* act of discipline moves +the colony climate regardless of which hand you use — the colony sees order done, whoever it was done +to. The class lives in the `Contraband` namespace (shared since the extraction from Contraband). + +--- + +*Part of the **Institution** suite for RimWorld 1.6: Core · Contraband · Justice · Gangs. Developed +with substantial assistance from Claude (Anthropic).* diff --git a/Wiki/justice/Home.md b/Wiki/justice/Home.md new file mode 100644 index 0000000..d42937a --- /dev/null +++ b/Wiki/justice/Home.md @@ -0,0 +1,96 @@ +# Institution: Justice + +*What the institution **does** about crime — the corrections and policing layer of the **Institution** +suite of RimWorld 1.6 mods.* + +Vanilla RimWorld keeps a rap sheet no one reads. You capture a raider, warden them, and the game +promptly forgets they ever tried to knife a guard. Prisoners are interchangeable mood-boxes; a +"maximum security" wing is a decoration you build by hand and the sim never acknowledges. There is a +record, and there are no consequences. + +**Justice turns the record into consequences, and closes the loop.** It grades every held pawn from +what they have actually done, gives the colony a *climate of order* that crime and punishment move, +feeds that climate back into every pawn's disposition, moves a *reform* score that decides whether a +sentence hardens or heals, gates release on it, and gives prisoners the recreation need vanilla flatly +denies them. The through-line is one sentence: + +> **Catching and punishing one pawn changes the disposition of the rest.** + +That is the difference between policing that *cleans up* and policing that **governs**. The reason to +punish an offender is not only this offender — it is the message it sends everyone else who is quietly +deciding, on a seeded roll, whether crime pays here. + +--- + +## The one shared record + +Justice never keeps its own per-pawn crime state. It reads and writes the single `CriminalRecord` that +**Institution: Core** holds for every pawn — the same record the search, the escape, the brewing, and +the gang systems write to. That is the whole point of the substrate: classification, deterrence, +discipline and parole all agree on how dangerous a pawn is, because they all read the same numbers. + +| Field | What it counts | Written by | +|---|---|---| +| `crimesCommitted` | offences on the record | Justice (`RecordCrime`), Gangs (fights) | +| `escapeAttempts` | proven flight risk | Contraband (escape/tunnel) | +| `contrabandMade` | shivs, brews, tools made | Contraband | +| `timesSearched` | searches endured | Contraband (warden search) | +| `timesCaught` | searches that found something | Contraband | +| `lastCrimeTick` | when they last offended | Justice (`RecordCrime`) | +| `reform` | −1 hardened … +1 rehabilitated | Justice (`Discipline`, `Parole`) | +| `pardoned` | released after genuine reform | Justice (`Parole`) | + +Justice authors the **crime / punishment / reform / pardon** columns; the counters of *what physically +happened* are fed by its siblings. It grades and gates over all of them. + +--- + +## The five modules + +| Module | What it does | Page | +|---|---|---| +| **Classification** | Grades held pawns `Minimum`..`Supermax` from a risk score over the record. | [Classification](Classification.md) | +| **Deterrence** | The colony's climate of order, moved by crime and punishment, drifting to baseline, read back into propensity. | [Deterrence](Deterrence.md) | +| **Discipline & Reform** | Punishment moves the reform score — harden vs. rehabilitate. The seat of institutionalization and recidivism. | [Discipline & Reform](Discipline-and-Reform.md) | +| **Parole** | Release when reform, grade, and pardon status line up. | [Parole](Parole.md) | +| **Regime** | Enables the prisoner recreation (Joy) need via a Harmony postfix, idempotent with Prisoner Recreation. | [Regime](Regime.md) | + +Threaded through all five is one small piece of glue, **`JusticeBootstrap`**, which at load fills the +neutral deterrence seam Core leaves open — reconnecting the feedback loop across the mod boundary +without Core ever depending on Justice. See [Deterrence](Deterrence.md). + +--- + +## Policing — the pre-arrest half (shipped) + +Everything above is the **post-arrest** side — corrections. The **pre-arrest** side now ships too: +colonists commit crimes on the propensity spectrum, witnesses and a constable's work name the culprit, +and a weighed arrest can end in a cell or a resisted breakout — plus prison riots and a recidivism +alert. Deterrence models *the consequence of visible justice*; policing is the machinery that produces +crime to answer. It has its own settings toggle (on by default). Full detail in **[Policing](Policing.md)**. + +--- + +## Requirements & load order + +- **RimWorld 1.6** +- **Institution: Core** — required. The propensity/record engine Justice grades against. +- **Harmony** — required. Regime's prisoner-recreation patch is the suite's only Harmony-using code. +- Load **after** Core and Harmony. + +Each mod in the suite stands alone as an install; together they form one system. Justice is useful +with Core alone, and richer with **Institution: Contraband** (which feeds the escape/search/contraband +counters) and **Institution: Gangs** (whose fights record crimes). + +--- + +## The suite + +Part of the **Institution** suite: **Core** (the propensity + record engine) · **Contraband** +(concealment, search, escape) · **Justice** (this mod) · **Gangs** (contraband economies). The +standalone **Foul Play** framework bridges into Core and Contraband when present. + +--- + +*Part of the **Institution** suite for RimWorld 1.6: Core · Contraband · Justice · Gangs. Developed +with substantial assistance from Claude (Anthropic).* diff --git a/Wiki/justice/Parole.md b/Wiki/justice/Parole.md new file mode 100644 index 0000000..3684e90 --- /dev/null +++ b/Wiki/justice/Parole.md @@ -0,0 +1,150 @@ +# Parole + +*Release, driven by what the pawn has become.* + +Parole is the end of the arc, and it is deliberately a *decision*, not a mechanic that rebuilds the +game's release flow. It asks one question — **is this pawn ready?** — and answers it from two things +already on the record: the reform the sentence earned (or failed to earn), and the grade the record +warrants. Both are self-complete reads; nothing here re-simulates a pawn's future. + +> The *return* outcome — a grateful ally, a vengeful raider — is Prisoner Realism's Recidivism, a good +> sim Justice does not rebuild. Justice's mirror is the parolee who **stays** and may reoffend, +> carrying their record into the colony (a Justice follow-up). + +--- + +## The gate + +``` +CanRelease(pawn): + rec = record for pawn (peek, no create) + if rec == null: return true // nothing on record to hold them + if rec.pardoned: return false // already released + return rec.reform >= 0.30 + && Classification.Grade(pawn) <= SecurityGrade.Medium +``` + +Three conditions, all of which must hold: + +| Condition | Threshold | Why | +|---|---|---| +| Not already pardoned | `pardoned == false` | Release is a one-way flag; you can't re-parole. | +| Genuinely reformed | `reform ≥ 0.30` | Proof the sentence *healed* rather than hardened. Three corrective acts minimum. | +| Low enough grade | `Grade ≤ Medium` (risk `< 0.8`) | Reform of attitude is not enough — the *record* must have cooled to something you'd let out. | + +A pawn with **no record at all** is releasable trivially (there is nothing to hold them). Everyone +else has to earn all three. + +The grade gate and the reform gate interact in a way worth internalizing. Because reform subtracts at +most `0.4` from risk ([Classification](Classification.md)), a pawn whose reform-*less* risk is `≥ 1.2` +can never reach Medium — so **no amount of reform will ever parole a prolific offender.** Parole is for +pawns whose record stayed shallow enough that treatment can pull them back under the ceiling. Let a +pawn rack up escapes and catches and you have quietly converted them into a lifer, whatever their +attitude. + +--- + +## Release + +``` +Release(pawn): + record.pardoned = true +``` + +`Release` sets one flag: `pardoned`. Vanilla does the actual freeing; the flag is Justice's memory +that this pawn was let out after genuine reform, so reintegration can grant a clean slate and +`CanRelease` won't offer them twice. A modest faction-goodwill / relationship nudge for a well-handled +release is the immediate payoff, wired when the release job lands. + +--- + +## The whole arc + +The pawn moves through every Justice module in turn, and each one writes the record the next one reads: + +| Stage | Module | What moves | Record touched | +|---|---|---|---| +| 1. Crime | [Deterrence](Deterrence.md) | Climate `−0.08`; the colony runs hot | `crimesCommitted++`, `lastCrimeTick` | +| 2. Caught & held | [Classification](Classification.md) | A grade is assigned from the record | reads all counters | +| 3. Discipline | [Discipline](Discipline-and-Reform.md) | `reform` moves ±; climate `+0.12` | `reform` | +| 4. Reform | [Discipline](Discipline-and-Reform.md) → Core | Disposition cools as reform rises | `reform` (read by Nurture) | +| 5. Regrade | [Classification](Classification.md) | Reform subtracts risk; grade falls | reads `reform` | +| 6. **Parole** | this page | Gate opens when reform ≥ 0.30 **and** grade ≤ Medium | reads `reform`, `pardoned`; sets `pardoned` | + +It is a loop, not a line: the crime that opens stage 1 also cools the colony's climate, which the *next* +pawn's disposition reads — so one pawn's whole sentence is faintly present in every other pawn's odds. + +--- + +## Worked example — a sentence that ends in release + +**Cass**, a Kind pickpocket. Nature `= clamp01(0.05 − 0.30) = 0`. She arrives with one crime, caught +once, no escapes, no contraband, `reform = 0`. + +``` +Grade at intake: +risk = 0 + 0 + (1*0.20) + (1*0.15) + 0 - 0 = 0.35 → MEDIUM +CanRelease? reform 0 < 0.30 → NO +``` + +She is already Medium-or-below, so the *grade* gate is met — but her `reform` is `0`. You give her the +corrective hand three times (structure, rewarded conduct), and fix her cell so she isn't stewing: + +``` +reform: 0 → +0.10 → +0.20 → +0.30 +Grade now: risk = 0.35 - (0.30 * 0.4) = 0.23 → MINIMUM +CanRelease? reform 0.30 ≥ 0.30 AND Minimum ≤ Medium AND not pardoned → YES +``` + +`Release(Cass)` sets `pardoned = true`. She walks. Contrast **Bram** from the Classification page, +whose reform-less risk of `1.55` keeps him at Maximum even at `reform = 1.0` — `CanRelease` returns +`false` for Bram forever, because the grade gate never opens. Two prisoners, the same treatment, and +the record decides which one you can ever let go. + +--- + +## A note on record cleanup + +A pawn whose record is `IsBlank` — no crimes, no escapes, no contraband, no catches, `reform == 0`, +not pardoned — is dropped on save/load (Core prunes blank and dead-pawn records). A *pardoned* pawn is +therefore **not** blank and their record persists: the pardon is remembered. This is why a released +parolee who stays and reoffends still carries their history — Justice does not forget that they were +once let out. + +--- + +## Ideology-flavoured thresholds + +Precepts layer on top of this baseline, not under it: an execution-favouring ideoligion paroles +rarely, a lenient one readily. `CanRelease` is the substrate-native baseline those precepts modulate — +the floor beneath the flavour, so the decision is coherent whether or not Ideology is installed. + +--- + +## How to play with parole + +- **Parole is earned, not granted.** You cannot release your way out of a deep record. Decide early + whether a pawn is a keep-or-release case and treat (gently) the ones you mean to free. +- **`reform ≥ 0.30` is three gentle acts, minimum.** Nothing faster reaches it. Start early. +- **Keep the record shallow if you want the option.** Every escape (`+0.35`) and catch (`+0.15`) you + let accumulate pushes a pawn toward the unparoleable side of the `1.2` line. +- **A parolee who stays is a Justice case, not a farewell.** They carry their record; if conditions + sour, their disposition (still reading their history) can put them back on it. + +--- + +## For modders + +```csharp +if (Parole.CanRelease(pawn)) // pure read: reform, grade, pardon + Parole.Release(pawn); // sets pardoned = true +``` + +`CanRelease` peeks (never creates a record) and treats a missing record as releasable. `Release` uses +`For` (creates if absent) and is idempotent — a second call is a no-op once `pardoned` is set. Both +live in the `Contraband` namespace (shared since Justice's extraction from Contraband). + +--- + +*Part of the **Institution** suite for RimWorld 1.6: Core · Contraband · Justice · Gangs. Developed +with substantial assistance from Claude (Anthropic).* diff --git a/Wiki/justice/Policing.md b/Wiki/justice/Policing.md new file mode 100644 index 0000000..eae95cf --- /dev/null +++ b/Wiki/justice/Policing.md @@ -0,0 +1,120 @@ +# Policing — the pre-arrest half + +*The other half of the loop. Where the rest of Justice deals with people the colony has already +caught, **policing** is what happens before the cell door: your own colonists commit crimes on the +propensity spectrum, the colony investigates, and — sometimes — arrests.* + +It is a big behavioural change (your colonists become potential criminals), so it has **its own +toggle** in the mod settings, on by default. Turn it off to run the rest of Justice — classification, +deterrence, discipline, parole, regime — without colony crime. + +--- + +## The loop + +``` +[crime] → [witnessed / investigated] → [weighed arrest (resist?)] → prison → … → parole → [recidivism] +``` + +Every stage reads the shared engine (`Propensity`, the `CriminalRecord`, the deterrence meter), so +policing is not a bolt-on — it feeds the same record classification grades on and discipline reforms. + +## Crime — and why small colonies stay honest + +Every ~40 seconds, each free colonist gets a seeded roll to offend. The base rate is low (`0.06`) and +multiplied hard by nature × nurture — almost nobody with an ordinary disposition ever does anything. +But it is *also* multiplied by **crime cover**: + +| Free colonists | Crime cover | +|---|---| +| ≤ 3 | 0.15 | +| ~8 | ~0.6 | +| ≥ 13 | 1.0 | + +In a tight 2–3 pawn colony everyone knows everyone and retribution is certain even without a jail, so +crime is **rare**; a larger, more anonymous colony offers cover. This is a deliberate inversion of the +naive "no enforcement → more crime": a small colony is kept honest by its own closeness, not by a +constable. + +A crime is one of **theft**, **vandalism**, or **assault** — leaning on who the culprit is (a +kleptomaniac steals, a bloodlusty pawn assaults, a pyromaniac vandalizes). + +## Consequences — the stakes + +A crime leaves a mark the colony can *see* (all wrapped defensively — a consequence that finds no +target never breaks anything): + +- **Theft** lifts a small stack of the colony's goods and the thief **pockets it**. RimWorld has no + per-person property (stockpiles are communal), so theft is theft from the common store — but the + culprit now physically carries what they took, a natural evidence trail a search can pick up. +- **Vandalism** damages a nearby colony building. +- **Assault** gives a nearby colonist a light, down-safe knock — and the culprit chooses their mark + **wisely**: someone they resent (motive), who is weak (little retribution), and unwatched (few + witnesses). +- **Relationships fray.** An assault victim thinks much worse of their attacker immediately (they saw + who did it); and when investigation *names* a culprit, the whole colony's opinion of them drops. +- The colony is **notified** a crime happened — culprit unknown. + +## Witnesses and investigation + +A crime is not equally solvable. Colonists who were near enough to **see** it are witnesses, and each +is a partial lead — a witnessed crime starts up to `0.8` of the way to solved before anyone lifts a +finger; an unseen one is a **cold case** worked from nothing. + +Investigation then accrues evidence toward `1.0` (naming the culprit) two ways: + +- **Ambient**, scaled by how many colonists the colony can spare (`~0.34` per check per unit of + effort — a few investigated windows). +- **The police work type.** Assign a colonist to *policing* and they walk to open **crime scenes** and + work them, adding a chunk of evidence scaled by their Intellectual + Social skill. A sharp constable + cracks cases fast; a colony that spares no one lets them go cold. + +## The weighed arrest + +A solved case does **not** mean an automatic arrest. Fellow-colonist cops will not gut the colony to +jail someone over a petty crime — the arrest is a cost/benefit (`ShouldArrest`): + +``` +arrest ⇔ colony can afford a prison AND severity ≥ 1.5 + value × 3 +``` + +- **Severity** — the crime's kind (theft 1 · vandalism 1.5 · assault 2.5), escalated by the culprit's + record (a rap sheet and prior escapes compound). +- **Value** — 0..1 from the culprit's best skills. A star colonist raises the bar to ~4.5; an + expendable one leaves it at ~1.5. +- **Capacity** — **below ~5 colonists, or with no prison bed, the colony arrests no one.** A prisoner + plus a warden would cripple a small colony. Capacity ramps up as the colony grows. + +So a petty crime by your only doctor → they walk. A murder → arrested regardless. And a crime that +paid, unanswered, erodes the climate of order a little more (crime emboldens). + +## Resisting arrest + +The disposed do not go quietly (`WouldResist`, seeded nature × nurture): + +- **Comply** → taken in cleanly (they become a prisoner of the colony — imprisonment is direct, and it + survives guest-management mods like Hospitality). +- **Resist** → the violent turn **berserk**, the rest **flee**. A botched arrest becomes a crisis the + colony must handle the hard way, and openly defying arrest emboldens the lawless. +- **Subdued** → a resister the colony beats down is then **imprisoned** (auto-capture) — the resist + path resolves to a cell once the colony wins the confrontation. One who flees the map gets away. + +## Prison riots + +When the climate of order **collapses** (deterrence at the floor) in a colony holding prisoners, a +neglected prison boils over into a **riot**: disposed prisoners turn violent together, and the unrest +shatters order further. A well-run colony (high deterrence) never sees one — a riot is the thing a +badly-run institution *earns*. It ties the deterrence meter, the propensity engine, and the prison +population into one emergent event. + +## Recidivism, made visible + +The loop closes on itself: a pawn hardened in prison (negative reform) runs a higher propensity and +reoffends. When investigation names a culprit the colony had **paroled**, it says so out loud — the +recidivism loop turning in the open, the payoff for everything classification, discipline, and parole +did upstream. + +--- + +Part of **Institution: Justice**, in the Institution suite. AI disclosure: developed with substantial +assistance from Claude (Anthropic). diff --git a/Wiki/justice/Regime.md b/Wiki/justice/Regime.md new file mode 100644 index 0000000..6c3df2f --- /dev/null +++ b/Wiki/justice/Regime.md @@ -0,0 +1,131 @@ +# Regime + +*The daily life of the held — and the one lever vanilla withholds.* + +Most of what a prison regime needs, vanilla already gives you. The schedule is the Restrict tab. The +mood-driven disposition already flows through `Propensity.Nurture` — a neglected prisoner runs hotter +with no new machinery. So Regime does not rebuild any of that. It adds the single thing vanilla flatly +refuses to model: **recreation.** + +--- + +## What vanilla does, and what Regime flips + +Vanilla denies prisoners the **Joy** (recreation) need outright — the need is flagged `colonistsOnly` +and `neverOnPrisoner`, so it simply never appears on a prisoner. A caged pawn has no recreation bar, +cannot take joy from anything, and never suffers for its absence. + +Regime flips exactly that one bit on, via a small Harmony postfix — the same approach the good, popular +**Prisoner Recreation** mod takes: + +```csharp +[HarmonyPatch(typeof(Pawn_NeedsTracker), "ShouldHaveNeed")] +static class Patch_PrisonerRecreation +{ + static void Postfix(NeedDef nd, Pawn ___pawn, ref bool __result) + { + if (__result || nd?.defName != "Joy") return; // only touch Joy, only when vanilla said no + if (___pawn?.RaceProps?.Humanlike == true + && ___pawn.IsPrisonerOfColony) + __result = true; // humanlike prisoners get the need + } +} +``` + +Three guards, in order: + +1. **`__result` already true** — vanilla or another patch already granted the need. Do nothing. This + is what makes the patch *additive*: it only ever turns a `false` into `true`, never the reverse. +2. **`nd.defName != "Joy"`** — this patch is about recreation and nothing else. Every other need is + left exactly as vanilla decided. +3. **humanlike prisoner-of-colony** — animals, guests, slaves and colonists are untouched; only a + humanlike prisoner of *your* colony gains the need. + +That is the entire mechanism. Enabling recreation is trivial — once the need exists, vanilla does all +the rest (joy sources, the recreation bar, the mood effects of a full or empty one). The *value* is +not the plumbing; it is what an empty bar now costs. + +--- + +## Why a need you can neglect is the point + +Giving prisoners recreation is not a mercy toggle — it is a **lever with two ends**, and the down end +is the one that matters to the suite. Once a prisoner *has* the Joy need, they can be **starved** of +it, and vanilla's own machinery then does the work: + +``` +no recreation → Joy falls → mood falls → Propensity.Nurture rises → higher propensity +``` + +Every one of those arrows already exists. Regime only opens the first door; mood is wired to +disposition in Core, so a prisoner who gets no yard time stews, their mood sinks, and their Nurture — +and with it their odds on every "would they?" roll — climbs. From [Deterrence](Deterrence.md) and +[Discipline & Reform](Discipline-and-Reform.md) you already know Nurture is the multiplier the whole +system keys off; Regime is what lets *neglect* feed it. + +Concretely, in Core's `Nurture`: + +| Prisoner condition | Nurture contribution | +|---|---| +| Mood `< 0.20` (recreation-starved, at the floor of despair) | `× 2.5` | +| Mood `< 0.35` (badly kept) | `× 1.6` | +| Held **and** mood `< 0.40` (a badly run cell) | additional `× 1.4` | + +A prisoner you give no recreation is a prisoner you are pushing up those brackets. Regime turns "I +didn't bother building a rec room" from a non-event into a disposition cost you pay on every roll. + +--- + +## Idempotent with Prisoner Recreation + +If you also run the **Prisoner Recreation** mod, nothing breaks. Both mods do the same thing — force +`ShouldHaveNeed` to return `true` for prisoner Joy — and because Regime's postfix only acts when +`__result` is still `false`, the two are simply *two postfixes that force the same `true`*. Whichever +runs first grants the need; the second sees it already granted and returns immediately. Running both is +harmless. Running either alone is sufficient. There is no double-need, no conflict, no load-order +sensitivity between them. + +Regime announces itself at startup so you can confirm it loaded: + +``` +[Institution: Justice] Regime: prisoner recreation enabled. +``` + +--- + +## Content on top + +Yard time, safety needs, and visitation are *content* on this foundation, not new machinery. Each of +them is just another thing that moves a prisoner's mood — and mood is already wired to disposition — so +they need only defs and jobs, not new systems. When they land, they will make a well-run regime feel +richer; the mechanical spine is already here in this one postfix. + +--- + +## How to play with Regime + +- **Build the rec room.** A prisoner with recreation settles; the mood brackets above stay off, and + their disposition sits near baseline. A cheap horseshoe pin or chess table earns its keep in crimes + that don't happen. +- **Neglect is a choice with a number.** Leaving prisoners with nothing to do isn't neutral — it drives + mood into the `×1.6` and `×2.5` Nurture brackets, and a hotter prisoner reoffends, which cools your + colony's climate of order, which heats *everyone*. The rec room is deterrence you build once. +- **Recreation and reform stack.** Regime cools disposition *live* (through mood); reform cools it + *permanently* (through [Discipline & Reform](Discipline-and-Reform.md)). A reformed prisoner in a + well-run wing is calm on both axes — which is exactly the pawn you can eventually [parole](Parole.md). +- **You don't need Prisoner Recreation too** — but if you have it, keep it. They coexist. + +--- + +## For modders + +Harmony ID `flan.institution.justice`, a single postfix on `Pawn_NeedsTracker.ShouldHaveNeed`. It is +the suite's *only* Harmony-using code — Contraband dropped its Harmony dependency after Justice was +split out. The patch class lives in the `Contraband` namespace (shared across the suite since the +extraction). The postfix is strictly additive (`false → true` only), so it is safe to stack with any +other mod that grants prisoner needs. + +--- + +*Part of the **Institution** suite for RimWorld 1.6: Core · Contraband · Justice · Gangs. Developed +with substantial assistance from Claude (Anthropic).* diff --git a/Wiki/ward/Commitment.md b/Wiki/ward/Commitment.md new file mode 100644 index 0000000..92faadf --- /dev/null +++ b/Wiki/ward/Commitment.md @@ -0,0 +1,316 @@ +# Commitment + +How involuntary psychiatric commitment actually works in play: how a colonist becomes a ward patient, +what the treatment station does, how a room turns into a ward, who does the counselling, what the +treatment buys, and what happens if you commit someone and then forget about them. + +Every def and class name below is the real one from the mod source. The `Ward_` prefix marks a +def; the C# class names are the workers behind them. + +--- + +## The loop in one paragraph + +You **arrest** a colonist (or otherwise take a prisoner) and set their interaction mode to +**psychiatric care**. You build a **treatment station** in their cell, which turns the room into a +**psychiatric ward**. A warden walks over, sits, and **counsels** them; each session adds a little +`Ward_UnderTreatment` severity, which **lowers their mental-break threshold**. That severity +**decays** at 0.15/day, so the effect fades unless wardens keep attending. Counselling also gives a +small mood lift (`Ward_Counselled`). Neglect a committed patient — leave them with no active treatment +hediff — and once a day they gain `Ward_Neglected`, a mood *penalty* that drags them back toward the +break that got them committed. Treat them and they stabilise; forget them and they don't. That +feedback loop is the entire mod. + +--- + +## Step 1 — Becoming a ward patient + +Commitment reuses vanilla's arrest → prisoner transition. You don't invent a new pawn state; you take +a prisoner (arrest your own colonist, or a raider) and then flip on a **non-exclusive interaction +mode**. + +### `Ward_PsychiatricCare` — the interaction mode + +| Field | Value | Why | +|---|---|---| +| Def type | `PrisonerInteractionModeDef` | A Def, not the compiled `GuestStatus` enum — that's the whole point | +| `defName` | `Ward_PsychiatricCare` | | +| `label` | "psychiatric care" | Shown in the prisoner's interaction dropdown | +| `listOrder` | `150` | Sits between vanilla's `ReduceResistance` (100) and `Release` (200) | +| `isNonExclusiveInteraction` | `true` | **Stacks** with recruit/convert/work instead of stealing the radio button | +| `mustBeAwake` | `false` | You can flag a downed or sleeping patient for care; the *work giver* decides when to actually treat | +| `allowOnWildMan` | `false` | A wild man isn't a commitment case | +| `allowInClassicIdeoMode` | `true` | Available without Ideology's ideoligion system | + +**Non-exclusive is load-bearing.** Vanilla's recruit/convert/enslave/release/execute modes are +mutually exclusive — you pick one from a radio group. Psychiatric care is modelled on vanilla's own +*Bloodfeed* and *Study* modes, which are toggles layered *on top* of the exclusive choice. So a +patient can be set to **recruit AND psychiatric care at once**: you counsel the sad colonist toward +stability while also chipping at their resistance. It also means Ward doesn't have to ship a +cross-product of `workAndPsychiatricCare` variants to coexist with *Prison Labor*'s work modes — both +just toggle on. + +The harness proves this on a live pawn: after `ToggleNonExclusiveInteraction(Ward_PsychiatricCare, +true)` and then `SetExclusiveInteraction(AttemptRecruit)`, **both** modes report enabled +(`live.stacksWithRecruit = True`). + +--- + +## Step 2 — The treatment station + +Flagging a patient for care does nothing on its own. Treatment happens *at a station, in the +patient's own room*. Without one, a patient flagged for care in an ordinary cell simply goes +untreated — which is the point of the neglect thought (Step 6). + +### `Ward_TreatmentStation` — the building + +> *"A desk, a chair bolted to the floor, and a locked cabinet of sedatives. A warden set to +> psychiatric care will use it to counsel patients held in this room."* + +| Field | Value | Why | +|---|---|---| +| `defName` | `Ward_TreatmentStation` | | +| Parent | `BuildingBase` | Ordinary passable furniture | +| Cost | **40 Steel + 2 Industrial medicine** | Deliberately cheap | +| `WorkToBuild` | `1600` | A quick build | +| `MaxHitPoints` | `120` | | +| `researchPrerequisites` | `MedicineProduction` | The one research gate | +| `size` | `(2,1)` | A two-tile desk | +| `graphicClass` | `Graphic_Single` | One texture, not a rotation set — nothing to keep in sync | +| `designationCategory` | `Misc` | | +| `passability` | `PassThroughOnly`, `pathCost 60` | Pawns squeeze past it; it doesn't wall a cell | +| `Flammability` | `1.0` | It burns | + +**It's cheap on purpose.** The real cost of running a ward is not the 40 steel. It is the **warden +hours** you spend counselling and the **labour you give up** by not putting the patient to work. The +building is just the gate that says "this room is set up to treat people." + +--- + +## Step 3 — The room becomes a ward + +Once a treatment station stands in a room that already holds prisoner beds, the room's *role* changes +from prison cell to psychiatric ward. + +### `Ward_PsychWard` — the room role + +| Field | Value | +|---|---| +| Def type | `RoomRoleDef` (`workerClass` is a public field — no patch needed) | +| `defName` | `Ward_PsychWard` | +| `label` | "psychiatric ward" | +| `workerClass` | `Ward.RoomRoleWorker_PsychWard` | +| `relatedStats` | Impressiveness, Cleanliness, Space | + +Vanilla already ships `PrisonCell`, `PrisonBarracks` and `Hospital` on exactly this rail; the ward is +the fourth sibling. The scoring worker, `RoomRoleWorker_PsychWard.GetScore`, is where the care goes: + +``` +score = 0 if stations == 0 OR prisonerBeds == 0 +score = 1_000_000 × prisonerBeds otherwise +``` + +Two design decisions are baked into that formula: + +- **Gated on a station.** The score is **zero** unless the room actually contains a + `Ward_TreatmentStation` *and* at least one prisoner bed. This is deliberate: without the gate, any + ordinary prison cell full of prisoner beds would start scoring as a ward and quietly steal the + `PrisonCell` role from vanilla — a compatibility bug dressed up as a feature. Build the station and + it's a ward; don't and your cells keep their vanilla role, untouched. +- **1e6, not a hard-coded constant.** Vanilla's room roles score in the `~1e5` range. The ward must + *outscore* `PrisonCell` for the same room or it would read as a cell block. Multiplying by + 1,000,000 clears that range without pinning to a magic number a future RimWorld patch might move. + (The harness's `FakeDLC` fixture deliberately adds a greedy competing role at `1e5 × beds`, and the + ward still wins the room.) + +**Crucially, it is still a prison cell.** Re-labelling the role does not release anyone. `Room.isPrisonCell` +is a cached field set only when the room's shape changes — never derived from the role — so a ward is +a prison cell *and* a ward simultaneously. Containment, food delivery, and prison breaks all keep +working. This is the pair of claims the harness holds together: `room.roleIsWard = True` **and** +`room.stillPrisonCellAsWard = True`. + +--- + +## Step 4 — The warden does the work + +### `Ward_WardenPsychiatricCare` — the work giver + +| Field | Value | Why | +|---|---|---| +| `defName` | `Ward_WardenPsychiatricCare` | | +| `giverClass` | `Ward.WorkGiver_Warden_PsychiatricCare` | A **new** class extending `WorkGiver_Warden` | +| `workType` | `Warden` | Assigned like any other warden work | +| `verb` / `gerund` | "treat" / "treating" | | +| `priorityInType` | `70` | Above chat (60), below feeding — an untreated patient deteriorates, a bored one doesn't | +| `requiredCapacities` | Talking, Hearing | A mute or deaf warden can't counsel | + +The class extends `WorkGiver_Warden` and its `JobOnThing` refuses the job unless **all** of these +hold: + +1. The pawn is a prisoner of the colony being taken care of (`ShouldTakeCareOfPrisoner`). +2. `IsInteractionEnabled(Ward_PsychiatricCare)` — asked, not "is this *the* mode," because the mode is + non-exclusive and the patient may also be set to recruit or forced labour. +3. The patient is **awake, not in a mental state, and not downed** — counselling someone mid-break or + unconscious isn't counselling. +4. The warden can reserve the patient. +5. A `Ward_TreatmentStation` exists **in the patient's own room** (not `PsychologicallyOutdoors`), is + reservable, and isn't forbidden. You can't treat someone through a wall from the station two + blocks over. + +**Being a new class is the entire compatibility story** (see The Compat Harness for the proof): + +- *Custom Prisoner Interactions* prefixes/postfixes the **named** vanilla warden givers (`_Chat`, + `_Convert`, `_Enslave`, `_ReleasePrisoner`). It literally cannot see `WorkGiver_Warden_PsychiatricCare`, + so it can't break it. +- *Prison Commons* postfixes `WorkGiver_Warden.ShouldSkip` on the **base** class. Ward's giver + inherits that method and doesn't override it, so Ward respects prison-commons areas **for free**, + without Ward knowing Prison Commons exists. + +### `Ward_ProvidePsychiatricCare` — the job + +| Field | Value | +|---|---| +| `defName` | `Ward_ProvidePsychiatricCare` | +| `driverClass` | `Ward.JobDriver_PsychiatricCare` | +| `casualInterruptible` | `false` | + +> Note: the `JobDef` and the `PrisonerInteractionModeDef` deliberately have **different** defNames. +> `[DefOf]` binds fields to defs by name, so two defs sharing one name couldn't both be reached from +> the `WardDefOf` class. + +The driver, `JobDriver_PsychiatricCare`, walks the warden to the patient and runs a counselling toil: + +- **Session length:** `SessionTicks = 1200` — about **20 in-game minutes**. +- The wait toil uses `activeSkill = Social` and shows a progress bar; it fails out if the patient + despawns, is forbidden, falls asleep, or enters a mental state partway through. +- On completion it applies treatment, grants the counselled mood memory, fires a `DeepTalk` social + interaction (so the relationship builds and the *next* session lands harder), and awards **60 Social + XP** to the warden. + +--- + +## Step 5 — What treatment buys: `Ward_UnderTreatment` + +Each completed session adds severity to a single hediff. The severity is **skill-scaled**: + +``` +gain = 0.20 + (wardenSocialLevel / 20) × 0.30 → range 0.20 .. 0.50 per session +``` + +| Warden Social skill | Severity gained per session | +|---|---| +| 0 | 0.20 | +| 5 | 0.275 | +| 10 | 0.35 | +| 15 | 0.425 | +| 20 | 0.50 | + +The floor is deliberate: even an unskilled warden buys **0.20** per session. A bad ward is *neglect*, +not *zero* — a clumsy counsellor is still worth something. Severity is capped at `maxSeverity = 1.0`. + +### The hediff + +| Field | Value | Why | +|---|---|---| +| `defName` | `Ward_UnderTreatment` | | +| `hediffClass` | `HediffWithComps` | | +| `label` | "under psychiatric care" | | +| `isBad` | `false` | It's a good hediff; won't be treated as an injury | +| `scenarioCanAdd` | `false` | Can't be granted by scenario editor | +| `maxSeverity` | `1.0` | | +| **Decay** | `severityPerDay = −0.15` | Roughly a week from a full course back to nothing, unattended | +| **Effect** | `MentalBreakThreshold −0.10` (stage 0) | A *negative* offset means the pawn breaks at a **lower** mood — i.e. **breaks less often** | + +**Decay is the mechanic.** Because severity bleeds off at 0.15/day, a patient treated once and then +ignored slides right back. Sustained treatment keeps the hediff topped up and the break threshold +suppressed; stopping lets it fade. Treatment is a *programme*, not a one-time cure. (The +`MentalBreakThreshold −0.10` value is a first-guess, not yet playtested — see the "unplaytested" +caveats in the README.) + +--- + +## Step 6 — The other half: neglect + +A ward you don't staff is not a neutral place to keep someone. It is a *worse* one. Without a cost, +"arrest the sad colonist and park them" would be a free way to remove a problem pawn from play. It +isn't free. + +### `MapComponent_WardNeglect` + +Once per **in-game day** (`CheckIntervalTicks = 60000`), this component scans every spawned prisoner +of the colony. For each prisoner flagged for `Ward_PsychiatricCare` who does **not** currently have a +`Ward_UnderTreatment` hediff, it grants the `Ward_Neglected` thought. + +The absence of the treatment hediff is the signal *by design*: because the hediff decays on its own, +its absence means **no warden has been near this patient in days** — not merely that they aren't being +counselled at this exact instant. + +### The two thoughts + +| Thought | Mood | Duration | Stack | Meaning | +|---|---|---|---|---| +| `Ward_Counselled` | **+6** | 2 days | up to 3 | "Someone sat with me and listened. It helped, a little." | +| `Ward_Neglected` | **−8** | 3 days | up to 4 | "They locked me in here for my own good and then forgot about me." | + +Neglect is asymmetric — a −8 penalty stacking four deep (−32) badly outweighs three counselling lifts +(+18). That's intentional: a neglected patient's mood falls, which raises their break risk, which is +exactly the crisis you committed them to avoid. The ward can *manufacture* the break it was built to +prevent. Staff it, or don't build it. + +> **Known rough edge (from the README's "Open" list):** neglect currently keys off "no +> `Ward_UnderTreatment` hediff." A patient treated once a week technically dodges the neglect thought +> on the single day the hediff expires. A real last-treated timestamp would be cleaner. Documented, +> not yet fixed. + +--- + +## How it stacks with recruitment (and everything else) + +Because `Ward_PsychiatricCare` is non-exclusive, it layers onto whatever else you've set: + +| You want to… | Set | Result | +|---|---|---| +| Just stabilise a broken colonist | Psychiatric care | Wardens counsel; break threshold drops | +| Talk a captured raider down *and* recruit them | Psychiatric care **+** Attempt recruit | Both run — counselling lifts mood while resistance is chipped | +| Convert *and* treat | Psychiatric care **+** convert | Both toggle on | +| Work a patient *and* treat them | Psychiatric care **+** Prison Labor's work mode | Both apply. Working a fragile patient is bleak — and it's your call to make | + +The last row is the darkest option the mod exposes, and it exposes it on purpose: Ward doesn't +forbid working a patient, it just makes the trade-off visible. + +Because a ward patient is a genuine vanilla prisoner, everything the wider Institution suite does to +prisoners applies to them unchanged — a committed patient can be classified, disciplined, paroled, +searched, and can conceal or improvise contraband. Ward writes no integration code for any of that; +the patient satisfies "held pawn" and the rest follows. See **Home** for the suite map and **The +Compat Harness** for how all of it is verified in one running game. + +--- + +## Quick reference — every Ward def + +| defName | Type | Class (if any) | Key numbers | +|---|---|---|---| +| `Ward_PsychiatricCare` | PrisonerInteractionModeDef | — | non-exclusive; listOrder 150 | +| `Ward_TreatmentStation` | ThingDef | `Building` | 40 steel + 2 medicine; WorkToBuild 1600; MedicineProduction research | +| `Ward_PsychWard` | RoomRoleDef | `RoomRoleWorker_PsychWard` | score = 1e6 × prisonerBeds (station-gated) | +| `Ward_WardenPsychiatricCare` | WorkGiverDef | `WorkGiver_Warden_PsychiatricCare` | priority 70; Talking + Hearing | +| `Ward_ProvidePsychiatricCare` | JobDef | `JobDriver_PsychiatricCare` | 1200-tick session; +60 Social XP | +| `Ward_UnderTreatment` | HediffDef | `HediffWithComps` | +0.20–0.50/session; −0.15/day decay; −0.10 break threshold | +| `Ward_Counselled` | ThoughtDef | `Thought_Memory` | +6 mood, 2 days, stack 3 | +| `Ward_Neglected` | ThoughtDef | `Thought_Memory` | −8 mood, 3 days, stack 4 | +| — | MapComponent | `MapComponent_WardNeglect` | daily scan (60000 ticks) | + +--- + +## Part of the Institution suite + +Institution: Ward is one mod in the **Institution** suite of RimWorld 1.6 mods — Ward, plus +**Institution: Core**, **Institution: Contraband**, **Institution: Justice**, **Institution: Gangs**, +and **Foul Play**. Each stands alone; together they interlock. A committed patient rides the same +prisoner rail the rest of the suite polices, so a psychiatric ward is also a secure context where a +patient can conceal and improvise contraband exactly as a prisoner can. + +## AI disclosure + +This mod was developed with substantial assistance from Claude (Anthropic), including the +compatibility analysis, the def and C# implementation, and the test harness. diff --git a/Wiki/ward/Home.md b/Wiki/ward/Home.md new file mode 100644 index 0000000..3825232 --- /dev/null +++ b/Wiki/ward/Home.md @@ -0,0 +1,118 @@ +# Institution: Ward + +**Involuntary psychiatric commitment for RimWorld 1.6 — the third pawn state, neither free nor guilty.** + +> **The psychiatric-care layer of the [Institution](https://git.onetick.ninja/flan/rimworld-institution) +> suite.** Ward runs on Core's shared treatment engine and ships bundled in the single Institution +> install with a toggle of its own; it still installs standalone (needing only Institution: Core and +> Harmony). Once a separate sister mod, now folded in. + +RimWorld gives a person exactly two places to stand: **colonist**, or **prisoner**. There is no +third state for someone who is neither free nor guilty. Out of the box you cannot commit anyone. A +colonist breaks, wanders off, punches a wall, recovers on their own, and goes back to hauling steel +as though nothing happened. Mental breaks are episodic and amnesiac — nothing persists, nothing is +*done* about them. + +Ward adds the missing state, and the room to put it in. A colonist you arrest can be set to +**psychiatric care**. Wardens counsel them at a **treatment station**. A room with prisoner beds +and a treatment station is a **psychiatric ward**, not a prison cell. Treatment lowers the patient's +mental-break threshold while it lasts — and it decays, so it has to be *sustained*. A ward you don't +staff is worse than no ward at all. + +--- + +## The third state, without a fourth enum slot + +The reason "commit a colonist" isn't a stock feature is a single compiled fact. `RimWorld.GuestStatus` +is a three-value enum: + +``` +Guest = 0 +Prisoner = 1 +Slave = 2 +``` + +A mod **cannot add a fourth value** to a compiled enum. That one constraint drives every design +decision in Ward — and in the rest of the ecosystem. It is why *Hospitality* carries dozens of +Harmony patches to maintain a pawn who is in your colony but is not your colonist: the enum has no +slot for one, so it runs a shadow guest system and defends it by patching bed validity, allowed +areas, the work JobGiver and the mental-state handler. + +**Ward doesn't pay that tax.** A committed pawn *is* a prisoner, in vanilla's own sense of the word — +arresting your own colonist already performs the colonist→prisoner transition. Ward only adds a new +**mode** to hold them under, and `PrisonerInteractionModeDef` is a **Def, not an enum**. Adding one +is XML plus, at most, a worker class. This is not a trick; it is the established pattern. *Prison +Labor* already ships seven custom interaction modes of its own. + +So the *state itself* costs **zero patches** — it is all defs: + +| Extension point | What Ward adds | Patches needed | +|---|---|---| +| `PrisonerInteractionModeDef` | `Ward_PsychiatricCare` (non-exclusive) | 0 | +| `RoomRoleDef` (`workerClass` is public) | `Ward_PsychWard` | 0 | +| `WorkGiverDef` (`giverClass`) | `Ward_WardenPsychiatricCare` | 0 | +| `JobDef`, `HediffDef`, `ThoughtDef`, `ThingDef` | treatment, recovery, neglect, the station | 0 | + +Ward *does* use Harmony where a patch is the cleaner tool (a patient's inspect line, and more), so it +is not a zero-patch mod. Its compatibility is about **which** methods it avoids: it touches none of +the contested methods the popular psych/prison mods fight over, and never prefixes +`MentalStateHandler.TryStartMentalState`. The full argument — which mods it was verified against and +why it can't collide with them — is on **The Compat Harness** page. + +--- + +## Riding the prisoner rail (why the suite cares) + +The design choice that makes Ward *cheap* also makes it *interesting* inside the wider Institution +suite. A committed patient is a genuine vanilla prisoner. That means they are a **secure context** in +exactly the sense the rest of the suite understands: a pawn who is held, searchable, and capable of +concealing and improvising contraband. A patient in a psych ward can hoard a shiv or brew something +foul in a smuggled vessel for **free**, because the contraband, search, corruption and gang systems +were written against "held pawn," and a ward patient satisfies that predicate without Ward writing a +line of integration code. + +The ward is a prison cell that happens to also be a ward. `Room.isPrisonCell` is a cached field +written only when the room's shape changes — it is **not** derived from the room's role — so +re-labelling a cell as a ward cannot break containment, food delivery, or prison breaks. The compat +harness asserts exactly this: a built ward is both `roleIsWard = True` **and** `stillPrisonCellAsWard += True`. The two claims pull against each other, and both have to hold. + +--- + +## Wiki index + +| Page | What's on it | +|---|---| +| **Home** (this page) | What Ward is, the third-state problem, the suite | +| **Commitment** | How commitment works in play: the mode, station, room role, work giver, job, hediff, thoughts, neglect, and how it stacks with recruitment — with every real def name and number | +| **The Compat Harness** | The in-game test tool this repo carries: booting real headless RimWorld with the whole suite + the most-subscribed Workshop mods (~24 active), the dependency-ordered build, the `WARDTEST` / `PNTEST` / `CBTEST` / `FPBRIDGE` assertion families, and how to run it | + +--- + +## Part of the Institution suite + +A set of RimWorld 1.6 mods about what an institution does to the people inside it. Each mod stands +alone as an install; together they interlock into one system — colonists and prisoners drift toward +crime on a spectrum of nature × nurture, and a policing/justice layer discovers, catches, punishes, +reforms, or paroles them. Ward's psychiatric ward is a secure context where a committed patient can +conceal and improvise contraband exactly as a prisoner can, because they ride the same prisoner rail. + +| Mod | What it is | +|---|---| +| **Institution: Ward** (this) | Involuntary psychiatric commitment — the third pawn state, neither free nor guilty. Also carries the suite's in-game compat harness. | +| **Institution: Core** | The shared engine — propensity (nature × nurture), the criminal record, secured context. | +| **Institution: Contraband** | The physical smuggling loop — conceal, improvise shivs, dig Prison-Architect tunnels, warden search, warden corruption. | +| **Institution: Justice** | Corrections & policing — classification, deterrence, discipline, parole, regime. | +| **Institution: Gangs** | Gangs as contraband economies — joining, smuggling networks, rivalry, fights-as-crime. Also the home of the suite's cross-module `CBTEST` integration test. | +| **Foul Play** | The vessel + substance + throw framework, and the "Piss Nuke" flagship; bridges to Core + Contraband when both are present. | + +Reference sibling mods by name — the mods are separate repositories and cross-repo links break. + +--- + +## AI disclosure + +This mod was developed with substantial assistance from Claude (Anthropic), including the +compatibility analysis, the def and C# implementation, and the test harness. This disclosure lives +in the repository (About.xml, README, and here); it is deliberately not repeated as commit-message +attribution. diff --git a/Wiki/ward/The-Compat-Harness.md b/Wiki/ward/The-Compat-Harness.md new file mode 100644 index 0000000..4504e4b --- /dev/null +++ b/Wiki/ward/The-Compat-Harness.md @@ -0,0 +1,285 @@ +# The Compat Harness + +`Tools/run-compat-test.sh` is not a unit test. It is a **documented tool** that boots a *real, +headless RimWorld* with the entire Institution suite and the most-subscribed relevant Workshop mods +loaded **at once**, plays the game at maximum speed for a few thousand ticks, and greps the log for +assertions that the mods it built actually work — and don't silently eat each other. + +Ward carries this harness for the whole suite because Ward is where the compatibility argument lives: +Ward's central claim is "I add defs, I patch nothing, so I can't collide with your mod list," and a +claim like that can only be *believed* from static analysis. It has to be *run* to be proven. + +--- + +## Why a running game, not static analysis + +Static analysis got the design most of the way. Dumping the Harmony attributes out of every mod +assembly shows **where** two mods want to patch the same vanilla method. What it cannot show is +whether that collision actually **bites**: + +- Two mods can both postfix a method and be completely fine. +- Or one mod's **prefix returns `false`**, which short-circuits the original method *and every other + prefix* — silently eating another mod's patch. **Nothing crashes.** A feature just quietly stops + working. + +The canonical example the harness guards: *Hospitality* prefixes +`MentalStateHandler.TryStartMentalState`. If Ward ever added a second prefix there that returned +`false`, Hospitality's would silently never run, and there would be no error to notice. So the harness +doesn't read code — it interrogates the **live Harmony patch table inside a running RimWorld** and +asserts who owns each contested method. + +The same failure mode, in a different shape, threatens Foul Play: the "Piss Nuke" psychotic-spree +feature is delivered by **XPath PatchOperations** into `MentalStateNonCritical` (and the escape/duty +defs). If another mod reshapes those defs, the XPath silently stops matching, the feature dies, and — +again — nothing errors. Only booting the whole stack and watching the spree actually happen catches +it. + +--- + +## What it loads (~24 active mods) + +The full-stack run activates the base game + three DLC, two libraries, the popular psych/prison +Workshop mods, and the whole Institution suite — **24 active mods** by default, **25** with the +`FakeDLC` fixture switched on. + +| Group | Mods | +|---|---| +| Base + DLC | RimWorld Core, Royalty, Ideology, Biotech | +| Libraries | Harmony, HugsLib | +| Guest/prisoner rail | Hospitality, Prison Labor, Locks, Prison Commons, Custom Prisoner Interactions, Prisoner Realism, Prisoner Recreation | +| Mental health | Psychology (unofficial), Rim Disorders, More Mental Breaks, Restraints | +| The big neighbour | Dubs Bad Hygiene (632k subscribers — the one Foul Play deliberately does *not* duplicate) | +| Institution suite | Core, Contraband, Justice, Gangs, Foul Play, **Ward** | +| Optional fixture | FakeDLC (stands in for Anomaly/Odyssey; only with `FAKE_DLC=1`) | + +The Workshop mods are read from a persistent, ID-keyed cache (default +`/home/dev/rimworld-ref/mods-cache`, keyed by Workshop file id). The cache lives on the home +partition, not `/tmp`, because a scratchpad reap once took the whole stack down with it. Rebuild it +from an installed run with `Tools/rebuild-mods-cache.sh`. + +Two sharp compat targets in that list are worth calling out, because they are exactly the ones that +*could* break Ward: + +- **Prisoner Realism** postfixes `Verse.Room.IsPrisonCell` — and Ward introduces a room role that can + outrank `PrisonCell`. Statically, `isPrisonCell` is a cached field written only by + `Notify_RoomShapeChanged` and is **not** derived from the role, so Ward can't break it. The harness + proves that at runtime, on a room it builds and re-roles live. +- **Hospitality** owns the whole "a pawn in the colony who is not a colonist" surface, including that + `TryStartMentalState` prefix. Ward rides the *prisoner* rail; Hospitality rides the *guest* rail; + the harness asserts they never touch. + +--- + +## The dependency-ordered build + +The Institution suite is split across separate assemblies with real cross-references, so they must +**build** and **load** in dependency order or the references won't resolve. Core is the dependency-free +leaf everything else reads. + +### Build order (what the script compiles, and why) + +| # | Built | Depends on (to compile) | SelfTest? | Notes | +|---|---|---|---|---| +| 1 | **Institution: Core** | base game only | no | The propensity / criminal-record / secured-context engine. Built even in Core-only, because everything below references its DLL. | +| 2 | **Institution: Justice** | Core | no | Classification, deterrence, discipline, parole, regime. Built even in Core-only (the bridge references it). | +| 3 | **Institution: Contraband** | Core | no | Concealment, search, tunnels. Builds **clean** now — see "Where CBTEST lives" below. | +| 4 | **Foul Play** (+ bridge) | Core, Contraband, its own DLL | **yes** (`PNTEST`) | The vessel/substance/throw framework and the Piss Nuke flagship. Its optional Contraband **bridge** is compiled against the Core/Contraband/FoulPlay DLLs. | +| 5 | **Institution: Gangs** | Core, Contraband, Justice | **yes** (`CBTEST`) | The social capstone — the only assembly that references *every* other Institution mod, which is why the cross-module integration harness lives here. | +| 6 | **Institution: Ward** | base game only (harness: + Harmony) | **separate assembly** (`WARDTEST`) | Ship `Ward.dll` carries no harness (Harmony ships as a normal dependency; the harness does not). The `WARDTEST` harness is its own project, `Source/Ward.CompatTest` → `Ward.CompatTest.dll`, built to a scratch dir and dropped beside the ship DLL at test time. | + +### Load order (the `activeMods` list) + +Harmony first (it says so itself), then the base game + DLC, then HugsLib, then the Workshop mods, +then the Institution suite **in dependency order**, and **Ward dead last**: + +``` +Core → Contraband → Justice → Foul Play → Gangs → Ward +``` + +Contraband and Justice each need only Core (their relative order doesn't matter). Foul Play's bridge +needs Contraband **active**, so Foul Play loads after it. Gangs is the capstone and needs Core + +Contraband + Justice. **Ward loads last on purpose** — its Harmony conflict report reads the live +patch table, so every other mod must have had its chance to patch *before* Ward looks. + +The script self-checks the config, not just the mod: every Workshop mod copied into `Mods/` has its +real `packageId` cross-checked against `activeMods`, because copying a mod in without activating it +would silently shrink the stack and still pass. It also lints every def XML first (`Tools/lint-xml.py`) — +a malformed comment makes RimWorld drop a def *silently*, and that's not worth a 15-minute headless +run to discover. + +--- + +## Where CBTEST lives now (Gangs, SelfTest) + +The suite's cross-module integration test — the `CBTEST` family, which drives the Prison-Architect +tunnel dig→search→breach loop and the full Core/Justice/Gangs interaction — used to live in +Contraband. It **moved into Gangs**, because Gangs is the one assembly that already references every +Institution mod (Core, Contraband, Justice). Consequences: + +- **Contraband now builds clean** — no `SelfTest`, no test code in the shipped `Contraband.dll`. +- **Gangs** is built with `-p:SelfTest=true`, which compiles its integration `GameComponent` in. That + build leaves test code in `Assemblies/InstitutionGangs.dll`, so the Gangs repo must be rebuilt + clean before its shipped DLL is committed. +- **Foul Play** is likewise built `-p:SelfTest=true` for its own `PNTEST` harness, so its DLL must + be rebuilt clean before it is shipped. +- **Ward** no longer works that way. Its harness is a **separate assembly** + (`Source/Ward.CompatTest` → `Ward.CompatTest.dll`), never compiled into `Ward.dll`. There is no + `SelfTest` flag on the ship project and nothing to "rebuild clean" — a build of `Ward.csproj` is + physically incapable of emitting the harness or a Harmony reference. `run-compat-test.sh` builds + the harness to a scratch dir and installs `Ward.CompatTest.dll` beside the clean ship DLL, where + RimWorld loads both. A CI guard additionally fails any push whose committed `Ward.dll` carries + `HarmonyLib`/`CompatTest` symbols. (Gangs and Foul Play still rely on the rebuild-clean + discipline; Ward removed that failure mode outright after a SelfTest `Ward.dll` shipped by + accident.) + +--- + +## The assertion families + +Everything is written to the log with a family tag, and the verdict section greps for each one. A run +with no `WARDTEST` lines at all is a hard failure ("NO ASSERTIONS RAN"). + +| Tag | Owner | What it proves | +|---|---|---| +| **`WARDTEST`** | Ward (`Source/Ward.CompatTest/CompatTest.cs`) | Ward loaded, patches no *contested* method, and works on a live prisoner | +| **`PNTEST`** | Foul Play self-test | The Piss Nuke / vessel / substance / ferment content works under the stack | +| **`FPBRIDGE`** | Foul Play ↔ Contraband bridge | The optional bridge loaded (and *only* when Contraband is present) and merged the two frameworks | +| **`CBTEST`** | Gangs self-test | The whole Institution loop — tunnel, search, classification, discipline, parole, corruption, gangs, regime — runs end-to-end on a live map | +| *(`PNCOMBAT`)* | Foul Play | Grepped alongside PNTEST for the combat/throw path | + +### `WARDTEST` — Ward's own checks + +Split between a `[StaticConstructorOnStartup]` (static def checks, "did Ward *load*") and a +`GameComponent` that runs at tick 60+ (the live checks, because the static-ctor order between mods is +undefined and Contraband injects its escape JobGivers from its *own* static ctor): + +- **Def load:** `def.interactionMode` (and `.nonExclusive`), `def.roomRole` (and `.workerResolves`), + `def.workGiver` (and `.classResolves`), `def.job`, `def.hediff`, `def.station`, + `station.graphicLoaded` (a missing texture is a magenta box and a log line, not a crash — so it's + checked on purpose). +- **Vanilla intact:** `vanilla.prisonerModes.intact` — `MaintainOnly`, `AttemptRecruit`, + `ReduceResistance`, `Release`, `Execution` all still exist (a defName collision would let the game + boot while quietly removing your ability to execute or release a prisoner). +- **The central claim:** `ward.patchesNoContestedMethod` — the harness enumerates every vanilla method + where a psych/prison mod *could* collide (`SetGuestStatus`, `IsValidBedFor`, `InAllowedArea`, + `TryStartMentalState`, `MentalBreaker.TryDoRandomMoodCausedMentalBreak`, `WorkGiver_Warden.ShouldSkip`, + `WorkGiver_Warden_Chat.JobOnThing`, …), reads the live Harmony patch owners, and asserts **none of + them is owned by Ward.** The moment Ward grows a Harmony patch on a contested method, this fails. +- **The specific one:** `ward.noPrefixOn.TryStartMentalState` — Ward must never add a *prefix* there, + or it silently eats Hospitality's. +- **Live usability:** `live.isPrisoner`, `live.psychCareEnabled`, `live.stacksWithRecruit` — a real + prisoner is generated, flagged for care via vanilla's *own* public API, and confirmed to stack with + `AttemptRecruit`, with Prison Labor / Custom Prisoner Interactions / Hospitality all loaded and + patching the guest tracker. +- **The built ward:** the component builds an actual walled, roofed room with a prisoner bed and a + station on the live map, forces a region/room rebuild, and asserts `room.proper`, + `room.plainCellNotStolen` (a bare cell keeps its vanilla role), `room.roleIsWard`, and — the + load-bearing one — `room.stillPrisonCellAsWard`. +- **Escape-tree order (`escape.armBeforeExit`, `escape.useContrabandBeforeExit`):** Ward also verifies + that Contraband's escape JobGivers land *before* `JobGiver_GotoTravelDestination` in the + `PrisonerEscape` think tree. A think node that *landed* is not a think node that can *run* — the tree + takes the first node that returns a job, and the travel-destination node returns one essentially + always, so anything appended after it is dead code that would pass a mere presence test. The harness + asserts **position**, because pre-order index is evaluation order. +- Terminated by `WARDTEST DONE`. + +### `PNTEST` / `FPBRIDGE` — Foul Play + +The Foul Play self-test proves the flagship content survives the stack: the carboy weapon def loads +(`def.weapon`), the spree think-tree XPath still matches (`thinktree.patched`), and the generic-vessel +refactor holds — runtime substance, label-shows-contents, the eight-substance catalogue, drink +consequences, mix ratios, pour capacity, water-douses-fire, blend round-trip through save/load, +fermentation dilution and spoilage, "only 100% ripe piss reads as a nuke," any pawn filling a vessel, +furniture dip/pour, and the autonomous cocktail on fill. The `FPBRIDGE` family fires only when +Contraband is present, and proves the optional bridge wired the carboy's brew-from-body worker, merged +the two propensity engines, and wired the content-tag delegates so a concealed vessel keeps its +substance. + +### `CBTEST` — the suite integration test (in Gangs) + +When Contraband is active, the Gangs self-test drives the entire loop on a live map and every one of +these must report `True`: `escape.registered`, `escape.foundWillingDigger`, `escape.digRatePositive`, +`escape.tickAdvancesTunnel`, `escape.recordsAttempt`, `escape.breachDestroysWall`, +`escape.emergedOutside`, `escape.toolConsumed`, `intake.concealsOnBodyContraband`, +`content.tagSurvivesRedeem`, `corruption.dispositionVaries`, `classification.gradeReflectsRecord`, +`prisonization.reshapesNurture`, `justice.deterrenceFeedback`, `discipline.hardens`, +`parole.releaseDecision`, `corruption.bentWardenSmuggles`, `gangs.smugglesAcrossWall`, +`gangs.fightIsCrime`, `gangs.membershipByDisposition`, `regime.prisonerRecreation`. Any `CBTEST … = +False`, or a `CBTEST HARNESS THREW`, fails the run. + +--- + +## How to run it + +```bash +# Full stack: ~24 active mods, all DLC, the popular Workshop mods. +Tools/run-compat-test.sh /path/to/rimworld-install [/path/to/workshop-mods] +``` + +The second argument (the Workshop-mod cache) defaults to `/home/dev/rimworld-ref/mods-cache` or +`$RIMWORLD_MODS_CACHE`. The Institution sibling source dirs default to `$HOME/rimworld-` and can +be overridden with `INSTITUTIONCORE`, `INSTITUTIONJUSTICE`, `CONTRABAND`, `FOULPLAY`, +`INSTITUTIONGANGS`. + +### The two modes — they fail in opposite directions + +| Env var | What it does | Why it exists | +|---|---|---| +| `CORE_ONLY=1` | No DLC, no Workshop mods, no Contraband/Justice/Gangs — just **Foul Play + Ward on a bare Core install** | Coexisting with 24 mods proves nothing about the player who owns no DLC. A def that accidentally referenced a DLC thing would resolve happily in the full run and break for everyone else. This is the opposite test. | +| `FAKE_DLC=1` | Loads `Tools/fixtures/FakeDLC` — a *hostile* mod that does to Ward's surface everything an unknown DLC could: competing prisoner interaction modes, a competing room role (`~1e5 × beds`), and pokes the very think-tree/duty defs the Piss Nuke XPaths depend on | You own Royalty/Ideology/Biotech but **cannot** load Anomaly/Odyssey without owning them (a DLC's defs can't be downloaded otherwise). The fixture stands in for what can't be loaded, and it loads *before* the suite so its patches land first. | + +`CORE_ONLY` is not achievable by editing `ModsConfig` (RimWorld re-activates any expansion whose +`Data/` folder is present) or by symlinks (Unity resolves the executable's real path back into the +original install). The script builds a **hard-link copy** of the game with the DLC `Data/` folders +deleted — the binary genuinely lives in a DLC-free tree, so its real path resolves there, at no disk +cost. In Core-only, `CB_ACTIVE=0` drops Contraband/Justice/Gangs from the load order too, so the pass +tests Foul Play + Ward genuinely standalone: the Contraband bridge must **not** load, yet the piss +content must still work. The verdict section asserts both directions — and a Core-only run that +quietly still had DLC loaded (which happened, twice, during development) is caught by grepping the +`loaded mods` line for `royalty|ideology|biotech`. + +### Watching a run + +```bash +# Stream a run to a stable log on the home partition (survives /tmp reaps): +FAKE_DLC=1 Tools/harness-stream.sh /path/to/rimworld-install + +# Follow it live from anywhere on the box (-F survives the log being recreated each run): +Tools/watch-harness.sh +``` + +Internally the run boots via `timeout 900 xvfb-run … RimWorldLinux -logfile … -quicktest`, forces +`TimeSpeed.Ultrafast` from the `GameComponent` (same per-tick logic, just no idle real-time between +ticks), and calls `Root.Shutdown()` once every assertion is written (~tick 8000; a 12000-tick watchdog +is the safety cap). The 900-second `timeout` is a backstop, not the normal exit. + +--- + +## Requirements — and why it's local, not CI + +| Requirement | Why | +|---|---| +| A **licensed RimWorld install** with the `RimWorldLinux` executable | The harness boots the *real* game engine. There is no headless stub — the whole value is that vanilla's own room grid, think trees, and mental-state handler run for real. | +| **The Workshop mods**, in the ID-keyed cache | Fetched with `DepotDownloader -app 294100 -pubfile ` — which needs a Steam account that *owns* RimWorld. The mods can't be redistributed. | +| **xvfb** + Mesa software GL (`libgl1-mesa-dri`) | RimWorld is a Unity app that insists on a GL context even for `-quicktest`. `xvfb-run` gives it a headless framebuffer; `LIBGL_ALWAYS_SOFTWARE=1` / `llvmpipe` render on the CPU (slow, but no GPU needed). | +| **dotnet** | Builds all six Institution assemblies (net472 via Krafs reference assemblies — no game install needed to *compile*, only to *run*). | + +Every one of those — a licensed game binary, non-redistributable Workshop content, a Steam login, a +GL-hungry Unity process — is a reason this cannot live in ordinary CI. It is a **local, on-demand +integration test**: you run it before a release, on a box that owns the game, and read the verdict. +The last line of a good run is `ALL COMPAT ASSERTIONS PASSED`. + +--- + +## Part of the Institution suite + +The harness in this repo builds and tests the whole suite — **Institution: Core**, **Institution: +Contraband**, **Institution: Justice**, **Institution: Gangs**, **Foul Play**, and **Institution: +Ward** — in dependency order, alongside the most-subscribed psych/prison Workshop mods, in one running +game. Ward carries it because Ward's design *is* a compatibility claim, and a claim like that is worth +nothing until it's run. + +## AI disclosure + +This mod was developed with substantial assistance from Claude (Anthropic), including the +compatibility analysis, the def and C# implementation, and the test harness.