279 lines
14 KiB
Python
279 lines
14 KiB
Python
#!/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
|