@lzimul/minecraft-mcp 1.0.4 → 1.0.6

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 (94) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +297 -219
  3. package/build/app/server.js +6 -1
  4. package/build/app/tool-events.js +72 -0
  5. package/build/app/websocket.js +116 -0
  6. package/build/config/server-info.js +4 -4
  7. package/build/config/storage.js +2 -1
  8. package/build/config/websocket.js +48 -0
  9. package/build/config/world-limits.js +2 -0
  10. package/build/enums/move-direction.enum.js +9 -0
  11. package/build/index.js +19 -1
  12. package/build/interfaces/websocket.interface.js +1 -0
  13. package/build/resources/dimension-resources.js +2 -2
  14. package/build/tools/entity-tools.js +318 -5
  15. package/build/tools/world-tools.js +388 -1
  16. package/build/utils/logger.js +1 -1
  17. package/build/world/block-spec.js +3 -1
  18. package/build/world/entity-inventory.js +21 -4
  19. package/build/world/simulated-dimension.js +135 -9
  20. package/build/world/stores/file-world-store.js +47 -39
  21. package/build/world/stores/memory-world-store.js +5 -0
  22. package/build/world/stores/serialized-entity.js +4 -3
  23. package/build/world/stores/serialized-snapshot.js +35 -0
  24. package/lib/app/server.d.ts +5 -1
  25. package/lib/app/server.d.ts.map +1 -1
  26. package/lib/app/tool-events.d.ts +5 -0
  27. package/lib/app/tool-events.d.ts.map +1 -0
  28. package/lib/app/websocket.d.ts +10 -0
  29. package/lib/app/websocket.d.ts.map +1 -0
  30. package/lib/config/dimensions.d.ts.map +1 -1
  31. package/lib/config/server-info.d.ts.map +1 -1
  32. package/lib/config/storage.d.ts.map +1 -1
  33. package/lib/config/websocket.d.ts +8 -0
  34. package/lib/config/websocket.d.ts.map +1 -0
  35. package/lib/config/world-limits.d.ts +2 -0
  36. package/lib/config/world-limits.d.ts.map +1 -1
  37. package/lib/data/entities.d.ts.map +1 -1
  38. package/lib/enums/bounding-box.enum.d.ts.map +1 -1
  39. package/lib/enums/dimension-type.enum.d.ts.map +1 -1
  40. package/lib/enums/entity-kind.enum.d.ts.map +1 -1
  41. package/lib/enums/icon-theme.enum.d.ts.map +1 -1
  42. package/lib/enums/inventory-section.enum.d.ts.map +1 -1
  43. package/lib/enums/move-direction.enum.d.ts +9 -0
  44. package/lib/enums/move-direction.enum.d.ts.map +1 -0
  45. package/lib/index.d.ts +1 -1
  46. package/lib/index.d.ts.map +1 -1
  47. package/lib/interfaces/block.interface.d.ts.map +1 -1
  48. package/lib/interfaces/data-entry.interface.d.ts.map +1 -1
  49. package/lib/interfaces/dimension.interface.d.ts.map +1 -1
  50. package/lib/interfaces/entity-inventory.interface.d.ts.map +1 -1
  51. package/lib/interfaces/entity-type.interface.d.ts.map +1 -1
  52. package/lib/interfaces/entity.interface.d.ts.map +1 -1
  53. package/lib/interfaces/item.interface.d.ts.map +1 -1
  54. package/lib/interfaces/placed-block.interface.d.ts.map +1 -1
  55. package/lib/interfaces/websocket.interface.d.ts +47 -0
  56. package/lib/interfaces/websocket.interface.d.ts.map +1 -0
  57. package/lib/interfaces/world-store.interface.d.ts +1 -0
  58. package/lib/interfaces/world-store.interface.d.ts.map +1 -1
  59. package/lib/resources/block-resources.d.ts.map +1 -1
  60. package/lib/resources/data-resources.d.ts.map +1 -1
  61. package/lib/resources/dimension-resources.d.ts +3 -0
  62. package/lib/resources/dimension-resources.d.ts.map +1 -1
  63. package/lib/resources/entity-resources.d.ts.map +1 -1
  64. package/lib/resources/index.d.ts.map +1 -1
  65. package/lib/resources/item-resources.d.ts.map +1 -1
  66. package/lib/schemas/block-schema.d.ts.map +1 -1
  67. package/lib/schemas/coordinate-schema.d.ts.map +1 -1
  68. package/lib/schemas/dimension-schema.d.ts.map +1 -1
  69. package/lib/schemas/entity-schema.d.ts.map +1 -1
  70. package/lib/schemas/item-schema.d.ts.map +1 -1
  71. package/lib/tools/block-tools.d.ts.map +1 -1
  72. package/lib/tools/entity-tools.d.ts.map +1 -1
  73. package/lib/tools/index.d.ts.map +1 -1
  74. package/lib/tools/item-tools.d.ts.map +1 -1
  75. package/lib/tools/result.d.ts.map +1 -1
  76. package/lib/tools/world-tools.d.ts.map +1 -1
  77. package/lib/utils/logger.d.ts.map +1 -1
  78. package/lib/world/block-spec.d.ts.map +1 -1
  79. package/lib/world/block-states.d.ts.map +1 -1
  80. package/lib/world/entity-inventory.d.ts.map +1 -1
  81. package/lib/world/item-spec.d.ts.map +1 -1
  82. package/lib/world/simulated-dimension.d.ts +25 -0
  83. package/lib/world/simulated-dimension.d.ts.map +1 -1
  84. package/lib/world/stores/file-world-store.d.ts +2 -2
  85. package/lib/world/stores/file-world-store.d.ts.map +1 -1
  86. package/lib/world/stores/index.d.ts.map +1 -1
  87. package/lib/world/stores/memory-world-store.d.ts +1 -0
  88. package/lib/world/stores/memory-world-store.d.ts.map +1 -1
  89. package/lib/world/stores/serialized-block.d.ts.map +1 -1
  90. package/lib/world/stores/serialized-entity.d.ts.map +1 -1
  91. package/lib/world/stores/serialized-snapshot.d.ts +6 -0
  92. package/lib/world/stores/serialized-snapshot.d.ts.map +1 -0
  93. package/lib/world/world.d.ts.map +1 -1
  94. package/package.json +116 -113
@@ -1,8 +1,39 @@
1
1
  import * as z from 'zod/v4';
2
+ import { WORLD_LIMITS } from '../config/world-limits.js';
3
+ import { blockPositionShape } from '../schemas/coordinate-schema.js';
2
4
  import { dimensionField } from '../schemas/dimension-schema.js';
5
+ import { entitySummarySchema, toEntitySummary } from '../schemas/entity-schema.js';
3
6
  import { toErrorMessage } from '../utils/error-message.js';
4
- import { toolError } from './result.js';
7
+ import { formatStates, isEmptyStates, sameStates } from '../world/block-states.js';
8
+ import { parseBlockSpec } from '../world/block-spec.js';
9
+ import { SNAPSHOT_FORMAT } from '../world/stores/serialized-snapshot.js';
10
+ import { coordinateRangeError, toolError } from './result.js';
5
11
  const blockStatesSchema = z.record(z.string(), z.string());
12
+ const positionSchema = z.object({
13
+ x: z.number().int(),
14
+ y: z.number().int(),
15
+ z: z.number().int()
16
+ });
17
+ const blockTypeSchema = z
18
+ .string()
19
+ .trim()
20
+ .min(1)
21
+ .max(256)
22
+ .describe('Block id, such as "minecraft:stone" or "stone"; states are optional, for example "oak_stairs[facing=south]"');
23
+ function normalizeArea(from, to) {
24
+ return {
25
+ from: {
26
+ x: Math.min(from.x, to.x),
27
+ y: Math.min(from.y, to.y),
28
+ z: Math.min(from.z, to.z)
29
+ },
30
+ to: {
31
+ x: Math.max(from.x, to.x),
32
+ y: Math.max(from.y, to.y),
33
+ z: Math.max(from.z, to.z)
34
+ }
35
+ };
36
+ }
6
37
  export function registerWorldTools(server, world) {
7
38
  server.registerTool('getDimension', {
8
39
  title: 'Get Dimension',
@@ -136,4 +167,360 @@ export function registerWorldTools(server, world) {
136
167
  return toolError(`getDimensionBlocks failed: ${toErrorMessage(error)}`);
137
168
  }
138
169
  });
170
+ server.registerTool('getBlocksInArea', {
171
+ title: 'Get Blocks In Area',
172
+ description: 'List placed (non-air) blocks inside an inclusive box in one dimension, sorted by x, y and z. Corners may be supplied in any order. An optional blockType filter matches the block id and, when states are supplied, the exact state map. Results are paginated with limit and offset. Unmodified coordinates are air and are not listed.',
173
+ inputSchema: z.object({
174
+ dimension: dimensionField,
175
+ from: z.object(blockPositionShape),
176
+ to: z.object(blockPositionShape),
177
+ blockType: blockTypeSchema
178
+ .optional()
179
+ .describe('Optional block filter, such as "minecraft:oak_stairs" or "oak_stairs[facing=south]"'),
180
+ limit: z
181
+ .number()
182
+ .int()
183
+ .min(1)
184
+ .max(10_000)
185
+ .default(1_000)
186
+ .describe('Maximum number of matching blocks to return (1-10000, default 1000)'),
187
+ offset: z
188
+ .number()
189
+ .int()
190
+ .min(0)
191
+ .default(0)
192
+ .describe('Number of matching blocks to skip (default 0)')
193
+ }),
194
+ outputSchema: z.object({
195
+ dimension: z.string(),
196
+ displayName: z.string(),
197
+ from: positionSchema,
198
+ to: positionSchema,
199
+ blockType: z.string().nullable(),
200
+ total: z.number().int(),
201
+ offset: z.number().int(),
202
+ limit: z.number().int(),
203
+ returned: z.number().int(),
204
+ hasMore: z.boolean(),
205
+ blocks: z.array(z.object({
206
+ x: z.number().int(),
207
+ y: z.number().int(),
208
+ z: z.number().int(),
209
+ block: z.string(),
210
+ displayName: z.string(),
211
+ states: blockStatesSchema
212
+ }))
213
+ }),
214
+ annotations: {
215
+ readOnlyHint: true,
216
+ idempotentHint: true,
217
+ openWorldHint: false
218
+ }
219
+ }, async ({ dimension: dimensionType, from, to, blockType, limit, offset }) => {
220
+ try {
221
+ const dimension = world.getDimension(dimensionType);
222
+ if (!dimension.contains(from.x, from.y, from.z)) {
223
+ return coordinateRangeError(dimension.info, from.x, from.y, from.z);
224
+ }
225
+ if (!dimension.contains(to.x, to.y, to.z)) {
226
+ return coordinateRangeError(dimension.info, to.x, to.y, to.z);
227
+ }
228
+ const area = normalizeArea(from, to);
229
+ const spec = blockType ? parseBlockSpec(blockType) : null;
230
+ if (spec && !spec.ok) {
231
+ return toolError(spec.error);
232
+ }
233
+ const matches = dimension.sortedPlacedBlocks.filter(placed => {
234
+ if (placed.x < area.from.x ||
235
+ placed.x > area.to.x ||
236
+ placed.y < area.from.y ||
237
+ placed.y > area.to.y ||
238
+ placed.z < area.from.z ||
239
+ placed.z > area.to.z) {
240
+ return false;
241
+ }
242
+ if (!spec) {
243
+ return true;
244
+ }
245
+ return (placed.block.key === spec.block.key &&
246
+ (isEmptyStates(spec.states) ||
247
+ sameStates(placed.states, spec.states)));
248
+ });
249
+ const total = matches.length;
250
+ const page = matches.slice(offset, offset + limit);
251
+ const returned = page.length;
252
+ const hasMore = offset + returned < total;
253
+ const filter = spec
254
+ ? `${spec.block.key}${formatStates(spec.states)}`
255
+ : null;
256
+ return {
257
+ content: [
258
+ {
259
+ type: 'text',
260
+ text: `Returned ${returned} of ${total} placed block(s) in ${dimension.info.displayName} from (${area.from.x}, ${area.from.y}, ${area.from.z}) to (${area.to.x}, ${area.to.y}, ${area.to.z})${filter ? ` matching ${filter}` : ''} (offset ${offset}, limit ${limit}${hasMore ? ', more available' : ''})`
261
+ }
262
+ ],
263
+ structuredContent: {
264
+ dimension: dimension.info.key,
265
+ displayName: dimension.info.displayName,
266
+ from: area.from,
267
+ to: area.to,
268
+ blockType: filter,
269
+ total,
270
+ offset,
271
+ limit,
272
+ returned,
273
+ hasMore,
274
+ blocks: page.map(placed => ({
275
+ x: placed.x,
276
+ y: placed.y,
277
+ z: placed.z,
278
+ block: placed.block.key,
279
+ displayName: placed.block.displayName,
280
+ states: placed.states
281
+ }))
282
+ }
283
+ };
284
+ }
285
+ catch (error) {
286
+ return toolError(`getBlocksInArea failed: ${toErrorMessage(error)}`);
287
+ }
288
+ });
289
+ server.registerTool('fillBlocks', {
290
+ title: 'Fill Blocks',
291
+ description: 'Fill an inclusive box in one dimension with one block type and optional states. Corners may be supplied in any order. By default only air coordinates are filled; set overwrite to true to replace existing blocks. Use "minecraft:air" with overwrite to clear a region. The maximum volume per request is 16,384 blocks.',
292
+ inputSchema: z.object({
293
+ dimension: dimensionField,
294
+ from: z.object(blockPositionShape),
295
+ to: z.object(blockPositionShape),
296
+ blockType: blockTypeSchema,
297
+ overwrite: z
298
+ .boolean()
299
+ .default(false)
300
+ .describe('Replace existing non-air blocks when true; default false fills air only')
301
+ }),
302
+ outputSchema: z.object({
303
+ dimension: z.string(),
304
+ displayName: z.string(),
305
+ from: positionSchema,
306
+ to: positionSchema,
307
+ blockType: z.string(),
308
+ block: z.string(),
309
+ states: blockStatesSchema,
310
+ overwrite: z.boolean(),
311
+ volume: z.number().int(),
312
+ changed: z.number().int(),
313
+ placed: z.number().int(),
314
+ removed: z.number().int(),
315
+ skipped: z.number().int(),
316
+ modifiedBlockCount: z.number().int()
317
+ }),
318
+ annotations: {
319
+ destructiveHint: true,
320
+ idempotentHint: true,
321
+ openWorldHint: false
322
+ }
323
+ }, async ({ dimension: dimensionType, from, to, blockType, overwrite }) => {
324
+ try {
325
+ const dimension = world.getDimension(dimensionType);
326
+ if (!dimension.contains(from.x, from.y, from.z)) {
327
+ return coordinateRangeError(dimension.info, from.x, from.y, from.z);
328
+ }
329
+ if (!dimension.contains(to.x, to.y, to.z)) {
330
+ return coordinateRangeError(dimension.info, to.x, to.y, to.z);
331
+ }
332
+ const area = normalizeArea(from, to);
333
+ const volume = (area.to.x - area.from.x + 1) *
334
+ (area.to.y - area.from.y + 1) *
335
+ (area.to.z - area.from.z + 1);
336
+ if (!Number.isSafeInteger(volume) ||
337
+ volume > WORLD_LIMITS.maxFillVolume) {
338
+ return toolError(`The requested fill volume is too large (${volume}); the maximum is ${WORLD_LIMITS.maxFillVolume} blocks.`);
339
+ }
340
+ const spec = parseBlockSpec(blockType);
341
+ if (!spec.ok) {
342
+ return toolError(spec.error);
343
+ }
344
+ const isAirTarget = spec.block.key === 'minecraft:air';
345
+ if (isAirTarget && !isEmptyStates(spec.states)) {
346
+ return toolError('Air cannot carry block states. Use "minecraft:air" to remove blocks.');
347
+ }
348
+ let newBlocks = 0;
349
+ let skipped = 0;
350
+ for (let x = area.from.x; x <= area.to.x; x += 1) {
351
+ for (let y = area.from.y; y <= area.to.y; y += 1) {
352
+ for (let z = area.from.z; z <= area.to.z; z += 1) {
353
+ const existing = dimension.getBlockState(x, y, z);
354
+ const existingIsAir = existing.block.key === 'minecraft:air';
355
+ if (!overwrite && !existingIsAir) {
356
+ skipped += 1;
357
+ continue;
358
+ }
359
+ if (isAirTarget) {
360
+ if (existingIsAir) {
361
+ skipped += 1;
362
+ }
363
+ continue;
364
+ }
365
+ if (!existingIsAir &&
366
+ existing.block.key === spec.block.key &&
367
+ sameStates(existing.states, spec.states)) {
368
+ skipped += 1;
369
+ continue;
370
+ }
371
+ if (existingIsAir) {
372
+ newBlocks += 1;
373
+ }
374
+ }
375
+ }
376
+ }
377
+ if (dimension.modifiedBlockCount + newBlocks >
378
+ WORLD_LIMITS.maxModifiedBlocks) {
379
+ return toolError(`The ${dimension.info.displayName} world would exceed the maximum of ${WORLD_LIMITS.maxModifiedBlocks} modified blocks.`);
380
+ }
381
+ let placed = 0;
382
+ let removed = 0;
383
+ let changed = 0;
384
+ for (let x = area.from.x; x <= area.to.x; x += 1) {
385
+ for (let y = area.from.y; y <= area.to.y; y += 1) {
386
+ for (let z = area.from.z; z <= area.to.z; z += 1) {
387
+ const existing = dimension.getBlockState(x, y, z);
388
+ const existingIsAir = existing.block.key === 'minecraft:air';
389
+ if (!overwrite && !existingIsAir) {
390
+ continue;
391
+ }
392
+ if (isAirTarget) {
393
+ if (!existingIsAir) {
394
+ dimension.setBlock(x, y, z, spec.block, spec.states);
395
+ removed += 1;
396
+ changed += 1;
397
+ }
398
+ continue;
399
+ }
400
+ if (!existingIsAir &&
401
+ existing.block.key === spec.block.key &&
402
+ sameStates(existing.states, spec.states)) {
403
+ continue;
404
+ }
405
+ dimension.setBlock(x, y, z, spec.block, spec.states);
406
+ placed += 1;
407
+ changed += 1;
408
+ }
409
+ }
410
+ }
411
+ if (changed > 0) {
412
+ try {
413
+ await dimension.flush();
414
+ }
415
+ catch (error) {
416
+ return toolError(`The region was changed but could not be persisted to the world store: ${toErrorMessage(error)}`);
417
+ }
418
+ }
419
+ const normalizedType = `${spec.block.key}${formatStates(spec.states)}`;
420
+ return {
421
+ content: [
422
+ {
423
+ type: 'text',
424
+ text: `Filled ${changed} of ${volume} coordinate(s) in ${dimension.info.displayName} with ${normalizedType} (${placed} placed, ${removed} removed, ${skipped} skipped)`
425
+ }
426
+ ],
427
+ structuredContent: {
428
+ dimension: dimension.info.key,
429
+ displayName: dimension.info.displayName,
430
+ from: area.from,
431
+ to: area.to,
432
+ blockType: normalizedType,
433
+ block: spec.block.key,
434
+ states: spec.states,
435
+ overwrite,
436
+ volume,
437
+ changed,
438
+ placed,
439
+ removed,
440
+ skipped,
441
+ modifiedBlockCount: dimension.modifiedBlockCount
442
+ }
443
+ };
444
+ }
445
+ catch (error) {
446
+ return toolError(`fillBlocks failed: ${toErrorMessage(error)}`);
447
+ }
448
+ });
449
+ server.registerTool('getDimensionEntities', {
450
+ title: 'Get Dimension Entities',
451
+ description: 'List every entity and player in one dimension, sorted by UUID. Each row contains the entity UUID, kind, type, display name, name, position and dimension. Use getEntity or getInventory for one entity and its full inventory.',
452
+ inputSchema: z.object({
453
+ dimension: dimensionField
454
+ }),
455
+ outputSchema: z.object({
456
+ dimension: z.string(),
457
+ displayName: z.string(),
458
+ entityCount: z.number().int(),
459
+ playerCount: z.number().int(),
460
+ entities: z.array(entitySummarySchema)
461
+ }),
462
+ annotations: {
463
+ readOnlyHint: true,
464
+ idempotentHint: true,
465
+ openWorldHint: false
466
+ }
467
+ }, async ({ dimension: dimensionType }) => {
468
+ try {
469
+ const dimension = world.getDimension(dimensionType);
470
+ const entities = dimension.sortedPlacedEntities.map(toEntitySummary);
471
+ return {
472
+ content: [
473
+ {
474
+ type: 'text',
475
+ text: `${dimension.info.displayName} (${dimension.info.key}) holds ${entities.length} entities and ${dimension.playerCount} players`
476
+ }
477
+ ],
478
+ structuredContent: {
479
+ dimension: dimension.info.key,
480
+ displayName: dimension.info.displayName,
481
+ entityCount: entities.length,
482
+ playerCount: dimension.playerCount,
483
+ entities
484
+ }
485
+ };
486
+ }
487
+ catch (error) {
488
+ return toolError(`getDimensionEntities failed: ${toErrorMessage(error)}`);
489
+ }
490
+ });
491
+ server.registerTool('getDimensionRawFile', {
492
+ title: 'Get Dimension Raw File',
493
+ description: 'Return the raw JSON snapshot of one dimension, including its top-level blocks and entities arrays. The result is the on-disk JSON for file storage, or the equivalent serialized state for memory storage, and can be loaded directly by minecraft_world_viewer.html.',
494
+ inputSchema: z.object({
495
+ dimension: dimensionField
496
+ }),
497
+ outputSchema: z.object({
498
+ dimension: z.string(),
499
+ format: z.string(),
500
+ bytes: z.number().int(),
501
+ raw: z.string()
502
+ }),
503
+ annotations: {
504
+ readOnlyHint: true,
505
+ idempotentHint: true,
506
+ openWorldHint: false
507
+ }
508
+ }, async ({ dimension: dimensionType }) => {
509
+ try {
510
+ const dimension = world.getDimension(dimensionType);
511
+ const raw = await dimension.readRaw();
512
+ return {
513
+ content: [{ type: 'text', text: raw }],
514
+ structuredContent: {
515
+ dimension: dimension.info.key,
516
+ format: SNAPSHOT_FORMAT,
517
+ bytes: Buffer.byteLength(raw, 'utf8'),
518
+ raw
519
+ }
520
+ };
521
+ }
522
+ catch (error) {
523
+ return toolError(`getDimensionRawFile failed: ${toErrorMessage(error)}`);
524
+ }
525
+ });
139
526
  }
@@ -16,7 +16,7 @@ const REQUESTED_LOG_LEVEL = process.env.MCP_LOG_LEVEL?.trim().toUpperCase();
16
16
  const LOG_LEVEL = REQUESTED_LOG_LEVEL && VALID_LOG_LEVELS.has(REQUESTED_LOG_LEVEL)
17
17
  ? REQUESTED_LOG_LEVEL
18
18
  : 'INFO';
19
- const LOGGER_CATEGORY = 'lzimul-minecraft-mcp';
19
+ const LOGGER_CATEGORY = '@lzimul/minecraft-mcp';
20
20
  function chalkLayout(event) {
21
21
  const line = `${event.startTime.toISOString()} [${event.level.levelStr}] ${event.categoryName} - ${format(...event.data)}`;
22
22
  switch (event.level.levelStr) {
@@ -26,6 +26,7 @@ export function parseBlockSpec(input) {
26
26
  };
27
27
  }
28
28
  const states = {};
29
+ let stateCount = 0;
29
30
  if (typeof rawStates === 'string') {
30
31
  if (rawStates.trim().length === 0) {
31
32
  return {
@@ -62,7 +63,8 @@ export function parseBlockSpec(input) {
62
63
  };
63
64
  }
64
65
  states[propertyName] = propertyValue;
65
- if (Object.keys(states).length > MAX_STATES) {
66
+ stateCount += 1;
67
+ if (stateCount > MAX_STATES) {
66
68
  return {
67
69
  ok: false,
68
70
  error: `Too many block states in "${input}" (max ${MAX_STATES}).`
@@ -74,11 +74,28 @@ export function sameItemStack(a, b) {
74
74
  return a !== null && b !== null && a.item === b.item && a.count === b.count;
75
75
  }
76
76
  export function inventoryUsedSlots(inventory) {
77
- return (inventory.hotbar.filter(stack => stack !== null).length +
78
- inventory.inventory.filter(stack => stack !== null).length);
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;
79
89
  }
80
90
  export function sectionUsedSlots(inventory, section) {
81
- return (section === EInventorySection.Hotbar
91
+ const slots = section === EInventorySection.Hotbar
82
92
  ? inventory.hotbar
83
- : inventory.inventory).filter(stack => stack !== null).length;
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;
84
101
  }
@@ -4,11 +4,30 @@ import { getBlockByName, MINECRAFT_BLOCKS } from '../data/blocks.js';
4
4
  import { getEntityTypeByName } from '../data/entities.js';
5
5
  import { EEntityKind } from '../enums/entity-kind.enum.js';
6
6
  import { EInventorySection } from '../enums/inventory-section.enum.js';
7
+ import { EMoveDirection } from '../enums/move-direction.enum.js';
7
8
  import { toErrorMessage } from '../utils/error-message.js';
8
9
  import { logger } from '../utils/logger.js';
9
10
  import { EMPTY_BLOCK_STATES, isEmptyStates, normalizeStates, sameStates } from './block-states.js';
10
- import { createEmptyInventory, getInventorySlot, isSlotInRange, sameItemStack, slotCount, withInventorySlot } from './entity-inventory.js';
11
+ import { createEmptyInventory, getInventorySlot, inventoryUsedSlots, isSlotInRange, sameItemStack, slotCount, withInventorySlot } from './entity-inventory.js';
11
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
+ }
12
31
  function coordinateKey(x, y, z) {
13
32
  return `${x},${y},${z}`;
14
33
  }
@@ -71,6 +90,12 @@ export class SimulatedDimension {
71
90
  return this.sortedEntitiesCache;
72
91
  }
73
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;
74
99
  const stored = await this.store.load(this.info.type);
75
100
  this.blocks.clear();
76
101
  this.entities.clear();
@@ -96,17 +121,19 @@ export class SimulatedDimension {
96
121
  entity.dimension !== this.info.type;
97
122
  const overLimit = !duplicateId &&
98
123
  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) {
124
+ if (duplicateId || outOfRange || overLimit) {
103
125
  skippedEntities++;
104
126
  continue;
105
127
  }
106
- this.entities.set(entity.id, entity);
107
128
  if (entity.kind === EEntityKind.Player) {
129
+ const playerName = entity.name.trim().toLowerCase();
130
+ if (this.playersByName.has(playerName)) {
131
+ skippedEntities++;
132
+ continue;
133
+ }
108
134
  this.playersByName.set(playerName, entity);
109
135
  }
136
+ this.entities.set(entity.id, entity);
110
137
  }
111
138
  if (skippedBlocks > 0) {
112
139
  logger.warn(`Skipped ${skippedBlocks} invalid block(s) while loading ${this.info.key}`);
@@ -229,6 +256,101 @@ export class SimulatedDimension {
229
256
  this.markDirty();
230
257
  return { previous, entity: updated };
231
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
+ }
232
354
  setBlock(x, y, z, block, states = EMPTY_BLOCK_STATES) {
233
355
  const key = coordinateKey(x, y, z);
234
356
  const existing = this.blocks.get(key);
@@ -254,6 +376,10 @@ export class SimulatedDimension {
254
376
  this.markDirty();
255
377
  return previous;
256
378
  }
379
+ async readRaw() {
380
+ await this.flush();
381
+ return this.store.readRaw(this.info.type);
382
+ }
257
383
  async flush() {
258
384
  if (this.timer !== null) {
259
385
  clearTimeout(this.timer);
@@ -268,8 +394,8 @@ export class SimulatedDimension {
268
394
  }
269
395
  this.dirty = false;
270
396
  const snapshot = {
271
- blocks: this.placedBlocks,
272
- entities: this.placedEntities
397
+ blocks: this.sortedPlacedBlocks,
398
+ entities: this.sortedPlacedEntities
273
399
  };
274
400
  const run = this.queue.then(async () => {
275
401
  await this.store.save(this.info.type, snapshot);
@@ -295,7 +421,7 @@ export class SimulatedDimension {
295
421
  }
296
422
  this.timer = setTimeout(() => {
297
423
  this.timer = null;
298
- void this.flush().catch((error) => {
424
+ void this.flush().catch(error => {
299
425
  logger.error(`Failed to persist ${this.info.key}: ${toErrorMessage(error)}`);
300
426
  });
301
427
  }, this.writeDelayMs);