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 + + 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 + + Synthesize a batch of sedatives from neutroamine. + Making sedatives. + 800 + DrugSynthesisSpeed + Cook + Recipe_Drug + Intellectual + +
  • + + +
  • Neutroamine
  • + + + 1 + +
    + + +
  • Neutroamine
  • +
    +
    + + 4 + + +
  • DrugLab
  • +
    + DrugProduction +
    + + + + Ward_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 + + 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 + + Ward.WorkGiver_Warden_PsychiatricCare + Warden + treat + treating + + 70 + +
  • Talking
  • +
  • Hearing
  • +
    +
    + + + + Ward_ProvidePsychiatricCare + Ward.JobDriver_PsychiatricCare + treating TargetA. + false + + + + + Ward_UnderTreatment + + 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 + +
  • + + Someone sat with me and listened. It helped, a little. + 6 +
  • +
    +
    + + + + Ward_Neglected + Thought_Memory + 3 + 4 + +
  • + + They locked me in here for my own good and then forgot about me. + -8 +
  • +
    +
    + + + + Ward_Recovery + + 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 + +
  • +
  • + 0.999 + +
  • +
    +
    + + + + Ward_Recovering + Thought_Memory + 2 + 3 + +
  • + + Whatever was wrong with me, it's easing. I think I might be getting better. + 4 +
  • +
    +
    + + + + Ward_Breakthrough + Thought_Memory + 5 + 1 + +
  • + + 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 + + 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 + + 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 + + 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 + + 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/^/ /'