Expose the runtime block/item registry through compact material search and single-material descriptions. Preserve private block-entity data through checked edits and durable undo without sending payloads to model context. Include the completed terrain tools, isolated world/map plugin, station lift, ACP streaming and guidance fixes, local camera auto-connect, construction scripts, and their public documentation, references and verification records. Active station decoration and private runtime data remain outside this commit. Validation: 145 Maven tests, 47 Bridge tests, successful camera Gradle build, and isolated Paper verification of all 1,196 block defaults plus 5,392 independent property cases for placement, same-material edits and restoration.
197 lines
10 KiB
Python
197 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""Offline heightmap previews and bounded, resumable terrain batches. Uses the normal checked RPC editor."""
|
|
import argparse
|
|
import fcntl
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import subprocess
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
import uuid
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
spec = importlib.util.spec_from_file_location('mcb_launcher', ROOT / 'scripts/bridge.py')
|
|
launcher = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(launcher)
|
|
|
|
|
|
class RpcError(RuntimeError):
|
|
def __init__(self, code, message):
|
|
super().__init__(f'{code}: {message}')
|
|
self.code = code
|
|
|
|
|
|
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
raise RpcError('redirect_rejected', 'Backend redirects are not permitted')
|
|
|
|
|
|
class Backend:
|
|
def __init__(self, config, console=False):
|
|
text = config.read_text()
|
|
self.token = launcher.scalar(text, 'agent-token')
|
|
self.scope = {'player_id': 'console' if console else launcher.scalar(text, 'owner-uuid'),
|
|
'project_id': launcher.scalar(text, 'project-id')}
|
|
port = int(launcher.scalar(text, 'http-port'))
|
|
if not 1 <= port <= 65535 or not self.token or not self.scope['player_id']:
|
|
raise ValueError('Invalid backend configuration or unbound owner')
|
|
self.url = f'http://127.0.0.1:{port}/v1/rpc'
|
|
self.opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect())
|
|
|
|
def call(self, method, **params):
|
|
body = json.dumps({'method': method, 'params': {**params, **self.scope}, 'requestId': str(uuid.uuid4())}).encode()
|
|
for _ in range(100):
|
|
request = urllib.request.Request(self.url, data=body, headers={'Content-Type': 'application/json', 'Authorization': 'Bearer ' + self.token})
|
|
try:
|
|
with self.opener.open(request, timeout=30) as response:
|
|
data = json.load(response)
|
|
except urllib.error.HTTPError as error:
|
|
data = json.load(error)
|
|
if data.get('ok'):
|
|
return data['result']
|
|
failure = data.get('error', {})
|
|
if failure.get('code') == 'busy':
|
|
time.sleep(.1)
|
|
continue
|
|
raise RpcError(failure.get('code', 'invalid_response'), failure.get('message', 'Backend rejected request').replace(self.token, '[REDACTED]'))
|
|
raise RpcError('busy', 'Backend did not become ready')
|
|
|
|
|
|
def save(path, value):
|
|
temporary = path.with_suffix(path.suffix + '.tmp')
|
|
with temporary.open('w') as f:
|
|
json.dump(value, f, indent=2)
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
temporary.replace(path)
|
|
directory = os.open(path.parent, os.O_RDONLY)
|
|
try:
|
|
os.fsync(directory)
|
|
finally:
|
|
os.close(directory)
|
|
|
|
|
|
def finish(backend, operation):
|
|
deadline = time.monotonic() + 180
|
|
while time.monotonic() < deadline:
|
|
result = backend.call('operation_status', operation_id=operation)
|
|
if result['status'] in ('applied', 'conflict', 'failed', 'cancelled', 'recovery_required'):
|
|
return result
|
|
time.sleep(.15)
|
|
raise RuntimeError(f'Operation still running: {operation}; resume with the same manifest')
|
|
|
|
|
|
def apply_plan(backend, entry, manifest, path):
|
|
# Persist plan and stable key BEFORE transmission. Unknown outcomes reuse this exact request.
|
|
entry.setdefault('idempotency_key', 'terrain-' + str(uuid.uuid4()))
|
|
save(path, manifest)
|
|
if 'operation_id' not in entry:
|
|
result = backend.call('build_apply', plan_id=entry['plan_id'], plan_hash=entry['plan_hash'], idempotency_key=entry['idempotency_key'])
|
|
entry['operation_id'] = result['operation_id']
|
|
save(path, manifest)
|
|
result = finish(backend, entry['operation_id'])
|
|
entry['status'] = result['status']
|
|
entry['written'] = result['written']
|
|
save(path, manifest)
|
|
if result['status'] != 'applied':
|
|
raise RuntimeError(f"Stopped on {result['status']}: {entry['operation_id']}. Inspect conflicts; no new plan or blind retry was created.")
|
|
|
|
|
|
def run_batch(args):
|
|
backend = Backend(args.config, args.console)
|
|
context = backend.call('project_context')
|
|
scope = {key: context[key] for key in ('project_id', 'world_id', 'world_epoch')}
|
|
path = args.manifest.resolve()
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with path.with_suffix(path.suffix + '.lock').open('a') as lock:
|
|
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
manifest = json.loads(path.read_text()) if path.exists() else None
|
|
if manifest and manifest['scope'] != scope:
|
|
raise RuntimeError('Manifest belongs to another project/world/epoch')
|
|
if args.command == 'undo':
|
|
if manifest is None:
|
|
raise RuntimeError('Manifest not found')
|
|
# Resolve an apply whose response was lost BEFORE assuming there is nothing to undo.
|
|
for entry in reversed(manifest['tiles']):
|
|
if 'idempotency_key' in entry and 'operation_id' not in entry:
|
|
raise RuntimeError('Uncertain apply response. Resume the original apply with this manifest to resolve its stable key before undo; undo will not start an unconfirmed write.')
|
|
if 'operation_id' not in entry:
|
|
continue
|
|
result = finish(backend, entry['operation_id'])
|
|
if result['status'] == 'recovery_required':
|
|
raise RuntimeError('Interrupted server operation requires recovery review first')
|
|
if not result['written']:
|
|
continue
|
|
if 'undo' not in entry:
|
|
entry['undo'] = backend.call('operation_undo_prepare', operation_id=entry['operation_id'])
|
|
save(path, manifest)
|
|
apply_plan(backend, entry['undo'], manifest, path)
|
|
manifest['undone'] = True; save(path, manifest)
|
|
print(json.dumps({'status': 'undone', 'manifest': str(path)}))
|
|
return
|
|
recipe = json.loads(args.recipe.read_text())
|
|
preview = backend.call('terrain_preview', recipe=recipe, resolution=64)
|
|
terrain_id = preview['terrain_id']
|
|
if manifest:
|
|
if manifest['terrain_id'] != terrain_id or manifest['start'] != args.start or manifest['count'] != args.count or manifest['tile_budget'] != preview['tile_budget']:
|
|
raise RuntimeError('Recipe or tile range differs from saved batch')
|
|
if manifest.get('undone') or any('undo' in entry for entry in manifest['tiles']):
|
|
raise RuntimeError('Batch has begun undo; finish undo and use a NEW manifest for new work')
|
|
else:
|
|
if args.start < 0 or not 1 <= args.count <= 64 or args.start + args.count > preview['tile_count']:
|
|
raise RuntimeError('Choose 1..64 existing tiles per batch')
|
|
manifest = {'version': 1, 'scope': scope, 'terrain_id': terrain_id, 'recipe': recipe,
|
|
'start': args.start, 'count': args.count, 'tile_budget': preview['tile_budget'], 'tiles': []}
|
|
save(path, manifest)
|
|
# The entire requested envelope must fit; never silently clip to an unrelated current project.
|
|
for axis in ('x', 'y', 'z'):
|
|
if recipe['min'][axis] < context['region']['min'][axis] or recipe['max'][axis] > context['region']['max'][axis]:
|
|
raise RuntimeError('Recipe exceeds selected project area; choose a dedicated terrain site first')
|
|
for index in range(args.start, args.start + args.count):
|
|
entry = next((e for e in manifest['tiles'] if e['tile_index'] == index), None)
|
|
if entry is None:
|
|
entry = backend.call('terrain_prepare', terrain_id=terrain_id, tile_index=index)
|
|
manifest['tiles'].append(entry)
|
|
save(path, manifest)
|
|
if entry.get('status') == 'empty' or entry.get('changed_blocks') == 0:
|
|
continue
|
|
if entry.get('status') in ('conflict', 'cancelled', 'failed', 'recovery_required'):
|
|
raise RuntimeError('Batch stopped previously; inspect or undo it instead of forcing through')
|
|
apply_plan(backend, entry, manifest, path)
|
|
print(json.dumps({'tile': index, 'status': entry['status'], 'written': entry['written']}), flush=True)
|
|
print(json.dumps({'status': 'completed', 'manifest': str(path), 'tiles': len(manifest['tiles'])}))
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
sub = parser.add_subparsers(dest='command', required=True)
|
|
preview = sub.add_parser('preview', help='Render the exact height field OFFLINE; no server or world mutation')
|
|
preview.add_argument('recipe', type=Path)
|
|
preview.add_argument('--output', type=Path, required=True)
|
|
for name in ('apply', 'undo'):
|
|
p = sub.add_parser(name)
|
|
p.add_argument('--manifest', type=Path, required=True)
|
|
p.add_argument('--config', type=Path, default=ROOT / '.runtime/server/plugins/MinecraftBuilderMCP/config.yml')
|
|
p.add_argument('--console', action='store_true', help='Only for an explicitly configured isolated fixture')
|
|
p.add_argument('--execute', action='store_true', required=True, help='Explicitly perform this world edit')
|
|
if name == 'apply':
|
|
p.add_argument('recipe', type=Path)
|
|
p.add_argument('--start', type=int, required=True)
|
|
p.add_argument('--count', type=int, required=True)
|
|
args = parser.parse_args()
|
|
if args.command == 'preview':
|
|
java = Path(os.environ.get('MCB_JAVA_HOME', str(Path.home() / '.cache/minecraft-builder-mcp/jdk-25.0.2'))) / 'bin/java'
|
|
jar = ROOT / 'paper-plugin/target/paper-plugin-0.1.0-SNAPSHOT.jar'
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
subprocess.run([str(java), '-Djava.awt.headless=true', '-cp', str(jar), 'io.github.minecraftbuilder.paper.TerrainPreview', str(args.recipe.resolve()), str(args.output.resolve()), str(args.output.with_suffix('.json').resolve())], check=True)
|
|
print(args.output)
|
|
else:
|
|
run_batch(args)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|