Files
Emil 89c53d7058 Expand material support and checkpoint the completed building toolkit
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.
2026-09-13 16:59:13 +03:00

327 lines
16 KiB
Python

#!/usr/bin/env python3
"""Place an explicit block layout with checked snapshots, resumable receipts, and guarded undo."""
import argparse
from collections import Counter, defaultdict
import fcntl
import hashlib
import importlib.util
import json
from pathlib import Path
import re
import sys
import time
spec = importlib.util.spec_from_file_location('layout_terrain', Path(__file__).with_name('terrain.py'))
terrain = importlib.util.module_from_spec(spec)
spec.loader.exec_module(terrain)
ROOT = terrain.ROOT
MAX_BLOCKS = 4096
MAX_OPERATIONS = 256
MAX_READ_CELLS = 32
SCOPE_KEYS = ('project_id', 'world_id', 'world_epoch')
FAILED = ('conflict', 'cancelled', 'failed', 'recovery_required')
STATE = re.compile(r'minecraft:[a-z0-9_]+(?:\[[a-z0-9_=,]+\])?\Z')
def position(block):
return tuple(block[axis] for axis in ('x', 'y', 'z'))
def coordinates(at):
return dict(zip(('x', 'y', 'z'), at))
def normalize(value):
if not isinstance(value, dict) or type(value.get('version')) is not int or value['version'] != 1:
raise ValueError('Layout version must be 1')
scope = value.get('scope', {})
if not isinstance(scope, dict) or any(not isinstance(scope.get(k), str) or not scope[k] for k in SCOPE_KEYS):
raise ValueError('Layout requires project_id, world_id, and world_epoch')
source = value.get('blocks')
if not isinstance(source, list) or not 1 <= len(source) <= 1_000_000:
raise ValueError('Layout must contain 1..1,000,000 explicit blocks')
blocks, seen = [], set()
for raw in source:
if not isinstance(raw, dict):
raise ValueError('Every block must be an object')
if any(type(raw.get(a)) is not int or not -2**31 <= raw[a] < 2**31 for a in ('x', 'y', 'z')):
raise ValueError('Block coordinates must be signed 32-bit integers')
at = position(raw)
if at in seen:
raise ValueError(f'Duplicate block position: {at}')
seen.add(at)
block, expected = raw.get('block'), raw.get('expected', 'minecraft:air')
if any(not isinstance(s, str) or len(s) > 512 or not STATE.fullmatch(s) for s in (block, expected)):
raise ValueError(f'Invalid block state at {at}; use full minecraft: names')
blocks.append({**coordinates(at), 'block': block, 'expected': expected})
blocks.sort(key=position)
return {'version': 1, 'scope': {k: scope[k] for k in SCOPE_KEYS}, 'blocks': blocks}
def digest(value):
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
def read_cell(block):
return (block['x'] // 16, block['y'] // 16, block['z'] // 16)
def bounds(blocks):
return {'min': {a: min(b[a] for b in blocks) for a in ('x', 'y', 'z')},
'max': {a: max(b[a] for b in blocks) for a in ('x', 'y', 'z')}}
def volume(box):
return (box['max']['x'] - box['min']['x'] + 1) * (box['max']['y'] - box['min']['y'] + 1) * (box['max']['z'] - box['min']['z'] + 1)
def read_groups(blocks):
cells = defaultdict(list)
for block in blocks:
cells[read_cell(block)].append(block)
return [cells[key] for key in sorted(cells)]
def compressed_runs(blocks):
"""Runs never cross a 16³ read cell, so every inspection is bounded to 4096 voxels."""
rows = defaultdict(list)
for block in blocks:
rows[(*read_cell(block), block['y'], block['z'], block['block'])].append(block)
for key in sorted(rows):
row = sorted(rows[key], key=lambda b: b['x'])
run = []
for block in row:
if run and block['x'] != run[-1]['x'] + 1:
yield run
run = []
run.append(block)
if run:
yield run
def make_batches(blocks):
batches, batch, cells = [], [], set()
for run in compressed_runs(blocks):
cell = read_cell(run[0])
if batch and (len(batch) >= MAX_OPERATIONS or sum(len(r) for r in batch) + len(run) > MAX_BLOCKS
or len(cells | {cell}) > MAX_READ_CELLS):
batches.append(batch)
batch, cells = [], set()
batch.append(run)
cells.add(cell)
if batch:
batches.append(batch)
result = []
for index, runs in enumerate(batches):
selected = [b for run in runs for b in run]
operations = [{'type': 'box', 'min': coordinates(position(run[0])),
'max': coordinates(position(run[-1])), 'block': run[0]['block']} for run in runs]
result.append({'index': index, 'blocks': selected, 'recipe': {'version': 1, 'operations': operations}})
return result
def check_context(context, layout, require_checked=True):
if {key: context.get(key) for key in SCOPE_KEYS} != layout['scope']:
raise RuntimeError('Layout belongs to another project/world/epoch')
if require_checked and context.get('checked_expected_blocks') is not True:
raise RuntimeError('Server lacks atomic checked_expected_blocks; update the plugin before placing this layout')
area = context.get('region', {})
for block in layout['blocks']:
if any(not area.get('min', {}).get(a, 1) <= block[a] <= area.get('max', {}).get(a, 0) for a in ('x', 'y', 'z')):
raise RuntimeError(f'Layout exceeds selected project area at {position(block)}')
def inspect_states(backend, blocks, epoch):
states = {}
for group in read_groups(blocks):
box = bounds(group)
result = backend.call('region_inspect', **box, detail='blocks')
if result.get('world_epoch') != epoch or result.get('truncated') is not False:
raise RuntimeError('Inspection was truncated or returned a different world epoch')
found = {}
for item in result.get('blocks', []):
at = position(item['pos'])
if at in found:
raise RuntimeError('Inspection contained duplicate positions')
found[at] = item['state']
if len(found) != volume(box):
raise RuntimeError('Inspection did not return the complete bounded region')
for block in group:
at = position(block)
if at not in found:
raise RuntimeError(f'Inspection omitted {at}')
states[at] = found[at]
return states
def verify(backend, blocks, epoch, field):
states = inspect_states(backend, blocks, epoch)
mismatches = [(position(b), b[field], states[position(b)]) for b in blocks if states[position(b)] != b[field]]
if mismatches:
at, expected, actual = mismatches[0]
raise RuntimeError(f'Block mismatch at {at}: expected {expected}, found {actual} ({len(mismatches)} mismatches); no blind overwrite')
return len(blocks)
def load_or_create(path, layout, batches):
expected_digest = digest(layout)
snapshot = path.with_suffix(path.suffix + '.input.json')
if path.exists():
manifest = json.loads(path.read_text())
if (manifest.get('version') != 1 or manifest.get('planning_version') != 1
or manifest.get('scope') != layout['scope'] or manifest.get('input_digest') != expected_digest):
raise RuntimeError('Manifest layout digest, planning version, or project/world/epoch differs; do not reuse it')
if not snapshot.exists() or digest(normalize(json.loads(snapshot.read_text()))) != expected_digest:
raise RuntimeError('Saved layout input is missing or changed')
if len(manifest.get('batches', [])) != len(batches):
raise RuntimeError('Saved batch count differs from deterministic planner')
for saved, planned in zip(manifest['batches'], batches):
if saved.get('index') != planned['index'] or saved.get('digest') != digest(planned):
raise RuntimeError('Saved batch differs from deterministic planner')
return manifest
if snapshot.exists() and digest(normalize(json.loads(snapshot.read_text()))) != expected_digest:
raise RuntimeError('An orphaned layout input differs; choose a new manifest path')
terrain.save(snapshot, layout)
manifest = {'version': 1, 'planning_version': 1, 'scope': layout['scope'],
'input_digest': expected_digest, 'blocks': len(layout['blocks']),
'batches': [{'index': b['index'], 'digest': digest(b), 'blocks': len(b['blocks'])} for b in batches]}
terrain.save(path, manifest)
return manifest
def apply_layout(backend, layout, path, progress=print):
check_context(backend.call('project_context'), layout)
batches = make_batches(layout['blocks'])
manifest = load_or_create(path, layout, batches)
if manifest.get('undo_started') or manifest.get('undone') or any('undo' in e for e in manifest['batches']):
raise RuntimeError('Layout has begun undo; finish undo and use a new manifest for new work')
epoch = layout['scope']['world_epoch']
for batch, entry in zip(batches, manifest['batches']):
if entry.get('status') in FAILED:
raise RuntimeError(f"Batch {entry['index']} stopped on {entry['status']}; inspect or undo it instead of creating a new plan")
if entry.get('status') == 'applied':
verify(backend, batch['blocks'], epoch, 'block')
continue
# An unknown apply may already have changed the world: resolve its same stable key first.
if 'idempotency_key' not in entry and 'operation_id' not in entry:
verify(backend, batch['blocks'], epoch, 'expected')
if 'plan_id' not in entry:
prepared = backend.call('build_prepare', recipe=batch['recipe'], expected_blocks=[
{'pos': coordinates(position(b)), 'state': b['expected']} for b in batch['blocks']])
entry.update(prepared)
terrain.save(path, manifest)
terrain.apply_plan(backend, entry, manifest, path)
verify(backend, batch['blocks'], epoch, 'block')
entry['verified_at'] = int(time.time())
terrain.save(path, manifest)
progress(json.dumps({'batch': entry['index'] + 1, 'batches': len(batches), 'status': 'verified',
'written': entry['written'], 'operation_id': entry['operation_id']}))
manifest['completed'] = True
terrain.save(path, manifest)
return {'status': 'completed', 'blocks': len(layout['blocks']), 'batches': len(batches), 'manifest': str(path)}
def undo_layout(backend, path, progress=print):
snapshot = path.with_suffix(path.suffix + '.input.json')
if not path.exists() or not snapshot.exists():
raise RuntimeError('Manifest or saved layout input not found')
layout = normalize(json.loads(snapshot.read_text()))
check_context(backend.call('project_context'), layout, require_checked=False)
batches = make_batches(layout['blocks'])
manifest = load_or_create(path, layout, batches)
for entry in manifest['batches']:
if 'idempotency_key' in entry and 'operation_id' not in entry:
raise RuntimeError('Uncertain apply response. Resume apply with the same manifest first; undo will not start an unconfirmed write')
manifest['undo_started'] = True
terrain.save(path, manifest)
for batch, entry in reversed(list(zip(batches, manifest['batches']))):
if 'operation_id' not in entry:
continue
source = terrain.finish(backend, entry['operation_id'])
if source['status'] == 'recovery_required':
raise RuntimeError('Interrupted server operation requires recovery review before undo')
if not source['written']:
continue
if 'undo' not in entry:
entry['undo'] = backend.call('operation_undo_prepare', operation_id=entry['operation_id'])
terrain.save(path, manifest)
if entry['undo'].get('status') in FAILED:
raise RuntimeError(f"Undo stopped on {entry['undo']['status']}; inspect conflicts before taking further action")
terrain.apply_plan(backend, entry['undo'], manifest, path)
# Only a fully applied source has receipts for every changed position. A guarded partial
# undo intentionally leaves conflicting manual edits alone; the server verifies its receipts.
if source['status'] == 'applied':
verify(backend, batch['blocks'], layout['scope']['world_epoch'], 'expected')
entry['undo']['verified_at'] = int(time.time())
else:
entry['undo']['verification'] = 'server_receipts_only_for_partial_source'
terrain.save(path, manifest)
progress(json.dumps({'batch': entry['index'] + 1, 'status': 'undone', 'written': entry['undo']['written']}))
manifest['undone'] = True
terrain.save(path, manifest)
return {'status': 'undone', 'manifest': str(path)}
def report(layout):
batches = make_batches(layout['blocks'])
boxes = [bounds(group) for b in batches for group in read_groups(b['blocks'])]
return {'status': 'offline_report', 'scope': layout['scope'], 'input_digest': digest(layout),
'blocks': len(layout['blocks']), 'batches': len(batches), 'bounds': bounds(layout['blocks']),
'materials': dict(sorted(Counter(b['block'] for b in layout['blocks']).items())),
'inspection_requests_per_pass': len(boxes), 'inspection_voxels_per_pass': sum(map(volume, boxes)),
'max_inspection_volume': max(map(volume, boxes)),
'max_batch_blocks': max(len(b['blocks']) for b in batches),
'max_batch_operations': max(len(b['recipe']['operations']) for b in batches)}
def main():
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest='command', required=True)
p = sub.add_parser('report', help='Report deterministic batching offline; no server required')
p.add_argument('layout', type=Path)
descriptions = {'prepare': 'Save immutable input and check current blocks without editing the world',
'apply': 'Place or resume the layout through checked, journalled RPC',
'undo': 'Undo recorded layout operations in reverse order, preserving later edits'}
for name, description in descriptions.items():
p = sub.add_parser(name, help=description)
if name != 'undo':
p.add_argument('layout', type=Path)
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')
if name != 'prepare':
p.add_argument('--execute', action='store_true', required=True, help='Explicitly perform this world edit')
args = parser.parse_args()
if args.command == 'report':
print(json.dumps(report(normalize(json.loads(args.layout.read_text()))), indent=2))
return
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)
backend = terrain.Backend(args.config, args.console)
if args.command == 'undo':
result = undo_layout(backend, path, progress=lambda s: print(s, flush=True))
else:
layout = normalize(json.loads(args.layout.read_text()))
if args.command == 'prepare':
check_context(backend.call('project_context'), layout)
batches = make_batches(layout['blocks'])
manifest = load_or_create(path, layout, batches)
if any('plan_id' in e for e in manifest['batches']):
raise RuntimeError('This manifest already has plans; use apply to resume, or report for an offline summary')
for batch in batches:
verify(backend, batch['blocks'], layout['scope']['world_epoch'], 'expected')
result = {**report(layout), 'status': 'prepared_input', 'manifest': str(path)}
else:
result = apply_layout(backend, layout, path, progress=lambda s: print(s, flush=True))
print(json.dumps(result, indent=2))
if __name__ == '__main__':
try:
main()
except (RuntimeError, ValueError, OSError) as error:
print(f'Layout stopped: {error}', file=sys.stderr)
sys.exit(1)