From f94075a1ca13d6976019af12819fbeee67ead35c Mon Sep 17 00:00:00 2001 From: MrBeverage Date: Fri, 6 Nov 2026 13:57:42 +0100 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=90=9B=20Keep=20a=20crafter=20in=20un?= =?UTF-8?q?iform=20storing=20a=20bill's=20product=20inside=20the=20stand's?= =?UTF-8?q?=20radius=20or=20area?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +- .../Harness/DebugTools_LifecycleHarness.cs | 40 ++ .../Harness/HarnessCatchments.BillProduct.cs | 453 ++++++++++++++++++ .../Interception/Patch_JobInterception.cs | 25 + docs/DESIGN.md | 16 + docs/DEVELOPMENT.md | 2 +- docs/TESTING.md | 10 +- 7 files changed, 549 insertions(+), 3 deletions(-) create mode 100644 Source/ShiftChange/Harness/HarnessCatchments.BillProduct.cs diff --git a/README.md b/README.md index 7e44c0d..fd25b86 100644 --- a/README.md +++ b/README.md @@ -279,7 +279,11 @@ either, a colonist keeps the outfit on only for: heat or cold; - at a work stand, hauling, cleaning and building, provided every place the job names is inside the ring or the area. A haul out to a freezer past its edge - changes them back before they leave, while the stand is still close. + changes them back before they leave, while the stand is still close; +- at a work stand, carrying a finished bill's product to its stockpile, provided + the stand serves that bill's work and the stockpile is inside the ring or the + area. Hauling does not have to be ticked. A stockpile past the edge changes + them back first, and they leave the stack at the bench to do it. Most other jobs send them back to change first: a sit-down meal, bed, recreation at a work stand, work the stand does not serve. A sleep stand keeps pyjamas on only diff --git a/Source/ShiftChange/Harness/DebugTools_LifecycleHarness.cs b/Source/ShiftChange/Harness/DebugTools_LifecycleHarness.cs index 7318568..34b007e 100644 --- a/Source/ShiftChange/Harness/DebugTools_LifecycleHarness.cs +++ b/Source/ShiftChange/Harness/DebugTools_LifecycleHarness.cs @@ -319,6 +319,46 @@ internal static bool Run(Map map, IntVec3 origin, bool toast) Case(map, pad, "pass-through work is judged at both ends, and an errand ahead of the stand's work rides along", (m, p) => Stage(m, p, StageKit.Displacing, enclose: true), PassThroughWorkIsJudgedAtBothEnds); + WorkTypeDef crafting = DefDatabase.GetNamedSilentFail("Crafting"); + Case(map, pad, "a bill's product hauled to a stockpile in the area keeps the uniform on", + (m, p) => StageWithDoorway(m, p, crafting), + f => BillProductHaul(f, CatchmentKind.Area, true, true, true)); + Case(map, pad, "a bill's product hauled to a stockpile in the ring keeps the uniform on", + (m, p) => StageWithDoorway(m, p, crafting), + f => BillProductHaul(f, CatchmentKind.Radius, true, true, true)); + Case(map, pad, "the same with Hauling not ticked, because the bill's work is what the stand serves", + (m, p) => StageWithDoorway(m, p, crafting), + f => BillProductHaul(f, CatchmentKind.Area, false, true, true)); + Case(map, pad, "a bill's product bound for a stockpile past the area changes them back first", + (m, p) => StageWithDoorway(m, p, crafting), + f => BillProductHaul(f, CatchmentKind.Area, true, false, false)); + Case(map, pad, "a bill's product bound for a stockpile past the ring changes them back first", + (m, p) => StageWithDoorway(m, p, crafting), + f => BillProductHaul(f, CatchmentKind.Radius, true, false, false)); + Case(map, pad, "the same with Hauling not ticked", + (m, p) => StageWithDoorway(m, p, crafting), + f => BillProductHaul(f, CatchmentKind.Area, false, false, false)); + Case(map, pad, "a room stand keeps the uniform on for a bill's product bound for a stockpile in the room", + (m, p) => StageWithDoorway(m, p, crafting), + f => BillProductHaul(f, CatchmentKind.Room, true, true, true)); + Case(map, pad, "and for one bound past its door, as it always has", + (m, p) => StageWithDoorway(m, p, crafting), + f => BillProductHaul(f, CatchmentKind.Room, true, false, true)); + Case(map, pad, "a bill's product is judged at the stockpile, not the crafter's unpainted cell", + (m, p) => StageWithDoorway(m, p, crafting), + BillProductIsJudgedAtTheStockpile); + Case(map, pad, "a bill's product goes by the ordinary rule once the stand stops serving the bill", + (m, p) => StageWithDoorway(m, p, crafting), + BillProductOfUnservedWorkChangesThemBack); + Case(map, pad, "a haul that starts with the stack on the ground is no bill's product", + (m, p) => StageWithDoorway(m, p, crafting), + BillProductMustBeInHand); + Case(map, pad, "the same stack in hand after other served work is no bill's product", + (m, p) => StageWithDoorway(m, p, crafting), + BillProductMustComeFromABill); + Case(map, pad, "a crafter off shift is not dressed to store what they made", + (m, p) => StageWithDoorway(m, p, crafting), + BillProductDressesNobody); Case(map, pad, "a radius sleep stand changes them back on the first job out of bed", (m, p) => Stage(m, p, StageKit.Displacing, enclose: true), RadiusSleepStandKeepsItOnlyForBed); diff --git a/Source/ShiftChange/Harness/HarnessCatchments.BillProduct.cs b/Source/ShiftChange/Harness/HarnessCatchments.BillProduct.cs new file mode 100644 index 0000000..a0da1a2 --- /dev/null +++ b/Source/ShiftChange/Harness/HarnessCatchments.BillProduct.cs @@ -0,0 +1,453 @@ +// 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 Verse; +using Verse.AI; +using static ShiftChange.DebugTools_LifecycleHarness; +using static ShiftChange.HarnessFixtures; + +namespace ShiftChange +{ + internal static partial class HarnessCatchments + { + /// The room's far corner, relative to the pad's corner. + internal static readonly IntVec3 FarCorner = new IntVec3(5, 0, 5); + + /// + /// Where a stonecutter's table and its bill put the stack, and what the stand does about + /// it, through a real DoBill driven to its end by the engine. The game stores a + /// finished bill's product with a new haul that names no giver, started with the stack + /// already in the pawn's hands, so nothing short of the real driver has this shape. + /// + /// An area stand's room is painted whole, less the stockpile when + /// is false. A radius stand's ring reaches the far + /// corner at 8 and stops short of it at 5, with the bench inside both. A room stand's + /// outside stockpile is past its door. Keeping the uniform means hauling on shift with the + /// stack in hand; changing back means the swap started, the haul waits behind it and the + /// stack is on the ground. + /// + internal static bool BillProductHaul(Fixture fix, CatchmentKind kind, bool tickHauling, + bool stockpileInside, bool keepsUniform) + { + BillStage stage = StageBill(fix); + if (stage == null) + { + return false; + } + WorkTypeDef hauling = DefDatabase.GetNamedSilentFail("Hauling"); + if (fix.Comp.HandlesWork(hauling) != tickHauling) + { + fix.Comp.ToggleWork(hauling); + } + IntVec3 store = stage.Corner + FarCorner; + if (kind == CatchmentKind.Room && !stockpileInside) + { + store = new IntVec3(fix.Outside.minX, 0, fix.Outside.minZ + 3); + } + if (kind == CatchmentKind.Area) + { + fix.Comp.SetServeArea(PaintRoom(fix, stage, stockpileInside ? IntVec3.Invalid : store)); + } + else if (kind == CatchmentKind.Radius) + { + fix.Comp.SetServes(CatchmentKind.Radius); + fix.Comp.SetServeRadius(stockpileInside ? 8 : 5); + } + + Zone_Stockpile stockpile = StageStockpile(fix.Map, store); + try + { + bool ok = Expect(fix.Comp.serves == kind + && fix.Comp.HandlesWork(hauling) == tickHauling + && fix.Comp.Covers(stage.Table.InteractionCell, fix.Map) + && fix.Comp.Covers(store, fix.Map) == stockpileInside, + "the stand serves as staged, covering the bench and " + + (stockpileInside ? "the stockpile" : "not the stockpile") + + " (control)") + & Expect(RunSwap(fix) && fix.Comp.OnShift, "the crafter is on shift (control)"); + if (!ok) + { + return false; + } + ok &= FinishBill(fix, stage); + return ok & (keepsUniform ? KeptTheUniform(fix, stage) : ChangedBackFirst(fix, stage)); + } + finally + { + Settle(fix.Pawn); + stockpile.Delete(); + } + } + + /// + /// A player painting the bench and not the cell in front of it, where the crafter stands + /// and the stack is made. Only the stockpile decides the haul, so the uniform stays on. + /// + internal static bool BillProductIsJudgedAtTheStockpile(Fixture fix) + { + BillStage stage = StageBill(fix); + if (stage == null) + { + return false; + } + IntVec3 store = stage.Corner + FarCorner; + fix.Comp.SetServeArea(PaintRoom(fix, stage, stage.Table.InteractionCell)); + Zone_Stockpile stockpile = StageStockpile(fix.Map, store); + try + { + bool ok = Expect(!fix.Comp.Covers(stage.Table.InteractionCell, fix.Map) + && fix.Comp.Covers(stage.Table.Position, fix.Map) + && fix.Comp.Covers(store, fix.Map), + "the area holds the bench and the stockpile, not the crafter's cell (control)") + & Expect(RunSwap(fix) && fix.Comp.OnShift, "the crafter is on shift (control)"); + if (!ok) + { + return false; + } + ok &= FinishBill(fix, stage); + return ok & KeptTheUniform(fix, stage); + } + finally + { + Settle(fix.Pawn); + stockpile.Delete(); + } + } + + /// + /// The bill must be work the stand serves: the same haul after a bill it no longer serves + /// goes by the ordinary rule for a job with no work type, and changes them back with the + /// stockpile inside the area. Hauling stays ticked throughout, both because it decides + /// nothing here and because a set emptied of its last work excludes the stand. + /// + internal static bool BillProductOfUnservedWorkChangesThemBack(Fixture fix) + { + BillStage stage = StageBill(fix); + if (stage == null) + { + return false; + } + IntVec3 store = stage.Corner + FarCorner; + WorkTypeDef hauling = DefDatabase.GetNamedSilentFail("Hauling"); + if (!fix.Comp.HandlesWork(hauling)) + { + fix.Comp.ToggleWork(hauling); + } + fix.Comp.SetServeArea(PaintRoom(fix, stage, IntVec3.Invalid)); + Zone_Stockpile stockpile = StageStockpile(fix.Map, store); + try + { + bool staged = Expect(fix.Comp.Covers(store, fix.Map), + "the area holds the stockpile (control)") + & Expect(RunSwap(fix) && fix.Comp.OnShift, + "the crafter is on shift (control)"); + if (!staged) + { + return false; + } + bool ok = FinishBill(fix, stage, () => fix.Comp.ToggleWork(stage.Crafting)); + return ok & Expect(!fix.Comp.HandlesWork(stage.Crafting), + "the stand stopped serving Crafting during the bill (control)") + & ChangedBackFirst(fix, stage); + } + finally + { + Settle(fix.Pawn); + stockpile.Delete(); + } + } + + /// + /// A haul that begins by walking to a stack is no bill's product, even with a served bill + /// under way and the stockpile inside the area, so it goes by the ordinary rule. + /// + internal static bool BillProductMustBeInHand(Fixture fix) + { + BillStage stage = StageBill(fix); + if (stage == null) + { + return false; + } + Pawn pawn = fix.Pawn; + fix.Comp.SetServeArea(PaintRoom(fix, stage, IntVec3.Invalid)); + Zone_Stockpile stockpile = StageStockpile(fix.Map, stage.Corner + FarCorner); + try + { + if (!Expect(RunSwap(fix) && fix.Comp.OnShift, "the crafter is on shift (control)") + || StartBill(fix, stage) == null) + { + return false; + } + Thing stack = GenSpawn.Spawn(ThingMaker.MakeThing(stage.Blocks), + stage.Corner + new IntVec3(3, 0, 3), fix.Map); + Job haul = HaulAIUtility.HaulToStorageJob(pawn, stack, false); + return Expect(haul != null && haul.def == JobDefOf.HaulToCell + && haul.workGiverDef == null + && pawn.carryTracker.CarriedThing == null, + "a giver-less haul of a stack on the ground, hands empty (control)") + & Expect(haul != null && Probe(fix, haul), "changes them back first"); + } + finally + { + Settle(pawn); + stockpile.Delete(); + } + } + + /// + /// Only a bill's own haul counts: the same stack in hand, after other work the stand + /// serves, goes by the ordinary rule. + /// + internal static bool BillProductMustComeFromABill(Fixture fix) + { + BillStage stage = StageBill(fix); + if (stage == null) + { + return false; + } + Pawn pawn = fix.Pawn; + IntVec3 store = stage.Corner + FarCorner; + fix.Comp.SetServeArea(PaintRoom(fix, stage, IntVec3.Invalid)); + Zone_Stockpile stockpile = StageStockpile(fix.Map, store); + try + { + if (!Expect(RunSwap(fix) && fix.Comp.OnShift, "the crafter is on shift (control)")) + { + return false; + } + pawn.jobs.EndCurrentJob(JobCondition.InterruptForced, startNewJob: false); + pawn.jobs.ClearQueuedJobs(); + Job served = JobMaker.MakeJob(JobDefOf.Wait, 600); + served.workGiverDef = stage.Giver; + pawn.jobs.StartJob(served, JobCondition.None, null, resumeCurJobAfterwards: false, + cancelBusyStances: true, null, null); + Thing stack = ThingMaker.MakeThing(stage.Blocks); + pawn.carryTracker.TryStartCarry(stack); + Job haul = JobMaker.MakeJob(JobDefOf.HaulToCell, stack, store); + return Expect(pawn.CurJobDef == JobDefOf.Wait && fix.Comp.OnShift + && pawn.carryTracker.CarriedThing == stack, + "served work that is no bill, stack in hand (control)") + & Expect(Probe(fix, haul), "changes them back first"); + } + finally + { + Settle(pawn); + pawn.carryTracker.DestroyCarriedThing(); + stockpile.Delete(); + } + } + + /// + /// The other direction is untouched: a crafter in their own clothes who finishes a bill at + /// a stand ticked for Hauling alone is not sent to dress for storing what they made. + /// + internal static bool BillProductDressesNobody(Fixture fix) + { + WorkTypeDef hauling = DefDatabase.GetNamedSilentFail("Hauling"); + BillStage stage = StageBill(fix); + if (stage == null) + { + return false; + } + if (!fix.Comp.HandlesWork(hauling)) + { + fix.Comp.ToggleWork(hauling); + } + fix.Comp.ToggleWork(stage.Crafting); + fix.Comp.SetServeArea(PaintRoom(fix, stage, IntVec3.Invalid)); + Zone_Stockpile stockpile = StageStockpile(fix.Map, stage.Corner + FarCorner); + try + { + bool ok = Expect(!fix.Comp.OnShift && !fix.Comp.HandlesWork(stage.Crafting), + "the crafter starts off shift at a stand that serves Hauling alone (control)") + & FinishBill(fix, stage); + return ok & Expect(fix.Pawn.CurJobDef == JobDefOf.HaulToCell, + "they haul in their own clothes, sent nowhere to dress") + & Expect(!fix.Comp.OnShift, "and are still off shift"); + } + finally + { + Settle(fix.Pawn); + stockpile.Delete(); + } + } + + /// What put on the pad. + internal sealed class BillStage + { + internal IntVec3 Corner; + internal Building_WorkTable Table; + internal Bill_Production Bill; + internal WorkGiverDef Giver; + internal WorkTypeDef Crafting; + internal ThingDef Chunk; + internal ThingDef Blocks; + } + + /// + /// Ticks Crafting, then stages a stonecutter's table along the room's south wall with one + /// granite block bill. Ticked before the table lands: it would make an automatic stand + /// read the room as a workshop. + /// + internal static BillStage StageBill(Fixture fix) + { + WorkTypeDef crafting = DefDatabase.GetNamedSilentFail("Crafting"); + WorkGiverDef giver = DefDatabase.GetNamedSilentFail("DoBillsStonecut"); + ThingDef tableDef = DefDatabase.GetNamedSilentFail("TableStonecutter"); + ThingDef chunkDef = DefDatabase.GetNamedSilentFail("ChunkGranite"); + RecipeDef recipe = DefDatabase.GetNamedSilentFail("Make_StoneBlocksGranite"); + if (crafting == null || giver == null || tableDef == null || chunkDef == null + || recipe == null || fix.Outside.Area == 0) + { + Expect(false, "the stonecutting defs resolve and the doorway fixture staged"); + return null; + } + if (!fix.Comp.HandlesWork(crafting)) + { + fix.Comp.ToggleWork(crafting); + } + MakeCalm(fix.Map); + + // The doorway strip pins the pad's corner: the stand is at (1, 1), the door at (0, 1). + IntVec3 corner = new IntVec3(fix.Outside.minX + 1, 0, fix.Outside.minZ); + // Facing south, so its interaction cell is inside the room rather than in the wall. + Building_WorkTable table = DebugTools_Fixtures.Spawn( + fix.Map, tableDef, ThingDefOf.WoodLog, corner + new IntVec3(4, 0, 1), Rot4.South) + as Building_WorkTable; + if (table == null) + { + Expect(false, "a stonecutter's table could be staged in the stand's room"); + return null; + } + Bill_Production bill = new Bill_Production(recipe); + table.BillStack.AddBill(bill); + return new BillStage + { + Corner = corner, + Table = table, + Bill = bill, + Giver = giver, + Crafting = crafting, + Chunk = chunkDef, + Blocks = recipe.products[0].thingDef, + }; + } + + /// The room's whole floor as an area, less . + internal static Area_Allowed PaintRoom(Fixture fix, BillStage stage, IntVec3 except) + { + List floor = new List(); + for (int x = 1; x <= 5; x++) + { + for (int z = 1; z <= 5; z++) + { + IntVec3 cell = stage.Corner + new IntVec3(x, 0, z); + if (cell != except) + { + floor.Add(cell); + } + } + } + return NewArea(fix, "Bill product", floor.ToArray()); + } + + /// A one-cell stockpile at critical priority, so the product goes there. + internal static Zone_Stockpile StageStockpile(Map map, IntVec3 cell) + { + Zone_Stockpile stockpile = new Zone_Stockpile(StorageSettingsPreset.DefaultStockpile, + map.zoneManager); + map.zoneManager.RegisterZone(stockpile); + stockpile.AddCell(cell); + stockpile.settings.Priority = StoragePriority.Critical; + return stockpile; + } + + /// + /// Starts a bill through the real driver from the crafter's own cell, with its chunk + /// already placed, so nothing walks: a pumped pawn's path requests go unanswered under the + /// stopped clock. Returns the driver, or null if the bill did not start. + /// + internal static JobDriver_DoBill StartBill(Fixture fix, BillStage stage) + { + Pawn pawn = fix.Pawn; + if (pawn.CurJob != null) + { + pawn.jobs.EndCurrentJob(JobCondition.InterruptForced, startNewJob: false); + } + pawn.jobs.ClearQueuedJobs(); + pawn.pather?.StopDead(); + pawn.Position = stage.Table.InteractionCell; + + Thing chunk = GenSpawn.Spawn(ThingMaker.MakeThing(stage.Chunk), + stage.Table.InteractionCell + IntVec3.North, fix.Map); + Job job = JobMaker.MakeJob(JobDefOf.DoBill, stage.Table); + job.bill = stage.Bill; + job.workGiverDef = stage.Giver; + job.haulMode = HaulMode.ToCellNonStorage; + job.placedThings = new List { new ThingCountClass(chunk, 1) }; + pawn.jobs.StartJob(job, JobCondition.None, null, resumeCurJobAfterwards: false, + cancelBusyStances: true, null, null); + + JobDriver_DoBill bench = pawn.jobs.curDriver as JobDriver_DoBill; + Expect(bench != null, "the bill started in uniform or out of it, as staged (control)"); + return bench; + } + + /// + /// Starts a bill and pumps it until the driver changes, cutting the recipe's work short. + /// runs once with the bill under way. + /// + internal static bool FinishBill(Fixture fix, BillStage stage, System.Action midBill = null) + { + Pawn pawn = fix.Pawn; + // Watch the driver, never the Job: jobs are pooled, drivers are not. + JobDriver_DoBill bench = StartBill(fix, stage); + if (bench == null) + { + return false; + } + midBill?.Invoke(); + for (int i = 0; i < 2000; i++) + { + if (pawn.jobs.curDriver != bench) + { + return true; + } + if (bench.workLeft > 1f) + { + bench.workLeft = 1f; + } + pawn.DoTick(); + } + return Expect(false, "the bill finished within the pump"); + } + + /// The crafter is hauling the stack in their hands, still on shift. + internal static bool KeptTheUniform(Fixture fix, BillStage stage) + { + Pawn pawn = fix.Pawn; + Thing carried = pawn.carryTracker.CarriedThing; + return Expect(pawn.CurJobDef == JobDefOf.HaulToCell, + "the haul starts, with no swap ahead of it (" + pawn.CurJobDef?.defName + ")") + & Expect(carried != null && carried.def == stage.Blocks, "with the stack in hand") + & Expect(fix.Comp.OnShift, "still on shift"); + } + + /// The swap started, the stack is down and the haul waits behind it. + internal static bool ChangedBackFirst(Fixture fix, BillStage stage) + { + Pawn pawn = fix.Pawn; + JobQueue queue = pawn.jobs.jobQueue; + return Expect(pawn.CurJobDef == ShiftChangeDefOf.ShiftChange_SwapAtStand, + "the crafter goes to change back first (" + pawn.CurJobDef?.defName + ")") + & Expect(queue.Count > 0 && queue[0].job.def == JobDefOf.HaulToCell, + "with the haul waiting behind the swap") + & Expect(pawn.carryTracker.CarriedThing == null + && fix.Map.listerThings.ThingsOfDef(stage.Blocks).Count > 0, + "and the stack on the ground, to fetch in their own clothes"); + } + } +} +#endif diff --git a/Source/ShiftChange/Interception/Patch_JobInterception.cs b/Source/ShiftChange/Interception/Patch_JobInterception.cs index ba4e3cd..ee3c7c8 100644 --- a/Source/ShiftChange/Interception/Patch_JobInterception.cs +++ b/Source/ShiftChange/Interception/Patch_JobInterception.cs @@ -445,6 +445,12 @@ internal static bool MealBreak(Job job, JobTag? tag, Pawn pawn, Pawn_JobTracker internal static bool StaysDressed(Job job, JobClass c, Pawn pawn, CompShiftStand onShift, JobPlace target, Map map) { + // The last step of work the stand serves, so only where it ends can send them back. + if (StoresServedBillProduct(job, pawn, onShift)) + { + bool named = false; + return PlaceCovered(job.targetB, onShift, map, ref named); + } // Changing back for a job this stand dresses for would only dress them again next job. // By work type, not kind: a delivery or meal run goes by its giver's work. WorkTypeDef work = c.Work; @@ -474,6 +480,25 @@ internal static bool StaysDressed(Job job, JobClass c, Pawn pawn, CompShiftStand && onShift.Covers(target.IsValid ? target : JobPlace.At(pawn.Position), map); } + /// + /// Whether this is the haul that stores a finished bill's product, for a bill whose work + /// the stand serves. The bill's last toil puts the stack in the pawn's hands and starts + /// the haul with no giver, so the haul has no work type, and the bill is still the current + /// job here. The stack in hand tells it from a haul that begins by walking to a stack. + /// + internal static bool StoresServedBillProduct(Job job, Pawn pawn, CompShiftStand stand) + { + if (job.def != JobDefOf.HaulToCell || job.workGiverDef != null) + { + return false; + } + Pawn_JobTracker jobs = pawn.jobs; + return jobs.curDriver is JobDriver_DoBill + && stand.HandlesWork(jobs.curJob.workGiverDef?.workType) + && job.targetA.Thing != null + && pawn.carryTracker?.CarriedThing == job.targetA.Thing; + } + /// The map is under threat, so no arm dresses anyone. internal static bool MapUnderThreat(Map map) { diff --git a/docs/DESIGN.md b/docs/DESIGN.md index b1c7352..f390598 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1035,6 +1035,22 @@ only for these (`StaysDressed`): work type first, then ordinary sleep (`IsOrdinarySleep`, which the sleep arm shares), then recreation. - **At a work stand, also pass-through work and the incidental jobs** (below). +- **At a work stand, also the haul that stores a served bill's product**, judged + at the stockpile alone (`StoresServedBillProduct`). A finished bill puts the + stack in the crafter's hands and starts a new `HaulToCell` that names no giver, + so the haul has no work type of its own. Read as any other job with no work + type, it would change the colonist back after each bill that stores its + product, with the stack dropped at the bench. It counts as the bill's last + step, so Hauling need not be ticked. The bill must be work the stand serves, + the bill must still be the current job as the haul starts, and the stack must + be in hand, which keeps every other giver-less haul out. The stack's own place + is left out on purpose: it is at the crafter's cell, and a player who painted + the bench and the stockpile but not the cell in front of the bench should not + lose the rule to that one cell. A stockpile outside the catchment changes them + back first, and the stack goes down at the bench for the walk to the stand, + unless served work is queued behind the haul ("Errands queued ahead of work"). + A room stand keeps its own rule, which reads the stack's place and never the + stockpile, so a room's crafter carries the product out of the door in uniform. - **At a recreation stand, also the incidental jobs**, pass-through work excepted. A swimmer wanders between pools in swimwear, but changes before hauling. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index bded8ac..01b83da 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -507,7 +507,7 @@ Each file here is guarded whole, by `SCENES`, `DEBUG` or `SCENES || HARNESS`, an |---|---| | `DebugTools_LifecycleHarness.cs` | `HARNESS` only. The test suite: the runner, its assertions, its self-check, and the list that registers every case in the order they run. The other cases' bodies, which drive real engine entry points, live in the files beside it — see [TESTING.md](TESTING.md). Its debug-menu entry, `RunHarness`, a node in `DebugTools_Menu`'s Dev tools submenu, is `SCENES` on top of that. | | `DebugTools_SaveRoundTrip.cs` | `HARNESS` only. The four save/load cases. Separate file because they replace `Current.Game` and must run last. | -| `HarnessCatchments.cs` | `HARNESS` only. The catchment cases, one `partial` class: `.Radius` for coverage and dressing in a ring, `.ReturnTrip`, `.Deliveries`, `.Area`, `.AreaUi` for the alert and the Serves menu, and `.Latch`. The main file holds the cases true in every mode and the helpers several files share. | +| `HarnessCatchments.cs` | `HARNESS` only. The catchment cases, one `partial` class: `.Radius` for coverage and dressing in a ring, `.ReturnTrip`, `.BillProduct` for the haul that stores a finished bill's product, `.Deliveries`, `.Area`, `.AreaUi` for the alert and the Serves menu, and `.Latch`. The main file holds the cases true in every mode and the helpers several files share. | | `HarnessVacuum.cs` | `HARNESS` only. The vacuum cases, on the second map, which is a vacuum map: the ground they stand on, then a staged ship of a room, open floor and a second room, and the cases a review of the gate added (each dress path, doors, a stranded colonist, the cooldown, an urgent patient, a stand on another map). | | `HarnessJobKinds.cs` | `HARNESS` only. The job classifier as a table: every job shape the other cases build, and the kind and work type it must get. | | `HarnessFixtureChecks.cs` | `HARNESS` only. What every case takes for granted about its fixture: a colonist who needs no doctor and whose needs hold still under the stopped clock, and open ground past the pad. Registered first. | diff --git a/docs/TESTING.md b/docs/TESTING.md index fc26a28..c9af316 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -363,7 +363,15 @@ Staying dressed goes by what the stand is for, with a case per trigger (`RadiusWorkStandKeepsTheUniformInItsRing`, `RadiusSleepStandKeepsItOnlyForBed`, `RadiusRecreationStandKeepsItForPlay`). Pass-through work is judged at both ends, and an errand ahead of the stand's -own work rides along (`PassThroughWorkIsJudgedAtBothEnds`). The two lists that +own work rides along (`PassThroughWorkIsJudgedAtBothEnds`). A finished bill's +product is driven through the real `DoBill` at an area, a radius and a room +stand, with the stockpile inside and outside, and Hauling ticked and not +(`BillProductHaul`). It is judged at the stockpile and not at the crafter's cell +(`BillProductIsJudgedAtTheStockpile`), goes by the ordinary rule once the stand +stops serving the bill (`BillProductOfUnservedWorkChangesThemBack`), needs the +stack in hand and a bill behind it (`BillProductMustBeInHand`, +`BillProductMustComeFromABill`), and sends nobody to dress +(`BillProductDressesNobody`). The two lists that decide it are checked as tables, with no map (`PassThroughAndIncidentalTablesHold`). Each case that dresses its colonist then asserts that the job tracker's pause after the swap did not send them From 1f20ef01ae270a95481f75265c4ddf8514e90275 Mon Sep 17 00:00:00 2001 From: MrBeverage Date: Fri, 6 Nov 2026 13:57:50 +0100 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=93=9D=20Draft=20the=20v1.5.5=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 --- CHANGELOG.md | 16 +++++++++++++++- media/changelog-v1.5.5.bbcode | 4 ++++ media/comment-v1.5.5.txt | 5 +++++ 3 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 media/changelog-v1.5.5.bbcode create mode 100644 media/comment-v1.5.5.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 2348ce4..5133165 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ posted on the Steam Workshop for that version, sorted into Added, Changed, Remov ## [Unreleased] +## [1.5.5] - 2026-11-06 + +### Fixed + +- A crafter on shift at a radius or area stand changed out of uniform to store each bill's product, + even with the stockpile inside the ring or the area. They left the product at the bench, walked + to the stand, came back for it in their own clothes and dressed again for the next bill. They now + carry it to that stockpile in uniform, whether or not Hauling is ticked. Reported by a player. + +Unchanged: a stockpile past the edge of the ring or the area still changes them back first, and +they leave the product at the bench to do it. Bills set to drop on the floor work as before, and +so do room stands. + ## [1.5.4] - 2026-10-29 ### Added @@ -741,7 +754,8 @@ The first release. It needs Odyssey, for the outfit stand, and Harmony. - A Change back button on any colonist in uniform takes them out of it at once. - One setting, Unassigned stands are shared, on by default. -[Unreleased]: https://github.com/beverage/shift-change/compare/v1.5.4...HEAD +[Unreleased]: https://github.com/beverage/shift-change/compare/v1.5.5...HEAD +[1.5.5]: https://github.com/beverage/shift-change/compare/v1.5.4...v1.5.5 [1.5.4]: https://github.com/beverage/shift-change/compare/v1.5.3...v1.5.4 [1.5.3]: https://github.com/beverage/shift-change/compare/v1.5.2...v1.5.3 [1.5.2]: https://github.com/beverage/shift-change/compare/v1.5.1...v1.5.2 diff --git a/media/changelog-v1.5.5.bbcode b/media/changelog-v1.5.5.bbcode new file mode 100644 index 0000000..520edde --- /dev/null +++ b/media/changelog-v1.5.5.bbcode @@ -0,0 +1,4 @@ +[list] +[*]Fixed: a crafter on shift at a radius or area stand changed out of uniform to store each bill's product, even with the stockpile inside the ring or the area. They left the product at the bench, walked to the stand, came back for it in their own clothes and dressed again for the next bill. They now carry it to that stockpile in uniform, whether or not Hauling is ticked. Reported by a player. +[*]Unchanged: a stockpile past the edge of the ring or the area still changes them back first, and they leave the product at the bench to do it. Bills set to drop on the floor work as before, and so do room stands. +[/list] diff --git a/media/comment-v1.5.5.txt b/media/comment-v1.5.5.txt new file mode 100644 index 0000000..e0dd34d --- /dev/null +++ b/media/comment-v1.5.5.txt @@ -0,0 +1,5 @@ +v1.5.5 update: + +Fixed: a crafter at a radius or area stand no longer changes out of uniform to store a bill's product in a stockpile inside the ring or the area, whether or not Hauling is ticked. + +A stockpile past the edge still changes them back first, and room stands work as before.