From ce197dfdd53713186dbb3fb4047f3d019c46e7a3 Mon Sep 17 00:00:00 2001 From: MrBeverage Date: Tue, 20 Oct 2026 22:13:00 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=90=9B=20Let=20a=20styling=20station'?= =?UTF-8?q?s=20recolor=20order=20through=20for=20a=20colonist=20on=20shift?= =?UTF-8?q?,=20as=20the=20direct-order=20rule=20promises,=20and=20cover=20?= =?UTF-8?q?the=20optimizer=20pause=20and=20the=20recolor=20guard=20in=20th?= =?UTF-8?q?e=20harness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../Harness/DebugTools_LifecycleHarness.cs | 4 + Source/ShiftChange/Harness/HarnessRecolor.cs | 141 ++++++++++++++++++ .../Swap/Patch_OptimizeApparelOnShift.cs | 22 ++- docs/DESIGN.md | 16 +- docs/TESTING.md | 23 ++- 5 files changed, 190 insertions(+), 16 deletions(-) create mode 100644 Source/ShiftChange/Harness/HarnessRecolor.cs diff --git a/Source/ShiftChange/Harness/DebugTools_LifecycleHarness.cs b/Source/ShiftChange/Harness/DebugTools_LifecycleHarness.cs index a376e08..3794555 100644 --- a/Source/ShiftChange/Harness/DebugTools_LifecycleHarness.cs +++ b/Source/ShiftChange/Harness/DebugTools_LifecycleHarness.cs @@ -20,6 +20,7 @@ using static ShiftChange.HarnessLifecycle; using static ShiftChange.HarnessGizmos; using static ShiftChange.HarnessOwnership; +using static ShiftChange.HarnessRecolor; using static ShiftChange.HarnessRideAlong; using static ShiftChange.HarnessScoring; using static ShiftChange.HarnessSwap; @@ -422,6 +423,9 @@ internal static bool Run(Map map, IntVec3 origin, bool toast) ParkedKitRefusesSpares); Case(map, pad, "but takes one once the parked kit is worn through", WornOutParkedKitStillGoesShopping); + // A known gap unless Ideology is active. + Case(map, pad, "a recolor order goes through on shift, the optimizer's recolor does not, and none reaches a parked garment", + RecolorOrderGoesThroughOnShift); // These two need a live map and no fixture, so they sit after the fixture cases and // before the game-replacing ones below. Case("a work target with no room of its own still resolves one", diff --git a/Source/ShiftChange/Harness/HarnessRecolor.cs b/Source/ShiftChange/Harness/HarnessRecolor.cs new file mode 100644 index 0000000..a354e29 --- /dev/null +++ b/Source/ShiftChange/Harness/HarnessRecolor.cs @@ -0,0 +1,141 @@ +// HARNESS only, compiled out of a plain Release build. The guard is whole-file, always +// (check-invariants.py enforces it); why: docs/DESIGN.md, "Development tooling". +#if HARNESS +using System.Collections.Generic; +using RimWorld; +using UnityEngine; +using Verse; +using Verse.AI; +using static ShiftChange.DebugTools_LifecycleHarness; + +namespace ShiftChange +{ + /// + /// Harness case for and + /// : on shift the optimizer's recolor never runs, + /// the player's order from a styling station does, and no recolor reaches a parked garment. + /// + internal static class HarnessRecolor + { + /// + /// On shift the styling station's order is taken while the optimizer's recolor is not, + /// and a recolor naming a parked garment is refused, ordered or not. Driven through the + /// giver, a job's reservations and TryTakeOrderedJob as the station calls it, each + /// refusal beside a control that differs only in what the guard keys on. Guards the + /// order being refused on shift, with a log warning. A known gap without Ideology, whose + /// defs the job and the station are. + /// + internal static bool RecolorOrderGoesThroughOnShift(Fixture fix) + { + if (!ModsConfig.IdeologyActive || JobDefOf.RecolorApparel == null + || ThingDefOf.StylingStation == null || ThingDefOf.Dye == null) + { + return ExpectKnownGap(false, "a recolor order goes through on shift", + "Ideology is not active on this mod list; add ludeon.rimworld.ideology to it"); + } + Pawn pawn = fix.Pawn; + Apparel uniform = fix.Comp.issuedUniform.Count > 0 ? fix.Comp.issuedUniform[0] : null; + Apparel parked = HarnessScoring.ParkedGarment(fix); + bool ok = Expect(CompShiftStand.OnShiftStandFor(pawn) == fix.Comp, + "the pawn is on shift (precondition)") + & Expect(uniform != null && pawn.apparel.WornApparel.Contains(uniform), + "and wears the stand's uniform (precondition)") + & Expect(parked != null && parked.ParentHolder is Building_OutfitStand, + "and their own garment is parked in the stand (precondition)"); + if (!ok) + { + return false; + } + + // The station across the pad, its interaction cell free, and dye beside it. + DebugTools_Fixtures.Spawn(fix.Map, ThingDefOf.StylingStation, ThingDefOf.WoodLog, + fix.Stand.Position + new IntVec3(2, 0, 4), Rot4.North); + Thing dye = ThingMaker.MakeThing(ThingDefOf.Dye); + dye.stackCount = 10; + GenSpawn.Spawn(dye, fix.Stand.Position + new IntVec3(4, 0, 2), fix.Map); + uniform.DesiredColor = new Color(0.8f, 0.2f, 0.2f); + ok &= Expect(JobGiver_OptimizeApparel.TryCreateRecolorJob(pawn, out _, dryRun: true), + "the station would offer Recolor apparel (precondition)"); + + ok &= Expect(!OptimizerJob(pawn).IsValid, + "on shift the optimizer gives no job, though the uniform wants dyeing"); + ok &= Expect(RefusedAtReservations(pawn, RecolorJob(pawn, ordered: false, names: null)), + "a recolor job the player did not order is refused on shift"); + + Job order = RecolorJob(pawn, ordered: false, names: null); + ok &= Expect(order != null && order.GetTargetQueue(TargetIndex.B).Contains(uniform), + "the station's order names the uniform"); + if (order != null) + { + ok &= Expect(pawn.jobs.TryTakeOrderedJob(order, JobTag.Misc), + "the station's order is taken on shift") + & Expect(pawn.CurJobDef == JobDefOf.RecolorApparel, + "and the pawn starts it") + & Expect(pawn.apparel.WornApparel.Contains(uniform), + "still in uniform, since an order is never intercepted"); + pawn.jobs.StopAll(); + } + ok &= Expect(RefusedAtReservations(pawn, RecolorJob(pawn, ordered: true, names: parked)), + "an order naming the parked garment is refused"); + + CompShiftStand.OnShiftStands.Remove(pawn); + ThinkResult offShift = OptimizerJob(pawn); + ok &= Expect(offShift.IsValid && offShift.Job.def == JobDefOf.RecolorApparel + && Reserves(pawn, offShift.Job), + "off shift the optimizer's recolor is given and reserves (control)") + & Expect(RefusedAtReservations(pawn, RecolorJob(pawn, ordered: false, names: parked)), + "and one naming the parked garment is still refused"); + return ok; + } + + /// The optimizer's answer for this pawn now, its retry delay cleared. + internal static ThinkResult OptimizerJob(Pawn pawn) + { + pawn.mindState.nextApparelOptimizeTick = Find.TickManager.TicksGame; + return new JobGiver_OptimizeApparel().TryIssueJobPackage(pawn, default(JobIssueParams)); + } + + /// + /// The recolor job the station builds, optionally marked as an order and pointed at one + /// garment in place of the worn ones. + /// + internal static Job RecolorJob(Pawn pawn, bool ordered, Apparel names) + { + if (!JobGiver_OptimizeApparel.TryCreateRecolorJob(pawn, out Job job) || job == null) + { + return null; + } + job.playerForced = ordered; + if (names != null) + { + List queue = job.GetTargetQueue(TargetIndex.B); + queue.Clear(); + queue.Add(names); + job.count = 1; + } + return job; + } + + /// Whether the job's reservations succeed, releasing whatever they took. + internal static bool Reserves(Pawn pawn, Job job) + { + if (job == null) + { + return false; + } + bool reserved = job.TryMakePreToilReservations(pawn, errorOnFailed: false); + pawn.ClearReservationsForJob(job); + return reserved; + } + + /// + /// Whether a job was built and then failed its reservations. A job that could not be + /// built is no refusal, so it does not count as one. + /// + internal static bool RefusedAtReservations(Pawn pawn, Job job) + { + return job != null && !Reserves(pawn, job); + } + } +} +#endif diff --git a/Source/ShiftChange/Swap/Patch_OptimizeApparelOnShift.cs b/Source/ShiftChange/Swap/Patch_OptimizeApparelOnShift.cs index 1ef7891..d91aac2 100644 --- a/Source/ShiftChange/Swap/Patch_OptimizeApparelOnShift.cs +++ b/Source/ShiftChange/Swap/Patch_OptimizeApparelOnShift.cs @@ -22,8 +22,9 @@ namespace ShiftChange /// optimizes the pawn's own clothes as normal. /// /// The styling station's float-menu order calls TryCreateRecolorJob directly - /// and never passes through this giver, but still - /// refuses that order while the pawn is on shift, at its reservations. + /// and never passes through this giver. It is the player's order, so it proceeds on shift + /// and dyes what the pawn wears, the uniform included: + /// lets it through. /// [HarmonyPatch(typeof(JobGiver_OptimizeApparel), "TryGiveJob")] public static class Patch_OptimizeApparelOnShift @@ -68,10 +69,11 @@ public static bool Prefix(Pawn pawn, ref Job __result) /// the game saved resumes without asking for reservations, so this does not reach it. /// /// Failing TryMakePreToilReservations is the clean kill: a queued job that fails - /// it is ended quietly as no longer valid, and the pawn picks another job. A player's order - /// fails it too, with a log warning. The job is blocked while the pawn is on shift (the worn - /// apparel is the uniform) and when any queued garment is parked in an outfit stand; off - /// shift, in their own clothes, recoloring runs as normal. + /// it is ended quietly as no longer valid, and the pawn picks another job. The job is + /// blocked while the pawn is on shift (the worn apparel is the uniform) unless the player + /// ordered it, and whenever any queued garment is parked in an outfit stand, ordered or not, + /// since the pawn no longer wears it. Off shift, in their own clothes, recoloring runs as + /// normal. /// [HarmonyPatch(typeof(JobDriver_RecolorApparel), nameof(JobDriver_RecolorApparel.TryMakePreToilReservations))] public static class Patch_RecolorExecutionGuard @@ -85,12 +87,16 @@ public static bool Prefix(JobDriver_RecolorApparel __instance, ref bool __result { SessionGuard.Ensure(); Pawn pawn = __instance.pawn; - if (pawn != null && CompShiftStand.OnShiftStandFor(pawn) != null) + Job job = __instance.job; + // TryTakeOrderedJob sets playerForced before it asks for reservations, and the + // flag is saved with a queued job. + bool ordered = job != null && job.playerForced; + if (pawn != null && !ordered && CompShiftStand.OnShiftStandFor(pawn) != null) { __result = false; return false; } - List queue = __instance.job?.GetTargetQueue(TargetIndex.B); + List queue = job?.GetTargetQueue(TargetIndex.B); if (queue != null) { for (int i = 0; i < queue.Count; i++) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 1abc1ef..9bedca5 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -638,16 +638,22 @@ Found in play: Ideology's apparel-recolor branch rides that giver (`JobGiver_OptimizeApparel.cs:84`) and fires whenever any *worn* item has a `DesiredColor`. On shift that is the uniform, so a pawn walked off and permanently dyed staged kit its favourite colour. The styling station's float-menu order -bypasses the giver, but the second guard below refuses it while the pawn is on -shift. `nextApparelOptimizeTick` is untouched, so civvies optimize normally after +bypasses the giver and proceeds, because direct orders are never blocked: on +shift it dyes what the colonist wears, the uniform included, in the colours the +player picked in the styling dialog. A uniform dyed that way goes back to its +stand in its new colours. +`nextApparelOptimizeTick` is untouched, so civvies optimize normally after changing out. A second guard works when a `RecolorApparel` job starts: a queued one is saved with the pawn and survives a load, and the driver dyes whatever its queue names, wherever it sits. Its `TryMakePreToilReservations` is failed — the vanilla-routine -way to cancel — when the pawn is on shift or any queued garment is parked in a -stand. A job already running when the game saved resumes without asking for -reservations, so the guard does not reach it. +way to cancel — when the pawn is on shift and the job is not `playerForced`, or +when any queued garment is parked in a stand, ordered or not. The order reaches +this guard too: `Pawn_JobTracker.TryTakeOrderedJob` sets `playerForced` and then +asks for reservations, and logs a warning when they fail, so the flag is what +lets it through. A job already running when the game saved resumes without +asking for reservations, so the guard does not reach it. ## Withholding from trade diff --git a/docs/TESTING.md b/docs/TESTING.md index 6a0bd30..02af6ec 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -415,6 +415,22 @@ means anything as one: a checked-out pawn refuses a spare duplicating the kit parked in their stand (`ParkedKitRefusesSpares`), and takes one once that kit is worn through (`WornOutParkedKitStillGoesShopping`). +**Recolor** (`HarnessRecolor`). `RecolorOrderGoesThroughOnShift` drives +Ideology's apparel recolor through the wardrobe optimizer, a job's own +reservations, and `TryTakeOrderedJob` as the styling station calls it. On +shift, the optimizer gives nothing and a recolor job the player did not order +is refused, while the station's order is taken and the pawn starts it in +uniform. An order naming a garment parked in the stand is refused. Off shift, +the optimizer's recolor comes back and reserves, and one naming the parked +garment is still refused. The job, the station and the order are Ideology's, +so the case is a known gap on a mod list without it. The minimal list +`run-harness.sh` writes names only Odyssey as a known expansion, and the game +turns on every installed expansion it has not seen, so that list has Ideology +whenever it is installed. `--full` has it when the copied list does, and a +runner that names every expansion as known has only the ones it activates. +The case does not check that the order finishes and dyes anything: the pawn +never walks under the harness, so it stops the job once it has started. + **Tables and rooms** (`HarnessTables`). Three cases need no map and check that the static tables still match the def database, where every lookup is silent-fail and a renamed def empties its row with no error: the room-role @@ -492,9 +508,10 @@ room under `OpenGround`. "Four engine traps" says why each is there. find on startup — but nothing pins the two collectors that reach stand contents in the first place, so a route quietly rerouted around the deal would take the protection with it and say nothing. -- **Pooling on and off, the optimizer pause, the recolor guard, `SwapPlan`'s - rollback.** No cases yet. The retry cooldown is asserted on the allowed-area - path only. +- **Pooling on and off, `SwapPlan`'s rollback.** No cases yet. The retry + cooldown is asserted on the allowed-area path only. The recolor case does not + reach a recolor job that was already running when the game saved, which + resumes without asking for reservations. - **A revert of `StaysInBed` to `pawn.InBed()`** — the specific defect the wake-up case was written for. The fixture reaches "on a bed" by assigning `Position`, never by running a lay-down driver, so posture stays `NotLaying` From f7c6513d02d5e5b99a0da6c9045c3f15e12ef032 Mon Sep 17 00:00:00 2001 From: MrBeverage Date: Wed, 21 Oct 2026 13:18:38 +0200 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=93=9D=20Draft=20the=20v1.5.3=20chang?= =?UTF-8?q?elog=20and=20Workshop=20comment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- media/changelog-v1.5.3.bbcode | 5 +++++ media/comment-v1.5.3.txt | 7 +++++++ 2 files changed, 12 insertions(+) create mode 100644 media/changelog-v1.5.3.bbcode create mode 100644 media/comment-v1.5.3.txt diff --git a/media/changelog-v1.5.3.bbcode b/media/changelog-v1.5.3.bbcode new file mode 100644 index 0000000..f059213 --- /dev/null +++ b/media/changelog-v1.5.3.bbcode @@ -0,0 +1,5 @@ +[list] +[*]Fixed: with Ideology, ordering a colonist in uniform to Recolor apparel at a styling station did nothing, and left a warning in the log. The order now goes through, and the colonist dyes the clothes you picked new colours for in Change style. When those include the stand's outfit, it goes back to the stand in its new colours, and the next colonist to borrow it wears them. +[*]Fixed: if a stand's owner died while the stand was boxed up for a move, setting it down made them its owner again, so nobody else could use it. A dead owner is now dropped when the stand is placed. A stand already held for a dead colonist this way needs them removed once with Set owner. +[*]Unchanged: a colonist in uniform still never recolors anything on their own; only your order does. Off shift, in their own clothes, recoloring works as before. A living owner still gets their stand back after a move. +[/list] diff --git a/media/comment-v1.5.3.txt b/media/comment-v1.5.3.txt new file mode 100644 index 0000000..9623682 --- /dev/null +++ b/media/comment-v1.5.3.txt @@ -0,0 +1,7 @@ +v1.5.3 update: + +Fixed: with Ideology, a colonist in uniform ordered to Recolor apparel at a styling station now does it. Colours picked for the stand's outfit stay on it when it goes back to the stand. + +Fixed: a stand whose owner died while it was boxed for a move no longer goes back to them when it is set down. + +Colonists in uniform still never recolor anything on their own.