@lzimul/minecraft-mcp 1.0.0 → 1.0.2

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/README.md +123 -30
  2. package/build/app/server.js +3 -3
  3. package/build/config/server-info.js +1 -1
  4. package/build/config/storage.js +15 -0
  5. package/build/config/world-limits.js +2 -1
  6. package/build/index.js +44 -1
  7. package/build/interfaces/block-states.interface.js +1 -0
  8. package/build/interfaces/block.interface.js +1 -1
  9. package/build/interfaces/dimension.interface.js +1 -1
  10. package/build/interfaces/world-store.interface.js +1 -0
  11. package/build/resources/data-resources.js +25 -10
  12. package/build/resources/dimension-resources.js +44 -28
  13. package/build/resources/error-content.js +12 -0
  14. package/build/resources/index.js +3 -3
  15. package/build/tools/block-tools.js +89 -52
  16. package/build/tools/index.js +2 -2
  17. package/build/tools/item-tools.js +24 -17
  18. package/build/utils/error-message.js +25 -0
  19. package/build/world/block-spec.js +74 -0
  20. package/build/world/block-states.js +43 -0
  21. package/build/world/simulated-dimension.js +97 -4
  22. package/build/world/stores/file-world-store.js +103 -0
  23. package/build/world/stores/index.js +9 -0
  24. package/build/world/stores/memory-world-store.js +14 -0
  25. package/build/world/stores/serialized-block.js +53 -0
  26. package/build/world/world.js +39 -0
  27. package/lib/app/server.d.ts +4 -3
  28. package/lib/app/server.d.ts.map +1 -1
  29. package/lib/config/dimensions.d.ts +3 -3
  30. package/lib/config/server-info.d.ts +15 -15
  31. package/lib/config/storage.d.ts +8 -0
  32. package/lib/config/storage.d.ts.map +1 -0
  33. package/lib/config/world-limits.d.ts +8 -7
  34. package/lib/config/world-limits.d.ts.map +1 -1
  35. package/lib/data/blocks.d.ts +5 -5
  36. package/lib/data/items.d.ts +5 -5
  37. package/lib/data/version.d.ts +2 -2
  38. package/lib/enums/bounding-box.enum.d.ts +5 -5
  39. package/lib/enums/dimension-type.enum.d.ts +6 -6
  40. package/lib/enums/icon-theme.enum.d.ts +5 -5
  41. package/lib/index.d.ts +3 -3
  42. package/lib/interfaces/block-states.interface.d.ts +2 -0
  43. package/lib/interfaces/block-states.interface.d.ts.map +1 -0
  44. package/lib/interfaces/block.interface.d.ts +14 -14
  45. package/lib/interfaces/data-entry.interface.d.ts +7 -7
  46. package/lib/interfaces/dimension.interface.d.ts +10 -10
  47. package/lib/interfaces/item.interface.d.ts +8 -8
  48. package/lib/interfaces/placed-block.interface.d.ts +10 -8
  49. package/lib/interfaces/placed-block.interface.d.ts.map +1 -1
  50. package/lib/interfaces/world-store.interface.d.ts +7 -0
  51. package/lib/interfaces/world-store.interface.d.ts.map +1 -0
  52. package/lib/resources/block-resources.d.ts +3 -3
  53. package/lib/resources/data-resources.d.ts +15 -21
  54. package/lib/resources/data-resources.d.ts.map +1 -1
  55. package/lib/resources/dimension-resources.d.ts +5 -4
  56. package/lib/resources/dimension-resources.d.ts.map +1 -1
  57. package/lib/resources/error-content.d.ts +8 -0
  58. package/lib/resources/error-content.d.ts.map +1 -0
  59. package/lib/resources/index.d.ts +4 -3
  60. package/lib/resources/index.d.ts.map +1 -1
  61. package/lib/resources/item-resources.d.ts +3 -3
  62. package/lib/schemas/block-schema.d.ts +17 -20
  63. package/lib/schemas/coordinate-schema.d.ts +4 -4
  64. package/lib/schemas/dimension-schema.d.ts +5 -7
  65. package/lib/schemas/item-schema.d.ts +14 -17
  66. package/lib/tools/block-tools.d.ts +4 -3
  67. package/lib/tools/block-tools.d.ts.map +1 -1
  68. package/lib/tools/index.d.ts +4 -3
  69. package/lib/tools/index.d.ts.map +1 -1
  70. package/lib/tools/item-tools.d.ts +3 -3
  71. package/lib/tools/item-tools.d.ts.map +1 -1
  72. package/lib/tools/result.d.ts +14 -18
  73. package/lib/utils/error-message.d.ts +2 -0
  74. package/lib/utils/error-message.d.ts.map +1 -0
  75. package/lib/world/block-spec.d.ts +12 -0
  76. package/lib/world/block-spec.d.ts.map +1 -0
  77. package/lib/world/block-states.d.ts +8 -0
  78. package/lib/world/block-states.d.ts.map +1 -0
  79. package/lib/world/simulated-dimension.d.ts +27 -14
  80. package/lib/world/simulated-dimension.d.ts.map +1 -1
  81. package/lib/world/stores/file-world-store.d.ts +14 -0
  82. package/lib/world/stores/file-world-store.d.ts.map +1 -0
  83. package/lib/world/stores/index.d.ts +3 -0
  84. package/lib/world/stores/index.d.ts.map +1 -0
  85. package/lib/world/stores/memory-world-store.d.ts +9 -0
  86. package/lib/world/stores/memory-world-store.d.ts.map +1 -0
  87. package/lib/world/stores/serialized-block.d.ts +12 -0
  88. package/lib/world/stores/serialized-block.d.ts.map +1 -0
  89. package/lib/world/world.d.ts +13 -0
  90. package/lib/world/world.d.ts.map +1 -0
  91. package/package.json +7 -3
  92. package/build/world/world-state.js +0 -16
  93. package/lib/world/world-state.d.ts +0 -6
  94. package/lib/world/world-state.d.ts.map +0 -1
@@ -1,17 +1,20 @@
1
1
  import * as z from 'zod/v4';
2
- import { getBlockByName } from '../data/blocks.js';
2
+ import { WORLD_LIMITS } from '../config/world-limits.js';
3
3
  import { blockInfoSchema, toBlockInfo } from '../schemas/block-schema.js';
4
4
  import { horizontalCoordinate, verticalCoordinate } from '../schemas/coordinate-schema.js';
5
5
  import { dimensionSchema } from '../schemas/dimension-schema.js';
6
- import { getDimension } from '../world/world-state.js';
6
+ import { toErrorMessage } from '../utils/error-message.js';
7
+ import { formatStates } from '../world/block-states.js';
8
+ import { parseBlockSpec } from '../world/block-spec.js';
7
9
  import { toolError } from './result.js';
8
- function rangeError(info, x, y, z) {
9
- return toolError(`Coordinates (${x}, ${y}, ${z}) are out of range in ${info.displayName} (Y: ${info.minY} to ${info.maxY - 1}).`);
10
+ const blockStatesSchema = z.record(z.string(), z.string());
11
+ function rangeError(dimension, x, y, z) {
12
+ return toolError(`Coordinates (${x}, ${y}, ${z}) are out of range in ${dimension.displayName} (Y: ${dimension.minY} to ${dimension.maxY - 1}).`);
10
13
  }
11
- export function registerBlockTools(server) {
14
+ export function registerBlockTools(server, world) {
12
15
  server.registerTool('getBlock', {
13
16
  title: 'Get Block',
14
- description: 'Get the block at the given coordinates in the given dimension, including its properties (name, hardness, light level, etc.). Each dimension has its own world state; unmodified coordinates return air (minecraft:air).',
17
+ description: 'Get the block at the given coordinates in the given dimension, including its properties (name, hardness, light level, etc.) and its block states (for example facing=south). Each dimension has its own persisted world state; unmodified coordinates return air (minecraft:air).',
15
18
  inputSchema: z.object({
16
19
  x: horizontalCoordinate.describe('X coordinate (-30000000 to 30000000)'),
17
20
  y: verticalCoordinate.describe('Y coordinate, within the dimension height range'),
@@ -23,7 +26,8 @@ export function registerBlockTools(server) {
23
26
  y: z.number(),
24
27
  z: z.number(),
25
28
  dimension: z.string(),
26
- block: blockInfoSchema
29
+ block: blockInfoSchema,
30
+ states: blockStatesSchema
27
31
  }),
28
32
  annotations: {
29
33
  readOnlyHint: true,
@@ -31,30 +35,37 @@ export function registerBlockTools(server) {
31
35
  openWorldHint: false
32
36
  }
33
37
  }, async ({ x, y, z, dimension: dimensionType }) => {
34
- const dimension = getDimension(dimensionType);
35
- if (!dimension.contains(x, y, z)) {
36
- return rangeError(dimension.info, x, y, z);
37
- }
38
- const block = dimension.getBlock(x, y, z);
39
- return {
40
- content: [
41
- {
42
- type: 'text',
43
- text: `Block at (${x}, ${y}, ${z}) in ${dimension.info.displayName} is ${block.displayName} (${block.key})`
44
- }
45
- ],
46
- structuredContent: {
47
- x,
48
- y,
49
- z,
50
- dimension: dimension.info.key,
51
- block: toBlockInfo(block)
38
+ try {
39
+ const dimension = world.getDimension(dimensionType);
40
+ if (!dimension.contains(x, y, z)) {
41
+ return rangeError(dimension.info, x, y, z);
52
42
  }
53
- };
43
+ const block = dimension.getBlock(x, y, z);
44
+ const states = dimension.getStates(x, y, z);
45
+ return {
46
+ content: [
47
+ {
48
+ type: 'text',
49
+ text: `Block at (${x}, ${y}, ${z}) in ${dimension.info.displayName} is ${block.displayName} (${block.key})${formatStates(states)}`
50
+ }
51
+ ],
52
+ structuredContent: {
53
+ x,
54
+ y,
55
+ z,
56
+ dimension: dimension.info.key,
57
+ block: toBlockInfo(block),
58
+ states
59
+ }
60
+ };
61
+ }
62
+ catch (error) {
63
+ return toolError(`getBlock failed: ${toErrorMessage(error)}`);
64
+ }
54
65
  });
55
66
  server.registerTool('setBlock', {
56
67
  title: 'Set Block',
57
- description: 'Place a block at the given coordinates in the given dimension and return the replaced block. Each dimension has its own world state. blockType is a block id such as "minecraft:stone" or "stone"; see the resource lzimul://minecraft/blocks for the full list.',
68
+ description: 'Place a block at the given coordinates in the given dimension and return the replaced block. Block states are supported: "minecraft:oak_stairs[facing=south,half=bottom]" or "oak_stairs[facing=south]". Each dimension has its own persisted world state; setting air removes the block. blockType is a block id such as "minecraft:stone" or "stone"; see the resource lzimul://minecraft/blocks for the full list.',
58
69
  inputSchema: z.object({
59
70
  x: horizontalCoordinate.describe('X coordinate (-30000000 to 30000000)'),
60
71
  y: verticalCoordinate.describe('Y coordinate, within the dimension height range'),
@@ -62,7 +73,8 @@ export function registerBlockTools(server) {
62
73
  blockType: z
63
74
  .string()
64
75
  .min(1)
65
- .describe('Block id, such as "minecraft:stone"'),
76
+ .max(256)
77
+ .describe('Block id with optional states, such as "minecraft:stone" or "minecraft:oak_stairs[facing=south]"'),
66
78
  dimension: dimensionSchema.describe('Dimension: "overworld" (default), "the_nether" or "the_end"')
67
79
  }),
68
80
  outputSchema: z.object({
@@ -71,7 +83,9 @@ export function registerBlockTools(server) {
71
83
  z: z.number(),
72
84
  dimension: z.string(),
73
85
  block: blockInfoSchema,
74
- previous: blockInfoSchema
86
+ states: blockStatesSchema,
87
+ previous: blockInfoSchema,
88
+ previousStates: blockStatesSchema
75
89
  }),
76
90
  annotations: {
77
91
  destructiveHint: true,
@@ -79,30 +93,53 @@ export function registerBlockTools(server) {
79
93
  openWorldHint: false
80
94
  }
81
95
  }, async ({ x, y, z, blockType, dimension: dimensionType }) => {
82
- const dimension = getDimension(dimensionType);
83
- if (!dimension.contains(x, y, z)) {
84
- return rangeError(dimension.info, x, y, z);
96
+ try {
97
+ const dimension = world.getDimension(dimensionType);
98
+ if (!dimension.contains(x, y, z)) {
99
+ return rangeError(dimension.info, x, y, z);
100
+ }
101
+ const spec = parseBlockSpec(blockType);
102
+ if (!spec.ok) {
103
+ return toolError(spec.error);
104
+ }
105
+ const isAir = spec.block.key === 'minecraft:air';
106
+ const hasStates = Object.keys(spec.states).length > 0;
107
+ if (isAir && hasStates) {
108
+ return toolError('Air cannot carry block states. Use "minecraft:air" to remove a block.');
109
+ }
110
+ if (!isAir && !dimension.canSetBlock(x, y, z)) {
111
+ return toolError(`The ${dimension.info.displayName} world already holds the maximum of ${WORLD_LIMITS.maxModifiedBlocks} modified blocks. Remove blocks with "minecraft:air" before adding more.`);
112
+ }
113
+ const previousBlock = dimension.getBlock(x, y, z);
114
+ const previousStates = dimension.getStates(x, y, z);
115
+ dimension.setBlock(x, y, z, spec.block, spec.states);
116
+ try {
117
+ await dimension.flush();
118
+ }
119
+ catch (error) {
120
+ return toolError(`Block ${spec.block.key} was applied at (${x}, ${y}, ${z}) in ${dimension.info.displayName} but could not be persisted to the world store: ${toErrorMessage(error)}`);
121
+ }
122
+ return {
123
+ content: [
124
+ {
125
+ type: 'text',
126
+ text: `Block set at (${x}, ${y}, ${z}) in ${dimension.info.displayName}: ${previousBlock.displayName} (${previousBlock.key})${formatStates(previousStates)} -> ${spec.block.displayName} (${spec.block.key})${formatStates(spec.states)}`
127
+ }
128
+ ],
129
+ structuredContent: {
130
+ x,
131
+ y,
132
+ z,
133
+ dimension: dimension.info.key,
134
+ block: toBlockInfo(spec.block),
135
+ states: spec.states,
136
+ previous: toBlockInfo(previousBlock),
137
+ previousStates
138
+ }
139
+ };
85
140
  }
86
- const block = getBlockByName(blockType);
87
- if (!block) {
88
- return toolError(`Unknown block "${blockType}". Use a block id such as "minecraft:stone"; see the resource lzimul://minecraft/blocks for the full list.`);
141
+ catch (error) {
142
+ return toolError(`setBlock failed: ${toErrorMessage(error)}`);
89
143
  }
90
- const previous = dimension.setBlock(x, y, z, block);
91
- return {
92
- content: [
93
- {
94
- type: 'text',
95
- text: `Block set at (${x}, ${y}, ${z}) in ${dimension.info.displayName}: ${previous.displayName} (${previous.key}) -> ${block.displayName} (${block.key})`
96
- }
97
- ],
98
- structuredContent: {
99
- x,
100
- y,
101
- z,
102
- dimension: dimension.info.key,
103
- block: toBlockInfo(block),
104
- previous: toBlockInfo(previous)
105
- }
106
- };
107
144
  });
108
145
  }
@@ -1,6 +1,6 @@
1
1
  import { registerBlockTools } from './block-tools.js';
2
2
  import { registerItemTools } from './item-tools.js';
3
- export function registerTools(server) {
4
- registerBlockTools(server);
3
+ export function registerTools(server, world) {
4
+ registerBlockTools(server, world);
5
5
  registerItemTools(server);
6
6
  }
@@ -1,6 +1,7 @@
1
1
  import * as z from 'zod/v4';
2
2
  import { getItemByName } from '../data/items.js';
3
3
  import { itemInfoSchema, toItemInfo } from '../schemas/item-schema.js';
4
+ import { toErrorMessage } from '../utils/error-message.js';
4
5
  import { toolError } from './result.js';
5
6
  export function registerItemTools(server) {
6
7
  server.registerTool('getItem', {
@@ -10,6 +11,7 @@ export function registerItemTools(server) {
10
11
  item: z
11
12
  .string()
12
13
  .min(1)
14
+ .max(256)
13
15
  .describe('Item id, such as "minecraft:diamond_sword" or "diamond_sword"')
14
16
  }),
15
17
  outputSchema: z.object({
@@ -21,23 +23,28 @@ export function registerItemTools(server) {
21
23
  openWorldHint: false
22
24
  }
23
25
  }, async ({ item: itemId }) => {
24
- const item = getItemByName(itemId);
25
- if (!item) {
26
- return toolError(`Unknown item "${itemId}". Use an item id such as "minecraft:diamond_sword"; see the resource lzimul://minecraft/items for the full list.`);
27
- }
28
- const durability = item.maxDurability
29
- ? `, durability ${item.maxDurability}`
30
- : '';
31
- return {
32
- content: [
33
- {
34
- type: 'text',
35
- text: `${item.displayName} (${item.key}): stack size ${item.stackSize}${durability}`
36
- }
37
- ],
38
- structuredContent: {
39
- item: toItemInfo(item)
26
+ try {
27
+ const item = getItemByName(itemId.trim().toLowerCase());
28
+ if (!item) {
29
+ return toolError(`Unknown item "${itemId}". Use an item id such as "minecraft:diamond_sword"; see the resource lzimul://minecraft/items for the full list.`);
40
30
  }
41
- };
31
+ const durability = item.maxDurability
32
+ ? `, durability ${item.maxDurability}`
33
+ : '';
34
+ return {
35
+ content: [
36
+ {
37
+ type: 'text',
38
+ text: `${item.displayName} (${item.key}): stack size ${item.stackSize}${durability}`
39
+ }
40
+ ],
41
+ structuredContent: {
42
+ item: toItemInfo(item)
43
+ }
44
+ };
45
+ }
46
+ catch (error) {
47
+ return toolError(`getItem failed: ${toErrorMessage(error)}`);
48
+ }
42
49
  });
43
50
  }
@@ -0,0 +1,25 @@
1
+ const FALLBACK_MESSAGE = 'Unknown error';
2
+ export function toErrorMessage(error) {
3
+ if (error === null || error === void 0) {
4
+ return FALLBACK_MESSAGE;
5
+ }
6
+ if (error instanceof Error) {
7
+ const message = error.message.trim();
8
+ return message.length > 0 ? message : error.name || FALLBACK_MESSAGE;
9
+ }
10
+ if (typeof error === 'string') {
11
+ return error.trim() || FALLBACK_MESSAGE;
12
+ }
13
+ if (typeof error === 'object') {
14
+ try {
15
+ const serialized = JSON.stringify(error);
16
+ return typeof serialized === 'string' && serialized !== '{}'
17
+ ? serialized
18
+ : FALLBACK_MESSAGE;
19
+ }
20
+ catch {
21
+ return FALLBACK_MESSAGE;
22
+ }
23
+ }
24
+ return String(error) || FALLBACK_MESSAGE;
25
+ }
@@ -0,0 +1,74 @@
1
+ import { getBlockByName } from '../data/blocks.js';
2
+ import { isSafeStateName, isSafeStateValue, normalizeStates } from './block-states.js';
3
+ const SPEC = /^(?:([a-z0-9_.-]+):)?([a-z0-9_./-]+)(?:\[([^\]]*)\])?$/;
4
+ const PROPERTY = /^([a-z0-9_]+)=([a-z0-9_.-]+)$/;
5
+ const MAX_STATES = 32;
6
+ export function parseBlockSpec(input) {
7
+ const spec = input.trim().toLowerCase();
8
+ const match = SPEC.exec(spec);
9
+ if (!match) {
10
+ return {
11
+ ok: false,
12
+ error: `Invalid block "${input}". Expected an id such as "minecraft:stone" or "minecraft:oak_stairs[facing=south]".`
13
+ };
14
+ }
15
+ const namespace = match[1];
16
+ const name = match[2];
17
+ const rawStates = match[3];
18
+ let block = getBlockByName(namespace ? `${namespace}:${name}` : name);
19
+ if (!block && !namespace) {
20
+ block = getBlockByName(`minecraft:${name}`);
21
+ }
22
+ if (!block) {
23
+ return {
24
+ ok: false,
25
+ error: `Unknown block "${input}". Use a block id such as "minecraft:stone"; see the resource lzimul://minecraft/blocks for the full list.`
26
+ };
27
+ }
28
+ const states = {};
29
+ if (typeof rawStates === 'string') {
30
+ if (rawStates.trim().length === 0) {
31
+ return {
32
+ ok: false,
33
+ error: `Empty block state list in "${input}". Use "minecraft:oak_stairs[facing=south]" or omit the brackets.`
34
+ };
35
+ }
36
+ for (const part of rawStates.split(',')) {
37
+ const property = PROPERTY.exec(part.trim());
38
+ if (!property) {
39
+ return {
40
+ ok: false,
41
+ error: `Invalid block state "${part.trim()}" in "${input}". Expected "property=value".`
42
+ };
43
+ }
44
+ const propertyName = property[1];
45
+ const propertyValue = property[2];
46
+ if (!isSafeStateName(propertyName)) {
47
+ return {
48
+ ok: false,
49
+ error: `Unsupported block state name "${propertyName}" in "${input}".`
50
+ };
51
+ }
52
+ if (!isSafeStateValue(propertyValue)) {
53
+ return {
54
+ ok: false,
55
+ error: `Unsupported block state value "${propertyValue}" for "${propertyName}" in "${input}".`
56
+ };
57
+ }
58
+ if (Object.hasOwn(states, propertyName)) {
59
+ return {
60
+ ok: false,
61
+ error: `Duplicate block state "${propertyName}" in "${input}".`
62
+ };
63
+ }
64
+ states[propertyName] = propertyValue;
65
+ }
66
+ if (Object.keys(states).length > MAX_STATES) {
67
+ return {
68
+ ok: false,
69
+ error: `Too many block states in "${input}" (max ${MAX_STATES}).`
70
+ };
71
+ }
72
+ }
73
+ return { ok: true, block, states: normalizeStates(states) };
74
+ }
@@ -0,0 +1,43 @@
1
+ export const EMPTY_BLOCK_STATES = Object.freeze({});
2
+ const MAX_STATE_NAME_LENGTH = 64;
3
+ const MAX_STATE_VALUE_LENGTH = 64;
4
+ const SAFE_STATE_NAME = /^[a-z0-9_]+$/;
5
+ const SAFE_STATE_VALUE = /^[a-z0-9_.-]+$/;
6
+ const RESERVED_STATE_NAMES = new Set(['__proto__', 'constructor', 'prototype']);
7
+ export function isSafeStateName(name) {
8
+ const normalized = name.toLowerCase();
9
+ return (normalized.length > 0 &&
10
+ normalized.length <= MAX_STATE_NAME_LENGTH &&
11
+ SAFE_STATE_NAME.test(normalized) &&
12
+ !RESERVED_STATE_NAMES.has(normalized));
13
+ }
14
+ export function isSafeStateValue(value) {
15
+ const normalized = value.toLowerCase();
16
+ return (normalized.length > 0 &&
17
+ normalized.length <= MAX_STATE_VALUE_LENGTH &&
18
+ SAFE_STATE_VALUE.test(normalized));
19
+ }
20
+ export function normalizeStates(states) {
21
+ if (!states) {
22
+ return EMPTY_BLOCK_STATES;
23
+ }
24
+ const entries = Object.entries(states)
25
+ .filter(([key, value]) => typeof value === 'string' &&
26
+ isSafeStateName(key) &&
27
+ isSafeStateValue(value))
28
+ .map(([key, value]) => [key.toLowerCase(), value.toLowerCase()])
29
+ .sort(([a], [b]) => a.localeCompare(b));
30
+ return Object.fromEntries(entries);
31
+ }
32
+ export function sameStates(a, b) {
33
+ const entries = Object.entries(a);
34
+ return (entries.length === Object.keys(b).length &&
35
+ entries.every(([key, value]) => b[key] === value));
36
+ }
37
+ export function formatStates(states) {
38
+ const entries = Object.entries(states).sort(([a], [b]) => a.localeCompare(b));
39
+ if (entries.length === 0) {
40
+ return '';
41
+ }
42
+ return `[${entries.map(([key, value]) => `${key}=${value}`).join(',')}]`;
43
+ }
@@ -1,5 +1,7 @@
1
1
  import { WORLD_LIMITS } from '../config/world-limits.js';
2
2
  import { getBlockByName, MINECRAFT_BLOCKS } from '../data/blocks.js';
3
+ import { toErrorMessage } from '../utils/error-message.js';
4
+ import { EMPTY_BLOCK_STATES, normalizeStates, sameStates } from './block-states.js';
3
5
  const AIR = getBlockByName('air') ?? MINECRAFT_BLOCKS[0];
4
6
  function coordinateKey(x, y, z) {
5
7
  return `${x},${y},${z}`;
@@ -7,8 +9,16 @@ function coordinateKey(x, y, z) {
7
9
  export class SimulatedDimension {
8
10
  info;
9
11
  blocks = new Map();
10
- constructor(info) {
12
+ store;
13
+ writeDelayMs;
14
+ timer = null;
15
+ queue = Promise.resolve();
16
+ savePromise = null;
17
+ dirty = false;
18
+ constructor(info, store, writeDelayMs = 25) {
11
19
  this.info = info;
20
+ this.store = store;
21
+ this.writeDelayMs = writeDelayMs;
12
22
  }
13
23
  get modifiedBlockCount() {
14
24
  return this.blocks.size;
@@ -16,6 +26,24 @@ export class SimulatedDimension {
16
26
  get placedBlocks() {
17
27
  return [...this.blocks.values()];
18
28
  }
29
+ async load() {
30
+ const stored = await this.store.load(this.info.type);
31
+ let skipped = 0;
32
+ for (const placed of stored) {
33
+ const key = coordinateKey(placed.x, placed.y, placed.z);
34
+ const isNew = !this.blocks.has(key);
35
+ const outOfRange = !this.contains(placed.x, placed.y, placed.z);
36
+ const overLimit = isNew && this.blocks.size >= WORLD_LIMITS.maxModifiedBlocks;
37
+ if (outOfRange || overLimit) {
38
+ skipped++;
39
+ continue;
40
+ }
41
+ this.blocks.set(key, placed);
42
+ }
43
+ if (skipped > 0) {
44
+ console.error(`[lzimul-minecraft-mcp] Skipped ${skipped} invalid block(s) while loading ${this.info.key}`);
45
+ }
46
+ }
19
47
  contains(x, y, z) {
20
48
  return (y >= this.info.minY &&
21
49
  y < this.info.maxY &&
@@ -24,13 +52,78 @@ export class SimulatedDimension {
24
52
  z >= WORLD_LIMITS.minHorizontal &&
25
53
  z <= WORLD_LIMITS.maxHorizontal);
26
54
  }
55
+ canSetBlock(x, y, z) {
56
+ return (this.blocks.has(coordinateKey(x, y, z)) ||
57
+ this.blocks.size < WORLD_LIMITS.maxModifiedBlocks);
58
+ }
27
59
  getBlock(x, y, z) {
28
60
  return this.blocks.get(coordinateKey(x, y, z))?.block ?? AIR;
29
61
  }
30
- setBlock(x, y, z, block) {
62
+ getStates(x, y, z) {
63
+ return (this.blocks.get(coordinateKey(x, y, z))?.states ?? EMPTY_BLOCK_STATES);
64
+ }
65
+ setBlock(x, y, z, block, states = EMPTY_BLOCK_STATES) {
31
66
  const key = coordinateKey(x, y, z);
32
- const previous = this.blocks.get(key)?.block ?? AIR;
33
- this.blocks.set(key, { x, y, z, block });
67
+ const existing = this.blocks.get(key);
68
+ const previous = existing?.block ?? AIR;
69
+ const normalized = normalizeStates(states);
70
+ if (previous.key === block.key &&
71
+ sameStates(existing?.states ?? EMPTY_BLOCK_STATES, normalized)) {
72
+ return previous;
73
+ }
74
+ const isRemoval = block.key === AIR.key && Object.keys(normalized).length === 0;
75
+ if (!existing &&
76
+ !isRemoval &&
77
+ this.blocks.size >= WORLD_LIMITS.maxModifiedBlocks) {
78
+ throw new RangeError(`The ${this.info.displayName} world already holds the maximum of ${WORLD_LIMITS.maxModifiedBlocks} modified blocks.`);
79
+ }
80
+ if (isRemoval) {
81
+ this.blocks.delete(key);
82
+ }
83
+ else {
84
+ this.blocks.set(key, { x, y, z, block, states: normalized });
85
+ }
86
+ this.dirty = true;
87
+ this.scheduleFlush();
34
88
  return previous;
35
89
  }
90
+ async flush() {
91
+ if (this.timer !== null) {
92
+ clearTimeout(this.timer);
93
+ this.timer = null;
94
+ }
95
+ while (this.savePromise !== null) {
96
+ await this.savePromise;
97
+ }
98
+ if (!this.dirty) {
99
+ await this.queue;
100
+ return;
101
+ }
102
+ this.dirty = false;
103
+ const snapshot = this.placedBlocks;
104
+ const run = this.queue.then(async () => {
105
+ await this.store.save(this.info.type, snapshot);
106
+ });
107
+ this.queue = run.catch(() => {
108
+ this.dirty = true;
109
+ });
110
+ this.savePromise = run;
111
+ try {
112
+ await run;
113
+ }
114
+ finally {
115
+ this.savePromise = null;
116
+ }
117
+ }
118
+ scheduleFlush() {
119
+ if (this.timer !== null) {
120
+ return;
121
+ }
122
+ this.timer = setTimeout(() => {
123
+ this.timer = null;
124
+ void this.flush().catch((error) => {
125
+ console.error(`[lzimul-minecraft-mcp] Failed to persist ${this.info.key}: ${toErrorMessage(error)}`);
126
+ });
127
+ }, this.writeDelayMs);
128
+ }
36
129
  }
@@ -0,0 +1,103 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { WORLD_LIMITS } from '../../config/world-limits.js';
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;
9
+ function warn(message) {
10
+ console.error(`[lzimul-minecraft-mcp] ${message}`);
11
+ }
12
+ export class FileWorldStore {
13
+ directory;
14
+ directoryReady = false;
15
+ constructor(directory) {
16
+ this.directory = directory;
17
+ }
18
+ async ensureDirectory() {
19
+ if (!this.directoryReady) {
20
+ await mkdir(this.directory, { recursive: true });
21
+ this.directoryReady = true;
22
+ }
23
+ }
24
+ fileFor(type) {
25
+ return path.join(this.directory, `${type}.json`);
26
+ }
27
+ async load(type) {
28
+ const file = this.fileFor(type);
29
+ let raw;
30
+ try {
31
+ const info = await stat(file);
32
+ if (info.size > MAX_SNAPSHOT_BYTES) {
33
+ await this.quarantine(file, `snapshot is larger than ${MAX_SNAPSHOT_BYTES} bytes`);
34
+ return [];
35
+ }
36
+ raw = await readFile(file, 'utf8');
37
+ }
38
+ catch (error) {
39
+ if (error.code === 'ENOENT') {
40
+ return [];
41
+ }
42
+ throw new Error(`Cannot read world snapshot ${file}: ${toErrorMessage(error)}`, { cause: error });
43
+ }
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(raw);
47
+ }
48
+ catch {
49
+ await this.quarantine(file, 'invalid JSON');
50
+ return [];
51
+ }
52
+ if (!parsed || !Array.isArray(parsed.blocks)) {
53
+ await this.quarantine(file, 'missing blocks array');
54
+ return [];
55
+ }
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 [];
59
+ }
60
+ return parsed.blocks.flatMap(entry => {
61
+ const block = fromSerialized(entry);
62
+ if (block) {
63
+ return [block];
64
+ }
65
+ warn(`Skipped malformed block entry in ${file}`);
66
+ return [];
67
+ });
68
+ }
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}`);
72
+ }
73
+ const file = this.fileFor(type);
74
+ try {
75
+ 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
+ const temporary = `${file}.tmp`;
85
+ await writeFile(temporary, `${JSON.stringify(payload)}\n`, 'utf8');
86
+ await rename(temporary, file);
87
+ }
88
+ catch (error) {
89
+ this.directoryReady = false;
90
+ throw new Error(`Failed to write world snapshot ${file}: ${toErrorMessage(error)}`, { cause: error });
91
+ }
92
+ }
93
+ async quarantine(file, reason) {
94
+ const backup = `${file}.corrupt-${randomUUID()}`;
95
+ try {
96
+ await rename(file, backup);
97
+ warn(`World file ${file} is unusable (${reason}); moved it to ${backup}`);
98
+ }
99
+ catch (error) {
100
+ warn(`World file ${file} is unusable (${reason}); could not move it: ${toErrorMessage(error)}`);
101
+ }
102
+ }
103
+ }
@@ -0,0 +1,9 @@
1
+ import { WORLD_STORAGE } from '../../config/storage.js';
2
+ import { FileWorldStore } from './file-world-store.js';
3
+ import { MemoryWorldStore } from './memory-world-store.js';
4
+ export function createWorldStore() {
5
+ if (WORLD_STORAGE.kind === 'memory') {
6
+ return new MemoryWorldStore();
7
+ }
8
+ return new FileWorldStore(WORLD_STORAGE.directory);
9
+ }
@@ -0,0 +1,14 @@
1
+ import { WORLD_LIMITS } from '../../config/world-limits.js';
2
+ export class MemoryWorldStore {
3
+ worlds = new Map();
4
+ async load(type) {
5
+ const stored = this.worlds.get(type);
6
+ return stored ? [...stored] : [];
7
+ }
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}`);
11
+ }
12
+ this.worlds.set(type, [...blocks]);
13
+ }
14
+ }