DM Placeables Tool#1958
Conversation
/dmtools now opens a placeable menu built off the existing player housing menu. Comes with a save/load feature.
Added necessary lines back into the module.ifo.json to stop it from packing corrupted .mod files.
This reverts commit 5d81947.
Trying to patch anything that might cause issues
30 layouts per area (shared across al DMs) and a 5 second cooldown to avoid spamming the server with requests.
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds a DM Placeable Tools feature: new entity types for persisting area placeable layouts, a DM-only chat command to open a GUI, DM-spawn metadata tagging, a GUI window definition, and a server-side view-model implementing discovery, live editing, pagination/search, and layout save/load/delete workflows. Changes
Sequence Diagram(s)sequenceDiagram
participant DM as DM Player
participant Chat as ChatCommand
participant GUI as DMTools Window
participant VM as DMToolsViewModel
participant Area as Game Area
participant DB as Database
DM->>Chat: /plctools
Chat->>GUI: Toggle open
GUI->>VM: Initialize
VM->>Area: Scan for DM_SPAWNED_PLACEABLE objects
VM->>DB: Load layouts for area
DB-->>VM: Return layouts
VM-->>GUI: Populate placeable list & layout toggles
DM->>GUI: Search / Page / Select / Edit actions
GUI->>VM: Action callbacks
VM->>Area: Highlight / Move / Rotate / Delete / Spawn placeables
VM->>DB: Save / Load / Delete layout records
VM-->>GUI: Update UI (counts, instructions)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@SWLOR.Game.Server/Feature/GuiDefinition/ViewModel/DMToolsViewModel.cs`:
- Around line 497-548: The code stamps DM_SPAWNED_BY* from Player directly which
attributes spawns to a possessed NPC; change the dmId/dmName assignment to
resolve the real DM first by checking GetIsDMPossessed(Player) and, if true,
calling GetMaster(Player) and using that master (if valid) for
GetObjectUUID/GetName; otherwise fall back to Player—update references around
dmId/dmName initialization (where GetObjectUUID(Player) and GetName(Player) are
used) to implement this resolution.
- Around line 420-455: Trim LayoutName at the start and use the trimmed value
for validation, comparisons, searches and when creating/saving a new
DMAreaPlaceableLayout to avoid near-duplicates; specifically, replace uses of
LayoutName with a trimmed variable (e.g., trimmedName = LayoutName?.Trim()),
check string.IsNullOrWhiteSpace(trimmedName), use trimmedName when matching
existing.Name (StringComparison.OrdinalIgnoreCase) and when assigning Name on a
new DMAreaPlaceableLayout, and ensure the area lookup (areaQuery) and
MaxLayoutsPerArea check operate against trimmedName to prevent saving names that
differ only by trailing/leading whitespace.
- Around line 588-597: Capture the selected layout values into locals before
calling ShowModal so the confirmation text and deletion use the same snapshot;
e.g., read LayoutName into a local (e.g., selectedName) and
_layoutIds[_selectedLayoutIndex] into a local (e.g., selectedId) immediately
before invoking ShowModal, then use those locals inside the modal confirmation
callback and the message instead of referencing LayoutName or
_selectedLayoutIndex/_layoutIds at callback time (affects ShowModal call,
LayoutName usage, _layoutIds/_selectedLayoutIndex lookup, and
DB.Delete<DMAreaPlaceableLayout> invocation).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 9ca9483f-af72-4930-a26a-d109bbf89067
📒 Files selected for processing (5)
SWLOR.Game.Server/Entity/DMAreaPlaceableLayout.csSWLOR.Game.Server/Feature/ChatCommandDefinition/DMChatCommand.csSWLOR.Game.Server/Feature/DMActions.csSWLOR.Game.Server/Feature/GuiDefinition/DMToolsDefinition.csSWLOR.Game.Server/Feature/GuiDefinition/ViewModel/DMToolsViewModel.cs
I forgot the delete button. Surgically delete placeables rather than relying on the DMFI tools. (Can only delete spawned placeables)
There was a problem hiding this comment.
🧹 Nitpick comments (2)
SWLOR.Game.Server/Feature/GuiDefinition/ViewModel/DMToolsViewModel.cs (2)
734-745: "Save Changes" button name may be misleading.The button updates the instruction text with current coordinates but doesn't persist anything—position/facing changes are already applied live by the individual nudge buttons. Consider renaming to "Show Position" or "Confirm Changes" to clarify that layout-level persistence requires "Save Layout."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@SWLOR.Game.Server/Feature/GuiDefinition/ViewModel/DMToolsViewModel.cs` around lines 734 - 745, The "Save Changes" handler name is misleading because it only updates the on-screen position/facing text rather than persisting layout; rename the method OnSaveChanges to a clearer name (e.g., OnShowPosition or OnConfirmChanges) and update all references/bindings (the UI button that triggers this action) to use the new name, while keeping the existing logic that reads GetSelectedPlaceable(), GetPosition(placeable), GetFacing(placeable), SelectedPlaceableIndex and _placeableDisplayNames and sets Instructions/InstructionColor; also update the button label in the view to "Show Position" or "Confirm Changes" to match the new method name.
477-487: Cooldown uses int truncation for Unix timestamp.The cooldown stores Unix timestamps as
intviaSetLocalInt/GetLocalInt. This works correctly today but will overflow after 2038 (Y2038 problem). This is likely acceptable given NWN's local variable system only supports 32-bit integers.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@SWLOR.Game.Server/Feature/GuiDefinition/ViewModel/DMToolsViewModel.cs` around lines 477 - 487, The code currently casts the 64-bit Unix timestamp to int which risks Y2038 overflow; read the stored value with GetLocalInt into a long (e.g. long nextAllowedLoad = (long)GetLocalInt(Player, NextLayoutLoadTimestampVariable)), compare using long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), and when setting use a clamp before calling SetLocalInt: compute long target = now + LayoutLoadCooldownSeconds and if target > int.MaxValue set the stored value to int.MaxValue else cast to (int)target; keep references to NextLayoutLoadTimestampVariable, LayoutLoadCooldownSeconds, GetLocalInt, and SetLocalInt, and preserve the Instruction/InstructionColor logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@SWLOR.Game.Server/Feature/GuiDefinition/ViewModel/DMToolsViewModel.cs`:
- Around line 734-745: The "Save Changes" handler name is misleading because it
only updates the on-screen position/facing text rather than persisting layout;
rename the method OnSaveChanges to a clearer name (e.g., OnShowPosition or
OnConfirmChanges) and update all references/bindings (the UI button that
triggers this action) to use the new name, while keeping the existing logic that
reads GetSelectedPlaceable(), GetPosition(placeable), GetFacing(placeable),
SelectedPlaceableIndex and _placeableDisplayNames and sets
Instructions/InstructionColor; also update the button label in the view to "Show
Position" or "Confirm Changes" to match the new method name.
- Around line 477-487: The code currently casts the 64-bit Unix timestamp to int
which risks Y2038 overflow; read the stored value with GetLocalInt into a long
(e.g. long nextAllowedLoad = (long)GetLocalInt(Player,
NextLayoutLoadTimestampVariable)), compare using long now =
DateTimeOffset.UtcNow.ToUnixTimeSeconds(), and when setting use a clamp before
calling SetLocalInt: compute long target = now + LayoutLoadCooldownSeconds and
if target > int.MaxValue set the stored value to int.MaxValue else cast to
(int)target; keep references to NextLayoutLoadTimestampVariable,
LayoutLoadCooldownSeconds, GetLocalInt, and SetLocalInt, and preserve the
Instruction/InstructionColor logic.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: f5df4294-c154-489d-9465-b663cf7572f7
📒 Files selected for processing (2)
SWLOR.Game.Server/Feature/GuiDefinition/DMToolsDefinition.csSWLOR.Game.Server/Feature/GuiDefinition/ViewModel/DMToolsViewModel.cs
|
|
||
| private void DMTools() | ||
| { | ||
| _builder.Create("dmtools", "plctools") |
There was a problem hiding this comment.
"dmtools" is too generic. This is specifically for placeable setup & storage. Suggest you use something less generic or keep it as plctools
There was a problem hiding this comment.
Swapped to /plctools specifically
| _builder.Create("dmtools", "plctools") | ||
| .Description("Toggles the DM Placeable Tools window.") | ||
| .Permissions(AuthorizationLevel.DM, AuthorizationLevel.Admin) | ||
| .AvailableToAllOnTestEnvironment() |
There was a problem hiding this comment.
I don't want this available to everyone on the test environment
There was a problem hiding this comment.
Removed test environment for everyone
As requested: Added area lock vars Changed Dmtools just to plctools Area-scoped cooldown
|
@coderabbitai re-review with current requested changes implemented. |
|
✅ Actions performedFull review triggered. |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@SWLOR.Game.Server/Feature/GuiDefinition/ViewModel/DMToolsViewModel.cs`:
- Around line 308-312: Cached _layoutIds may be stale when the DM changes areas;
before performing load or delete actions (e.g., in the lambda returned by
OnRefreshPlaceables and in OnDeleteLayout), refresh/validate layouts from
RebuildPlaceables/Search and verify the selected layout's layout.AreaResref
equals the current GetAreaResref() value; only proceed to set the shared load
cooldown or open the delete modal after this validation passes. Locate uses of
_layoutIds and the cooldown assignment in the load handler and the modal-trigger
in OnDeleteLayout and add the guard that re-fetches the placeable list (call
RebuildPlaceables()/Search()) and compares layout.AreaResref to GetAreaResref()
before applying the cooldown or opening the confirmation.
- Around line 284-300: GetSelectedPlaceable currently only verifies the object
handle validity; additionally check that the cached placeable is in the current
player area by comparing GetArea(placeable) (or GetArea(placeable) equivalent)
to GetArea(Player) after GetIsObjectValid(placeable) returns true, and if the
areas differ set Instructions/InstructionColor, call ClearHighlight(),
RebuildPlaceables(), Search() and return OBJECT_INVALID; update references:
GetSelectedPlaceable(), SelectedPlaceableIndex, _placeables,
GetIsObjectValid(placeable), GetArea(Player), OBJECT_INVALID, Instructions,
InstructionColor, ClearHighlight(), RebuildPlaceables(), Search().
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 0acd74aa-52c7-49d2-82be-917293c82739
📒 Files selected for processing (2)
SWLOR.Game.Server/Feature/ChatCommandDefinition/DMChatCommand.csSWLOR.Game.Server/Feature/GuiDefinition/ViewModel/DMToolsViewModel.cs
Added lightweight per-placeable edit claims in DMToolsViewModel Added stale timeout: EditingClaimStaleSeconds = 120 Enforced ownership checks before: AdjustPosition AdjustFacing OnZAxisReset OnDeletePlaceable When selected placeable is invalid (e.g., another DM deleted it), message now says: "Selected placeable no longer exists (possibly removed by another DM). List refreshed. Hardened OnDeleteLayout modal callback
|
@coderabbitai re-re-review for all the marbles, CodeVole. |
|
🧠 Learnings used✅ Actions performedFull review triggered. |
|
@coderabbitai Okay actual re-review now that we're not rate limited |
|
🧠 Learnings used✅ Actions performedFull review triggered. |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
SWLOR.Game.Server/Feature/GuiDefinition/ViewModel/DMToolsViewModel.cs (2)
747-758: Consider logging caught exceptions before discarding.The catch-all silently swallows exceptions, which could hide real issues. Per coding guidelines, Serilog should be used for logging.
♻️ Proposed improvement
try { DB.Delete<DMAreaPlaceableLayout>(layoutId); } catch + catch (Exception ex) { + Log.Warning(ex, "Failed to delete layout {LayoutId}", layoutId); LayoutName = string.Empty; LoadLayouts(); Instructions = "Unable to delete layout. It may have already been removed."; InstructionColor = GuiColor.Red; return; }Add
using Serilog;at the top of the file.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@SWLOR.Game.Server/Feature/GuiDefinition/ViewModel/DMToolsViewModel.cs` around lines 747 - 758, The catch-all around DB.Delete<DMAreaPlaceableLayout>(layoutId) is currently swallowing exceptions; add logging via Serilog instead of discarding the exception: add "using Serilog;" to the file, catch the exception as a variable (e.g., catch(Exception ex)) and call Log.Error/existing logger with context (e.g., Log.Error(ex, "Failed to delete DMAreaPlaceableLayout {LayoutId}"), then keep the existing recovery steps: clear LayoutName, call LoadLayouts(), set Instructions and InstructionColor to GuiColor.Red and return. Ensure the logged message includes layoutId and the exception.
855-873: RedundantGetSelectedPlaceable()calls in rotation methods.Both
OnRotateClockwiseandOnRotateCounterClockwisecallGetSelectedPlaceable()to get the facing, thenAdjustFacingcalls it again internally. This is minor but could be streamlined.♻️ Proposed refactor
- private void AdjustFacing(float facing) + private void AdjustFacing(float facing, uint placeable = OBJECT_INVALID) { - var placeable = GetSelectedPlaceable(); - if (!GetIsObjectValid(placeable)) - return; + if (!GetIsObjectValid(placeable)) + { + placeable = GetSelectedPlaceable(); + if (!GetIsObjectValid(placeable)) + return; + } if (!EnsureEditingOwnership(placeable)) return; facing = NormalizeFacing(facing); AssignCommand(placeable, () => SetFacing(facing)); } public Action OnRotateClockwise() => () => { var placeable = GetSelectedPlaceable(); if (!GetIsObjectValid(placeable)) return; var facing = GetFacing(placeable) + 20f; - AdjustFacing(facing); + AdjustFacing(facing, placeable); }; public Action OnRotateCounterClockwise() => () => { var placeable = GetSelectedPlaceable(); if (!GetIsObjectValid(placeable)) return; var facing = GetFacing(placeable) - 20f; - AdjustFacing(facing); + AdjustFacing(facing, placeable); };🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@SWLOR.Game.Server/Feature/GuiDefinition/ViewModel/DMToolsViewModel.cs` around lines 855 - 873, The rotation handlers call GetSelectedPlaceable() twice (once in the handler and again inside AdjustFacing); to remove the redundant call, capture the selected placeable once in OnRotateClockwise and OnRotateCounterClockwise, validate it with GetIsObjectValid, compute the new facing with GetFacing(placeable) ±20f, and then call a single AdjustFacing variant that uses that same placeable; implement this by adding an AdjustFacing(NwPlaceable placeable, float facing) overload (or changing AdjustFacing to accept an optional placeable parameter) and update both rotation methods to call that overload, referencing OnRotateClockwise, OnRotateCounterClockwise, GetSelectedPlaceable, GetIsObjectValid, GetFacing, and AdjustFacing.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@SWLOR.Game.Server/Feature/DMActions.cs`:
- Around line 18-22: The current early return when
EventsPlugin.GetEventData("OBJECT_TYPE") fails prevents the subsequent creature
droppable-flag logic from running; instead, parse OBJECT_TYPE into rawObjectType
but do not return on failure—either set a sentinel (e.g., rawObjectType = -1) or
only cast to objectType when TryParse succeeds, and allow the code that uses
GetObjectType and the creature inventory/droppable flag logic (the block after
the OBJECT_TYPE handling) to run regardless; update the code around
EventsPlugin.GetEventData("OBJECT_TYPE"), rawObjectType, and objectType so
failure to parse does not cause an immediate return.
---
Nitpick comments:
In `@SWLOR.Game.Server/Feature/GuiDefinition/ViewModel/DMToolsViewModel.cs`:
- Around line 747-758: The catch-all around
DB.Delete<DMAreaPlaceableLayout>(layoutId) is currently swallowing exceptions;
add logging via Serilog instead of discarding the exception: add "using
Serilog;" to the file, catch the exception as a variable (e.g., catch(Exception
ex)) and call Log.Error/existing logger with context (e.g., Log.Error(ex,
"Failed to delete DMAreaPlaceableLayout {LayoutId}"), then keep the existing
recovery steps: clear LayoutName, call LoadLayouts(), set Instructions and
InstructionColor to GuiColor.Red and return. Ensure the logged message includes
layoutId and the exception.
- Around line 855-873: The rotation handlers call GetSelectedPlaceable() twice
(once in the handler and again inside AdjustFacing); to remove the redundant
call, capture the selected placeable once in OnRotateClockwise and
OnRotateCounterClockwise, validate it with GetIsObjectValid, compute the new
facing with GetFacing(placeable) ±20f, and then call a single AdjustFacing
variant that uses that same placeable; implement this by adding an
AdjustFacing(NwPlaceable placeable, float facing) overload (or changing
AdjustFacing to accept an optional placeable parameter) and update both rotation
methods to call that overload, referencing OnRotateClockwise,
OnRotateCounterClockwise, GetSelectedPlaceable, GetIsObjectValid, GetFacing, and
AdjustFacing.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 037168d0-0ebe-4bbf-adc7-2a4720afccfc
📒 Files selected for processing (5)
SWLOR.Game.Server/Entity/DMAreaPlaceableLayout.csSWLOR.Game.Server/Feature/ChatCommandDefinition/DMChatCommand.csSWLOR.Game.Server/Feature/DMActions.csSWLOR.Game.Server/Feature/GuiDefinition/DMToolsDefinition.csSWLOR.Game.Server/Feature/GuiDefinition/ViewModel/DMToolsViewModel.cs
|
@zunath Merged and ready to party |
|
@coderabbitai - Are previous discussions resolved? |
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed: Length of output: 20375
✅ Resolved (4 threads)
|
and Should be the fixes to Zunath's original question. I just forgot to resolve the threads. Double check those cover the issues. |
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed: Length of output: 6885 🏁 Script executed: Length of output: 9411
✅ Commit
|
| Unresolved Thread | Status |
|---|---|
| 1. Command name too generic | ✅ Fully fixed — primary command is now plctools, alias placeabletools |
| 2. Available to all on test env | ✅ Fully fixed — .AvailableToAllOnTestEnvironment() removed |
| 3. Multi-DM concurrent saves | ✅ Improved — save lock moved from Player-local → area-object-local with try/finally guarantee; load cooldown also moved to area-local so it throttles all DMs on that area, not just the one who loaded |
✅ Commit fc96659 — Addresses thread 3 (edit claims) + hardens delete
| Unresolved Thread | Status |
|---|---|
| 3. Multi-DM concurrent edits | ✅ Fully covered — full per-placeable edit claim system added (DMTOOLS_EDITING_DM_ID/NAME/LAST_TS, 120s stale timeout). Every mutating action (AdjustPosition, OnZAxisReset, AdjustFacing, OnDeletePlaceable) now calls EnsureEditingOwnership(). Claim is acquired on OnSelectPlaceable() and released in ClearHighlight() / on selection switch. |
4. GetSelectedPlaceable() area validation |
✅ Effectively mitigated — EnsureEditingOwnership() gates all mutations. The remaining no-area-check in GetSelectedPlaceable() itself is acceptable given the window-close-on-area-change lifecycle guarantee. |
5. _layoutIds stale revalidation |
✅ Effectively mitigated — explicit area validity check added before load/save, and the window lifecycle still guarantees a clean re-Initialize() on every area transition. |
Summary: All five unresolved threads are covered by these two commits. Threads 1 & 2 are fully and cleanly fixed. Thread 3 (multi-DM concurrency) is now comprehensively handled end-to-end between the two commits. Threads 4 & 5 are sufficiently mitigated via the new ownership checks plus the existing window lifecycle behaviour. You're good to go! 🐇✨
🧠 Learnings used
Learnt from: zunath
Repo: zunath/SWLOR_NWN PR: 1958
File: SWLOR.Game.Server/Feature/GuiDefinition/ViewModel/DMToolsViewModel.cs:770-770
Timestamp: 2026-04-04T17:02:24.442Z
Learning: Preference: For DM/admin tools in SWLOR.Game.Server, favor code-level concurrency guards over relying on manual DM coordination. In DMToolsViewModel, use placeable-local edit claims (DMTOOLS_EDITING_DM_ID/NAME/TS) with stale timeout and release on window close to prevent multi-DM overwrite.
Learnt from: LazyTrain082
Repo: zunath/SWLOR_NWN PR: 1958
File: SWLOR.Game.Server/Feature/GuiDefinition/ViewModel/DMToolsViewModel.cs:308-312
Timestamp: 2026-04-04T16:54:53.747Z
Learning: In SWLOR.Game.Server (C#), NUI windows (including DMToolsViewModel / GuiWindowType.DMTools) automatically close when a DM jumps to a different area. This means cached per-area state (e.g., `_layoutIds`, `_allPlaceables`) is always rebuilt fresh via `Initialize()` when the window is reopened in the new area. Do not flag stale-cache concerns for area-scoped data in this view-model, as the window lifecycle guarantees a clean re-initialization on every area transition.
Learnt from: zunath
Repo: zunath/SWLOR_NWN PR: 1957
File: Module/ifo/module.ifo.json:2899-2899
Timestamp: 2026-03-29T13:23:17.045Z
Learning: In the SWLOR_NWN repository, `Module/ifo/module.ifo.json` has been historically corrupted by PRs that introduced truncated or missing lines (e.g., incomplete entries in `Mod_Area_list`). On every PR touching this file, always: (1) verify the file is valid, well-formed JSON; (2) check every entry in `Mod_Area_list` has a complete `__struct_id` and `Area_Name` block with both `type` and `value` fields; (3) flag any truncated, incomplete, or empty entries; (4) flag any removals from `Mod_Area_list` unless explicitly confirmed as intentional by the PR author.

New command: /plctools
DMs now have a dedicated placeable tool NUI panel that should help with map setup.
The tool does the following:
Allows you to see all DM-spawned placeables in the current map in one list.
Click to highlight/select for editing like the current player housing system
Nudge positions on XYZ and rotate facing
Quick search/filter to find a specific placeable
Save and load setups as named layout presets
Missing placeables from a saved layout get respawned automatically
Shared presets let the whole DM team reuse setups in shared-event scenarios
Layouts capped at 30 with a short 5 sec cooldown to prevent spam/mistakes
Summary by CodeRabbit
/plctools(alias/placeabletools) DM/Admin-only command to open a resizable DM Placeable Tools window.