@lzimul/minecraft-mcp 1.0.2 → 1.0.4

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 (88) hide show
  1. package/README.md +219 -168
  2. package/SECURITY.md +38 -21
  3. package/build/config/server-info.js +3 -2
  4. package/build/config/world-limits.js +4 -1
  5. package/build/data/entities.js +1581 -0
  6. package/build/enums/entity-kind.enum.js +5 -0
  7. package/build/enums/inventory-section.enum.js +5 -0
  8. package/build/index.js +4 -3
  9. package/build/interfaces/entity-inventory.interface.js +1 -0
  10. package/build/interfaces/entity-type.interface.js +1 -0
  11. package/build/interfaces/entity.interface.js +1 -0
  12. package/build/resources/dimension-resources.js +23 -6
  13. package/build/resources/entity-resources.js +17 -0
  14. package/build/resources/index.js +2 -0
  15. package/build/schemas/coordinate-schema.js +5 -0
  16. package/build/schemas/dimension-schema.js +1 -0
  17. package/build/schemas/entity-schema.js +95 -0
  18. package/build/tools/block-tools.js +14 -23
  19. package/build/tools/entity-tools.js +325 -0
  20. package/build/tools/index.js +4 -0
  21. package/build/tools/result.js +3 -5
  22. package/build/tools/world-tools.js +139 -0
  23. package/build/utils/logger.js +53 -0
  24. package/build/world/block-spec.js +6 -6
  25. package/build/world/block-states.js +23 -4
  26. package/build/world/entity-inventory.js +84 -0
  27. package/build/world/item-spec.js +25 -0
  28. package/build/world/simulated-dimension.js +187 -13
  29. package/build/world/stores/file-world-store.js +67 -20
  30. package/build/world/stores/memory-world-store.js +13 -5
  31. package/build/world/stores/serialized-block.js +9 -14
  32. package/build/world/stores/serialized-entity.js +74 -0
  33. package/build/world/world.js +33 -1
  34. package/lib/config/server-info.d.ts.map +1 -1
  35. package/lib/config/world-limits.d.ts +3 -0
  36. package/lib/config/world-limits.d.ts.map +1 -1
  37. package/lib/data/entities.d.ts +5 -0
  38. package/lib/data/entities.d.ts.map +1 -0
  39. package/lib/enums/entity-kind.enum.d.ts +5 -0
  40. package/lib/enums/entity-kind.enum.d.ts.map +1 -0
  41. package/lib/enums/inventory-section.enum.d.ts +5 -0
  42. package/lib/enums/inventory-section.enum.d.ts.map +1 -0
  43. package/lib/interfaces/entity-inventory.interface.d.ts +9 -0
  44. package/lib/interfaces/entity-inventory.interface.d.ts.map +1 -0
  45. package/lib/interfaces/entity-type.interface.d.ts +8 -0
  46. package/lib/interfaces/entity-type.interface.d.ts.map +1 -0
  47. package/lib/interfaces/entity.interface.d.ts +17 -0
  48. package/lib/interfaces/entity.interface.d.ts.map +1 -0
  49. package/lib/interfaces/world-store.interface.d.ts +7 -2
  50. package/lib/interfaces/world-store.interface.d.ts.map +1 -1
  51. package/lib/resources/dimension-resources.d.ts.map +1 -1
  52. package/lib/resources/entity-resources.d.ts +3 -0
  53. package/lib/resources/entity-resources.d.ts.map +1 -0
  54. package/lib/resources/index.d.ts.map +1 -1
  55. package/lib/schemas/coordinate-schema.d.ts +5 -0
  56. package/lib/schemas/coordinate-schema.d.ts.map +1 -1
  57. package/lib/schemas/dimension-schema.d.ts +1 -0
  58. package/lib/schemas/dimension-schema.d.ts.map +1 -1
  59. package/lib/schemas/entity-schema.d.ts +134 -0
  60. package/lib/schemas/entity-schema.d.ts.map +1 -0
  61. package/lib/tools/block-tools.d.ts.map +1 -1
  62. package/lib/tools/entity-tools.d.ts +4 -0
  63. package/lib/tools/entity-tools.d.ts.map +1 -0
  64. package/lib/tools/index.d.ts.map +1 -1
  65. package/lib/tools/result.d.ts +2 -6
  66. package/lib/tools/result.d.ts.map +1 -1
  67. package/lib/tools/world-tools.d.ts +4 -0
  68. package/lib/tools/world-tools.d.ts.map +1 -0
  69. package/lib/utils/logger.d.ts +3 -0
  70. package/lib/utils/logger.d.ts.map +1 -0
  71. package/lib/world/block-states.d.ts +2 -0
  72. package/lib/world/block-states.d.ts.map +1 -1
  73. package/lib/world/entity-inventory.d.ts +16 -0
  74. package/lib/world/entity-inventory.d.ts.map +1 -0
  75. package/lib/world/item-spec.d.ts +10 -0
  76. package/lib/world/item-spec.d.ts.map +1 -0
  77. package/lib/world/simulated-dimension.d.ts +29 -0
  78. package/lib/world/simulated-dimension.d.ts.map +1 -1
  79. package/lib/world/stores/file-world-store.d.ts +3 -3
  80. package/lib/world/stores/file-world-store.d.ts.map +1 -1
  81. package/lib/world/stores/memory-world-store.d.ts +3 -4
  82. package/lib/world/stores/memory-world-store.d.ts.map +1 -1
  83. package/lib/world/stores/serialized-block.d.ts.map +1 -1
  84. package/lib/world/stores/serialized-entity.d.ts +24 -0
  85. package/lib/world/stores/serialized-entity.d.ts.map +1 -0
  86. package/lib/world/world.d.ts +3 -0
  87. package/lib/world/world.d.ts.map +1 -1
  88. package/package.json +113 -109
@@ -0,0 +1,84 @@
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
+ return (inventory.hotbar.filter(stack => stack !== null).length +
78
+ inventory.inventory.filter(stack => stack !== null).length);
79
+ }
80
+ export function sectionUsedSlots(inventory, section) {
81
+ return (section === EInventorySection.Hotbar
82
+ ? inventory.hotbar
83
+ : inventory.inventory).filter(stack => stack !== null).length;
84
+ }
@@ -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,43 @@
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';
3
7
  import { toErrorMessage } from '../utils/error-message.js';
4
- import { EMPTY_BLOCK_STATES, normalizeStates, sameStates } from './block-states.js';
8
+ import { logger } from '../utils/logger.js';
9
+ import { EMPTY_BLOCK_STATES, isEmptyStates, normalizeStates, sameStates } from './block-states.js';
10
+ import { createEmptyInventory, getInventorySlot, isSlotInRange, sameItemStack, slotCount, withInventorySlot } from './entity-inventory.js';
5
11
  const AIR = getBlockByName('air') ?? MINECRAFT_BLOCKS[0];
6
12
  function coordinateKey(x, y, z) {
7
13
  return `${x},${y},${z}`;
8
14
  }
15
+ function playerEntityType() {
16
+ const type = getEntityTypeByName('minecraft:player');
17
+ if (!type) {
18
+ throw new Error('The entity registry is missing minecraft:player.');
19
+ }
20
+ return type;
21
+ }
22
+ function compareEntityIds(a, b) {
23
+ if (a.id === b.id) {
24
+ return 0;
25
+ }
26
+ return a.id < b.id ? -1 : 1;
27
+ }
9
28
  export class SimulatedDimension {
10
29
  info;
11
30
  blocks = new Map();
31
+ entities = new Map();
32
+ playersByName = new Map();
12
33
  store;
13
34
  writeDelayMs;
14
35
  timer = null;
15
36
  queue = Promise.resolve();
16
37
  savePromise = null;
17
38
  dirty = false;
39
+ sortedBlocksCache = null;
40
+ sortedEntitiesCache = null;
18
41
  constructor(info, store, writeDelayMs = 25) {
19
42
  this.info = info;
20
43
  this.store = store;
@@ -23,25 +46,73 @@ export class SimulatedDimension {
23
46
  get modifiedBlockCount() {
24
47
  return this.blocks.size;
25
48
  }
49
+ get entityCount() {
50
+ return this.entities.size;
51
+ }
52
+ get playerCount() {
53
+ return this.playersByName.size;
54
+ }
26
55
  get placedBlocks() {
27
56
  return [...this.blocks.values()];
28
57
  }
58
+ get sortedPlacedBlocks() {
59
+ if (!this.sortedBlocksCache) {
60
+ this.sortedBlocksCache = this.placedBlocks.toSorted((a, b) => a.x - b.x || a.y - b.y || a.z - b.z);
61
+ }
62
+ return this.sortedBlocksCache;
63
+ }
64
+ get placedEntities() {
65
+ return [...this.entities.values()];
66
+ }
67
+ get sortedPlacedEntities() {
68
+ if (!this.sortedEntitiesCache) {
69
+ this.sortedEntitiesCache = this.placedEntities.toSorted(compareEntityIds);
70
+ }
71
+ return this.sortedEntitiesCache;
72
+ }
29
73
  async load() {
30
74
  const stored = await this.store.load(this.info.type);
31
- let skipped = 0;
32
- for (const placed of stored) {
75
+ this.blocks.clear();
76
+ this.entities.clear();
77
+ this.playersByName.clear();
78
+ this.sortedBlocksCache = null;
79
+ this.sortedEntitiesCache = null;
80
+ let skippedBlocks = 0;
81
+ for (const placed of stored.blocks) {
33
82
  const key = coordinateKey(placed.x, placed.y, placed.z);
34
- const isNew = !this.blocks.has(key);
35
83
  const outOfRange = !this.contains(placed.x, placed.y, placed.z);
36
- const overLimit = isNew && this.blocks.size >= WORLD_LIMITS.maxModifiedBlocks;
84
+ const overLimit = !this.blocks.has(key) &&
85
+ this.blocks.size >= WORLD_LIMITS.maxModifiedBlocks;
37
86
  if (outOfRange || overLimit) {
38
- skipped++;
87
+ skippedBlocks++;
39
88
  continue;
40
89
  }
41
90
  this.blocks.set(key, placed);
42
91
  }
43
- if (skipped > 0) {
44
- console.error(`[lzimul-minecraft-mcp] Skipped ${skipped} invalid block(s) while loading ${this.info.key}`);
92
+ let skippedEntities = 0;
93
+ for (const entity of stored.entities) {
94
+ const duplicateId = this.entities.has(entity.id);
95
+ const outOfRange = !this.contains(entity.x, entity.y, entity.z) ||
96
+ entity.dimension !== this.info.type;
97
+ const overLimit = !duplicateId &&
98
+ this.entities.size >= WORLD_LIMITS.maxEntitiesPerDimension;
99
+ const playerName = entity.name.trim().toLowerCase();
100
+ const duplicatePlayer = entity.kind === EEntityKind.Player &&
101
+ this.playersByName.has(playerName);
102
+ if (duplicateId || outOfRange || overLimit || duplicatePlayer) {
103
+ skippedEntities++;
104
+ continue;
105
+ }
106
+ this.entities.set(entity.id, entity);
107
+ if (entity.kind === EEntityKind.Player) {
108
+ this.playersByName.set(playerName, entity);
109
+ }
110
+ }
111
+ if (skippedBlocks > 0) {
112
+ logger.warn(`Skipped ${skippedBlocks} invalid block(s) while loading ${this.info.key}`);
113
+ }
114
+ if (skippedEntities > 0) {
115
+ logger.warn(`Skipped ${skippedEntities} invalid entity/player record(s) while loading ${this.info.key}`);
45
116
  }
46
117
  }
47
118
  contains(x, y, z) {
@@ -56,12 +127,108 @@ export class SimulatedDimension {
56
127
  return (this.blocks.has(coordinateKey(x, y, z)) ||
57
128
  this.blocks.size < WORLD_LIMITS.maxModifiedBlocks);
58
129
  }
130
+ canAddEntity() {
131
+ return this.entities.size < WORLD_LIMITS.maxEntitiesPerDimension;
132
+ }
59
133
  getBlock(x, y, z) {
60
134
  return this.blocks.get(coordinateKey(x, y, z))?.block ?? AIR;
61
135
  }
62
136
  getStates(x, y, z) {
63
137
  return (this.blocks.get(coordinateKey(x, y, z))?.states ?? EMPTY_BLOCK_STATES);
64
138
  }
139
+ getBlockState(x, y, z) {
140
+ const placed = this.blocks.get(coordinateKey(x, y, z));
141
+ return placed
142
+ ? { block: placed.block, states: placed.states }
143
+ : { block: AIR, states: EMPTY_BLOCK_STATES };
144
+ }
145
+ getEntity(id) {
146
+ return this.entities.get(id);
147
+ }
148
+ findPlayer(name) {
149
+ return this.playersByName.get(name.trim().toLowerCase());
150
+ }
151
+ removeDuplicatePlayers(claimedNames) {
152
+ let removed = 0;
153
+ for (const entity of this.entities.values()) {
154
+ if (entity.kind !== EEntityKind.Player) {
155
+ continue;
156
+ }
157
+ const name = entity.name.trim().toLowerCase();
158
+ if (claimedNames.has(name)) {
159
+ this.entities.delete(entity.id);
160
+ this.playersByName.delete(name);
161
+ removed++;
162
+ }
163
+ else {
164
+ claimedNames.add(name);
165
+ }
166
+ }
167
+ if (removed > 0) {
168
+ this.sortedEntitiesCache = null;
169
+ }
170
+ return removed;
171
+ }
172
+ createEntity(type, name, x, y, z) {
173
+ if (type.name === 'player') {
174
+ throw new Error('Use createPlayer to create a player; createEntity only creates non-player entities.');
175
+ }
176
+ return this.addEntity(EEntityKind.Entity, type, name, x, y, z);
177
+ }
178
+ createPlayer(name, x, y, z) {
179
+ if (this.findPlayer(name)) {
180
+ throw new Error(`A player named "${name}" already exists in ${this.info.displayName}. Player names are case-insensitive.`);
181
+ }
182
+ return this.addEntity(EEntityKind.Player, playerEntityType(), name, x, y, z);
183
+ }
184
+ addEntity(kind, type, name, x, y, z) {
185
+ if (!this.canAddEntity()) {
186
+ throw new RangeError(`The ${this.info.displayName} world already holds the maximum of ${WORLD_LIMITS.maxEntitiesPerDimension} entities.`);
187
+ }
188
+ const entity = {
189
+ id: randomUUID(),
190
+ kind,
191
+ type,
192
+ name,
193
+ x,
194
+ y,
195
+ z,
196
+ dimension: this.info.type,
197
+ inventory: createEmptyInventory(),
198
+ createdAt: new Date().toISOString()
199
+ };
200
+ this.entities.set(entity.id, entity);
201
+ if (kind === EEntityKind.Player) {
202
+ this.playersByName.set(entity.name.trim().toLowerCase(), entity);
203
+ }
204
+ this.sortedEntitiesCache = null;
205
+ this.markDirty();
206
+ return entity;
207
+ }
208
+ setInventorySlot(id, section, slot, stack) {
209
+ const entity = this.entities.get(id);
210
+ if (!entity) {
211
+ throw new RangeError(`Unknown entity "${id}" in ${this.info.displayName}.`);
212
+ }
213
+ if (!isSlotInRange(section, slot)) {
214
+ throw new RangeError(`Slot ${slot} is out of range for the ${section} (0-${slotCount(section) - 1}).`);
215
+ }
216
+ const previous = getInventorySlot(entity.inventory, section, slot);
217
+ if (sameItemStack(previous, stack)) {
218
+ return { previous, entity };
219
+ }
220
+ const updated = {
221
+ ...entity,
222
+ inventory: withInventorySlot(entity.inventory, section, slot, stack)
223
+ };
224
+ this.entities.set(id, updated);
225
+ if (updated.kind === EEntityKind.Player) {
226
+ this.playersByName.set(updated.name.trim().toLowerCase(), updated);
227
+ }
228
+ this.sortedEntitiesCache = null;
229
+ this.markDirty();
230
+ return { previous, entity: updated };
231
+ }
65
232
  setBlock(x, y, z, block, states = EMPTY_BLOCK_STATES) {
66
233
  const key = coordinateKey(x, y, z);
67
234
  const existing = this.blocks.get(key);
@@ -71,7 +238,7 @@ export class SimulatedDimension {
71
238
  sameStates(existing?.states ?? EMPTY_BLOCK_STATES, normalized)) {
72
239
  return previous;
73
240
  }
74
- const isRemoval = block.key === AIR.key && Object.keys(normalized).length === 0;
241
+ const isRemoval = block.key === AIR.key && isEmptyStates(normalized);
75
242
  if (!existing &&
76
243
  !isRemoval &&
77
244
  this.blocks.size >= WORLD_LIMITS.maxModifiedBlocks) {
@@ -83,8 +250,8 @@ export class SimulatedDimension {
83
250
  else {
84
251
  this.blocks.set(key, { x, y, z, block, states: normalized });
85
252
  }
86
- this.dirty = true;
87
- this.scheduleFlush();
253
+ this.sortedBlocksCache = null;
254
+ this.markDirty();
88
255
  return previous;
89
256
  }
90
257
  async flush() {
@@ -100,7 +267,10 @@ export class SimulatedDimension {
100
267
  return;
101
268
  }
102
269
  this.dirty = false;
103
- const snapshot = this.placedBlocks;
270
+ const snapshot = {
271
+ blocks: this.placedBlocks,
272
+ entities: this.placedEntities
273
+ };
104
274
  const run = this.queue.then(async () => {
105
275
  await this.store.save(this.info.type, snapshot);
106
276
  });
@@ -115,6 +285,10 @@ export class SimulatedDimension {
115
285
  this.savePromise = null;
116
286
  }
117
287
  }
288
+ markDirty() {
289
+ this.dirty = true;
290
+ this.scheduleFlush();
291
+ }
118
292
  scheduleFlush() {
119
293
  if (this.timer !== null) {
120
294
  return;
@@ -122,7 +296,7 @@ export class SimulatedDimension {
122
296
  this.timer = setTimeout(() => {
123
297
  this.timer = null;
124
298
  void this.flush().catch((error) => {
125
- console.error(`[lzimul-minecraft-mcp] Failed to persist ${this.info.key}: ${toErrorMessage(error)}`);
299
+ logger.error(`Failed to persist ${this.info.key}: ${toErrorMessage(error)}`);
126
300
  });
127
301
  }, this.writeDelayMs);
128
302
  }
@@ -3,11 +3,22 @@ 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 { logger } from '../../utils/logger.js';
6
7
  import { fromSerialized, toSerialized } from './serialized-block.js';
7
- const FILE_FORMAT = 'lzimul-world/2';
8
+ import { fromSerializedEntity, toSerializedEntity } from './serialized-entity.js';
9
+ const FILE_FORMAT = 'lzimul:minecraft/dimension';
8
10
  const MAX_SNAPSHOT_BYTES = 128 * 1024 * 1024;
9
11
  function warn(message) {
10
- console.error(`[lzimul-minecraft-mcp] ${message}`);
12
+ logger.warn(message);
13
+ }
14
+ function emptySnapshot() {
15
+ return { blocks: [], entities: [] };
16
+ }
17
+ function compareIds(a, b) {
18
+ if (a.id === b.id) {
19
+ return 0;
20
+ }
21
+ return a.id < b.id ? -1 : 1;
11
22
  }
12
23
  export class FileWorldStore {
13
24
  directory;
@@ -31,13 +42,13 @@ export class FileWorldStore {
31
42
  const info = await stat(file);
32
43
  if (info.size > MAX_SNAPSHOT_BYTES) {
33
44
  await this.quarantine(file, `snapshot is larger than ${MAX_SNAPSHOT_BYTES} bytes`);
34
- return [];
45
+ return emptySnapshot();
35
46
  }
36
47
  raw = await readFile(file, 'utf8');
37
48
  }
38
49
  catch (error) {
39
50
  if (error.code === 'ENOENT') {
40
- return [];
51
+ return emptySnapshot();
41
52
  }
42
53
  throw new Error(`Cannot read world snapshot ${file}: ${toErrorMessage(error)}`, { cause: error });
43
54
  }
@@ -47,17 +58,29 @@ export class FileWorldStore {
47
58
  }
48
59
  catch {
49
60
  await this.quarantine(file, 'invalid JSON');
50
- return [];
61
+ return emptySnapshot();
51
62
  }
52
63
  if (!parsed || !Array.isArray(parsed.blocks)) {
53
64
  await this.quarantine(file, 'missing blocks array');
54
- return [];
65
+ return emptySnapshot();
55
66
  }
56
67
  if (parsed.blocks.length > WORLD_LIMITS.maxModifiedBlocks) {
57
68
  await this.quarantine(file, `${parsed.blocks.length} blocks exceed the limit of ${WORLD_LIMITS.maxModifiedBlocks}`);
58
- return [];
69
+ return emptySnapshot();
59
70
  }
60
- return parsed.blocks.flatMap(entry => {
71
+ let serializedEntities = [];
72
+ if (parsed.entities !== void 0) {
73
+ if (!Array.isArray(parsed.entities)) {
74
+ await this.quarantine(file, 'invalid entities array');
75
+ return emptySnapshot();
76
+ }
77
+ if (parsed.entities.length > WORLD_LIMITS.maxEntitiesPerDimension) {
78
+ await this.quarantine(file, `${parsed.entities.length} entities exceed the limit of ${WORLD_LIMITS.maxEntitiesPerDimension}`);
79
+ return emptySnapshot();
80
+ }
81
+ serializedEntities = parsed.entities;
82
+ }
83
+ const blocks = parsed.blocks.flatMap(entry => {
61
84
  const block = fromSerialized(entry);
62
85
  if (block) {
63
86
  return [block];
@@ -65,24 +88,48 @@ export class FileWorldStore {
65
88
  warn(`Skipped malformed block entry in ${file}`);
66
89
  return [];
67
90
  });
91
+ const entities = serializedEntities.flatMap(entry => {
92
+ try {
93
+ const entity = fromSerializedEntity(entry, type);
94
+ if (entity) {
95
+ return [entity];
96
+ }
97
+ }
98
+ catch {
99
+ }
100
+ warn(`Skipped malformed entity entry in ${file}`);
101
+ return [];
102
+ });
103
+ return { blocks, entities };
68
104
  }
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}`);
105
+ async save(type, snapshot) {
106
+ if (snapshot.blocks.length > WORLD_LIMITS.maxModifiedBlocks) {
107
+ throw new RangeError(`World snapshot has ${snapshot.blocks.length} blocks, above the limit of ${WORLD_LIMITS.maxModifiedBlocks}`);
108
+ }
109
+ if (snapshot.entities.length > WORLD_LIMITS.maxEntitiesPerDimension) {
110
+ throw new RangeError(`World snapshot has ${snapshot.entities.length} entities, above the limit of ${WORLD_LIMITS.maxEntitiesPerDimension}`);
72
111
  }
73
112
  const file = this.fileFor(type);
113
+ const sortedBlocks = snapshot.blocks.toSorted((a, b) => a.x - b.x || a.y - b.y || a.z - b.z);
114
+ const sortedEntities = snapshot.entities.toSorted(compareIds);
115
+ const payload = {
116
+ format: FILE_FORMAT,
117
+ dimension: `minecraft:${type}`,
118
+ updatedAt: new Date().toISOString(),
119
+ modifiedBlockCount: sortedBlocks.length,
120
+ entityCount: sortedEntities.length,
121
+ blocks: sortedBlocks.map(toSerialized),
122
+ entities: sortedEntities.map(toSerializedEntity)
123
+ };
124
+ const text = `${JSON.stringify(payload)}\n`;
125
+ const bytes = Buffer.byteLength(text, 'utf8');
126
+ if (bytes > MAX_SNAPSHOT_BYTES) {
127
+ 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.`);
128
+ }
74
129
  try {
75
130
  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
131
  const temporary = `${file}.tmp`;
85
- await writeFile(temporary, `${JSON.stringify(payload)}\n`, 'utf8');
132
+ await writeFile(temporary, text, 'utf8');
86
133
  await rename(temporary, file);
87
134
  }
88
135
  catch (error) {
@@ -3,12 +3,20 @@ export class MemoryWorldStore {
3
3
  worlds = new Map();
4
4
  async load(type) {
5
5
  const stored = this.worlds.get(type);
6
- return stored ? [...stored] : [];
6
+ return stored
7
+ ? { blocks: [...stored.blocks], entities: [...stored.entities] }
8
+ : { blocks: [], entities: [] };
7
9
  }
8
- async save(type, blocks) {
9
- if (blocks.length > WORLD_LIMITS.maxModifiedBlocks) {
10
- throw new RangeError(`World snapshot has ${blocks.length} blocks, above the limit of ${WORLD_LIMITS.maxModifiedBlocks}`);
10
+ async save(type, snapshot) {
11
+ if (snapshot.blocks.length > WORLD_LIMITS.maxModifiedBlocks) {
12
+ throw new RangeError(`World snapshot has ${snapshot.blocks.length} blocks, above the limit of ${WORLD_LIMITS.maxModifiedBlocks}`);
11
13
  }
12
- this.worlds.set(type, [...blocks]);
14
+ if (snapshot.entities.length > WORLD_LIMITS.maxEntitiesPerDimension) {
15
+ throw new RangeError(`World snapshot has ${snapshot.entities.length} entities, above the limit of ${WORLD_LIMITS.maxEntitiesPerDimension}`);
16
+ }
17
+ this.worlds.set(type, {
18
+ blocks: [...snapshot.blocks],
19
+ entities: [...snapshot.entities]
20
+ });
13
21
  }
14
22
  }
@@ -1,21 +1,16 @@
1
1
  import { getBlockByName } from '../../data/blocks.js';
2
- import { normalizeStates } from '../block-states.js';
2
+ import { isEmptyStates, normalizeStates } from '../block-states.js';
3
3
  export function toSerialized(placed) {
4
- const states = normalizeStates(placed.states);
5
- if (Object.keys(states).length === 0) {
6
- return {
7
- x: placed.x,
8
- y: placed.y,
9
- z: placed.z,
10
- block: placed.block.key
11
- };
4
+ const { x, y, z, block, states } = placed;
5
+ if (isEmptyStates(states)) {
6
+ return { x, y, z, block: block.key };
12
7
  }
13
8
  return {
14
- x: placed.x,
15
- y: placed.y,
16
- z: placed.z,
17
- block: placed.block.key,
18
- states
9
+ x,
10
+ y,
11
+ z,
12
+ block: block.key,
13
+ states: normalizeStates(states)
19
14
  };
20
15
  }
21
16
  function readStates(value) {
@@ -0,0 +1,74 @@
1
+ import { getEntityTypeByName } from '../../data/entities.js';
2
+ import { EEntityKind } from '../../enums/entity-kind.enum.js';
3
+ import { createEmptyInventory, normalizeInventory } from '../entity-inventory.js';
4
+ function serializeStack(stack) {
5
+ return stack ? { item: stack.item, count: stack.count } : null;
6
+ }
7
+ function serializeInventory(inventory) {
8
+ return {
9
+ hotbar: inventory.hotbar.map(serializeStack),
10
+ inventory: inventory.inventory.map(serializeStack)
11
+ };
12
+ }
13
+ function hasItems(inventory) {
14
+ return (inventory.hotbar.some(stack => stack !== null) ||
15
+ inventory.inventory.some(stack => stack !== null));
16
+ }
17
+ export function toSerializedEntity(entity) {
18
+ const payload = {
19
+ id: entity.id,
20
+ kind: entity.kind,
21
+ type: entity.type.key,
22
+ name: entity.name,
23
+ x: entity.x,
24
+ y: entity.y,
25
+ z: entity.z,
26
+ createdAt: entity.createdAt
27
+ };
28
+ if (hasItems(entity.inventory)) {
29
+ return { ...payload, inventory: serializeInventory(entity.inventory) };
30
+ }
31
+ return payload;
32
+ }
33
+ function isCoordinate(value) {
34
+ return typeof value === 'number' && Number.isInteger(value);
35
+ }
36
+ export function fromSerializedEntity(entry, dimension) {
37
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
38
+ return null;
39
+ }
40
+ const record = entry;
41
+ const { id, type, name, x, y, z, createdAt, inventory } = record;
42
+ if (typeof id !== 'string' ||
43
+ id.trim().length === 0 ||
44
+ id.length > 128 ||
45
+ typeof type !== 'string' ||
46
+ typeof name !== 'string' ||
47
+ name.trim().length === 0 ||
48
+ name.length > 64 ||
49
+ !isCoordinate(x) ||
50
+ !isCoordinate(y) ||
51
+ !isCoordinate(z)) {
52
+ return null;
53
+ }
54
+ const resolved = getEntityTypeByName(type.trim().toLowerCase());
55
+ if (!resolved) {
56
+ return null;
57
+ }
58
+ return {
59
+ id: id.trim(),
60
+ kind: resolved.name === 'player' ? EEntityKind.Player : EEntityKind.Entity,
61
+ type: resolved,
62
+ name: name.trim(),
63
+ x,
64
+ y,
65
+ z,
66
+ dimension,
67
+ inventory: inventory === void 0
68
+ ? createEmptyInventory()
69
+ : normalizeInventory(inventory),
70
+ createdAt: typeof createdAt === 'string' && createdAt.length > 0
71
+ ? createdAt
72
+ : new Date().toISOString()
73
+ };
74
+ }