@lzimul/minecraft-mcp 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +126 -67
  3. package/SECURITY.md +38 -21
  4. package/build/config/server-info.js +5 -4
  5. package/build/config/storage.js +2 -1
  6. package/build/config/world-limits.js +6 -1
  7. package/build/data/entities.js +1581 -0
  8. package/build/enums/entity-kind.enum.js +5 -0
  9. package/build/enums/inventory-section.enum.js +5 -0
  10. package/build/enums/move-direction.enum.js +9 -0
  11. package/build/index.js +4 -3
  12. package/build/interfaces/entity-inventory.interface.js +1 -0
  13. package/build/interfaces/entity-type.interface.js +1 -0
  14. package/build/interfaces/entity.interface.js +1 -0
  15. package/build/resources/dimension-resources.js +24 -7
  16. package/build/resources/entity-resources.js +17 -0
  17. package/build/resources/index.js +2 -0
  18. package/build/schemas/coordinate-schema.js +5 -0
  19. package/build/schemas/dimension-schema.js +1 -0
  20. package/build/schemas/entity-schema.js +95 -0
  21. package/build/tools/block-tools.js +14 -23
  22. package/build/tools/entity-tools.js +638 -0
  23. package/build/tools/index.js +4 -0
  24. package/build/tools/result.js +3 -5
  25. package/build/tools/world-tools.js +526 -0
  26. package/build/utils/logger.js +53 -0
  27. package/build/world/block-spec.js +8 -6
  28. package/build/world/block-states.js +23 -4
  29. package/build/world/entity-inventory.js +101 -0
  30. package/build/world/item-spec.js +25 -0
  31. package/build/world/simulated-dimension.js +314 -14
  32. package/build/world/stores/file-world-store.js +84 -29
  33. package/build/world/stores/memory-world-store.js +18 -5
  34. package/build/world/stores/serialized-block.js +9 -14
  35. package/build/world/stores/serialized-entity.js +75 -0
  36. package/build/world/stores/serialized-snapshot.js +35 -0
  37. package/build/world/world.js +33 -1
  38. package/lib/app/server.d.ts.map +1 -1
  39. package/lib/config/dimensions.d.ts.map +1 -1
  40. package/lib/config/server-info.d.ts.map +1 -1
  41. package/lib/config/storage.d.ts.map +1 -1
  42. package/lib/config/world-limits.d.ts +5 -0
  43. package/lib/config/world-limits.d.ts.map +1 -1
  44. package/lib/data/entities.d.ts +5 -0
  45. package/lib/data/entities.d.ts.map +1 -0
  46. package/lib/enums/bounding-box.enum.d.ts.map +1 -1
  47. package/lib/enums/dimension-type.enum.d.ts.map +1 -1
  48. package/lib/enums/entity-kind.enum.d.ts +5 -0
  49. package/lib/enums/entity-kind.enum.d.ts.map +1 -0
  50. package/lib/enums/icon-theme.enum.d.ts.map +1 -1
  51. package/lib/enums/inventory-section.enum.d.ts +5 -0
  52. package/lib/enums/inventory-section.enum.d.ts.map +1 -0
  53. package/lib/enums/move-direction.enum.d.ts +9 -0
  54. package/lib/enums/move-direction.enum.d.ts.map +1 -0
  55. package/lib/interfaces/block.interface.d.ts.map +1 -1
  56. package/lib/interfaces/data-entry.interface.d.ts.map +1 -1
  57. package/lib/interfaces/dimension.interface.d.ts.map +1 -1
  58. package/lib/interfaces/entity-inventory.interface.d.ts +9 -0
  59. package/lib/interfaces/entity-inventory.interface.d.ts.map +1 -0
  60. package/lib/interfaces/entity-type.interface.d.ts +8 -0
  61. package/lib/interfaces/entity-type.interface.d.ts.map +1 -0
  62. package/lib/interfaces/entity.interface.d.ts +17 -0
  63. package/lib/interfaces/entity.interface.d.ts.map +1 -0
  64. package/lib/interfaces/item.interface.d.ts.map +1 -1
  65. package/lib/interfaces/placed-block.interface.d.ts.map +1 -1
  66. package/lib/interfaces/world-store.interface.d.ts +8 -2
  67. package/lib/interfaces/world-store.interface.d.ts.map +1 -1
  68. package/lib/resources/block-resources.d.ts.map +1 -1
  69. package/lib/resources/data-resources.d.ts.map +1 -1
  70. package/lib/resources/dimension-resources.d.ts.map +1 -1
  71. package/lib/resources/entity-resources.d.ts +3 -0
  72. package/lib/resources/entity-resources.d.ts.map +1 -0
  73. package/lib/resources/index.d.ts.map +1 -1
  74. package/lib/resources/item-resources.d.ts.map +1 -1
  75. package/lib/schemas/block-schema.d.ts.map +1 -1
  76. package/lib/schemas/coordinate-schema.d.ts +5 -0
  77. package/lib/schemas/coordinate-schema.d.ts.map +1 -1
  78. package/lib/schemas/dimension-schema.d.ts +1 -0
  79. package/lib/schemas/dimension-schema.d.ts.map +1 -1
  80. package/lib/schemas/entity-schema.d.ts +134 -0
  81. package/lib/schemas/entity-schema.d.ts.map +1 -0
  82. package/lib/schemas/item-schema.d.ts.map +1 -1
  83. package/lib/tools/block-tools.d.ts.map +1 -1
  84. package/lib/tools/entity-tools.d.ts +4 -0
  85. package/lib/tools/entity-tools.d.ts.map +1 -0
  86. package/lib/tools/index.d.ts.map +1 -1
  87. package/lib/tools/item-tools.d.ts.map +1 -1
  88. package/lib/tools/result.d.ts +2 -6
  89. package/lib/tools/result.d.ts.map +1 -1
  90. package/lib/tools/world-tools.d.ts +4 -0
  91. package/lib/tools/world-tools.d.ts.map +1 -0
  92. package/lib/utils/logger.d.ts +3 -0
  93. package/lib/utils/logger.d.ts.map +1 -0
  94. package/lib/world/block-spec.d.ts.map +1 -1
  95. package/lib/world/block-states.d.ts +2 -0
  96. package/lib/world/block-states.d.ts.map +1 -1
  97. package/lib/world/entity-inventory.d.ts +16 -0
  98. package/lib/world/entity-inventory.d.ts.map +1 -0
  99. package/lib/world/item-spec.d.ts +10 -0
  100. package/lib/world/item-spec.d.ts.map +1 -0
  101. package/lib/world/simulated-dimension.d.ts +54 -0
  102. package/lib/world/simulated-dimension.d.ts.map +1 -1
  103. package/lib/world/stores/file-world-store.d.ts +4 -4
  104. package/lib/world/stores/file-world-store.d.ts.map +1 -1
  105. package/lib/world/stores/index.d.ts.map +1 -1
  106. package/lib/world/stores/memory-world-store.d.ts +4 -4
  107. package/lib/world/stores/memory-world-store.d.ts.map +1 -1
  108. package/lib/world/stores/serialized-block.d.ts.map +1 -1
  109. package/lib/world/stores/serialized-entity.d.ts +24 -0
  110. package/lib/world/stores/serialized-entity.d.ts.map +1 -0
  111. package/lib/world/stores/serialized-snapshot.d.ts +6 -0
  112. package/lib/world/stores/serialized-snapshot.d.ts.map +1 -0
  113. package/lib/world/world.d.ts +3 -0
  114. package/lib/world/world.d.ts.map +1 -1
  115. package/package.json +16 -12
@@ -0,0 +1,101 @@
1
+ import { getItemByName } from '../data/items.js';
2
+ import { EInventorySection } from '../enums/inventory-section.enum.js';
3
+ export const HOTBAR_SLOT_COUNT = 9;
4
+ export const INVENTORY_SLOT_COUNT = 27;
5
+ export const TOTAL_SLOT_COUNT = HOTBAR_SLOT_COUNT + INVENTORY_SLOT_COUNT;
6
+ export function slotCount(section) {
7
+ return section === EInventorySection.Hotbar
8
+ ? HOTBAR_SLOT_COUNT
9
+ : INVENTORY_SLOT_COUNT;
10
+ }
11
+ export function isSlotInRange(section, slot) {
12
+ return Number.isInteger(slot) && slot >= 0 && slot < slotCount(section);
13
+ }
14
+ export function createEmptyInventory() {
15
+ return {
16
+ hotbar: Array.from({ length: HOTBAR_SLOT_COUNT }, () => null),
17
+ inventory: Array.from({ length: INVENTORY_SLOT_COUNT }, () => null)
18
+ };
19
+ }
20
+ export function normalizeItemStack(value) {
21
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
22
+ return null;
23
+ }
24
+ const record = value;
25
+ const { item, count } = record;
26
+ if (typeof item !== 'string' ||
27
+ typeof count !== 'number' ||
28
+ !Number.isInteger(count) ||
29
+ count <= 0) {
30
+ return null;
31
+ }
32
+ const resolved = getItemByName(item.trim().toLowerCase());
33
+ if (!resolved || resolved.key === 'minecraft:air') {
34
+ return null;
35
+ }
36
+ return {
37
+ item: resolved.key,
38
+ count: Math.min(count, resolved.stackSize)
39
+ };
40
+ }
41
+ function normalizeSlotArray(value, length) {
42
+ const source = Array.isArray(value) ? value : [];
43
+ return Array.from({ length }, (_, index) => normalizeItemStack(source[index]));
44
+ }
45
+ export function normalizeInventory(value) {
46
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
47
+ return createEmptyInventory();
48
+ }
49
+ const record = value;
50
+ return {
51
+ hotbar: normalizeSlotArray(record.hotbar, HOTBAR_SLOT_COUNT),
52
+ inventory: normalizeSlotArray(record.inventory, INVENTORY_SLOT_COUNT)
53
+ };
54
+ }
55
+ export function getInventorySlot(inventory, section, slot) {
56
+ return section === EInventorySection.Hotbar
57
+ ? inventory.hotbar[slot]
58
+ : inventory.inventory[slot];
59
+ }
60
+ export function withInventorySlot(inventory, section, slot, stack) {
61
+ if (section === EInventorySection.Hotbar) {
62
+ const hotbar = [...inventory.hotbar];
63
+ hotbar[slot] = stack;
64
+ return { ...inventory, hotbar };
65
+ }
66
+ const slots = [...inventory.inventory];
67
+ slots[slot] = stack;
68
+ return { ...inventory, inventory: slots };
69
+ }
70
+ export function sameItemStack(a, b) {
71
+ if (a === b) {
72
+ return true;
73
+ }
74
+ return a !== null && b !== null && a.item === b.item && a.count === b.count;
75
+ }
76
+ export function inventoryUsedSlots(inventory) {
77
+ let used = 0;
78
+ for (const stack of inventory.hotbar) {
79
+ if (stack !== null) {
80
+ used += 1;
81
+ }
82
+ }
83
+ for (const stack of inventory.inventory) {
84
+ if (stack !== null) {
85
+ used += 1;
86
+ }
87
+ }
88
+ return used;
89
+ }
90
+ export function sectionUsedSlots(inventory, section) {
91
+ const slots = section === EInventorySection.Hotbar
92
+ ? inventory.hotbar
93
+ : inventory.inventory;
94
+ let used = 0;
95
+ for (const stack of slots) {
96
+ if (stack !== null) {
97
+ used += 1;
98
+ }
99
+ }
100
+ return used;
101
+ }
@@ -0,0 +1,25 @@
1
+ import { getItemByName } from '../data/items.js';
2
+ const ITEM_SPEC = /^(?:([a-z0-9_.-]+):)?([a-z0-9_./-]+)$/;
3
+ export function parseItemSpec(input) {
4
+ const spec = input.trim().toLowerCase();
5
+ const match = ITEM_SPEC.exec(spec);
6
+ if (!match) {
7
+ return {
8
+ ok: false,
9
+ error: `Invalid item "${input}". Expected an id such as "minecraft:diamond_sword" or "diamond_sword".`
10
+ };
11
+ }
12
+ const namespace = match[1];
13
+ const name = match[2];
14
+ let item = getItemByName(namespace ? `${namespace}:${name}` : name);
15
+ if (!item && !namespace) {
16
+ item = getItemByName(`minecraft:${name}`);
17
+ }
18
+ if (!item) {
19
+ return {
20
+ ok: false,
21
+ error: `Unknown item "${input}". Use an item id such as "minecraft:diamond_sword"; see the resource lzimul://minecraft/items for the full list.`
22
+ };
23
+ }
24
+ return { ok: true, item };
25
+ }
@@ -1,20 +1,62 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { WORLD_LIMITS } from '../config/world-limits.js';
2
3
  import { getBlockByName, MINECRAFT_BLOCKS } from '../data/blocks.js';
4
+ import { getEntityTypeByName } from '../data/entities.js';
5
+ import { EEntityKind } from '../enums/entity-kind.enum.js';
6
+ import { EInventorySection } from '../enums/inventory-section.enum.js';
7
+ import { EMoveDirection } from '../enums/move-direction.enum.js';
3
8
  import { toErrorMessage } from '../utils/error-message.js';
4
- import { EMPTY_BLOCK_STATES, normalizeStates, sameStates } from './block-states.js';
9
+ import { logger } from '../utils/logger.js';
10
+ import { EMPTY_BLOCK_STATES, isEmptyStates, normalizeStates, sameStates } from './block-states.js';
11
+ import { createEmptyInventory, getInventorySlot, inventoryUsedSlots, isSlotInRange, sameItemStack, slotCount, withInventorySlot } from './entity-inventory.js';
5
12
  const AIR = getBlockByName('air') ?? MINECRAFT_BLOCKS[0];
13
+ function directionDelta(direction) {
14
+ switch (direction) {
15
+ case EMoveDirection.Forward:
16
+ return { x: 0, y: 0, z: 1 };
17
+ case EMoveDirection.Back:
18
+ return { x: 0, y: 0, z: -1 };
19
+ case EMoveDirection.Left:
20
+ return { x: -1, y: 0, z: 0 };
21
+ case EMoveDirection.Right:
22
+ return { x: 1, y: 0, z: 0 };
23
+ case EMoveDirection.Up:
24
+ return { x: 0, y: 1, z: 0 };
25
+ case EMoveDirection.Down:
26
+ return { x: 0, y: -1, z: 0 };
27
+ default:
28
+ throw new Error(`Unknown movement direction "${direction}".`);
29
+ }
30
+ }
6
31
  function coordinateKey(x, y, z) {
7
32
  return `${x},${y},${z}`;
8
33
  }
34
+ function playerEntityType() {
35
+ const type = getEntityTypeByName('minecraft:player');
36
+ if (!type) {
37
+ throw new Error('The entity registry is missing minecraft:player.');
38
+ }
39
+ return type;
40
+ }
41
+ function compareEntityIds(a, b) {
42
+ if (a.id === b.id) {
43
+ return 0;
44
+ }
45
+ return a.id < b.id ? -1 : 1;
46
+ }
9
47
  export class SimulatedDimension {
10
48
  info;
11
49
  blocks = new Map();
50
+ entities = new Map();
51
+ playersByName = new Map();
12
52
  store;
13
53
  writeDelayMs;
14
54
  timer = null;
15
55
  queue = Promise.resolve();
16
56
  savePromise = null;
17
57
  dirty = false;
58
+ sortedBlocksCache = null;
59
+ sortedEntitiesCache = null;
18
60
  constructor(info, store, writeDelayMs = 25) {
19
61
  this.info = info;
20
62
  this.store = store;
@@ -23,25 +65,81 @@ export class SimulatedDimension {
23
65
  get modifiedBlockCount() {
24
66
  return this.blocks.size;
25
67
  }
68
+ get entityCount() {
69
+ return this.entities.size;
70
+ }
71
+ get playerCount() {
72
+ return this.playersByName.size;
73
+ }
26
74
  get placedBlocks() {
27
75
  return [...this.blocks.values()];
28
76
  }
77
+ get sortedPlacedBlocks() {
78
+ if (!this.sortedBlocksCache) {
79
+ this.sortedBlocksCache = this.placedBlocks.toSorted((a, b) => a.x - b.x || a.y - b.y || a.z - b.z);
80
+ }
81
+ return this.sortedBlocksCache;
82
+ }
83
+ get placedEntities() {
84
+ return [...this.entities.values()];
85
+ }
86
+ get sortedPlacedEntities() {
87
+ if (!this.sortedEntitiesCache) {
88
+ this.sortedEntitiesCache = this.placedEntities.toSorted(compareEntityIds);
89
+ }
90
+ return this.sortedEntitiesCache;
91
+ }
29
92
  async load() {
93
+ if (this.timer !== null) {
94
+ clearTimeout(this.timer);
95
+ this.timer = null;
96
+ }
97
+ await this.queue;
98
+ this.dirty = false;
30
99
  const stored = await this.store.load(this.info.type);
31
- let skipped = 0;
32
- for (const placed of stored) {
100
+ this.blocks.clear();
101
+ this.entities.clear();
102
+ this.playersByName.clear();
103
+ this.sortedBlocksCache = null;
104
+ this.sortedEntitiesCache = null;
105
+ let skippedBlocks = 0;
106
+ for (const placed of stored.blocks) {
33
107
  const key = coordinateKey(placed.x, placed.y, placed.z);
34
- const isNew = !this.blocks.has(key);
35
108
  const outOfRange = !this.contains(placed.x, placed.y, placed.z);
36
- const overLimit = isNew && this.blocks.size >= WORLD_LIMITS.maxModifiedBlocks;
109
+ const overLimit = !this.blocks.has(key) &&
110
+ this.blocks.size >= WORLD_LIMITS.maxModifiedBlocks;
37
111
  if (outOfRange || overLimit) {
38
- skipped++;
112
+ skippedBlocks++;
39
113
  continue;
40
114
  }
41
115
  this.blocks.set(key, placed);
42
116
  }
43
- if (skipped > 0) {
44
- console.error(`[lzimul-minecraft-mcp] Skipped ${skipped} invalid block(s) while loading ${this.info.key}`);
117
+ let skippedEntities = 0;
118
+ for (const entity of stored.entities) {
119
+ const duplicateId = this.entities.has(entity.id);
120
+ const outOfRange = !this.contains(entity.x, entity.y, entity.z) ||
121
+ entity.dimension !== this.info.type;
122
+ const overLimit = !duplicateId &&
123
+ this.entities.size >= WORLD_LIMITS.maxEntitiesPerDimension;
124
+ if (duplicateId || outOfRange || overLimit) {
125
+ skippedEntities++;
126
+ continue;
127
+ }
128
+ if (entity.kind === EEntityKind.Player) {
129
+ const playerName = entity.name.trim().toLowerCase();
130
+ if (this.playersByName.has(playerName)) {
131
+ skippedEntities++;
132
+ continue;
133
+ }
134
+ this.playersByName.set(playerName, entity);
135
+ }
136
+ this.entities.set(entity.id, entity);
137
+ }
138
+ if (skippedBlocks > 0) {
139
+ logger.warn(`Skipped ${skippedBlocks} invalid block(s) while loading ${this.info.key}`);
140
+ }
141
+ if (skippedEntities > 0) {
142
+ logger.warn(`Skipped ${skippedEntities} invalid entity/player record(s) while loading ${this.info.key}`);
45
143
  }
46
144
  }
47
145
  contains(x, y, z) {
@@ -56,12 +154,203 @@ export class SimulatedDimension {
56
154
  return (this.blocks.has(coordinateKey(x, y, z)) ||
57
155
  this.blocks.size < WORLD_LIMITS.maxModifiedBlocks);
58
156
  }
157
+ canAddEntity() {
158
+ return this.entities.size < WORLD_LIMITS.maxEntitiesPerDimension;
159
+ }
59
160
  getBlock(x, y, z) {
60
161
  return this.blocks.get(coordinateKey(x, y, z))?.block ?? AIR;
61
162
  }
62
163
  getStates(x, y, z) {
63
164
  return (this.blocks.get(coordinateKey(x, y, z))?.states ?? EMPTY_BLOCK_STATES);
64
165
  }
166
+ getBlockState(x, y, z) {
167
+ const placed = this.blocks.get(coordinateKey(x, y, z));
168
+ return placed
169
+ ? { block: placed.block, states: placed.states }
170
+ : { block: AIR, states: EMPTY_BLOCK_STATES };
171
+ }
172
+ getEntity(id) {
173
+ return this.entities.get(id);
174
+ }
175
+ findPlayer(name) {
176
+ return this.playersByName.get(name.trim().toLowerCase());
177
+ }
178
+ removeDuplicatePlayers(claimedNames) {
179
+ let removed = 0;
180
+ for (const entity of this.entities.values()) {
181
+ if (entity.kind !== EEntityKind.Player) {
182
+ continue;
183
+ }
184
+ const name = entity.name.trim().toLowerCase();
185
+ if (claimedNames.has(name)) {
186
+ this.entities.delete(entity.id);
187
+ this.playersByName.delete(name);
188
+ removed++;
189
+ }
190
+ else {
191
+ claimedNames.add(name);
192
+ }
193
+ }
194
+ if (removed > 0) {
195
+ this.sortedEntitiesCache = null;
196
+ }
197
+ return removed;
198
+ }
199
+ createEntity(type, name, x, y, z) {
200
+ if (type.name === 'player') {
201
+ throw new Error('Use createPlayer to create a player; createEntity only creates non-player entities.');
202
+ }
203
+ return this.addEntity(EEntityKind.Entity, type, name, x, y, z);
204
+ }
205
+ createPlayer(name, x, y, z) {
206
+ if (this.findPlayer(name)) {
207
+ throw new Error(`A player named "${name}" already exists in ${this.info.displayName}. Player names are case-insensitive.`);
208
+ }
209
+ return this.addEntity(EEntityKind.Player, playerEntityType(), name, x, y, z);
210
+ }
211
+ addEntity(kind, type, name, x, y, z) {
212
+ if (!this.canAddEntity()) {
213
+ throw new RangeError(`The ${this.info.displayName} world already holds the maximum of ${WORLD_LIMITS.maxEntitiesPerDimension} entities.`);
214
+ }
215
+ const entity = {
216
+ id: randomUUID(),
217
+ kind,
218
+ type,
219
+ name,
220
+ x,
221
+ y,
222
+ z,
223
+ dimension: this.info.type,
224
+ inventory: createEmptyInventory(),
225
+ createdAt: new Date().toISOString()
226
+ };
227
+ this.entities.set(entity.id, entity);
228
+ if (kind === EEntityKind.Player) {
229
+ this.playersByName.set(entity.name.trim().toLowerCase(), entity);
230
+ }
231
+ this.sortedEntitiesCache = null;
232
+ this.markDirty();
233
+ return entity;
234
+ }
235
+ setInventorySlot(id, section, slot, stack) {
236
+ const entity = this.entities.get(id);
237
+ if (!entity) {
238
+ throw new RangeError(`Unknown entity "${id}" in ${this.info.displayName}.`);
239
+ }
240
+ if (!isSlotInRange(section, slot)) {
241
+ throw new RangeError(`Slot ${slot} is out of range for the ${section} (0-${slotCount(section) - 1}).`);
242
+ }
243
+ const previous = getInventorySlot(entity.inventory, section, slot);
244
+ if (sameItemStack(previous, stack)) {
245
+ return { previous, entity };
246
+ }
247
+ const updated = {
248
+ ...entity,
249
+ inventory: withInventorySlot(entity.inventory, section, slot, stack)
250
+ };
251
+ this.entities.set(id, updated);
252
+ if (updated.kind === EEntityKind.Player) {
253
+ this.playersByName.set(updated.name.trim().toLowerCase(), updated);
254
+ }
255
+ this.sortedEntitiesCache = null;
256
+ this.markDirty();
257
+ return { previous, entity: updated };
258
+ }
259
+ deleteEntity(id) {
260
+ const entity = this.entities.get(id);
261
+ if (!entity) {
262
+ return;
263
+ }
264
+ this.entities.delete(id);
265
+ if (entity.kind === EEntityKind.Player) {
266
+ this.playersByName.delete(entity.name.trim().toLowerCase());
267
+ }
268
+ this.sortedEntitiesCache = null;
269
+ this.markDirty();
270
+ return entity;
271
+ }
272
+ clearInventory(id) {
273
+ const entity = this.entities.get(id);
274
+ if (!entity) {
275
+ throw new RangeError(`Unknown entity "${id}" in ${this.info.displayName}.`);
276
+ }
277
+ const clearedSlots = inventoryUsedSlots(entity.inventory);
278
+ if (clearedSlots === 0) {
279
+ return { clearedSlots, entity };
280
+ }
281
+ const updated = {
282
+ ...entity,
283
+ inventory: createEmptyInventory()
284
+ };
285
+ this.entities.set(id, updated);
286
+ if (updated.kind === EEntityKind.Player) {
287
+ this.playersByName.set(updated.name.trim().toLowerCase(), updated);
288
+ }
289
+ this.sortedEntitiesCache = null;
290
+ this.markDirty();
291
+ return { clearedSlots, entity: updated };
292
+ }
293
+ swapInventorySlots(id, fromSection, fromSlot, toSection, toSlot) {
294
+ const entity = this.entities.get(id);
295
+ if (!entity) {
296
+ throw new RangeError(`Unknown entity "${id}" in ${this.info.displayName}.`);
297
+ }
298
+ if (!isSlotInRange(fromSection, fromSlot)) {
299
+ throw new RangeError(`Slot ${fromSlot} is out of range for the ${fromSection} (0-${slotCount(fromSection) - 1}).`);
300
+ }
301
+ if (!isSlotInRange(toSection, toSlot)) {
302
+ throw new RangeError(`Slot ${toSlot} is out of range for the ${toSection} (0-${slotCount(toSection) - 1}).`);
303
+ }
304
+ const previousFrom = getInventorySlot(entity.inventory, fromSection, fromSlot);
305
+ const previousTo = getInventorySlot(entity.inventory, toSection, toSlot);
306
+ const sameSlot = fromSection === toSection && fromSlot === toSlot;
307
+ if (sameSlot || sameItemStack(previousFrom, previousTo)) {
308
+ return { previousFrom, previousTo, entity };
309
+ }
310
+ let inventory = withInventorySlot(entity.inventory, fromSection, fromSlot, previousTo);
311
+ inventory = withInventorySlot(inventory, toSection, toSlot, previousFrom);
312
+ const updated = { ...entity, inventory };
313
+ this.entities.set(id, updated);
314
+ if (updated.kind === EEntityKind.Player) {
315
+ this.playersByName.set(updated.name.trim().toLowerCase(), updated);
316
+ }
317
+ this.sortedEntitiesCache = null;
318
+ this.markDirty();
319
+ return { previousFrom, previousTo, entity: updated };
320
+ }
321
+ moveEntity(id, direction) {
322
+ const entity = this.entities.get(id);
323
+ if (!entity) {
324
+ throw new RangeError(`Unknown entity "${id}" in ${this.info.displayName}.`);
325
+ }
326
+ const delta = directionDelta(direction);
327
+ return this.setEntityPosition(id, entity.x + delta.x, entity.y + delta.y, entity.z + delta.z);
328
+ }
329
+ setEntityPosition(id, x, y, z) {
330
+ const entity = this.entities.get(id);
331
+ if (!entity) {
332
+ throw new RangeError(`Unknown entity "${id}" in ${this.info.displayName}.`);
333
+ }
334
+ if (!this.contains(x, y, z)) {
335
+ throw new RangeError(`Position (${x}, ${y}, ${z}) is out of range in ${this.info.displayName} (Y: ${this.info.minY} to ${this.info.maxY - 1}).`);
336
+ }
337
+ const previous = {
338
+ x: entity.x,
339
+ y: entity.y,
340
+ z: entity.z
341
+ };
342
+ if (entity.x === x && entity.y === y && entity.z === z) {
343
+ return { previous, entity };
344
+ }
345
+ const updated = { ...entity, x, y, z };
346
+ this.entities.set(id, updated);
347
+ if (updated.kind === EEntityKind.Player) {
348
+ this.playersByName.set(updated.name.trim().toLowerCase(), updated);
349
+ }
350
+ this.sortedEntitiesCache = null;
351
+ this.markDirty();
352
+ return { previous, entity: updated };
353
+ }
65
354
  setBlock(x, y, z, block, states = EMPTY_BLOCK_STATES) {
66
355
  const key = coordinateKey(x, y, z);
67
356
  const existing = this.blocks.get(key);
@@ -71,7 +360,7 @@ export class SimulatedDimension {
71
360
  sameStates(existing?.states ?? EMPTY_BLOCK_STATES, normalized)) {
72
361
  return previous;
73
362
  }
74
- const isRemoval = block.key === AIR.key && Object.keys(normalized).length === 0;
363
+ const isRemoval = block.key === AIR.key && isEmptyStates(normalized);
75
364
  if (!existing &&
76
365
  !isRemoval &&
77
366
  this.blocks.size >= WORLD_LIMITS.maxModifiedBlocks) {
@@ -83,10 +372,14 @@ export class SimulatedDimension {
83
372
  else {
84
373
  this.blocks.set(key, { x, y, z, block, states: normalized });
85
374
  }
86
- this.dirty = true;
87
- this.scheduleFlush();
375
+ this.sortedBlocksCache = null;
376
+ this.markDirty();
88
377
  return previous;
89
378
  }
379
+ async readRaw() {
380
+ await this.flush();
381
+ return this.store.readRaw(this.info.type);
382
+ }
90
383
  async flush() {
91
384
  if (this.timer !== null) {
92
385
  clearTimeout(this.timer);
@@ -100,7 +393,10 @@ export class SimulatedDimension {
100
393
  return;
101
394
  }
102
395
  this.dirty = false;
103
- const snapshot = this.placedBlocks;
396
+ const snapshot = {
397
+ blocks: this.sortedPlacedBlocks,
398
+ entities: this.sortedPlacedEntities
399
+ };
104
400
  const run = this.queue.then(async () => {
105
401
  await this.store.save(this.info.type, snapshot);
106
402
  });
@@ -115,14 +411,18 @@ export class SimulatedDimension {
115
411
  this.savePromise = null;
116
412
  }
117
413
  }
414
+ markDirty() {
415
+ this.dirty = true;
416
+ this.scheduleFlush();
417
+ }
118
418
  scheduleFlush() {
119
419
  if (this.timer !== null) {
120
420
  return;
121
421
  }
122
422
  this.timer = setTimeout(() => {
123
423
  this.timer = null;
124
- void this.flush().catch((error) => {
125
- console.error(`[lzimul-minecraft-mcp] Failed to persist ${this.info.key}: ${toErrorMessage(error)}`);
424
+ void this.flush().catch(error => {
425
+ logger.error(`Failed to persist ${this.info.key}: ${toErrorMessage(error)}`);
126
426
  });
127
427
  }, this.writeDelayMs);
128
428
  }
@@ -3,11 +3,15 @@ import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { WORLD_LIMITS } from '../../config/world-limits.js';
5
5
  import { toErrorMessage } from '../../utils/error-message.js';
6
- import { fromSerialized, toSerialized } from './serialized-block.js';
7
- const FILE_FORMAT = 'lzimul-world/2';
8
- const MAX_SNAPSHOT_BYTES = 128 * 1024 * 1024;
6
+ import { logger } from '../../utils/logger.js';
7
+ import { fromSerialized } from './serialized-block.js';
8
+ import { fromSerializedEntity } from './serialized-entity.js';
9
+ import { MAX_SNAPSHOT_BYTES, serializeSnapshot } from './serialized-snapshot.js';
9
10
  function warn(message) {
10
- console.error(`[lzimul-minecraft-mcp] ${message}`);
11
+ logger.warn(message);
12
+ }
13
+ function emptySnapshot() {
14
+ return { blocks: [], entities: [] };
11
15
  }
12
16
  export class FileWorldStore {
13
17
  directory;
@@ -31,13 +35,13 @@ export class FileWorldStore {
31
35
  const info = await stat(file);
32
36
  if (info.size > MAX_SNAPSHOT_BYTES) {
33
37
  await this.quarantine(file, `snapshot is larger than ${MAX_SNAPSHOT_BYTES} bytes`);
34
- return [];
38
+ return emptySnapshot();
35
39
  }
36
40
  raw = await readFile(file, 'utf8');
37
41
  }
38
42
  catch (error) {
39
43
  if (error.code === 'ENOENT') {
40
- return [];
44
+ return emptySnapshot();
41
45
  }
42
46
  throw new Error(`Cannot read world snapshot ${file}: ${toErrorMessage(error)}`, { cause: error });
43
47
  }
@@ -47,42 +51,93 @@ export class FileWorldStore {
47
51
  }
48
52
  catch {
49
53
  await this.quarantine(file, 'invalid JSON');
50
- return [];
54
+ return emptySnapshot();
51
55
  }
52
- if (!parsed || !Array.isArray(parsed.blocks)) {
56
+ if (!parsed ||
57
+ typeof parsed !== 'object' ||
58
+ Array.isArray(parsed) ||
59
+ !Array.isArray(parsed.blocks)) {
53
60
  await this.quarantine(file, 'missing blocks array');
54
- return [];
61
+ return emptySnapshot();
62
+ }
63
+ const stored = parsed;
64
+ if (stored.blocks.length > WORLD_LIMITS.maxModifiedBlocks) {
65
+ await this.quarantine(file, `${stored.blocks.length} blocks exceed the limit of ${WORLD_LIMITS.maxModifiedBlocks}`);
66
+ return emptySnapshot();
55
67
  }
56
- if (parsed.blocks.length > WORLD_LIMITS.maxModifiedBlocks) {
57
- await this.quarantine(file, `${parsed.blocks.length} blocks exceed the limit of ${WORLD_LIMITS.maxModifiedBlocks}`);
58
- return [];
68
+ let serializedEntities = [];
69
+ if (typeof stored.entities !== 'undefined') {
70
+ if (!Array.isArray(stored.entities)) {
71
+ await this.quarantine(file, 'invalid entities array');
72
+ return emptySnapshot();
73
+ }
74
+ if (stored.entities.length > WORLD_LIMITS.maxEntitiesPerDimension) {
75
+ await this.quarantine(file, `${stored.entities.length} entities exceed the limit of ${WORLD_LIMITS.maxEntitiesPerDimension}`);
76
+ return emptySnapshot();
77
+ }
78
+ serializedEntities = stored.entities;
59
79
  }
60
- return parsed.blocks.flatMap(entry => {
80
+ const blocks = [];
81
+ for (const entry of stored.blocks) {
61
82
  const block = fromSerialized(entry);
62
83
  if (block) {
63
- return [block];
84
+ blocks.push(block);
85
+ }
86
+ else {
87
+ warn(`Skipped malformed block entry in ${file}`);
88
+ }
89
+ }
90
+ const entities = [];
91
+ for (const entry of serializedEntities) {
92
+ try {
93
+ const entity = fromSerializedEntity(entry, type);
94
+ if (entity) {
95
+ entities.push(entity);
96
+ continue;
97
+ }
98
+ }
99
+ catch {
100
+ }
101
+ warn(`Skipped malformed entity entry in ${file}`);
102
+ }
103
+ return { blocks, entities };
104
+ }
105
+ async readRaw(type) {
106
+ const file = this.fileFor(type);
107
+ try {
108
+ const info = await stat(file);
109
+ if (info.size > MAX_SNAPSHOT_BYTES) {
110
+ throw new RangeError(`World snapshot ${file} is larger than ${MAX_SNAPSHOT_BYTES} bytes`);
111
+ }
112
+ return await readFile(file, 'utf8');
113
+ }
114
+ catch (error) {
115
+ if (error.code === 'ENOENT') {
116
+ return serializeSnapshot(type, emptySnapshot());
117
+ }
118
+ if (error instanceof RangeError) {
119
+ throw error;
64
120
  }
65
- warn(`Skipped malformed block entry in ${file}`);
66
- return [];
67
- });
121
+ throw new Error(`Cannot read world snapshot ${file}: ${toErrorMessage(error)}`, { cause: error });
122
+ }
68
123
  }
69
- async save(type, blocks) {
70
- if (blocks.length > WORLD_LIMITS.maxModifiedBlocks) {
71
- throw new RangeError(`World snapshot has ${blocks.length} blocks, above the limit of ${WORLD_LIMITS.maxModifiedBlocks}`);
124
+ async save(type, snapshot) {
125
+ if (snapshot.blocks.length > WORLD_LIMITS.maxModifiedBlocks) {
126
+ throw new RangeError(`World snapshot has ${snapshot.blocks.length} blocks, above the limit of ${WORLD_LIMITS.maxModifiedBlocks}`);
127
+ }
128
+ if (snapshot.entities.length > WORLD_LIMITS.maxEntitiesPerDimension) {
129
+ throw new RangeError(`World snapshot has ${snapshot.entities.length} entities, above the limit of ${WORLD_LIMITS.maxEntitiesPerDimension}`);
72
130
  }
73
131
  const file = this.fileFor(type);
132
+ const text = serializeSnapshot(type, snapshot);
133
+ const bytes = Buffer.byteLength(text, 'utf8');
134
+ if (bytes > MAX_SNAPSHOT_BYTES) {
135
+ throw new RangeError(`World snapshot for ${type} would be ${bytes} bytes, above the load limit of ${MAX_SNAPSHOT_BYTES} bytes. Remove blocks, block states or entities and retry.`);
136
+ }
74
137
  try {
75
138
  await this.ensureDirectory();
76
- const sorted = blocks.toSorted((a, b) => a.x - b.x || a.y - b.y || a.z - b.z);
77
- const payload = {
78
- format: FILE_FORMAT,
79
- dimension: `minecraft:${type}`,
80
- updatedAt: new Date().toISOString(),
81
- modifiedBlockCount: sorted.length,
82
- blocks: sorted.map(toSerialized)
83
- };
84
139
  const temporary = `${file}.tmp`;
85
- await writeFile(temporary, `${JSON.stringify(payload)}\n`, 'utf8');
140
+ await writeFile(temporary, text, 'utf8');
86
141
  await rename(temporary, file);
87
142
  }
88
143
  catch (error) {