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.
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
#!/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)
|
||||
Reference in New Issue
Block a user