Files

137 lines
7.8 KiB
Python

#!/usr/bin/env python3
"""Plan/apply/verify the Gothic reference build through checked Paper editor operations."""
import argparse
from collections import Counter
import hashlib
import json
import os
from pathlib import Path
import re
import time
import urllib.error
import urllib.request
import uuid
from builds.gothic_hall.scene import build_scene, manifest
ROOT = Path(__file__).resolve().parents[1]
OUTPUT = ROOT / '.runtime/gothic-hall'
CONFIG = ROOT / '.runtime/server/plugins/MinecraftBuilderMCP/config.yml'
def save(path, data):
temp = path.with_suffix('.tmp')
with temp.open('w') as stream:
json.dump(data, stream, indent=2)
stream.write('\n'); stream.flush(); os.fsync(stream.fileno())
temp.replace(path)
def digest(data):
return hashlib.sha256(json.dumps(data, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
def point_key(value):
return value['x'], value['y'], value['z']
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('action', choices=['plan', 'apply', 'verify'])
args = parser.parse_args()
OUTPUT.mkdir(exist_ok=True)
config = CONFIG.read_text()
def scalar(key):
return re.search(r'^' + re.escape(key) + r':[ \t]*(.*?)$', config, re.M).group(1).strip().strip("'\"")
scope = {'player_id': scalar('owner-uuid'), 'project_id': scalar('project-id')}
assert scope['player_id'], 'An online project owner is required'
token = scalar('agent-token')
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
def rpc(method, params):
for attempt in range(100):
data = {'method': method, 'params': {**params, **scope}, 'requestId': str(uuid.uuid4())}
req = urllib.request.Request('http://127.0.0.1:' + scalar('http-port') + '/v1/rpc',
data=json.dumps(data).encode(), headers={'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json'})
try:
with opener.open(req, timeout=30) as response: body = json.load(response)
except urllib.error.HTTPError as error:
body = json.load(error)
if body.get('ok'): return body['result']
code = body.get('error', {}).get('code', 'unknown')
if code == 'busy': time.sleep(.1); continue
raise RuntimeError(f'{method}: {code}; no blind overwrite or replay performed')
raise RuntimeError('Paper remained busy')
context = rpc('project_context', {})
if args.action == 'plan':
voxels = build_scene(); model = manifest(voxels)
allowed = set(context['supported_materials'])
assert all(block.split('[')[0] in allowed for block in model['palette']), 'Unsupported material in blueprint'
for batch in model['batches']:
assert batch['blocks'] <= 4096
expanded = {}
for operation in batch['recipe']['operations']:
a, b = operation['min'], operation['max']
for y in range(a['y'], b['y'] + 1):
for z in range(a['z'], b['z'] + 1):
for x in range(a['x'], b['x'] + 1):
assert (x, y, z) not in expanded, 'Compression overlapped a voxel'
local = tuple(n - o for n, o in zip((x, y, z), model['origin']))
assert voxels.blocks[local] == operation['block'], 'Compression changed the blueprint'
expanded[(x, y, z)] = operation['block']
assert len(expanded) == batch['blocks']
for axis in ('x', 'y', 'z'):
assert context['region']['min'][axis] <= batch['min'][axis] <= batch['max'][axis] <= context['region']['max'][axis]
model['world_id'] = context['world_id']; model['world_epoch'] = context['world_epoch']
path = OUTPUT / 'manifest.json'
if (OUTPUT / 'ledger.json').exists() and path.exists():
assert digest(json.loads(path.read_text())) == digest(model), 'Existing live build has a different blueprint; preserve it'
save(path, model)
print(json.dumps({'blocks': model['blocks'], 'batches': len(model['batches']), 'stages': model['stages'],
'max_recipe_operations': max(len(b['recipe']['operations']) for b in model['batches'])}), flush=True)
return
model = json.loads((OUTPUT / 'manifest.json').read_text())
assert model['world_id'] == context['world_id'] and model['world_epoch'] == context['world_epoch'], 'World identity changed'
ledger_path = OUTPUT / 'ledger.json'
ledger = json.loads(ledger_path.read_text()) if ledger_path.exists() else {'manifest_sha256': digest(model), 'batches': {}}
assert ledger['manifest_sha256'] == digest(model), 'Blueprint changed after writing began'
for index, batch in enumerate(model['batches'], 1):
record = ledger['batches'].get(batch['key'])
if args.action == 'apply':
if record is None:
before = rpc('region_inspect', {'min': batch['min'], 'max': batch['max'], 'detail': 'summary'})
assert set(before['palette']) == {'minecraft:air'}, f"Occupied site in batch {batch['key']}; preserve and review"
plan = rpc('build_prepare', {'recipe': batch['recipe']})
# The immutable persisted plan must still agree with the reviewed empty site.
envelope = json.loads((CONFIG.parent / 'journal/plans' / (plan['plan_id'] + '.json')).read_text())
persisted = json.loads(envelope['payload'])
assert all(change['expected'] == 'minecraft:air' for change in persisted['changes']), 'Concurrent edit before prepare; stop'
record = {'plan': plan, 'idempotency_key': 'gothic-hall-' + plan['plan_id']}
ledger['batches'][batch['key']] = record; save(ledger_path, ledger)
if not record.get('operation_id'):
operation = rpc('build_apply', {**record['plan'], 'idempotency_key': record['idempotency_key']})
record['operation_id'] = operation['operation_id']; save(ledger_path, ledger)
for _ in range(600):
status = rpc('operation_status', {'operation_id': record['operation_id']})
if status['status'] not in ('queued', 'applying'): break
time.sleep(.1)
record['status'] = status; save(ledger_path, ledger)
assert status['status'] == 'applied', f"Batch {batch['key']} stopped: {status['status']}; inspect the operation"
print(json.dumps({'batch': index, 'of': len(model['batches']), 'key': batch['key'], 'written': status['written'],
'stages': batch['stages'], 'operation_id': record['operation_id']}), flush=True)
else:
assert record and record.get('status', {}).get('status') == 'applied', 'Build is not fully applied'
envelope = json.loads((CONFIG.parent / 'journal/plans' / (record['plan']['plan_id'] + '.json')).read_text())
planned = json.loads(envelope['payload'])
current = rpc('region_inspect', {'min': batch['min'], 'max': batch['max'], 'detail': 'blocks'})
states = {point_key(block['pos']): block['state'] for block in current['blocks']}
mismatches = [change['pos'] for change in planned['changes'] if states[point_key(change['pos'])] != change['desired']]
assert not mismatches, f"Live geometry differs in batch {batch['key']} at {mismatches[:5]}; preserve edits"
if args.action == 'verify':
report = {'status': 'verified', 'blocks': model['blocks'], 'batches': len(model['batches']), 'verified_at': time.time(),
'manifest_sha256': digest(model), 'scope': scope}
save(OUTPUT / 'verification.json', report)
print(json.dumps(report), flush=True)
else:
print('GOTHIC HALL BUILD APPLIED', flush=True)
if __name__ == '__main__':
main()