#!/usr/bin/env python3 """Prepare/apply 18 checked Gothic-hall corrections; verify the entire original build plus this patch.""" import argparse import hashlib import json import os from pathlib import Path import re import tempfile import time import urllib.error import urllib.request import uuid from builds.gothic_hall.scene import build_scene ROOT = Path(__file__).resolve().parents[1] OUTPUT = ROOT / '.runtime/gothic-hall' CONFIG = ROOT / '.runtime/server/plugins/MinecraftBuilderMCP/config.yml' LEDGER = OUTPUT / 'finish-ledger.json' STAIR = 'minecraft:stone_brick_stairs[facing=east,half=bottom,shape=straight,waterlogged=false]' def require(condition, message): if not condition: raise RuntimeError(message) def digest(value): return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest() def point(value): return value['x'], value['y'], value['z'] def position(values): return dict(zip(('x', 'y', 'z'), values)) def canonical(state): if '[' not in state: return state name, properties = state.rstrip(']').split('[', 1) return name + '[' + ','.join(sorted(properties.split(','))) + ']' def envelope(path, kind): data = json.loads(path.read_text()) require(data.get('format') == 1 and data.get('kind') == kind, 'Unexpected journal envelope') require(data.get('id') == path.stem, 'Journal identity mismatch') require(hashlib.sha256(data['payload'].encode()).hexdigest() == data['sha256'], 'Journal checksum mismatch') value = json.loads(data['payload']) require(value['id'] == path.stem, 'Journal payload identity mismatch') return value, data['sha256'] def save(path, data, immutable=False): """Fully persist a complete JSON file; immutable files are never replaced.""" path.parent.mkdir(parents=True, exist_ok=True) fd, temporary = tempfile.mkstemp(prefix='.finish-', suffix='.tmp', dir=path.parent) try: with os.fdopen(fd, 'w') as stream: json.dump(data, stream, indent=2) stream.write('\n') stream.flush() os.fsync(stream.fileno()) if immutable: os.link(temporary, path) # Atomic publication with fail-if-existing semantics. else: os.replace(temporary, path) directory = os.open(path.parent, os.O_RDONLY) try: os.fsync(directory) finally: os.close(directory) finally: if os.path.exists(temporary): os.unlink(temporary) class NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): raise RuntimeError('Paper RPC redirect refused') class Backend: def __init__(self): config = CONFIG.read_text() def scalar(name): match = re.search(r'^' + re.escape(name) + r':[ \t]*(.*?)$', config, re.M) require(match is not None, 'Required Paper configuration field is missing') return match.group(1).strip().strip("'\"") self.scope = {'player_id': scalar('owner-uuid'), 'project_id': scalar('project-id')} require(self.scope['player_id'], 'An online bound owner is required') self.token = scalar('agent-token') port = scalar('http-port') require(port.isdigit() and 1 <= int(port) <= 65535, 'Invalid Paper port') self.url = 'http://127.0.0.1:' + port + '/v1/rpc' self.opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect()) def rpc(self, method, params): for _ in range(100): payload = {'method': method, 'params': {**params, **self.scope}, 'requestId': str(uuid.uuid4())} request = urllib.request.Request(self.url, data=json.dumps(payload).encode(), headers={ 'Authorization': 'Bearer ' + self.token, 'Content-Type': 'application/json'}) try: with self.opener.open(request, timeout=30) as response: raw = response.read(2_097_153) except urllib.error.HTTPError as error: raw = error.read(2_097_153) except (OSError, urllib.error.URLError): raise RuntimeError(f'{method}: connection interrupted; reuse the existing finish ledger') from None require(len(raw) <= 2_097_152, 'Paper response exceeded limit') body = json.loads(raw) 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 new idempotency key was used') raise RuntimeError('Paper remained busy') def references(): model = json.loads((OUTPUT / 'manifest.json').read_text()) base = json.loads((OUTPUT / 'ledger.json').read_text()) require(base['manifest_sha256'] == digest(model), 'Original manifest no longer matches its ledger') require(model['blocks'] == 29354 and len(model['batches']) == len(base['batches']) == 87, 'Expected the original completed 29,354-block, 87-plan build') require({b['key'] for b in model['batches']} == set(base['batches']), 'Original batch set changed') states, plans = {}, {} for batch in model['batches']: record = base['batches'][batch['key']] require(record.get('status', {}).get('status') == 'applied', 'Original build has an incomplete batch') summary = record['plan'] plan, checksum = envelope(CONFIG.parent / 'journal/plans' / (summary['plan_id'] + '.json'), 'plan') require(checksum == summary['plan_hash'], 'Original saved plan hash changed') require(plan['worldEpoch'] == model['world_epoch'] and plan['region']['worldId'] == model['world_id'], 'Original plan world identity differs') require(len(plan['changes']) == batch['blocks'], 'Original plan block count differs') for change in plan['changes']: at = point(change['pos']) require(at not in states, 'Original plans overlap') states[at] = change['desired'] plans[batch['key']] = {'plan_id': plan['id'], 'sha256': checksum, 'plan': plan} require(len(states) == model['blocks'], 'Original saved plans are incomplete') return model, states, plans def targets(model, states): scene = build_scene() local = [(x, 30, z) for x in (6, 32) for z in (14, 22, 30, 38, 46, 54)] local += [(x, 34, 12) for x in (7, 31)] local += [(35, 8, z) for z in range(36, 40)] require(len(local) == len(set(local)) == 18, 'Finish mask must contain exactly 18 cells') result = [] for at in local: world = tuple(a + b for a, b in zip(at, model['origin'])) before = states.get(world) require(before is not None and canonical(scene.blocks.get(at, 'minecraft:air')) == canonical(before), f'Frozen scene differs from original canonical plan at local {at}') desired = STAIR if at[0] == 35 else 'minecraft:stone_bricks' if at[0] != 35: require(before == 'minecraft:stone_brick_slab[type=bottom,waterlogged=false]', f'Expected the original pinnacle cap at local {at}') else: require(before == 'minecraft:spruce_planks', f'Expected the original gallery floor at local {at}') result.append({'local': list(at), 'pos': position(world), 'expected': before, 'desired': desired}) return result def inspect_patch(backend, changes, field): for group in ([c for c in changes if c['local'][1] == y] for y in (30, 34, 8)): locations = [point(c['pos']) for c in group] lo = position([min(p[i] for p in locations) for i in range(3)]) hi = position([max(p[i] for p in locations) for i in range(3)]) result = backend.rpc('region_inspect', {'min': lo, 'max': hi, 'detail': 'blocks'}) live = {point(b['pos']): b['state'] for b in result['blocks']} for change in group: require(live.get(point(change['pos'])) == change[field], f'Live {field} mismatch at local {tuple(change["local"])}; preserve current world') def validate_saved_finish(ledger, changes, model, plans): require(ledger['version'] == 1 and ledger['manifest_sha256'] == digest(model), 'Finish ledger blueprint mismatch') require(ledger['changes'] == changes, 'Immutable finish mask changed') sources = {key: {'plan_id': value['plan_id'], 'sha256': value['sha256']} for key, value in plans.items()} require(ledger['source_plans'] == sources, 'Original plan references changed') plan, checksum = envelope(CONFIG.parent / 'journal/plans' / (ledger['plan']['plan_id'] + '.json'), 'plan') require(checksum == ledger['plan']['plan_hash'], 'Prepared finish plan hash mismatch') require(plan['worldEpoch'] == model['world_epoch'] and plan['region']['worldId'] == model['world_id'], 'Finish plan world identity changed') expected = {point(c['pos']): (c['expected'], c['desired']) for c in changes} actual = {point(c['pos']): (c['expected'], c['desired']) for c in plan['changes']} require(len(plan['changes']) == 18 and actual == expected, 'Prepared expected/desired states differ from reviewed original states; do not apply') def operation_id(ledger): matches = [] for path in (CONFIG.parent / 'journal/operations').glob('*.json'): operation, _ = envelope(path, 'operation') if operation['planId'] == ledger['plan']['plan_id'] and operation['idempotencyKey'] == ledger['idempotency_key']: matches.append(operation['id']) require(len(matches) <= 1, 'Multiple operations share the finish identity') return matches[0] if matches else None def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('action', choices=['plan', 'apply', 'verify']) args = parser.parse_args() model, originals, plans = references() changes = targets(model, originals) backend = Backend() context = backend.rpc('project_context', {}) require(context['world_id'] == model['world_id'] and context['world_epoch'] == model['world_epoch'], 'Live world identity changed') if args.action == 'plan' and not LEDGER.exists(): inspect_patch(backend, changes, 'expected') recipe = {'version': 1, 'operations': [ {'type': 'box', 'min': c['pos'], 'max': c['pos'], 'block': c['desired']} for c in changes]} summary = backend.rpc('build_prepare', {'recipe': recipe}) ledger = {'version': 1, 'manifest_sha256': digest(model), 'scope': backend.scope, 'source_plans': {k: {'plan_id': p['plan_id'], 'sha256': p['sha256']} for k, p in plans.items()}, 'changes': changes, 'plan': summary, 'idempotency_key': 'gothic-hall-finish-' + summary['plan_id']} validate_saved_finish(ledger, changes, model, plans) save(LEDGER, ledger, immutable=True) require(LEDGER.is_file(), 'Run plan first; finish-ledger.json must be durably saved before applying') ledger = json.loads(LEDGER.read_text()) validate_saved_finish(ledger, changes, model, plans) require(ledger['scope'] == backend.scope, 'Finish owner/project scope changed') if args.action == 'plan': print(json.dumps({'status': 'prepared', 'changes': 18, 'plan_id': ledger['plan']['plan_id'], 'expires_at': ledger['plan']['expires_at'], 'immutable_ledger': str(LEDGER)})) return ident = operation_id(ledger) if args.action == 'apply': # Always keep the persisted key. A lost response must never cause a fresh operation. if ident is None: result = backend.rpc('build_apply', {**ledger['plan'], 'idempotency_key': ledger['idempotency_key']}) ident = result['operation_id'] for _ in range(600): status = backend.rpc('operation_status', {'operation_id': ident}) if status['status'] not in ('queued', 'applying'): break time.sleep(.1) require(status['status'] == 'applied', f'Finish operation stopped: {status["status"]}; inspect before continuing') inspect_patch(backend, changes, 'desired') print(json.dumps({'status': 'applied', 'changes': 18, 'written': status['written'], 'operation_id': ident})) return require(ident is not None, 'Finish operation has not been applied') status = backend.rpc('operation_status', {'operation_id': ident}) require(status['status'] == 'applied', 'Finish operation is not fully applied') final = dict(originals) final.update({point(c['pos']): c['desired'] for c in changes}) for batch in model['batches']: result = backend.rpc('region_inspect', {'min': batch['min'], 'max': batch['max'], 'detail': 'blocks'}) live = {point(b['pos']): b['state'] for b in result['blocks']} for change in plans[batch['key']]['plan']['changes']: at = point(change['pos']) require(live.get(at) == final[at], f'Final build differs at world {at}; preserve current world') report = {'status': 'verified', 'blocks': len(final), 'batches': len(model['batches']), 'finish_changes': 18, 'operation_id': ident, 'manifest_sha256': digest(model), 'finish_ledger_sha256': digest(ledger), 'verified_at': time.time(), 'scope': backend.scope} save(OUTPUT / 'finish-verification.json', report) print(json.dumps(report)) if __name__ == '__main__': try: main() except (OSError, ValueError, KeyError, RuntimeError) as error: raise SystemExit('Finish stopped: ' + str(error)) from None