vitaminmcp 2.1.0 → 2.2.0

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.
package/checksums.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "2.1.0",
2
+ "version": "2.2.0",
3
3
  "jars": {
4
- "mcp-server.jar": "deb34b38856dce0cac71edd1e705bdf8d3911ea3e38dd95f38100d6a65d95c02"
4
+ "mcp-server.jar": "27f1543ced4e59ed6501aa4238eab76473cbf4ff1612a4622b5bb27455447685"
5
5
  },
6
6
  "assets": {
7
- "bot-runner-win-x64.exe": "ef94fa66b9792f484ef32f58a15bcf75af555ad68584d37cfafcd73ed770fcdd"
7
+ "bot-runner-win-x64.exe": "82438774fc5b43e1a348daade6cc22e5be1e97f39cd63ee8b8b65afa242b2109"
8
8
  }
9
9
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vitaminmcp",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "mcpName": "io.github.Backas03/vitaminmcp",
5
5
  "description": "MCP server for testing Minecraft plugins: drives a real Paper server and real protocol bots from an AI agent.",
6
6
  "keywords": [
@@ -45,24 +45,93 @@ function requireInWorld(bot, name) {
45
45
  }
46
46
  }
47
47
 
48
- export function breakBlock(bot, name, x, y, z) {
48
+ /** How long the server gets to confirm a dig before it counts as never having arrived. */
49
+ const DIG_ACK_TIMEOUT_MILLIS = 1500;
50
+
51
+ /** How long the resulting block change gets to come back after the confirmation. */
52
+ const DIG_SETTLE_MILLIS = 300;
53
+
54
+ /**
55
+ * Breaks a block, and says what became of the attempt.
56
+ *
57
+ * <p>This used to write two packets and answer `sent`, which made a dig the server cancelled and
58
+ * a dig the server never received the same observation — and a plugin that cancels
59
+ * BlockBreakEvent silently is exactly the thing someone reaches for this tool to find. A whole
60
+ * dogfooding round went on telling those apart by hand (dogfood/JOURNAL.md, 2026-08-23).
61
+ *
62
+ * <p>The server settles it for us. Every block action carries a sequence number, and the server
63
+ * answers with the highest one it has resolved — that is what the field is for, so the client
64
+ * knows when to stop predicting and accept what it is told. An acknowledgement means the dig
65
+ * reached the world and was dealt with; the block then says whether it was allowed.
66
+ */
67
+ export async function breakBlock(bot, name, x, y, z) {
49
68
  requireInWorld(bot, name);
50
69
  const location = { x, y, z };
70
+ const at = new Vec3(x, y, z);
71
+
72
+ const before = bot.blockAt(at)?.name ?? null;
73
+ if (before === null) {
74
+ // Not a refusal and not a failure to send: this client has never been told what is there,
75
+ // which usually means it has only just joined and its chunks have not arrived.
76
+ return `the bot's client has no block at ${x}, ${y}, ${z} yet, so nothing was dug`;
77
+ }
78
+
79
+ const started = nextSequence(bot);
80
+ const finished = nextSequence(bot);
81
+
82
+ // Registered before the packets go out. The acknowledgement can come back inside the same tick.
83
+ const confirmed = acknowledgement(bot, finished);
51
84
 
52
85
  bot._client.write('block_dig', {
53
86
  status: START_DIGGING,
54
87
  location,
55
88
  face: FACES.up,
56
- sequence: nextSequence(bot),
89
+ sequence: started,
57
90
  });
58
91
  bot._client.write('block_dig', {
59
92
  status: FINISH_DIGGING,
60
93
  location,
61
94
  face: FACES.up,
62
- sequence: nextSequence(bot),
95
+ sequence: finished,
96
+ });
97
+
98
+ if (!await confirmed) {
99
+ return `the server did not acknowledge the dig within ${DIG_ACK_TIMEOUT_MILLIS}ms, `
100
+ + 'so it never reached the world';
101
+ }
102
+
103
+ await delay(DIG_SETTLE_MILLIS);
104
+ const after = bot.blockAt(at)?.name ?? null;
105
+
106
+ return after === before
107
+ ? `the server acknowledged the dig and left ${before} in place, so something refused it`
108
+ : `broke ${before}`;
109
+ }
110
+
111
+ /** Resolves true once the server says it has resolved everything up to `sequence`. */
112
+ function acknowledgement(bot, sequence) {
113
+ return new Promise((resolve) => {
114
+ const finish = (value) => {
115
+ clearTimeout(timer);
116
+ bot._client.removeListener('acknowledge_player_digging', onAcknowledged);
117
+ resolve(value);
118
+ };
119
+
120
+ const onAcknowledged = (packet) => {
121
+ if (packet?.sequenceId >= sequence) {
122
+ finish(true);
123
+ }
124
+ };
125
+
126
+ const timer = setTimeout(() => finish(false), DIG_ACK_TIMEOUT_MILLIS);
127
+ bot._client.on('acknowledge_player_digging', onAcknowledged);
63
128
  });
64
129
  }
65
130
 
131
+ function delay(millis) {
132
+ return new Promise((resolve) => setTimeout(resolve, millis));
133
+ }
134
+
66
135
  /** Runs a command as the bot. The leading slash is not part of the packet. */
67
136
  export function command(bot, name, line) {
68
137
  requireInWorld(bot, name);
@@ -16,6 +16,9 @@ const SETTLE_TIMEOUT_MILLIS = 15_000;
16
16
  const SETTLED_CHECKS = 5;
17
17
  const SETTLE_POLL_MILLIS = 50;
18
18
 
19
+ /** How long the client gets to be sent the chunk it is standing in. */
20
+ const WORLD_TIMEOUT_MILLIS = 15_000;
21
+
19
22
  /** The bots this runner holds, and the server they all connect to. */
20
23
  export class BotRegistry {
21
24
  #host;
@@ -73,6 +76,7 @@ export class BotRegistry {
73
76
  this.#bots.set(name, bot);
74
77
  configurePathfinder(bot);
75
78
  await settle(bot, name);
79
+ await worldKnown(bot, name);
76
80
  return position(bot);
77
81
  }
78
82
 
@@ -186,6 +190,36 @@ async function settle(bot, name) {
186
190
  );
187
191
  }
188
192
 
193
+ /**
194
+ * Waits until the bot's client knows the world it is standing in.
195
+ *
196
+ * `spawn` fires on the position packet, which can arrive before the chunk does. A bot that acts in
197
+ * that gap sends block actions against blocks it has never been told about, and the server answers
198
+ * with nothing at all — indistinguishable, from the caller's side, from a plugin cancelling the
199
+ * action silently. A dogfooding round spent most of itself on that ambiguity
200
+ * (dogfood/JOURNAL.md, 2026-08-23).
201
+ *
202
+ * This closes the client half. The server half — a plugin, or Paper itself, dropping interactions
203
+ * from a player who has only just joined — cannot be waited out from here, and is why
204
+ * `breakBlock` reports whether the server acknowledged the dig.
205
+ */
206
+ async function worldKnown(bot, name) {
207
+ const deadline = Date.now() + WORLD_TIMEOUT_MILLIS;
208
+
209
+ while (Date.now() < deadline) {
210
+ const at = bot.entity?.position;
211
+ if (at && bot.blockAt(at.offset(0, -1, 0))) {
212
+ return;
213
+ }
214
+ await delay(SETTLE_POLL_MILLIS);
215
+ }
216
+
217
+ throw new Error(
218
+ `Bot ${name} joined but its client was never sent the world around it within `
219
+ + `${WORLD_TIMEOUT_MILLIS}ms`,
220
+ );
221
+ }
222
+
189
223
  /** Resolves when the bot is in the world; rejects on a kick, an error, or the timeout. */
190
224
  function joined(bot, name) {
191
225
  return new Promise((resolve, reject) => {
@@ -64,14 +64,15 @@ export class Dispatch {
64
64
  }
65
65
 
66
66
  case protocol.BREAK: {
67
- actions.breakBlock(
67
+ // Carries what became of the dig, which is the whole point of waiting for it.
68
+ const outcome = await actions.breakBlock(
68
69
  this.#bots.require(command[1]),
69
70
  command[1],
70
71
  Number(command[2]),
71
72
  Number(command[3]),
72
73
  Number(command[4]),
73
74
  );
74
- return ok(verb);
75
+ return protocol.encode(protocol.OK, verb, outcome);
75
76
  }
76
77
 
77
78
  case protocol.COMMAND: {
@@ -4,6 +4,20 @@ import mc from 'minecraft-protocol';
4
4
  /** Long enough for a server still finishing its first tick, short enough to fail. */
5
5
  export const PING_TIMEOUT_MILLIS = 10_000;
6
6
 
7
+ /**
8
+ * The version whose packet definitions a server-list ping is written with.
9
+ *
10
+ * A ping happens before anything knows what the server speaks, so minecraft-protocol otherwise
11
+ * falls back to the newest version it has ever heard of and loads that entire data set — blocks,
12
+ * items, the lot — to send a handshake and read a status string. Naming a version here skips that,
13
+ * and it is safe because handshake and status have not changed shape since 1.7: the server answers
14
+ * with its own protocol number whatever we send.
15
+ *
16
+ * It must be a version the SEA build bundles, so keep it on the supported line — see
17
+ * `scripts/slim-minecraft-data.mjs`, which is what makes the others absent.
18
+ */
19
+ export const PING_VERSION = '1.21';
20
+
7
21
  /**
8
22
  * The protocol number a server speaks, asked without speaking it.
9
23
  *
@@ -17,7 +31,7 @@ export function pingProtocol(host, port, timeoutMillis = PING_TIMEOUT_MILLIS) {
17
31
  () => reject(new Error(`${host}:${port} did not answer a server-list ping within ${timeoutMillis}ms`)),
18
32
  timeoutMillis,
19
33
  );
20
- mc.ping({ host, port, closeTimeout: timeoutMillis }, (error, result) => {
34
+ mc.ping({ host, port, closeTimeout: timeoutMillis, version: PING_VERSION }, (error, result) => {
21
35
  clearTimeout(timer);
22
36
  if (error) {
23
37
  reject(error);