diff --git a/CHANGELOG.md b/CHANGELOG.md index 1761cb0..eea56ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,25 @@ posted on the Steam Workshop for that version, sorted into Added, Changed, Remov ## [Unreleased] +### Added + +- A mod setting, **New stands are used for shift changes**, on by default. Turn it off and every + outfit stand built from then on starts as "Not used for shift changes": ordinary furniture + until you set it up with its switch. Stands that already exist keep their own setting, whichever + way it is set. +- Hovering a stand's switch or its Set owner button now marks that stand on the map with the + pulsing square vanilla shows on a target you are about to pick. An arrow points to it as well, + as it does when you hover a storage link. With several stands selected, each stand shows its + own pair of buttons, and until now nothing on them said which stand they belong to. + ### Fixed +- The description of **Unassigned stands are shared** said that with it off, an unassigned stand + behaves exactly like vanilla furniture. It does not: it dresses nobody, but + "Allow removing items" stays off and its contents stay out of trade. The description now says + so, and points at "Not used for shift changes" for a stand that should be ordinary furniture. + How the setting works is unchanged. + - If a stand's owner was kidnapped while the stand was boxed up for a move, setting it down made them its owner again and kept the shift they had open at it, so nobody else could use it until they came home. A kidnapped owner is now dropped when the stand is placed, as they already were diff --git a/Languages/English/Keyed/ShiftChange.xml b/Languages/English/Keyed/ShiftChange.xml index b2b2378..e0bd768 100644 --- a/Languages/English/Keyed/ShiftChange.xml +++ b/Languages/English/Keyed/ShiftChange.xml @@ -146,8 +146,10 @@ {0} is heading through vacuum in work clothes that protect them less than their own. Their own clothes are across vacuum too, so they will not go back to change first. {0} is heading through vacuum in work clothes that protect them less than their own, and their own clothes are not on this map. + New stands are used for shift changes + When on, a newly built outfit stand reads its room and dresses whoever comes to work, relax or sleep there.\n\nWhen off, a newly built stand starts as 'Not used for shift changes' and stays ordinary furniture until you set it up with its switch. Use this when most of your stands are for storage or display.\n\nOnly stands built after you change this are affected. Stands that already exist keep their own setting. Unassigned stands are shared - When on, an outfit stand with no assigned owner may be used by any colonist about to do what it serves.\n\nWhen off, only stands with an explicit owner list ever dress anyone, and every unassigned stand behaves exactly like vanilla furniture. + When on, an outfit stand with no assigned owner may be used by any colonist about to do what it serves.\n\nWhen off, only stands with an explicit owner list ever dress anyone. An unassigned stand still counts as a shift stand, though: 'Allow removing items' stays off, and its contents stay out of trade if 'Keep contents out of trade' is ticked. To make a stand ordinary furniture, set it to 'Not used for shift changes'. Keep colonists decent Off by default, so nothing changes in a colony you are already playing.\n\nTurn it on and a stand will not leave a colonist without basic clothing: if what it hands out does not cover them, their innermost garments stay on and the rest of the swap happens as normal. That mostly matters with Change the whole outfit ticked, where a stand holding only a hat or a belt would otherwise send someone out with nothing else on.\n\nNudists, and anyone whose ideoligion prefers nudity, are exempt either way. Medical emergencies change first diff --git a/README.md b/README.md index b70abf4..7e44c0d 100644 --- a/README.md +++ b/README.md @@ -373,10 +373,17 @@ stands are the mechanized ones from their own, raid or no raid, but "on their own next job" can be a long time if the job they just started was sleep. -Three mod settings, under Options → Mod settings: +Four mod settings, under Options → Mod settings: +- **New stands are used for shift changes**, on by default. Turn it off and + every stand built from then on starts as "Not used for shift changes", so + stands kept for storage or display need nothing done to them, and you switch + on only the ones you want. Stands already built keep their own setting. - **Unassigned stands are shared**, on by default. Turn it off and only stands - with an explicit owner list ever dress anyone. + with an explicit owner list ever dress anyone. An unassigned stand is still a + shift stand, though: "Allow removing items" stays off and its contents stay + out of trade. "Not used for shift changes" is what makes a stand ordinary + furniture. - **Keep colonists decent**, off by default, described further up. - **Medical emergencies change first**, off by default. On, a doctor answering an emergency tend puts scrubs on first and a colonist in critical condition diff --git a/Source/ShiftChange/Dev/DebugTools_Fixtures.cs b/Source/ShiftChange/Dev/DebugTools_Fixtures.cs index ac54461..510c290 100644 --- a/Source/ShiftChange/Dev/DebugTools_Fixtures.cs +++ b/Source/ShiftChange/Dev/DebugTools_Fixtures.cs @@ -21,10 +21,17 @@ internal static class DebugTools_Fixtures /// The green tint of the researcher's duster. internal static readonly Color DusterGreen = new Color(0.62f, 0.9f, 0.7f); - /// Spawns a player-faction thing with no style and graphic variant 0. + /// Spawns a player-faction thing with no style and graphic variant 0. A stand + /// starts in service whatever the new-stand setting says, so a fixture or a stage does not + /// depend on the profile it runs in. internal static Thing Spawn(Map map, ThingDef def, ThingDef stuff, IntVec3 cell, Rot4 rot) { Thing thing = ThingMaker.MakeThing(def, stuff); + CompShiftStand stand = thing.TryGetComp(); + if (stand != null) + { + stand.excluded = false; + } thing.SetFactionDirect(Faction.OfPlayer); thing.SetStyleDef(null); thing.overrideGraphicIndex = 0; diff --git a/Source/ShiftChange/Harness/DebugTools_LifecycleHarness.cs b/Source/ShiftChange/Harness/DebugTools_LifecycleHarness.cs index d7383d2..7318568 100644 --- a/Source/ShiftChange/Harness/DebugTools_LifecycleHarness.cs +++ b/Source/ShiftChange/Harness/DebugTools_LifecycleHarness.cs @@ -238,6 +238,9 @@ internal static bool Run(Map map, IntVec3 origin, bool toast) Case(map, pad, "the medical emergency setting covers doctors and patients, never fires", (m, p) => Stage(m, p, StageKit.Displacing, enclose: true), MedicalEmergencySettingCoversTheRightWork); + Case(map, pad, "the new-stand setting decides what a stand made from then on starts as", + (m, p) => Stage(m, p, StageKit.Displacing, enclose: true), + NewStandSettingDecidesOnlyNewStands); Case(map, pad, "the job classifier sorts every job shape in the arms' order", (m, p) => Stage(m, p, StageKit.Displacing, enclose: true), HarnessJobKinds.ClassifierSortsEveryJobShape); @@ -422,6 +425,8 @@ internal static bool Run(Map map, IntVec3 origin, bool toast) (m, p) => Stage(m, p, StageKit.Displacing, enclose: true), SwitchNamesWhatTheStandServes); Case(map, pad, "selecting several stands shows each stand's own buttons", (m, p) => Stage(m, p, StageKit.Displacing, enclose: true), StandButtonsStaySeparate); + Case(map, pad, "hovering a stand's button marks that stand and no other", + (m, p) => Stage(m, p, StageKit.Displacing, enclose: true), HoveredButtonsMarkTheirOwnStand); Case(map, pad, "fishing dresses at a stand ticked for it, and at a radius stand by the shore", (m, p) => StageWithDoorway(m, p, DefDatabase.GetNamedSilentFail("Fishing")), FishingDressesAtAStandTickedForIt); diff --git a/Source/ShiftChange/Harness/DebugTools_SaveRoundTrip.cs b/Source/ShiftChange/Harness/DebugTools_SaveRoundTrip.cs index 92b3a8e..af8b1da 100644 --- a/Source/ShiftChange/Harness/DebugTools_SaveRoundTrip.cs +++ b/Source/ShiftChange/Harness/DebugTools_SaveRoundTrip.cs @@ -211,7 +211,18 @@ internal static bool RoundTrip(Map map, CellRect pad) && saved.Contains("9"), "the save carries the catchment under its prefixed keys"); - SavedGameLoaderNow.LoadGameFromSaveFileNow(SaveName); + // Loaded with the new-stand setting off, which must not reach a stand that already + // exists: the setting is read only as a stand is made. + bool newStandsWas = ShiftChangeMod.Settings.newStandsInService; + ShiftChangeMod.Settings.newStandsInService = false; + try + { + SavedGameLoaderNow.LoadGameFromSaveFileNow(SaveName); + } + finally + { + ShiftChangeMod.Settings.newStandsInService = newStandsWas; + } // Positive control for the absence assertions, logged after the load so that a load // which cleared the queue could not hide it. @@ -265,7 +276,8 @@ internal static bool RoundTrip(Map map, CellRect pad) "and the load put it back in the registry") & DebugTools_LifecycleHarness.Expect( !ledger.IsExcluded, - "the loaded stand is in service (control for the sweep below)") + "the loaded stand is in service though the new-stand setting was off " + + "(and the control for the sweep below)") & DebugTools_LifecycleHarness.Expect( Patch_AllowRemovingToggle.AllowRemovingItemsRef != null && !Patch_AllowRemovingToggle.AllowRemovingItemsRef(loaded), diff --git a/Source/ShiftChange/Harness/HarnessGizmos.cs b/Source/ShiftChange/Harness/HarnessGizmos.cs index 1693c95..577020f 100644 --- a/Source/ShiftChange/Harness/HarnessGizmos.cs +++ b/Source/ShiftChange/Harness/HarnessGizmos.cs @@ -1,8 +1,11 @@ // 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 System.Linq; +using HarmonyLib; using RimWorld; +using UnityEngine; using Verse; using static ShiftChange.DebugTools_LifecycleHarness; using static ShiftChange.HarnessFixtures; @@ -10,8 +13,9 @@ namespace ShiftChange { /// - /// Cases for the stand's buttons: what the switch says, and that selecting several stands keeps - /// each stand's buttons its own. Read from the comps' gizmos, never drawn. + /// Cases for the stand's buttons: what the switch says, that selecting several stands keeps + /// each stand's buttons its own, and that hovering one marks its stand. Read from the comps' + /// gizmos and from what vanilla queues to draw, never from the screen. /// internal static class HarnessGizmos { @@ -117,6 +121,87 @@ internal static bool StandButtonsStaySeparate(Fixture fix) && !myOwner.GroupsWith(theirOwner) && !theirOwner.GroupsWith(myOwner), "and those stay separate too"); } + + /// + /// Hovering a stand's switch or Set owner button marks that stand and no other, so with + /// several stands selected a player can find whose button is whose. The arrow is read + /// from vanilla's list, which the next frame draws and empties. The square is read from + /// the targeting material, whose colour vanilla rewrites each time it hands it out to + /// draw, so a colour set beforehand changes only if the square was drawn. A direct call + /// for the other stand is the control that both are read right. + /// + internal static bool HoveredButtonsMarkTheirOwnStand(Fixture fix) + { + ThingDef standDef = DefDatabase.GetNamedSilentFail("Building_OutfitStand"); + List arrows = AccessTools.Field(typeof(TargetHighlighter), "arrowPositions") + ?.GetValue(null) as List; + Material square = GenDraw.CurTargetingMat; + if (!Expect(standDef != null, "the stand def resolves") + || !Expect(arrows != null, "vanilla's arrow list resolves") + || !Expect(fix.Map == Find.CurrentMap, "the fixture's map is the current map (control)")) + { + return false; + } + Building_OutfitStand other = DebugTools_Fixtures.Spawn(fix.Map, standDef, ThingDefOf.WoodLog, + fix.Stand.Position + new IntVec3(2, 0, 0), Rot4.North) as Building_OutfitStand; + CompShiftStand otherComp = other?.TryGetComp(); + if (otherComp == null) + { + return Expect(false, "a second stand could be staged"); + } + + void Reset() + { + arrows.Clear(); + // Vanilla hands the square out opaque, never clear. + square.color = Color.clear; + } + bool Squared() + { + return square.color != Color.clear; + } + bool Marks(Thing stand) + { + return arrows.Any(a => (a - stand.DrawPos).Yto0().magnitude < 0.6f); + } + string Seen() + { + return " (" + arrows.Count + " arrows, square " + (Squared() ? "drawn" : "not drawn") + ")"; + } + bool OnlyMarks(Command_Action button, Thing mine, Thing theirs, string name) + { + Reset(); + if (!Expect(button?.onHover != null, name + " has a hover action")) + { + return false; + } + button.onHover(); + return Expect(Marks(mine) && !Marks(theirs) && Squared(), + name + " marks its own stand and not the other" + Seen()); + } + + Reset(); + bool ok = Expect(!Squared(), "the square's material keeps a colour set on it (control)"); + CompShiftStand.PointAt(other); + ok = ok + & Expect(Marks(other) && !Marks(fix.Stand) && Squared(), + "a direct call for the second stand marks that stand only (control)" + Seen()) + & OnlyMarks(Switch(fix.Comp), fix.Stand, other, "the first stand's switch") + & OnlyMarks(Switch(otherComp), other, fix.Stand, "the second stand's switch") + & OnlyMarks(OwnerButton(fix.Stand), fix.Stand, other, "the first stand's Set owner") + & OnlyMarks(OwnerButton(other), other, fix.Stand, "the second stand's Set owner"); + + // Vanilla keeps calling the last hovered button while another main tab is open, so a + // button can outlive its stand. + Command_Action stale = Switch(otherComp); + other.Destroy(); + Reset(); + stale?.onHover?.Invoke(); + ok &= Expect(stale != null && arrows.Count == 0 && !Squared(), + "a button kept from a destroyed stand marks nothing" + Seen()); + arrows.Clear(); + return ok; + } } } #endif diff --git a/Source/ShiftChange/Harness/HarnessTriggers.cs b/Source/ShiftChange/Harness/HarnessTriggers.cs index 621dd6a..d0ecc40 100644 --- a/Source/ShiftChange/Harness/HarnessTriggers.cs +++ b/Source/ShiftChange/Harness/HarnessTriggers.cs @@ -504,6 +504,51 @@ internal static bool MedicalEmergencySettingCoversTheRightWork(Fixture fix) "the setting was restored for the cases that follow"); } + /// + /// The new-stand setting decides what a stand made from then on starts as, through + /// ThingMaker.MakeThing, the call a finished frame makes. A stand made with it off + /// spawns as vanilla furniture; the fixture's stand, made before the flip, stays in + /// service (control). That a load never applies it is asserted in + /// . + /// + internal static bool NewStandSettingDecidesOnlyNewStands(Fixture fix) + { + bool was = ShiftChangeMod.Settings.newStandsInService; + bool ok = Expect(was, "the setting ships on, which is the default under test"); + try + { + CompShiftStand on = (ThingMaker.MakeThing(fix.Stand.def, ThingDefOf.WoodLog) + as ThingWithComps)?.TryGetComp(); + ok &= Expect(on != null && on.IsAutomatic, "with it on a new stand starts automatic"); + + ShiftChangeMod.Settings.newStandsInService = false; + Building_OutfitStand stand = + ThingMaker.MakeThing(fix.Stand.def, ThingDefOf.WoodLog) as Building_OutfitStand; + CompShiftStand off = stand?.TryGetComp(); + if (off == null || Patch_AllowRemovingToggle.AllowRemovingItemsRef == null) + { + return ok & Expect(false, "a stand and its removal flag could be made with it off"); + } + ok &= Expect(off.IsExcluded, "with it off a new stand starts as not used for shift changes"); + + // Spawned with the removal flag on: a stand in service would force it off here. + stand.SetFactionDirect(Faction.OfPlayer); + Patch_AllowRemovingToggle.AllowRemovingItemsRef(stand) = true; + GenSpawn.Spawn(stand, fix.Stand.Position + new IntVec3(2, 0, 0), fix.Map, Rot4.North); + ok &= Expect(off.IsExcluded, "and spawning it keeps it so") + & Expect(Patch_AllowRemovingToggle.AllowRemovingItemsRef(stand), + "its Allow removing items stays as set, as on a vanilla stand") + & Expect(!off.BlocksTrade, "and its contents can be traded") + & Expect(!fix.Comp.IsExcluded, "while the stand made before the flip is still in service"); + } + finally + { + ShiftChangeMod.Settings.newStandsInService = was; + } + return ok & Expect(ShiftChangeMod.Settings.newStandsInService, + "the setting was restored for the cases that follow"); + } + /// /// Deposit only: a colonist parks what the stand's filter accepts, keeps the rest of their /// clothes on, and gets it all back on the return trip. The stand must be claimed at all: diff --git a/Source/ShiftChange/Ownership/CompAssignableToPawn_ShiftStand.cs b/Source/ShiftChange/Ownership/CompAssignableToPawn_ShiftStand.cs index 81638ef..db17c23 100644 --- a/Source/ShiftChange/Ownership/CompAssignableToPawn_ShiftStand.cs +++ b/Source/ShiftChange/Ownership/CompAssignableToPawn_ShiftStand.cs @@ -384,7 +384,8 @@ protected override bool CanSetUninstallAssignedPawn(Pawn pawn) } /// - /// The base's Set owner gizmo with its hotkey stripped and its window swapped for ours. + /// The base's Set owner gizmo with its hotkey stripped, its window swapped for ours, and + /// the stand marked on the map while it is hovered. /// hides other mods' own, so this is the only one. /// public override IEnumerable CompGetGizmosExtra() @@ -409,6 +410,13 @@ public override IEnumerable CompGetGizmosExtra() // and a merged button opens every stand's dialog, each closing the one // before, so it edits one list while looking as if it edits all. assign.groupable = false; + // Chained, not replaced: a hover another mod put on the base gizmo still runs. + System.Action hovered = assign.onHover; + assign.onHover = () => + { + hovered?.Invoke(); + CompShiftStand.PointAt(parent); + }; } yield return gizmo; } diff --git a/Source/ShiftChange/ShiftChangeMod.cs b/Source/ShiftChange/ShiftChangeMod.cs index 3cdb98f..604cc12 100644 --- a/Source/ShiftChange/ShiftChangeMod.cs +++ b/Source/ShiftChange/ShiftChangeMod.cs @@ -5,10 +5,20 @@ namespace ShiftChange { public class ShiftChangeSettings : ModSettings { + /// + /// When true (the default), a newly made stand starts in service, reading its room. When + /// false it starts as "Not used for shift changes". Read once, as the stand is made + /// (), so stands that already exist keep their + /// own setting whichever way this is flipped. + /// + public bool newStandsInService = true; + /// /// When true (the default), an unassigned stand in a work room is a pool stand that any /// capable colonist may claim. When false, only a stand assigned to a colonist dresses - /// anyone, and every unassigned stand behaves like vanilla furniture. + /// anyone. An unassigned stand still counts as in service, so it keeps the removal toggle + /// off and its contents out of trade; only "Not used for shift changes" makes a stand + /// vanilla furniture. /// public bool poolUnassignedStands = true; @@ -44,6 +54,7 @@ public class ShiftChangeSettings : ModSettings public override void ExposeData() { base.ExposeData(); + Scribe_Values.Look(ref newStandsInService, "newStandsInService", defaultValue: true); Scribe_Values.Look(ref poolUnassignedStands, "poolUnassignedStands", defaultValue: true); Scribe_Values.Look(ref keepColonistsDecent, "keepColonistsDecent", defaultValue: false); Scribe_Values.Look(ref medicalEmergenciesChangeFirst, @@ -60,6 +71,12 @@ public class ShiftChangeMod : Mod public static ShiftChangeSettings Settings => settings; + /// + /// The read point for . Unloaded + /// settings read as on, its default, as for . + /// + public static bool NewStandsInService => Settings == null || Settings.newStandsInService; + /// /// The read point for . /// Null-tolerant, so a read before the settings load cannot throw; it gives the default, @@ -97,6 +114,10 @@ public override void DoSettingsWindowContents(Rect inRect) { Listing_Standard listing = new Listing_Standard(); listing.Begin(inRect); + listing.CheckboxLabeled( + "ShiftChange.SettingNewStandsInService".Translate(), + ref Settings.newStandsInService, + "ShiftChange.SettingNewStandsInServiceDesc".Translate()); listing.CheckboxLabeled( "ShiftChange.SettingPoolUnassigned".Translate(), ref Settings.poolUnassignedStands, diff --git a/Source/ShiftChange/Stand/CompShiftStand.Ui.cs b/Source/ShiftChange/Stand/CompShiftStand.Ui.cs index c037170..f17dba1 100644 --- a/Source/ShiftChange/Stand/CompShiftStand.Ui.cs +++ b/Source/ShiftChange/Stand/CompShiftStand.Ui.cs @@ -192,7 +192,26 @@ public override IEnumerable CompGetGizmosExtra() // looking as if it set them all. groupable = false, action = () => Find.WindowStack.Add(new Dialog_SetStandWorkTypes(this)), + onHover = () => PointAt(parent), }; } + + /// + /// Marks a stand on the map while one of its buttons is hovered: with several stands + /// selected their buttons sit side by side, and none of them says where its stand is. + /// + internal static void PointAt(Thing stand) + { + // Vanilla's arrow sits just below a stand near the screen centre, pointing up at it; + // further out it points from the centre towards it, so the square marks the stand. + TargetHighlighter.Highlight(stand, arrow: true, colonistBar: false); + if (stand.Map != Find.CurrentMap) + { + return; + } + // The targeter's square sits above buildings; the highlighter's circle hides under the + // sprite. Hover runs in the map interface's update, so it shows this frame. + GenDraw.DrawTargetHighlight(stand); + } } } diff --git a/Source/ShiftChange/Stand/CompShiftStand.cs b/Source/ShiftChange/Stand/CompShiftStand.cs index 8bad99b..956f873 100644 --- a/Source/ShiftChange/Stand/CompShiftStand.cs +++ b/Source/ShiftChange/Stand/CompShiftStand.cs @@ -190,6 +190,21 @@ public static CompShiftStand OnShiftStandFor(Pawn pawn) return comp; } + /// Applies the new-stand setting. ThingMaker.MakeThing is the only caller, + /// a finished frame included; a load rebuilds the comp without it and reads + /// back from the save, so a stand that already exists never + /// changes when the setting does. + public override void PostPostMake() + { + base.PostPostMake(); + if (!ShiftChangeMod.NewStandsInService) + { + // The field alone: every other setting is still at its default here, and the + // stand is not spawned, so SetExcluded's catchment reset has nothing to do. + excluded = true; + } + } + public override void PostSpawnSetup(bool respawningAfterLoad) { base.PostSpawnSetup(respawningAfterLoad); diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 9bedca5..b1c7352 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -450,7 +450,16 @@ gizmo on load. The cost is discipline about touching a shared building. **Unassigned means pooled.** Any capable colonist may claim a free stand, the bed model, and the answer to "a kitchen has eight possible cooks": you need one stand per *concurrent* cook. An assigned stand is reserved for its owner. A mod setting -(default on) turns pooling off globally. +(default on) turns pooling off globally. That only stops unassigned stands +dressing anyone: they are still declared in service, so the removal toggle and +trade withholding, which key on the declaration, still apply. + +**Whether a new stand starts in service is a mod setting** (default on). It is +read once, in `CompShiftStand.PostPostMake`, which `ThingMaker.MakeThing` alone +calls, a finished frame included. A load rebuilds the comp without that call and +reads `excluded` back from the save, so flipping the setting never changes a +stand that already exists. A player with many storage or display stands sets +it once instead of opening each stand's settings. **One owner list per stand, and it is ours.** A stand can carry a second `CompAssignableToPawn`: Outfit Stands Plus adds one, and nothing stops another mod @@ -849,6 +858,24 @@ one set ticked across stands in different rooms would overwrite each room's own reading with a custom set — the clobbering that kept stand settings off the copy and paste buttons. +Unmerged buttons leave a selection of stands showing a row of pairs, and +nothing on a pair says which stand it belongs to. So hovering either button +marks its stand, from the command's `onHover`, which runs every frame the +button is hovered, in the map interface's update. Both marks are vanilla's. +The arrow is `TargetHighlighter.Highlight`, as a storage link or an ITab's +contents use it. Within nine cells of the screen centre it sits just below the +target, pointing up at it; further out it is drawn seven cells from the centre, +pointing towards the target, which among thirty stands names a direction and +not a stand. So the stand also gets `GenDraw.DrawTargetHighlight`, the pulsing +square a targeter draws on the thing under the cursor, above the building +layer. + +Two marks that look like the answer do not work here. The highlighter's own +circle is drawn at the target's altitude with the footprint's radius, so on a +one-cell stand the sprite and the selection brackets cover all but a few +pixels of it. A line ring drawn above the stand shows, but the line texture is +faint, and a selection of radius stands is already covered in rings. + The trigger is the **job**, not the doorway: work-type-in-set AND job-target-in-room. A doctor crossing the hospital to reach the storeroom, or anyone walking in to eat, changes nothing.