Files

255 lines
13 KiB
Python

#!/usr/bin/env python3
"""Plan, apply, or verify the Gothic hall polish against its saved canonical baseline."""
import argparse
from collections import defaultdict
import fcntl
import importlib.util
import json
from pathlib import Path
import time
from builds.gothic_hall.scene import boxes
ROOT = Path(__file__).resolve().parents[1]
OUTPUT = ROOT / '.runtime/gothic-hall/polish'
MANIFEST = OUTPUT / 'manifest.json'
LEDGER = OUTPUT / 'ledger.json'
AIR = 'minecraft:air'
spec = importlib.util.spec_from_file_location('gothic_finish', ROOT / 'scripts/finish-gothic-hall.py')
finish = importlib.util.module_from_spec(spec)
spec.loader.exec_module(finish)
require, digest, point, position = finish.require, finish.digest, finish.point, finish.position
class Backend(finish.Backend):
def rpc(self, method, params):
try:
return super().rpc(method, params)
except RuntimeError as error:
raise RuntimeError(str(error).replace('finish ledger', 'polish ledger')) from None
def matches(actual, requested):
"""Match explicit requested properties while accepting registry defaults."""
def split(state):
name, _, suffix = state.partition('[')
properties = dict(item.split('=', 1) for item in suffix.rstrip(']').split(',') if item)
return name, properties
actual_name, actual_properties = split(actual)
requested_name, requested_properties = split(requested)
return actual_name == requested_name and all(
actual_properties.get(key) == value for key, value in requested_properties.items())
def baseline():
model, originals, plans = finish.references()
patch = json.loads(finish.LEDGER.read_text())
finish.validate_saved_finish(patch, finish.targets(model, originals), model, plans)
states = dict(originals)
states.update({point(c['pos']): c['desired'] for c in patch['changes']})
return model, states, plans, patch
def bounds(positions):
lo = tuple(min(p[i] for p in positions) for i in range(3))
hi = tuple(max(p[i] for p in positions) for i in range(3))
require((hi[0] - lo[0] + 1) * (hi[1] - lo[1] + 1) * (hi[2] - lo[2] + 1) <= 2048,
'Inspection volume exceeds the 16x8x16 tile budget')
return {'min': position(lo), 'max': position(hi)}
def manifest(model, states, plans, patch):
from builds.gothic_hall.polish_scene import finished_scene, build_scene
before, after = finished_scene().blocks, build_scene().blocks
origin = model['origin']
def world(local):
return tuple(a + b for a, b in zip(local, origin))
require({world(p) for p in before} == set(states), 'Finished scene baseline mask changed')
require(all(matches(states[world(p)], value) for p, value in before.items()),
'Finished scene differs from the original plans plus the 18 finish states')
changes, tiles = {}, defaultdict(dict)
for local in sorted(before.keys() | after.keys()):
old, desired = before.get(local, AIR), after.get(local, AIR)
if finish.canonical(old) == finish.canonical(desired):
continue
require(-2 <= local[0] <= 68 and -1 <= local[1] <= 64 and -2 <= local[2] <= 68,
'Polish extends beyond the agreed local bounds')
at = world(local)
expected = states.get(at, AIR)
# Different spellings that only make existing default properties explicit
# have no world effect and are omitted before requesting a server plan.
if finish.canonical(expected) == finish.canonical(desired):
continue
changes[local] = {'pos': position(at), 'expected': expected, 'desired': desired}
tiles[(local[0] // 16, local[1] // 8, local[2] // 16)][local] = desired
batches = []
for tile, cells in sorted(tiles.items()):
compressed = boxes(cells)
for start in range(0, len(compressed), 256):
operations, selected = [], []
for x1, y1, z1, x2, y2, z2, block in compressed[start:start + 256]:
operations.append({'type': 'box', 'min': position(world((x1, y1, z1))),
'max': position(world((x2, y2, z2))), 'block': block})
selected.extend(changes[(x, y, z)]
for x in range(x1, x2 + 1) for y in range(y1, y2 + 1)
for z in range(z1, z2 + 1))
selected.sort(key=lambda c: point(c['pos']))
require(len(selected) == len({point(c['pos']) for c in selected}) <= 2048,
'Compressed batch contains duplicates or exceeds the block budget')
batches.append({'key': '_'.join(map(str, (*tile, start // 256))),
**bounds([point(c['pos']) for c in selected]), 'changes': selected,
'recipe': {'version': 1, 'operations': operations}})
require(sum(len(b['changes']) for b in batches) == len(changes), 'Incomplete polish batch mask')
return {'version': 1, 'name': 'Gothic hall polish', 'origin': origin,
'world_id': model['world_id'], 'world_epoch': model['world_epoch'],
'baseline_manifest_sha256': digest(model), 'finish_ledger_sha256': digest(patch),
'source_plans': {key: {'plan_id': value['plan_id'], 'sha256': value['sha256']}
for key, value in plans.items()},
'before_blocks': len(before), 'after_blocks': len(after), 'changes': len(changes),
'added': sum(p not in before for p in changes),
'removed': sum(p not in after for p in changes), 'batches': batches}
def prepared(record, batch, model, scope):
plan, checksum = finish.envelope(
finish.CONFIG.parent / 'journal/plans' / (record['plan']['plan_id'] + '.json'), 'plan')
require(checksum == record['plan']['plan_hash'], 'Prepared polish plan hash changed')
require(plan['worldEpoch'] == model['world_epoch'] and plan['region']['worldId'] == model['world_id'],
'Prepared polish world identity changed')
require(plan['projectId'] == scope['project_id'], 'Prepared polish project changed')
expected = {point(c['pos']): c for c in batch['changes']}
actual = {point(c['pos']): c for c in plan['changes']}
require(len(plan['changes']) == len(actual) == len(expected) and actual.keys() == expected.keys(),
'Prepared polish mask differs from the reviewed diff')
for at, change in actual.items():
require(change['expected'] == expected[at]['expected'],
f'Prepared expected state changed at world {at}; preserve current world')
require(matches(change['desired'], expected[at]['desired']),
f'Prepared desired properties differ at world {at}')
require(record['idempotency_key'] == 'gothic-hall-polish-' + plan['id'],
'Polish idempotency identity changed')
return {at: c['desired'] for at, c in actual.items()}
def inspect(backend, region, states, planter_soil=frozenset()):
result = backend.rpc('region_inspect', {**region, 'detail': 'blocks'})
live = {point(block['pos']): block['state'] for block in result['blocks']}
variations = []
for at, expected in states.items():
# Verification may observe grass spreading over the newly planted soil.
# Preparation and application pass no such allowance and remain exact.
if at in planter_soil and expected == 'minecraft:dirt' and live.get(at) == 'minecraft:grass_block[snowy=false]':
variations.append({'pos': position(at), 'expected': expected, 'observed': live[at]})
continue
require(live.get(at) == expected,
f'Live state differs at world {at}; preserve current world and inspect before continuing')
return variations
def completed(backend, record, apply=False):
ident = finish.operation_id(record)
if ident is None and apply:
# The record and key have already been fsynced. A transport exception
# escapes immediately; a later invocation first inspects the journal.
result = backend.rpc('build_apply', {**record['plan'], 'idempotency_key': record['idempotency_key']})
ident = result['operation_id']
require(ident is not None, 'A required operation has not been applied')
for _ in range(600 if apply else 1):
status = backend.rpc('operation_status', {'operation_id': ident})
if status['status'] not in ('queued', 'applying'):
break
if apply:
time.sleep(.1)
require(status['status'] == 'applied',
f'Operation {ident} stopped at {status["status"]}; inspect before continuing')
return ident
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('action', choices=['plan', 'apply', 'verify'])
args = parser.parse_args()
model, states, source_plans, patch = baseline()
wanted = manifest(model, states, source_plans, patch)
if MANIFEST.exists():
require(digest(json.loads(MANIFEST.read_text())) == digest(wanted),
'Immutable polish manifest differs from the current generator or baseline')
else:
require(args.action == 'plan', 'Run plan before apply or verify')
finish.save(MANIFEST, wanted, immutable=True)
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')
require(patch['scope'] == backend.scope, 'Finished baseline owner/project scope changed')
completed(backend, patch)
if LEDGER.exists():
ledger = json.loads(LEDGER.read_text())
else:
require(args.action == 'plan', 'The polish ledger is missing; run plan first')
ledger = {'version': 1, 'manifest_sha256': digest(wanted), 'scope': backend.scope, 'batches': {}}
finish.save(LEDGER, ledger, immutable=True)
require(ledger['version'] == 1 and ledger['manifest_sha256'] == digest(wanted)
and ledger['scope'] == backend.scope, 'Polish ledger identity changed')
require(set(ledger['batches']) <= {b['key'] for b in wanted['batches']}, 'Unknown polish ledger batch')
canonical_targets = {}
for batch in wanted['batches']:
key = batch['key']
if key not in ledger['batches']:
require(args.action == 'plan', 'Some polish batches are not prepared; run plan first')
inspect(backend, {'min': batch['min'], 'max': batch['max']},
{point(c['pos']): c['expected'] for c in batch['changes']})
summary = backend.rpc('build_prepare', {'recipe': batch['recipe']})
record = {'plan': summary, 'idempotency_key': 'gothic-hall-polish-' + summary['plan_id']}
prepared(record, batch, wanted, backend.scope)
# Existing records are never replaced. Atomic publication follows
# server-plan validation and always precedes any build_apply call.
ledger['batches'][key] = record
finish.save(LEDGER, ledger)
canonical_targets.update(prepared(ledger['batches'][key], batch, wanted, backend.scope))
if args.action == 'plan':
print(json.dumps({'status': 'prepared', 'changes': wanted['changes'],
'batches': len(wanted['batches']), 'ledger': str(LEDGER)}))
return
operations = []
for index, batch in enumerate(wanted['batches'], 1):
ident = completed(backend, ledger['batches'][batch['key']], apply=args.action == 'apply')
operations.append(ident)
if args.action == 'apply':
inspect(backend, {'min': batch['min'], 'max': batch['max']},
{point(c['pos']): canonical_targets[point(c['pos'])] for c in batch['changes']})
print(json.dumps({'status': 'applied', 'batch': index, 'batches': len(wanted['batches']),
'operation_id': ident}), flush=True)
if args.action == 'apply':
return
final, tiles = {**states, **canonical_targets}, defaultdict(dict)
for at, value in final.items():
local = tuple(a - b for a, b in zip(at, wanted['origin']))
tiles[(local[0] // 16, local[1] // 8, local[2] // 16)][at] = value
planter_soil = frozenset(at for at, state in canonical_targets.items()
if state == 'minecraft:dirt' and at[1] == wanted['origin'][1])
soil_variations = []
for _, group in sorted(tiles.items()):
soil_variations.extend(inspect(backend, bounds(list(group)), group, planter_soil))
report = {'status': 'verified', 'checked_positions': len(final),
'blocks': sum(value != AIR for value in final.values()),
'checked_air': sum(value == AIR for value in final.values()),
'changes': wanted['changes'], 'inspection_tiles': len(tiles),
'accepted_planter_grass': soil_variations,
'manifest_sha256': digest(wanted), 'ledger_sha256': digest(ledger),
'operations': operations, 'scope': backend.scope, 'verified_at': time.time()}
finish.save(OUTPUT / 'verification.json', report)
print(json.dumps(report))
if __name__ == '__main__':
try:
OUTPUT.mkdir(parents=True, exist_ok=True)
with (OUTPUT / '.lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
main()
except (OSError, ValueError, KeyError, RuntimeError) as error:
raise SystemExit('Polish stopped: ' + str(error)) from None