diff --git a/About/Preview.png b/About/Preview.png
new file mode 100644
index 0000000..374c51c
Binary files /dev/null and b/About/Preview.png differ
diff --git a/Assemblies/Contraband.dll b/Assemblies/Contraband.dll
index 9fc6d33..2c76db3 100644
Binary files a/Assemblies/Contraband.dll and b/Assemblies/Contraband.dll differ
diff --git a/Assemblies/Institution.dll b/Assemblies/Institution.dll
index 2e9eba7..e2692b8 100644
Binary files a/Assemblies/Institution.dll and b/Assemblies/Institution.dll differ
diff --git a/Assemblies/InstitutionCore.dll b/Assemblies/InstitutionCore.dll
index 87ccca7..f0577be 100644
Binary files a/Assemblies/InstitutionCore.dll and b/Assemblies/InstitutionCore.dll differ
diff --git a/Assemblies/InstitutionGangs.dll b/Assemblies/InstitutionGangs.dll
index 431e2b8..76de6d0 100644
Binary files a/Assemblies/InstitutionGangs.dll and b/Assemblies/InstitutionGangs.dll differ
diff --git a/Assemblies/InstitutionJustice.dll b/Assemblies/InstitutionJustice.dll
index 764fc63..6af0cac 100644
Binary files a/Assemblies/InstitutionJustice.dll and b/Assemblies/InstitutionJustice.dll differ
diff --git a/Assemblies/Ward.dll b/Assemblies/Ward.dll
new file mode 100644
index 0000000..2e8b964
Binary files /dev/null and b/Assemblies/Ward.dll differ
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..ca308b0
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,24 @@
+# Changelog
+
+Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [SemVer](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+### Added
+- **Institution** — the aggregate mod. One install bundles the whole suite: the **Core** propensity /
+ record engine plus the **Contraband**, **Justice**, and **Gangs** feature layers, each behind its own
+ in-game toggle (all on by default). The layers remain modular — the suite can be split back into
+ separate mods at any time; this is packaging, not a merge.
+- **Settings panel** — a checkbox per layer:
+ - **Contraband** — concealment, search, escape tunnels.
+ - **Justice** — classification, deterrence, discipline & reform, parole, regime.
+ - **Gangs** — prison contraband economies and social capstone.
+ - **Policing** — colony crime and weighed arrests. A large behavioural change, so it carries its own
+ switch (on by default): your own colonists commit crimes on the propensity spectrum, and a weighed
+ arrest can end in a cell or a resisted breakout.
+
+ Toggling a layer off disables its systems in place, without uninstalling anything.
+
+### Notes
+- Requires **Harmony**. The suite's only Harmony-using code is Justice's prisoner-recreation postfix.
+- The standalone **Foul Play** framework bridges into this suite when both are installed.
diff --git a/Defs/JobDefs/Jobs_Policing.xml b/Defs/JobDefs/Jobs_Policing.xml
new file mode 100644
index 0000000..f94048b
--- /dev/null
+++ b/Defs/JobDefs/Jobs_Policing.xml
@@ -0,0 +1,11 @@
+
+
+
+
+ Institution_Investigate
+ Contraband.JobDriver_Investigate
+ investigating a crime scene.
+ true
+
+
+
diff --git a/Defs/Medication_Ward.xml b/Defs/Medication_Ward.xml
new file mode 100644
index 0000000..792618e
--- /dev/null
+++ b/Defs/Medication_Ward.xml
@@ -0,0 +1,134 @@
+
+
+
+
+
+ Ward_Sedative
+ sedative
+ A measured dose of fast-acting sedative. A warden can administer one to a patient on the edge of a breakdown to buy calm — at the cost of a groggy, near-useless day. It settles a crisis; it does not treat one.
+
+
+ Things/Item/Resource/Medicine/MedicineIndustrial
+ Graphic_StackCount
+
+ 25
+ false
+
+ 14
+ 0.05
+ 0.7
+ 2
+
+
+ Medicine
+
+
+ Medicine
+
+
+
+
+ Ward_MakeSedative
+ make sedatives
+ Synthesize a batch of sedatives from neutroamine.
+ Making sedatives.
+ 800
+ DrugSynthesisSpeed
+ Cook
+ Recipe_Drug
+ Intellectual
+
+
+
+
+ Neutroamine
+
+
+ 1
+
+
+
+
+ Neutroamine
+
+
+
+ 4
+
+
+ DrugLab
+
+ DrugProduction
+
+
+
+
+ Ward_Sedated
+ sedated
+ Dosed with a sedative. Very unlikely to break — and too groggy to do much of anything.
+ HediffWithComps
+ (0.6, 0.6, 0.9)
+ false
+ true
+ 1.0
+
+
+
+ -1.0
+
+
+
+
+
+
+ -0.40
+
+
+
+ Consciousness
+ -0.25
+
+
+ Moving
+ -0.15
+
+
+
+
+
+
+
+
+ Ward_WardenAdministerSedative
+ administer sedative
+ Ward.WorkGiver_Warden_Sedate
+ Warden
+ sedate
+ sedating
+ 90
+
+ Manipulation
+
+
+
+
+ Ward_AdministerSedative
+ Ward.JobDriver_AdministerSedative
+ administering a sedative to TargetA.
+ false
+
+
+
diff --git a/Defs/Misc_Ward.xml b/Defs/Misc_Ward.xml
new file mode 100644
index 0000000..cb5d85d
--- /dev/null
+++ b/Defs/Misc_Ward.xml
@@ -0,0 +1,165 @@
+
+
+
+
+
+ Ward_WardenPsychiatricCare
+ provide psychiatric care
+ Ward.WorkGiver_Warden_PsychiatricCare
+ Warden
+ treat
+ treating
+
+ 70
+
+ Talking
+ Hearing
+
+
+
+
+
+ Ward_ProvidePsychiatricCare
+ Ward.JobDriver_PsychiatricCare
+ treating TargetA.
+ false
+
+
+
+
+ Ward_UnderTreatment
+ under psychiatric care
+ This person is receiving regular counselling. They are less likely to suffer a mental break.
+ HediffWithComps
+ (0.6, 0.8, 0.9)
+ false
+ 1.0
+ false
+
+
+
+ -0.15
+
+
+
+
+ 0
+
+
+ -0.10
+
+
+
+
+
+
+ Ward_Counselled
+ Thought_Memory
+ 2
+ 3
+
+
+ counselled
+ Someone sat with me and listened. It helped, a little.
+ 6
+
+
+
+
+
+
+ Ward_Neglected
+ Thought_Memory
+ 3
+ 4
+
+
+ left in the ward
+ They locked me in here for my own good and then forgot about me.
+ -8
+
+
+
+
+
+
+ Ward_Recovery
+ recovering
+ This person has worked through what they were committed for and is stabilising. Once fully recovered they can safely be discharged — held longer, they will start to resent it.
+ HediffWithComps
+ (0.55, 0.85, 0.65)
+ false
+ 1.0
+ false
+
+
+
+ -0.10
+
+
+
+
+ 0
+ on the mend
+
+
+ 0.999
+ stabilised
+
+
+
+
+
+
+ Ward_Recovering
+ Thought_Memory
+ 2
+ 3
+
+
+ on the mend
+ Whatever was wrong with me, it's easing. I think I might be getting better.
+ 4
+
+
+
+
+
+
+ Ward_Breakthrough
+ Thought_Memory
+ 5
+ 1
+
+
+ a breakthrough
+ Something finally shifted. For the first time in a long while, I feel like myself.
+ 10
+
+
+
+
+
diff --git a/Defs/PrisonerInteractionModeDefs/PrisonerInteractionModes_Ward.xml b/Defs/PrisonerInteractionModeDefs/PrisonerInteractionModes_Ward.xml
new file mode 100644
index 0000000..f7d2a6f
--- /dev/null
+++ b/Defs/PrisonerInteractionModeDefs/PrisonerInteractionModes_Ward.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+ Ward_PsychiatricCare
+ psychiatric care
+ 150
+ true
+ false
+ false
+ true
+
+
+
diff --git a/Defs/RoomRoleDefs/RoomRoles_Ward.xml b/Defs/RoomRoleDefs/RoomRoles_Ward.xml
new file mode 100644
index 0000000..b3c4a6f
--- /dev/null
+++ b/Defs/RoomRoleDefs/RoomRoles_Ward.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+ Ward_PsychWard
+ psychiatric ward
+ Ward.RoomRoleWorker_PsychWard
+
+ Impressiveness
+ Cleanliness
+ Space
+
+
+
+
diff --git a/Defs/ThingDefs/Buildings_Ward.xml b/Defs/ThingDefs/Buildings_Ward.xml
new file mode 100644
index 0000000..08f4c13
--- /dev/null
+++ b/Defs/ThingDefs/Buildings_Ward.xml
@@ -0,0 +1,52 @@
+
+
+
+
+
+ Ward_TreatmentStation
+ treatment station
+ 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.\n\nA room containing one is treated as a psychiatric ward rather than a prison cell. Whether that is a mercy depends entirely on how you run it.
+ Building
+
+ Things/Building/Ward/TreatmentStation
+
+ Graphic_Single
+ (2,1)
+
+ (0,0.05,2,0.9)
+
+
+ true
+ Building
+ PassThroughOnly
+ 60
+ false
+ 0.4
+
+ 120
+ 1600
+ 1.0
+ 0
+
+ (2,1)
+
+ 40
+ 2
+
+
+ MedicineProduction
+
+ Misc
+ true
+ true
+ Light
+
+ false
+
+
+
+
diff --git a/Defs/WorkGiverDefs/WorkGivers_Policing.xml b/Defs/WorkGiverDefs/WorkGivers_Policing.xml
new file mode 100644
index 0000000..70c52dc
--- /dev/null
+++ b/Defs/WorkGiverDefs/WorkGivers_Policing.xml
@@ -0,0 +1,18 @@
+
+
+
+
+ Institution_Investigate
+ investigate crimes
+ Contraband.WorkGiver_Investigate
+ Institution_Investigating
+ investigate
+ investigating
+ 100
+
+ Manipulation
+ Sight
+
+
+
+
diff --git a/Defs/WorkTypeDefs/WorkTypes_Policing.xml b/Defs/WorkTypeDefs/WorkTypes_Policing.xml
new file mode 100644
index 0000000..71e1b11
--- /dev/null
+++ b/Defs/WorkTypeDefs/WorkTypes_Policing.xml
@@ -0,0 +1,22 @@
+
+
+
+
+ Institution_Investigating
+ police
+ Constable
+ investigating
+ Working open crime scenes to gather evidence and identify the culprit.
+ investigate
+ 455
+
+ Intellectual
+ Social
+
+
+ Intellectual
+ Social
+
+
+
+
diff --git a/README.md b/README.md
index 5136793..310957b 100644
--- a/README.md
+++ b/README.md
@@ -20,15 +20,17 @@ Arrest is the hinge. Nothing hard-codes "prisoner" — every held-person system
|---|---|---|---|
| [Institution: Core](https://git.onetick.ninja/flan/rimworld-core) | the shared engine — **propensity** (nature × nurture), the **criminal record**, the **secured-context** predicate. Invisible on its own | — | shipping |
| [Institution: Contraband](https://git.onetick.ninja/flan/rimworld-contraband) | the physical smuggling loop — conceal / improvise shivs / Prison-Architect tunnels / warden search / corruption | Core | shipping |
-| [Institution: Justice](https://git.onetick.ninja/flan/rimworld-justice) | corrections & policing — classification, deterrence, discipline, parole, regime (a policing/cops layer is planned here) | Core, Harmony | shipping |
+| [Institution: Justice](https://git.onetick.ninja/flan/rimworld-justice) | corrections & **policing** — classification, deterrence, discipline & reform, parole, regime, and colony crime → witnesses → a weighed arrest | Core, Harmony | shipping |
| [Institution: Gangs](https://git.onetick.ninja/flan/rimworld-gangs) | gangs as **contraband economies** — joining by disposition, smuggling networks, rivalry, fights-as-crime | Core, Contraband, Justice | shipping |
-| [Institution: Ward](https://git.onetick.ninja/flan/rimworld-ward) | involuntary psychiatric commitment — the third pawn state, neither free nor guilty; also carries the suite's in-game test harness | — | shipping |
+| [Institution: Ward](https://git.onetick.ninja/flan/rimworld-ward) | involuntary **psychiatric care** — a prisoner committed for treatment, not punishment, on Core's shared treatment engine: counselling reduces the disorder, a recovery track leads to discharge, and neglect deteriorates. Folded into the suite as its psychiatric-care layer | Core, Harmony | shipping |
| [Foul Play](https://git.onetick.ninja/flan/rimworld-foulplay) | throw a container of something off a body; a Noita-flasks liquid framework (vessels + substances) with the **Piss Nuke** flagship; bridges to the suite | standalone | shipping |
The original monolith split along its natural seams in 2026-07: the substrate became **Core**, the
corrections/justice systems became **Justice**, and the gang layer became **Gangs**, leaving
-**Contraband** as the physical smuggling loop. Regime (prisoner recreation) folded into Justice; a
-policing/cops layer is where Justice grows next.
+**Contraband** as the physical smuggling loop. Regime (prisoner recreation) folded into Justice, which
+now also ships **policing** (colony crime → witnesses → a weighed arrest). **Ward** — once a standalone
+sister mod — is folded in as the psychiatric-care layer, consuming Core's shared treatment engine, so
+the suite is now five layers over one Core, bundled into a single install with a toggle each.
## Principles
diff --git a/Source/Institution/InstitutionMod.cs b/Source/Institution/InstitutionMod.cs
index 64eedc7..bdc7a8d 100644
--- a/Source/Institution/InstitutionMod.cs
+++ b/Source/Institution/InstitutionMod.cs
@@ -5,7 +5,7 @@ namespace InstitutionSuite
{
///
/// The single player-facing mod. It bundles the Institution feature layers -- Contraband,
- /// Justice, Gangs -- into one install with a checkbox per layer, and it owns NO gameplay code of
+ /// Justice, Gangs, Ward -- into one install with a checkbox per layer, and it owns NO gameplay code of
/// its own. All it does is read the player's choices and write them into Core's shared
/// , which the feature assemblies (shipped alongside
/// in this same mod) read at their entry points.
@@ -46,6 +46,10 @@ namespace InstitutionSuite
l.CheckboxLabeled("Policing (colony crime)",
ref settings.policing,
"The pre-arrest layer: your own colonists commit crimes on a spectrum (rarer in small, tight colonies), witnesses and a constable's work name the culprit, and a weighed arrest can end in a cell -- or a resisted breakout. A big behavioural change; turn it off to keep the rest of Justice without colony crime.");
+ l.Gap();
+ l.CheckboxLabeled("Ward (psychiatric care)",
+ ref settings.ward,
+ "Commit a prisoner for treatment instead of punishment: sustained counselling in a proper ward reduces the disorder that broke them and builds a recovery track toward discharge. Neglect a committed patient and they deteriorate. Uses Harmony.");
l.End();
settings.Apply(); // live: a toggle takes effect immediately for the gated behaviour
}
@@ -57,6 +61,7 @@ namespace InstitutionSuite
public bool justice = true;
public bool gangs = true;
public bool policing = true;
+ public bool ward = true;
/// Mirror the choices into Core's shared flags that every feature reads.
public void Apply()
@@ -65,6 +70,7 @@ namespace InstitutionSuite
Contraband.InstitutionSettings.justice = justice;
Contraband.InstitutionSettings.gangs = gangs;
Contraband.InstitutionSettings.policing = policing;
+ Contraband.InstitutionSettings.ward = ward;
}
public override void ExposeData()
@@ -73,6 +79,7 @@ namespace InstitutionSuite
Scribe_Values.Look(ref justice, "justice", true);
Scribe_Values.Look(ref gangs, "gangs", true);
Scribe_Values.Look(ref policing, "policing", true);
+ Scribe_Values.Look(ref ward, "ward", true);
base.ExposeData();
}
}
diff --git a/Textures/Things/Building/Ward/TreatmentStation.png b/Textures/Things/Building/Ward/TreatmentStation.png
new file mode 100644
index 0000000..b300bc1
Binary files /dev/null and b/Textures/Things/Building/Ward/TreatmentStation.png differ
diff --git a/Tools/make_preview.py b/Tools/make_preview.py
new file mode 100644
index 0000000..e7291da
--- /dev/null
+++ b/Tools/make_preview.py
@@ -0,0 +1,626 @@
+#!/usr/bin/env python3
+"""
+Generate the Institution suite's Workshop Preview.png images. Pure stdlib -- no Pillow, no CUDA,
+no AI/diffusion, nothing ripped. Every pixel is drawn by the code below, exactly like the per-repo
+Tools/make_textures.py sprite generators, so the copyright story stays clean.
+
+ Tools/make_preview.py # run from anywhere; writes into every sibling repo
+
+Reuses the same from-scratch machinery the texture generators use: a hand-rolled RGBA PNG writer
+(zlib + struct, see png()) and analytic signed-distance-field shapes supersampled for clean edges
+(see _raster). Nothing beyond the standard library, so it runs on a box with no image tools at all.
+
+Writes About/Preview.png (640x360, a safe 16:9 Workshop size) into each of:
+
+ rimworld-institution flagship: a barred cell window over the nature->nurture crime spectrum
+ rimworld-core the invisible engine: a nature x nurture node feeding a 0..1 spectrum bar
+ rimworld-contraband a filed shiv against a brick wall breached by an escape tunnel
+ rimworld-justice a level balance -- the corrections layer weighing what a pawn has done
+ rimworld-gangs a network of pawn tokens, two rival clusters bridged by a contraband line
+
+They are one SET: shared muted institutional green-grey / tin palette, the same embossed-tin border,
+the same faint grain, and a shared propensity spectrum ribbon (green -> amber -> red) along the
+bottom of every image -- the suite's signature, the spectrum every layer reads from.
+"""
+
+import os
+import zlib
+import struct
+import math
+
+REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # rimworld-institution
+SUITE = os.path.dirname(REPO) # holds all the sibling repos
+
+W, H = 640, 360
+AS = W / H # aspect; everything is normalised by HEIGHT so circles stay round
+SS = int(os.environ.get("SS", "3"))
+
+
+# ---------------------------------------------------------------------------
+# PNG encoder + supersampled rasteriser (pure stdlib) -- same as make_textures.py
+# ---------------------------------------------------------------------------
+
+def png(path, w, h, pixel_fn):
+ """Write an 8-bit RGBA PNG from scratch. pixel_fn(x, y) -> (r, g, b, a)."""
+ raw = bytearray()
+ for y in range(h):
+ raw.append(0) # filter byte: 0 = None, once per scanline
+ row = bytearray()
+ for x in range(w):
+ r, g, b, a = pixel_fn(x, y)
+ row += bytes((r & 255, g & 255, b & 255, a & 255))
+ raw += row
+
+ def chunk(typ, data):
+ return (struct.pack(">I", len(data)) + typ + data
+ + struct.pack(">I", zlib.crc32(typ + data) & 0xffffffff))
+
+ ihdr = struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0) # 8-bit depth, colour type 6 = RGBA
+ idat = zlib.compress(bytes(raw), 9)
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ with open(path, "wb") as f:
+ f.write(b"\x89PNG\r\n\x1a\n")
+ f.write(chunk(b"IHDR", ihdr))
+ f.write(chunk(b"IDAT", idat))
+ f.write(chunk(b"IEND", b""))
+
+
+def _raster(w, h, sample, ss=SS):
+ """Wrap an analytic sample(fx, fy) -> (r, g, b, a) into a supersampled pixel_fn."""
+ inv = 1.0 / ss
+ n = ss * ss
+
+ def pixel(x, y):
+ pr = pg = pb = pa = 0.0
+ for j in range(ss):
+ fy = y + (j + 0.5) * inv
+ for i in range(ss):
+ r, g, b, a = sample(x + (i + 0.5) * inv, fy)
+ if a:
+ af = a * (1.0 / 255.0)
+ pr += r * af
+ pg += g * af
+ pb += b * af
+ pa += af
+ if pa <= 1e-6:
+ return (0, 0, 0, 0)
+ return (_cb(pr / pa), _cb(pg / pa), _cb(pb / pa), _cb(pa / n * 255.0))
+
+ return pixel
+
+
+# ---------------------------------------------------------------------------
+# colour + geometry helpers (normalised coords; u in 0..AS, v in 0..1)
+# ---------------------------------------------------------------------------
+
+def _cb(x):
+ x = int(x)
+ return 0 if x < 0 else (255 if x > 255 else x)
+
+
+def _clamp01(x):
+ return 0.0 if x < 0.0 else (1.0 if x > 1.0 else x)
+
+
+def _shade(c, f):
+ return (_cb(c[0] * f), _cb(c[1] * f), _cb(c[2] * f))
+
+
+def _mix(a, b, t):
+ return (_cb(a[0] + (b[0] - a[0]) * t),
+ _cb(a[1] + (b[1] - a[1]) * t),
+ _cb(a[2] + (b[2] - a[2]) * t))
+
+
+def _grain(ix, iy):
+ """Deterministic faint speckle so fills aren't dead-flat."""
+ n = (ix * 374761393 + iy * 668265263) & 0xffffffff
+ n ^= (n >> 13)
+ n = (n * 1274126177) & 0xffffffff
+ return ((n >> 9) & 7) - 3 # -3..+4
+
+
+def _grainy(c, u, v):
+ g = _grain(int(u * 300), int(v * 300))
+ return (_cb(c[0] + g), _cb(c[1] + g), _cb(c[2] + g))
+
+
+def _seg(px, py, ax, ay, bx, by):
+ """Distance from P to segment AB, and the clamped projection param t in [0, 1]."""
+ dx, dy = bx - ax, by - ay
+ l2 = dx * dx + dy * dy
+ if l2 < 1e-12:
+ return math.hypot(px - ax, py - ay), 0.0
+ t = ((px - ax) * dx + (py - ay) * dy) / l2
+ t = 0.0 if t < 0 else (1.0 if t > 1 else t)
+ cx, cy = ax + dx * t, ay + dy * t
+ return math.hypot(px - cx, py - cy), t
+
+
+def _ellipse(u, v, cx, cy, rx, ry):
+ """Approximate signed distance to an ellipse. Negative inside."""
+ k = math.hypot((u - cx) / rx, (v - cy) / ry)
+ return (k - 1.0) * min(rx, ry)
+
+
+def _rrect(u, v, cx, cy, hw, hh, r):
+ """Signed distance to a rounded rectangle. Negative inside."""
+ qx = abs(u - cx) - (hw - r)
+ qy = abs(v - cy) - (hh - r)
+ ox = qx if qx > 0 else 0.0
+ oy = qy if qy > 0 else 0.0
+ return math.hypot(ox, oy) + min(max(qx, qy), 0.0) - r
+
+
+def _convex(u, v, pts):
+ """Signed distance to a convex polygon (negative inside). Winding-agnostic."""
+ n = len(pts)
+ cxp = sum(p[0] for p in pts) / n
+ cyp = sum(p[1] for p in pts) / n
+ dmax = -1e9
+ for i in range(n):
+ ax, ay = pts[i]
+ bx, by = pts[(i + 1) % n]
+ ex, ey = bx - ax, by - ay
+ L = math.hypot(ex, ey) or 1e-9
+ nx, ny = ey / L, -ex / L # a normal to the edge
+ dp = (u - ax) * nx + (v - ay) * ny
+ dc = (cxp - ax) * nx + (cyp - ay) * ny # which side the interior is on
+ if dc > 0:
+ dp = -dp
+ if dp > dmax:
+ dmax = dp
+ return dmax
+
+
+# ---------------------------------------------------------------------------
+# shared palette -- muted institutional green-grey / tin, dark outline
+# ---------------------------------------------------------------------------
+
+OUTLINE = (22, 26, 20)
+BG_TOP = (56, 64, 55)
+BG_BOT = (31, 37, 31)
+
+TIN = (138, 142, 126)
+TIN_HI = (178, 182, 162)
+TIN_DK = (92, 96, 82)
+TIN_DDK = (62, 66, 56)
+
+STEEL = (180, 184, 178)
+STEEL_HI = (210, 214, 208)
+STEEL_DK = (120, 124, 118)
+
+BRICK = (94, 98, 88)
+BRICK2 = (110, 114, 102)
+MORTAR = (54, 58, 50)
+EARTH = (46, 39, 29)
+EARTH_DK = (24, 20, 14)
+
+# the propensity spectrum: calm green (low) -> amber -> dangerous red (high)
+SPEC_LOW = (104, 150, 100)
+SPEC_MID = (198, 160, 74)
+SPEC_HI = (176, 68, 54)
+
+
+def spec(t):
+ """Colour along the nature->nurture propensity spectrum, t in 0..1."""
+ t = _clamp01(t)
+ if t < 0.5:
+ return _mix(SPEC_LOW, SPEC_MID, t * 2.0)
+ return _mix(SPEC_MID, SPEC_HI, (t - 0.5) * 2.0)
+
+
+# ---------------------------------------------------------------------------
+# shared frame + background + spectrum ribbon (identical on every preview)
+# ---------------------------------------------------------------------------
+
+CX, CY = AS / 2.0, 0.5
+FR_M = 0.050 # outer margin
+FR_R = 0.038 # corner radius
+FR_W = 0.026 # frame band width
+FHW, FHH = CX - FR_M, CY - FR_M
+
+IL, IR = FR_M + FR_W + 0.004, AS - FR_M - FR_W - 0.004 # inner content bounds
+IT, IB = FR_M + FR_W + 0.004, 1.0 - FR_M - FR_W - 0.004
+
+RIB_B = IB - 0.006
+RIB_T = RIB_B - 0.036
+RIB_L, RIB_R = IL + 0.004, IR - 0.004
+
+
+def _bg(u, v):
+ c = _mix(BG_TOP, BG_BOT, _clamp01(v))
+ dx, dy = (u - CX) / CX, (v - CY) / CY
+ c = _shade(c, 1.0 - 0.24 * _clamp01(dx * dx + dy * dy))
+ return _grainy(c, u, v)
+
+
+def _frame(u, v, d):
+ """Embossed-tin border colour for a point whose rounded-rect distance is d."""
+ if d > -0.004:
+ return OUTLINE # crisp outer edge line
+ p = (-0.004 - d) / (FR_W - 0.004) # 0 outer .. 1 inner
+ if p > 0.90:
+ return OUTLINE # crisp inner edge line
+ nx, ny = (u - CX), (v - CY)
+ lit = -(nx * 0.55 + ny * 0.85) # light from the top-left
+ if lit > 0:
+ c = _mix(TIN, TIN_HI, _clamp01(lit * 2.4))
+ else:
+ c = _mix(TIN, TIN_DDK, _clamp01(-lit * 2.4))
+ return _grainy(c, u, v)
+
+
+def _ribbon(u, v):
+ t = (u - RIB_L) / (RIB_R - RIB_L)
+ c = _shade(spec(t), 0.86)
+ if v - RIB_T < 0.004:
+ return OUTLINE # dark lintel over the ribbon
+ if RIB_B - v < 0.004:
+ c = _shade(c, 0.62)
+ if (t * 8.0) % 1.0 < 0.035: # eighth-tick divisions
+ c = _shade(c, 0.68)
+ return _grainy(c, u, v)
+
+
+def preview(motif):
+ """Compose background + shared ribbon + motif + border into one supersampled pixel_fn."""
+ def sample(fx, fy):
+ u, v = fx / H, fy / H
+ d = _rrect(u, v, CX, CY, FHW, FHH, FR_R)
+ if d > 0.004:
+ return _grainy(_shade(BG_BOT, 0.72), u, v) + (255,) # rounded corner falloff
+ if d > -FR_W:
+ return _frame(u, v, d) + (255,)
+ if RIB_T <= v <= RIB_B and RIB_L <= u <= RIB_R:
+ return _ribbon(u, v) + (255,)
+ m = motif(u, v)
+ if m is not None:
+ return m if len(m) == 4 else (m + (255,))
+ return _bg(u, v) + (255,)
+
+ return _raster(W, H, sample)
+
+
+# ===========================================================================
+# INSTITUTION -- the flagship. A barred cell window; behind the bars the whole
+# nature->nurture crime spectrum glows. Prison + a spectrum of crime, in one image.
+# ===========================================================================
+
+WIN_CX, WIN_CY = CX, 0.45
+WIN_HW, WIN_HH = 0.62, 0.30
+NBARS = 6
+
+
+def m_institution(u, v):
+ d = _rrect(u, v, WIN_CX, WIN_CY, WIN_HW, WIN_HH, 0.028)
+ if d > 0.030:
+ return None
+ if d > -0.006:
+ return OUTLINE # window outer edge
+ if d > -0.036: # tin window frame, bevelled
+ nx, ny = (u - WIN_CX), (v - WIN_CY)
+ lit = -(nx * 0.5 + ny * 0.9)
+ c = _mix(TIN, TIN_HI, _clamp01(lit * 3.0)) if lit > 0 else _mix(TIN, TIN_DK, _clamp01(-lit * 3.0))
+ return _grainy(c, u, v)
+
+ L, R = WIN_CX - WIN_HW, WIN_CX + WIN_HW
+ t = (u - L) / (R - L)
+
+ # bars: NBARS vertical + two horizontal, in front of the spectrum
+ s = (2 * WIN_HW) / NBARS
+ i = int((u - L) / s)
+ xc = L + s * (i + 0.5)
+ dvb = abs(u - xc)
+ bw = 0.016
+ hb = min(abs(v - (WIN_CY - 0.175)), abs(v - (WIN_CY + 0.175)))
+ hh = 0.014
+
+ is_v = dvb < bw
+ is_h = hb < hh
+ if is_v or is_h:
+ vertical = is_v and (not is_h or (dvb / bw) <= (hb / hh))
+ if vertical:
+ if dvb > bw - 0.0035:
+ return OUTLINE
+ p = (u - (xc - bw)) / (2 * bw) # 0 left .. 1 right
+ c = _mix(STEEL_DK, STEEL, 1.0 - p * 0.85)
+ c = _mix(c, STEEL_HI, _clamp01(0.5 - p) * 0.7)
+ return _grainy(c, u, v)
+ else:
+ if hb > hh - 0.0035:
+ return OUTLINE
+ near = WIN_CY - 0.175 if abs(v - (WIN_CY - 0.175)) < abs(v - (WIN_CY + 0.175)) else WIN_CY + 0.175
+ p = (v - (near - hh)) / (2 * hh)
+ c = _mix(STEEL_DK, STEEL, 1.0 - p * 0.85)
+ c = _mix(c, STEEL_HI, _clamp01(0.5 - p) * 0.7)
+ return _grainy(c, u, v)
+
+ # interior: the spectrum, brighter through the middle so it reads as light behind bars
+ base = spec(t)
+ gy = 1.0 - _clamp01(abs(v - WIN_CY) / WIN_HH)
+ base = _shade(base, 0.58 + 0.55 * gy)
+ if d > -0.020: # inner shadow just inside the frame
+ base = _shade(base, 0.80)
+ return _grainy(base, u, v)
+
+
+# ===========================================================================
+# CORE -- the invisible foundation. Two faint axes, NATURE (vertical) x NURTURE
+# (horizontal), meet at the engine node, which drives the 0..1 spectrum bar below.
+# Deliberately subdued: on its own it changes nothing you can see.
+# ===========================================================================
+
+NODE_X, NODE_Y = CX, 0.38
+BAR_Y, BAR_HW, BAR_HH = 0.60, 0.62, 0.052
+
+
+def m_core(u, v):
+ # 0..1 spectrum bar (the hero, low and central)
+ db = _rrect(u, v, CX, BAR_Y, BAR_HW, BAR_HH, 0.024)
+ if db <= 0.005:
+ if db > -0.005:
+ return OUTLINE
+ t = (u - (CX - BAR_HW)) / (2 * BAR_HW)
+ c = spec(t)
+ if (t * 10.0) % 1.0 < 0.028 and abs(v - BAR_Y) > BAR_HH - 0.016:
+ c = _shade(c, 0.6) # scale ticks along the rim
+ if abs(v - (BAR_Y - BAR_HH * 0.45)) < 0.006:
+ c = _mix(c, (255, 255, 255), 0.12) # a faint sheen line
+ return _grainy(c, u, v)
+
+ # engine node at the crossing
+ dn = math.hypot(u - NODE_X, v - NODE_Y)
+ if dn < 0.058:
+ if dn > 0.050:
+ return OUTLINE
+ c = _mix(TIN, STEEL_HI, _clamp01(1.0 - dn / 0.050))
+ return _grainy(c, u, v)
+
+ # faint NATURE x NURTURE axes behind everything
+ if abs(u - NODE_X) < 0.0035 and 0.14 < v < 0.62:
+ return _grainy(TIN_DK, u, v)
+ if abs(v - NODE_Y) < 0.0035 and (CX - 0.54) < u < (CX + 0.54):
+ return _grainy(TIN_DK, u, v)
+ # end caps on the axes -> small pips (nature high/low, nurture low/high)
+ for (px, py) in ((NODE_X, 0.15), (NODE_X, 0.61), (CX - 0.54, NODE_Y), (CX + 0.54, NODE_Y)):
+ if math.hypot(u - px, v - py) < 0.016:
+ return _grainy(TIN, u, v)
+ return None
+
+
+# ===========================================================================
+# CONTRABAND -- a filed shiv lying against a brick wall that an escape tunnel has
+# breached. The physical smuggling loop: a whittled weapon and a hole under the wall.
+# ===========================================================================
+
+BX, BY, BRX, BRY = CX + 0.44, 0.54, 0.28, 0.25 # the tunnel breach
+
+
+def _brick(u, v):
+ bh, bw = 0.078, 0.180
+ row = int((v - IT) / bh)
+ x = u + (bw * 0.5 if row % 2 else 0.0)
+ col = int(x / bw)
+ ly = (v - IT) - row * bh
+ lx = x - col * bw
+ if ly < 0.012 or lx < 0.012:
+ return MORTAR
+ h = (row * 928371 + col * 123457) & 255
+ return _shade(_mix(BRICK, BRICK2, h / 255.0), 0.94 + 0.12 * (((h >> 3) & 3) / 3.0))
+
+
+def _breach(u, v):
+ ang = math.atan2(v - BY, u - BX)
+ wob = 0.028 * math.sin(ang * 7.0 + 0.7) + 0.020 * math.sin(ang * 3.0)
+ k = math.hypot((u - BX) / (BRX + wob), (v - BY) / (BRY + wob))
+ return (k - 1.0) * min(BRX, BRY)
+
+
+def _shiv(u, v):
+ Bx, By = 0.30, 0.80 # cloth grip low-left
+ Jx, Jy = 0.44, 0.60
+ Tx, Ty = 0.78, 0.24 # filed point high-right
+ db, tb = _seg(u, v, Jx, Jy, Tx, Ty)
+ half = 0.030 * (1.0 - tb) + 0.004 * tb
+ d_blade = db - half
+ dg, tg = _seg(u, v, Bx, By, Jx, Jy)
+ d_grip = dg - 0.040
+ d = min(d_blade, d_grip)
+ if d > 0:
+ return None
+ if d > -0.006:
+ return OUTLINE
+ if d_grip <= d_blade:
+ wind = (110, 110, 104) if (tg * 4.3) % 1.0 < 0.34 else (150, 150, 142)
+ return _grainy(_shade(wind, 0.95 + 0.10 * tg), u, v)
+ bevel = 1.0 - _clamp01(db / max(1e-6, half))
+ c = _mix(STEEL_DK, STEEL, _clamp01(bevel * 1.6))
+ c = _mix(c, STEEL_HI, _clamp01(bevel - 0.55) * 1.4)
+ return _grainy(c, u, v)
+
+
+def m_contraband(u, v):
+ s = _shiv(u, v) # the shiv sits on top of the wall
+ if s is not None:
+ return s
+ db = _breach(u, v)
+ if db < 0: # tunnel mouth: dark earth, deeper = darker
+ f = _clamp01(-db / BRY)
+ return _grainy(_mix(EARTH, EARTH_DK, f), u, v)
+ if db < 0.020: # broken, crumbling brick edge
+ return _grainy(_shade(MORTAR, 0.7), u, v)
+ return _grainy(_brick(u, v), u, v)
+
+
+# ===========================================================================
+# JUSTICE -- a level balance. Corrections weighing what a pawn has actually done:
+# classification, deterrence, discipline. A gavel-less balance, held even.
+# ===========================================================================
+
+J_BEAM_Y = 0.28
+J_BEAM_HW = 0.40
+J_BASE_Y = 0.74
+J_PAN_Y = 0.54
+
+
+def _pan(u, v, px):
+ d = _ellipse(u, v, px, J_PAN_Y, 0.15, 0.058)
+ if v < J_PAN_Y: # keep the lower half -> a shallow bowl
+ d = max(d, (J_PAN_Y - v))
+ return d
+
+
+def m_justice(u, v):
+ OW = 0.006
+ d_post = _rrect(u, v, CX, (J_BASE_Y + J_BEAM_Y) / 2.0, 0.018, (J_BASE_Y - J_BEAM_Y) / 2.0, 0.006)
+ d_base = min(_rrect(u, v, CX, J_BASE_Y, 0.150, 0.026, 0.012),
+ _convex(u, v, [(CX - 0.055, J_BASE_Y - 0.055), (CX + 0.055, J_BASE_Y - 0.055),
+ (CX + 0.100, J_BASE_Y - 0.020), (CX - 0.100, J_BASE_Y - 0.020)]))
+ d_beam = _rrect(u, v, CX, J_BEAM_Y, J_BEAM_HW, 0.015, 0.008)
+ d_fulc = _convex(u, v, [(CX, J_BEAM_Y - 0.055), (CX + 0.052, J_BEAM_Y),
+ (CX - 0.052, J_BEAM_Y)])
+ d_panL = _pan(u, v, CX - J_BEAM_HW)
+ d_panR = _pan(u, v, CX + J_BEAM_HW)
+
+ d_solid = min(d_post, d_base, d_beam, d_fulc, d_panL, d_panR)
+
+ # hangers: a thin line from each beam end down to its pan
+ dcl, _ = _seg(u, v, CX - J_BEAM_HW, J_BEAM_Y, CX - J_BEAM_HW, J_PAN_Y - 0.05)
+ dcr, _ = _seg(u, v, CX + J_BEAM_HW, J_BEAM_Y, CX + J_BEAM_HW, J_PAN_Y - 0.05)
+ d_chain = min(dcl, dcr) - 0.004
+
+ if d_chain <= 0 and d_chain < d_solid:
+ return _grainy(TIN_DK, u, v) if d_chain > -0.004 else _grainy(TIN, u, v)
+
+ if d_solid > 0:
+ return None
+ if d_solid > -OW:
+ return OUTLINE
+ # tin, lit from the top
+ lit = 1.0 - _clamp01((v - J_BEAM_Y) / (J_BASE_Y - J_BEAM_Y))
+ c = _mix(TIN_DK, TIN, 0.55 + 0.45 * lit)
+ if d_solid <= d_panL or d_solid <= d_panR or d_solid <= d_beam:
+ c = _mix(c, TIN_HI, 0.25 * lit)
+ return _grainy(c, u, v)
+
+
+# ===========================================================================
+# GANGS -- a contraband network. Pawn tokens band into two rival clusters (amber
+# vs red) that a bright contraband line bridges through a broker in the middle.
+# ===========================================================================
+
+GANG_A, GANG_A_HI = (190, 150, 72), (216, 180, 98)
+GANG_B, GANG_B_HI = (176, 70, 56), (208, 100, 84)
+CONDUIT = (200, 202, 182)
+
+NODES = [
+ (CX - 0.50, 0.34, 'A'), (CX - 0.62, 0.50, 'A'), (CX - 0.52, 0.66, 'A'), (CX - 0.36, 0.48, 'A'),
+ (CX, 0.44, 'X'),
+ (CX + 0.36, 0.36, 'B'), (CX + 0.54, 0.50, 'B'), (CX + 0.42, 0.66, 'B'), (CX + 0.24, 0.52, 'B'),
+]
+EDGES = [
+ (0, 1, 'A'), (1, 2, 'A'), (0, 3, 'A'), (3, 2, 'A'), (1, 3, 'A'),
+ (5, 6, 'B'), (6, 7, 'B'), (5, 8, 'B'), (8, 7, 'B'), (6, 8, 'B'),
+ (3, 4, 'X'), (4, 8, 'X'),
+]
+
+
+def _token(u, v, nx, ny, base, hi):
+ d_head = math.hypot(u - nx, v - (ny - 0.024)) - 0.020
+ d_body = _rrect(u, v, nx, ny + 0.014, 0.032, 0.024, 0.012)
+ d = min(d_head, d_body)
+ if d > 0:
+ return None
+ if d > -0.005:
+ return OUTLINE
+ return _grainy(hi if d_head < d_body else base, u, v)
+
+
+def m_gangs(u, v):
+ for (nx, ny, g) in NODES: # tokens sit on top of the wires
+ if abs(u - nx) < 0.06 and abs(v - ny) < 0.06:
+ if g == 'A':
+ r = _token(u, v, nx, ny, GANG_A, GANG_A_HI)
+ elif g == 'B':
+ r = _token(u, v, nx, ny, GANG_B, GANG_B_HI)
+ else:
+ r = _token(u, v, nx, ny, TIN, TIN_HI)
+ if r is not None:
+ return r
+
+ best, kind, bt = 1e9, None, 0.0
+ for (i, j, k) in EDGES:
+ ax, ay = NODES[i][0], NODES[i][1]
+ bx, by = NODES[j][0], NODES[j][1]
+ dd, tt = _seg(u, v, ax, ay, bx, by)
+ if dd < best:
+ best, kind, bt = dd, k, tt
+ hw = 0.005
+ if best < hw:
+ if kind == 'X': # the contraband conduit: a bright dashed line
+ return _grainy(CONDUIT, u, v) if int(bt * 24) % 2 == 0 else None
+ col = _shade(GANG_A, 0.62) if kind == 'A' else _shade(GANG_B, 0.62)
+ if best > hw - 0.0018:
+ return _shade(col, 0.7)
+ return _grainy(col, u, v)
+ return None
+
+
+# ===========================================================================
+# verify: decode a PNG we wrote and confirm it is non-trivial (varied pixels)
+# ===========================================================================
+
+def verify(path):
+ data = open(path, "rb").read()
+ assert data[:8] == b"\x89PNG\r\n\x1a\n", "not a PNG"
+ i, idat, w, h = 8, b"", 0, 0
+ while i < len(data):
+ ln = struct.unpack(">I", data[i:i + 4])[0]
+ typ = data[i + 4:i + 8]
+ body = data[i + 8:i + 8 + ln]
+ if typ == b"IHDR":
+ w, h = struct.unpack(">II", body[:8])
+ elif typ == b"IDAT":
+ idat += body
+ i += 12 + ln
+ raw = zlib.decompress(idat) # rows: filter byte 0 + w*4 RGBA bytes
+ stride = w * 4 + 1
+ seen = set()
+ lo = [255, 255, 255]
+ hi = [0, 0, 0]
+ for y in range(0, h, 7): # sparse sample is plenty to prove variety
+ base = y * stride + 1
+ for x in range(0, w, 7):
+ o = base + x * 4
+ px = (raw[o], raw[o + 1], raw[o + 2])
+ seen.add(px)
+ for c in range(3):
+ lo[c] = min(lo[c], px[c])
+ hi[c] = max(hi[c], px[c])
+ spread = max(hi[c] - lo[c] for c in range(3))
+ return {"bytes": len(data), "w": w, "h": h, "distinct": len(seen), "spread": spread}
+
+
+TARGETS = [
+ ("institution", m_institution, "barred cell window over the nature->nurture crime spectrum"),
+ ("core", m_core, "nature x nurture engine node driving a 0..1 spectrum bar"),
+ ("contraband", m_contraband, "filed shiv against a brick wall breached by an escape tunnel"),
+ ("justice", m_justice, "a level balance weighing what a pawn has done"),
+ ("gangs", m_gangs, "two rival pawn clusters bridged by a contraband line"),
+]
+
+
+def main():
+ for name, motif, desc in TARGETS:
+ path = os.path.join(SUITE, "rimworld-" + name, "About", "Preview.png")
+ png(path, W, H, preview(motif))
+ info = verify(path)
+ ok = "OK" if info["distinct"] > 200 and info["spread"] > 80 else "?? CHECK"
+ print(f"{ok} {os.path.relpath(path, SUITE)} "
+ f"{info['w']}x{info['h']} {info['bytes']:,}B "
+ f"distinct~{info['distinct']} spread={info['spread']} -- {desc}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/Tools/package.sh b/Tools/package.sh
index 46f45c0..b444817 100755
--- a/Tools/package.sh
+++ b/Tools/package.sh
@@ -2,7 +2,7 @@
# Assemble the shippable, drop-in "Institution" mod into this repo.
#
# The Institution suite's code lives in SEPARATE sibling repos (rimworld-core, -contraband, -justice,
-# -gangs) as independent projects/DLLs -- that is what keeps it modular and splittable. For DISTRIBUTION
+# -gangs, -ward) as independent projects/DLLs -- that is what keeps it modular and splittable. For DISTRIBUTION
# they are vendored into ONE mod folder (this repo) with one About.xml and a settings shim that toggles
# each layer. This script rebuilds the sibling DLLs clean (no self-test) and copies them + Contraband's
# Defs in here, so the repo is a complete installable mod.
@@ -19,6 +19,7 @@ CORE="$SIB/rimworld-core"
CB="$SIB/rimworld-contraband"
JUST="$SIB/rimworld-justice"
GANGS="$SIB/rimworld-gangs"
+WARD="$SIB/rimworld-ward"
command -v dotnet >/dev/null || { echo "dotnet not on PATH" >&2; exit 1; }
@@ -27,6 +28,7 @@ dotnet build "$CORE/Source/Core/Core.csproj" -c Release -v q --n
dotnet build "$JUST/Source/Justice/Justice.csproj" -c Release -v q --nologo
dotnet build "$CB/Source/Contraband/Contraband.csproj" -c Release -v q --nologo
dotnet build "$GANGS/Source/Gangs/Gangs.csproj" -c Release -v q --nologo
+dotnet build "$WARD/Source/Ward/Ward.csproj" -c Release -v q --nologo
echo "==> vendoring DLLs into Assemblies/"
mkdir -p "$HERE/Assemblies"
@@ -36,6 +38,7 @@ cp "$CORE/Assemblies/InstitutionCore.dll" "$HERE/Assemblies/"
cp "$CB/Assemblies/Contraband.dll" "$HERE/Assemblies/"
cp "$JUST/Assemblies/InstitutionJustice.dll" "$HERE/Assemblies/"
cp "$GANGS/Assemblies/InstitutionGangs.dll" "$HERE/Assemblies/"
+cp "$WARD/Assemblies/Ward.dll" "$HERE/Assemblies/"
echo "==> vendoring the feature content"
for d in Defs Patches Languages Textures; do
@@ -47,6 +50,16 @@ if [[ -e "$JUST/Defs" ]]; then
mkdir -p "$HERE/Defs"
cp -r "$JUST/Defs/." "$HERE/Defs/"
fi
+# Ward ships defs (interaction mode, hediffs, treatment station, room role) and its own textures.
+# All its files are *_Ward.xml or under a Ward/ texture subdir, so nothing collides with the others.
+if [[ -e "$WARD/Defs" ]]; then
+ mkdir -p "$HERE/Defs"
+ cp -r "$WARD/Defs/." "$HERE/Defs/"
+fi
+if [[ -e "$WARD/Textures" ]]; then
+ mkdir -p "$HERE/Textures"
+ cp -r "$WARD/Textures/." "$HERE/Textures/"
+fi
echo "==> done. Assemblies:"
ls -1 "$HERE/Assemblies/" | sed 's/^/ /'