vitaminmcp 2.1.1 → 3.0.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/README.md +10 -2
- package/bin/vitaminmcp.mjs +21 -4
- package/checksums.json +8 -3
- package/lib/jars.mjs +3 -0
- package/lib/node.mjs +13 -3
- package/lib/viewer-loader.mjs +74 -0
- package/package.json +1 -1
- package/runner/src/actions.mjs +109 -4
- package/runner/src/bots.mjs +39 -3
- package/runner/src/clientview.mjs +43 -2
- package/runner/src/dispatch.mjs +18 -4
package/README.md
CHANGED
|
@@ -60,6 +60,7 @@ agent leaves its host, ports and token where this server reads them.
|
|
|
60
60
|
| `VITAMINMCP_RUNNER_JAR` | use this runner path instead of automatic selection |
|
|
61
61
|
| `VITAMINMCP_NODE` | Node executable for the source runner fallback |
|
|
62
62
|
| `VITAMINMCP_NODE_RUNNER` | bundled `runner.mjs` path when Node is available |
|
|
63
|
+
| `VITAMINMCP_VIEWER_PATH` | local viewer module to use instead of the pinned optional asset |
|
|
63
64
|
|
|
64
65
|
## What it downloads
|
|
65
66
|
|
|
@@ -71,8 +72,15 @@ matching this package's version, into `~/.vitaminmcp/jars/<version>/`:
|
|
|
71
72
|
- otherwise one platform runner asset (`win-x64`, `linux-x64`, `linux-arm64`, `darwin-x64` or
|
|
72
73
|
`darwin-arm64`), checked against a SHA-256 pinned into this package
|
|
73
74
|
|
|
74
|
-
|
|
75
|
-
|
|
75
|
+
The trimmed `bot-runner-viewer-win-x64.tgz` is separate and is downloaded into
|
|
76
|
+
`~/.vitaminmcp/assets/<version>/` only when `bot_view` asks for a world view. It is checked against
|
|
77
|
+
the SHA-256 pinned into this package at publish time, extracted on first use, and never fetched by
|
|
78
|
+
an installation that only spawns bots or uses the inventory viewer. The current native viewer asset
|
|
79
|
+
is Windows x64; on Linux and macOS, set `VITAMINMCP_VIEWER_PATH` to a local sidecar until those
|
|
80
|
+
assets are released.
|
|
81
|
+
|
|
82
|
+
Every downloaded file is checked against its pinned SHA-256. A file that does not match is deleted
|
|
83
|
+
rather than run.
|
|
76
84
|
|
|
77
85
|
Full documentation, design notes and the plugin itself:
|
|
78
86
|
**[github.com/Backas03/VitaminMCP-minecraft](https://github.com/Backas03/VitaminMCP-minecraft)**
|
package/bin/vitaminmcp.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
MCP_SERVER_JAR, assetPath, ensureAsset, ensureJar,
|
|
11
11
|
} from '../lib/jars.mjs';
|
|
12
12
|
import { checkJava, findJava } from '../lib/java.mjs';
|
|
13
|
-
import { checkNode, findNode, runnerAssetName } from '../lib/node.mjs';
|
|
13
|
+
import { checkNode, findNode, runnerAssetName, viewerAssetName } from '../lib/node.mjs';
|
|
14
14
|
|
|
15
15
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
16
16
|
|
|
@@ -91,6 +91,13 @@ async function main() {
|
|
|
91
91
|
const configuredRunner = process.env.VITAMINMCP_RUNNER_JAR;
|
|
92
92
|
const node = findNode();
|
|
93
93
|
const nodeCheck = checkNode(node);
|
|
94
|
+
let viewerAsset;
|
|
95
|
+
try {
|
|
96
|
+
viewerAsset = viewerAssetName();
|
|
97
|
+
} catch {
|
|
98
|
+
// The source runner can still be used on platforms whose native viewer asset is not released.
|
|
99
|
+
viewerAsset = '';
|
|
100
|
+
}
|
|
94
101
|
const sourceRunner = process.env.VITAMINMCP_NODE_RUNNER
|
|
95
102
|
?? path.join(HERE, '..', 'runner', 'runner.mjs');
|
|
96
103
|
let runner;
|
|
@@ -114,7 +121,7 @@ async function main() {
|
|
|
114
121
|
}
|
|
115
122
|
|
|
116
123
|
await runnerReady;
|
|
117
|
-
return await run(java, server, runner);
|
|
124
|
+
return await run(java, server, runner, release, viewerAsset);
|
|
118
125
|
}
|
|
119
126
|
|
|
120
127
|
/**
|
|
@@ -124,10 +131,20 @@ async function main() {
|
|
|
124
131
|
* `.part` file it leaves is claimed again by the next start, and a client waiting on a process
|
|
125
132
|
* that no longer serves anything is worse than a jar fetched twice.
|
|
126
133
|
*/
|
|
127
|
-
function run(java, server, runner) {
|
|
134
|
+
function run(java, server, runner, release, viewerAsset) {
|
|
135
|
+
const env = {
|
|
136
|
+
...process.env,
|
|
137
|
+
VITAMINMCP_RUNNER_JAR: runner,
|
|
138
|
+
VITAMINMCP_VERSION: release,
|
|
139
|
+
VITAMINMCP_VIEWER_ASSET: viewerAsset,
|
|
140
|
+
};
|
|
141
|
+
// The loader imports the pinned archive only when the Node runner receives bot_view(world).
|
|
142
|
+
if (!env.VITAMINMCP_VIEWER_PATH) {
|
|
143
|
+
env.VITAMINMCP_VIEWER_PATH = path.join(HERE, '..', 'lib', 'viewer-loader.mjs');
|
|
144
|
+
}
|
|
128
145
|
const child = spawn(java, ['-jar', server], {
|
|
129
146
|
stdio: 'inherit',
|
|
130
|
-
env
|
|
147
|
+
env,
|
|
131
148
|
});
|
|
132
149
|
|
|
133
150
|
return new Promise((resolve) => {
|
package/checksums.json
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "
|
|
2
|
+
"version": "3.0.0",
|
|
3
3
|
"jars": {
|
|
4
|
-
"mcp-server.jar": "
|
|
4
|
+
"mcp-server.jar": "576610b6f7deb691fad5633533678ef7a9266574ad731094cc78a01bb24be480"
|
|
5
5
|
},
|
|
6
6
|
"assets": {
|
|
7
|
-
"bot-runner-win-x64.exe": "
|
|
7
|
+
"bot-runner-win-x64.exe": "1a762d0773269ea3205ccb6816e964f33f3ddd15fd1003c6cadd5af4460572ca",
|
|
8
|
+
"bot-runner-linux-x64": "a2e975d331461fc699c1998e86a847f8ca161a266caae57c6060bc74bf87569c",
|
|
9
|
+
"bot-runner-linux-arm64": "31aad6207a268ddb70298ca1eaa2d443c2f097e1e5b714a2db6b40eea57ec749",
|
|
10
|
+
"bot-runner-darwin-x64": "573eb872b1965fe6b8575611f42336fd4dda436e749196e4a1373cffd2939bde",
|
|
11
|
+
"bot-runner-darwin-arm64": "853183f7da381f9b831aef9824c69563a732205c61d300fad908cd0f502a73e0",
|
|
12
|
+
"bot-runner-viewer-win-x64.tgz": "e39a36cdf92b9223aa7c9610f8231afc7a577e8ed007bc3571895bd945baf3e1"
|
|
8
13
|
}
|
|
9
14
|
}
|
package/lib/jars.mjs
CHANGED
|
@@ -14,6 +14,9 @@ const RELEASES = 'https://github.com/Backas03/VitaminMCP-minecraft/releases/down
|
|
|
14
14
|
/** The jar this package launches, and the one it launches in turn. */
|
|
15
15
|
export const MCP_SERVER_JAR = 'mcp-server.jar';
|
|
16
16
|
|
|
17
|
+
/** The optional viewer archive fetched only when a world view is requested. */
|
|
18
|
+
export const VIEWER_ASSET = 'bot-runner-viewer-win-x64.tgz';
|
|
19
|
+
|
|
17
20
|
let checksumsPromise;
|
|
18
21
|
let manifestPromise;
|
|
19
22
|
|
package/lib/node.mjs
CHANGED
|
@@ -33,11 +33,21 @@ export function checkNode(node) {
|
|
|
33
33
|
/** The release asset name for the current SEA target. */
|
|
34
34
|
export function runnerAssetName(platform = process.platform, arch = process.arch) {
|
|
35
35
|
if (platform === 'win32' && arch === 'x64') return 'bot-runner-win-x64.exe';
|
|
36
|
+
if (platform === 'linux' && arch === 'x64') return 'bot-runner-linux-x64';
|
|
37
|
+
if (platform === 'linux' && arch === 'arm64') return 'bot-runner-linux-arm64';
|
|
38
|
+
if (platform === 'darwin' && arch === 'x64') return 'bot-runner-darwin-x64';
|
|
39
|
+
if (platform === 'darwin' && arch === 'arm64') return 'bot-runner-darwin-arm64';
|
|
40
|
+
throw new Error(`No VitaminMCP runner asset exists for ${platform}-${arch}.`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The optional world-view asset for the platforms currently published. */
|
|
44
|
+
export function viewerAssetName(platform = process.platform, arch = process.arch) {
|
|
45
|
+
if (platform === 'win32' && arch === 'x64') return 'bot-runner-viewer-win-x64.tgz';
|
|
36
46
|
if (platform === 'linux' || platform === 'darwin') {
|
|
37
47
|
throw new Error(
|
|
38
|
-
`
|
|
39
|
-
+ '
|
|
48
|
+
`Optional world viewer assets for ${platform}-${arch} are planned but not released yet. `
|
|
49
|
+
+ 'Set VITAMINMCP_VIEWER_PATH to a local prismarine-viewer sidecar.',
|
|
40
50
|
);
|
|
41
51
|
}
|
|
42
|
-
throw new Error(`No VitaminMCP
|
|
52
|
+
throw new Error(`No VitaminMCP world viewer asset exists for ${platform}-${arch}.`);
|
|
43
53
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lazy bridge from the npm launcher's pinned asset fetcher to the Node runner's viewer import.
|
|
3
|
+
*
|
|
4
|
+
* This module is imported only when `bot_view` asks for a world view. The normal runner startup
|
|
5
|
+
* therefore never downloads or extracts the optional viewer archive.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import fs from 'node:fs/promises';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { pathToFileURL } from 'node:url';
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
assetCacheDirectory,
|
|
15
|
+
ensureAsset,
|
|
16
|
+
} from './jars.mjs';
|
|
17
|
+
|
|
18
|
+
const version = process.env.VITAMINMCP_VERSION;
|
|
19
|
+
if (!version) throw new Error('VITAMINMCP_VERSION is required to load the optional viewer asset.');
|
|
20
|
+
const asset = process.env.VITAMINMCP_VIEWER_ASSET;
|
|
21
|
+
if (!asset) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
'No packaged world viewer asset exists for this platform. '
|
|
24
|
+
+ 'Set VITAMINMCP_VIEWER_PATH to a local prismarine-viewer sidecar.',
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const archive = await ensureAsset(version, asset, {
|
|
29
|
+
log: (message) => process.stderr.write(`[vitaminmcp] ${message}\n`),
|
|
30
|
+
});
|
|
31
|
+
const extractionRoot = path.join(assetCacheDirectory(version), 'viewer');
|
|
32
|
+
const entry = path.join(extractionRoot, 'package', 'node_modules', 'prismarine-viewer', 'index.js');
|
|
33
|
+
const marker = path.join(extractionRoot, '.ready');
|
|
34
|
+
|
|
35
|
+
if (!(await exists(marker)) || !(await exists(entry))) {
|
|
36
|
+
await fs.rm(extractionRoot, { recursive: true, force: true });
|
|
37
|
+
await fs.mkdir(extractionRoot, { recursive: true });
|
|
38
|
+
await extract(archive, extractionRoot);
|
|
39
|
+
if (!(await exists(entry))) {
|
|
40
|
+
throw new Error(`The viewer asset did not contain ${entry}.`);
|
|
41
|
+
}
|
|
42
|
+
await fs.writeFile(marker, 'ready\n');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const viewerModule = await import(pathToFileURL(entry).href);
|
|
46
|
+
const viewer = viewerModule.default ?? viewerModule;
|
|
47
|
+
|
|
48
|
+
export default viewer;
|
|
49
|
+
export const mineflayer = viewer.mineflayer;
|
|
50
|
+
export const supportedVersions = viewer.supportedVersions;
|
|
51
|
+
|
|
52
|
+
async function exists(file) {
|
|
53
|
+
try {
|
|
54
|
+
await fs.access(file);
|
|
55
|
+
return true;
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function extract(file, directory) {
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
const child = spawn('tar', ['-xzf', file, '-C', directory], {
|
|
64
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
65
|
+
});
|
|
66
|
+
let error = '';
|
|
67
|
+
child.stderr.on('data', (chunk) => { error += chunk; });
|
|
68
|
+
child.once('error', reject);
|
|
69
|
+
child.once('close', (code) => {
|
|
70
|
+
if (code === 0) resolve();
|
|
71
|
+
else reject(new Error(`Could not extract viewer asset (tar exited ${code}): ${error}`));
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
}
|
package/package.json
CHANGED
package/runner/src/actions.mjs
CHANGED
|
@@ -45,24 +45,99 @@ function requireInWorld(bot, name) {
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
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
|
+
/** How long a client gets to receive the block before an interaction is sent. */
|
|
55
|
+
const BLOCK_KNOWN_TIMEOUT_MILLIS = 15_000;
|
|
56
|
+
|
|
57
|
+
/** One client tick for a block update already queued by a preceding server command. */
|
|
58
|
+
const BLOCK_UPDATE_SETTLE_MILLIS = 50;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Breaks a block, and says what became of the attempt.
|
|
62
|
+
*
|
|
63
|
+
* <p>This used to write two packets and answer `sent`, which made a dig the server cancelled and
|
|
64
|
+
* a dig the server never received the same observation — and a plugin that cancels
|
|
65
|
+
* BlockBreakEvent silently is exactly the thing someone reaches for this tool to find. A whole
|
|
66
|
+
* dogfooding round went on telling those apart by hand (dogfood/JOURNAL.md, 2026-08-23).
|
|
67
|
+
*
|
|
68
|
+
* <p>The server settles it for us. Every block action carries a sequence number, and the server
|
|
69
|
+
* answers with the highest one it has resolved — that is what the field is for, so the client
|
|
70
|
+
* knows when to stop predicting and accept what it is told. An acknowledgement means the dig
|
|
71
|
+
* reached the world and was dealt with; the block then says whether it was allowed.
|
|
72
|
+
*/
|
|
73
|
+
export async function breakBlock(bot, name, x, y, z) {
|
|
49
74
|
requireInWorld(bot, name);
|
|
50
75
|
const location = { x, y, z };
|
|
76
|
+
const at = new Vec3(x, y, z);
|
|
77
|
+
|
|
78
|
+
const before = bot.blockAt(at)?.name ?? null;
|
|
79
|
+
if (before === null) {
|
|
80
|
+
// Not a refusal and not a failure to send: this client has never been told what is there,
|
|
81
|
+
// which usually means it has only just joined and its chunks have not arrived.
|
|
82
|
+
return `the bot's client has no block at ${x}, ${y}, ${z} yet, so nothing was dug`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const started = nextSequence(bot);
|
|
86
|
+
const finished = nextSequence(bot);
|
|
87
|
+
|
|
88
|
+
// Registered before the packets go out. The acknowledgement can come back inside the same tick.
|
|
89
|
+
const confirmed = acknowledgement(bot, finished);
|
|
51
90
|
|
|
52
91
|
bot._client.write('block_dig', {
|
|
53
92
|
status: START_DIGGING,
|
|
54
93
|
location,
|
|
55
94
|
face: FACES.up,
|
|
56
|
-
sequence:
|
|
95
|
+
sequence: started,
|
|
57
96
|
});
|
|
58
97
|
bot._client.write('block_dig', {
|
|
59
98
|
status: FINISH_DIGGING,
|
|
60
99
|
location,
|
|
61
100
|
face: FACES.up,
|
|
62
|
-
sequence:
|
|
101
|
+
sequence: finished,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (!await confirmed) {
|
|
105
|
+
return `the server did not acknowledge the dig within ${DIG_ACK_TIMEOUT_MILLIS}ms, `
|
|
106
|
+
+ 'so it never reached the world';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
await delay(DIG_SETTLE_MILLIS);
|
|
110
|
+
const after = bot.blockAt(at)?.name ?? null;
|
|
111
|
+
|
|
112
|
+
return after === before
|
|
113
|
+
? `the server acknowledged the dig and left ${before} in place, so something refused it`
|
|
114
|
+
: `broke ${before}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Resolves true once the server says it has resolved everything up to `sequence`. */
|
|
118
|
+
function acknowledgement(bot, sequence) {
|
|
119
|
+
return new Promise((resolve) => {
|
|
120
|
+
const finish = (value) => {
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
bot._client.removeListener('acknowledge_player_digging', onAcknowledged);
|
|
123
|
+
resolve(value);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const onAcknowledged = (packet) => {
|
|
127
|
+
if (packet?.sequenceId >= sequence) {
|
|
128
|
+
finish(true);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const timer = setTimeout(() => finish(false), DIG_ACK_TIMEOUT_MILLIS);
|
|
133
|
+
bot._client.on('acknowledge_player_digging', onAcknowledged);
|
|
63
134
|
});
|
|
64
135
|
}
|
|
65
136
|
|
|
137
|
+
function delay(millis) {
|
|
138
|
+
return new Promise((resolve) => setTimeout(resolve, millis));
|
|
139
|
+
}
|
|
140
|
+
|
|
66
141
|
/** Runs a command as the bot. The leading slash is not part of the packet. */
|
|
67
142
|
export function command(bot, name, line) {
|
|
68
143
|
requireInWorld(bot, name);
|
|
@@ -89,7 +164,7 @@ export function chat(bot, name, message) {
|
|
|
89
164
|
}
|
|
90
165
|
|
|
91
166
|
/** Right-clicks a block — which is how a container or a plugin menu gets opened. */
|
|
92
|
-
export function useBlock(bot, name, x, y, z, face) {
|
|
167
|
+
export async function useBlock(bot, name, x, y, z, face) {
|
|
93
168
|
requireInWorld(bot, name);
|
|
94
169
|
|
|
95
170
|
const direction = !face || !face.trim() ? FACES.up : FACES[face.trim().toLowerCase()];
|
|
@@ -99,6 +174,9 @@ export function useBlock(bot, name, x, y, z, face) {
|
|
|
99
174
|
);
|
|
100
175
|
}
|
|
101
176
|
|
|
177
|
+
const location = new Vec3(x, y, z);
|
|
178
|
+
await blockKnown(bot, name, location);
|
|
179
|
+
|
|
102
180
|
bot._client.write('block_place', {
|
|
103
181
|
hand: MAIN_HAND,
|
|
104
182
|
location: { x, y, z },
|
|
@@ -112,6 +190,33 @@ export function useBlock(bot, name, x, y, z, face) {
|
|
|
112
190
|
});
|
|
113
191
|
}
|
|
114
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Waits until the client has a block record for an interaction target.
|
|
195
|
+
*
|
|
196
|
+
* <p>A console command can change a block before its block update reaches the bot. Sending the
|
|
197
|
+
* interaction in that gap is legal at the protocol level, but the client has not loaded the target
|
|
198
|
+
* yet and Paper may drop the window-opening packet. This was the intermittent compatibility
|
|
199
|
+
* failure on the otherwise deterministic chest check.
|
|
200
|
+
*/
|
|
201
|
+
async function blockKnown(bot, name, location) {
|
|
202
|
+
const deadline = Date.now() + BLOCK_KNOWN_TIMEOUT_MILLIS;
|
|
203
|
+
while (Date.now() < deadline) {
|
|
204
|
+
if (bot.blockAt(location)) {
|
|
205
|
+
// A command_exec that changed this block has already completed on the server, but the
|
|
206
|
+
// corresponding packet may still be queued on the bot socket. Give that one client tick
|
|
207
|
+
// a chance to settle before sending the interaction packet.
|
|
208
|
+
await delay(BLOCK_UPDATE_SETTLE_MILLIS);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
await delay(50);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
throw new Error(
|
|
215
|
+
`Bot ${name} was not told about the block at ${location.x} ${location.y} ${location.z} within `
|
|
216
|
+
+ `${BLOCK_KNOWN_TIMEOUT_MILLIS}ms`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
115
220
|
/** Right-clicks the nearest entity to a point, and returns which one it was. */
|
|
116
221
|
export function useEntity(bot, name, x, y, z, radius, type) {
|
|
117
222
|
requireInWorld(bot, name);
|
package/runner/src/bots.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { configurePathfinder, loadPathfinder, moveTo } from './movement.mjs';
|
|
|
3
3
|
import { reachable } from './movement.mjs';
|
|
4
4
|
import { stopAllViews, stopView as stopBotView, view as startView } from './viewer.mjs';
|
|
5
5
|
|
|
6
|
-
import { collect } from './clientview.mjs';
|
|
6
|
+
import { collect, forget } from './clientview.mjs';
|
|
7
7
|
import { addressField, identity } from './identity.mjs';
|
|
8
8
|
|
|
9
9
|
/** How long a bot has to get from a socket to standing in the world. */
|
|
@@ -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;
|
|
@@ -55,7 +58,7 @@ export class BotRegistry {
|
|
|
55
58
|
// Before waiting to join, not after: messages are events, and a plugin that greets or refuses
|
|
56
59
|
// on join says so within a tick of the bot arriving. Attaching afterwards loses exactly the
|
|
57
60
|
// messages most worth having.
|
|
58
|
-
collect(bot);
|
|
61
|
+
collect(bot, name);
|
|
59
62
|
|
|
60
63
|
try {
|
|
61
64
|
await joined(bot, name);
|
|
@@ -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
|
|
|
@@ -104,6 +108,7 @@ export class BotRegistry {
|
|
|
104
108
|
return;
|
|
105
109
|
}
|
|
106
110
|
this.#bots.delete(name);
|
|
111
|
+
forget(name);
|
|
107
112
|
stopBotView(bot);
|
|
108
113
|
quietly(() => bot.quit());
|
|
109
114
|
}
|
|
@@ -115,7 +120,8 @@ export class BotRegistry {
|
|
|
115
120
|
/** Disconnects every bot. */
|
|
116
121
|
shutdown() {
|
|
117
122
|
stopAllViews();
|
|
118
|
-
for (const bot of this.#bots
|
|
123
|
+
for (const [name, bot] of this.#bots) {
|
|
124
|
+
forget(name);
|
|
119
125
|
quietly(() => bot.quit());
|
|
120
126
|
}
|
|
121
127
|
this.#bots.clear();
|
|
@@ -186,6 +192,36 @@ async function settle(bot, name) {
|
|
|
186
192
|
);
|
|
187
193
|
}
|
|
188
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Waits until the bot's client knows the world it is standing in.
|
|
197
|
+
*
|
|
198
|
+
* `spawn` fires on the position packet, which can arrive before the chunk does. A bot that acts in
|
|
199
|
+
* that gap sends block actions against blocks it has never been told about, and the server answers
|
|
200
|
+
* with nothing at all — indistinguishable, from the caller's side, from a plugin cancelling the
|
|
201
|
+
* action silently. A dogfooding round spent most of itself on that ambiguity
|
|
202
|
+
* (dogfood/JOURNAL.md, 2026-08-23).
|
|
203
|
+
*
|
|
204
|
+
* This closes the client half. The server half — a plugin, or Paper itself, dropping interactions
|
|
205
|
+
* from a player who has only just joined — cannot be waited out from here, and is why
|
|
206
|
+
* `breakBlock` reports whether the server acknowledged the dig.
|
|
207
|
+
*/
|
|
208
|
+
async function worldKnown(bot, name) {
|
|
209
|
+
const deadline = Date.now() + WORLD_TIMEOUT_MILLIS;
|
|
210
|
+
|
|
211
|
+
while (Date.now() < deadline) {
|
|
212
|
+
const at = bot.entity?.position;
|
|
213
|
+
if (at && bot.blockAt(at.offset(0, -1, 0))) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
await delay(SETTLE_POLL_MILLIS);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
throw new Error(
|
|
220
|
+
`Bot ${name} joined but its client was never sent the world around it within `
|
|
221
|
+
+ `${WORLD_TIMEOUT_MILLIS}ms`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
189
225
|
/** Resolves when the bot is in the world; rejects on a kick, an error, or the timeout. */
|
|
190
226
|
function joined(bot, name) {
|
|
191
227
|
return new Promise((resolve, reject) => {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
1
3
|
import { menu } from './actions.mjs';
|
|
2
4
|
import { plainText, stripLegacyCodes, untag } from './text.mjs';
|
|
3
5
|
|
|
@@ -20,27 +22,63 @@ const MAX_MESSAGES = 100;
|
|
|
20
22
|
|
|
21
23
|
const state = new WeakMap();
|
|
22
24
|
|
|
25
|
+
/**
|
|
26
|
+
* The active message stream for each bot name.
|
|
27
|
+
*
|
|
28
|
+
* Each client connection gets a random id, so a cursor cannot be mistaken for one from another
|
|
29
|
+
* session, runner process or same-named replacement bot.
|
|
30
|
+
*/
|
|
31
|
+
const messageStreamsByName = new Map();
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Drops the message stream a despawned bot left behind.
|
|
35
|
+
*
|
|
36
|
+
* The stream outlives the bot on purpose while a replacement is connecting, but a bot that is gone
|
|
37
|
+
* for good would otherwise keep its entry for the lifetime of the runner. The next bot of the same
|
|
38
|
+
* name gets a new id either way, so nothing that survives here is ever reused.
|
|
39
|
+
*/
|
|
40
|
+
export function forget(name) {
|
|
41
|
+
const stream = messageStreamsByName.get(name);
|
|
42
|
+
if (stream) stream.activeCollector = null;
|
|
43
|
+
messageStreamsByName.delete(name);
|
|
44
|
+
}
|
|
45
|
+
|
|
23
46
|
/**
|
|
24
47
|
* Starts collecting what the server tells this bot.
|
|
25
48
|
*
|
|
26
49
|
* Must be called as soon as the bot exists: this is all events, so anything said before the
|
|
27
50
|
* listeners attach is gone, and a plugin greets or refuses within a tick of the bot arriving.
|
|
28
51
|
*/
|
|
29
|
-
export function collect(bot) {
|
|
52
|
+
export function collect(bot, name) {
|
|
53
|
+
const previous = messageStreamsByName.get(name);
|
|
54
|
+
if (previous) previous.activeCollector = null;
|
|
55
|
+
|
|
56
|
+
const stream = { id: randomUUID(), nextSequence: 0, activeCollector: null };
|
|
57
|
+
|
|
30
58
|
const own = {
|
|
31
59
|
messages: [],
|
|
32
60
|
bossBars: new Map(),
|
|
33
61
|
objectiveTitles: new Map(),
|
|
34
62
|
objectiveScores: new Map(),
|
|
35
63
|
sidebar: null,
|
|
64
|
+
messageStream: stream,
|
|
36
65
|
};
|
|
66
|
+
stream.activeCollector = own;
|
|
67
|
+
messageStreamsByName.set(name, stream);
|
|
37
68
|
state.set(bot, own);
|
|
38
69
|
|
|
39
70
|
const remember = (text) => {
|
|
71
|
+
// Closing a replaced bot is asynchronous. Ignore anything its socket delivers after the new
|
|
72
|
+
// bot took over, otherwise an invisible message would consume a sequence and look like loss.
|
|
73
|
+
if (stream.activeCollector !== own) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
40
76
|
if (text == null || String(text).trim() === '') {
|
|
41
77
|
return;
|
|
42
78
|
}
|
|
43
|
-
|
|
79
|
+
const sequence = stream.nextSequence;
|
|
80
|
+
stream.nextSequence += 1;
|
|
81
|
+
own.messages.push({ sequence, timestamp: Date.now(), text: String(text) });
|
|
44
82
|
while (own.messages.length > MAX_MESSAGES) {
|
|
45
83
|
own.messages.shift();
|
|
46
84
|
}
|
|
@@ -90,10 +128,13 @@ export function collect(bot) {
|
|
|
90
128
|
/** Everything the client was told, in the shape `RunnerDispatch` writes. */
|
|
91
129
|
export function inspect(bot, name) {
|
|
92
130
|
const own = state.get(bot) ?? { messages: [], bossBars: new Map() };
|
|
131
|
+
const stream = own.messageStream ?? messageStreamsByName.get(name);
|
|
93
132
|
return {
|
|
94
133
|
menu: menu(bot, name),
|
|
95
134
|
items: menuItems(bot),
|
|
96
135
|
messages: [...own.messages],
|
|
136
|
+
nextMessageSequence: stream?.nextSequence ?? 0,
|
|
137
|
+
messageStreamId: stream?.id ?? '',
|
|
97
138
|
bossBars: [...own.bossBars.values()],
|
|
98
139
|
scoreboard: sidebarOf(own),
|
|
99
140
|
health: Number.isFinite(bot.health) ? bot.health : null,
|
package/runner/src/dispatch.mjs
CHANGED
|
@@ -64,14 +64,15 @@ export class Dispatch {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
case protocol.BREAK: {
|
|
67
|
-
|
|
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
|
|
75
|
+
return protocol.encode(protocol.OK, verb, outcome);
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
case protocol.COMMAND: {
|
|
@@ -85,7 +86,7 @@ export class Dispatch {
|
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
case protocol.USE: {
|
|
88
|
-
actions.useBlock(
|
|
89
|
+
await actions.useBlock(
|
|
89
90
|
this.#bots.require(command[1]),
|
|
90
91
|
command[1],
|
|
91
92
|
Number(command[2]),
|
|
@@ -207,7 +208,7 @@ export class Dispatch {
|
|
|
207
208
|
String(open === null ? -1 : open.containerId),
|
|
208
209
|
open === null ? '' : protocol.sanitize(open.title),
|
|
209
210
|
items(view.items),
|
|
210
|
-
|
|
211
|
+
messageList(view.messages),
|
|
211
212
|
bossBarList(view.bossBars),
|
|
212
213
|
board === null ? '' : protocol.sanitize(board.title),
|
|
213
214
|
board === null ? '' : recordList(board.lines),
|
|
@@ -217,6 +218,8 @@ export class Dispatch {
|
|
|
217
218
|
view.totalExperience == null ? '' : String(view.totalExperience),
|
|
218
219
|
view.experienceProgress == null ? '' : protocol.javaFloat(view.experienceProgress),
|
|
219
220
|
recordList(view.effects),
|
|
221
|
+
String(view.nextMessageSequence),
|
|
222
|
+
protocol.sanitize(view.messageStreamId),
|
|
220
223
|
);
|
|
221
224
|
}
|
|
222
225
|
|
|
@@ -263,6 +266,17 @@ function items(list) {
|
|
|
263
266
|
.join(protocol.RECORD_SEPARATOR);
|
|
264
267
|
}
|
|
265
268
|
|
|
269
|
+
/** `sequence ␟ timestamp ␟ text`, oldest first, joined by ␞. */
|
|
270
|
+
function messageList(messages) {
|
|
271
|
+
return messages
|
|
272
|
+
.map((message) => [
|
|
273
|
+
message.sequence,
|
|
274
|
+
message.timestamp,
|
|
275
|
+
protocol.sanitize(message.text),
|
|
276
|
+
].join(protocol.UNIT_SEPARATOR))
|
|
277
|
+
.join(protocol.RECORD_SEPARATOR);
|
|
278
|
+
}
|
|
279
|
+
|
|
266
280
|
/** `title ␟ progress ␟ colour`, one record each. */
|
|
267
281
|
function bossBarList(bars) {
|
|
268
282
|
return bars
|