-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathBookkeeperModSystem.cs
More file actions
318 lines (282 loc) · 14.2 KB
/
Copy pathBookkeeperModSystem.cs
File metadata and controls
318 lines (282 loc) · 14.2 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
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Vintagestory.API.Client;
using Vintagestory.API.Common;
using Vintagestory.API.Server;
using Vintagestory.API.Util;
using Vintagestory.API.Datastructures;
using Vintagestory.API.MathTools;
using Vintagestory.GameContent;
namespace Bookkeeper
{
public class BookkeeperModSystem : ModSystem
{
public static IClientNetworkChannel clientChannel;
public static IServerNetworkChannel serverChannel;
public static GuiDialogBookkeeper dialog;
private List<BlockPos> activeHighlights = new List<BlockPos>();
private WaypointMapLayer wpLayer;
private ContainerLabelRenderer labelRenderer;
private ICoreClientAPI capi;
private static BookkeeperConfig config;
public override void Start(ICoreAPI api)
{
api.RegisterBlockClass("BlockBookkeeperLectern", typeof(BlockBookkeeperLectern));
api.Network.RegisterChannel("bookkeeper")
.RegisterMessageType(typeof(PacketBookkeeperRequest))
.RegisterMessageType(typeof(PacketBookkeeperResponse))
.RegisterMessageType(typeof(SimplePos));
}
public override void StartClientSide(ICoreClientAPI api)
{
this.capi = api;
clientChannel = capi.Network.GetChannel("bookkeeper")
.SetMessageHandler<PacketBookkeeperResponse>(OnBookkeeperDataReceived);
dialog = new GuiDialogBookkeeper(capi, this);
capi.Input.RegisterHotKey("bookkeeper", "Open Storage Bookkeeper", GlKeys.K, HotkeyType.GUIOrOtherControls);
capi.Input.SetHotKeyHandler("bookkeeper", OnHotKey);
// Uses GameTickListener for instant click detection/removal
capi.Event.RegisterGameTickListener(OnClientTick, 50);
// 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)
{
try
{
config = sapi.LoadModConfig<BookkeeperConfig>("BookkeeperConfig.json");
}
catch { }
if (config == null)
{
config = new BookkeeperConfig();
}
sapi.StoreModConfig(config, "BookkeeperConfig.json");
serverChannel = sapi.Network.GetChannel("bookkeeper")
.SetMessageHandler<PacketBookkeeperRequest>(OnClientRequest);
}
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)
{
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)
{
RefreshHighlights();
}
}
public void SetHighlights(List<BlockPos> positions, string itemName = null, Dictionary<string, int> perLocationCounts = null)
{
// Clear previous highlights
activeHighlights.Clear();
activeHighlights.AddRange(positions);
RefreshHighlights();
// Build and display floating labels
if (labelRenderer != null && itemName != null)
{
var labelList = 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 = count > 0 ? $"{itemName} x{count}" : itemName;
labelList.Add(new ContainerLabel
{
X = pos.X,
Y = pos.Y,
Z = pos.Z,
Text = labelText
});
}
labelRenderer.SetLabels(labelList);
}
else if (labelRenderer != null)
{
labelRenderer.ClearLabels();
}
// Lazy-load the waypoint layer
if (wpLayer == null)
{
try
{
var mapManager = capi.ModLoader.GetModSystem<WorldMapManager>();
if (mapManager?.MapLayers != null)
{
wpLayer = mapManager.MapLayers.OfType<WaypointMapLayer>().FirstOrDefault();
}
}
catch (Exception ex)
{
capi.ShowChatMessage($"Bookkeeper: Could not access waypoint system: {ex.Message}");
}
}
// Create temporary waypoints visible through walls
if (wpLayer != null)
{
foreach (var pos in positions)
{
var wp = new Waypoint
{
Position = new Vec3d(pos.X + 0.5, pos.Y + 0.5, pos.Z + 0.5),
Title = itemName ?? "Bookkeeper",
Icon = "circle",
Color = ColorUtil.ToRgba(255, 51, 153, 255),
ShowInWorld = true,
Pinned = false,
Temporary = true,
OwningPlayerUid = capi.World.Player.PlayerUID
};
wpLayer.AddTemporaryWaypoint(wp);
}
capi.ShowChatMessage($"Locating {positions.Count} containers. Look for floating labels through walls.");
}
else
{
capi.ShowChatMessage($"Locating {positions.Count} containers. Look for floating labels.");
}
}
private void ClearBookkeeperWaypoints()
{
}
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);
} else {
capi.World.HighlightBlocks(capi.World.Player, 56, new List<BlockPos>(), new List<int>());
// Also clear floating labels when all highlights are dismissed
labelRenderer?.ClearLabels();
}
}
private void OnClientRequest(IServerPlayer player, PacketBookkeeperRequest packet)
{
Dictionary<string, BookkeeperItemDTO> consolidated = new Dictionary<string, BookkeeperItemDTO>();
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) {
// Never index work-station/processing inventories (firepit, quern,
// anvil, etc.) — they hold items mid-process, not in storage.
if (entry.Value == null || IsProcessingDevice(entry.Value)) continue;
// Honor land claims: skip containers the player isn't allowed to use.
if (config.HonorClaims && !HasClaimAccess(player, entry.Key)) continue;
// Standard containers (chests, vessels, crates, barrels, etc.)
if (entry.Value is IBlockEntityContainer container && container.Inventory != null)
{
ScanInventory(container.Inventory, consolidated, entry.Key);
}
// Display entities (tool racks, display cases, shelves, etc.)
// These don't implement IBlockEntityContainer but have an Inventory property.
else
{
// Try public "Inventory" property first
var invProp = entry.Value.GetType().GetProperty("Inventory");
if (invProp != null)
{
var inv = invProp.GetValue(entry.Value) as IInventory;
if (inv != null)
{
ScanInventory(inv, consolidated, entry.Key);
continue;
}
}
// Fallback: try "inventory" field (some VS classes use lowercase)
var invField = entry.Value.GetType().GetField("inventory",
BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
if (invField != null)
{
var inv = invField.GetValue(entry.Value) as IInventory;
if (inv != null)
{
ScanInventory(inv, consolidated, entry.Key);
}
}
}
}
}
}
}
serverChannel.SendPacket(new PacketBookkeeperResponse { Items = consolidated.Values.ToList() }, player);
}
// Work-station / processing block entities hold items mid-process, not in storage
// (cooking pot on a firepit, ore in a bloomery, workitem on an anvil, grain in a quern,
// iron plates packed in a stone coffin mid-cementation).
// Excluded so the ledger reflects real storage only; matches Quartermaster's behavior.
// 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 ledger 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;
}
private void ScanInventory(IInventory inventory, Dictionary<string, BookkeeperItemDTO> 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 BookkeeperItemDTO {
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 OnBookkeeperDataReceived(PacketBookkeeperResponse packet) => dialog?.UpdateDataFromServer(packet.Items);
}
}