255 lines
13 KiB
Python
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
|