-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathQuartermasterModSystem.cs
More file actions
894 lines (779 loc) · 41.9 KB
/
Copy pathQuartermasterModSystem.cs
File metadata and controls
894 lines (779 loc) · 41.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Vintagestory.API.Client;
using Vintagestory.API.Common;
using Vintagestory.API.Config;
using Vintagestory.API.Datastructures;
using Vintagestory.API.Server;
using Vintagestory.API.Util;
using Vintagestory.API.MathTools;
using Vintagestory.GameContent;
namespace Quartermaster
{
public class QuartermasterModSystem : ModSystem
{
public static IClientNetworkChannel clientChannel;
public static IServerNetworkChannel serverChannel;
public static GuiDialogQuartermaster dialog;
private List<BlockPos> activeHighlights = new List<BlockPos>();
private WaypointMapLayer wpLayer;
private ContainerLabelRenderer labelRenderer;
private ICoreClientAPI capi;
private ICoreServerAPI sapi;
private static QuartermasterConfig config;
// Containers players have hidden from the desk with a Quartermaster's Tag,
// keyed "x,y,z". Server-side, persisted in the world save.
private HashSet<string> excludedPositions = new HashSet<string>();
// Client-side held-tag overlay state.
private bool holdingTag;
private bool showingExcluded;
private float tagRefreshAccum;
// Accumulator for the periodic slot-stats refresh while the ledger is open.
private float slotStatsAccum;
// Locate state, kept in sync as containers are opened.
private string currentItemName;
private List<ContainerLabel> currentLabels = new List<ContainerLabel>();
// The temporary waypoint map-components we created, so we can remove them again.
private List<MapComponent> bkWaypointComponents = new List<MapComponent>();
private System.Reflection.FieldInfo wpTmpField, wpAllField;
public override void Start(ICoreAPI api)
{
api.RegisterBlockClass("BlockQuartermasterDesk", typeof(BlockQuartermasterDesk));
api.RegisterItemClass("ItemQuartermasterTag", typeof(ItemQuartermasterTag));
api.Network.RegisterChannel("quartermaster")
.RegisterMessageType(typeof(PacketQuartermasterRequest))
.RegisterMessageType(typeof(PacketQuartermasterResponse))
.RegisterMessageType(typeof(SimplePos))
.RegisterMessageType(typeof(PacketWithdraw))
.RegisterMessageType(typeof(PacketDeposit))
.RegisterMessageType(typeof(PacketExcludedRequest))
.RegisterMessageType(typeof(PacketExcludedList))
.RegisterMessageType(typeof(PacketSlotStatsRequest))
.RegisterMessageType(typeof(PacketSlotStats));
}
public override void StartClientSide(ICoreClientAPI api)
{
this.capi = api;
clientChannel = capi.Network.GetChannel("quartermaster")
.SetMessageHandler<PacketQuartermasterResponse>(OnQuartermasterDataReceived)
.SetMessageHandler<PacketExcludedList>(OnExcludedListReceived)
.SetMessageHandler<PacketSlotStats>(OnSlotStatsReceived);
dialog = new GuiDialogQuartermaster(capi, this);
capi.Input.RegisterHotKey("quartermaster", "Open Quartermaster's Desk", GlKeys.J, HotkeyType.GUIOrOtherControls);
capi.Input.SetHotKeyHandler("quartermaster", OnHotKey);
// Uses GameTickListener for instant click detection/removal
capi.Event.RegisterGameTickListener(OnClientTick, 50);
// Shift-click deposit while the ledger is open. This raw event fires before
// any GUI dialog sees the click (see OnClientMouseDown).
capi.Event.MouseDown += OnClientMouseDown;
// Register the floating label renderer for through-wall container labels
labelRenderer = new ContainerLabelRenderer(capi);
capi.Event.RegisterRenderer(labelRenderer, EnumRenderStage.Ortho);
}
public override void StartServerSide(ICoreServerAPI sapi)
{
this.sapi = sapi;
try
{
config = sapi.LoadModConfig<QuartermasterConfig>("QuartermasterConfig.json");
}
catch { }
if (config == null)
{
config = new QuartermasterConfig();
}
sapi.StoreModConfig(config, "QuartermasterConfig.json");
serverChannel = sapi.Network.GetChannel("quartermaster")
.SetMessageHandler<PacketQuartermasterRequest>(OnClientRequest)
.SetMessageHandler<PacketWithdraw>(OnWithdraw)
.SetMessageHandler<PacketDeposit>(OnDeposit)
.SetMessageHandler<PacketExcludedRequest>(OnExcludedRequest)
.SetMessageHandler<PacketSlotStatsRequest>(OnSlotStatsRequest);
sapi.Event.SaveGameLoaded += LoadExcludedPositions;
sapi.Event.GameWorldSave += SaveExcludedPositions;
// Exclusions are keyed to a position, not a block entity — drop the entry the
// moment its block is broken (or something new is placed over an excluded spot,
// e.g. after an explosion) so a replacement container never inherits it. The lazy
// prune in SendExcludedList remains as a backstop for other removal paths.
sapi.Event.DidBreakBlock += OnServerBlockBroken;
sapi.Event.DidPlaceBlock += OnServerBlockPlaced;
}
private bool OnHotKey(KeyCombination comb)
{
if (dialog == null) return true;
if (dialog.IsOpened()) dialog.TryClose();
else if (dialog.IsLookingAtStation()) dialog.TryOpen();
// IsLookingAtStation will show appropriate error message if needed
return true;
}
// --- INSTANT REMOVAL LOGIC ---
private void OnClientTick(float dt)
{
UpdateTagOverlay(dt);
// While the ledger is open, refresh the "Empty slots" label every couple of
// seconds so chests placed/broken/filled in range show up without reopening.
if (dialog != null && dialog.IsOpened())
{
slotStatsAccum += dt;
if (slotStatsAccum > 2f)
{
slotStatsAccum = 0f;
clientChannel?.SendPacket(new PacketSlotStatsRequest());
}
}
else slotStatsAccum = 0f;
if (!capi.Input.MouseButton.Right) return;
var blockSel = capi.World.Player.CurrentBlockSelection;
if (blockSel?.Position == null) return;
int removed = activeHighlights.RemoveAll(p =>
p.X == blockSel.Position.X &&
p.Y == blockSel.Position.Y &&
p.Z == blockSel.Position.Z);
if (removed > 0)
{
// An opened container's highlight, label AND map waypoint all clear together.
RefreshHighlights();
}
}
// Shift+Left-clicking an item in the hotbar or bags while the ledger is open
// deposits it straight into storage. Hooked on the raw mouse-down event because it
// fires before any GUI dialog gets the click — the vanilla inventory dialog is
// ahead of ours in input order and would otherwise shift-move the stack between
// bags and hotbar before we ever saw it.
private void OnClientMouseDown(MouseEvent args)
{
if (args.Handled || args.Button != EnumMouseButton.Left) return;
if (dialog == null || !dialog.IsOpened() || dialog.IsLocateOnly) return;
// Read the RAW key state: a focused text input (e.g. the ledger's search bar)
// marks the Shift keydown event handled, and ClientMain then skips updating the
// filtered KeyboardKeyState array entirely — so with the search bar focused,
// Shift reads as "not held" there even while physically down. KeyboardKeyStateRaw
// is set before GUI dispatch and is what the engine's own text-selection code
// reads for the same reason.
if (!capi.Input.KeyboardKeyStateRaw[(int)GlKeys.ShiftLeft] &&
!capi.Input.KeyboardKeyStateRaw[(int)GlKeys.ShiftRight]) return;
// CurrentHoveredSlot is a cache refreshed only by slot enter/leave events, and
// slot grids only fire "enter" when the hovered slot *changes*. The hotbar HUD
// is never recomposed between ledger sessions, so its grid still remembers the
// slot hovered last session while the engine nulled CurrentHoveredSlot on dialog
// close — reopen the ledger with the cursor on that same slot and no enter event
// ever refires. A move replayed at the cursor position alone (the v1.0.16 fix)
// can't heal that, so first replay a move at an off-screen point to make every
// grid fire "leave" and forget its remembered slot, then one at the real cursor
// position so the grid under it fires a fresh "enter".
var moveOut = new MouseEvent(-100, -100);
foreach (GuiDialog dlg in capi.Gui.LoadedGuis)
{
if (!dlg.ShouldReceiveMouseEvents()) continue;
dlg.OnMouseMove(moveOut);
}
var move = new MouseEvent(args.X, args.Y);
foreach (GuiDialog dlg in capi.Gui.LoadedGuis)
{
if (!dlg.ShouldReceiveMouseEvents()) continue;
dlg.OnMouseMove(move);
if (move.Handled) break;
}
var invMgr = capi.World.Player?.InventoryManager;
ItemSlot hovered = invMgr?.CurrentHoveredSlot;
// Only filled slots in the player's own hotbar/backpack, never an equipped bag
// itself, and only with an empty cursor (a held stack keeps its vanilla
// place/swap behavior). The class-name check also leaves the ledger's own
// virtual grid alone, where shift-click still means "withdraw all".
if (hovered?.Itemstack == null || hovered is ItemSlotBackpack) return;
if (invMgr.MouseItemSlot?.Itemstack != null) return;
string invClass = hovered.Inventory?.ClassName;
if (invClass != GlobalConstants.hotBarInvClassName &&
invClass != GlobalConstants.backpackInvClassName) return;
int slotId = hovered.Inventory.GetSlotId(hovered);
if (slotId < 0) return;
clientChannel.SendPacket(new PacketDeposit { Mode = 3, InventoryClass = invClass, SlotId = slotId });
args.Handled = true;
}
// While the player holds a Quartermaster's Tag, nearby excluded containers show an
// "Excluded" label (reusing the through-wall label renderer). The list refreshes on
// equip and every couple of seconds while held (so it tracks toggles and newly loaded
// areas); putting the tag away restores the normal locate labels.
private void UpdateTagOverlay(float dt)
{
bool holding = capi.World.Player?.InventoryManager?.ActiveHotbarSlot?.Itemstack?.Collectible is ItemQuartermasterTag;
if (holding)
{
tagRefreshAccum += dt;
if (!holdingTag || tagRefreshAccum > 2f)
{
tagRefreshAccum = 0f;
clientChannel.SendPacket(new PacketExcludedRequest());
}
}
else if (holdingTag)
{
showingExcluded = false;
RefreshHighlights();
}
holdingTag = holding;
}
private void OnExcludedListReceived(PacketExcludedList packet)
{
if (!holdingTag) return;
showingExcluded = true;
string text = Lang.Get("quartermaster:tag-label-excluded");
var labels = packet.Positions
.Select(p => new ContainerLabel { X = p.X, Y = p.Y, Z = p.Z, Text = text })
.ToList();
labelRenderer?.SetLabels(labels);
}
public void SetHighlights(List<BlockPos> positions, string itemName = null, Dictionary<string, int> perLocationCounts = null)
{
activeHighlights.Clear();
activeHighlights.AddRange(positions);
currentItemName = itemName ?? "Quartermaster";
// Build the full label set; RefreshHighlights filters it to remaining containers.
currentLabels = new List<ContainerLabel>();
foreach (var pos in positions)
{
string key = $"{pos.X},{pos.Y},{pos.Z}";
int count = 0;
perLocationCounts?.TryGetValue(key, out count);
string labelText = (itemName != null && count > 0) ? $"{itemName} x{count}" : (itemName ?? "");
currentLabels.Add(new ContainerLabel { X = pos.X, Y = pos.Y, Z = pos.Z, Text = labelText });
}
RefreshHighlights();
capi.ShowChatMessage($"Locating {positions.Count} containers. Markers clear as you open each one.");
}
// Lazily resolves the waypoint map layer and its private component lists (used to
// remove our temporary map markers, which have no public removal API).
private void EnsureWaypointLayer()
{
if (wpLayer != null) return;
try
{
var mapManager = capi.ModLoader.GetModSystem<WorldMapManager>();
wpLayer = mapManager?.MapLayers?.OfType<WaypointMapLayer>().FirstOrDefault();
if (wpLayer != null)
{
var t = wpLayer.GetType();
wpTmpField = t.GetField("tmpWayPointComponents", BindingFlags.Instance | BindingFlags.NonPublic);
wpAllField = t.GetField("wayPointComponents", BindingFlags.Instance | BindingFlags.NonPublic);
}
}
catch (Exception ex)
{
capi.ShowChatMessage($"Quartermaster: Could not access waypoint system: {ex.Message}");
}
}
private List<MapComponent> WpList(System.Reflection.FieldInfo f) => f?.GetValue(wpLayer) as List<MapComponent>;
// Removes the markers we previously added, then adds fresh ones for the positions
// still active. Keeps the map dots and in-world beacons in sync with activeHighlights.
private void SyncWaypoints()
{
EnsureWaypointLayer();
if (wpLayer == null) return;
var tmpList = WpList(wpTmpField);
var allList = WpList(wpAllField);
// Remove our existing markers.
foreach (var comp in bkWaypointComponents)
{
tmpList?.Remove(comp);
allList?.Remove(comp);
try { comp.Dispose(); } catch { }
}
bkWaypointComponents.Clear();
// Re-add for remaining containers.
foreach (var pos in activeHighlights)
{
var wp = new Waypoint
{
Position = new Vec3d(pos.X + 0.5, pos.Y + 0.5, pos.Z + 0.5),
Title = currentItemName ?? "Quartermaster",
Icon = "circle",
Color = ColorUtil.ToRgba(255, 51, 153, 255),
ShowInWorld = true,
Pinned = false,
Temporary = true,
OwningPlayerUid = capi.World.Player.PlayerUID
};
wpLayer.AddTemporaryWaypoint(wp);
var added = tmpList ?? WpList(wpTmpField);
if (added != null && added.Count > 0) bkWaypointComponents.Add(added[added.Count - 1]);
}
}
private void RefreshHighlights()
{
// BLUE: (Alpha=150, Blue=255, Green=0, Red=0)
int blue = ColorUtil.ToRgba(150, 255, 0, 0);
if (activeHighlights.Count > 0)
{
List<int> colors = activeHighlights.Select(_ => blue).ToList();
capi.World.HighlightBlocks(capi.World.Player, 56, activeHighlights, colors, EnumHighlightBlocksMode.Absolute, EnumHighlightShape.Cube);
// Show labels only for containers still open. While the held-tag "Excluded"
// overlay owns the renderer, leave its labels alone.
if (!showingExcluded)
{
var remaining = currentLabels
.Where(l => activeHighlights.Any(p => p.X == l.X && p.Y == l.Y && p.Z == l.Z))
.ToList();
labelRenderer?.SetLabels(remaining);
}
}
else
{
capi.World.HighlightBlocks(capi.World.Player, 56, new List<BlockPos>(), new List<int>());
if (!showingExcluded) labelRenderer?.ClearLabels();
}
SyncWaypoints();
}
// Walks every chunk within the configured range and yields each block entity
// that exposes an inventory, optionally filtered by a predicate. Shared by the
// read-only scan and the sort feature.
private IEnumerable<(BlockPos pos, IInventory inv, BlockEntity be)> EnumerateContainers(
IServerPlayer player, System.Func<BlockEntity, bool> predicate = null)
{
BlockPos pPos = player.Entity.Pos.AsBlockPos;
int radius = config.ChunkRadius;
int vertRange = config.VerticalRange;
for (int x = -radius; x <= radius; x++) {
for (int z = -radius; z <= radius; z++) {
int chunkX = pPos.X / 32 + x;
int chunkZ = pPos.Z / 32 + z;
int minChunkY = Math.Max(0, pPos.Y - vertRange) / 32;
int maxChunkY = Math.Min(player.Entity.World.BlockAccessor.MapSizeY, pPos.Y + vertRange) / 32;
for (int y = minChunkY; y <= maxChunkY; y++) {
IWorldChunk chunk = player.Entity.World.BlockAccessor.GetChunk(chunkX, y, chunkZ);
if (chunk == null) continue;
foreach (var entry in chunk.BlockEntities) {
BlockEntity be = entry.Value;
if (be == null) continue;
// Player-tagged exclusions (Quartermaster's Tag): the container is
// invisible to the desk — no browse, locate, withdraw, or deposit.
if (excludedPositions.Contains(PosKey(entry.Key))) continue;
// Never index or touch work-station inventories (cooking pots on a
// firepit, ore in a bloomery, a workitem on an anvil, etc.).
if (IsProcessingDevice(be)) continue;
// Barrels are excluded too: they hold liquids and sealed
// curing/fermenting recipes, so pulling from them is destructive.
if (be is BlockEntityBarrel) continue;
// Placed buckets likewise: they hold liquids, empty or not,
// and shouldn't show up as storage.
if (be is BlockEntityBucket) continue;
// Planters and flowerpots/vases store their planted flower in a
// 1-slot inventory; a withdraw would strip decor out of builds.
if (be is BlockEntityPlantContainer) continue;
if (predicate != null && !predicate(be)) continue;
// Honor land claims: skip containers the player isn't allowed to use.
if (config.HonorClaims && !HasClaimAccess(player, entry.Key)) continue;
IInventory inv = GetInventory(be);
if (inv != null)
yield return (entry.Key, inv, be);
}
}
}
}
}
// Resolves a block entity's inventory: IBlockEntityContainer first, then the
// "Inventory" property, then a non-public "inventory" field (display entities).
private IInventory GetInventory(BlockEntity be)
{
if (be is IBlockEntityContainer container && container.Inventory != null)
return container.Inventory;
var invProp = be.GetType().GetProperty("Inventory");
if (invProp != null && invProp.GetValue(be) is IInventory invFromProp)
return invFromProp;
var invField = be.GetType().GetField("inventory",
BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
if (invField != null && invField.GetValue(be) is IInventory invFromField)
return invFromField;
return null;
}
// Work-station / processing block entities hold items mid-process, not in storage:
// a cooking pot on a firepit, ore in a bloomery, metal in a forge, a workitem on an
// anvil, grain in a quern, or iron plates packed in a stone coffin mid-cementation.
// These must never appear in the ledger or be withdrawable —
// pulling from them removes the item from an active device (destructive). `is` checks
// also catch modded subclasses. Barrels and placed buckets are excluded separately in
// EnumerateContainers, since they hold liquids (and, for barrels, sealed
// curing/fermenting recipes).
// Land-claim check: true if the player may USE (open) a block at pos — owners and
// granted players/groups pass, everyone else is denied. Lets the remote terminal honor
// claims by skipping containers the player couldn't access by hand. Unclaimed land and
// single-player return Granted, so there's no behavior change where claims aren't used.
private static bool HasClaimAccess(IServerPlayer player, BlockPos pos)
{
return player.Entity.World.Claims.TestAccess(player, pos, EnumBlockAccessFlags.Use)
== EnumWorldAccessResponse.Granted;
}
private static bool IsProcessingDevice(BlockEntity be)
{
return be is BlockEntityFirepit
|| be is BlockEntityOven
|| be is BlockEntityBloomery
|| be is BlockEntityForge
|| be is BlockEntityQuern
|| be is BlockEntityStove
|| be is BlockEntityBoiler
|| be is BlockEntityStoneCoffin
|| be is BlockEntityAnvil;
}
// --- CONTAINER EXCLUSION (Quartermaster's Tag) ---
private static string PosKey(BlockPos pos) => $"{pos.X},{pos.Y},{pos.Z}";
private void LoadExcludedPositions()
{
try
{
byte[] data = sapi.WorldManager.SaveGame.GetData("quartermasterExcluded");
if (data != null)
excludedPositions = new HashSet<string>(SerializerUtil.Deserialize<List<string>>(data));
}
catch (Exception ex)
{
Mod.Logger.Error("[Quartermaster] Failed to load excluded-container list: {0}", ex);
excludedPositions = new HashSet<string>();
}
}
private void SaveExcludedPositions()
{
sapi.WorldManager.SaveGame.StoreData("quartermasterExcluded", SerializerUtil.Serialize(excludedPositions.ToList()));
}
// Toggles a container in or out of the ledger. Called server-side by
// ItemQuartermasterTag when a player sneak-clicks a block while holding a tag.
// Only blocks the desk would actually scan can be tagged, and claim access is
// checked so players can't tag containers they couldn't open by hand.
public void ToggleExcluded(IServerPlayer player, BlockPos pos)
{
BlockEntity be = player.Entity.World.BlockAccessor.GetBlockEntity(pos);
bool scannable = be != null && !IsProcessingDevice(be) && !(be is BlockEntityBarrel)
&& !(be is BlockEntityBucket) && !(be is BlockEntityPlantContainer)
&& GetInventory(be) != null;
if (!scannable)
{
player.SendMessage(GlobalConstants.GeneralChatGroup,
"Quartermaster: that block isn't a container the desk scans.", EnumChatType.Notification);
return;
}
if (config.HonorClaims && !HasClaimAccess(player, pos))
{
player.SendMessage(GlobalConstants.GeneralChatGroup,
"Quartermaster: you don't have permission to use that container.", EnumChatType.Notification);
return;
}
string key = PosKey(pos);
bool nowExcluded = excludedPositions.Add(key);
if (!nowExcluded) excludedPositions.Remove(key);
SaveExcludedPositions();
player.SendMessage(GlobalConstants.GeneralChatGroup,
nowExcluded ? "Quartermaster: container excluded from the ledger."
: "Quartermaster: container returned to the ledger.",
EnumChatType.Notification);
// Keep the held-tag overlay in sync immediately.
SendExcludedList(player);
}
private void OnServerBlockBroken(IServerPlayer byPlayer, int oldblockId, BlockSelection blockSel)
=> RemoveExclusionAt(blockSel?.Position);
private void OnServerBlockPlaced(IServerPlayer byPlayer, int oldblockId, BlockSelection blockSel, ItemStack withItemStack)
=> RemoveExclusionAt(blockSel?.Position);
private void RemoveExclusionAt(BlockPos pos)
{
if (pos == null || !excludedPositions.Remove(PosKey(pos))) return;
SaveExcludedPositions();
}
private void OnExcludedRequest(IServerPlayer player, PacketExcludedRequest packet) => SendExcludedList(player);
// Sends the excluded positions near the player, for the held-tag label overlay.
// Also lazily prunes entries whose container no longer exists — but only when the
// chunk is loaded, so a chest in an unloaded area is never mistaken for a removed one.
private void SendExcludedList(IServerPlayer player)
{
var accessor = player.Entity.World.BlockAccessor;
BlockPos pPos = player.Entity.Pos.AsBlockPos;
int range = (config.ChunkRadius + 1) * 32;
var nearby = new List<SimplePos>();
var stale = new List<string>();
foreach (string key in excludedPositions)
{
string[] parts = key.Split(',');
if (parts.Length != 3
|| !int.TryParse(parts[0], out int x)
|| !int.TryParse(parts[1], out int y)
|| !int.TryParse(parts[2], out int z))
{
stale.Add(key);
continue;
}
if (Math.Abs(x - pPos.X) > range || Math.Abs(z - pPos.Z) > range) continue;
BlockPos pos = new BlockPos(x, y, z, 0);
if (accessor.GetChunkAtBlockPos(pos) != null)
{
BlockEntity be = accessor.GetBlockEntity(pos);
if (be == null || GetInventory(be) == null) { stale.Add(key); continue; }
}
// Honor land claims: never reveal excluded containers inside claims this
// player can't use — the through-wall "Excluded" label would otherwise leak
// the position of storage someone deliberately hid.
if (config.HonorClaims && !HasClaimAccess(player, pos)) continue;
nearby.Add(new SimplePos { X = x, Y = y, Z = z });
}
if (stale.Count > 0)
{
foreach (string key in stale) excludedPositions.Remove(key);
SaveExcludedPositions();
}
serverChannel.SendPacket(new PacketExcludedList { Positions = nearby }, player);
}
private void OnClientRequest(IServerPlayer player, PacketQuartermasterRequest packet) => SendLedger(player);
// Scans all nearby containers and sends the consolidated ledger to the client.
private void SendLedger(IServerPlayer player)
{
Dictionary<string, QuartermasterItemDTO> consolidated = new Dictionary<string, QuartermasterItemDTO>();
int freeSlots = 0, totalSlots = 0;
foreach (var (pos, inv, be) in EnumerateContainers(player))
{
ScanInventory(inv, consolidated, pos);
// Slot stats count only deposit-eligible storage (chests/trunks), so the
// "Empty slots" label matches what a deposit could actually fill — racks,
// shelves and display cases are browsable but not deposit targets.
if (IsStorageContainer(be)) CountSlots(inv, ref freeSlots, ref totalSlots);
}
serverChannel.SendPacket(new PacketQuartermasterResponse
{
Items = consolidated.Values.ToList(),
LocateOnly = config.LocateOnly,
FreeSlots = freeSlots,
TotalSlots = totalSlots
}, player);
}
private static void CountSlots(IInventory inv, ref int freeSlots, ref int totalSlots)
{
foreach (var slot in inv)
{
totalSlots++;
if (slot?.Itemstack == null) freeSlots++;
}
}
// Cheap periodic refresh for the "Empty slots" label: counts slots without
// building the item DTOs of a full ledger scan.
private void OnSlotStatsRequest(IServerPlayer player, PacketSlotStatsRequest packet)
{
int freeSlots = 0, totalSlots = 0;
foreach (var (pos, inv, be) in EnumerateContainers(player, IsStorageContainer))
CountSlots(inv, ref freeSlots, ref totalSlots);
serverChannel.SendPacket(new PacketSlotStats { FreeSlots = freeSlots, TotalSlots = totalSlots }, player);
}
private void ScanInventory(IInventory inventory, Dictionary<string, QuartermasterItemDTO> list, BlockPos pos)
{
foreach (var slot in inventory) {
if (slot?.Itemstack == null) continue;
string code = slot.Itemstack.Collectible.Code.ToString();
// Decorative chests, clutter, and other attribute-variant blocks share one block
// code; the specific kind lives in the "type"/"variant"/"material" attributes. Key
// on all of them so distinct variants don't collapse into a single generic entry.
string variantType = slot.Itemstack.Attributes?.GetString("type") ?? "";
string variant = slot.Itemstack.Attributes?.GetString("variant") ?? "";
string material = slot.Itemstack.Attributes?.GetString("material") ?? "";
string key = code + "|" + variantType + "|" + variant + "|" + material;
if (!list.ContainsKey(key))
list[key] = new QuartermasterItemDTO {
Code = code, Count = 0, Type = slot.Itemstack.Class.ToString(),
VariantType = variantType, Variant = variant, Material = material,
// Keep a representative attribute tree so the client can rebuild the exact
// look (mesh + name) — cheap: one snapshot per distinct variant, not per stack.
AttributesData = (slot.Itemstack.Attributes as TreeAttribute)?.ToBytes()
};
list[key].Count += slot.Itemstack.StackSize;
if (list[key].Locations.Count < 20 && !list[key].Locations.Any(l => l.X == pos.X && l.Y == pos.Y && l.Z == pos.Z))
list[key].Locations.Add(new SimplePos { X = pos.X, Y = pos.Y, Z = pos.Z });
}
}
private void OnQuartermasterDataReceived(PacketQuartermasterResponse packet)
=> dialog?.UpdateDataFromServer(packet.Items, packet.LocateOnly, packet.FreeSlots, packet.TotalSlots);
private void OnSlotStatsReceived(PacketSlotStats packet) => dialog?.UpdateSlotStats(packet.FreeSlots, packet.TotalSlots);
private void OnWithdraw(IServerPlayer player, PacketWithdraw packet)
{
// Server-authoritative read-only guard: even a modified client can't move items.
if (config.LocateOnly) { SendLedger(player); return; }
try { DoWithdraw(player, packet); }
catch (Exception ex) { Mod.Logger.Error("[Quartermaster] Withdraw failed: {0}", ex); }
SendLedger(player);
}
private void DoWithdraw(IServerPlayer player, PacketWithdraw packet)
{
var world = player.Entity.World;
AssetLocation code = new AssetLocation(packet.Code);
EnumItemClass cls = packet.Type == "Block" ? EnumItemClass.Block : EnumItemClass.Item;
CollectibleObject coll = cls == EnumItemClass.Block
? (CollectibleObject)world.GetBlock(code)
: (CollectibleObject)world.GetItem(code);
if (coll == null) return;
int want = packet.Mode == 1 ? 1 : (packet.Mode == 0 ? coll.MaxStackSize : int.MaxValue);
int delivered = 0;
foreach (var (pos, inv, be) in EnumerateContainers(player))
{
bool dirty = false;
foreach (var slot in inv)
{
if (delivered >= want) break;
var st = slot.Itemstack;
if (st == null || st.Class != cls || !st.Collectible.Code.Equals(code)) continue;
// Match the exact attribute-variant the player clicked (normalize null↔"").
if ((st.Attributes?.GetString("type") ?? "") != (packet.VariantType ?? "")) continue;
if ((st.Attributes?.GetString("variant") ?? "") != (packet.Variant ?? "")) continue;
if ((st.Attributes?.GetString("material") ?? "") != (packet.Material ?? "")) continue;
int take = Math.Min(st.StackSize, want - delivered);
// Give first, then remove only the amount actually accepted (loss-free).
ItemStack give = st.Clone();
give.StackSize = take;
player.InventoryManager.TryGiveItemstack(give, true);
int accepted = take - give.StackSize;
if (accepted <= 0) continue;
st.StackSize -= accepted;
if (st.StackSize <= 0) slot.Itemstack = null;
slot.MarkDirty();
dirty = true;
delivered += accepted;
}
if (dirty) be.MarkDirty(true);
if (delivered >= want) break;
}
}
private void OnDeposit(IServerPlayer player, PacketDeposit packet)
{
// Server-authoritative read-only guard: even a modified client can't move items.
if (config.LocateOnly) { SendLedger(player); return; }
try { DoDeposit(player, packet); }
catch (Exception ex) { Mod.Logger.Error("[Quartermaster] Deposit failed: {0}", ex); }
SendLedger(player);
}
private void DoDeposit(IServerPlayer player, PacketDeposit packet)
{
// Mode 2: deposit the contents of the player's backpack bags only. Never the
// worn bag containers themselves (ItemSlotBackpack slots), and never the hotbar.
if (packet.Mode == 2)
{
IInventory backpack = player.InventoryManager.GetOwnInventory(GlobalConstants.backpackInvClassName);
if (backpack == null) return;
int notDeposited = 0;
foreach (var slot in backpack)
{
if (slot?.Itemstack == null) continue;
if (slot is ItemSlotBackpack) continue; // skip equipped bags
StoreIntoContainers(player, slot.Itemstack);
notDeposited += slot.Itemstack.StackSize; // whatever didn't fit stays in the bag
if (slot.Itemstack.StackSize <= 0) slot.Itemstack = null;
slot.MarkDirty();
}
WarnIfStorageFull(player, notDeposited, null);
return;
}
// Mode 3: shift-click deposit of one specific hotbar/backpack slot. Only the
// player's own inventories are ever resolved — the class name is validated
// rather than trusted, so a forged packet can't reference anything else.
if (packet.Mode == 3)
{
if (packet.InventoryClass != GlobalConstants.hotBarInvClassName &&
packet.InventoryClass != GlobalConstants.backpackInvClassName) return;
IInventory inv = player.InventoryManager.GetOwnInventory(packet.InventoryClass);
if (inv == null || packet.SlotId < 0 || packet.SlotId >= inv.Count) return;
ItemSlot slot = inv[packet.SlotId];
if (slot?.Itemstack == null || slot is ItemSlotBackpack) return;
string name = slot.Itemstack.GetName();
StoreIntoContainers(player, slot.Itemstack);
int remaining = slot.Itemstack.StackSize; // whatever didn't fit stays put
if (remaining <= 0) slot.Itemstack = null;
slot.MarkDirty();
WarnIfStorageFull(player, remaining, name);
return;
}
// Modes 0/1: deposit from the cursor.
ItemSlot mouse = player.InventoryManager.MouseItemSlot;
if (mouse?.Itemstack == null) return;
string itemName = mouse.Itemstack.GetName();
if (packet.Mode == 1)
{
ItemStack one = mouse.Itemstack.Clone();
one.StackSize = 1;
int stored = StoreIntoContainers(player, one);
if (stored > 0)
{
mouse.Itemstack.StackSize -= stored;
if (mouse.Itemstack.StackSize <= 0) mouse.Itemstack = null;
mouse.MarkDirty();
}
WarnIfStorageFull(player, stored > 0 ? 0 : 1, itemName);
}
else
{
StoreIntoContainers(player, mouse.Itemstack);
int notDeposited = mouse.Itemstack.StackSize; // remainder stays on the cursor
if (mouse.Itemstack.StackSize <= 0) mouse.Itemstack = null;
mouse.MarkDirty();
WarnIfStorageFull(player, notDeposited, itemName);
}
}
// Tells the player when a deposit couldn't be (fully) stored because nearby storage
// is full. Nothing is lost — the leftover stays on the cursor / in the backpack — so
// this is purely to explain why the click appeared to do nothing.
private void WarnIfStorageFull(IServerPlayer player, int notDeposited, string itemName)
{
if (notDeposited <= 0) return;
string what = itemName != null ? $"{notDeposited}x {itemName}" : $"{notDeposited} item{(notDeposited == 1 ? "" : "s")}";
player.SendMessage(GlobalConstants.GeneralChatGroup,
$"Quartermaster: storage full — {what} couldn't be deposited.", EnumChatType.Notification);
}
// Only real storage (chests + trunks); never per-player or display racks/cases.
private bool IsStorageContainer(BlockEntity be)
{
return be is BlockEntityGenericTypedContainer gtc && !gtc.isPerPlayer;
}
// Stores as much of 'stack' as possible into nearby storage containers: tops up
// existing matching stacks first, then fills empty slots. Decrements stack.StackSize
// by the amount stored and returns that amount.
private int StoreIntoContainers(IServerPlayer player, ItemStack stack)
{
if (stack == null || stack.StackSize <= 0) return 0;
int stored = 0;
// Pass 1: top up existing matching stacks.
foreach (var (pos, inv, be) in EnumerateContainers(player, IsStorageContainer))
{
bool dirty = false;
foreach (var slot in inv)
{
if (stack.StackSize <= 0) break;
var st = slot.Itemstack;
if (st == null) continue;
int mergable = st.Collectible.GetMergableQuantity(st, stack, EnumMergePriority.AutoMerge);
if (mergable <= 0) continue;
int move = Math.Min(Math.Min(mergable, st.Collectible.MaxStackSize - st.StackSize), stack.StackSize);
if (move <= 0) continue;
st.StackSize += move;
stack.StackSize -= move;
stored += move;
slot.MarkDirty();
dirty = true;
}
if (dirty) be.MarkDirty(true);
if (stack.StackSize <= 0) return stored;
}
// Pass 2: place into empty slots.
foreach (var (pos, inv, be) in EnumerateContainers(player, IsStorageContainer))
{
bool dirty = false;
foreach (var slot in inv)
{
if (stack.StackSize <= 0) break;
if (slot.Itemstack != null) continue;
int move = Math.Min(stack.Collectible.MaxStackSize, stack.StackSize);
ItemStack place = stack.Clone();
place.StackSize = move;
slot.Itemstack = place;
stack.StackSize -= move;
stored += move;
slot.MarkDirty();
dirty = true;
}
if (dirty) be.MarkDirty(true);
if (stack.StackSize <= 0) return stored;
}
return stored;
}
}
}