Separate map projects and add reusable island and operator camera tools
This commit is contained in:
+3
-2
@@ -8,6 +8,7 @@ import re
|
||||
import subprocess
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
RUNTIME = Path(os.environ.get('MCB_RUNTIME_DIR') or ROOT / '.runtime').expanduser().resolve()
|
||||
|
||||
|
||||
def scalar(text, key):
|
||||
@@ -37,7 +38,7 @@ def main():
|
||||
parser.add_argument('mode', choices=['doctor', 'chat', 'mcp', 'login', 'status'])
|
||||
parser.add_argument('action', nargs='?', choices=['status'], help='Only for login: check authentication without starting a login flow')
|
||||
parser.add_argument('--status', action='store_true', help='With login: check authentication without starting login')
|
||||
parser.add_argument('--config', type=Path, default=ROOT / '.runtime/server/plugins/MinecraftBuilderMCP/config.yml')
|
||||
parser.add_argument('--config', type=Path, default=RUNTIME / 'server/plugins/MinecraftBuilderMCP/config.yml')
|
||||
parser.add_argument('--console', action='store_true', help='Use console scope only with allow-local-automation in the disposable fixture')
|
||||
args = parser.parse_args()
|
||||
if (args.action or args.status) and args.mode != 'login':
|
||||
@@ -46,7 +47,7 @@ def main():
|
||||
# Login/authentication status needs no Paper config or Paper token. Always use the same
|
||||
# default state directory as the chat daemon, including when called from another cwd.
|
||||
env = dict(os.environ)
|
||||
env.setdefault('MCB_STATE_DIR', str(ROOT / '.runtime/bridge-state'))
|
||||
env.setdefault('MCB_STATE_DIR', str(RUNTIME / 'bridge-state'))
|
||||
needs_backend = args.mode in ('chat', 'mcp')
|
||||
if needs_backend and not args.config.is_file():
|
||||
raise SystemExit('Start the Paper plugin once to generate its private config.')
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
#!/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()
|
||||
@@ -1,171 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compile a continuous, checked arrival-square balustrade; never edit the world.
|
||||
|
||||
The existing polygon is followed exactly. Cardinal elbows close diagonal gaps;
|
||||
garden-side elbows move outward and receive observed, grounded stone footings.
|
||||
Every road clear cell, planted block and existing light remains protected.
|
||||
"""
|
||||
import argparse
|
||||
from collections import Counter
|
||||
import importlib.util
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
STAGE = ROOT / '.runtime/balustrade-stage04'
|
||||
AIR = 'minecraft:air'
|
||||
N = {'east': (1, 0), 'north': (0, -1), 'south': (0, 1), 'west': (-1, 0)}
|
||||
|
||||
|
||||
def module(name, path):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
value = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(value)
|
||||
return value
|
||||
|
||||
|
||||
survey = module('balustrade_survey', ROOT / 'scripts/foundation-survey.py')
|
||||
geometry = module('balustrade_geometry', ROOT / 'scripts/foundation-study/geometry.py')
|
||||
|
||||
|
||||
def compile_plan(before):
|
||||
design = json.loads((ROOT / '.runtime/plaza-stage03/design-final.json').read_text())
|
||||
geo = json.loads((ROOT / '.runtime/foundations-stage02/foundations-final.geometry.json').read_text())
|
||||
cells = {(c['x'], c['z']): c for c in geo['cells']}
|
||||
roads = {tuple(p) for r in geo['routes'] for p in r['clear_cells']}
|
||||
outer = geometry.polygon_cells(design['outer_hex'])
|
||||
edge = {p for p in outer if any((p[0]+dx, p[1]+dz) not in outer for dx, dz in N.values())}
|
||||
gardens = set()
|
||||
for bed in design['beds']:
|
||||
gardens |= geometry.polygon_cells(bed['outline'])
|
||||
gardens |= {tuple(p) for p in bed.get('additional_planting_columns', [])}
|
||||
ordered = sorted(edge, key=lambda p: math.atan2(p[1]-9, p[0]))
|
||||
chain, elbows = [], []
|
||||
for a, b in zip(ordered, ordered[1:]+ordered[:1]):
|
||||
chain.append(a)
|
||||
dx, dz = b[0]-a[0], b[1]-a[1]
|
||||
if max(abs(dx), abs(dz)) > 1:
|
||||
raise ValueError('Boundary order contains a nonadjacent jump')
|
||||
if dx and dz:
|
||||
candidates = [(a[0], b[1]), (b[0], a[1])]
|
||||
def score(p):
|
||||
state = before.state(p[0], 96, p[1])
|
||||
return (p in edge or p in chain, p in gardens or state != AIR, p not in outer, p)
|
||||
# An opening remains open even when its diagonal connector lies in it.
|
||||
p = min(candidates, key=score)
|
||||
chain.append(p)
|
||||
elbows.append(p)
|
||||
if len(set(chain)) != len(chain):
|
||||
raise ValueError('Cardinal perimeter is not a simple cycle')
|
||||
selected = {p for p in chain if p not in roads}
|
||||
for p in selected:
|
||||
state = before.state(p[0], 96, p[1])
|
||||
if state != AIR and not ('stone_brick_wall[' in state or
|
||||
(p in edge and state == 'minecraft:smooth_sandstone_slab[type=bottom,waterlogged=false]')):
|
||||
raise ValueError(f'Protected decoration intersects fence at {p}: {state}')
|
||||
if before.state(p[0], 97, p[1]) != AIR:
|
||||
raise ValueError(f'Protected decoration intersects handrail at {p}')
|
||||
# Rotate at an opening, then split into uninterrupted fence runs.
|
||||
cut = next(i for i, p in enumerate(chain) if p not in selected)
|
||||
linear = chain[cut:]+chain[:cut]
|
||||
runs, run = [], []
|
||||
for p in linear:
|
||||
if p in selected:
|
||||
run.append(p)
|
||||
elif run:
|
||||
runs.append(run)
|
||||
run = []
|
||||
if run:
|
||||
runs.append(run)
|
||||
piers = set()
|
||||
for run in runs:
|
||||
segments = max(1, round((len(run)-1)/8))
|
||||
piers |= {run[round(i*(len(run)-1)/segments)] for i in range(segments+1)}
|
||||
piers |= {tuple(p) for p in design['outer_hex'] if tuple(p) in run}
|
||||
desired, groups, footing_columns, replaced_rims = {}, {}, [], []
|
||||
def put(x, y, z, state, group):
|
||||
if (x, z) in roads:
|
||||
raise ValueError(f'Protected road cell at {(x, z)}')
|
||||
before.state(x, y, z)
|
||||
desired[x, y, z] = 'minecraft:'+state
|
||||
groups[x, y, z] = group
|
||||
for x, z in sorted(selected):
|
||||
if (x, z) not in outer:
|
||||
# Stop at an observed full support; never assume air or bury plants.
|
||||
y = 95
|
||||
while before.state(x, y, z) == AIR:
|
||||
y -= 1
|
||||
support = before.state(x, y, z).split('[')[0].removeprefix('minecraft:')
|
||||
if support not in survey.FULL or y > 95:
|
||||
raise ValueError(f'No simple observed footing at {(x, y, z)}')
|
||||
if y < 95:
|
||||
footing_columns.append({'x': x, 'z': z, 'support_y': y})
|
||||
if support == 'grass_block':
|
||||
put(x, y, z, 'dirt', 'stable-buried-footing-soil')
|
||||
for yy in range(y+1, 96):
|
||||
put(x, yy, z, 'cut_sandstone' if yy == 95 else 'stone_bricks', 'grounded-elbow-footing')
|
||||
if before.state(x, 96, z).startswith('minecraft:smooth_sandstone_slab'):
|
||||
replaced_rims.append([x, z])
|
||||
if (x, z) in piers:
|
||||
state = 'cut_sandstone'
|
||||
else:
|
||||
connected = {}
|
||||
for name, (dx, dz) in N.items():
|
||||
neighbor = before.state(x+dx, 96, z+dz).split('[')[0].removeprefix('minecraft:')
|
||||
connected[name] = (x+dx, z+dz) in selected or neighbor.endswith('_wall') or neighbor in survey.FULL
|
||||
straight = (connected['east'] and connected['west'] and not connected['north'] and not connected['south']) or (connected['north'] and connected['south'] and not connected['east'] and not connected['west'])
|
||||
props = {name: 'tall' if value else 'none' for name, value in connected.items()}
|
||||
props |= {'up': 'false' if straight else 'true', 'waterlogged': 'false'}
|
||||
state = 'stone_brick_wall['+','.join(f'{k}={v}' for k, v in sorted(props.items()))+']'
|
||||
put(x, 96, z, state, 'sandstone-piers' if (x, z) in piers else 'connected-stone-balusters')
|
||||
put(x, 97, z, 'smooth_sandstone_slab[type=bottom,waterlogged=false]', 'continuous-cream-handrail')
|
||||
# Old uncapped road rails need reciprocal arms where the new fence joins them.
|
||||
adjacent = {(x+dx, z+dz) for x, z in selected for dx, dz in N.values()} - selected
|
||||
neighboring_rails = {p for p in adjacent if before.state(p[0], 96, p[1]).startswith('minecraft:stone_brick_wall[')}
|
||||
for x, z in sorted(neighboring_rails):
|
||||
if before.state(x, 97, z) != AIR:
|
||||
raise ValueError('Neighboring rail has an unsupported cap configuration')
|
||||
links = {}
|
||||
for name, (dx, dz) in N.items():
|
||||
p = x+dx, 96, z+dz
|
||||
neighbor = desired.get(p, before.state(*p)).split('[')[0].removeprefix('minecraft:')
|
||||
links[name] = neighbor.endswith('_wall') or neighbor in survey.FULL
|
||||
straight = (links['east'] and links['west'] and not links['north'] and not links['south']) or (links['north'] and links['south'] and not links['east'] and not links['west'])
|
||||
props = {name: 'low' if linked else 'none' for name, linked in links.items()}
|
||||
props |= {'up': 'false' if straight else 'true', 'waterlogged': 'false'}
|
||||
put(x, 96, z, 'stone_brick_wall['+','.join(f'{k}={v}' for k, v in sorted(props.items()))+']', 'reciprocal-road-rail-joins')
|
||||
blocks = [dict(zip(('x', 'y', 'z'), p)) | {'block': state, 'expected': before.state(*p), 'group': groups[p]}
|
||||
for p, state in sorted(desired.items()) if state != before.state(*p)]
|
||||
meta = {'version': 1, 'scope': before.scope, 'floor_y': 95, 'handrail_top_y': 97.5,
|
||||
'fence_columns': sorted(selected), 'pier_columns': sorted(piers), 'path_runs': runs,
|
||||
'elbow_columns': sorted(set(elbows) & selected), 'footing_columns': footing_columns,
|
||||
'neighboring_rail_columns': sorted(neighboring_rails),
|
||||
'replaced_planter_rims': replaced_rims, 'protected_road_columns': sorted(roads),
|
||||
'by_group': dict(Counter(b['group'] for b in blocks)), 'changed_blocks': len(blocks)}
|
||||
# Reuse the independent garden and inherited-route auditor with the new obstacle mask.
|
||||
garden_meta = json.loads((ROOT / '.runtime/plaza-stage03/plaza-polished.metadata.json').read_text())
|
||||
garden_meta['unwalkable_columns'] = sorted({tuple(p) for p in garden_meta['unwalkable_columns']} | selected)
|
||||
walk = json.loads((ROOT / '.runtime/plaza-stage03/plaza-polished.walk.json').read_text())
|
||||
walk['points'] = [p for p in walk['points'] if (p['x'], p['z']) not in selected]
|
||||
garden_meta['walk_samples'] = len(walk['points'])
|
||||
return {'version': 1, 'scope': before.scope, 'blocks': blocks}, meta, garden_meta, walk
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--before', type=Path, default=STAGE / 'before-full.json.gz')
|
||||
parser.add_argument('--output', type=Path, default=STAGE / 'balustrade.json')
|
||||
args = parser.parse_args()
|
||||
before = survey.load_snapshot(args.before)
|
||||
plan, meta, garden_meta, walk = compile_plan(before)
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
for suffix, document in (('.json', plan), ('.metadata.json', meta), ('.garden-metadata.json', garden_meta), ('.walk.json', walk)):
|
||||
args.output.with_suffix(suffix).write_text(json.dumps(document, separators=(',', ':'))+'\n')
|
||||
print(json.dumps({k: meta[k] for k in ('changed_blocks', 'by_group', 'replaced_planter_rims')}
|
||||
| {'fence_columns': len(meta['fence_columns']), 'piers': len(meta['pier_columns']),
|
||||
'runs': [len(r) for r in meta['path_runs']], 'footings': len(meta['footing_columns'])}))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,226 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compile the reviewed foundations and streets into a checked, reversible block plan.
|
||||
|
||||
No server writes here. A full live voxel survey is required, and differences from
|
||||
the known natural world + previous marker receipts are protected, not adopted.
|
||||
Apply the resulting JSON with scripts/layout.py.
|
||||
"""
|
||||
import argparse
|
||||
from collections import Counter, deque
|
||||
import importlib.util
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
|
||||
def module(name,path):
|
||||
spec=importlib.util.spec_from_file_location(name,path)
|
||||
value=importlib.util.module_from_spec(spec);spec.loader.exec_module(value);return value
|
||||
|
||||
geometry=module('foundation_geometry',ROOT/'scripts/foundation-study/geometry.py')
|
||||
survey=module('foundation_survey',ROOT/'scripts/foundation-survey.py')
|
||||
N=((1,0),(-1,0),(0,1),(0,-1))
|
||||
|
||||
|
||||
def distances(mask):
|
||||
dist={p:0 for p in mask if any((p[0]+dx,p[1]+dz) not in mask for dx,dz in N)}
|
||||
q=deque(dist)
|
||||
while q:
|
||||
x,z=q.popleft()
|
||||
for dx,dz in N:
|
||||
p=(x+dx,z+dz)
|
||||
if p in mask and p not in dist:dist[p]=dist[(x,z)]+1;q.append(p)
|
||||
return dist
|
||||
|
||||
|
||||
def compile_plan(design,layout,before,original,marker_documents):
|
||||
scope=before.document['scope'];geo=geometry.build_geometry(design,layout);cells=geo['cells']
|
||||
mask=set(cells);edge=distances(mask);desired={};groups={}
|
||||
known={}
|
||||
for doc in marker_documents:
|
||||
for b in doc['blocks']:known[(b['x'],b['y'],b['z'])]=b['block']
|
||||
def natural(x,z):
|
||||
i=(z-original['min_z'])*original['width']+x-original['min_x']
|
||||
return original['surface_y'][i],original['palette'][original['material_index'][i]]
|
||||
def predicted(x,y,z):
|
||||
if (x,y,z) in known:return known[(x,y,z)]
|
||||
h,material=natural(x,z)
|
||||
if y>h:return 'minecraft:air'
|
||||
if material=='minecraft:water':raise ValueError('Water column outside foundation scope')
|
||||
if y==h:return material+'[snowy=false]' if material=='minecraft:grass_block' else material
|
||||
return 'minecraft:dirt' if material=='minecraft:grass_block' and y>=h-3 else 'minecraft:stone'
|
||||
def put(x,y,z,block,group):
|
||||
if not block.startswith('minecraft:'):block='minecraft:'+block
|
||||
desired[(x,y,z)]=block;groups[(x,y,z)]=group
|
||||
# Remove only old survey blocks within this stage's exact footprint + 1-column
|
||||
# cleanup margin. Outside district markers remain unchanged.
|
||||
cleanup=mask|{(x+dx,z+dz) for x,z in mask for dx,dz in N}
|
||||
for (x,y,z),state in known.items():
|
||||
if (x,z) not in cleanup:continue
|
||||
try:before.state(x,y,z)
|
||||
except (KeyError,ValueError):continue # Only the optional cleanup margin may extend beyond the survey.
|
||||
h,m=natural(x,z)
|
||||
if y>h:new='minecraft:air'
|
||||
elif y==h:new=m+'[snowy=false]' if m=='minecraft:grass_block' else m
|
||||
else:continue
|
||||
put(x,y,z,new,'remove-obsolete-survey')
|
||||
|
||||
road_clear={tuple(p) for r in geo['routes'] for p in r['clear_cells']}
|
||||
road_buffer=road_clear|{(x+dx,z+dz) for x,z in road_clear for dx,dz in N}
|
||||
# Exact solid footings and two structural courses beneath every walking deck.
|
||||
for (x,z),c in cells.items():
|
||||
y=c['block_y'];h,_=natural(x,z);d=edge[(x,z)];group=c['group']
|
||||
bottom=min(h-1,y-2) if d<=1 else min(h,y-2)
|
||||
for yy in range(bottom,y):
|
||||
block='stone'
|
||||
if d==0:
|
||||
block='deepslate_bricks' if yy<=h+1 else 'stone_bricks'
|
||||
if yy==y-1:block='polished_andesite'
|
||||
if (x+z)%12 in (0,1) and yy>h+1:block='smooth_sandstone'
|
||||
elif d==1 and yy==y-1:block='stone_bricks'
|
||||
put(x,yy,z,block,group+'-foundation')
|
||||
# Clear all natural overburden and former stakes above the finished floor.
|
||||
for yy in range(y+1,max(h+2,y+4)+1):put(x,yy,z,'air',group+'-clearance')
|
||||
if c['kind']=='stairs':
|
||||
paving=f"stone_brick_stairs[facing={c['facing']},half=bottom,shape=straight,waterlogged=false]"
|
||||
elif not c['clear'] or d==0:paving='polished_andesite'
|
||||
elif d==1:paving='stone_bricks'
|
||||
elif d==2:paving='smooth_sandstone'
|
||||
elif group=='clock-station':
|
||||
# Quiet structural floor; regular narrow foundation setting-out bands.
|
||||
paving='stone_bricks' if x%16==0 or z%16==0 else 'smooth_stone'
|
||||
elif group in ('arrival-hex','station-forecourt'):
|
||||
paving='smooth_sandstone'
|
||||
if x%12==0 or z%12==0:paving='smooth_stone'
|
||||
else:
|
||||
paving='smooth_stone'
|
||||
if c['kind']=='full' and ((x if 'radial' in group or 'ring-' in group else z)%8==0):paving='stone_bricks'
|
||||
put(x,y,z,paving,group+'-paving')
|
||||
|
||||
# Green inset medallion. All tesserae are flush with the arrival paving.
|
||||
hexagon=[(round(16*math.sin(math.pi/3*i)),round(9-16*math.cos(math.pi/3*i))) for i in range(6)]
|
||||
medallion=geometry.polygon_cells(hexagon);md=distances(medallion)
|
||||
for x,z in medallion:
|
||||
put(x,95,z,'smooth_quartz' if md[(x,z)]<=1 else 'green_concrete','arrival-medallion')
|
||||
glyph=['01110','11000','11000','01110','00011','00011','01110']
|
||||
for row,bits in enumerate(glyph):
|
||||
for col,on in enumerate(bits):
|
||||
if on=='1':
|
||||
for dx in (0,1):
|
||||
for dz in (0,1):put(-5+col*2+dx,95,2+row*2+dz,'smooth_quartz','arrival-medallion')
|
||||
|
||||
# Keep the future tower and pavilion footing outlines readable in the deck.
|
||||
for f in layout['features']:
|
||||
if f['id'] not in ('station-clock-base','station-pavilion--63','station-pavilion-29'):continue
|
||||
vertices=f['points'];outline=geometry.polygon_cells(vertices)
|
||||
for x,z in outline:
|
||||
if any((x+dx,z+dz) not in outline for dx,dz in N):put(x,98,z,'polished_andesite','station-structural-bands')
|
||||
|
||||
# A shallow blind arcade breaks up the tall western station retaining wall.
|
||||
# Each opening is one block deep, with an intact solid backing and lintel.
|
||||
for center in (-132,-124,-116,-108):
|
||||
for offset in range(-2,3):
|
||||
z=center+offset;h,_=natural(-74,z);top=94-abs(offset)
|
||||
for y in range(max(h+2,85),top+1):
|
||||
put(-74,y,z,'air','station-west-blind-arcade')
|
||||
put(-73,y,z,'deepslate_bricks','station-west-blind-arcade')
|
||||
if top>=h+2:put(-74,top+1,z,'smooth_sandstone','station-west-arch-stones')
|
||||
for z0 in (-138,-128,-120,-112,-104,-99):
|
||||
for z in (z0,z0+1):
|
||||
h,_=natural(-75,z)
|
||||
for y in range(h-1,98):put(-75,y,z,'deepslate_bricks' if y<h+2 else 'stone_bricks','station-west-pilasters')
|
||||
put(-75,98,z,'smooth_sandstone','station-west-pilasters')
|
||||
|
||||
# Guard exposed edges while reserving every planned road opening at full width.
|
||||
rail={}
|
||||
for (x,z),c in cells.items():
|
||||
if edge[(x,z)]!=0:continue
|
||||
if c['clear'] and (x,z) in road_buffer:continue
|
||||
outside=[(x+dx,z+dz) for dx,dz in N if (x+dx,z+dz) not in cells]
|
||||
drop=max([c['block_y']-natural(*p)[0] for p in outside] or [0])
|
||||
if drop>=3 or c['group']=='station-entrance-stair':rail[(x,z)]=c['block_y']+1
|
||||
for (x,z),y in rail.items():
|
||||
props={name:('low' if (x+dx,z+dz) in rail and abs(rail[(x+dx,z+dz)]-y)<=1 else 'none')
|
||||
for name,(dx,dz) in {'east':(1,0),'north':(0,-1),'south':(0,1),'west':(-1,0)}.items()}
|
||||
straight=(props['east']==props['west']=='low' and props['north']==props['south']=='none') or (props['north']==props['south']=='low' and props['east']==props['west']=='none')
|
||||
up='false' if straight and (x+z)%8 else 'true'
|
||||
state=f"stone_brick_wall[east={props['east']},north={props['north']},south={props['south']},up={up},waterlogged=false,west={props['west']}]"
|
||||
put(x,y,z,state,'edge-balustrades')
|
||||
|
||||
# Roads meet the reserved bridge decks exactly. A temporary end balustrade
|
||||
# prevents a finished street from leading straight into an unbuilt span.
|
||||
bridge_gates=[]
|
||||
for ident in ('east-radial-local','ring-northeast-local'):
|
||||
r=next(r for r in geo['routes'] if r['id']==ident)
|
||||
end_x,end_z=r['centerline'][-1];deck=cells[(end_x,end_z)]['block_y']
|
||||
zs=sorted(z for x,z in r['corridor'] if x==end_x)
|
||||
for z in zs:
|
||||
x=end_x+1;h,_=natural(x,z)
|
||||
for y in range(min(h,deck-1),deck+1):put(x,y,z,'stone_bricks','temporary-bridge-threshold')
|
||||
north='low' if z-1 in zs else 'none';south='low' if z+1 in zs else 'none'
|
||||
put(x,deck+1,z,f'stone_brick_wall[east=none,north={north},south={south},up=true,waterlogged=false,west=none]','temporary-bridge-gates')
|
||||
bridge_gates.append({'x':end_x+1.5,'y':deck+4,'z':end_z+.5,'deck_y':deck})
|
||||
|
||||
# Lit piers are part of the stone edge, never obstacles in the clear road lane.
|
||||
candidates=[p for p in rail if cells[p]['kind']=='full']
|
||||
chosen=[]
|
||||
landmarks=[(0,-37),(38,-14),(42,30),(0,56),(-42,30),(-38,-14),
|
||||
(-36,-61),(27,-61),(-25,-83),(13,-83),(-74,-139),(40,-139),(-74,-98),(40,-98)]
|
||||
for target in landmarks:
|
||||
options=sorted(candidates,key=lambda p:(p[0]-target[0])**2+(p[1]-target[1])**2)
|
||||
if options and math.dist(options[0],target)<12 and all(math.dist(options[0],p)>8 for p in chosen):chosen.append(options[0])
|
||||
for point in sorted(candidates,key=lambda p:(p[1],p[0])):
|
||||
if all(math.dist(point,p)>19 for p in chosen):chosen.append(point)
|
||||
for x,z in chosen:
|
||||
y=cells[(x,z)]['block_y']
|
||||
put(x,y+1,z,'chiseled_stone_bricks','lamp-piers');put(x,y+2,z,'stone_bricks','lamp-piers')
|
||||
put(x,y+3,z,'smooth_stone_slab[type=double,waterlogged=false]','lamp-piers')
|
||||
put(x,y+4,z,'lantern[hanging=false,waterlogged=false]','lamps')
|
||||
|
||||
# Preserve any unexpected human edits, including underground blocks. We do
|
||||
# not silently rebase onto arbitrary newly observed content.
|
||||
blocks=[];mismatches=[]
|
||||
for (x,y,z),block in sorted(desired.items()):
|
||||
actual=before.state(x,y,z);expected=predicted(x,y,z)
|
||||
if actual!=expected:
|
||||
mismatches.append({'x':x,'y':y,'z':z,'expected':expected,'actual':actual})
|
||||
continue
|
||||
if block!=actual:blocks.append({'x':x,'y':y,'z':z,'block':block,'expected':actual,'group':groups[(x,y,z)]})
|
||||
if mismatches:raise ValueError(f'Unexpected live edits preserved: {len(mismatches)}, first {mismatches[:5]}')
|
||||
# Sample both treads of every stair and every usable full floor column. Rails
|
||||
# and lamp piers are excluded; actual after-survey tests their surrounding lanes.
|
||||
walk=[]
|
||||
for (x,z),c in cells.items():
|
||||
if (x,z) in rail or not c['clear']:continue
|
||||
if c['kind']=='stairs':
|
||||
for sub in (.25,.75):
|
||||
sx,sz=(sub,.5) if c['facing'] in ('east','west') else (.5,sub)
|
||||
walk.append({'x':x,'z':z,'standing_y':geometry.tread_height(c,sx,sz),'sub_x':sx,'sub_z':sz})
|
||||
else:walk.append({'x':x,'z':z,'standing_y':c['block_y']+1})
|
||||
metadata={'scope':scope,'geometry_checks':geo['checks'],'changed_blocks':len(blocks),'walk_samples':len(walk),
|
||||
'lamps':len(chosen),'rail_columns':len(rail),'by_material':dict(Counter(b['block'] for b in blocks)),
|
||||
'temporary_bridge_gates':bridge_gates,
|
||||
'source_snapshot_finished_at':before.document['finished_at'],'source_snapshot_atomic':False,
|
||||
'note':'Only zones01/02 foundations and local access roads; other districts remain marked reservations.'}
|
||||
return {'version':1,'scope':scope,'blocks':blocks},metadata,{'scope':scope,'points':walk},geo
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--design',type=Path,required=True);p.add_argument('--snapshot',type=Path,required=True)
|
||||
p.add_argument('--output',type=Path,required=True);a=p.parse_args()
|
||||
before=survey.load_snapshot(a.snapshot)
|
||||
design=json.loads(a.design.read_text());layout=json.loads((ROOT/'examples/layout/shacraft-lobby-layout.json').read_text())
|
||||
original=json.loads((ROOT/'.runtime/server/plugins/ShacraftTerrain/maps/layout-before.json').read_text())
|
||||
markers=[json.loads((ROOT/name).read_text()) for name in ('.runtime/layout-study/markers-final.json','.runtime/layout-study/access-blocks.json')]
|
||||
plan,meta,walk,geo=compile_plan(design,layout,before,original,markers)
|
||||
a.output.parent.mkdir(parents=True,exist_ok=True)
|
||||
a.output.write_text(json.dumps(plan,separators=(',',':'))+'\n')
|
||||
a.output.with_suffix('.metadata.json').write_text(json.dumps(meta,indent=2)+'\n')
|
||||
a.output.with_suffix('.walk.json').write_text(json.dumps(walk,separators=(',',':'))+'\n')
|
||||
a.output.with_suffix('.geometry.json').write_text(json.dumps(geometry.serializable(geo),separators=(',',':'))+'\n')
|
||||
print(json.dumps(meta))
|
||||
|
||||
|
||||
if __name__=='__main__':main()
|
||||
@@ -1,294 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compile a reference-led arrival garden against observed, protected voxel states.
|
||||
|
||||
No world writes. Apply the resulting checked recipe with scripts/layout.py.
|
||||
The brand bitmap is sampled into block coordinates, not used as a rendered fake.
|
||||
"""
|
||||
import argparse
|
||||
from collections import Counter, deque
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
N2 = ((1, 0), (-1, 0), (0, 1), (0, -1))
|
||||
N3 = ((1, 0, 0), (-1, 0, 0), (0, 1, 0), (0, -1, 0), (0, 0, 1), (0, 0, -1))
|
||||
AIR = 'minecraft:air'
|
||||
|
||||
|
||||
def module(name, path):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
return m
|
||||
|
||||
|
||||
geometry = module('plaza_geometry', ROOT / 'scripts/foundation-study/geometry.py')
|
||||
survey = module('plaza_survey', ROOT / 'scripts/foundation-survey.py')
|
||||
assets = module('plaza_assets', ROOT / 'scripts/plaza-assets.py')
|
||||
|
||||
|
||||
def distances(mask):
|
||||
result = {p: 0 for p in mask if any((p[0]+dx, p[1]+dz) not in mask for dx, dz in N2)}
|
||||
pending = deque(result)
|
||||
while pending:
|
||||
x, z = pending.popleft()
|
||||
for dx, dz in N2:
|
||||
p = x+dx, z+dz
|
||||
if p in mask and p not in result:
|
||||
result[p] = result[x, z]+1
|
||||
pending.append(p)
|
||||
return result
|
||||
|
||||
|
||||
def brand_cells(path, width=25, height=35):
|
||||
from PIL import Image
|
||||
source = Image.open(path).convert('RGBA')
|
||||
# Select the green artwork, excluding transparency and the pale antialias fringe.
|
||||
mask = Image.new('L', source.size)
|
||||
pixels = source.get_flattened_data() if hasattr(source, 'get_flattened_data') else source.getdata()
|
||||
mask.putdata([255 if a > 100 and g > r*1.15 and g > b*1.15 else 0 for r, g, b, a in pixels])
|
||||
box = mask.getbbox()
|
||||
if not box:
|
||||
raise ValueError('Brand image contains no green artwork')
|
||||
cells = mask.crop(box).resize((width, height), Image.Resampling.BOX)
|
||||
return {(x-width//2, z+9-height//2) for z in range(height) for x in range(width)
|
||||
if cells.getpixel((x, z)) >= 115}
|
||||
|
||||
|
||||
def compile_plan(design, before, previous, logo):
|
||||
base_geometry = json.loads((ROOT / '.runtime/foundations-stage02/foundations-final.geometry.json').read_text())
|
||||
cells = {(c['x'], c['z']): c for c in base_geometry['cells']}
|
||||
outer = geometry.polygon_cells(design['outer_hex'])
|
||||
floor = {p for p in outer if cells[p]['kind'] == 'full' and cells[p]['block_y'] == 95}
|
||||
edge = distances(outer)
|
||||
protected = {tuple(p) for route in base_geometry['routes'] for p in route['clear_cells']}
|
||||
desired, groups = {}, {}
|
||||
|
||||
def put(x, y, z, state, group):
|
||||
if not state.startswith('minecraft:'):
|
||||
state = 'minecraft:'+state
|
||||
before.state(x, y, z) # Require observed support and air, including canopy overhangs.
|
||||
desired[x, y, z], groups[x, y, z] = state, group
|
||||
|
||||
def current(x, y, z):
|
||||
return desired.get((x, y, z), before.state(x, y, z))
|
||||
|
||||
# Retire only still-matching zone01 survey marks, including its exterior
|
||||
# stakes and number. Other districts and changed human blocks are preserved.
|
||||
marker_groups = {'arrival-hex', 'spawn-medallion', 'spawn-monogram', 'label-01', 'wayfinding-01'}
|
||||
markers = json.loads((ROOT/'.runtime/layout-study/markers-final.json').read_text())
|
||||
for b in markers['blocks']:
|
||||
if b.get('group') not in marker_groups:
|
||||
continue
|
||||
p = b['x'], b['y'], b['z']
|
||||
try:
|
||||
observed = before.state(*p)
|
||||
except (KeyError, ValueError):
|
||||
continue
|
||||
if observed == b['block']:
|
||||
put(*p, b['expected'], 'retire-zone01-survey')
|
||||
|
||||
# Preserve stair treads, the foundation footprint, and all neighboring districts.
|
||||
for x, z in sorted(floor):
|
||||
material = 'smooth_sandstone'
|
||||
if edge[x, z] == 0:
|
||||
material = 'cut_sandstone'
|
||||
elif edge[x, z] == 1:
|
||||
material = 'smooth_stone'
|
||||
put(x, 95, z, material, 'cream-paving')
|
||||
|
||||
for radius, material in ((22, 'polished_andesite'), (24, 'smooth_stone'), (28, 'cut_sandstone')):
|
||||
poly = [(round(radius*math.sin(i*math.pi/3)), 9-round(radius*math.cos(i*math.pi/3))) for i in range(6)]
|
||||
ring = geometry.polygon_cells(poly)
|
||||
for x, z in ring:
|
||||
if any((x+dx, z+dz) not in ring for dx, dz in N2):
|
||||
put(x, 95, z, material, 'hexagonal-paving-bands')
|
||||
logo_mask = brand_cells(logo)
|
||||
for x, z in logo_mask:
|
||||
if (x, z) not in floor:
|
||||
raise ValueError('Logo exceeds the paving')
|
||||
put(x, 95, z, 'green_concrete', 'original-brand-inlay')
|
||||
|
||||
# Replace the former bulky survey-stage light piers. Their perimeter rails remain.
|
||||
old_plan = json.loads((ROOT / '.runtime/foundations-stage02/foundations-final.json').read_text())
|
||||
old_lamps = [(b['x'], b['z']) for b in old_plan['blocks'] if b.get('group') == 'lamps'
|
||||
and ((b['x'], b['z']) in outer or (b['x'], b['z']) == (-5, 56))]
|
||||
for x, z in old_lamps:
|
||||
for y in range(96, 100):
|
||||
put(x, y, z, 'air', 'replace-old-lamp-piers')
|
||||
props = {}
|
||||
for name, (dx, dz) in {'east': (1, 0), 'north': (0, -1), 'south': (0, 1), 'west': (-1, 0)}.items():
|
||||
neighbor = before.state(x+dx, 96, z+dz)
|
||||
props[name] = 'low' if any(n in neighbor for n in ('stone_brick_wall', 'chiseled_stone_bricks')) else 'none'
|
||||
put(x, 96, z, 'stone_brick_wall['+','.join(f'{k}={v}' for k, v in sorted(props.items() | {'up': 'true', 'waterlogged': 'false'}.items()))+']', 'restored-perimeter-rail')
|
||||
|
||||
beds, beds_union, bench_aprons, fixture_records = [], set(), set(), []
|
||||
flower_types = ('pink_tulip', 'white_tulip', 'oxeye_daisy', 'allium', 'azure_bluet')
|
||||
for index, bed in enumerate(design['beds']):
|
||||
mask = geometry.polygon_cells(bed['outline'])
|
||||
mask |= {tuple(p) for p in bed.get('additional_planting_columns', [])}
|
||||
if not mask <= floor or mask & protected:
|
||||
raise ValueError('Garden intersects a protected road or non-flat foundation')
|
||||
beds.append(mask)
|
||||
beds_union |= mask
|
||||
inset = distances(mask)
|
||||
for x, z in sorted(mask):
|
||||
put(x, 94, z, 'dirt', 'garden-soil')
|
||||
put(x, 95, z, 'grass_block[snowy=false]', 'garden-soil')
|
||||
if inset[x, z] == 0:
|
||||
put(x, 95, z, 'smooth_sandstone', 'planter-rim')
|
||||
put(x, 96, z, 'smooth_sandstone_slab[type=bottom,waterlogged=false]', 'planter-rim')
|
||||
else:
|
||||
# Drifts follow small clusters, rather than an alternating plant checkerboard.
|
||||
patch = (x//3+2*(z//3)+index) % 7
|
||||
if inset[x, z] == 1 and patch in (0, 1, 5):
|
||||
put(x, 96, z, 'oak_leaves[distance=7,persistent=true,waterlogged=false]', 'low-evergreen-hedge')
|
||||
elif (x*17+z*31) % 5 != 0:
|
||||
flower = flower_types[patch % len(flower_types)]
|
||||
put(x, 96, z, flower, 'flower-drifts')
|
||||
|
||||
tree = bed['tree']
|
||||
put(tree['x'], 95, tree['z'], 'dirt', 'stable-tree-soil')
|
||||
height = max(11, min(15, tree['height_above_floor']))
|
||||
for (dx, y, dz), state in assets.conifer(height, seed=1337+index*71).items():
|
||||
if 'leaves' in state and y < 4:
|
||||
continue
|
||||
put(tree['x']+dx, 96+y, tree['z']+dz, state, 'custom-conifers')
|
||||
fixture_records.append({'type': 'conifer', 'x': tree['x'], 'z': tree['z'], 'height': height})
|
||||
t = bed['small_topiary']
|
||||
put(t['x'], 95, t['z'], 'dirt', 'stable-tree-soil')
|
||||
for y in range(4):
|
||||
put(t['x'], 96+y, t['z'], 'spruce_log[axis=y]', 'small-topiary')
|
||||
for y, radius in ((2, 1), (3, 1), (4, 0)):
|
||||
for dx in range(-radius, radius+1):
|
||||
for dz in range(-radius, radius+1):
|
||||
if dx*dx+dz*dz <= 2 and (dx or dz or y == 4):
|
||||
put(t['x']+dx, 96+y, t['z']+dz, 'spruce_leaves[distance=7,persistent=true,waterlogged=false]', 'small-topiary')
|
||||
fixture_records.append({'type': 'topiary', 'x': t['x'], 'z': t['z'], 'height': 5})
|
||||
|
||||
facing_vectors = {'north': (0, -1), 'east': (1, 0), 'south': (0, 1), 'west': (-1, 0)}
|
||||
opposite = {'north': 'south', 'south': 'north', 'east': 'west', 'west': 'east'}
|
||||
for bed, mask in zip(design['beds'], beds):
|
||||
b = bed['bench']
|
||||
cx, cz = b['center']
|
||||
facing = opposite[b['back_faces']]
|
||||
dx, dz = facing_vectors[facing]
|
||||
# Open a level, three-block seat approach through the planter rim.
|
||||
for offset in (-1, 0, 1):
|
||||
for step in range(1, 8):
|
||||
x, z = cx+dx*step-dz*offset, cz+dz*step+dx*offset
|
||||
if (x, z) not in floor:
|
||||
raise ValueError('Bench approach exceeds the plaza')
|
||||
for y in (96, 97):
|
||||
if '_log[' in current(x, y, z):
|
||||
raise ValueError('Bench approach intersects a tree')
|
||||
put(x, y, z, 'air', 'bench-access')
|
||||
put(x, 95, z, 'smooth_sandstone', 'bench-access')
|
||||
bench_aprons.add((x, z))
|
||||
if (x, z) not in mask:
|
||||
break
|
||||
for (ox, y, oz), state in assets.bench(b['length'], facing).items():
|
||||
x, z = cx+ox, cz+oz
|
||||
if (x, z) not in floor or (x, z) in protected:
|
||||
raise ValueError('Bench exceeds its clear garden bay')
|
||||
for yy in (96, 97):
|
||||
if '_log[' in current(x, yy, z):
|
||||
raise ValueError('Bench intersects a tree root')
|
||||
put(x, yy, z, 'air', 'bench-access')
|
||||
put(x, 95, z, 'smooth_sandstone', 'bench-foundation')
|
||||
put(x, 96+y, z, state, 'garden-benches')
|
||||
fixture_records.append({'type': 'bench', 'x': cx, 'z': cz, 'facing': facing, 'seats': 3})
|
||||
|
||||
lamps = [(v['x'], v['z']) for v in design['lighting']['lamps']]
|
||||
for x, z in lamps:
|
||||
c = cells.get((x, z), {})
|
||||
if (x, z) in protected or c.get('kind') != 'full' or c.get('block_y') != 95:
|
||||
raise ValueError('Lamp base intrudes into an approach')
|
||||
put(x, 95, z, 'chiseled_stone_bricks', 'lamp-footing')
|
||||
for (dx, y, dz), state in assets.lamp(6).items():
|
||||
put(x+dx, 96+y, z+dz, state, 'copper-garden-lamps')
|
||||
fixture_records.append({'type': 'lamp', 'x': x, 'z': z, 'lantern_y': 100})
|
||||
|
||||
# Leaves use their stable distance to the composed logs, including touching hedges.
|
||||
leaf_positions = {p for p, state in desired.items() if '_leaves[' in state}
|
||||
logs = {p for p, state in desired.items() if '_log[' in state}
|
||||
leaf_dist, pending = {}, deque()
|
||||
for x, y, z in logs:
|
||||
for dx, dy, dz in N3:
|
||||
p = x+dx, y+dy, z+dz
|
||||
if p in leaf_positions:
|
||||
leaf_dist[p] = 1
|
||||
pending.append(p)
|
||||
while pending:
|
||||
x, y, z = pending.popleft()
|
||||
if leaf_dist[x, y, z] >= 6:
|
||||
continue
|
||||
for dx, dy, dz in N3:
|
||||
p = x+dx, y+dy, z+dz
|
||||
if p in leaf_positions and p not in leaf_dist:
|
||||
leaf_dist[p] = leaf_dist[x, y, z]+1
|
||||
pending.append(p)
|
||||
for p in leaf_positions:
|
||||
material = desired[p].split('[', 1)[0]
|
||||
desired[p] = f'{material}[distance={leaf_dist.get(p, 7)},persistent=true,waterlogged=false]'
|
||||
|
||||
# Every floor column with a low obstacle is excluded from the walking network.
|
||||
blocked = set()
|
||||
for x, z in floor:
|
||||
if current(x, 96, z) != AIR or current(x, 97, z) != AIR:
|
||||
blocked.add((x, z))
|
||||
blocked |= beds_union - bench_aprons
|
||||
if blocked & protected:
|
||||
raise ValueError(f'Decoration obstructs protected road cells: {sorted(blocked & protected)[:8]}')
|
||||
for x, z in blocked:
|
||||
if abs(x) <= 6 and (x, z) not in beds_union:
|
||||
# Existing outer balustrades are handled by the previous navigation mask.
|
||||
if any(groups.get((x, y, z), '') not in ('restored-perimeter-rail', '') for y in (96, 97)):
|
||||
raise ValueError('Central sightline/walking axis was obstructed')
|
||||
|
||||
blocks = []
|
||||
for p, state in sorted(desired.items()):
|
||||
observed = before.state(*p)
|
||||
try:
|
||||
prior = previous.state(*p)
|
||||
except (KeyError, ValueError):
|
||||
if p[1] <= 114:
|
||||
raise
|
||||
prior = AIR
|
||||
if observed != prior:
|
||||
raise ValueError(f'Unexpected manual edit preserved at {p}')
|
||||
if state != observed:
|
||||
blocks.append(dict(zip(('x', 'y', 'z'), p)) | {'block': state, 'expected': observed, 'group': groups[p]})
|
||||
walk = [{'x': x, 'z': z, 'standing_y': 96} for x, z in sorted(floor-blocked)]
|
||||
meta = {'scope': before.scope, 'district': '01', 'changed_blocks': len(blocks), 'planters': len(beds),
|
||||
'planter_columns': len(beds_union), 'trees': 6, 'topiary': 6, 'benches': 6, 'new_lamps': len(lamps),
|
||||
'old_lamps_replaced': len(old_lamps), 'logo_blocks': len(logo_mask), 'walk_samples': len(walk),
|
||||
'unwalkable_columns': sorted(blocked), 'bench_access_columns': sorted(bench_aprons),
|
||||
'fixtures': fixture_records, 'by_material': dict(Counter(b['block'] for b in blocks)),
|
||||
'by_group': dict(Counter(b['group'] for b in blocks)), 'source': 'Observed after-foundation baseline; approved reference sheets 03 and 11'}
|
||||
return {'version': 1, 'scope': before.scope, 'blocks': blocks}, meta, {'scope': before.scope, 'points': walk}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--design', type=Path, default=ROOT/'.runtime/plaza-stage03/design-final.json')
|
||||
parser.add_argument('--before', type=Path, default=ROOT/'.runtime/plaza-stage03/before.json.gz')
|
||||
parser.add_argument('--output', type=Path, default=ROOT/'.runtime/plaza-stage03/plaza.json')
|
||||
parser.add_argument('--logo', type=Path, default=Path('/home/emil/Desktop/Shacraft-Lobby-References/brand/logo-180.png'))
|
||||
args = parser.parse_args()
|
||||
before = survey.load_snapshot(args.before)
|
||||
previous = survey.load_snapshot(ROOT/'.runtime/foundations-stage02/after.json.gz')
|
||||
design = json.loads(args.design.read_text())
|
||||
plan, meta, walk = compile_plan(design, before, previous, args.logo)
|
||||
meta['source_logo_sha256'] = hashlib.sha256(args.logo.read_bytes()).hexdigest()
|
||||
args.output.write_text(json.dumps(plan, separators=(',', ':'))+'\n')
|
||||
args.output.with_suffix('.metadata.json').write_text(json.dumps(meta, indent=2)+'\n')
|
||||
args.output.with_suffix('.walk.json').write_text(json.dumps(walk, separators=(',', ':'))+'\n')
|
||||
print(json.dumps({k: v for k, v in meta.items() if k not in ('unwalkable_columns', 'bench_access_columns', 'fixtures', 'by_material')}))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,91 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compile the two-floor clock station against observed, unchanged foundation voxels.
|
||||
|
||||
No live writes: apply the resulting recipe using scripts/layout.py after QA.
|
||||
"""
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
STAGE=ROOT/'.runtime/station-stage07'
|
||||
|
||||
def module(name,path):
|
||||
spec=importlib.util.spec_from_file_location(name,path)
|
||||
value=importlib.util.module_from_spec(spec);spec.loader.exec_module(value);return value
|
||||
|
||||
survey=module('station_survey',ROOT/'scripts/foundation-survey.py')
|
||||
|
||||
def canonical(state):
|
||||
if '[' not in state:return state
|
||||
name,raw=state[:-1].split('[',1)
|
||||
return name+'['+','.join(sorted(raw.split(',')))+']'
|
||||
|
||||
def compile_station(before):
|
||||
layout=json.loads((ROOT/'docs/references/zone02-interior-v1/layout.json').read_text())
|
||||
geometry=json.loads((ROOT/'.runtime/foundations-stage02/foundations-final.geometry.json').read_text())
|
||||
foundation=survey.load_snapshot(ROOT/'.runtime/foundations-stage02/after.json.gz')
|
||||
foot={(c['x'],c['z']) for c in geometry['cells'] if c['group']=='clock-station'}
|
||||
allowed=set(json.loads((STAGE/'context-public.json').read_text())['supported_materials'])
|
||||
exterior=module('station_exterior',ROOT/'scripts/station-exterior.py')
|
||||
interior=module('station_interior',ROOT/'scripts/station-interior.py')
|
||||
states,groups,ext=exterior.compile_exterior(foot,layout)
|
||||
decorations,labels,intmeta=interior.compile_interior(foot,layout)
|
||||
changes=Counter()
|
||||
for p,v in decorations.items():
|
||||
if p in states and states[p]!=v:changes[(groups[p],labels[p])]+=1
|
||||
states.update(decorations);groups.update(labels)
|
||||
rows=[];conflicts=[]
|
||||
for p,value in sorted(states.items()):
|
||||
state=canonical(value)
|
||||
if state.split('[')[0] not in allowed:raise ValueError(f'Material unavailable: {state}')
|
||||
observed=before.state(*p)
|
||||
if observed==state:continue
|
||||
if observed not in survey.AIR:
|
||||
try: prior=foundation.state(*p)
|
||||
except KeyError:prior=None
|
||||
if observed!=prior:conflicts.append({'at':p,'current':observed,'foundation_stage':prior})
|
||||
rows.append(dict(zip(('x','y','z'),p))|{'block':state,'expected':observed,'group':groups[p]})
|
||||
if conflicts:raise ValueError(f'Unexpected existing changes preserved: {conflicts[:12]} ({len(conflicts)} total)')
|
||||
recipe={'version':1,'scope':before.scope,'blocks':rows}
|
||||
desired={(b['x'],b['y'],b['z']):b['block'] for b in rows}
|
||||
def state(x,y,z):return desired.get((x,y,z),before.state(x,y,z))
|
||||
inner=exterior._erode(foot,2)
|
||||
floors=[]
|
||||
for feet,name in [(99,'vestibule'),(113,'smash')]:
|
||||
candidates=set(inner)
|
||||
if feet==99:candidates|={(x,z) for x in range(-9,-2) for z in range(-85,-82)}
|
||||
clear=sorted(p for p in candidates if all(state(p[0],y,p[1]) in survey.AIR for y in range(feet,feet+4)))
|
||||
floors.append({'id':name,'standing_y':feet,'source':{'x':-6,'z':-120},'clear_columns':clear,'min_headroom':4})
|
||||
def region(name,x0,x1,y0,y1,z0,z1):
|
||||
return {'id':name,'min':{'x':x0,'y':y0,'z':z0},'max':{'x':x1,'y':y1,'z':z1}}
|
||||
clear_regions=[region('entrance',-9,-3,99,102,-85,-83)]
|
||||
for feet in [99,113]:
|
||||
clear_regions += [region(f'cabin-{feet}',-8,-4,feet,feet+3,-122,-118),
|
||||
region(f'lift-door-{feet}',-8,-4,feet,feet+3,-117,-116)]
|
||||
clear_regions += [region('ground-entry-aisle',-9,-3,99,102,-115,-86),
|
||||
region('smash-bay-front',-46,11,113,116,-135,-129)]
|
||||
metadata={'scope':before.scope,'exterior':ext,'interior':intmeta,'public_floors':floors,
|
||||
'clear_regions':clear_regions,'footprint':sorted(foot),
|
||||
'containment':{'bounds':{'min':{'x':-79,'y':94,'z':-150},'max':{'x':45,'y':163,'z':-76}},
|
||||
'authorized_caps':[region('ground-entrance-audit-cap',-9,-3,99,110,-84,-84)],
|
||||
'forbidden_y_at_or_above':126,
|
||||
'seeds':[{'id':'vestibule-and-cabin','x':-6,'y':99,'z':-120,'min_y':99,'max_y':110},
|
||||
{'id':'smash-and-cabin','x':-6,'y':113,'z':-120,'min_y':113,'max_y':123}]},
|
||||
'compile_summary':{'written_blocks':len(rows),'intended_states':len(states),'materials':dict(Counter(b['block'].split('[')[0] for b in rows)),
|
||||
'layer_overrides':[{'exterior':a,'interior':b,'count':n} for (a,b),n in changes.items()]}}
|
||||
return recipe,metadata
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--before',type=Path,default=STAGE/'before.json.gz')
|
||||
parser.add_argument('--output',type=Path,default=STAGE/'compiled')
|
||||
args=parser.parse_args();recipe,metadata=compile_station(survey.load_snapshot(args.before))
|
||||
args.output.mkdir(parents=True,exist_ok=True)
|
||||
for name,data in [('station.json',recipe),('station.metadata.json',metadata)]:
|
||||
(args.output/name).write_text(json.dumps(data,separators=(',',':'))+'\n')
|
||||
print(json.dumps(metadata['compile_summary']))
|
||||
|
||||
if __name__=='__main__':main()
|
||||
@@ -1,291 +0,0 @@
|
||||
"""Main Gothic hall geometry in local coordinates; pure voxel composition, no IO."""
|
||||
|
||||
|
||||
def build(v):
|
||||
"""Compose the north-facing hall, hollow interior, roof, and attached masonry."""
|
||||
stone = "minecraft:stone_bricks"
|
||||
ashlar = "minecraft:polished_andesite"
|
||||
plinth = "minecraft:andesite"
|
||||
pale = "minecraft:polished_diorite"
|
||||
dark = "minecraft:deepslate_tiles"
|
||||
glass = "minecraft:tinted_glass"
|
||||
wood = "minecraft:dark_oak_planks"
|
||||
oak = "minecraft:spruce_planks"
|
||||
|
||||
def slab(material="stone_brick", half="bottom"):
|
||||
return f"minecraft:{material}_slab[type={half},waterlogged=false]"
|
||||
|
||||
def stair(material, facing, half="bottom"):
|
||||
return (f"minecraft:{material}_stairs[facing={facing},half={half},"
|
||||
"shape=straight,waterlogged=false]")
|
||||
|
||||
def log(axis="y"):
|
||||
return f"minecraft:dark_oak_log[axis={axis}]"
|
||||
|
||||
def roof_height(x):
|
||||
return 43 - (abs(x - 19) * 16 + 13) // 14
|
||||
|
||||
def pinnacle(x, z, base, height=8):
|
||||
"""Square masonry shaft with a cap, taper, and slender stone finial."""
|
||||
v.box(x - 1, base, z - 1, x + 1, base, z + 1, ashlar)
|
||||
v.box(x, base + 1, z, x, base + height - 3, z, stone)
|
||||
v.box(x - 1, base + height - 4, z - 1,
|
||||
x + 1, base + height - 4, z + 1, slab())
|
||||
v.set(x, base + height - 2, z, ashlar)
|
||||
v.set(x, base + height - 1, z, slab())
|
||||
|
||||
def side_window(face, inner, center, bottom, tops, trim_face):
|
||||
"""Carved pointed aperture with glass one block behind the wall face."""
|
||||
half = max(tops)
|
||||
lo, hi = sorted((face, inner))
|
||||
for dz in range(-half, half + 1):
|
||||
top = tops[abs(dz)]
|
||||
v.clear(lo, bottom, center + dz, hi, top, center + dz)
|
||||
v.box(inner, bottom, center + dz, inner, top, center + dz, glass)
|
||||
v.set(trim_face, top + 1, center + dz, ashlar)
|
||||
for dz in (-half - 1, half + 1):
|
||||
v.box(trim_face, bottom - 1, center + dz,
|
||||
trim_face, tops[half] + 1, center + dz, ashlar)
|
||||
v.box(trim_face, bottom - 1, center - half - 1,
|
||||
trim_face, bottom - 1, center + half + 1, pale)
|
||||
# Alternating vertical/horizontal pieces describe the arch, rather than
|
||||
# colouring an arch outline on an otherwise flat facade.
|
||||
for dz in range(-half, half + 1):
|
||||
top = tops[abs(dz)]
|
||||
if dz:
|
||||
facing = "south" if dz < 0 else "north"
|
||||
v.set(trim_face, top + 2, center + dz, stair("stone_brick", facing))
|
||||
v.set(trim_face, tops[0] + 2, center, slab())
|
||||
|
||||
def front_aperture(z_face, z_glass, center, bottom, tops):
|
||||
for dx in range(-max(tops), max(tops) + 1):
|
||||
top = tops[abs(dx)]
|
||||
v.clear(center + dx, bottom, z_face, center + dx, top, z_glass)
|
||||
v.box(center + dx, bottom, z_glass, center + dx, top, z_glass, glass)
|
||||
|
||||
# Dense foundations; two genuinely hollow occupied storeys above them.
|
||||
v.box(7, 0, 13, 31, 0, 56, plinth)
|
||||
v.box(7, 1, 13, 31, 1, 56, ashlar)
|
||||
v.box(7, 2, 13, 31, 27, 56, stone)
|
||||
v.clear(9, 2, 15, 29, 5, 54)
|
||||
v.clear(9, 7, 15, 29, 43, 54)
|
||||
v.box(9, 6, 15, 29, 6, 54, wood)
|
||||
v.box(17, 6, 15, 21, 6, 53, ashlar)
|
||||
for x in (9, 29):
|
||||
v.box(x, 6, 15, x, 6, 54, stone)
|
||||
for z in (15, 54):
|
||||
v.box(9, 6, z, 29, 6, z, stone)
|
||||
|
||||
# Continuous ledges make the raised main floor legible from the courtyard.
|
||||
for x in (6, 32):
|
||||
v.box(x, 0, 13, x, 1, 56, ashlar)
|
||||
v.box(x, 5, 13, x, 5, 56, stone)
|
||||
v.box(x, 6, 13, x, 6, 56, slab("smooth_stone"))
|
||||
v.box(x, 25, 12, x, 25, 57, ashlar)
|
||||
v.box(x, 26, 12, x, 26, 57, pale)
|
||||
v.box(x, 27, 12, x, 27, 57, slab())
|
||||
for z in (12, 57):
|
||||
v.box(6, 5, z, 32, 5, z, stone)
|
||||
v.box(6, 6, z, 32, 6, z, slab("smooth_stone"))
|
||||
v.box(6, 25, z, 32, 25, z, ashlar)
|
||||
v.box(6, 26, z, 32, 26, z, pale)
|
||||
# Timber is visible in the short shadow immediately below the roof edge.
|
||||
for x in (7, 31):
|
||||
v.box(x, 26, 15, x, 26, 54, log("z"))
|
||||
for z in range(15, 55, 2):
|
||||
v.set(x, 25, z, stair("spruce", "east" if x == 7 else "west", "top"))
|
||||
|
||||
bays = (18, 26, 34, 42, 50)
|
||||
piers = (14, 22, 30, 38, 46, 54)
|
||||
for center in bays:
|
||||
for face, inner, trim in ((7, 8, 6), (31, 30, 32)):
|
||||
side_window(face, inner, center, 10, {0: 22, 1: 21, 2: 19}, trim)
|
||||
# The recessed central mullion and transom divide four dark lancets.
|
||||
v.box(inner, 10, center, inner, 20, center, ashlar)
|
||||
v.box(inner, 15, center - 2, inner, 15, center + 2, ashlar)
|
||||
v.set(inner, 21, center, wood)
|
||||
# A modest three-wide basement window below each high bay.
|
||||
side_window(face, inner, center, 2, {0: 4, 1: 3}, trim)
|
||||
v.set(inner, 2, center, wood)
|
||||
# Deep, bevelled main-window sill; its ends connect to the piers.
|
||||
outside = trim - 1 if trim < 19 else trim + 1
|
||||
v.box(outside, 8, center - 3, outside, 8, center + 3, slab())
|
||||
v.box(trim, 9, center - 3, trim, 9, center + 3, stone)
|
||||
|
||||
# Each buttress has an outward foot and two real setbacks.
|
||||
for z in piers:
|
||||
for left in (True, False):
|
||||
if left:
|
||||
foot, middle, shaft, outside = (3, 7), (4, 7), (5, 7), 5
|
||||
else:
|
||||
foot, middle, shaft, outside = (31, 35), (31, 34), (31, 33), 33
|
||||
v.box(foot[0], 0, z - 1, foot[1], 1, z + 1, plinth)
|
||||
v.box(middle[0], 2, z - 1, middle[1], 5, z + 1, stone)
|
||||
v.box(middle[0], 6, z - 1, middle[1], 6, z + 1, slab("smooth_stone"))
|
||||
v.box(shaft[0], 7, z - 1, shaft[1], 11, z + 1, stone)
|
||||
v.box(shaft[0], 12, z - 1, shaft[1], 12, z + 1, ashlar)
|
||||
v.box(min(outside, 7 if left else 31), 13, z,
|
||||
max(outside, 7 if left else 31), 24, z, stone)
|
||||
v.set(outside, 19, z, ashlar)
|
||||
v.box(shaft[0], 25, z - 1, shaft[1], 25, z + 1, ashlar)
|
||||
v.set(outside, 26, z, slab("smooth_stone"))
|
||||
pinnacle(outside + (1 if left else -1), z, 27, 7)
|
||||
|
||||
# Recessed rear lancets, kept separate from the side gallery connection.
|
||||
for center in (12, 19, 26):
|
||||
front_aperture(55, 56, center, 10, {0: 22, 1: 20})
|
||||
for dx in (-2, 2):
|
||||
v.box(center + dx, 9, 57, center + dx, 21, 57, ashlar)
|
||||
v.set(center, 24, 57, ashlar)
|
||||
v.box(center - 2, 9, 57, center + 2, 9, 57, pale)
|
||||
|
||||
# Steep, two-cell-thick stepped roof. Both sides slope to the central ridge.
|
||||
for x in range(5, 34):
|
||||
high = roof_height(x)
|
||||
if x == 19:
|
||||
v.box(x, 42, 12, x, 43, 57, dark)
|
||||
else:
|
||||
v.box(x, high - 1, 12, x, high, 57, dark)
|
||||
v.box(x, high, 12, x, high, 57,
|
||||
stair("deepslate_tile", "east" if x < 19 else "west"))
|
||||
# A restrained ridge crest uses slabs and piers instead of unsupported fences.
|
||||
v.box(19, 43, 14, 19, 43, 55, slab("stone_brick"))
|
||||
for z in range(15, 56, 4):
|
||||
v.set(19, 43, z, ashlar)
|
||||
|
||||
# Front and rear stepped gables: substantial masonry underneath the coping.
|
||||
for z in (12, 57):
|
||||
for x in range(6, 33):
|
||||
high = roof_height(x)
|
||||
v.box(x, 27, z, x, high, z, stone)
|
||||
v.set(x, high, z, ashlar)
|
||||
if x != 19:
|
||||
v.set(x, high + 1, z, stair("stone_brick", "east" if x < 19 else "west"))
|
||||
v.box(19, 44, z, 19, 46, z, ashlar)
|
||||
v.box(18, 47, z, 20, 47, z, stone)
|
||||
v.set(19, 48, z, slab())
|
||||
|
||||
# Tall north facade, projecting three-layer porch, and its open pointed door.
|
||||
v.box(13, 6, 9, 25, 6, 14, ashlar)
|
||||
v.box(14, 6, 8, 24, 6, 8, ashlar)
|
||||
portal_top = {0: 19, 1: 18, 2: 17, 3: 15, 4: 13}
|
||||
for dx, top in portal_top.items():
|
||||
for sign in (-1, 1) if dx else (1,):
|
||||
x = 19 + sign * dx
|
||||
v.clear(x, 7, 9, x, top, 15)
|
||||
for z, material, expand in ((9, ashlar, 1), (10, stone, 0), (11, pale, 0)):
|
||||
for dx in (-5, 5):
|
||||
v.box(19 + dx, 7, z, 19 + dx, 14, z, material)
|
||||
for dx, top in portal_top.items():
|
||||
for sign in (-1, 1) if dx else (1,):
|
||||
x = 19 + sign * dx
|
||||
v.box(x, top + 1, z, x, top + 1 + expand, z, material)
|
||||
for x in (13, 25):
|
||||
v.box(x, 7, 10, x, 14, 12, stone)
|
||||
v.box(x, 15, 10, x, 15, 12, slab())
|
||||
# Open wooden leaves are flush with the inner jambs, not across the entrance.
|
||||
v.box(15, 7, 14, 15, 12, 15, log())
|
||||
v.box(23, 7, 14, 23, 12, 15, log())
|
||||
v.clear(16, 7, 9, 22, 12, 15)
|
||||
|
||||
# Two slim flanking lancets and a large upper traceried front window.
|
||||
for center in (10, 28):
|
||||
front_aperture(12, 14, center, 10, {0: 23, 1: 21})
|
||||
for x in (center - 2, center + 2):
|
||||
v.box(x, 9, 11, x, 22, 11, ashlar)
|
||||
v.set(center, 25, 11, slab())
|
||||
v.box(center - 2, 9, 11, center + 2, 9, 11, pale)
|
||||
front_aperture(12, 14, 19, 29, {0: 39, 1: 38, 2: 36, 3: 33})
|
||||
for dx, top in {0: 39, 1: 38, 2: 36, 3: 33}.items():
|
||||
for sign in (-1, 1) if dx else (1,):
|
||||
v.set(19 + dx * sign, top + 1, 11, ashlar)
|
||||
for x in (15, 23):
|
||||
v.box(x, 28, 11, x, 34, 11, ashlar)
|
||||
for x in (18, 20):
|
||||
v.box(x, 29, 14, x, 36, 14, ashlar)
|
||||
v.box(16, 33, 14, 22, 33, 14, ashlar)
|
||||
v.box(15, 28, 11, 23, 28, 11, pale)
|
||||
# Small heraldic mosaic echoes the reference's central hanging cross.
|
||||
v.box(17, 22, 11, 21, 27, 11, dark)
|
||||
v.box(19, 23, 10, 19, 26, 10, pale)
|
||||
v.box(18, 25, 10, 20, 25, 10, pale)
|
||||
v.set(19, 21, 11, dark)
|
||||
for x in (7, 31):
|
||||
v.box(x - 1, 2, 11, x + 1, 9, 13, stone)
|
||||
v.box(x, 10, 11, x, 28, 12, ashlar)
|
||||
for y in (6, 13, 25):
|
||||
v.box(x - 1, y, 10, x + 1, y, 13, slab("smooth_stone"))
|
||||
pinnacle(x, 12, 29, 9)
|
||||
|
||||
# Five small pointed dormers on each long roof slope.
|
||||
for z in bays:
|
||||
for left in (True, False):
|
||||
lo, hi, face, inner = (10, 14, 10, 11) if left else (24, 28, 28, 27)
|
||||
v.clear(lo, 33, z - 1, hi, 36, z + 1)
|
||||
for dz in (-2, 2):
|
||||
v.box(lo, 32, z + dz, hi, 37, z + dz, dark)
|
||||
v.box(face, 32, z - 1, face, 37, z + 1, ashlar)
|
||||
v.clear(min(face, inner), 34, z, max(face, inner), 36, z)
|
||||
v.box(inner, 34, z, inner, 36, z, glass)
|
||||
for dz in range(-2, 3):
|
||||
top = 39 - abs(dz)
|
||||
v.box(lo, top, z + dz, hi, top, z + dz, dark)
|
||||
v.set(face, top, z + dz, ashlar)
|
||||
v.set(face, 40, z, slab())
|
||||
|
||||
# Galleries retain a broad, open central nave and three-block walkways.
|
||||
for x1, x2 in ((9, 12), (26, 29)):
|
||||
v.box(x1, 16, 16, x2, 16, 52, wood)
|
||||
rail = x2 if x1 < 19 else x1
|
||||
v.box(rail, 17, 16, rail, 17, 52, slab("stone_brick"))
|
||||
for z in range(16, 53, 4):
|
||||
v.set(rail, 17, z, ashlar)
|
||||
v.set(rail, 18, z, slab())
|
||||
for z in piers[1:]:
|
||||
if z > 52:
|
||||
continue
|
||||
v.box(rail, 7, z, rail, 15, z, stone)
|
||||
v.box(rail - 1, 7, z - 1, rail + 1, 7, z + 1, ashlar)
|
||||
v.box(rail - 1, 15, z, rail + 1, 15, z, ashlar)
|
||||
# A rear bridge connects both galleries to the single staircase.
|
||||
v.box(9, 16, 51, 29, 16, 53, wood)
|
||||
v.box(12, 17, 50, 25, 17, 50, slab())
|
||||
for x in (12, 19, 25):
|
||||
v.set(x, 17, 50, ashlar)
|
||||
v.set(x, 18, 50, slab())
|
||||
for x in (12, 26):
|
||||
v.box(x, 7, 52, x, 15, 52, stone)
|
||||
# Transverse oak roof frames are visible from the nave between the dormers.
|
||||
for z in (22, 30, 38, 46, 54):
|
||||
v.box(9, 28, z, 29, 28, z, log("x"))
|
||||
for x in range(9, 30):
|
||||
y = 40 - abs(x - 19)
|
||||
v.set(x, y, z, wood)
|
||||
for x in (9, 29):
|
||||
v.box(x, 19, z, x, 28, z, log())
|
||||
|
||||
# Rear right gallery stair: ten rises, with its complete headroom shaft.
|
||||
v.clear(26, 7, 39, 28, 19, 49)
|
||||
for step in range(10):
|
||||
y, z = 7 + step, 40 + step
|
||||
if y > 7:
|
||||
v.box(26, 7, z, 28, y - 1, z, wood)
|
||||
v.box(26, y, z, 28, y, z, stair("spruce", "south"))
|
||||
# Separate basement access under the west gallery; the main floor is pierced.
|
||||
v.clear(10, 2, 43, 11, 9, 48)
|
||||
for step in range(5):
|
||||
y, z = 2 + step, 44 + step
|
||||
if y > 2:
|
||||
v.box(10, 2, z, 11, y - 1, z, stone)
|
||||
v.box(10, y, z, 11, y, z, stair("stone_brick", "south"))
|
||||
|
||||
# Modest furnishings preserve a five-block processional route to the dais.
|
||||
v.box(14, 7, 50, 24, 7, 53, wood)
|
||||
v.box(16, 7, 49, 22, 7, 49, stair("spruce", "south"))
|
||||
for z in (24, 30, 36, 42):
|
||||
for x1, x2 in ((14, 16), (22, 24)):
|
||||
v.box(x1, 7, z, x2, 7, z, stair("spruce", "north"))
|
||||
v.set(x1, 8, z, slab("spruce"))
|
||||
v.set(x2, 8, z, slab("spruce"))
|
||||
# Agreed lower-level passage to the east gallery/wing, cut after decoration.
|
||||
v.clear(30, 7, 36, 35, 11, 39)
|
||||
@@ -1,120 +0,0 @@
|
||||
"""Add the architecture polish layer to the finished v1 scene, without I/O.
|
||||
|
||||
The input is scene.Voxels after the eighteen finishing corrections. V1 modules
|
||||
remain immutable; removed blocks disappear from this sparse voxel composition.
|
||||
"""
|
||||
|
||||
|
||||
def build(v):
|
||||
"""Restore the roof silhouette and add restrained Gothic stone/iron detail."""
|
||||
stone = "minecraft:stone_bricks"
|
||||
trim = "minecraft:polished_andesite"
|
||||
dark = "minecraft:deepslate_tiles"
|
||||
glass = "minecraft:gray_stained_glass"
|
||||
wall = ("minecraft:stone_brick_wall[east=none,north=none,south=none,"
|
||||
"up=true,waterlogged=false,west=none]")
|
||||
|
||||
def stair(material, facing, half="bottom"):
|
||||
return (f"minecraft:{material}_stairs[facing={facing},half={half},"
|
||||
"shape=straight,waterlogged=false]")
|
||||
|
||||
def bars(north=False, south=False, east=False, west=False):
|
||||
flags = {"east": east, "north": north, "south": south, "west": west}
|
||||
return "minecraft:iron_bars[" + ",".join(
|
||||
f"{name}={str(value).lower()}" for name, value in flags.items()
|
||||
) + ",waterlogged=false]"
|
||||
|
||||
def roof_height(x):
|
||||
return 43 - (abs(x - 19) * 16 + 13) // 14
|
||||
|
||||
# Use the actual input mask so a material replacement cannot fill an opening.
|
||||
# Preserve the six pale blocks of the heraldic cross on the north facade.
|
||||
crest = {(19, y, 10) for y in range(23, 27)} | {
|
||||
(x, 25, 10) for x in range(18, 21)
|
||||
}
|
||||
timber = {
|
||||
p: block for p, block in v.blocks.items()
|
||||
if 9 <= p[0] <= 29 and 28 <= p[1] <= 40 and 15 <= p[2] <= 54
|
||||
and (block.startswith("minecraft:dark_oak_log[")
|
||||
or block == "minecraft:dark_oak_planks")
|
||||
}
|
||||
for p, block in tuple(v.blocks.items()):
|
||||
if block == "minecraft:polished_diorite" and p not in crest:
|
||||
v.set(*p, trim)
|
||||
elif block == "minecraft:tinted_glass":
|
||||
v.set(*p, glass)
|
||||
elif (block == "minecraft:cut_sandstone"
|
||||
and 39 <= p[0] <= 41 and 34 <= p[1] <= 37 and 48 <= p[2] <= 50):
|
||||
v.set(*p, "minecraft:gold_block")
|
||||
|
||||
# Remove only the ten old dormer envelopes, then reconstruct the same
|
||||
# two-cell roof section. These envelopes exclude both gables and all trusses.
|
||||
for z in (18, 26, 34, 42, 50):
|
||||
for x1, x2 in ((10, 14), (24, 28)):
|
||||
v.clear(x1, 32, z - 2, x2, 40, z + 2)
|
||||
for x in range(x1, x2 + 1):
|
||||
high = roof_height(x)
|
||||
for y in (high - 1, high):
|
||||
if 32 <= y <= 40:
|
||||
block = dark if y < high else stair(
|
||||
"deepslate_tile", "east" if x < 19 else "west")
|
||||
v.box(x, y, z - 2, x, y, z + 2, block)
|
||||
|
||||
# Three narrow, low dormers per slope: a one-by-two recessed light, three
|
||||
# blocks of facade width, and a short roof merging into the original slope.
|
||||
for z in (22, 34, 46):
|
||||
for face, inner, back in ((10, 11, 13), (28, 27, 25)):
|
||||
x1, x2 = sorted((face, back))
|
||||
near = back - 1 if face < back else back + 1
|
||||
v.clear(min(face, near), 33, z, max(face, near), 34, z)
|
||||
for dz in (-1, 1):
|
||||
v.box(x1, 32, z + dz, x2, 34, z + dz, dark)
|
||||
facing = "south" if dz < 0 else "north"
|
||||
v.box(x1, 35, z + dz, x2, 35, z + dz,
|
||||
stair("deepslate_tile", facing))
|
||||
v.box(face, 33, z + dz, face, 34, z + dz, trim)
|
||||
v.set(face, 35, z + dz, stair("stone_brick", facing))
|
||||
v.box(x1, 36, z, x2, 36, z, dark)
|
||||
v.box(face, 32, z - 1, face, 32, z + 1, trim)
|
||||
v.box(inner, 33, z, inner, 34, z, glass)
|
||||
v.set(face, 35, z, stone)
|
||||
v.set(face, 36, z, wall)
|
||||
|
||||
# The old bright, chunky ridge is now a dark backing for one thin iron rail.
|
||||
v.box(19, 43, 14, 19, 43, 55, dark)
|
||||
for z in range(14, 56):
|
||||
v.set(19, 44, z, bars(north=z > 14, south=z < 55))
|
||||
|
||||
# Replace full-block mullions with slender rods in their original glazing
|
||||
# plane. Connected horizontal bars provide a clear transom without a grille.
|
||||
for center in (18, 26, 34, 42, 50):
|
||||
for inner, outside in ((8, 6), (30, 32)):
|
||||
for y in range(10, 21):
|
||||
v.set(inner, y, center, bars())
|
||||
for dz in range(-2, 3):
|
||||
v.set(inner, 15, center + dz,
|
||||
bars(north=dz > -2, south=dz < 2))
|
||||
# A bevel on the underside of each stepped arch reduces square caps.
|
||||
for dz, top in ((-2, 19), (-1, 21), (1, 21), (2, 19)):
|
||||
v.set(outside, top + 1, center + dz,
|
||||
stair("stone_brick", "north" if dz < 0 else "south", "top"))
|
||||
for x in (18, 20):
|
||||
for y in range(29, 37):
|
||||
v.set(x, y, 14, bars())
|
||||
for x in range(16, 23):
|
||||
v.set(x, 33, 14, bars(east=x < 22, west=x > 16))
|
||||
|
||||
# Taper the tips, retaining the full support blocks installed by finish v1.
|
||||
for x in (6, 32):
|
||||
for z in (14, 22, 30, 38, 46, 54):
|
||||
v.box(x, 32, z, x, 33, z, wall)
|
||||
for x in (7, 31):
|
||||
v.box(x, 36, 12, x, 37, 12, wall)
|
||||
for x in (35, 45):
|
||||
for z in (43, 55):
|
||||
v.box(x, 49, z, x, 50, z, wall)
|
||||
|
||||
# Two new dormer centers cross truss planes. Keep every original timber
|
||||
# voxel, including the inclined members, instead of cutting a frame for glass.
|
||||
for p, block in timber.items():
|
||||
v.set(*p, block)
|
||||
@@ -1,159 +0,0 @@
|
||||
"""Reference-driven finishing layer; the original scene and its ledgers stay immutable."""
|
||||
from .scene import build_scene as original_scene
|
||||
from .polish_architecture import build as architecture
|
||||
|
||||
STONE = 'minecraft:stone_bricks'
|
||||
TRIM = 'minecraft:polished_andesite'
|
||||
LEAVES = 'minecraft:oak_leaves[distance=7,persistent=true,waterlogged=false]'
|
||||
CHAIN = 'minecraft:iron_chain[axis=y,waterlogged=false]'
|
||||
SLAB = 'minecraft:stone_brick_slab[type=bottom,waterlogged=false]'
|
||||
|
||||
|
||||
def stair(facing, half='top'):
|
||||
return f'minecraft:stone_brick_stairs[facing={facing},half={half},shape=straight,waterlogged=false]'
|
||||
|
||||
|
||||
def bars(north=False, east=False, south=False, west=False):
|
||||
return ('minecraft:iron_bars[' + ','.join(f'{key}={str(value).lower()}' for key, value in
|
||||
[('north', north), ('east', east), ('south', south), ('west', west)]) + ',waterlogged=false]')
|
||||
|
||||
|
||||
def finished_scene():
|
||||
v = original_scene()
|
||||
for x in (6, 32):
|
||||
for z in (14, 22, 30, 38, 46, 54):
|
||||
v.set(x, 30, z, STONE)
|
||||
for x in (7, 31):
|
||||
v.set(x, 34, 12, STONE)
|
||||
for z in range(36, 40):
|
||||
v.set(35, 8, z, stair('east', 'bottom'))
|
||||
return v
|
||||
|
||||
|
||||
def lamp(v, x, y, z, hanging=True):
|
||||
v.set(x, y, z, f'minecraft:lantern[hanging={str(hanging).lower()},waterlogged=false]')
|
||||
|
||||
|
||||
def lighting(v):
|
||||
v.stage = 'lanterns and interior lighting'
|
||||
# Portal fixtures attach to the existing masonry jambs.
|
||||
for x in (13, 25):
|
||||
v.set(x, 14, 9, stair('south'))
|
||||
v.set(x, 13, 9, CHAIN)
|
||||
lamp(v, x, 12, 9)
|
||||
for x, facing, bays in ((4, 'east', (14, 22, 30, 38, 46, 54)),
|
||||
(34, 'west', (14, 22, 30))):
|
||||
for z in bays:
|
||||
v.set(x, 16, z, stair(facing))
|
||||
v.set(x, 15, z, CHAIN)
|
||||
lamp(v, x, 14, z)
|
||||
for x in (43, 52):
|
||||
v.set(x, 8, 14, stair('south'))
|
||||
v.set(x, 7, 14, CHAIN)
|
||||
lamp(v, x, 6, 14)
|
||||
for z in (21, 29, 37):
|
||||
v.set(59, 14, z, TRIM)
|
||||
v.set(60, 14, z, stair('west'))
|
||||
v.set(60, 13, z, CHAIN)
|
||||
lamp(v, 60, 12, z)
|
||||
# Existing low piers become courtyard lamps, with a full supporting cap.
|
||||
for x in (8, 30, 35, 61):
|
||||
for z in (6, 59):
|
||||
v.set(x, 4, z, TRIM)
|
||||
lamp(v, x, 5, z, False)
|
||||
# Three chandeliers hang in the clear nave, below the preserved timber ties.
|
||||
for z in (22, 38, 46):
|
||||
v.box(19, 19, z, 19, 27, z, CHAIN)
|
||||
for dx in range(-2, 3):
|
||||
v.set(19 + dx, 18, z, bars(dx == 0, dx < 2, dx == 0, dx > -2))
|
||||
for dz in (-2, -1, 1, 2):
|
||||
v.set(19, 18, z + dz, bars(dz > -2, False, dz < 2, False))
|
||||
for x, zz in ((17, z), (21, z), (19, z - 2), (19, z + 2)):
|
||||
lamp(v, x, 17, zz)
|
||||
for x, facing in ((13, 'west'), (25, 'east')):
|
||||
for z in (22, 38, 46):
|
||||
v.set(x, 12, z, stair(facing))
|
||||
v.set(x, 11, z, CHAIN)
|
||||
lamp(v, x, 10, z)
|
||||
# Wing ground floor: lights hang from the existing floor beams.
|
||||
for x in (43, 50):
|
||||
for z in (23, 31):
|
||||
v.set(x, 6, z, CHAIN)
|
||||
lamp(v, x, 5, z)
|
||||
for z in (23, 35):
|
||||
v.box(38, 17, z, 57, 17, z, 'minecraft:spruce_log[axis=x]')
|
||||
v.box(47, 14, z, 47, 16, z, CHAIN)
|
||||
lamp(v, 47, 13, z)
|
||||
# Tower landings and the block-built bell receive a restrained warm light.
|
||||
for y in (7, 16, 25):
|
||||
v.box(40, y + 1, 46, 40, y + 2, 46, CHAIN)
|
||||
lamp(v, 40, y, 46)
|
||||
for x in (37, 43):
|
||||
v.box(x, 39, 49, x, 40, 49, CHAIN)
|
||||
lamp(v, x, 38, 49)
|
||||
|
||||
|
||||
def landscape(v):
|
||||
v.stage = 'planted stone terrace'
|
||||
|
||||
def bed(x1, z1, x2, z2, high=3):
|
||||
# Soil replaces only the known terrace surface, with a low dressed rim.
|
||||
for x in range(x1, x2 + 1):
|
||||
for z in range(z1, z2 + 1):
|
||||
edge = x in (x1, x2) or z in (z1, z2)
|
||||
if edge:
|
||||
if (x, 1, z) not in v.blocks:
|
||||
v.set(x, 1, z, SLAB)
|
||||
else:
|
||||
v.set(x, 0, z, 'minecraft:dirt')
|
||||
height = high - int((x * 7 + z * 11) % 5 == 0)
|
||||
for y in range(1, height + 1):
|
||||
if (x, y, z) not in v.blocks:
|
||||
v.set(x, y, z, LEAVES)
|
||||
|
||||
for z in (18, 26, 34, 42, 50):
|
||||
bed(1, z - 2, 5, z + 2, 2)
|
||||
for z in (21, 29, 37):
|
||||
bed(60, z - 2, 64, z + 2, 2)
|
||||
bed(5, 9, 12, 11, 2)
|
||||
bed(26, 9, 32, 11, 2)
|
||||
bed(39, 11, 43, 13, 2)
|
||||
bed(52, 11, 56, 13, 2)
|
||||
bed(9, 59, 28, 61, 2)
|
||||
bed(48, 45, 59, 47, 2)
|
||||
|
||||
def narrow_tree(x, z, height):
|
||||
# Persistent leaves cannot decay; every addition respects existing masonry.
|
||||
if (x, 0, z) not in v.blocks or v.blocks[(x, 0, z)].split('[')[0] in (
|
||||
'minecraft:smooth_stone', 'minecraft:polished_andesite', 'minecraft:stone_bricks'):
|
||||
v.set(x, 0, z, 'minecraft:moss_block')
|
||||
for y in range(1, height - 1):
|
||||
if (x, y, z) not in v.blocks or v.blocks[(x, y, z)] == LEAVES:
|
||||
v.set(x, y, z, 'minecraft:spruce_log[axis=y]')
|
||||
for y in range(2, height + 1):
|
||||
radius = 2 if y <= height - 4 else 1 if y <= height - 1 else 0
|
||||
for dx in range(-radius, radius + 1):
|
||||
for dz in range(-radius, radius + 1):
|
||||
if abs(dx) + abs(dz) > radius + 1:
|
||||
continue
|
||||
p = x + dx, y, z + dz
|
||||
if p not in v.blocks or v.blocks[p] == LEAVES:
|
||||
v.set(*p, LEAVES)
|
||||
for x, z, h in ((10, 9, 8), (28, 9, 8), (62, 16, 7), (62, 44, 7),
|
||||
(6, 60, 8), (32, 60, 7)):
|
||||
narrow_tree(x, z, h)
|
||||
# Slight wear follows the edges, leaving the main approach calm and readable.
|
||||
for (x, y, z), block in list(v.blocks.items()):
|
||||
if y == 0 and block == 'minecraft:smooth_stone' and (x < 6 or x > 59 or z > 57):
|
||||
if (x * 31 + z * 17) % 29 == 0:
|
||||
v.set(x, y, z, 'minecraft:andesite')
|
||||
if y in (1, 2, 3) and block == STONE and (x < 8 or x > 56) and (x * 13 + y * 7 + z * 19) % 31 == 0:
|
||||
v.set(x, y, z, 'minecraft:mossy_stone_bricks')
|
||||
|
||||
|
||||
def build_scene():
|
||||
v = finished_scene()
|
||||
architecture(v)
|
||||
lighting(v)
|
||||
landscape(v)
|
||||
return v
|
||||
@@ -1,157 +0,0 @@
|
||||
"""Deterministic voxel blueprint for the generated Gothic hall reference."""
|
||||
from collections import Counter, defaultdict
|
||||
import importlib
|
||||
import re
|
||||
|
||||
ORIGIN = (-50, -60, -40)
|
||||
|
||||
class Voxels:
|
||||
def __init__(self):
|
||||
self.blocks = {}
|
||||
self.stage = 'site'
|
||||
self.owners = {}
|
||||
|
||||
def set(self, x, y, z, block):
|
||||
assert all(isinstance(v, int) for v in (x, y, z)), (x, y, z)
|
||||
assert -2 <= x <= 68 and -1 <= y <= 64 and -2 <= z <= 68, (x, y, z)
|
||||
if not block.startswith('minecraft:'):
|
||||
block = 'minecraft:' + block
|
||||
assert re.fullmatch(r'minecraft:[a-z0-9_]+(?:\[[a-z0-9_=,]+\])?', block), block
|
||||
if block == 'minecraft:air':
|
||||
self.blocks.pop((x, y, z), None)
|
||||
self.owners.pop((x, y, z), None)
|
||||
else:
|
||||
self.blocks[(x, y, z)] = block
|
||||
self.owners[(x, y, z)] = self.stage
|
||||
|
||||
def box(self, x1, y1, z1, x2, y2, z2, block):
|
||||
x1, x2 = sorted((x1, x2))
|
||||
y1, y2 = sorted((y1, y2))
|
||||
z1, z2 = sorted((z1, z2))
|
||||
for y in range(y1, y2 + 1):
|
||||
for z in range(z1, z2 + 1):
|
||||
for x in range(x1, x2 + 1):
|
||||
self.set(x, y, z, block)
|
||||
|
||||
def clear(self, x1, y1, z1, x2, y2, z2):
|
||||
self.box(x1, y1, z1, x2, y2, z2, 'air')
|
||||
|
||||
def site(v):
|
||||
v.box(1, 0, 1, 63, 0, 62, 'smooth_stone')
|
||||
for z in (1, 62):
|
||||
v.box(1, 0, z, 63, 0, z, 'polished_andesite')
|
||||
for x in (1, 63):
|
||||
v.box(x, 0, 1, x, 0, 62, 'polished_andesite')
|
||||
# A few continuous paving bands give the terrace scale without noisy texture.
|
||||
for x in (5, 33, 60):
|
||||
v.box(x, 0, 2, x, 0, 61, 'stone_bricks')
|
||||
for z in (4, 59):
|
||||
v.box(2, 0, z, 62, 0, z, 'stone_bricks')
|
||||
for step in range(1, 7):
|
||||
z = step + 1
|
||||
v.box(13, 1, z, 25, step, z, 'stone_bricks')
|
||||
v.box(14, step, z, 24, step, z,
|
||||
'stone_brick_stairs[facing=south,half=bottom,shape=straight,waterlogged=false]')
|
||||
for x in (12, 26):
|
||||
v.box(x, 1, z, x, step + 1, z, 'polished_andesite')
|
||||
v.set(x, step + 2, z, 'stone_brick_slab[type=bottom,waterlogged=false]')
|
||||
v.box(13, 1, 8, 25, 6, 9, 'stone_bricks')
|
||||
for x in (8, 30, 35, 61):
|
||||
for z in (6, 59):
|
||||
v.box(x, 1, z, x + 1, 1, z + 1, 'polished_andesite')
|
||||
v.box(x, 2, z, x + 1, 3, z + 1, 'stone_bricks')
|
||||
v.box(x, 4, z, x + 1, 4, z + 1,
|
||||
'stone_brick_slab[type=bottom,waterlogged=false]')
|
||||
|
||||
def connection(v):
|
||||
# Two-level enclosed passage between the great hall and companion wing.
|
||||
v.stage = 'connecting gallery'
|
||||
v.box(31, 0, 34, 40, 6, 42, 'stone_bricks')
|
||||
v.box(31, 7, 34, 40, 13, 34, 'stone_bricks')
|
||||
v.box(31, 7, 42, 40, 13, 42, 'stone_bricks')
|
||||
v.box(31, 13, 33, 40, 13, 43, 'polished_andesite')
|
||||
v.box(31, 14, 34, 40, 14, 42, 'deepslate_tiles')
|
||||
v.box(31, 15, 36, 40, 15, 40, 'deepslate_tiles')
|
||||
v.box(31, 16, 38, 40, 16, 38, 'deepslate_tiles')
|
||||
for z, facing, y in [(34, 'south', 14), (35, 'south', 14),
|
||||
(36, 'south', 15), (37, 'south', 15),
|
||||
(39, 'north', 15), (40, 'north', 15),
|
||||
(41, 'north', 14), (42, 'north', 14)]:
|
||||
v.box(31, y, z, 40, y, z,
|
||||
f'deepslate_tile_stairs[facing={facing},half=bottom,shape=straight,waterlogged=false]')
|
||||
v.clear(30, 7, 36, 32, 11, 39)
|
||||
v.clear(33, 8, 36, 34, 11, 39)
|
||||
v.clear(35, 9, 36, 40, 12, 39)
|
||||
v.box(31, 6, 36, 32, 6, 39, 'spruce_planks')
|
||||
for x, y in [(33, 6), (34, 7)]:
|
||||
v.box(x, y, 36, x, y, 39,
|
||||
'spruce_stairs[facing=east,half=bottom,shape=straight,waterlogged=false]')
|
||||
v.box(35, 8, 36, 40, 8, 39, 'spruce_planks')
|
||||
|
||||
def build_scene():
|
||||
v = Voxels()
|
||||
site(v)
|
||||
for name, stage in [('main_hall', 'great hall'), ('side_wing', 'side wing'), ('tower', 'bell tower')]:
|
||||
v.stage = stage
|
||||
importlib.import_module('builds.gothic_hall.' + name).build(v)
|
||||
connection(v)
|
||||
return v
|
||||
|
||||
def boxes(blocks):
|
||||
"""Merge runs on X, then Z and Y, preserving the exact sparse block mask."""
|
||||
rows = defaultdict(list)
|
||||
for (x, y, z), block in blocks.items():
|
||||
rows[(y, z, block)].append(x)
|
||||
runs = []
|
||||
for (y, z, block), xs in sorted(rows.items()):
|
||||
xs.sort(); start = end = xs[0]
|
||||
for x in xs[1:]:
|
||||
if x == end + 1:
|
||||
end = x
|
||||
else:
|
||||
runs.append((start, y, z, end, y, z, block)); start = end = x
|
||||
runs.append((start, y, z, end, y, z, block))
|
||||
merged = []
|
||||
groups = defaultdict(list)
|
||||
for x1, y1, z1, x2, y2, z2, block in runs:
|
||||
groups[(x1, x2, y1, block)].append(z1)
|
||||
for (x1, x2, y, block), zs in sorted(groups.items()):
|
||||
zs.sort(); start = end = zs[0]
|
||||
for z in zs[1:]:
|
||||
if z == end + 1: end = z
|
||||
else:
|
||||
merged.append((x1, y, start, x2, y, end, block)); start = end = z
|
||||
merged.append((x1, y, start, x2, y, end, block))
|
||||
result = []; groups = defaultdict(list)
|
||||
for x1, y1, z1, x2, y2, z2, block in merged:
|
||||
groups[(x1, x2, z1, z2, block)].append(y1)
|
||||
for (x1, x2, z1, z2, block), ys in sorted(groups.items()):
|
||||
ys.sort(); start = end = ys[0]
|
||||
for y in ys[1:]:
|
||||
if y == end + 1: end = y
|
||||
else:
|
||||
result.append((x1, start, z1, x2, end, z2, block)); start = end = y
|
||||
result.append((x1, start, z1, x2, end, z2, block))
|
||||
return result
|
||||
|
||||
def manifest(v):
|
||||
tiles = defaultdict(dict)
|
||||
for p, block in v.blocks.items():
|
||||
tiles[(p[1] // 8, p[2] // 16, p[0] // 16)][p] = block
|
||||
batches = []
|
||||
for key, data in sorted(tiles.items()):
|
||||
recipe = []
|
||||
for x1, y1, z1, x2, y2, z2, block in boxes(data):
|
||||
lo = dict(zip(('x', 'y', 'z'), (x1 + ORIGIN[0], y1 + ORIGIN[1], z1 + ORIGIN[2])))
|
||||
hi = dict(zip(('x', 'y', 'z'), (x2 + ORIGIN[0], y2 + ORIGIN[1], z2 + ORIGIN[2])))
|
||||
recipe.append({'type': 'box', 'min': lo, 'max': hi, 'block': block})
|
||||
positions = list(data)
|
||||
minimum = [min(p[i] for p in positions) + ORIGIN[i] for i in range(3)]
|
||||
maximum = [max(p[i] for p in positions) + ORIGIN[i] for i in range(3)]
|
||||
batches.append({'key': '-'.join(map(str, key)), 'blocks': len(data),
|
||||
'min': dict(zip(('x', 'y', 'z'), minimum)), 'max': dict(zip(('x', 'y', 'z'), maximum)),
|
||||
'recipe': {'version': 1, 'operations': recipe},
|
||||
'stages': dict(Counter(v.owners[p] for p in data))})
|
||||
return {'name': 'Gothic hall reference reconstruction', 'origin': ORIGIN,
|
||||
'blocks': len(v.blocks), 'stages': dict(Counter(v.owners.values())),
|
||||
'palette': dict(Counter(v.blocks.values())), 'batches': batches}
|
||||
@@ -1,176 +0,0 @@
|
||||
"""The smaller, two-storey gabled companion to the main Gothic hall.
|
||||
|
||||
Coordinates are inclusive and local to the complete composition. ``build``
|
||||
only writes through the supplied voxel API; it does not contact Minecraft.
|
||||
"""
|
||||
|
||||
STONE = "minecraft:stone_bricks"
|
||||
TRIM = "minecraft:polished_andesite"
|
||||
LIGHT = "minecraft:polished_diorite"
|
||||
GLASS = "minecraft:tinted_glass"
|
||||
WOOD = "minecraft:spruce_planks"
|
||||
BEAM = "minecraft:stripped_spruce_log[axis=y]"
|
||||
ROOF = "minecraft:deepslate_tiles"
|
||||
AIR = "minecraft:air"
|
||||
|
||||
|
||||
def _stair(material, facing, half="bottom"):
|
||||
return (
|
||||
f"minecraft:{material}[facing={facing},half={half},"
|
||||
"shape=straight,waterlogged=false]"
|
||||
)
|
||||
|
||||
|
||||
def _wall_block(x, y, z):
|
||||
"""Sparse, deterministic masonry variation, without a checkerboard."""
|
||||
n = (x * 31 + y * 17 + z * 47 + x * z * 3) % 53
|
||||
if n < 3:
|
||||
return "minecraft:cracked_stone_bricks"
|
||||
if n < 8:
|
||||
return "minecraft:andesite"
|
||||
return STONE
|
||||
|
||||
|
||||
def _lancet(v, left, right, bottom, top, wall, outside, north=False):
|
||||
"""A stone pointed surround, open reveal, and recessed dark glass."""
|
||||
center = (left + right) / 2
|
||||
|
||||
def point(u, y, depth, block):
|
||||
if north:
|
||||
v.set(u, y, depth, block)
|
||||
else:
|
||||
v.set(depth, y, u, block)
|
||||
|
||||
for u in range(left - 1, right + 2):
|
||||
cap = top + 1 - int(abs(u - center))
|
||||
for y in range(bottom - 1, cap + 1):
|
||||
point(u, y, outside, LIGHT if y == cap else TRIM)
|
||||
for u in range(left, right + 1):
|
||||
cap = top - int(abs(u - center))
|
||||
for y in range(bottom, cap + 1):
|
||||
point(u, y, outside, AIR)
|
||||
point(u, y, wall, GLASS)
|
||||
# The sill projects one block; the glass stays in the wall behind it.
|
||||
for u in range(left - 1, right + 2):
|
||||
point(u, bottom - 1, outside, LIGHT)
|
||||
|
||||
|
||||
def _buttress(v, west, z):
|
||||
"""Three diminishing stages, with projecting pale weathering caps."""
|
||||
outer, inner = (36, 38) if west else (57, 59)
|
||||
v.box(outer, 0, z - 1, inner, 1, z + 1, TRIM)
|
||||
a, b = (37, 38) if west else (57, 58)
|
||||
v.box(a, 2, z - 1, b, 6, z + 1, STONE)
|
||||
v.box(outer, 7, z - 1, inner, 7, z + 1, LIGHT)
|
||||
v.box(a, 8, z, b, 14, z, STONE)
|
||||
v.box(a, 15, z - 1, b, 15, z + 1, TRIM)
|
||||
v.box(a, 16, z, b, 18, z, STONE)
|
||||
v.box(a, 19, z, b, 19, z, LIGHT)
|
||||
|
||||
|
||||
def _entrance(v):
|
||||
# Two nested pointed archivolts give the entrance visible depth.
|
||||
for z, left, right, peak, block in (
|
||||
(14, 44, 51, 8, TRIM),
|
||||
(15, 45, 50, 7, LIGHT),
|
||||
):
|
||||
for x in range(left, right + 1):
|
||||
cap = peak - int(abs(x - 47.5))
|
||||
v.box(x, 1, z, x, cap, z, block)
|
||||
for x in range(46, 50):
|
||||
cap = 6 - int(abs(x - 47.5))
|
||||
v.clear(x, 1, 14, x, cap, 17)
|
||||
# Timber inner jambs suggest the tall wooden doorway, leaving it usable.
|
||||
v.box(45, 1, 16, 45, 4, 16, BEAM)
|
||||
v.box(50, 1, 16, 50, 4, 16, BEAM)
|
||||
v.box(44, 0, 13, 51, 0, 16, TRIM)
|
||||
v.box(46, 0, 14, 49, 0, 17, "minecraft:smooth_stone")
|
||||
|
||||
|
||||
def build(v):
|
||||
"""Build within x=36..59, y=0..28, z=13..43."""
|
||||
# Hollow masonry shell, an accessible ground floor and an upper floor.
|
||||
v.box(38, 0, 16, 57, 0, 41, TRIM)
|
||||
v.clear(39, 1, 17, 56, 27, 40)
|
||||
for y in range(1, 18):
|
||||
for x in range(38, 58):
|
||||
for z in (16, 41):
|
||||
v.set(x, y, z, _wall_block(x, y, z))
|
||||
for z in range(17, 41):
|
||||
for x in (38, 57):
|
||||
v.set(x, y, z, _wall_block(x, y, z))
|
||||
v.box(39, 0, 17, 56, 0, 40, WOOD)
|
||||
v.box(39, 8, 17, 56, 8, 40, WOOD)
|
||||
|
||||
# Floor bands and the projecting eaves visually join the two storeys.
|
||||
for y, block in ((0, TRIM), (7, TRIM), (8, LIGHT), (16, TRIM)):
|
||||
v.box(37, y, 15, 58, y, 15, block)
|
||||
v.box(37, y, 42, 58, y, 42, block)
|
||||
v.box(58, y, 16, 58, y, 41, block)
|
||||
for z in range(16, 42):
|
||||
# The main composition will connect the gallery through here.
|
||||
if not (7 <= y <= 11 and 36 <= z <= 39):
|
||||
v.set(37, y, z, block)
|
||||
|
||||
for z in (17, 25, 33, 41):
|
||||
_buttress(v, True, z)
|
||||
_buttress(v, False, z)
|
||||
for x in (38, 57):
|
||||
v.box(x - 1, 0, 14, x + 1, 1, 16, TRIM)
|
||||
v.box(x, 2, 14, x, 16, 16, STONE)
|
||||
v.box(x - 1, 17, 14, x + 1, 17, 16, LIGHT)
|
||||
v.box(x, 18, 15, x, 20, 15, STONE)
|
||||
v.set(x, 21, 15, LIGHT)
|
||||
|
||||
# A paired rhythm of small lower and tall upper side windows.
|
||||
for z in (21, 29, 37):
|
||||
for wall, outside in ((38, 37), (57, 58)):
|
||||
if wall == 38 and z == 37:
|
||||
continue # Keep the gallery doorway and its approach clear.
|
||||
_lancet(v, z - 1, z + 1, 2, 6, wall, outside)
|
||||
_lancet(v, z - 1, z + 1, 10, 15, wall, outside)
|
||||
for left, right in ((41, 42), (53, 54)):
|
||||
_lancet(v, left, right, 2, 6, 16, 15, north=True)
|
||||
for left, right in ((42, 45), (50, 53)):
|
||||
_lancet(v, left, right, 10, 15, 16, 15, north=True)
|
||||
_entrance(v)
|
||||
|
||||
# Timber supports remain against the edges, leaving the rooms traversable.
|
||||
for x in (40, 55):
|
||||
for z in (18, 39):
|
||||
v.box(x, 1, z, x, 7, z, BEAM)
|
||||
for z in (23, 31, 39):
|
||||
v.box(39, 7, z, 56, 7, z, "minecraft:stripped_spruce_log[axis=x]")
|
||||
v.clear(53, 8, 28, 55, 11, 35)
|
||||
for step in range(8):
|
||||
y, z = step + 1, 28 + step
|
||||
if y > 1:
|
||||
v.box(53, 1, z, 55, y - 1, z, WOOD)
|
||||
v.box(53, y, z, 55, y, z, _stair("spruce_stairs", "south"))
|
||||
v.clear(53, y + 1, z, 55, y + 2, z)
|
||||
v.box(52, 9, 28, 52, 9, 35, "minecraft:spruce_slab[type=bottom,waterlogged=false]")
|
||||
|
||||
# A steep roof, dark in the middle and bounded by pale stepped gables.
|
||||
for x in range(37, 59):
|
||||
rise = min(x - 37, 58 - x)
|
||||
y = 17 + rise
|
||||
facing = "east" if x <= 47 else "west"
|
||||
if 38 <= x <= 57 and y >= 18:
|
||||
for z in (16, 41):
|
||||
v.box(x, 18, z, x, y, z, STONE)
|
||||
v.box(x, y, 15, x, y, 42, _stair("deepslate_tile_stairs", facing))
|
||||
for z in (14, 43):
|
||||
v.set(x, y, z, _stair("stone_brick_stairs", facing))
|
||||
if y > 17:
|
||||
v.set(x, y - 1, z, TRIM)
|
||||
v.box(47, 28, 15, 48, 28, 42, ROOF)
|
||||
for z in (14, 43):
|
||||
v.box(47, 27, z, 48, 28, z, LIGHT)
|
||||
_lancet(v, 46, 49, 19, 24, 16, 15, north=True)
|
||||
for x in (47, 48):
|
||||
v.box(x, 26, 15, x, 28, 15, TRIM)
|
||||
|
||||
# Wooden cornice brackets are visible under the long roof slopes.
|
||||
for z in (19, 23, 27, 31, 35, 39):
|
||||
for x, facing in ((37, "east"), (58, "west")):
|
||||
v.set(x, 16, z, _stair("spruce_stairs", facing, half="top"))
|
||||
@@ -1,190 +0,0 @@
|
||||
"""Reference bell tower, in the shared scene's inclusive local coordinates.
|
||||
|
||||
The scene supplies set(x, y, z, block), box(x1, y1, z1, x2, y2, z2,
|
||||
block), and clear(x1, y1, z1, x2, y2, z2). This module has no I/O.
|
||||
All geometry stays inside x=34..46, y=0..60, z=42..56.
|
||||
"""
|
||||
|
||||
|
||||
STONE = "minecraft:stone_bricks"
|
||||
TRIM = "minecraft:polished_andesite"
|
||||
LIGHT = "minecraft:polished_diorite"
|
||||
DARK = "minecraft:deepslate_tiles"
|
||||
GLASS = "minecraft:tinted_glass"
|
||||
AIR = "minecraft:air"
|
||||
|
||||
|
||||
def _face(face, u, y, depth=0):
|
||||
"""Positive depth projects outward; -1 is the recessed glazing plane."""
|
||||
if face == "north":
|
||||
return u, y, 43 - depth
|
||||
if face == "south":
|
||||
return u, y, 55 + depth
|
||||
if face == "west":
|
||||
return 35 - depth, y, u
|
||||
return 45 + depth, y, u
|
||||
|
||||
|
||||
def _put(v, face, u, y, block, depth=0):
|
||||
v.set(*_face(face, u, y, depth), block)
|
||||
|
||||
|
||||
def _ring(v, x1, z1, x2, z2, y, block):
|
||||
v.box(x1, y, z1, x2, y, z1, block)
|
||||
v.box(x1, y, z2, x2, y, z2, block)
|
||||
v.box(x1, y, z1, x1, y, z2, block)
|
||||
v.box(x2, y, z1, x2, y, z2, block)
|
||||
|
||||
|
||||
def _lancet(v, face, center, bottom, top, half_width):
|
||||
"""A pointed opening through a two-block wall, glass one block behind it."""
|
||||
for y in range(bottom, top + 1):
|
||||
radius = 0 if y >= top - 1 else half_width
|
||||
for u in range(center - radius, center + radius + 1):
|
||||
_put(v, face, u, y, AIR)
|
||||
_put(v, face, u, y, GLASS, -1)
|
||||
# The projecting dressings are stone; the glazing never projects.
|
||||
for u in {center - radius - 1, center + radius + 1}:
|
||||
_put(v, face, u, y, TRIM, 1)
|
||||
for u in range(center - half_width - 1, center + half_width + 2):
|
||||
_put(v, face, u, bottom - 1, "minecraft:stone_brick_slab[type=top]", 1)
|
||||
_put(v, face, center, top + 1, LIGHT, 1)
|
||||
|
||||
|
||||
def _belfry_arch(v, face, center):
|
||||
# Seven blocks wide below, narrowing to a high, single-block apex.
|
||||
for y in range(32, 41):
|
||||
radius = 3 if y <= 37 else 40 - y
|
||||
for u in range(center - radius, center + radius + 1):
|
||||
for depth in (0, -1):
|
||||
_put(v, face, u, y, AIR, depth)
|
||||
for u in (center - radius - 1, center + radius + 1):
|
||||
_put(v, face, u, y, TRIM, 1)
|
||||
_put(v, face, center, 41, LIGHT, 1)
|
||||
for u in range(center - 4, center + 5):
|
||||
_put(v, face, u, 31, "minecraft:stone_brick_slab[type=top]", 1)
|
||||
|
||||
|
||||
def _stairs(v):
|
||||
# Three narrow alternating flights join the interior floors. A landing at
|
||||
# each end leaves a three-block-wide central well open through the tower.
|
||||
for y_floor, xs, reverse in ((1, (37, 38), False),
|
||||
(10, (42, 43), True),
|
||||
(19, (37, 38), False)):
|
||||
facing = "north" if reverse else "south"
|
||||
state = f"minecraft:stone_brick_stairs[facing={facing},half=bottom,shape=straight]"
|
||||
for i in range(9):
|
||||
z = 53 - i if reverse else 45 + i
|
||||
y = y_floor + i + 1
|
||||
for x in xs:
|
||||
# Clearance through the destination floor, including headroom.
|
||||
v.clear(x, y + 1, z, x, y + 3, z)
|
||||
v.set(x, y, z, state)
|
||||
# The flight exits sideways onto the destination floor.
|
||||
v.clear(39, 29, 53, 41, 32, 53)
|
||||
v.set(41, 29, 52, "minecraft:stone_brick_stairs[facing=north]")
|
||||
v.set(41, 30, 51, "minecraft:stone_brick_stairs[facing=north]")
|
||||
v.clear(41, 30, 52, 41, 33, 52)
|
||||
v.clear(41, 31, 51, 41, 33, 51)
|
||||
|
||||
|
||||
def _spire(v):
|
||||
# A hollow, steep rectangular hip roof; alternate contractions avoid a
|
||||
# stack of disconnected floating rings while giving the spire a fine tip.
|
||||
for y in range(45, 57):
|
||||
step = y - 45
|
||||
rx = 5 - step // 2
|
||||
rz = 6 - (step + 1) // 2
|
||||
if rx < 0:
|
||||
rx = 0
|
||||
if rz < 0:
|
||||
rz = 0
|
||||
x1, x2, z1, z2 = 40 - rx, 40 + rx, 49 - rz, 49 + rz
|
||||
v.box(x1, y, z1, x2, y, z2, DARK)
|
||||
if rx > 0 and rz > 0:
|
||||
v.clear(x1 + 1, y, z1 + 1, x2 - 1, y, z2 - 1)
|
||||
# Stair edges soften the one-block roof steps without adding width.
|
||||
if rx > 0 and rz > 0:
|
||||
for x in range(x1 + 1, x2):
|
||||
v.set(x, y, z1, "minecraft:deepslate_tile_stairs[facing=south]")
|
||||
v.set(x, y, z2, "minecraft:deepslate_tile_stairs[facing=north]")
|
||||
for z in range(z1 + 1, z2):
|
||||
v.set(x1, y, z, "minecraft:deepslate_tile_stairs[facing=east]")
|
||||
v.set(x2, y, z, "minecraft:deepslate_tile_stairs[facing=west]")
|
||||
# The cross is plain masonry, with no unsupported entities or thin blocks.
|
||||
v.box(40, 57, 49, 40, 60, 49, TRIM)
|
||||
v.box(39, 59, 49, 41, 59, 49, TRIM)
|
||||
|
||||
|
||||
def build(v):
|
||||
"""Add a hollow Gothic bell tower and its pointed roof to a scene."""
|
||||
# Foundation and shell: two-block walls give the apertures real reveals.
|
||||
v.box(35, 0, 43, 45, 43, 55, STONE)
|
||||
v.clear(37, 2, 45, 43, 42, 53)
|
||||
_ring(v, 34, 42, 46, 56, 0, "minecraft:deepslate_bricks")
|
||||
_ring(v, 34, 42, 46, 56, 1, TRIM)
|
||||
_ring(v, 34, 42, 46, 56, 3, "minecraft:stone_brick_slab[type=bottom]")
|
||||
|
||||
# Dressed corners and stepped buttress feet, all within the tower envelope.
|
||||
corners = ((34, 42, 35, 43), (45, 42, 46, 43),
|
||||
(34, 55, 35, 56), (45, 55, 46, 56))
|
||||
for x1, z1, x2, z2 in corners:
|
||||
v.box(x1, 1, z1, x2, 5, z2, TRIM)
|
||||
v.box(x1, 6, z1, x2, 29, z2, STONE)
|
||||
for y in (6, 15, 28):
|
||||
v.box(x1, y, z1, x2, y, z2, TRIM)
|
||||
for x, z in ((35, 43), (45, 43), (35, 55), (45, 55)):
|
||||
v.box(x, 4, z, x, 43, z, TRIM)
|
||||
|
||||
# Strong horizontal courses separate the shaft from the open belfry.
|
||||
for y in (15, 16, 29, 30, 42, 43):
|
||||
_ring(v, 34, 42, 46, 56, y, TRIM if y % 2 else STONE)
|
||||
for y in (10, 19, 28, 30):
|
||||
v.box(37, y, 45, 43, y, 53, "minecraft:oak_planks")
|
||||
if y != 30:
|
||||
v.clear(39, y, 47, 41, y, 51)
|
||||
v.box(35, 44, 43, 45, 44, 55, STONE)
|
||||
_ring(v, 34, 42, 46, 56, 44, "minecraft:stone_brick_slab[type=bottom]")
|
||||
|
||||
# Paired narrow lower lights and broader, pointed upper lancets.
|
||||
for face, centers in (("north", (38, 42)), ("south", (38, 42)),
|
||||
("west", (47, 51)), ("east", (47, 51))):
|
||||
for center in centers:
|
||||
_lancet(v, face, center, 6, 12, 0)
|
||||
_lancet(v, face, center, 19, 26, 1)
|
||||
_belfry_arch(v, face, 40 if face in ("north", "south") else 49)
|
||||
|
||||
# An actual walk-in doorway toward the wing; no decorative solid door.
|
||||
v.clear(39, 2, 42, 41, 5, 44)
|
||||
v.clear(40, 6, 42, 40, 6, 44)
|
||||
for x in (38, 42):
|
||||
v.box(x, 2, 42, x, 5, 42, TRIM)
|
||||
v.set(39, 6, 42, TRIM)
|
||||
v.set(41, 6, 42, TRIM)
|
||||
v.set(40, 7, 42, LIGHT)
|
||||
_stairs(v)
|
||||
|
||||
# A block-built ochre bell echoes the reference using allowlisted sandstone.
|
||||
# Its open mouth and central clapper remain visible through all four arches.
|
||||
v.box(35, 41, 49, 45, 41, 49, "minecraft:spruce_log[axis=x]")
|
||||
v.box(40, 38, 49, 40, 40, 49, "minecraft:dark_oak_log[axis=y]")
|
||||
v.box(40, 36, 49, 40, 37, 49, "minecraft:cut_sandstone")
|
||||
for x, z in ((39, 49), (40, 48), (40, 49), (40, 50), (41, 49)):
|
||||
v.set(x, 35, z, "minecraft:cut_sandstone")
|
||||
_ring(v, 39, 48, 41, 50, 34, "minecraft:cut_sandstone")
|
||||
v.set(40, 34, 49, "minecraft:dark_oak_log[axis=y]")
|
||||
v.set(40, 33, 49, TRIM)
|
||||
|
||||
# A row of small corbels under the cornice is readable from a distance.
|
||||
for face, us in (("north", (37, 39, 41, 43)), ("south", (37, 39, 41, 43)),
|
||||
("west", (45, 47, 49, 51, 53)), ("east", (45, 47, 49, 51, 53))):
|
||||
for u in us:
|
||||
_put(v, face, u, 41, f"minecraft:stone_brick_stairs[facing={face},half=top]", 1)
|
||||
_spire(v)
|
||||
|
||||
# Four light corner pinnacles frame the darker central spire.
|
||||
for x, z in ((35, 43), (45, 43), (35, 55), (45, 55)):
|
||||
v.box(x, 44, z, x, 49, z, TRIM)
|
||||
for dx, dz in ((-1, 0), (1, 0), (0, -1), (0, 1)):
|
||||
v.set(x + dx, 46, z + dz, "minecraft:stone_brick_slab[type=top]")
|
||||
v.set(x, 50, z, "minecraft:stone_brick_slab[type=bottom]")
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Capture an already positioned spectator through the authenticated local worker.
|
||||
|
||||
The server/operator must teleport the observer first. This helper cannot teleport
|
||||
or edit a world and does not extend the MCP project's authorized world boundary.
|
||||
Useful for operator-side checks of separate initial-generation worlds. The normal
|
||||
MCP camera_capture tool continues to use Paper's project-scoped route.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
RUNTIME = Path(os.environ.get('MCB_RUNTIME_DIR') or ROOT / '.runtime').expanduser().resolve()
|
||||
# Share bounded transport/PNG checks; importing this module does not run its test.
|
||||
spec = importlib.util.spec_from_file_location('camera_checks', ROOT / 'scripts/live-camera-test.py')
|
||||
checks = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(checks)
|
||||
|
||||
|
||||
def validate_request(pose):
|
||||
"""Validate locally before reading credentials or sending a capture request."""
|
||||
values = [pose.get(key) for key in ('x', 'y', 'z', 'yaw', 'pitch')]
|
||||
if any(type(value) not in (int, float) for value in values):
|
||||
raise checks.TestFailure('Pose values must all be numbers.')
|
||||
checks.validate_pose(values)
|
||||
dimension = pose.get('dimension')
|
||||
if not isinstance(dimension, str) or len(dimension) > 128 or not re.fullmatch(r'[a-z0-9_.-]+:[a-z0-9_./-]+', dimension):
|
||||
raise checks.TestFailure('Dimension must be a namespaced Minecraft dimension key.')
|
||||
for key, fallback, low, high in (('fov', 70, 30, 110), ('width', 1280, 320, 1920), ('height', 720, 180, 1080)):
|
||||
value = pose.get(key, fallback)
|
||||
if type(value) is not int or not low <= value <= high:
|
||||
raise checks.TestFailure(f'{key} must be an integer in {low}..{high}.')
|
||||
|
||||
|
||||
def validated_image(result, capture_id, pose, token, started):
|
||||
if result.get('captureId') != capture_id or result.get('mimeType') != 'image/png':
|
||||
raise checks.TestFailure('Completed capture identity or MIME type did not match.')
|
||||
if result.get('dimension') != pose['dimension']:
|
||||
raise checks.TestFailure('Completed capture came from a different dimension.')
|
||||
captured_at = checks.instant(result.get('capturedAt'), 'Capture timestamp')
|
||||
if captured_at < started - 1 or captured_at > time.time() + 5:
|
||||
raise checks.TestFailure('The frame timestamp does not correspond to this capture request.')
|
||||
encoded = result.get('imageBase64')
|
||||
if not isinstance(encoded, str) or len(encoded) > 12_000_000:
|
||||
raise checks.TestFailure('Completed capture did not include a bounded image payload.')
|
||||
try:
|
||||
raw = base64.b64decode(encoded, validate=True)
|
||||
except ValueError:
|
||||
raise checks.TestFailure('Completed capture contained invalid image encoding.') from None
|
||||
width, height = checks.inspect_png(raw)
|
||||
if result.get('width') != width or result.get('height') != height:
|
||||
raise checks.TestFailure('PNG dimensions disagree with the capture metadata.')
|
||||
metadata = {key: value for key, value in result.items() if key in checks.SAFE_METADATA}
|
||||
if any(not isinstance(value, (str, int, float, bool, type(None))) or isinstance(value, str) and len(value) > 1024
|
||||
for value in metadata.values()):
|
||||
raise checks.TestFailure('Capture metadata contains unexpected values.')
|
||||
metadata.update(operator_direct_worker=True, imageSha256=hashlib.sha256(raw).hexdigest(), imageBytes=len(raw))
|
||||
try:
|
||||
receipt = json.dumps(metadata, indent=2, allow_nan=False) + '\n'
|
||||
except ValueError:
|
||||
raise checks.TestFailure('Capture metadata contains nonfinite values.') from None
|
||||
if token in receipt:
|
||||
raise checks.TestFailure('Refusing to save metadata containing a component secret.')
|
||||
return raw, receipt
|
||||
|
||||
|
||||
def capture(config, pose, output):
|
||||
validate_request(pose)
|
||||
try:
|
||||
source = Path(config).expanduser().read_text()
|
||||
token = checks.scalar(source, 'camera-token')
|
||||
port = int(checks.scalar(source, 'camera-port'))
|
||||
except (OSError, UnicodeError, ValueError):
|
||||
raise checks.TestFailure('Cannot read a valid private camera configuration.') from None
|
||||
if not 1024 <= port <= 65535 or not re.fullmatch(r'[A-Za-z0-9._~-]{32,512}', token):
|
||||
raise checks.TestFailure('Invalid private camera configuration.')
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), checks.NoRedirect())
|
||||
endpoint = f'http://127.0.0.1:{port}'
|
||||
started, deadline = time.time(), time.monotonic() + 30
|
||||
result = checks.request_json(opener, endpoint + '/v1/capture', token, pose, timeout=25)
|
||||
capture_id = checks.identifier(result.get('captureId'), 'Capture ID')
|
||||
while result.get('status') in ('pending', 'queued', 'capturing'):
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise checks.TestFailure('Camera did not complete in time.')
|
||||
time.sleep(min(.3, remaining))
|
||||
result = checks.request_json(opener, endpoint + '/v1/captures/' + capture_id, token, timeout=min(10, remaining))
|
||||
if result.get('captureId') != capture_id:
|
||||
raise checks.TestFailure('Camera returned a different capture identity.')
|
||||
if result.get('status') != 'completed':
|
||||
code = result.get('error', '')
|
||||
safe_code = code if isinstance(code, str) and re.fullmatch(r'[a-z_]{1,64}', code) else 'capture_failed'
|
||||
raise checks.TestFailure('Camera unavailable: ' + safe_code)
|
||||
raw, receipt = validated_image(result, capture_id, pose, token, started)
|
||||
output = Path(output).expanduser()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_bytes(raw)
|
||||
output.with_suffix('.capture.json').write_text(receipt)
|
||||
print(json.dumps({'path': str(output.resolve()), 'dimension': result.get('dimension'), 'status': 'completed'}))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--config', type=Path, default=Path(os.environ.get('MCB_CAMERA_PAPER_CONFIG') or RUNTIME / 'server/plugins/MinecraftBuilderMCP/config.yml'))
|
||||
p.add_argument('--pose', type=float, nargs=5, required=True, metavar=('X', 'Y', 'Z', 'YAW', 'PITCH'))
|
||||
p.add_argument('--dimension', required=True)
|
||||
p.add_argument('--fov', type=int, default=80)
|
||||
p.add_argument('--out', type=Path, required=True)
|
||||
a = p.parse_args()
|
||||
try:
|
||||
capture(a.config, dict(zip(('x', 'y', 'z', 'yaw', 'pitch'), a.pose)) | {'dimension': a.dimension, 'fov': a.fov, 'width': 1280, 'height': 720}, a.out)
|
||||
except checks.TestFailure as error:
|
||||
raise SystemExit(str(error)) from None
|
||||
@@ -6,11 +6,12 @@ import re
|
||||
import sys
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
RUNTIME = Path(os.environ.get('MCB_RUNTIME_DIR') or ROOT / '.runtime').expanduser().resolve()
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
raise SystemExit('This helper is a Prism WrapperCommand; it requires the Java command.')
|
||||
config = Path(os.environ.get('MCB_CAMERA_PAPER_CONFIG', str(ROOT / '.runtime/server/plugins/MinecraftBuilderMCP/config.yml')))
|
||||
config = Path(os.environ.get('MCB_CAMERA_PAPER_CONFIG') or RUNTIME / 'server/plugins/MinecraftBuilderMCP/config.yml').expanduser()
|
||||
try:
|
||||
data = config.read_text()
|
||||
token_match = re.search(r'^camera-token:[ \t]*([^\r\n]+)$', data, re.M)
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compile audited access refinements against the original survey plus placed markers."""
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
spec=importlib.util.spec_from_file_location('mark',ROOT/'scripts/mark-shacraft-layout.py')
|
||||
mark=importlib.util.module_from_spec(spec);spec.loader.exec_module(mark)
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser();p.add_argument('--before',type=Path,required=True)
|
||||
p.add_argument('--base',type=Path,required=True);p.add_argument('--study',type=Path,required=True)
|
||||
p.add_argument('--access',type=Path,required=True);p.add_argument('--output',type=Path,required=True)
|
||||
a=p.parse_args();before=json.loads(a.before.read_text());base=json.loads(a.base.read_text())
|
||||
study=json.loads(a.study.read_text());access=json.loads(a.access.read_text());desired={}
|
||||
old={(b['x'],b['y'],b['z']):b['block'] for b in base['blocks']}
|
||||
def ground(x,z):
|
||||
i=(z-before['min_z'])*before['width']+x-before['min_x']
|
||||
return before['surface_y'][i],before['palette'][before['material_index'][i]]
|
||||
def put(x,z,block,group,y=None):
|
||||
x,z=round(x),round(z);h,material=ground(x,z);y=max(h,51) if y is None else y
|
||||
if y<h:raise ValueError('Refusing subsurface edit')
|
||||
if material=='minecraft:grass_block':material+='[snowy=false]'
|
||||
key=(x,y,z);expected=old.get(key,material if y==h else 'minecraft:air')
|
||||
if expected=='minecraft:water':raise ValueError('Refusing water edit')
|
||||
if expected==block:return
|
||||
desired[key]={'x':x,'y':y,'z':z,'block':block,'expected':expected,'group':group}
|
||||
labels=[]
|
||||
for route in access['routes']:
|
||||
pts=route['points'];color='minecraft:white_concrete'
|
||||
for side in (-1,1):
|
||||
for x,z in mark.raster_line(mark.offset(pts,side),.6):put(x,z,color,route['id'])
|
||||
if route['role']=='stairs':
|
||||
for i in range(0,len(pts),6):
|
||||
aa=mark.offset(pts,-1)[i];bb=mark.offset(pts,1)[i]
|
||||
for x,z in mark.raster_line([aa,bb],.6):put(x,z,'minecraft:blue_concrete',route['id'])
|
||||
for label in route.get('labels',[]):
|
||||
x,z=label['point'];h,_=ground(x,z)
|
||||
labels.append({'x':x+.5,'y':h+5,'z':z+.5,'text':label['text']})
|
||||
for route in study['routes']:
|
||||
if route['role']!='bridge':continue
|
||||
pts=route['points'];deck=route['deck_y']
|
||||
for side in (-1,1):
|
||||
for end in (0,-1):
|
||||
x,z=map(round,mark.offset(pts,side*(route['width']-1)/2)[end]);h,_=ground(x,z)
|
||||
for y in range(max(50,h+1),deck+1):put(x,z,'minecraft:white_concrete',route['id']+'-height-stakes',y)
|
||||
for abutment in route.get('abutments',[]):
|
||||
if abutment['future_stairs']:
|
||||
x,z=abutment['point'];h,_=ground(x,z)
|
||||
labels.append({'x':x+.5,'y':deck+5,'z':z+.5,
|
||||
'text':f"FUTURE STAIRS / +{deck-h}m\nDECK Y{deck}"})
|
||||
# The raised pier reservations meet a future upper terminal level.
|
||||
for z in (-158,-132,-106):labels.append({'x':161.5,'y':83,'z':z+.5,'text':'PIER DECK Y79 / FUTURE ACCESS'})
|
||||
data={'version':1,'scope':base['scope'],'blocks':sorted(desired.values(),key=lambda b:(b['z'],b['x'],b['y']))}
|
||||
a.output.write_text(json.dumps(data,separators=(',',':'))+'\n')
|
||||
a.output.with_suffix('.labels.json').write_text(json.dumps(labels,indent=2)+'\n')
|
||||
print(json.dumps({'access_blocks':len(desired),'labels':len(labels)}))
|
||||
|
||||
if __name__=='__main__':main()
|
||||
@@ -11,12 +11,13 @@ import subprocess
|
||||
import urllib.request
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
RUNTIME=Path(os.environ.get('MCB_RUNTIME_DIR') or ROOT/'.runtime').expanduser().resolve()
|
||||
CACHE=Path(os.environ.get('MCB_TOOL_CACHE', str(Path.home()/'.cache/minecraft-builder-mcp')))
|
||||
URL='https://fill-data.papermc.io/v1/objects/7b7b3b43c009103e1971a0576c26f655a7dd9b56a0a2a4438e352c03a7fecd08/paper-26.2-123.jar'
|
||||
SHA='7b7b3b43c009103e1971a0576c26f655a7dd9b56a0a2a4438e352c03a7fecd08'
|
||||
|
||||
def prepare():
|
||||
server=ROOT/'.runtime/server'
|
||||
server=RUNTIME/'server'
|
||||
server.mkdir(parents=True, exist_ok=True)
|
||||
jar=server/'paper.jar'
|
||||
if not jar.exists():
|
||||
@@ -26,7 +27,7 @@ def prepare():
|
||||
shutil.copyfileobj(source,out)
|
||||
tmp.replace(jar)
|
||||
if hashlib.sha256(jar.read_bytes()).hexdigest()!=SHA:
|
||||
raise SystemExit('Paper SHA256 mismatch; remove .runtime/server/paper.jar and retry')
|
||||
raise SystemExit(f'Paper SHA256 mismatch; remove {jar} and retry')
|
||||
plugin=ROOT/'paper-plugin/target/paper-plugin-0.1.0-SNAPSHOT.jar'
|
||||
if not plugin.exists(): raise SystemExit('Build the plugin first: ./mvnw package')
|
||||
(server/'plugins').mkdir(exist_ok=True)
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,174 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compile small arrival details and a separately reversible invisible enclosure.
|
||||
|
||||
Reads observed states only; apply the two recipes through scripts/layout.py.
|
||||
The normal-player boundary has a full floor, side membrane and transparent roof.
|
||||
Existing full stone cells can form the membrane; partial collision shapes cannot.
|
||||
"""
|
||||
import argparse
|
||||
from collections import Counter
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
STAGE = ROOT / '.runtime/zone01-stage05'
|
||||
AIR = 'minecraft:air'
|
||||
BARRIER = 'minecraft:barrier[waterlogged=false]'
|
||||
N = ((1, 0), (-1, 0), (0, 1), (0, -1))
|
||||
|
||||
|
||||
def module(name, path):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
value = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(value)
|
||||
return value
|
||||
|
||||
|
||||
survey = module('zone01_survey', ROOT / 'scripts/foundation-survey.py')
|
||||
geometry = module('zone01_geometry', ROOT / 'scripts/foundation-study/geometry.py')
|
||||
FULL = survey.FULL | {'waxed_oxidized_cut_copper', 'barrier'}
|
||||
|
||||
|
||||
def full(state):
|
||||
return state.split('[')[0].removeprefix('minecraft:') in FULL
|
||||
|
||||
|
||||
def compile_plans(before):
|
||||
design = json.loads((ROOT / '.runtime/plaza-stage03/design-final.json').read_text())
|
||||
geo = json.loads((ROOT / '.runtime/foundations-stage02/foundations-final.geometry.json').read_text())
|
||||
rails = json.loads((ROOT / '.runtime/balustrade-stage04/balustrade-final.metadata.json').read_text())
|
||||
outer = geometry.polygon_cells(design['outer_hex'])
|
||||
roads = {tuple(p) for r in geo['routes'] for p in r['clear_cells']}
|
||||
details, groups = {}, {}
|
||||
|
||||
def decorate(x, y, z, state, group):
|
||||
at = x, y, z
|
||||
old = before.state(*at)
|
||||
if y >= 96 and (old != AIR or (x, z) in roads):
|
||||
raise ValueError(f'Furniture touches existing decoration or an approach at {at}')
|
||||
if y == 95 and not full(old):
|
||||
raise ValueError(f'Inlay would change a stair or unsupported floor at {at}')
|
||||
details[at] = 'minecraft:'+state
|
||||
groups[at] = group
|
||||
|
||||
urns = [(-10, 35), (10, 35)]
|
||||
for cx, cz in urns:
|
||||
decorate(cx, 96, cz, 'cut_sandstone', 'urn-pedestals')
|
||||
for dx, dz in N:
|
||||
decorate(cx+dx, 96, cz+dz, 'smooth_sandstone_slab[type=bottom,waterlogged=false]', 'urn-plinths')
|
||||
for dx in range(-1, 2):
|
||||
for dz in range(-1, 2):
|
||||
if dx == dz == 0:
|
||||
bowl = 'dirt'
|
||||
elif dx and dz:
|
||||
bowl = 'cut_sandstone'
|
||||
else:
|
||||
facing = 'east' if dx == 1 else 'west' if dx == -1 else 'south' if dz == 1 else 'north'
|
||||
bowl = f'smooth_sandstone_stairs[facing={facing},half=bottom,shape=straight,waterlogged=false]'
|
||||
decorate(cx+dx, 97, cz+dz, bowl, 'urn-bowls')
|
||||
decorate(cx, 98, cz, 'white_tulip', 'urn-flowers')
|
||||
# A low freestanding enamel nameboard; its text display is a separate receipt.
|
||||
decorate(-8, 96, 43, 'cut_sandstone', 'welcome-pedestal')
|
||||
for x in range(-9, -6):
|
||||
decorate(x, 97, 43, 'green_concrete', 'welcome-nameboard')
|
||||
decorate(x, 98, 43, 'waxed_oxidized_cut_copper_slab[type=bottom,waterlogged=false]', 'welcome-coping')
|
||||
thresholds = {
|
||||
'north': [(x, z) for x in range(-4, 5) for z in (-31, -30)],
|
||||
'south': [(x, z) for x in range(-4, 5) for z in (50, 51)],
|
||||
'east': [(x, z) for x in (38, 39) for z in range(13, 20)],
|
||||
'west': [(x, z) for x in (-40, -39) for z in range(16, 21)],
|
||||
'northwest': [(x, z) for x in range(-36, -31) for z in range(-16, -9)
|
||||
if (x, z) in roads and x-z in (-21, -20)],
|
||||
}
|
||||
for name, columns in thresholds.items():
|
||||
for x, z in columns:
|
||||
decorate(x, 95, z, 'smooth_stone', 'flush-threshold-'+name)
|
||||
# Restrained green/brass corner accents stay at the exact paving height.
|
||||
for x, z in (min(columns), max(columns)):
|
||||
decorate(x, 95, z, 'waxed_oxidized_cut_copper', 'threshold-copper-insets')
|
||||
|
||||
# Solid joint piers stop a partial-shaped road rail from carrying a folded
|
||||
# membrane through a whole garden-side balustrade and its connected plants.
|
||||
for x, z in ((-42, 22), (41, 11), (42, 20)):
|
||||
at = x, 96, z
|
||||
if not before.state(*at).startswith('minecraft:stone_brick_wall['):
|
||||
raise ValueError(f'Joint pier baseline changed at {at}')
|
||||
details[at], groups[at] = 'minecraft:cut_sandstone', 'finished-road-joint-piers'
|
||||
|
||||
# Allow the complete plaza, its balustrade and short level approach aprons.
|
||||
near = {(x+dx, z+dz) for x, z in outer for dx in range(-2, 3) for dz in range(-2, 3)}
|
||||
interior = outer | {tuple(p) for p in rails['fence_columns']}
|
||||
interior |= {(c['x'], c['z']) for c in geo['cells'] if c['kind'] == 'full' and c['block_y'] == 95
|
||||
and (c['x'], c['z']) in near}
|
||||
# Fold the six-face membrane inward around partial collision shapes instead
|
||||
# of replacing visible rails, stair treads, plants or fixture overhangs.
|
||||
n6 = ((1, 0, 0), (-1, 0, 0), (0, 1, 0), (0, -1, 0), (0, 0, 1), (0, 0, -1))
|
||||
original_volume = {(x, y, z) for x, z in interior for y in range(96, 116)}
|
||||
volume = set(original_volume)
|
||||
cache = {}
|
||||
def observed(p):
|
||||
if p not in cache:
|
||||
cache[p] = details.get(p, before.state(*p))
|
||||
return cache[p]
|
||||
for _ in range(128):
|
||||
membrane = {(x+dx, y+dy, z+dz) for x, y, z in volume for dx, dy, dz in n6} - volume
|
||||
partial = {p for p in membrane if observed(p) != AIR and not full(observed(p))}
|
||||
if not partial:
|
||||
break
|
||||
remove = {(x+dx, y+dy, z+dz) for x, y, z in partial for dx, dy, dz in n6} & volume
|
||||
if not remove:
|
||||
raise ValueError('Partial-shape boundary cannot be sealed without editing decoration')
|
||||
volume -= remove
|
||||
else:
|
||||
raise ValueError('Boundary folding did not converge')
|
||||
if (0, 96, 43) not in volume:
|
||||
raise ValueError('Spawn is outside the finished enclosure')
|
||||
shell = {(x, z) for x, y, z in membrane if 96 <= y < 116}
|
||||
floor = {p for p in membrane if p[1] == 95}
|
||||
roof = {p for p in membrane if p[1] == 116}
|
||||
walls = membrane - floor - roof
|
||||
barriers, barrier_groups = {}, {}
|
||||
for group, positions in [('invisible-side-wall', walls), ('invisible-floor-gap', floor), ('invisible-roof', roof)]:
|
||||
for at in sorted(positions):
|
||||
state = details.get(at, before.state(*at))
|
||||
if full(state):
|
||||
continue
|
||||
if state != AIR:
|
||||
raise ValueError(f'Enclosure would erase a visible/partial block at {at}: {state}')
|
||||
barriers[at], barrier_groups[at] = BARRIER, group
|
||||
if details.keys() & barriers.keys():
|
||||
raise ValueError('Decoration and containment recipes overlap')
|
||||
def recipe(states, labels):
|
||||
return {'version': 1, 'scope': before.scope, 'blocks': [dict(zip(('x', 'y', 'z'), p)) |
|
||||
{'block': state, 'expected': before.state(*p), 'group': labels[p]}
|
||||
for p, state in sorted(states.items()) if state != before.state(*p)]}
|
||||
finish, envelope = recipe(details, groups), recipe(barriers, barrier_groups)
|
||||
combined = {'version': 1, 'scope': before.scope, 'blocks': finish['blocks']+envelope['blocks']}
|
||||
metadata = {'version': 1, 'scope': before.scope, 'interior_columns': sorted(interior),
|
||||
'shell_columns': sorted(shell), 'floor_y': 95, 'roof_y': 116,
|
||||
'source': {'x': .5, 'y': 96, 'z': 43.5},
|
||||
'interior_excluded_voxels': sorted(original_volume-volume),
|
||||
'decoration': {'urn_centers': urns, 'welcome_plinth': [-8, 43], 'thresholds': thresholds},
|
||||
'barrier_groups': dict(Counter(b['group'] for b in envelope['blocks'])),
|
||||
'detail_groups': dict(Counter(b['group'] for b in finish['blocks'])),
|
||||
'scope_note': 'Static collision enclosure for normal players; spectator and operator teleport/break commands bypass it.'}
|
||||
return finish, envelope, combined, metadata
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--before', type=Path, default=STAGE / 'before.json.gz')
|
||||
parser.add_argument('--output-dir', type=Path, default=STAGE)
|
||||
args = parser.parse_args()
|
||||
result = compile_plans(survey.load_snapshot(args.before))
|
||||
args.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
for name, value in zip(('finishing.json', 'barriers.json', 'combined.json', 'zone-boundary.metadata.json'), result):
|
||||
(args.output_dir/name).write_text(json.dumps(value, separators=(',', ':'))+'\n')
|
||||
print(json.dumps({'details': len(result[0]['blocks']), 'barriers': len(result[1]['blocks']),
|
||||
'interior_columns': len(result[3]['interior_columns']), 'shell_columns': len(result[3]['shell_columns']),
|
||||
'groups': result[3]['barrier_groups']}))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,143 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only elevation study for the first two Shacraft construction districts.
|
||||
|
||||
Writes a reviewable design specification and sections. Does not issue world edits.
|
||||
Run with .runtime/terrain-study/venv/bin/python scripts/foundation-study/design.py.
|
||||
Y convention: floor_y is the block coordinate; full-block walking height is Y + 1.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
import matplotlib
|
||||
matplotlib.use('Agg')
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.path import Path as Polygon
|
||||
from matplotlib.colors import LightSource
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[2]
|
||||
OUT=ROOT/'.runtime/foundation-study/design'
|
||||
OUT.mkdir(parents=True,exist_ok=True)
|
||||
layout=json.loads((ROOT/'examples/layout/shacraft-lobby-layout.json').read_text())
|
||||
actual=json.loads((ROOT/'.runtime/server/plugins/ShacraftTerrain/maps/layout-final.json').read_text())
|
||||
natural=np.floor(np.fromfile(ROOT/'.runtime/terrain-study/natural.f32',dtype='>f4').reshape(768,768)).astype(int)
|
||||
live_heights=np.asarray(actual['surface_y']).reshape(768,768)
|
||||
live_materials=np.asarray(actual['material_index']).reshape(768,768)
|
||||
zz,xx=np.mgrid[-384:384,-384:384]
|
||||
points=np.column_stack([xx.ravel(),zz.ravel()])
|
||||
|
||||
def feature(fid): return next(f for f in layout['features'] if f['id']==fid)
|
||||
def route(fid): return next(f for f in layout['routes'] if f['id']==fid)
|
||||
def polygon_mask(vertices):return Polygon(vertices).contains_points(points,radius=.1).reshape(natural.shape)
|
||||
def metrics(mask,y):
|
||||
h=natural[mask]
|
||||
deltas,counts=np.unique(live_heights[mask]-h,return_counts=True)
|
||||
actual_delta_counts={str(int(k)):int(v) for k,v in zip(deltas,counts)}
|
||||
return {'columns':int(mask.sum()),'live_surface_delta_from_natural_counts':actual_delta_counts,'natural_min_y':int(h.min()),'natural_max_y':int(h.max()),'cut_above_floor_blocks':int(np.maximum(h-y,0).sum()),'fill_above_natural_blocks':int(np.maximum(y-h,0).sum()),'maximum_cut':int(max(0,(h-y).max())),'maximum_fill':int(max(0,(y-h).max()))}
|
||||
|
||||
surfaces=[]
|
||||
for fid,y in [('arrival-hex',95),('station-forecourt',95),('clock-station',98)]:
|
||||
f=feature(fid); mask=polygon_mask(f['points'])
|
||||
surfaces.append({'id':fid,'district':f['district'],'geometry':'polygon','points':f['points'],'floor_y':y,'walk_y':y+1,'support':'Solid to existing ground inside the exact footprint; two-deep structural deck in cut areas. Exterior dark stone masonry, light stone coping, no broad earth platform.','metrics':metrics(mask,y)})
|
||||
|
||||
# Each schedule is expressed in the increasing travel coordinate d, irrespective of
|
||||
# whether the selected world axis increases or decreases along travel. A stair
|
||||
# replaces the current high-level full block; its back points toward the higher
|
||||
# previous row. The next row is one full block lower. This guarantees 0.5 steps.
|
||||
def descent(fid,axis,sign,start,end,y,stairs,final_y,width,waypoints):
|
||||
rows=[]; level=y
|
||||
for a in range(start*sign,end*sign+1):
|
||||
c=a*sign
|
||||
is_stair=c in stairs
|
||||
facing=('north' if axis=='z' else ('west' if sign==1 else 'east'))
|
||||
row={'coordinate':c,'block_y':level,'kind':'stairs' if is_stair else 'full','walk_high_y':level+1,'walk_low_y':level+.5 if is_stair else level+1}
|
||||
if is_stair:row['facing']=facing
|
||||
rows.append(row)
|
||||
if is_stair:level-=1
|
||||
assert level==final_y,(fid,level,final_y)
|
||||
# Walking from higher to lower: flat->stair upper edge is same elevation;
|
||||
# stair upper->lower edge is 0.5, lower->next flat is another 0.5.
|
||||
for a,b in zip(rows,rows[1:]):assert abs(a['walk_low_y']-b['walk_high_y'])<=.5
|
||||
return {'id':fid,'geometry':'road_profile','axis':axis,'travel_sign':sign,'width':width,'width_note':'Clear paving width, with one additional coping block outside either edge; use the existing marked centerline tangent.','waypoints':waypoints,'start_floor_y':y,'end_floor_y':final_y,'rows':rows,'max_walk_step':.5,'scope':'Only this local part is built in stage 01; preserve all later district markers.'}
|
||||
|
||||
roads=[
|
||||
{'id':'station-axis','geometry':'road_flat','width':9,'floor_y':95,'walk_y':96,'waypoints':route('station-axis')['waypoints'],'profile_until_z':-77,'intent':'Continuous flat link into the forecourt; ornamental edge strips outside nine clear blocks.'},
|
||||
descent('south-axis-local','z',1,57,110,95,[58,61,64,67,70,73,76,79,82,86,90,94,98,101,104,107],79,9,[[0,57],[3,97],[1,110]]),
|
||||
descent('portal-radial-local','x',-1,-35,-72,95,[-42,-47,-52,-58,-64,-71],89,7,[[-35,-14],[-67,-43],[-72,-46]]),
|
||||
descent('lake-radial-local','x',-1,-42,-90,95,[-44,-47,-50,-55,-64,-72,-78,-83,-85,-87,-89,-90],83,5,[[-42,18],[-77,27],[-90,32]]),
|
||||
descent('east-radial-local','x',1,42,82,95,[43,47,51,55,60,66,72,78],87,7,[[42,16],[65,10],[82,8]]),
|
||||
descent('ring-northwest-local','x',-1,-40,-80,95,[-42,-45,-49,-53,-57,-61,-65,-69,-73,-77],85,7,[[-40,-65],[-77,-76],[-80,-77]]),
|
||||
descent('ring-northeast-local','x',1,40,82,98,[41,44,47,50,54,58,62,66,70,74,78,81],86,7,[[40,-95],[60,-91],[82,-78]])
|
||||
]
|
||||
# Endpoint toes use actual captured surface elevations. At the lake the last
|
||||
# in-bounds column is a stair: a full block here would leave a full-block drop to
|
||||
# the next native row, and editing that row would exceed the stage's X=-90 bound.
|
||||
endpoint_toes=[]
|
||||
for rid,end in [('portal-radial-local',(-72,-46)),('lake-radial-local',(-90,32))]:
|
||||
r=next(item for item in roads if item['id']==rid)
|
||||
last=r['rows'][-1]
|
||||
native=[]
|
||||
for z in range(end[1]-1,end[1]+2):
|
||||
x=end[0]-1
|
||||
native.append({'x':x,'z':z,'natural_block_y':int(natural[z+384,x+384]),'live_surface_y':int(live_heights[z+384,x+384]),'step_from_road':abs(last['walk_low_y']-(int(live_heights[z+384,x+384])+1))})
|
||||
assert all(n['step_from_road']<=.5 for n in native),(rid,native)
|
||||
r['endpoint_kind']=last['kind']
|
||||
r['endpoint_block_y']=last['block_y']
|
||||
r['endpoint_walk_low_y']=last['walk_low_y']
|
||||
r['native_toe']=native
|
||||
endpoint_toes.append({'id':rid,'last_road_column':list(end),'last_road_kind':last['kind'],'last_road_block_y':last['block_y'],'native_front_three':native})
|
||||
stairs=[
|
||||
{'z':-77,'block_y':95,'kind':'full'},
|
||||
{'z':-78,'block_y':96,'kind':'stairs','facing':'north'},
|
||||
{'z':-79,'block_y':96,'kind':'full'},
|
||||
{'z':-80,'block_y':97,'kind':'stairs','facing':'north'},
|
||||
{'z':-81,'block_y':97,'kind':'full'},
|
||||
{'z':-82,'block_y':98,'kind':'stairs','facing':'north'},
|
||||
{'z':-83,'block_y':98,'kind':'full'}]
|
||||
plan={
|
||||
'schema':'shacraft-foundation-elevation-design-v1',
|
||||
'status':'DESIGN ONLY; root builder must compare actual voxels before applying and verify final live world.',
|
||||
'world':actual['world'],'source_capture_finished_at':actual['capture_finished_at'],
|
||||
'y_convention':'floor_y/block_y is the Minecraft block coordinate. A full block at Y95 is walked on at Y96. A bottom stair at Y96 spans feet heights96.5..97.',
|
||||
'selected_districts':['01','02'],
|
||||
'surfaces':surfaces,'roads':roads,'verified_native_endpoint_toes':endpoint_toes,
|
||||
'station_entrance_stair':{'x_min':-16,'x_max':4,'width':21,'travel_direction':'north','rows':stairs,'max_walk_step':.5},
|
||||
'station_ne_connection':{'geometry':'polygon','points':[[35,-103],[42,-103],[44,-95],[42,-91],[35,-94],[35,-103]],'floor_y':98,'intent':'Small local upper landing joins east wing to the northeast descending road. Keep inside this polygon; no platform around the whole station.'},
|
||||
'plaza_inlay':{'center':[0,9],'outer_radius':16,'floor_y':95,'intent':'Flat green hexagonal ring with cream Shacraft S inlay. Reserve the core for later fountain/arrival feature if desired; never raise the inlay above paving.'},
|
||||
'masonry':{'foundation_core':'minecraft:stone','dark_footing':'minecraft:deepslate_bricks','wall':'minecraft:stone_bricks','secondary_wall':'minecraft:andesite','light_coping':'minecraft:smooth_sandstone','paving':'minecraft:smooth_sandstone','paving_bands':'minecraft:smooth_stone','accent':'minecraft:green_concrete','notes':['Do not fill the surrounding rectangle. Shape all exposed plinth walls to the exact existing footprint.','The west station wing has up to16 blocks of foundation. Use vertical pilasters every8 blocks and recessed blind arch panels; one plain flat wall would look excessive.','Leave district02 building floor usable and flat. Footing lines can indicate tower and pavilions; no tall unfinished walls across doors.','Put parapets only on exposed drops, outside the clear walking width; leave all route openings unblocked.','Finish side road ends with a full-width landing; east87/northeast86 match the existing future bridge levels, so keep the boundary to those markers precise.']},
|
||||
'quality_checks':['Actual block-for-block scan of finished footprint and road surfaces.','Cardinal-direction walking graph from spawn to station floor and every road endpoint, including stair orientation and at least2 air blocks headroom.','Every descent has half-block treads; never create a full block jump as a path transition.','Foundation columns extend down to solid existing ground; no hidden unsupported floating perimeter.','No new block in a water column; no edit outside foundation/road footprints except explicitly approved local rail, light and footing cells.','Check road width across diagonal curves; evaluate the union of row footprints rather than nearest centerline points alone.','Remove obsolete colored markers and text only inside the stage01 mutation envelope; preserve all other district reservations.','Compare orthographic live surface map with plan and inspect actual west station foundation + north axis from player height.'],
|
||||
'endpoint_join_notes':['South road ends at z110/block79 near actual ground78..80; a few local grading cells or one final stair row may be required after live voxel inspection.','Portal endpoint(-72,-46) fullY89 joins native89. Lake endpoint(-90,32) uses terminal east-facing stairY84, joining native83 beyond x-90 across the center3 cells with half-block steps; never replace this terminal stair with a full-block landing.','Northeast road must originate at the station upper landing98, not the forecourt95.','The existing camera is spectator; physical walking correctness still requires geometric collision checks.']
|
||||
}
|
||||
(OUT/'foundation-design.json').write_text(json.dumps(plan,indent=2)+'\n')
|
||||
|
||||
# Inspectable plan/sections are computed from immutable natural heights.
|
||||
# Live surface deltas are measured separately in the JSON metrics above.
|
||||
fig=plt.figure(figsize=(15,11),layout='constrained')
|
||||
gs=fig.add_gridspec(2,2,width_ratios=[1.22,1],height_ratios=[1,1])
|
||||
ax=fig.add_subplot(gs[:,0]); extent=(-110,105,125,-165)
|
||||
h=natural[219:510,274:490]
|
||||
shade=LightSource(315,40).hillshade(h,vert_exag=2,dx=1,dy=1)
|
||||
ax.imshow(h,cmap='gist_earth',extent=extent,alpha=.8,vmin=48,vmax=150)
|
||||
ax.imshow(shade,cmap='gray',extent=extent,alpha=.2)
|
||||
colors=['#f2dfbd','#ddd2b6','#f0c75b']
|
||||
for s,c in zip(surfaces,colors):
|
||||
pp=np.array(s['points']); ax.fill(pp[:,0],pp[:,1],c,alpha=.8);ax.plot(pp[:,0],pp[:,1],color='#263e39',lw=1.2)
|
||||
cc=pp.mean(axis=0);ax.text(cc[0],cc[1],s['id'].replace('-',' ')+'\nblock Y'+str(s['floor_y']),ha='center',va='center',fontsize=9,bbox=dict(facecolor='white',alpha=.8,edgecolor='none'))
|
||||
for r in roads:
|
||||
p=np.array(r['waypoints']);ax.plot(p[:,0],p[:,1],color='#ede3cf',lw=r['width']*.55,solid_capstyle='round');ax.plot(p[:,0],p[:,1],color='#485b53',lw=.6)
|
||||
if 'end_floor_y' in r:ax.text(p[-1,0],p[-1,1],'Y'+str(r.get('endpoint_block_y',r['end_floor_y']))+(' stair' if r.get('endpoint_kind')=='stairs' else ''),fontsize=8,ha='center',va='bottom',bbox=dict(facecolor='white',alpha=.85,edgecolor='none'))
|
||||
ax.set(xlim=(-105,100),ylim=(125,-165),xlabel='X — east →',ylabel='Z — south →',title='Shacraft · stage 01 foundations and local streets')
|
||||
ax.set_aspect('equal');ax.grid(alpha=.15)
|
||||
ax2=fig.add_subplot(gs[0,1]);xs=np.arange(-80,47); zs=-120*np.ones_like(xs); ground=natural[zs+384,xs+384]
|
||||
ax2.fill_between(xs,ground,70,color='#718356',alpha=.65,label='Existing natural ground'); ax2.plot(xs,ground,color='#354d2c',lw=1)
|
||||
inside=(xs>=-74)&(xs<=40);ax2.plot(xs[inside],np.full(sum(inside),99),color='#ae7731',lw=2,label='Station walking plane Y99');ax2.fill_between(xs[inside],ground[inside],99,color='#c4bda9',alpha=.6,label='Supported station plinth')
|
||||
ax2.set(xlabel='X across station at Z−120',ylabel='Y elevation',ylim=(78,103),title='Station: one continuous floor; articulated west retaining base');ax2.legend(fontsize=8,loc='lower right');ax2.grid(alpha=.2)
|
||||
ax3=fig.add_subplot(gs[1,1]);zvals=np.arange(-100,111);xvals=np.where(zvals<0,-6,0);ground=natural[zvals+384,xvals+384];ax3.fill_between(zvals,ground,65,color='#718356',alpha=.65)
|
||||
walk=np.full(len(zvals),96.);walk[zvals<=-83]=99
|
||||
for row in stairs:
|
||||
k=np.where(zvals==row['z'])[0][0];walk[k]=row['block_y']+(0.75 if row['kind']=='stairs' else 1)
|
||||
for row in roads[1]['rows']:
|
||||
k=np.where(zvals==row['coordinate'])[0][0];walk[k]=(row['walk_low_y']+row['walk_high_y'])/2
|
||||
ax3.plot(zvals,walk,color='#a47230',lw=2,label='Planned walking surface');ax3.plot(zvals,ground,color='#354d2c',lw=1,label='Natural centerline')
|
||||
ax3.set(xlabel='Z along north/south arrival route',ylabel='Y elevation',ylim=(74,103),title='Continuous arrival sequence: station → forecourt → square → approach');ax3.grid(alpha=.2);ax3.legend(fontsize=8)
|
||||
fig.savefig(OUT/'foundation-plan-and-sections.png',dpi=170)
|
||||
print(json.dumps({'plan':str(OUT/'foundation-design.json'),'figure':str(OUT/'foundation-plan-and-sections.png'),'surfaces':[{k:v for k,v in s.items() if k in ['id','floor_y','metrics']} for s in surfaces]},indent=2))
|
||||
@@ -1,276 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rasterize the reviewed Shacraft stage-one design without editing the world.
|
||||
|
||||
The public build_geometry(design, layout) function uses only the Python standard
|
||||
library. Coordinate keys are (x, z). Each cell has block_y, kind, group and clear;
|
||||
straight bottom stairs additionally have facing. block_y is not player feet Y.
|
||||
|
||||
Precedence: exact foundation polygons, northeast landing, local roads, central
|
||||
station staircase. Existing dense approved centerlines are used for road curves.
|
||||
Roads extend a few flat rows back into their origin plaza so an avenue cannot
|
||||
pinch down to the one-block vertex of the hexagonal square.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
Point = tuple[int, int]
|
||||
Cell = dict[str, Any]
|
||||
|
||||
|
||||
def _on_segment(x: int, z: int, a, b) -> bool:
|
||||
cross = (x-a[0])*(b[1]-a[1]) - (z-a[1])*(b[0]-a[0])
|
||||
return abs(cross) < 1e-8 and min(a[0],b[0]) <= x <= max(a[0],b[0]) and min(a[1],b[1]) <= z <= max(a[1],b[1])
|
||||
|
||||
|
||||
def polygon_cells(vertices) -> set[Point]:
|
||||
"""Integer block columns inside OR on the exact polygon, with no box fill."""
|
||||
vertices = [tuple(p) for p in vertices]
|
||||
if len(vertices) < 3:
|
||||
raise ValueError('A foundation polygon needs three vertices')
|
||||
segments = list(zip(vertices, vertices[1:]+vertices[:1]))
|
||||
result: set[Point] = set()
|
||||
for z in range(math.floor(min(p[1] for p in vertices)), math.ceil(max(p[1] for p in vertices))+1):
|
||||
for x in range(math.floor(min(p[0] for p in vertices)), math.ceil(max(p[0] for p in vertices))+1):
|
||||
inside = False
|
||||
boundary = False
|
||||
for a,b in segments:
|
||||
if _on_segment(x,z,a,b):
|
||||
boundary = True
|
||||
break
|
||||
if (a[1] > z) != (b[1] > z):
|
||||
cross_x = a[0] + (z-a[1])*(b[0]-a[0])/(b[1]-a[1])
|
||||
if x < cross_x:
|
||||
inside = not inside
|
||||
if boundary or inside:
|
||||
result.add((x,z))
|
||||
return result
|
||||
|
||||
|
||||
def _distance_squared(x: int, z: int, a, b) -> float:
|
||||
dx,dz=b[0]-a[0],b[1]-a[1]
|
||||
if not dx and not dz:
|
||||
return (x-a[0])**2+(z-a[1])**2
|
||||
t=max(0.,min(1.,((x-a[0])*dx+(z-a[1])*dz)/(dx*dx+dz*dz)))
|
||||
return (x-a[0]-t*dx)**2+(z-a[1]-t*dz)**2
|
||||
|
||||
|
||||
def _densify(points) -> list[Point]:
|
||||
result=[]
|
||||
for a,b in zip(points,points[1:]):
|
||||
n=max(1,math.ceil(max(abs(a[0]-b[0]),abs(a[1]-b[1]))))
|
||||
for i in range(n+1):
|
||||
p=(round(a[0]+(b[0]-a[0])*i/n),round(a[1]+(b[1]-a[1])*i/n))
|
||||
if not result or result[-1] != p:
|
||||
result.append(p)
|
||||
if not result and points:
|
||||
result=[tuple(map(round,points[0]))]
|
||||
return result
|
||||
|
||||
|
||||
def _cardinal_path(points: list[Point]) -> list[Point]:
|
||||
"""Insert cardinal intermediate samples for collision checks on diagonals."""
|
||||
if not points:
|
||||
return []
|
||||
result=[points[0]]
|
||||
for x,z in points[1:]:
|
||||
ax,az=result[-1]
|
||||
while (ax,az)!=(x,z):
|
||||
if ax!=x:
|
||||
ax += 1 if x>ax else -1
|
||||
else:
|
||||
az += 1 if z>az else -1
|
||||
result.append((ax,az))
|
||||
return result
|
||||
|
||||
|
||||
def _approved_centerline(spec, layout) -> list[Point]:
|
||||
base_id=spec['id'].removesuffix('-local')
|
||||
approved=next((r for r in layout.get('routes',[]) if r['id']==base_id),None)
|
||||
points=_densify(approved['points'] if approved else spec['waypoints'])
|
||||
if spec['geometry']=='road_profile':
|
||||
axis=0 if spec['axis']=='x' else 1
|
||||
lo=min(row['coordinate'] for row in spec['rows'])
|
||||
hi=max(row['coordinate'] for row in spec['rows'])
|
||||
points=[p for p in points if lo <= p[axis] <= hi]
|
||||
# If the approved path terminates just before a reviewed local endpoint,
|
||||
# add the small explicit tail without replacing its established curve.
|
||||
expected=tuple(spec['waypoints'][-1])
|
||||
if points and points[-1][axis] != expected[axis]:
|
||||
points += _densify([points[-1],expected])[1:]
|
||||
elif 'profile_until_z' in spec:
|
||||
stop=spec['profile_until_z']
|
||||
points=[p for p in points if p[1]>=stop]
|
||||
if len(points)<2:
|
||||
raise ValueError(f"No usable centerline for {spec['id']}")
|
||||
return points
|
||||
|
||||
|
||||
def _origin_extension(points: list[Point], spec) -> list[Point]:
|
||||
"""Overlap the origin plateau without changing its level or approved curve."""
|
||||
first=points[0]
|
||||
distance=max(4,spec['width']//2+2)
|
||||
target=points[min(len(points)-1,distance)]
|
||||
dx,dz=target[0]-first[0],target[1]-first[1]
|
||||
length=math.hypot(dx,dz)
|
||||
if not length:
|
||||
return points
|
||||
back=(round(first[0]-dx*distance/length),round(first[1]-dz*distance/length))
|
||||
return _densify([back,first])[:-1]+points
|
||||
|
||||
|
||||
def _corridor(points: list[Point], width: int, axis=None, lo=None, hi=None):
|
||||
"""Clear corridor plus one block of side coping, evaluated by cell centers."""
|
||||
outer=width/2+1
|
||||
clear_radius2=(width/2)**2
|
||||
outer_radius2=outer**2
|
||||
distances: dict[Point,float] = {}
|
||||
for a,b in zip(points,points[1:]):
|
||||
for z in range(math.floor(min(a[1],b[1])-outer),math.ceil(max(a[1],b[1])+outer)+1):
|
||||
for x in range(math.floor(min(a[0],b[0])-outer),math.ceil(max(a[0],b[0])+outer)+1):
|
||||
if axis is not None and not lo <= (x,z)[axis] <= hi:
|
||||
continue
|
||||
d=_distance_squared(x,z,a,b)
|
||||
if d <= outer_radius2+1e-8 and d < distances.get((x,z),math.inf):
|
||||
distances[(x,z)]=d
|
||||
return {p:d<=clear_radius2+1e-8 for p,d in distances.items()}
|
||||
|
||||
|
||||
def tread_height(cell: Cell, local_x: float, local_z: float) -> float:
|
||||
"""Collision surface of a full block or a straight bottom stair at an offset.
|
||||
|
||||
Use .25/.75 offsets to inspect both tread halves, avoiding the central edge.
|
||||
"""
|
||||
if cell['kind']=='full':
|
||||
return cell['block_y']+1
|
||||
high={'north':local_z<.5,'south':local_z>.5,
|
||||
'west':local_x<.5,'east':local_x>.5}[cell['facing']]
|
||||
return cell['block_y']+(1. if high else .5)
|
||||
|
||||
|
||||
def build_geometry(design, layout):
|
||||
"""Return cells, route metadata and compact invariant checks.
|
||||
|
||||
cells[(x,z)] -> {block_y:int,kind:'full'|'stairs',facing?:str,
|
||||
group:str,clear:bool}
|
||||
routes[] -> {id,centerline,centerline_4,corridor,clear_cells,clear_width}
|
||||
|
||||
`clear` distinguishes usable road paving from exterior coping; for a main
|
||||
polygon every floor cell is clear. It does not mean the world has been cleared.
|
||||
"""
|
||||
cells: dict[Point,Cell] = {}
|
||||
routes=[]
|
||||
polygon_areas={}
|
||||
for surface in design['surfaces']:
|
||||
footprint=polygon_cells(surface['points'])
|
||||
polygon_areas[surface['id']]=len(footprint)
|
||||
for p in footprint:
|
||||
cells[p]={'block_y':surface['floor_y'],'kind':'full','group':surface['id'],'clear':True}
|
||||
landing=design.get('station_ne_connection')
|
||||
if landing:
|
||||
for p in polygon_cells(landing['points']):
|
||||
cells[p]={'block_y':landing['floor_y'],'kind':'full','group':'station-ne-connection','clear':True}
|
||||
for spec in design['roads']:
|
||||
centerline=_approved_centerline(spec,layout)
|
||||
extended=_origin_extension(centerline,spec)
|
||||
profiled=spec['geometry']=='road_profile'
|
||||
if profiled:
|
||||
axis=0 if spec['axis']=='x' else 1
|
||||
profiles={row['coordinate']:row for row in spec['rows']}
|
||||
# Extend only the origin; stop exactly at the designed last road row.
|
||||
endpoint=centerline[-1][axis]
|
||||
origin=extended[0][axis]
|
||||
lo,hi=sorted([endpoint,origin])
|
||||
corridor=_corridor(extended,spec['width'],axis,lo,hi)
|
||||
first=spec['rows'][0]
|
||||
else:
|
||||
corridor=_corridor(extended,spec['width'])
|
||||
# The last flat avenue rows must not cover the station staircase.
|
||||
if 'profile_until_z' in spec:
|
||||
corridor={p:clear for p,clear in corridor.items() if p[1]>=spec['profile_until_z']}
|
||||
for p,clear in corridor.items():
|
||||
if profiled:
|
||||
row=profiles.get(p[axis])
|
||||
if row is None:
|
||||
row={'block_y':first['block_y'],'kind':'full'}
|
||||
cell={'block_y':row['block_y'],'kind':row['kind'],'group':spec['id'],'clear':clear}
|
||||
if row['kind']=='stairs':
|
||||
cell['facing']=row['facing']
|
||||
else:
|
||||
cell={'block_y':spec['floor_y'],'kind':'full','group':spec['id'],'clear':clear}
|
||||
# A coping line within an already level clear plaza is a floor band,
|
||||
# not an obstacle or a place for a parapet. Preserve that distinction.
|
||||
previous=cells.get(p)
|
||||
if previous and previous['clear'] and previous['block_y']==cell['block_y'] and previous['kind']==cell['kind']=='full':
|
||||
cell['clear']=True
|
||||
cells[p]=cell
|
||||
routes.append({'id':spec['id'],'centerline':centerline,'centerline_4':_cardinal_path(centerline),'corridor':sorted(corridor),'clear_cells':sorted(p for p,c in corridor.items() if c),'clear_width':spec['width']})
|
||||
stair=design['station_entrance_stair']
|
||||
stair_cells=[]
|
||||
stair_clear=[]
|
||||
for row in stair['rows']:
|
||||
for x in range(stair['x_min']-1,stair['x_max']+2):
|
||||
p=(x,row['z'])
|
||||
clear=stair['x_min']<=x<=stair['x_max']
|
||||
cells[p]={'block_y':row['block_y'],'kind':row['kind'],'group':'station-entrance-stair','clear':clear}
|
||||
if row['kind']=='stairs':
|
||||
cells[p]['facing']=row['facing']
|
||||
stair_cells.append(p)
|
||||
if clear:
|
||||
stair_clear.append(p)
|
||||
mid=(stair['x_min']+stair['x_max'])//2
|
||||
line=[(mid,row['z']) for row in stair['rows']]
|
||||
routes.append({'id':'station-entrance-stair','centerline':line,'centerline_4':_cardinal_path(line),'corridor':stair_cells,'clear_cells':stair_clear,'clear_width':stair['width']})
|
||||
missing=[]
|
||||
blocked=[]
|
||||
for route in routes:
|
||||
for p in route['centerline_4']:
|
||||
if p not in cells:
|
||||
missing.append((route['id'],p))
|
||||
elif not cells[p]['clear']:
|
||||
blocked.append((route['id'],p))
|
||||
if missing or blocked:
|
||||
raise ValueError(f'Road centerline incomplete: missing={missing[:8]}, non-clear={blocked[:8]}')
|
||||
max_step=0.
|
||||
for route in routes:
|
||||
path=route['centerline_4']
|
||||
route_step=0.
|
||||
for p,q in zip(path,path[1:]):
|
||||
dx,dz=q[0]-p[0],q[1]-p[1]
|
||||
departure=tread_height(cells[p],.5+dx*.25,.5+dz*.25)
|
||||
arrival=tread_height(cells[q],.5-dx*.25,.5-dz*.25)
|
||||
step=abs(departure-arrival)
|
||||
if step>.5:
|
||||
raise ValueError(f"Unwalkable centerline in {route['id']}: {p}->{q}, {step} blocks")
|
||||
route_step=max(route_step,step)
|
||||
route['maximum_centerline_step']=route_step
|
||||
max_step=max(max_step,route_step)
|
||||
counts={}
|
||||
for cell in cells.values():
|
||||
counts[cell['group']]=counts.get(cell['group'],0)+1
|
||||
return {'cells':cells,'routes':routes,'checks':{'columns':len(cells),'polygon_areas':polygon_areas,'columns_by_final_group':counts,'centerline_missing':len(missing),'centerline_nonclear':len(blocked),'maximum_centerline_step':max_step,'stairs':sum(c['kind']=='stairs' for c in cells.values()),'minimum_block_y':min(c['block_y'] for c in cells.values()),'maximum_block_y':max(c['block_y'] for c in cells.values()),'world_edits':0}}
|
||||
|
||||
|
||||
def serializable(geometry):
|
||||
return {**geometry,'cells':[{'x':x,'z':z,**cell} for (x,z),cell in sorted(geometry['cells'].items(),key=lambda p:(p[0][1],p[0][0]))]}
|
||||
|
||||
|
||||
def main():
|
||||
root=Path(__file__).resolve().parents[2]
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--design',type=Path,default=root/'.runtime/foundation-study/design/foundation-design.json')
|
||||
parser.add_argument('--layout',type=Path,default=root/'examples/layout/shacraft-lobby-layout.json')
|
||||
parser.add_argument('--output',type=Path,default=root/'.runtime/foundation-study/design/geometry.json')
|
||||
args=parser.parse_args()
|
||||
geometry=build_geometry(json.loads(args.design.read_text()),json.loads(args.layout.read_text()))
|
||||
args.output.parent.mkdir(parents=True,exist_ok=True)
|
||||
args.output.write_text(json.dumps(serializable(geometry),separators=(',',':'))+'\n')
|
||||
print(json.dumps({'output':str(args.output),'checks':geometry['checks']},indent=2))
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
main()
|
||||
@@ -1,238 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Independently audit planned foundation navigation on a half-block surface grid.
|
||||
|
||||
This is geometry QA, not a Minecraft collision simulator or a live block survey.
|
||||
Only clear cells are traversable. Four quarter-center samples describe each full
|
||||
block or straight bottom stair; adjacent samples require <= 0.5-block height change.
|
||||
"""
|
||||
import argparse
|
||||
from collections import Counter, deque
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
|
||||
def normalize_cells(document):
|
||||
result = {}
|
||||
for cell in document['cells']:
|
||||
if any(type(cell.get(key)) is not int for key in ('x', 'z', 'block_y')):
|
||||
raise ValueError('Geometry coordinates and block_y must be integers')
|
||||
if type(cell.get('clear')) is not bool or cell.get('kind') not in ('full', 'stairs'):
|
||||
raise ValueError('Geometry needs explicit clear and full/stairs kind')
|
||||
if cell['kind'] == 'stairs' and cell.get('facing') not in ('north', 'east', 'south', 'west'):
|
||||
raise ValueError('Stairs need an explicit cardinal facing')
|
||||
at = (cell['x'], cell['z'])
|
||||
if at in result:
|
||||
raise ValueError(f'Duplicate geometry cell {at}')
|
||||
result[at] = cell
|
||||
return result
|
||||
|
||||
|
||||
def cell_nodes(x, z):
|
||||
return [(2 * x + i, 2 * z + j) for i in (0, 1) for j in (0, 1)]
|
||||
|
||||
|
||||
def height2(cell, sample):
|
||||
if cell['kind'] == 'full':
|
||||
return 2 * cell['block_y'] + 2
|
||||
ix, iz = sample[0] % 2, sample[1] % 2
|
||||
facing = cell['facing']
|
||||
high = ((facing == 'north' and iz == 0) or (facing == 'south' and iz == 1)
|
||||
or (facing == 'west' and ix == 0) or (facing == 'east' and ix == 1))
|
||||
return 2 * cell['block_y'] + (2 if high else 1)
|
||||
|
||||
|
||||
def surface_graph(cells):
|
||||
return {node: height2(cell, node) for (x, z), cell in cells.items() if cell['clear']
|
||||
for node in cell_nodes(x, z)}
|
||||
|
||||
|
||||
def neighbors(node):
|
||||
x, z = node
|
||||
return ((x - 1, z), (x + 1, z), (x, z - 1), (x, z + 1))
|
||||
|
||||
|
||||
def reachable(graph, source):
|
||||
starts = [p for p in cell_nodes(*source) if p in graph]
|
||||
if len(starts) != 4:
|
||||
raise ValueError(f'Navigation source {source} is missing or non-clear')
|
||||
# One source prevents accidentally joining two disconnected halves of a cell.
|
||||
queue, reached = deque(starts[:1]), {starts[0]: 0}
|
||||
while queue:
|
||||
node = queue.popleft()
|
||||
for other in neighbors(node):
|
||||
if other in graph and other not in reached and abs(graph[node] - graph[other]) <= 1:
|
||||
reached[other] = reached[node] + 1
|
||||
queue.append(other)
|
||||
return reached
|
||||
|
||||
|
||||
def at_node(node, height=None):
|
||||
value = {'x': node[0] / 2 + .25, 'z': node[1] / 2 + .25}
|
||||
if height is not None:
|
||||
value['standing_y'] = height / 2
|
||||
return value
|
||||
|
||||
|
||||
def endpoint_report(name, at, graph, reached):
|
||||
nodes = cell_nodes(*at)
|
||||
existing = [p for p in nodes if p in graph]
|
||||
connected = [p for p in nodes if p in reached]
|
||||
return {'name': name, 'x': at[0], 'z': at[1], 'surface_samples': len(existing),
|
||||
'reachable_samples': len(connected), 'passed': len(connected) == 4,
|
||||
'shortest_surface_route_blocks': min((reached[p] / 2 for p in connected), default=None)}
|
||||
|
||||
|
||||
def route_report(route, cells, graph, reached):
|
||||
path = [tuple(p) for p in route.get('centerline_4', route['centerline'])]
|
||||
corridor = {tuple(p) for p in route['corridor']}
|
||||
clear = {tuple(p) for p in route['clear_cells']}
|
||||
width = route['clear_width']
|
||||
if type(width) is not int or width < 1 or width % 2 != 1:
|
||||
raise ValueError('This cross-section audit requires positive odd clear_width')
|
||||
missing_corridor = sorted(corridor - cells.keys())
|
||||
nonclear = sorted(p for p in clear if p not in cells or not cells[p]['clear'])
|
||||
unreachable = sorted(p for p in clear if any(n not in reached for n in cell_nodes(*p)))
|
||||
jumps, invalid_steps = [], []
|
||||
max_boundary_step = 0
|
||||
for p, q in zip(path, path[1:]):
|
||||
dx, dz = q[0] - p[0], q[1] - p[1]
|
||||
if abs(dx) + abs(dz) != 1:
|
||||
invalid_steps.append({'from': list(p), 'to': list(q)})
|
||||
continue
|
||||
# Check both parallel quarter-center lanes across the shared block face.
|
||||
for a in cell_nodes(*p):
|
||||
b = (a[0] + dx, a[1] + dz)
|
||||
if (b[0] // 2, b[1] // 2) != q:
|
||||
continue
|
||||
if a not in graph or b not in graph:
|
||||
jumps.append({'from': at_node(a), 'to': at_node(b), 'reason': 'missing_clear_surface'})
|
||||
continue
|
||||
step = abs(graph[a] - graph[b]) / 2
|
||||
max_boundary_step = max(max_boundary_step, step)
|
||||
if step > .5:
|
||||
jumps.append({'from': at_node(a, graph[a]), 'to': at_node(b, graph[b]),
|
||||
'height_change': step, 'reason': 'height_step_exceeds_half_block'})
|
||||
cross_sections, narrow = [], []
|
||||
for i, p in enumerate(path):
|
||||
# Use at least one corridor-width of tangent support. Inserted cardinal
|
||||
# elbows near an axis-clipped endpoint must not rotate the cross-section
|
||||
# to measure longitudinally past that deliberate terminal boundary.
|
||||
window = max(6, width)
|
||||
before, after = path[max(0, i - window)], path[min(len(path) - 1, i + window)]
|
||||
tx, tz = after[0] - before[0], after[1] - before[1]
|
||||
normal = (0, 1) if abs(tx) > abs(tz) else (1, 0)
|
||||
radius = width // 2
|
||||
samples = [(p[0] + normal[0] * offset, p[1] + normal[1] * offset)
|
||||
for offset in range(-radius, radius + 1)]
|
||||
present = sum(s in cells for s in samples)
|
||||
usable = sum(s in cells and cells[s]['clear'] for s in samples)
|
||||
connected = sum(all(n in reached for n in cell_nodes(*s)) for s in samples)
|
||||
cross_sections.append((present, usable, connected))
|
||||
if min(present, usable, connected) < width:
|
||||
narrow.append({'x': p[0], 'z': p[1], 'normal': list(normal), 'required_width': width,
|
||||
'structural_columns': present, 'clear_columns': usable,
|
||||
'reachable_columns': connected,
|
||||
'problem_columns': [list(s) for s in samples if s not in cells or not cells[s]['clear']
|
||||
or any(n not in reached for n in cell_nodes(*s))]})
|
||||
ends = [endpoint_report(route['id'] + ':' + name, at, graph, reached)
|
||||
for name, at in [('start', path[0]), ('end', path[-1])]]
|
||||
passed = not (missing_corridor or nonclear or unreachable or jumps or invalid_steps or narrow)
|
||||
return {'id': route['id'], 'passed': passed, 'declared_clear_width': width,
|
||||
'corridor_columns': len(corridor), 'missing_corridor_columns': missing_corridor,
|
||||
'declared_clear_columns': len(clear), 'nonclear_declared_columns': nonclear,
|
||||
'unreachable_declared_columns': unreachable,
|
||||
'cross_sections': len(cross_sections),
|
||||
'minimum_structural_cross_section': min(row[0] for row in cross_sections),
|
||||
'minimum_clear_cross_section': min(row[1] for row in cross_sections),
|
||||
'minimum_reachable_cross_section': min(row[2] for row in cross_sections),
|
||||
'narrow_cross_sections': narrow, 'centerline_invalid_steps': invalid_steps,
|
||||
'centerline_maximum_boundary_step': max_boundary_step,
|
||||
'centerline_jumps': jumps, 'endpoints': ends}
|
||||
|
||||
|
||||
def audit(document, source=(0, 9), station=(-6, -105)):
|
||||
cells = normalize_cells(document)
|
||||
graph = surface_graph(cells)
|
||||
reached = reachable(graph, source)
|
||||
unreachable = sorted(p for p, cell in cells.items() if cell['clear']
|
||||
and any(n not in reached for n in cell_nodes(*p)))
|
||||
routes = [route_report(route, cells, graph, reached) for route in document['routes']]
|
||||
goal = endpoint_report('clock-station', station, graph, reached)
|
||||
return {'version': 1, 'source': {'x': source[0], 'z': source[1]}, 'world_edits': 0,
|
||||
'method': 'Four quarter-center surface samples per clear cell; cardinal half-block BFS, rise/drop <= 0.5 block.',
|
||||
'limitations': 'Planned surface topology only. No headroom, body-width collision, material state or live-world verification. Width checks are cardinal cross-sections using the dominant local tangent; full declared masks are also checked.',
|
||||
'geometry_sha256': hashlib.sha256(json.dumps(document, sort_keys=True, separators=(',', ':')).encode()).hexdigest(),
|
||||
'planned_columns': len(cells), 'clear_columns': sum(c['clear'] for c in cells.values()),
|
||||
'surface_samples': len(graph), 'reachable_samples': len(reached),
|
||||
'unreachable_clear_columns': [list(p) for p in unreachable],
|
||||
'unreachable_by_group': dict(Counter(cells[p].get('group', 'unknown') for p in unreachable)),
|
||||
'station': goal, 'routes': routes,
|
||||
'passed': not unreachable and goal['passed'] and all(r['passed'] for r in routes)}
|
||||
|
||||
|
||||
class GeometryAuditTests(unittest.TestCase):
|
||||
def test_full_block_jump_separates_components(self):
|
||||
cells = {(x, 0): {'kind': 'full', 'block_y': 10 if x == 0 else 11, 'clear': True}
|
||||
for x in range(2)}
|
||||
graph = surface_graph(cells)
|
||||
self.assertEqual(len(reachable(graph, (0, 0))), 4)
|
||||
|
||||
def test_stairs_connect_two_levels_for_all_directions(self):
|
||||
for facing, direction in [('north', (0, -1)), ('south', (0, 1)), ('west', (-1, 0)), ('east', (1, 0))]:
|
||||
with self.subTest(facing=facing):
|
||||
dx, dz = direction
|
||||
cells = {(-dx, -dz): {'kind': 'full', 'block_y': 9, 'clear': True},
|
||||
(0, 0): {'kind': 'stairs', 'block_y': 10, 'facing': facing, 'clear': True},
|
||||
(dx, dz): {'kind': 'full', 'block_y': 10, 'clear': True}}
|
||||
graph = surface_graph(cells)
|
||||
self.assertEqual(len(reachable(graph, (-dx, -dz))), 12)
|
||||
|
||||
def test_nonclear_surface_is_never_traversable(self):
|
||||
graph = surface_graph({(0, 0): {'kind': 'full', 'block_y': 10, 'clear': False}})
|
||||
self.assertFalse(graph)
|
||||
|
||||
def test_missing_width_column_is_reported(self):
|
||||
cells = {(x, z): {'x': x, 'z': z, 'kind': 'full', 'block_y': 10, 'clear': True}
|
||||
for x in range(-1, 2) for z in range(3)}
|
||||
del cells[(-1, 1)]
|
||||
graph = surface_graph(cells)
|
||||
route = {'id': 'test', 'centerline': [(0, 0), (0, 1), (0, 2)],
|
||||
'corridor': list(cells), 'clear_cells': list(cells), 'clear_width': 3}
|
||||
report = route_report(route, cells, graph, reachable(graph, (0, 0)))
|
||||
self.assertFalse(report['passed'])
|
||||
self.assertEqual(report['minimum_structural_cross_section'], 2)
|
||||
self.assertEqual(report['narrow_cross_sections'][0]['problem_columns'], [[-1, 1]])
|
||||
|
||||
|
||||
def main():
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--geometry', type=Path, default=root / '.runtime/foundation-study/design/geometry.json')
|
||||
parser.add_argument('--report', type=Path, default=root / '.runtime/foundation-study/design/navigation-qa.json')
|
||||
parser.add_argument('--source', type=int, nargs=2, default=(0, 9), metavar=('X', 'Z'))
|
||||
parser.add_argument('--station', type=int, nargs=2, default=(-6, -105), metavar=('X', 'Z'))
|
||||
parser.add_argument('--self-test', action='store_true')
|
||||
args = parser.parse_args()
|
||||
if args.self_test:
|
||||
result = unittest.TextTestRunner().run(unittest.defaultTestLoader.loadTestsFromTestCase(GeometryAuditTests))
|
||||
raise SystemExit(0 if result.wasSuccessful() else 1)
|
||||
report = audit(json.loads(args.geometry.read_text()), tuple(args.source), tuple(args.station))
|
||||
args.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.report.write_text(json.dumps(report, indent=2) + '\n')
|
||||
print(json.dumps({'passed': report['passed'], 'clear_columns': report['clear_columns'],
|
||||
'reachable_samples': report['reachable_samples'], 'surface_samples': report['surface_samples'],
|
||||
'unreachable_clear_columns': len(report['unreachable_clear_columns']),
|
||||
'unreachable_by_group': report['unreachable_by_group'], 'station': report['station'],
|
||||
'routes': [{'id': r['id'], 'passed': r['passed'],
|
||||
'minimum_clear_width': r['minimum_clear_cross_section'],
|
||||
'narrow_sections': len(r['narrow_cross_sections']),
|
||||
'centerline_jumps': len(r['centerline_jumps']),
|
||||
'unreachable_columns': len(r['unreachable_declared_columns'])} for r in report['routes']],
|
||||
'report': str(args.report.resolve())}, indent=2))
|
||||
raise SystemExit(0 if report['passed'] else 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -371,7 +371,7 @@ def main():
|
||||
capture.add_argument('--columns', type=Path)
|
||||
capture.add_argument('--out', type=Path, required=True)
|
||||
capture.add_argument('--resume', action='store_true', help='Explicitly reuse older partial observations')
|
||||
capture.add_argument('--config', type=Path, default=terrain.ROOT / '.runtime/server/plugins/MinecraftBuilderMCP/config.yml')
|
||||
capture.add_argument('--config', type=Path, default=terrain.RUNTIME / 'server/plugins/MinecraftBuilderMCP/config.yml')
|
||||
verify = commands.add_parser('verify-walk', help='Check surfaces against an observed snapshot')
|
||||
verify.add_argument('--snapshot', type=Path, required=True)
|
||||
verify.add_argument('--points', type=Path, required=True, help='JSON {scope:{...},points:[{x,z,standing_y,sub_x?,sub_z?,route?}]}')
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
# Shacraft layout study
|
||||
|
||||
`plan.py` produces a terrain-aware block marking plan and a review image. It reads the approved, big-endian 768 × 768 float height field at `.runtime/terrain-study/natural.f32`; it does not connect to Minecraft or write world blocks.
|
||||
|
||||
Run with the terrain study's NumPy / Matplotlib environment:
|
||||
|
||||
```sh
|
||||
.runtime/terrain-study/venv/bin/python scripts/layout-study/plan.py
|
||||
```
|
||||
|
||||
Outputs are `.runtime/layout-study/layout.json` and `layout-preview.png`. The image is a computed planning diagram, not an in-game photograph or a live map. Live world state and safe replacement materials must be checked separately before marking.
|
||||
|
||||
The plan retains the Shacraft reference's district order while adapting footprints to the natural valley. A central clock avenue gives spawn a clear destination. The promenade links the station, portals, waterworks, market, gardens and harbor without forcing every trip through spawn. Scenic branches stay subordinate to that main circulation.
|
||||
|
||||
The market uses six separate foundations on the calmer southern shoulder. The portal hall is moved north of the lake edge. All building and plaza footprints in the plan lie on dry ground. No route centerline cuts through a building interior by more than a doorway allowance. This is a geometric check, not a human navigation playtest.
|
||||
|
||||
JSON coordinates are `[x,z]`, with north in negative Z. Feature polylines and route points are already sampled and rounded. Routes also retain their sparse `waypoints`. `role: bridge` routes have a planned `deck_y` and `abutments` with measured ground height and a future stairs flag. The northeastern bridge needs a substantial east landing stair: its deck is Y86 while that bank is approximately Y69. The three harbor piers and flagship reservation also carry explicit design heights.
|
||||
|
||||
Ground contours reserve future buildings; they do not specify large flat platforms. The station and portal hall still span sloped ground, so later architecture should use separate foundations, lower wings and short stairs. The actual terrain, rivers and lake remain the governing geometry. Side trails on mountain shoulders may need stair segments when built.
|
||||
@@ -1,188 +0,0 @@
|
||||
"""Design-stage contours and circulation derived from Shacraft's approved height field.
|
||||
No world access or mutation. Coordinates use north=-Z; fields are planning data only.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import json, math
|
||||
import numpy as np
|
||||
import matplotlib
|
||||
matplotlib.use('Agg')
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.colors import LightSource
|
||||
from matplotlib.path import Path as MplPath
|
||||
ROOT=Path(__file__).resolve().parents[2]
|
||||
OUT=ROOT/'.runtime/layout-study'; OUT.mkdir(parents=True,exist_ok=True)
|
||||
h=np.fromfile(ROOT/'.runtime/terrain-study/natural.f32',dtype='>f4').reshape(768,768).astype(float)
|
||||
features=[];routes=[]
|
||||
colors={'01':'#84d440','02':'#f4d35e','03':'#bc80ef','04':'#40c8e3','05':'#64d9a0','06':'#ef9863','07':'#f5f4e9','08':'#4ca3ff','09':'#f07eaf'}
|
||||
districts=[
|
||||
('01','Arrival square',[0,8],'An open hexagonal plaza; preserve the raised grassy crown. Main north axis reveals the clock tower.'),
|
||||
('02','Clock station',[-18,-119],'A long station hall with projecting clock tower and two end pavilions. Its forecourt faces spawn.'),
|
||||
('03','Portal concourse',[-201,-156],'A chamfered hall with six separate portal-bay footprints and a sunken-feeling garden approach.'),
|
||||
('04','Airship harbor',[179,-137],'East-bank terminal with three west-facing piers above the water gorge. One future flagship uses the middle pier.'),
|
||||
('05','Sky gardens',[207,88],'Three distinct rounded landmarks connected by winding garden paths: greenhouse, winter garden, observatory.'),
|
||||
('06','Market quarter',[-195,225],'Six small footprints follow the slope around a compact square. Use stepped streets and individual foundations.'),
|
||||
('07','Arrival viaduct',[8,284],'A long south approach follows the valley ridge; the final viaduct crosses the merging river branches.'),
|
||||
('08','Lake waterworks',[-135,43],'A compact pumping house above the east lake shore and a low over-water promenade.'),
|
||||
('09','Scenic overlooks',[48,-180],'Small optional lookouts frame the valley, lake and arrival route; no mountain flattening.')]
|
||||
districts=[dict(id=i,name=n,label=p,color=colors[i],intent=t) for i,n,p,t in districts]
|
||||
|
||||
def closed(points): return points+[points[0]] if points[0]!=points[-1] else points
|
||||
|
||||
def add(i,d,n,p,role='building',**kw):
|
||||
f=dict(id=i,district=d,name=n,type='polyline' if role in ('detail','promenade','pier','bridge','axis') else 'polygon',points=p,color=colors[d],role=role,**kw)
|
||||
features.append(f);return f
|
||||
|
||||
def ellipse(cx,cz,rx,rz=None,n=72,angle=0):
|
||||
rz=rx if rz is None else rz;a=math.radians(angle)
|
||||
return closed([[round(cx+math.cos(t)*rx*math.cos(a)-math.sin(t)*rz*math.sin(a)),round(cz+math.cos(t)*rx*math.sin(a)+math.sin(t)*rz*math.cos(a))]for t in np.linspace(0,2*math.pi,n,endpoint=False)])
|
||||
|
||||
def rect(cx,cz,w,d,angle=0):
|
||||
a=math.radians(angle)
|
||||
return closed([[round(cx+x*math.cos(a)-z*math.sin(a)),round(cz+x*math.sin(a)+z*math.cos(a))]for x,z in[(-w/2,-d/2),(w/2,-d/2),(w/2,d/2),(-w/2,d/2)]])
|
||||
|
||||
def smooth(points):
|
||||
p=np.array([points[0]]+points+[points[-1]],float);out=[]
|
||||
for k in range(1,len(p)-2):
|
||||
a,b,c,d=p[k-1:k+3];n=max(2,int(np.linalg.norm(c-b)))
|
||||
for t in np.linspace(0,1,n,endpoint=False):
|
||||
q=.5*((2*b)+(-a+c)*t+(2*a-5*b+4*c-d)*t*t+(-a+3*b-3*c+d)*t*t*t)
|
||||
q=[round(float(q[0])),round(float(q[1]))]
|
||||
if not out or q!=out[-1]:out.append(q)
|
||||
out.append(points[-1]);return out
|
||||
|
||||
def route(i,name,points,width=7,role='main',**kw):
|
||||
r=dict(id=i,name=name,points=smooth(points),waypoints=points,width=width,role=role,**kw);routes.append(r);return r
|
||||
|
||||
add('arrival-hex','01','Arrival square',closed([[0,-37],[39,-15],[43,31],[0,57],[-43,31],[-39,-15]]),'plaza')
|
||||
add('spawn-medallion','01','Shacraft medallion reserve',ellipse(0,9,16,n=36),'plaza')
|
||||
add('clock-station','02','Clock station and tower',closed([[-74,-139],[-51,-139],[-51,-145],[16,-145],[16,-139],[40,-139],[40,-98],[13,-98],[13,-83],[-25,-83],[-25,-98],[-74,-98]]))
|
||||
add('station-clock-base','02','Clock tower base',rect(-6,-105,18,18),'detail')
|
||||
for cx in[-63,29]:add('station-pavilion-'+str(cx),'02','End pavilion',rect(cx,-119,16,30),'detail')
|
||||
add('station-forecourt','02','Station forecourt',ellipse(-5,-65,36,15,n=48),'plaza')
|
||||
add('portal-hall','03','Portal concourse',closed([[-227,-163],[-161,-163],[-152,-154],[-152,-122],[-161,-113],[-227,-113],[-236,-122],[-236,-154]]))
|
||||
for k,(cx,cz) in enumerate([(x,z)for z in[-151,-125]for x in[-218,-194,-170]],1):add('portal-bay-'+str(k),'03','Portal bay '+str(k),rect(cx,cz,13,8),'detail',number=k)
|
||||
add('portal-forecourt','03','Portal garden court',ellipse(-159,-93,17,12,n=48),'plaza')
|
||||
add('harbor-terminal','04','Airship terminal',closed([[169,-170],[191,-170],[199,-162],[199,-103],[191,-95],[169,-95],[161,-103],[161,-162]]))
|
||||
for k,z in enumerate([-158,-132,-106],1):
|
||||
add('airship-pier-'+str(k),'04','Airship pier '+str(k),closed([[161,z-4],[107,z-4],[103,z],[107,z+4],[161,z+4]]),'pier',deck_y=79,number=k)
|
||||
add('flagship-reserve','04','Flagship mooring reserve',ellipse(78,-132,29,10,n=48),'detail',deck_y=92)
|
||||
for i,(x,z,rx,rz,n) in enumerate([(185,23,25,25,'Palm glasshouse'),(211,94,22,18,'Winter garden'),(233,152,15,15,'Observatory')],1):
|
||||
add('garden-'+str(i),'05',n,ellipse(x,z,rx,rz), 'building')
|
||||
add('garden-court-'+str(i),'05',n+' surrounding walk',ellipse(x,z,rx+7,rz+7),'plaza')
|
||||
add('market-square','06','Market square',ellipse(-195,225,22,25,n=48),'plaza')
|
||||
for i,(x,z,w,d,a,n) in enumerate([(-219,188,24,18,-8,'Bakery'),(-177,195,24,18,7,'Crafts hall'),(-162,231,17,26,8,'Tea house'),(-178,272,22,18,-8,'Guild shop'),(-214,272,23,17,8,'Workshop'),(-233,239,22,26,-5,'Guild hall')],1):
|
||||
add('market-'+str(i),'06',n,rect(x,z,w,d,a))
|
||||
add('waterworks-pump','08','Pumping house',rect(-135,43,14,18,-15))
|
||||
add('waterworks-lookout','08','Waterworks lookout',ellipse(-135,69,10,n=32),'plaza')
|
||||
shore=[]
|
||||
for z in range(-69,76,3):
|
||||
wet=np.where(h[z+384,:384]<48)[0]-384
|
||||
if len(wet):shore.append([int(wet[-1]-3),z])
|
||||
add('lake-boardwalk','08','Lake boardwalk alignment',smooth(shore),'promenade',deck_y=51)
|
||||
for i,(x,z,r,n) in enumerate([(43,-173,8,'North clock-view belvedere'),(-130,-188,7,'Portal ridge overlook'),(229,-76,8,'Harbor overlook'),(266,110,7,'Garden lookout'),(-258,258,7,'Valley approach lookout')],1):
|
||||
add('overlook-'+str(i),'09',n,ellipse(x,z,r,n=32),'plaza')
|
||||
# Pull the portal footprint north onto its drier shoulder; retain all six bays.
|
||||
for f in features:
|
||||
if f['id']=='portal-hall' or f['id'].startswith('portal-bay-'):
|
||||
f['points']=[[x-7,round(-156+(z+138)*.8)] for x,z in f['points']]
|
||||
# Direct spawn routes make the principal destinations easy to find.
|
||||
route('station-axis','Clock avenue',[[0,-37],[-4,-48],[-5,-65],[-6,-83]],9)
|
||||
route('portal-radial','Portal approach',[[-35,-14],[-67,-43],[-107,-65],[-135,-75],[-159,-81]],7)
|
||||
route('portal-forecourt-link','Portal court entrance',[[-159,-105],[-154,-120],[-164,-139]],5,'secondary')
|
||||
route('lake-radial','Lake walk',[[-42,18],[-77,27],[-104,38],[-126,40]],5,'secondary')
|
||||
route('east-radial','Garden approach',[[42,16],[65,10],[82,8]],7)
|
||||
route('south-axis','Arrival avenue',[[0,57],[3,97],[-5,143],[4,189],[8,237],[7,281],[5,321]],9)
|
||||
# A district promenade closes two large loops and bypasses spawn.
|
||||
route('ring-northwest','Station to portals',[[-40,-65],[-77,-76],[-116,-89],[-145,-105],[-152,-122],[-164,-139]],7)
|
||||
route('ring-west','West inner promenade',[[-147,-111],[-125,-80],[-118,-41],[-110,4],[-115,47],[-113,88],[-127,126]],7)
|
||||
route('ring-market-north','Market north street',[[-213,128],[-218,148],[-205,171],[-193,182],[-195,200]],7)
|
||||
route('ring-market-south','Market south street',[[-195,250],[-187,253],[-170,252],[-145,242]],7)
|
||||
route('ring-south','South inner promenade',[[-72,242],[-36,238],[8,237],[31,218],[46,194],[59,166]],7)
|
||||
route('ring-garden-south','Garden south promenade',[[143,166],[172,179],[203,172],[218,157]],7)
|
||||
route('ring-gardens','Garden promenade',[[218,137],[204,117],[186,109],[176,88],[184,65],[201,46]],7)
|
||||
route('ring-harbor','Harbor garden promenade',[[185,-9],[197,-43],[192,-72],[181,-95]],7)
|
||||
route('ring-northeast','Station to east bridge',[[40,-95],[60,-91],[82,-78]],7)
|
||||
route('harbor-bridge-landing','Harbor bridge landing',[[155,-81],[171,-83],[180,-95]],7)
|
||||
route('garden-bridge-landing','Garden bridge landing',[[151,8],[157,16],[160,23]],7)
|
||||
route('market-shop-street','Market north street',[[-207,192],[-205,198],[-203,202]],5,'secondary')
|
||||
route('market-south-shop-street','Market south shops',[[-187,253],[-178,259],[-178,262]],5,'secondary')
|
||||
route('market-workshop-street','Workshop approach',[[-198,250],[-207,254],[-214,262]],5,'secondary')
|
||||
route('market-guild-street','Guild approach',[[-216,231],[-220,234],[-222,236]],5,'secondary')
|
||||
route('market-teahouse-street','Tea house approach',[[-173,229],[-171,229]],5,'secondary')
|
||||
route('market-crafts-street','Crafts approach',[[-184,203],[-179,206]],5,'secondary')
|
||||
route('waterworks-connector','Waterworks approach',[[-114,44],[-122,44],[-128,43]],5,'secondary')
|
||||
route('waterworks-lookout-path','Waterworks viewing path',[[-135,52],[-135,59]],3,'secondary')
|
||||
# Bridges are measured separately. Grade is a later construction task, not terrain flattening.
|
||||
for i,n,a,b,w in [
|
||||
('bridge-northeast','Clock / harbor bridge',[82,-78],[155,-81],7),
|
||||
('bridge-east','Spawn / garden bridge',[82,8],[151,8],7),
|
||||
('bridge-southeast','Garden / south bridge',[59,166],[143,166],7),
|
||||
('bridge-market-north','Market / lake bridge',[-127,126],[-213,128],7),
|
||||
('bridge-market-south','Market / arrival bridge',[-145,242],[-72,242],7),
|
||||
('arrival-viaduct','Arrival viaduct',[5,321],[5,383],11)]:
|
||||
pts=np.linspace(a,b,int(np.linalg.norm(np.array(b)-a))+1).round().astype(int)
|
||||
heights=h[pts[:,1]+384,pts[:,0]+384]
|
||||
deck=int(math.ceil(max(heights[0],heights[-1])))+2
|
||||
if i=='arrival-viaduct':deck=max(deck,65)
|
||||
r=route(i,n,[a,b],w,'bridge',deck_y=deck,minimum_ground=float(heights.min()),endpoint_ground=[float(heights[0]),float(heights[-1])]);
|
||||
# Edge lines are appropriate for construction marking; they are not a complete bridge.
|
||||
r['intent']='Mark both parapet alignments and abutments; preserve the river and banks.'
|
||||
r['abutments']=[dict(point=q,ground_y=round(float(y),1),deck_y=deck,future_stairs=(deck-math.floor(y)>4),rise=deck-math.floor(y)) for q,y in zip([a,b],[heights[0],heights[-1]])]
|
||||
# Scenic branches never substitute for main circulation.
|
||||
for i,n,p in[
|
||||
('north-overlook-path','North overlook',[[40,-128],[58,-140],[62,-161],[50,-171]]),
|
||||
('portal-overlook-path','Portal ridge path',[[-159,-151],[-136,-158],[-123,-172],[-129,-181]]),
|
||||
('harbor-overlook-path','Harbor lookout trail',[[196,-80],[213,-75],[221,-76]]),
|
||||
('garden-overlook-path','Garden lookout trail',[[232,96],[249,99],[259,107]]),
|
||||
('south-overlook-path','Valley lookout trail',[[-218,236],[-224,219],[-248,223],[-263,240],[-264,251]])]:route(i,n,p,3,'secondary')
|
||||
# Geometric checks on the immutable approved field; live blocks must still be checked before writes.
|
||||
zz,xx=np.mgrid[-384:384,-384:384]
|
||||
for f in features:
|
||||
pts=np.array(f['points']);sample=h[np.clip(pts[:,1]+384,0,767),np.clip(pts[:,0]+384,0,767)]
|
||||
if f['type']=='polygon':
|
||||
x0,z0=pts.min(axis=0);x1,z1=pts.max(axis=0);px,pz=np.meshgrid(np.arange(x0,x1+1),np.arange(z0,z1+1))
|
||||
mask=MplPath(pts).contains_points(np.column_stack((px.flat,pz.flat)),radius=.01).reshape(px.shape)
|
||||
sample=h[z0+384:z1+385,x0+384:x1+385][mask]
|
||||
f['ground']={'min':round(float(sample.min()),2),'max':round(float(sample.max()),2),'water_samples':int((sample<48).sum()),'samples':len(sample)}
|
||||
for r in routes:
|
||||
pts=np.array(r['points']);v=h[pts[:,1]+384,pts[:,0]+384]
|
||||
ds=np.linalg.norm(np.diff(pts,axis=0),axis=1);grade=np.abs(np.diff(v))/np.maximum(ds,.001)
|
||||
r['analysis']={'length':round(float(ds.sum()),1),'ground_min':round(float(v.min()),1),'ground_max':round(float(v.max()),1),'p95_raw_grade':round(float(np.percentile(grade,95)),2),'water_samples':int((v<48).sum())}
|
||||
plan={'schema':'shacraft-layout-study-v1','world':'shacraft_lobby_v2','bounds':{'min_x':-384,'max_x':383,'min_z':-384,'max_z':383},'water_y':48,'status':'offline design; not a world mutation or live snapshot','districts':districts,'features':features,'routes':routes,'construction_notes':[
|
||||
'Colored outlines are building reservations, not instructions to level their entire bounding boxes.',
|
||||
'Walks remain aligned to terrain; steep local runs need short stairs during the later building phase.',
|
||||
'Main avenues 9 blocks, district ring 7, side paths 5, scenic trails 3; marker widths can be thinner than final clear widths.',
|
||||
'Keep protected water intact. Bridges have a separate planned deck Y above their endpoint terrain.',
|
||||
'The natural western lake bank is too steep for a main ring street. The main route uses the calm inner/east lake shoulder; the low over-water boardwalk is secondary.',
|
||||
'No minigame arenas. Six portal bays, three harbor piers, one future flagship reservation.'
|
||||
]}
|
||||
(OUT/'layout.json').write_text(json.dumps(plan,indent=2)+'\n')
|
||||
# Render a design review from data, not a Minecraft screenshot.
|
||||
dz,dx=np.gradient(h);rock=np.clip((np.hypot(dx,dz)-.55)/1.5,0,1)[...,None]
|
||||
rgb=np.array([.32,.40,.27])*(1-rock)+np.array([.49,.49,.46])*rock
|
||||
rgb=LightSource(315,42).shade_rgb(rgb,h,vert_exag=1,blend_mode='soft');rgb=np.where((h<48)[...,None],np.array([.13,.28,.35]),rgb)
|
||||
fig,ax=plt.subplots(figsize=(14,14),facecolor='#151b18');ax.set_facecolor('#151b18')
|
||||
ax.imshow(rgb,extent=(-384,384,384,-384))
|
||||
for r in routes:
|
||||
p=np.array(r['points']);lw=2.2 if r['role']!='secondary' else 1
|
||||
ax.plot(p[:,0],p[:,1],color='#f7f2de' if r['role']!='bridge' else '#ffffff',lw=lw,alpha=.92,linestyle='-' if r['role']!='bridge' else '--')
|
||||
for f in features:
|
||||
p=np.array(f['points']);ax.plot(p[:,0],p[:,1],color=f['color'],lw=2 if f['role'] not in ('detail','promenade') else 1.25)
|
||||
for d in districts:
|
||||
if d['id']=='09':continue
|
||||
x,z=d['label'];ax.text(x,z,d['id'],color='#111914',ha='center',va='center',fontsize=12,fontweight='bold',bbox=dict(boxstyle='circle,pad=.35',fc=d['color'],ec='#111914',lw=1))
|
||||
ax.set(xlim=(-325,310),ylim=(384,-255),xlabel='X / blocks',ylabel='Z / blocks; north up')
|
||||
ax.set_title('SHACRAFT / terrain-adapted lobby layout',loc='left',color='#f5f3ea',fontsize=19,pad=22)
|
||||
ax.tick_params(colors='#b5c0b8');ax.xaxis.label.set_color('#b5c0b8');ax.yaxis.label.set_color('#b5c0b8')
|
||||
for i,d in enumerate(districts):
|
||||
fig.text(.085+(i%3)*.302,.065-(i//3)*.021,d['id']+' '+d['name'],color=d['color'],fontsize=11)
|
||||
fig.text(.085,.009,'DESIGN STUDY • Exact approved height field; outlines not yet placed. White: routes / dashed: future bridge spans.',color='#b5c0b8',fontsize=9)
|
||||
fig.subplots_adjust(left=.06,right=.985,top=.945,bottom=.11)
|
||||
fig.savefig(OUT/'layout-preview.png',dpi=140);plt.close(fig)
|
||||
print(json.dumps({'features':len(features),'routes':len(routes),'path_length':round(sum(r['analysis']['length']for r in routes)), 'output':str(OUT)}))
|
||||
print('BUILDING HEIGHT SPANS')
|
||||
for f in features:
|
||||
if f['role']=='building':print(f['id'],f['ground'])
|
||||
print('BRIDGES')
|
||||
for r in routes:
|
||||
if r['role']=='bridge':print(r['id'],r['deck_y'],r['endpoint_ground'],r['analysis'])
|
||||
+2
-1
@@ -15,6 +15,7 @@ spec = importlib.util.spec_from_file_location('layout_terrain', Path(__file__).w
|
||||
terrain = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(terrain)
|
||||
ROOT = terrain.ROOT
|
||||
RUNTIME = terrain.RUNTIME
|
||||
MAX_BLOCKS = 4096
|
||||
MAX_OPERATIONS = 256
|
||||
MAX_READ_CELLS = 32
|
||||
@@ -287,7 +288,7 @@ def main():
|
||||
if name != 'undo':
|
||||
p.add_argument('layout', type=Path)
|
||||
p.add_argument('--manifest', type=Path, required=True)
|
||||
p.add_argument('--config', type=Path, default=ROOT / '.runtime/server/plugins/MinecraftBuilderMCP/config.yml')
|
||||
p.add_argument('--config', type=Path, default=RUNTIME / 'server/plugins/MinecraftBuilderMCP/config.yml')
|
||||
p.add_argument('--console', action='store_true', help='Only for an explicitly configured isolated fixture')
|
||||
if name != 'prepare':
|
||||
p.add_argument('--execute', action='store_true', required=True, help='Explicitly perform this world edit')
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compile concealed station lighting against an explicit observed snapshot.
|
||||
|
||||
No world I/O. Each fitting replaces full cubes only: brown glass forms a flush
|
||||
floor tile or a recessed ceiling lens, with glowstone hidden directly behind it.
|
||||
The caller applies and independently verifies the resulting checked recipe.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from collections import Counter
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
STAGE = ROOT / ".runtime/station-stage07"
|
||||
GLOW = "minecraft:glowstone"
|
||||
LENS = "minecraft:brown_stained_glass"
|
||||
WOOD = "minecraft:spruce_planks"
|
||||
CREAM = "minecraft:smooth_sandstone"
|
||||
|
||||
|
||||
def _module(name, path):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
value = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(value)
|
||||
return value
|
||||
|
||||
|
||||
survey = _module("station_lighting_survey", ROOT / "scripts/foundation-survey.py")
|
||||
|
||||
|
||||
def compile_lighting(before, station):
|
||||
if station["scope"] != before.scope:
|
||||
raise ValueError("Station metadata and observed snapshot have different scopes")
|
||||
footprint = {tuple(p) for p in station["footprint"]}
|
||||
inside = {(x, z) for x, z in footprint
|
||||
if all((x + dx, z + dz) in footprint
|
||||
for dx in range(-2, 3) for dz in range(-2, 3))}
|
||||
targets = {feet: {(t["x"] + dx, t["z"] + dz)
|
||||
for t in station["interior"]["navigation"][str(feet)]["named_targets"]
|
||||
for dx in range(-1, 2) for dz in range(-1, 2)}
|
||||
for feet in (99, 113)}
|
||||
# Keep both cabin landings, controls, doorway and immediate approach unchanged.
|
||||
lift_exclusion = {(x, z) for x in range(-11, 0) for z in range(-125, -112)}
|
||||
changes = {}
|
||||
fixtures = []
|
||||
|
||||
def full(state):
|
||||
return state.split("[", 1)[0].removeprefix("minecraft:") in survey.FULL
|
||||
|
||||
def put(x, y, z, value, group):
|
||||
if (x, z) not in inside or not 97 <= y <= 124:
|
||||
raise ValueError(f"Lighting write outside authorized volume: {(x, y, z)}")
|
||||
old = before.state(x, y, z)
|
||||
if not full(old) or not full(value):
|
||||
raise ValueError(f"Lighting may replace full cubes only: {(x, y, z)} {old}")
|
||||
if old == value:
|
||||
return
|
||||
p = (x, y, z)
|
||||
if p in changes and changes[p]["block"] != value:
|
||||
raise ValueError(f"Lighting layers disagree at {p}")
|
||||
changes[p] = {"x": x, "y": y, "z": z, "expected": old,
|
||||
"block": value, "group": group}
|
||||
|
||||
for feet, ceiling in ((99, 110), (113, 123)):
|
||||
public = {(p["x"], p["z"]) for p in station["interior"]["walk_points_by_floor"][str(feet)]}
|
||||
public &= inside
|
||||
candidates = {"floor": set(), "ceiling": set()}
|
||||
for x, z in sorted(public - lift_exclusion):
|
||||
# Existing flooring mosaics and green/brass bands are not recoloured.
|
||||
if (x, z) not in targets[feet] and before.state(x, feet - 1, z) == CREAM:
|
||||
neighborhood = {(x + dx, z + dz) for dx in range(-1, 2) for dz in range(-1, 2)}
|
||||
if neighborhood <= public and all(
|
||||
before.state(xx, y, zz) in survey.AIR
|
||||
for xx, zz in neighborhood for y in range(feet, feet + 4)
|
||||
) and full(before.state(x, feet - 2, z)):
|
||||
candidates["floor"].add((x, z))
|
||||
# Plain spruce cells only: beams, chains, chandeliers, panels and lift
|
||||
# geometry are excluded by the observed material and air-column tests.
|
||||
if (before.state(x, ceiling, z) == WOOD
|
||||
and full(before.state(x, ceiling + 1, z))
|
||||
and all(before.state(x, y, z) in survey.AIR for y in range(feet, ceiling))):
|
||||
candidates["ceiling"].add((x, z))
|
||||
|
||||
for kind in ("floor", "ceiling"):
|
||||
chosen = set()
|
||||
# A common architectural grid; a maximum two-block adjustment avoids
|
||||
# a column, mosaic or ceiling beam without creating dense bright rows.
|
||||
for gx in range(-69, 40, 9):
|
||||
for gz in range(-139, -83, 9):
|
||||
nearby = [(x, z) for x in range(gx - 2, gx + 3)
|
||||
for z in range(gz - 2, gz + 3)
|
||||
if (x, z) in candidates[kind]]
|
||||
nearby.sort(key=lambda p: ((p[0] - gx) ** 2 + (p[1] - gz) ** 2,
|
||||
abs(p[0] - gx) + abs(p[1] - gz), p))
|
||||
selected = next((p for p in nearby if all(
|
||||
(p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2 >= 36 for q in chosen)), None)
|
||||
if selected is None:
|
||||
continue
|
||||
x, z = selected
|
||||
chosen.add(selected)
|
||||
lens_y, emit_y = (feet - 1, feet - 2) if kind == "floor" else (ceiling, ceiling + 1)
|
||||
group = f"station-lighting:{feet}:{kind}"
|
||||
put(x, lens_y, z, LENS, group)
|
||||
put(x, emit_y, z, GLOW, group)
|
||||
fixtures.append({"floor_walk_y": feet, "kind": kind,
|
||||
"lens": [x, lens_y, z], "emitter": [x, emit_y, z],
|
||||
"grid_anchor_xz": [gx, gz]})
|
||||
|
||||
recipe = {"version": 1, "scope": before.scope,
|
||||
"blocks": [changes[p] for p in sorted(changes)]}
|
||||
metadata = {
|
||||
"version": 1, "scope": before.scope, "world_writes": 0,
|
||||
"status": "checked lighting candidate; live application and visual review pending",
|
||||
"style": "Sparse warm floor tiles and small concealed ceiling lenses on a nine-block grid",
|
||||
"grid_spacing": 9, "maximum_anchor_adjustment": 2,
|
||||
"minimum_same_layer_fixture_distance": 6,
|
||||
"owned_y": [97, 124], "perimeter_setback": 2,
|
||||
"changed_states": len(changes), "fixtures": fixtures,
|
||||
"fixture_counts": dict(sorted(Counter(f"{f['floor_walk_y']}:{f['kind']}" for f in fixtures).items())),
|
||||
"changed_materials": dict(Counter(row["block"] for row in changes.values())),
|
||||
"checks": {"only_full_cube_replacements": True, "no_new_collision_obstructions": True,
|
||||
"existing_mosaic_bands_preserved": True, "lift_landings_and_doorway_excluded": True,
|
||||
"floor_fixtures_outside_named_target_neighborhoods": True,
|
||||
"floor_fixtures_have_clear_three_by_three_surrounds": True,
|
||||
"ceiling_lenses_replace_only_plain_spruce": True,
|
||||
"decorative_furniture_and_diorama_preserved": True},
|
||||
"limits": "Fixture geometry does not predict the client's final rendered brightness. Review actual Minecraft images after applying and waiting for lighting updates.",
|
||||
}
|
||||
return recipe, metadata
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--before", type=Path, default=STAGE / "after.json.gz")
|
||||
parser.add_argument("--station-metadata", type=Path, default=STAGE / "compiled/station.metadata.json")
|
||||
parser.add_argument("--output", type=Path, default=STAGE / "lighting")
|
||||
args = parser.parse_args()
|
||||
recipe, metadata = compile_lighting(survey.load_snapshot(args.before), json.loads(args.station_metadata.read_text()))
|
||||
metadata["compiled_at_utc"] = datetime.now(timezone.utc).isoformat()
|
||||
metadata["inputs"] = {str(path): hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
for path in (args.before, args.station_metadata, Path(__file__))}
|
||||
args.output.mkdir(parents=True, exist_ok=True)
|
||||
for name, doc in (("lighting.json", recipe), ("lighting.metadata.json", metadata)):
|
||||
destination = args.output / name
|
||||
if destination.exists():
|
||||
raise FileExistsError(f"Preserve the previous candidate: {destination}")
|
||||
destination.write_text(json.dumps(doc, indent=2) + "\n")
|
||||
print(json.dumps({"recipe": str(args.output / "lighting.json"), "changed_states": len(recipe["blocks"]),
|
||||
"fixture_counts": metadata["fixture_counts"]}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -24,6 +24,7 @@ import uuid
|
||||
import zlib
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
RUNTIME = Path(os.environ.get('MCB_RUNTIME_DIR') or ROOT / '.runtime').expanduser().resolve()
|
||||
MAX_RESPONSE = 12 * 1024 * 1024
|
||||
PNG_SIGNATURE = b'\x89PNG\r\n\x1a\n'
|
||||
SAFE_METADATA = {
|
||||
@@ -271,7 +272,7 @@ def run(args):
|
||||
safe_json = json.dumps(metadata, ensure_ascii=False, allow_nan=False, indent=2).encode('utf-8') + b'\n'
|
||||
if agent_token.encode() in safe_json or camera_token.encode() in safe_json:
|
||||
raise TestFailure('Refusing to save metadata containing a component secret.')
|
||||
directory = ROOT / '.runtime/camera-test'
|
||||
directory = RUNTIME / 'camera-test'
|
||||
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
prefix = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ') + '-' + capture_id[:8]
|
||||
image_path, metadata_path = directory / (prefix + '.png'), directory / (prefix + '.json')
|
||||
@@ -286,7 +287,7 @@ def run(args):
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument('--config', type=Path, default=ROOT / '.runtime/server/plugins/MinecraftBuilderMCP/config.yml')
|
||||
parser.add_argument('--config', type=Path, default=Path(os.environ.get('MCB_CAMERA_PAPER_CONFIG') or RUNTIME / 'server/plugins/MinecraftBuilderMCP/config.yml').expanduser())
|
||||
source = parser.add_mutually_exclusive_group()
|
||||
source.add_argument('--camera-id', default='test', help='Saved in-game camera name; default: test')
|
||||
source.add_argument('--pose', type=float, nargs=5, metavar=('X', 'Y', 'Z', 'YAW', 'PITCH'), help='Explicit feet position and viewing angles')
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compile the Shacraft spatial study into checked, reversible survey blocks.
|
||||
|
||||
This only writes a desired-block document. scripts/layout.py performs live edits.
|
||||
Ground lines replace one observed surface block; they never level terrain.
|
||||
"""
|
||||
import argparse
|
||||
from collections import Counter
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
COLORS = {'01': 'lime', '02': 'yellow', '03': 'purple', '04': 'cyan',
|
||||
'05': 'red', '06': 'orange', '07': 'white', '08': 'blue', '09': 'pink'}
|
||||
RGB = {'lime':'#98d84d','yellow':'#f6d34a','purple':'#9460ce','cyan':'#23b6b6',
|
||||
'green':'#527c31','orange':'#f78d27','white':'#f0f0e6','blue':'#4a69d8',
|
||||
'pink':'#f394b5','black':'#26282d','light_gray':'#a4aaa6','gray':'#545c61',
|
||||
'red':'#e3544b','light_blue':'#68c8ec'}
|
||||
FONT = {
|
||||
'0':['111','101','101','101','111'], '1':['010','110','010','010','111'],
|
||||
'2':['111','001','111','100','111'], '3':['111','001','111','001','111'],
|
||||
'4':['101','101','111','001','001'], '5':['111','100','111','001','111'],
|
||||
'6':['111','100','111','101','111'], '7':['111','001','010','010','010'],
|
||||
'8':['111','101','111','101','111'], '9':['111','101','111','001','111'],
|
||||
'S':['111','100','111','001','111'],
|
||||
}
|
||||
|
||||
|
||||
def raster_line(points, radius=.6):
|
||||
"""Integer columns whose centres meet a piecewise line, with no diagonal holes."""
|
||||
result=set()
|
||||
for (ax,az),(bx,bz) in zip(points,points[1:]):
|
||||
dx,dz=bx-ax,bz-az; length=dx*dx+dz*dz
|
||||
for z in range(math.floor(min(az,bz)-radius),math.ceil(max(az,bz)+radius)+1):
|
||||
for x in range(math.floor(min(ax,bx)-radius),math.ceil(max(ax,bx)+radius)+1):
|
||||
t=max(0,min(1,((x-ax)*dx+(z-az)*dz)/length)) if length else 0
|
||||
if (x-ax-t*dx)**2+(z-az-t*dz)**2 <= radius**2:
|
||||
result.add((x,z))
|
||||
return result
|
||||
|
||||
|
||||
def offset(points, distance):
|
||||
result=[]
|
||||
for i,(x,z) in enumerate(points):
|
||||
a=points[max(0,i-3)];b=points[min(len(points)-1,i+3)]
|
||||
dx,dz=b[0]-a[0],b[1]-a[1];norm=math.hypot(dx,dz) or 1
|
||||
result.append((x-dz/norm*distance,z+dx/norm*distance))
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--study',type=Path,required=True);p.add_argument('--surface',type=Path,required=True)
|
||||
p.add_argument('--scope',type=Path,required=True);p.add_argument('--output',type=Path,required=True)
|
||||
args=p.parse_args();study=json.loads(args.study.read_text());surface=json.loads(args.surface.read_text())
|
||||
scope=json.loads(args.scope.read_text())
|
||||
if surface['world_uuid']!=scope['world_id'] or surface['world']!=study['world']:
|
||||
raise ValueError('Study, world surface and authorization scope disagree')
|
||||
W=surface['width'];MINX=surface['min_x'];MINZ=surface['min_z']
|
||||
heights=surface['surface_y'];palette=surface['palette'];materials=surface['material_index']
|
||||
desired={}; priorities={};skipped=Counter();raised=[]
|
||||
|
||||
def at(x,z):
|
||||
if not MINX<=x<=surface['max_x'] or not MINZ<=z<=surface['max_z']:
|
||||
return None,None
|
||||
i=(z-MINZ)*W+x-MINX
|
||||
return heights[i],palette[materials[i]]
|
||||
|
||||
def put(x,z,color,group,priority=10,y=None):
|
||||
x,z=round(x),round(z);top,material=at(x,z)
|
||||
if top is None:skipped['outside']+=1;return
|
||||
if y is None:
|
||||
if material=='minecraft:water':skipped['water']+=1;return
|
||||
y=max(50,top)
|
||||
if y<top:
|
||||
# Fixed-height architectural reservations can cross rising land. Raise
|
||||
# only the visible survey stroke; never excavate unknown subsurface.
|
||||
raised.append({'x':x,'z':z,'requested_y':y,'surface_y':top,'group':group});y=top
|
||||
if y<50:raise ValueError('Survey stroke would contact water')
|
||||
expected=material if y==top else 'minecraft:air'
|
||||
if expected=='minecraft:water':raise ValueError('Refusing to replace water')
|
||||
if expected=='minecraft:grass_block':expected+='[snowy=false]'
|
||||
block=('minecraft:'+color+'_concrete') if color in RGB else ('minecraft:'+color)
|
||||
key=(x,y,z)
|
||||
if priority>=priorities.get(key,-1):
|
||||
desired[key]={'x':x,'y':y,'z':z,'block':block,'expected':expected,'group':group}
|
||||
priorities[key]=priority
|
||||
|
||||
def stroke(points,color,group,radius=.6,priority=10,y=None):
|
||||
for x,z in sorted(raster_line(points,radius)):
|
||||
put(x,z,color,group,priority,y)
|
||||
|
||||
def text(value,cx,cz,color,group,scale=2):
|
||||
width=(len(value)*4-1)*scale
|
||||
for j,char in enumerate(value):
|
||||
for row,bits in enumerate(FONT[char]):
|
||||
for col,bit in enumerate(bits):
|
||||
if bit=='1':
|
||||
for dz in range(scale):
|
||||
for dx in range(scale):
|
||||
put(cx-width//2+j*4*scale+col*scale+dx,cz-5*scale//2+row*scale+dz,
|
||||
color,group,35)
|
||||
|
||||
# The main route is a corridor reservation, with a clear green interior.
|
||||
# Thin edge lines and spaced centre ticks keep the terrain visually dominant.
|
||||
for route in study['routes']:
|
||||
pts=route['points'];group=route['id'];width=route['width'];deck=route.get('deck_y')
|
||||
is_bridge=route['role']=='bridge'
|
||||
edgecolor='white' if width>=5 else 'light_gray'
|
||||
if is_bridge:edgecolor='white'
|
||||
for side in (-1,1):
|
||||
stroke(offset(pts,side*(width-1)/2),edgecolor,group,.65,10,deck)
|
||||
if width>=7:
|
||||
for i in range(0,len(pts),18):
|
||||
stroke(pts[i:i+3],'light_gray',group,.55,11,deck)
|
||||
if is_bridge:
|
||||
# Cross ties define the future deck without filling it or the river.
|
||||
for i in range(0,len(pts),12):
|
||||
a=offset(pts,-(width-1)/2)[i];b=offset(pts,(width-1)/2)[i]
|
||||
stroke([a,b],'red' if group=='arrival-viaduct' else 'light_gray',group,.55,12,deck)
|
||||
for end in (0,-1):
|
||||
for side in (-1,1):
|
||||
x,z=offset(pts,side*(width-1)/2)[end]
|
||||
for y in range(deck+1,deck+5):put(x,z,'white',group,28,y)
|
||||
put(x,z,'glowstone',group,29,deck+5)
|
||||
|
||||
for feature in study['features']:
|
||||
color=COLORS[feature['district']];role=feature['role'];group=feature['id']
|
||||
pts=feature['points'];deck=feature.get('deck_y')
|
||||
if feature['type']=='polygon' and pts[0]!=pts[-1]:pts=pts+[pts[0]]
|
||||
radius=1.05 if role in ('building','pier') or group=='arrival-hex' else .65
|
||||
stroke(pts,color,group,radius,20,deck)
|
||||
if role=='building':
|
||||
# Four sparse survey stakes make footprints recognizable at eye level.
|
||||
for point in [pts[i] for i in sorted(set([0,(len(pts)-1)//4,(len(pts)-1)//2,3*(len(pts)-1)//4]))]:
|
||||
x,z=point;top,_=at(x,z)
|
||||
if top is None:continue
|
||||
for y in range(top+1,top+4):put(x,z,color,group,27,y)
|
||||
put(x,z,'glowstone',group,28,top+4)
|
||||
|
||||
# Wayfinding IDs appear as blocks as well as external map labels.
|
||||
# Small districts use one-block pixels to keep their reservations readable.
|
||||
for district in study['districts']:
|
||||
ident=district['id'];x,z=district['label'];color=COLORS[ident]
|
||||
if ident=='09':continue
|
||||
x,z={'01':(-22,27),'07':(26,271),'08':(-136,63)}.get(ident,(x,z))
|
||||
text(ident,x,z,'white','label-'+ident,1 if ident=='08' else 2)
|
||||
# The brand medallion remains a reserved ring; its S is a simple block glyph.
|
||||
text('S',0,8,'lime','spawn-monogram',3)
|
||||
|
||||
# Monumental colored posts stand outside the main entry points, never in a path.
|
||||
posts=[('01',33,48),('02',11,-73),('03',-151,-125),('04',197,-97),
|
||||
('05',174,75),('06',-171,226),('07',18,287),('08',-132,43)]
|
||||
labels=[]
|
||||
for ident,x,z in posts:
|
||||
top,material=at(x,z)
|
||||
if material=='minecraft:water':raise ValueError('Wayfinding post in water')
|
||||
color=COLORS[ident];group='wayfinding-'+ident
|
||||
for xx in range(x-1,x+2):
|
||||
for zz in range(z-1,z+2):put(xx,zz,color,group,40)
|
||||
for y in range(top+1,top+9):put(x,z,color,group,40,y)
|
||||
put(x,z,'glowstone',group,41,top+9)
|
||||
for xx in (x-1,x+1):put(xx,z,color,group,40,top+7)
|
||||
district=next(d for d in study['districts'] if d['id']==ident)
|
||||
labels.append({'id':ident,'name':district['name'],'x':x+.5,'y':top+11,'z':z+.5,'color':RGB[color]})
|
||||
|
||||
blocks=sorted(desired.values(),key=lambda b:(b['z']//16,b['x']//16,b['y']//16,b['y'],b['z'],b['x']))
|
||||
out={'version':1,'scope':scope,'blocks':blocks}
|
||||
args.output.parent.mkdir(parents=True,exist_ok=True)
|
||||
args.output.write_text(json.dumps(out,separators=(',',':'))+'\n')
|
||||
metadata={'source_surface':str(args.surface),'world':surface['world'],'blocks':len(blocks),
|
||||
'by_material':dict(Counter(b['block'] for b in blocks)),
|
||||
'by_group':dict(Counter(b['group'] for b in blocks)),
|
||||
'skipped_strokes':dict(skipped),'raised_fixed_height_strokes':raised,
|
||||
'wayfinding_labels':labels,'districts':study['districts'],
|
||||
'note':'Surface contours preserve height; bridge outlines reserve a future deck. Walkability requires later paths and stairs.'}
|
||||
args.output.with_suffix('.metadata.json').write_text(json.dumps(metadata,indent=2)+'\n')
|
||||
print(json.dumps({'blocks':len(blocks),'groups':len(metadata['by_group']),
|
||||
'skipped':dict(skipped),'raised_fixed_height_strokes':len(raised)}))
|
||||
|
||||
|
||||
if __name__=='__main__':main()
|
||||
@@ -1,177 +0,0 @@
|
||||
"""Small, stateless voxel assets for the Shacraft arrival gardens.
|
||||
|
||||
Every public builder returns ``{(x, y, z): full_block_state}`` in local block
|
||||
coordinates. Origin Y is the first air block above the paving/soil: translate
|
||||
by Y96 for a paving block at Y95. No builder reads or changes a Minecraft world.
|
||||
Trees need soil under their trunk; the composer owns foundations, collision
|
||||
checks, leaf-distance propagation, block connection updates, and placement.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
|
||||
Position = tuple[int, int, int]
|
||||
Asset = dict[Position, str]
|
||||
_DIRECTIONS = ("north", "east", "south", "west")
|
||||
|
||||
|
||||
def _state(material: str, **properties: object) -> str:
|
||||
suffix = ",".join(
|
||||
f"{key}={str(value).lower()}" for key, value in sorted(properties.items())
|
||||
)
|
||||
return f"minecraft:{material}" + (f"[{suffix}]" if suffix else "")
|
||||
|
||||
|
||||
def _stair(material: str, facing: str) -> str:
|
||||
return _state(material, facing=facing, half="bottom", shape="straight", waterlogged=False)
|
||||
|
||||
|
||||
def _connections(material: str, *directions: str) -> str:
|
||||
return _state(material, **{d: d in directions for d in _DIRECTIONS}, waterlogged=False)
|
||||
|
||||
|
||||
def _rotate(asset: Asset, quarter_turns: int) -> Asset:
|
||||
"""Rotate north to east, including stair backs and fence/bar connections."""
|
||||
result: Asset = {}
|
||||
direction = {d: _DIRECTIONS[(i + quarter_turns) % 4] for i, d in enumerate(_DIRECTIONS)}
|
||||
for (x, y, z), state in asset.items():
|
||||
for _ in range(quarter_turns):
|
||||
x, z = -z, x
|
||||
if "[" in state:
|
||||
material, raw = state[:-1].split("[", 1)
|
||||
properties = dict(pair.split("=", 1) for pair in raw.split(","))
|
||||
properties = {direction.get(k, k): direction.get(v, v) for k, v in properties.items()}
|
||||
if quarter_turns % 2 and properties.get("axis") in ("x", "z"):
|
||||
properties["axis"] = "z" if properties["axis"] == "x" else "x"
|
||||
state = _state(material.removeprefix("minecraft:"), **properties)
|
||||
result[x, y, z] = state
|
||||
return result
|
||||
|
||||
|
||||
def conifer(height: int = 13, seed: int = 0) -> Asset:
|
||||
"""A narrow spruce with irregular connected whorls and three clear trunk rows.
|
||||
|
||||
Height is the occupied block count, 11..15. The maximum canopy radius is
|
||||
three blocks, and foliage begins at local Y3. Leaves deliberately start at
|
||||
distance=7; recompute distances against the composed world before applying.
|
||||
"""
|
||||
if type(height) is not int or not 11 <= height <= 15:
|
||||
raise ValueError("Conifer height must be an integer from 11 through 15")
|
||||
if type(seed) is not int:
|
||||
raise ValueError("Conifer seed must be an integer")
|
||||
rng = random.Random(seed)
|
||||
phase = rng.uniform(0, math.tau)
|
||||
phase2 = rng.uniform(0, math.tau)
|
||||
leaves = _state("spruce_leaves", distance=7, persistent=True, waterlogged=False)
|
||||
asset: Asset = {}
|
||||
for y in range(3, height):
|
||||
progress = (y - 3) / (height - 4)
|
||||
tier = (0.30, -0.30, -0.65)[(y - 3) % 3]
|
||||
radius = max(0.0, 2.95 * (1.0 - progress) ** 0.85 + tier)
|
||||
if y == height - 1:
|
||||
radius = 0.0
|
||||
for x in range(-3, 4):
|
||||
for z in range(-3, 4):
|
||||
angle = math.atan2(z, x)
|
||||
edge = radius + 0.23 * math.cos(4 * angle + phase) + 0.16 * math.sin(3 * angle + phase2)
|
||||
edge += rng.uniform(-0.10, 0.10)
|
||||
if (x == 0 and z == 0) or math.hypot(x, z) <= edge:
|
||||
asset[x, y, z] = leaves
|
||||
# Leave a connected green leader above the last woody branch; overwriting
|
||||
# these narrow upper layers with logs creates visible brown pegs at the tip.
|
||||
for y in range(height - 5):
|
||||
asset[0, y, 0] = _state("spruce_log", axis="y")
|
||||
# Angular variation can leave a diagonal-only leaf at a narrow upper tier.
|
||||
# Keep the face-connected canopy so no isolated foliage floats beside it.
|
||||
connected = {(0, 0, 0)}
|
||||
pending = [(0, 0, 0)]
|
||||
while pending:
|
||||
x, y, z = pending.pop()
|
||||
for dx, dy, dz in ((1, 0, 0), (-1, 0, 0), (0, 1, 0), (0, -1, 0), (0, 0, 1), (0, 0, -1)):
|
||||
neighbor = (x + dx, y + dy, z + dz)
|
||||
if neighbor in asset and neighbor not in connected:
|
||||
connected.add(neighbor)
|
||||
pending.append(neighbor)
|
||||
return {position: state for position, state in asset.items() if position in connected}
|
||||
|
||||
|
||||
def bench(length: int = 5, facing: str = "north") -> Asset:
|
||||
"""A 3..5 block overall bench, including its two stone armrests.
|
||||
|
||||
``facing`` is the seated viewer's direction, not Minecraft's stair-facing
|
||||
property: a north-looking seat has a south-facing stair/high back. The
|
||||
default occupies X=-2..2, Z=0..1, Y=0; its front opens toward negative Z.
|
||||
The fence back has Minecraft's 1.5-block collision height. This is decorative
|
||||
seating and does not add a sit interaction.
|
||||
"""
|
||||
if type(length) is not int or not 3 <= length <= 5:
|
||||
raise ValueError("Bench overall length must be an integer from 3 through 5")
|
||||
if facing not in _DIRECTIONS:
|
||||
raise ValueError("Bench facing must be north, east, south, or west")
|
||||
first = -(length // 2)
|
||||
last = first + length - 1
|
||||
asset: Asset = {}
|
||||
for x in range(first + 1, last):
|
||||
asset[x, 0, 0] = _stair("spruce_stairs", "south")
|
||||
asset[x, 0, 1] = _connections("spruce_fence", "north", "east", "west")
|
||||
for x in (first, last):
|
||||
asset[x, 0, 0] = _state("stone_bricks")
|
||||
asset[x, 0, 1] = _state("stone_bricks")
|
||||
return _rotate(asset, _DIRECTIONS.index(facing))
|
||||
|
||||
|
||||
def lamp(height: int = 6) -> Asset:
|
||||
"""A slender warm street lamp with a copper hood and a small brass collar.
|
||||
|
||||
Height is 6..8 occupied blocks. The stone/chain stem occupies one column;
|
||||
the 3x3 cross-shaped housing begins at height-3, above pedestrian headroom.
|
||||
Its hanging lantern has a full copper block immediately above it. The four
|
||||
inward-facing copper stairs form the hood eaves; no trapdoors are used.
|
||||
"""
|
||||
if type(height) is not int or not 6 <= height <= 8:
|
||||
raise ValueError("Lamp height must be an integer from 6 through 8")
|
||||
collar_y = height - 3
|
||||
light_y = height - 2
|
||||
roof_y = height - 1
|
||||
asset: Asset = {
|
||||
(0, 0, 0): _state("chiseled_stone_bricks"),
|
||||
(0, 1, 0): _state("stone_brick_wall", east="none", north="none", south="none", up=True, waterlogged=False, west="none"),
|
||||
(0, collar_y, 0): _state("gold_block"),
|
||||
(0, light_y, 0): _state("lantern", hanging=True, waterlogged=False),
|
||||
(0, roof_y, 0): _state("waxed_oxidized_cut_copper"),
|
||||
}
|
||||
for y in range(2, collar_y):
|
||||
asset[0, y, 0] = _state("iron_chain", axis="y", waterlogged=False)
|
||||
for x, z, inward in ((-1, 0, "east"), (1, 0, "west"), (0, -1, "south"), (0, 1, "north")):
|
||||
asset[x, collar_y, z] = _connections("iron_bars", inward)
|
||||
asset[x, light_y, z] = _connections("iron_bars")
|
||||
asset[x, roof_y, z] = _stair("waxed_oxidized_cut_copper_stairs", inward)
|
||||
return asset
|
||||
|
||||
|
||||
def describe(asset: Asset) -> dict:
|
||||
"""Compact occupied bounds and ground contact cells for the layout composer."""
|
||||
if not asset:
|
||||
raise ValueError("Cannot describe an empty asset")
|
||||
low = [min(p[i] for p in asset) for i in range(3)]
|
||||
high = [max(p[i] for p in asset) for i in range(3)]
|
||||
return {
|
||||
"blocks": len(asset),
|
||||
"min": dict(zip(("x", "y", "z"), low)),
|
||||
"max": dict(zip(("x", "y", "z"), high)),
|
||||
"size": dict(zip(("x", "y", "z"), (high[i] - low[i] + 1 for i in range(3)))),
|
||||
"ground_contacts": sorted([x, z] for x, y, z in asset if y == 0),
|
||||
"materials": sorted({state.split("[", 1)[0] for state in asset.values()}),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(json.dumps({
|
||||
"coordinate_convention": "Local Y0 is first air above paving; add Y96 over paving Y95.",
|
||||
"conifer": describe(conifer()),
|
||||
"bench_north": describe(bench()),
|
||||
"lamp": describe(lamp()),
|
||||
}, indent=2))
|
||||
@@ -1,254 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,83 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render a survey atlas from live surface data; optional blocks are a labelled preview."""
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
import matplotlib
|
||||
matplotlib.use('Agg')
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.colors import to_rgb, LightSource
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
COLOR={'01':'#98d84d','02':'#f6d34a','03':'#9460ce','04':'#23b6b6','05':'#e3544b',
|
||||
'06':'#f78d27','07':'#f0f0e6','08':'#4a69d8','09':'#f394b5'}
|
||||
BLOCK={'lime':'#98d84d','yellow':'#f6d34a','purple':'#9460ce','cyan':'#23b6b6',
|
||||
'green':'#527c31','orange':'#f78d27','white':'#f0f0e6','blue':'#4a69d8',
|
||||
'pink':'#f394b5','black':'#26282d','light_gray':'#a4aaa6','red':'#e3544b'}
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser();p.add_argument('surface',type=Path);p.add_argument('--study',type=Path,required=True)
|
||||
p.add_argument('--blocks',type=Path);p.add_argument('--output',type=Path,required=True)
|
||||
args=p.parse_args();s=json.loads(args.surface.read_text());study=json.loads(args.study.read_text())
|
||||
h=np.array(s['surface_y']).reshape(s['length'],s['width']).astype(float)
|
||||
indexes=np.array(s['material_index']).reshape(h.shape);palette=list(s['palette']);colors=list(s['palette_rgb'])
|
||||
if args.blocks:
|
||||
for b in json.loads(args.blocks.read_text())['blocks']:
|
||||
x,z=b['x']-s['min_x'],b['z']-s['min_z']
|
||||
if b['y']<h[z,x]:continue
|
||||
material=b['block']
|
||||
if material not in palette:
|
||||
palette.append(material)
|
||||
colors.append(BLOCK.get(material.replace('minecraft:','').replace('_concrete',''),'#f4cf58'))
|
||||
indexes[z,x]=palette.index(material);h[z,x]=b['y']
|
||||
rgb=np.array([to_rgb(c) for c in colors])[indexes]
|
||||
dz,dx=np.gradient(h);norm=np.sqrt(dx*dx+dz*dz+1)
|
||||
shade=np.clip(.76+.36*(.55*dx+.55*dz+.63)/norm,.44,1.13)
|
||||
categorical=np.array([m.endswith('_concrete') or m=='minecraft:glowstone' for m in palette])[indexes]
|
||||
water=np.array([m=='minecraft:water' for m in palette])[indexes]
|
||||
shade=np.where(categorical,1,np.where(water,.96,shade))
|
||||
rgb=np.clip(rgb*shade[...,None],0,1)
|
||||
bg='#14211e';fg='#e8eade';muted='#a9bcb0'
|
||||
fig=plt.figure(figsize=(16,12),facecolor=bg)
|
||||
ax=fig.add_axes([.045,.12,.67,.80],facecolor=bg)
|
||||
ax.imshow(rgb,extent=(s['min_x']-.5,s['max_x']+.5,s['max_z']+.5,s['min_z']-.5),interpolation='nearest')
|
||||
ax.set_xlim(-320,319);ax.set_ylim(384,-255)
|
||||
ax.set_xticks(np.arange(-256,320,64));ax.set_yticks(np.arange(-192,384,64))
|
||||
ax.tick_params(colors=muted,labelsize=9);ax.set_xlabel('X / blocks',color=muted,labelpad=8)
|
||||
ax.set_ylabel('Z / blocks · north up',color=muted,labelpad=8)
|
||||
for spine in ax.spines.values():spine.set_color('#607166')
|
||||
for d in study['districts']:
|
||||
x,z=d['label'];ident=d['id']
|
||||
ax.text(x,z-13,ident,ha='center',va='center',fontsize=12,fontweight='bold',color='#14211e',
|
||||
bbox=dict(boxstyle='circle,pad=.30',facecolor=COLOR[ident],edgecolor=fg,linewidth=1.1),zorder=9)
|
||||
ax.annotate('N',xy=(287,-224),xytext=(287,-198),ha='center',color=fg,fontsize=12,
|
||||
arrowprops=dict(arrowstyle='-|>',color=fg,lw=1.5))
|
||||
ax.plot([-288,-224],[355,355],color=fg,lw=3);ax.text(-256,370,'64 blocks',color=fg,ha='center',fontsize=9)
|
||||
fig.text(.06,.953,'SHACRAFT',color=fg,fontsize=28,fontweight='bold')
|
||||
fig.text(.242,.958,'LOBBY / SITE MARKING',color=muted,fontsize=16)
|
||||
fig.text(.746,.881,'DISTRICTS',color=muted,fontsize=12,fontweight='bold')
|
||||
descriptions={
|
||||
'01':'Hexagonal arrival plaza\nCentral Shacraft medallion',
|
||||
'02':'Clock tower + station hall\nPavilions and forecourt',
|
||||
'03':'Portal concourse\nSix individual portal bays',
|
||||
'04':'Airship terminal\nThree piers + flagship reserve',
|
||||
'05':'Palm house · winter garden\nObservatory and garden walks',
|
||||
'06':'Market square\nSix separate building plots',
|
||||
'07':'Arrival avenue and viaduct\nSouthern entrance to the valley',
|
||||
'08':'Lake pumping house\nBoardwalk and viewing terrace',
|
||||
'09':'Scenic overlooks\nSmall optional ridge trails'}
|
||||
for i,d in enumerate(study['districts']):
|
||||
y=.842-i*.067;ident=d['id']
|
||||
fig.text(.749,y,ident,color=COLOR[ident],fontsize=16,fontweight='bold')
|
||||
fig.text(.779,y,descriptions[ident],color=fg,fontsize=10,linespacing=1.55)
|
||||
fig.text(.747,.20,'READING THE MARKS',color=muted,fontsize=11,fontweight='bold')
|
||||
fig.text(.747,.169,'Color = building / courtyard boundary\nWhite = future path edges\nRaised ribs = bridge deck reservation\nLit stakes = corners and wayfinding',color=fg,fontsize=9,linespacing=1.7,va='top')
|
||||
note='DESIGN PREVIEW · proposed blocks over a live survey' if args.blocks else 'ACTUAL WORLD SURFACE · captured after placement · no player camera required'
|
||||
fig.text(.06,.055,note,color=fg,fontsize=10)
|
||||
fig.text(.06,.035,'768 × 768 world · central development shown · terrain heights and water preserved · paths and stairs are still reservations',color=muted,fontsize=9)
|
||||
args.output.parent.mkdir(parents=True,exist_ok=True);fig.savefig(args.output,dpi=150,facecolor=bg);plt.close(fig)
|
||||
print(args.output)
|
||||
|
||||
if __name__=='__main__':main()
|
||||
@@ -1,423 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic, detached exterior for the approved two-floor Shacraft station.
|
||||
|
||||
The caller supplies the exact station foundation cells and combines this shell with
|
||||
the interior before taking expected-state snapshots. This module never reads or
|
||||
writes a Minecraft world. Coordinates and block states are explicit and stable.
|
||||
"""
|
||||
|
||||
from collections import Counter, defaultdict
|
||||
from math import ceil, hypot
|
||||
|
||||
|
||||
AIR = "minecraft:air"
|
||||
STONE = "minecraft:stone_bricks"
|
||||
DARK = "minecraft:polished_deepslate"
|
||||
CREAM = "minecraft:smooth_sandstone"
|
||||
ASHLAR = "minecraft:cut_sandstone"
|
||||
PALE = "minecraft:smooth_quartz"
|
||||
PILLAR = "minecraft:quartz_pillar[axis=y]"
|
||||
GREEN = "minecraft:waxed_oxidized_cut_copper"
|
||||
DEEP_GREEN = "minecraft:green_concrete"
|
||||
GOLD = "minecraft:gold_block"
|
||||
WOOD = "minecraft:spruce_planks"
|
||||
GLASS = "minecraft:brown_stained_glass"
|
||||
CLEAR_GLASS = "minecraft:glass"
|
||||
LAMP = "minecraft:lantern[hanging=true,waterlogged=false]"
|
||||
CHAIN = "minecraft:iron_chain[axis=y,waterlogged=false]"
|
||||
GLOW = "minecraft:glowstone"
|
||||
CARDINALS = ((0, -1, "north"), (1, 0, "east"), (0, 1, "south"), (-1, 0, "west"))
|
||||
|
||||
|
||||
def _dilate(cells, radius):
|
||||
return {(x + dx, z + dz) for x, z in cells
|
||||
for dx in range(-radius, radius + 1) for dz in range(-radius, radius + 1)}
|
||||
|
||||
|
||||
def _erode(cells, radius):
|
||||
return {(x, z) for x, z in cells if all((x + dx, z + dz) in cells
|
||||
for dx in range(-radius, radius + 1) for dz in range(-radius, radius + 1))}
|
||||
|
||||
|
||||
def _runs(values):
|
||||
result = []
|
||||
for value in sorted(values):
|
||||
if result and value == result[-1][-1] + 1:
|
||||
result[-1].append(value)
|
||||
else:
|
||||
result.append([value])
|
||||
return result
|
||||
|
||||
|
||||
def _line(x0, y0, x1, y1):
|
||||
"""Inclusive integer line for legible clock hands and roof trim."""
|
||||
dx, dy = abs(x1 - x0), -abs(y1 - y0)
|
||||
sx, sy = 1 if x0 < x1 else -1, 1 if y0 < y1 else -1
|
||||
error = dx + dy
|
||||
while True:
|
||||
yield x0, y0
|
||||
if (x0, y0) == (x1, y1):
|
||||
return
|
||||
twice = 2 * error
|
||||
if twice >= dy:
|
||||
error += dy
|
||||
x0 += sx
|
||||
if twice <= dx:
|
||||
error += dx
|
||||
y0 += sy
|
||||
|
||||
|
||||
def compile_exterior(footprint: set[tuple[int, int]], layout: dict):
|
||||
"""Return (block states, ownership groups, metadata), with no external effects.
|
||||
|
||||
Structural floors are exactly the supplied footprint. Interior ornament may
|
||||
replace their finish, but must retain the two complete separating decks.
|
||||
All exterior windows are full glass blocks, including their inner wall layer.
|
||||
Only the south entrance removes wall blocks below the sealed roof space.
|
||||
"""
|
||||
footprint = {(int(x), int(z)) for x, z in footprint}
|
||||
if not footprint:
|
||||
raise ValueError("Station foundation must not be empty")
|
||||
if any(not (-74 <= x <= 40 and -145 <= z <= -83) for x, z in footprint):
|
||||
raise ValueError("Foundation exceeds the approved station envelope")
|
||||
if layout.get("perimeter_wall_thickness", 2) != 2:
|
||||
raise ValueError("The approved shell requires two-block perimeter walls")
|
||||
inner = _erode(footprint, 2)
|
||||
walls = footprint - inner
|
||||
boundary = footprint - _erode(footprint, 1)
|
||||
shadow = _dilate(footprint, 2)
|
||||
maximum_shadow = _dilate(footprint, 3)
|
||||
states, groups = {}, {}
|
||||
|
||||
def put(x, y, z, state, group):
|
||||
x, y, z = int(x), int(y), int(z)
|
||||
if not (-78 <= x <= 44 and -149 <= z <= -80 and 98 <= y <= 160):
|
||||
raise ValueError(f"Exterior escaped approved coordinate bounds: {(x, y, z)}")
|
||||
if (x, z) not in maximum_shadow:
|
||||
raise ValueError(f"Exterior overhang exceeds three blocks: {(x, y, z)}")
|
||||
if y == 98 and (x, z) not in footprint:
|
||||
raise ValueError(f"Ground floor escaped foundation: {(x, y, z)}")
|
||||
states[x, y, z] = state
|
||||
groups[x, y, z] = group
|
||||
|
||||
def box(x0, x1, y0, y1, z0, z1, state, group, mask=None):
|
||||
for z in range(z0, z1 + 1):
|
||||
for x in range(x0, x1 + 1):
|
||||
if mask is not None and (x, z) not in mask:
|
||||
continue
|
||||
for y in range(y0, y1 + 1):
|
||||
put(x, y, z, state, group)
|
||||
|
||||
def stair(material, facing, half="bottom"):
|
||||
return f"minecraft:{material}[facing={facing},half={half},shape=straight,waterlogged=false]"
|
||||
|
||||
# The upper cabin will be stationary; neither deck contains a lift-shaft hole.
|
||||
for x, z in sorted(footprint):
|
||||
for y, state, group in ((98, CREAM, "floor.ground"),
|
||||
(111, WOOD, "floor.intermediate.structure"),
|
||||
(112, CREAM, "floor.upper"),
|
||||
(124, WOOD, "ceiling.upper.structure"),
|
||||
(125, CREAM, "ceiling.upper.seal")):
|
||||
put(x, y, z, state, group)
|
||||
for x, z in sorted(walls):
|
||||
for y in range(99, 125):
|
||||
state = ASHLAR
|
||||
if y == 99:
|
||||
state = DARK
|
||||
elif y == 100:
|
||||
state = STONE
|
||||
elif y in (101, 109, 113, 122, 123):
|
||||
state = CREAM
|
||||
elif y in (110, 111):
|
||||
state = STONE
|
||||
elif y in (112, 124):
|
||||
state = PALE
|
||||
put(x, y, z, state, "wall.masonry")
|
||||
|
||||
# Identify straight stretches from the exact contour, including its eastern recess.
|
||||
faces = []
|
||||
for nx, nz, facing in CARDINALS:
|
||||
planes = defaultdict(list)
|
||||
for x, z in boundary:
|
||||
if (x + nx, z + nz) not in footprint:
|
||||
planes[z if nz else x].append(x if nz else z)
|
||||
for plane, positions in sorted(planes.items()):
|
||||
for run in _runs(positions):
|
||||
if len(run) >= 7:
|
||||
faces.append((nx, nz, facing, plane, run[0], run[-1]))
|
||||
|
||||
def face_position(nx, nz, plane, tangent, depth=0):
|
||||
# Positive depth is toward the inside; negative depth is a relief projection.
|
||||
return (tangent - nx * depth, plane - nz * depth) if nz else (plane - nx * depth, tangent - nz * depth)
|
||||
|
||||
windows, pilasters, facade_lamps = [], [], []
|
||||
for nx, nz, facing, plane, lo, hi in faces:
|
||||
phase = -6 if nz else -119
|
||||
centers = [c for c in range(lo + 3, hi - 2) if (c - phase) % 10 == 0]
|
||||
if not centers and hi - lo >= 8:
|
||||
centers = [(lo + hi) // 2]
|
||||
for center in centers:
|
||||
for base, top in ((102, 108), (115, 122)):
|
||||
# A five-wide pointed arch, enclosed by cream voussoirs and piers.
|
||||
for tangent in range(center - 3, center + 4):
|
||||
delta = abs(tangent - center)
|
||||
for y in range(base - 1, top + 2):
|
||||
cap = top - max(0, delta - 1)
|
||||
opening = delta <= 2 and base <= y <= cap
|
||||
state = GLASS if opening else CREAM
|
||||
if opening and tangent == center and y < top - 1:
|
||||
state = WOOD
|
||||
if opening and y == base + 3:
|
||||
state = WOOD
|
||||
if delta == 3 and y <= top - 1:
|
||||
state = PILLAR
|
||||
for depth in (0, 1):
|
||||
x, z = face_position(nx, nz, plane, tangent, depth)
|
||||
if (x, z) in walls:
|
||||
put(x, y, z, state, "window.frame" if state != GLASS else "window.glass")
|
||||
# Bottom sill projects by one block but never opens the shell.
|
||||
for tangent in range(center - 3, center + 4):
|
||||
x, z = face_position(nx, nz, plane, tangent, -1)
|
||||
if (x, z) in maximum_shadow:
|
||||
put(x, base - 1, z, CREAM, "window.sill")
|
||||
windows.append({"normal": facing, "plane": plane, "center": center,
|
||||
"base_y": base, "apex_y": top, "glass_layers": 2})
|
||||
# Vertical bays use quiet, regular dressed-stone pilasters, not material noise.
|
||||
pier_centers = sorted({lo, hi, *[c + 5 for c in centers if c + 5 <= hi]})
|
||||
for center in pier_centers:
|
||||
x, z = face_position(nx, nz, plane, center)
|
||||
for depth in (0, 1):
|
||||
px, pz = face_position(nx, nz, plane, center, depth)
|
||||
if (px, pz) not in walls:
|
||||
continue
|
||||
for y in range(101, 124):
|
||||
put(px, y, pz, PILLAR if y not in (110, 111, 112) else PALE, "wall.pilaster")
|
||||
pilasters.append([x, z])
|
||||
if center not in (lo, hi) and hi - lo >= 15:
|
||||
px, pz = face_position(nx, nz, plane, center, -1)
|
||||
# Eave-mounted lighting: clear below, and supported immediately above.
|
||||
put(px, 124, pz, CREAM, "lamp.bracket")
|
||||
put(px, 123, pz, CHAIN, "lamp.chain")
|
||||
put(px, 122, pz, LAMP, "lamp.lantern")
|
||||
facade_lamps.append([px, 122, pz])
|
||||
|
||||
# Continuous layered cornices connect every projection of the exact footprint.
|
||||
first_relief = _dilate(footprint, 1) - inner
|
||||
second_relief = shadow - _erode(footprint, 1)
|
||||
for x, z in sorted(first_relief):
|
||||
put(x, 110, z, CREAM, "cornice.floor.lower")
|
||||
put(x, 112, z, PALE, "cornice.floor.upper")
|
||||
put(x, 124, z, CREAM, "cornice.eave.lower")
|
||||
for x, z in sorted(second_relief):
|
||||
put(x, 125, z, CREAM, "cornice.eave.upper")
|
||||
|
||||
# Union of three pitched masses: a broad central hall and two hipped end wings.
|
||||
# Every roof column is closed, without making the inaccessible attic solid fill.
|
||||
def roof_height(x, z):
|
||||
candidates = []
|
||||
if -53 <= x <= 18 and -147 <= z <= -95:
|
||||
candidates.append(140 - ceil(abs(z + 121) * 14 / 26))
|
||||
if -76 <= x <= -50 and -141 <= z <= -96:
|
||||
candidates.append(126 + max(0, min(13 - abs(x + 63), z + 141, -96 - z)))
|
||||
if 14 <= x <= 42 and -141 <= z <= -96:
|
||||
candidates.append(126 + max(0, min(14 - abs(x - 28), z + 141, -96 - z)))
|
||||
if -27 <= x <= 15 and -100 <= z <= -81:
|
||||
candidates.append(126 + max(0, min(10 - abs(z + 91), x + 27, 15 - x)))
|
||||
return max([126, *candidates])
|
||||
|
||||
roof_heights = {p: min(140, roof_height(*p)) for p in shadow}
|
||||
shadow_boundary = shadow - _erode(shadow, 1)
|
||||
for x, z in sorted(shadow):
|
||||
top = roof_heights[x, z]
|
||||
# Close all verge/gable faces down to the continuous eave line.
|
||||
if (x, z) in shadow_boundary:
|
||||
for y in range(126, top):
|
||||
put(x, y, z, ASHLAR if y < top - 1 else GREEN, "roof.gable")
|
||||
put(x, top - 1, z, GREEN, "roof.underlay")
|
||||
uphill = [(roof_heights.get((x + dx, z + dz), top - 1), facing)
|
||||
for dx, dz, facing in CARDINALS]
|
||||
high, facing = max(uphill, key=lambda item: item[0])
|
||||
state = stair("waxed_oxidized_cut_copper_stairs", facing) if high > top else GREEN
|
||||
if (x + 6) % 12 == 0 and top < 139:
|
||||
state = GREEN
|
||||
put(x, top, z, state, "roof.copper")
|
||||
if top == 140:
|
||||
put(x, 140, z, GREEN, "roof.ridge")
|
||||
|
||||
# Four pavilion lantern roofs lend a clear rhythm to the ends of the facade.
|
||||
pavilions = [(-66, -131, 7), (-66, -106, 7), (32, -131, 7), (28, -106, 6)]
|
||||
for cx, cz, radius in pavilions:
|
||||
pavilion = {(x, z) for x in range(cx - radius, cx + radius + 1)
|
||||
for z in range(cz - radius, cz + radius + 1) if (x, z) in shadow}
|
||||
for x, z in sorted(pavilion):
|
||||
ring = max(abs(x - cx), abs(z - cz))
|
||||
top = 137 - ring
|
||||
# The cap only raises the parent roof; it never cuts an accidental opening.
|
||||
if top <= roof_heights[x, z]:
|
||||
continue
|
||||
for y in range(roof_heights[x, z], top):
|
||||
put(x, y, z, GREEN if y >= top - 1 else CREAM, "pavilion.roof.support")
|
||||
facing = "east" if x < cx else "west" if x > cx else "south" if z < cz else "north"
|
||||
put(x, top, z, stair("waxed_oxidized_cut_copper_stairs", facing) if ring else GREEN, "pavilion.roof.copper")
|
||||
for y, state in ((138, GREEN), (139, GOLD), (140, CHAIN)):
|
||||
put(cx, y, cz, state, "pavilion.finial")
|
||||
|
||||
# Shacraft-green hanging stone panels, with a small gold S motif on the end bays.
|
||||
# These are block reliefs, not entities or inventory-backed banner blocks.
|
||||
shield_centers = [(-65, -97, 1), (28, -97, 1), (-65, -140, -1), (31, -140, -1)]
|
||||
glyph = ("111", "100", "111", "001", "111")
|
||||
for cx, plane, normal in shield_centers:
|
||||
for y in range(110, 123):
|
||||
half = 2 if y >= 112 else y - 109
|
||||
for dx in range(-half, half + 1):
|
||||
put(cx + dx, y, plane, GOLD if abs(dx) == half else DEEP_GREEN, "ornament.green.shield")
|
||||
for row, bits in enumerate(glyph):
|
||||
for col, bit in enumerate(bits):
|
||||
if bit == "1":
|
||||
put(cx + col - 1, 120 - row, plane + normal, GOLD, "ornament.gold.s")
|
||||
for dx in range(-3, 4):
|
||||
put(cx + dx, 123, plane, CREAM, "ornament.shield.cap")
|
||||
|
||||
# The projecting entrance arch remains seven blocks clear at useful head height.
|
||||
axis = int(layout.get("entrance_axis_x", -6))
|
||||
entrance = layout.get("entrance_opening_x", [-9, -3])
|
||||
for x in range(axis - 6, axis + 7):
|
||||
dx = abs(x - axis)
|
||||
for z in (-85, -84):
|
||||
if (x, z) not in footprint:
|
||||
continue
|
||||
for y in range(99, 111):
|
||||
if dx in (4, 5):
|
||||
put(x, y, z, PILLAR if y > 100 else DARK, "entrance.pier")
|
||||
elif y >= 109 - min(dx, 4):
|
||||
put(x, y, z, CREAM, "entrance.arch")
|
||||
entrance_air = []
|
||||
for x in range(int(entrance[0]), int(entrance[1]) + 1):
|
||||
top = 109 - abs(x - axis)
|
||||
for z in (-85, -84, -83):
|
||||
for y in range(99, top + 1):
|
||||
put(x, y, z, AIR, "entrance.clear")
|
||||
entrance_air.append([x, y, z])
|
||||
for px in (axis - 6, axis + 6):
|
||||
put(px, 108, -83, CREAM, "entrance.lamp.bracket")
|
||||
put(px, 108, -82, CREAM, "entrance.lamp.bracket")
|
||||
put(px, 107, -82, CHAIN, "entrance.lamp.chain")
|
||||
put(px, 106, -82, LAMP, "entrance.lamp")
|
||||
# A high, glazed lancet over the portal echoes the long window below the reference clock.
|
||||
for x in range(axis - 3, axis + 4):
|
||||
for y in range(115, 124):
|
||||
cap = 123 - abs(x - axis)
|
||||
state = GLASS if y <= cap else CREAM
|
||||
if x == axis or y == 118:
|
||||
state = WOOD if y <= cap else CREAM
|
||||
for z in (-85, -84):
|
||||
if (x, z) in walls:
|
||||
put(x, y, z, state, "entrance.upper.lancet")
|
||||
|
||||
# Sealed clock tower: solid underside already exists at Y124/125, no future-floor doorway.
|
||||
tx0, tx1, tz0, tz1 = axis - 9, axis + 9, -102, -84
|
||||
# Carry the tower's front corner piers down through both public-storey facades.
|
||||
for x in (tx0, tx0 + 1, tx1 - 1, tx1):
|
||||
for z in (tz1, tz1 - 1):
|
||||
for y in range(101, 125):
|
||||
put(x, y, z, PALE if y in (110, 111, 112, 124) else PILLAR, "tower.facade.pier")
|
||||
tower = {(x, z) for x in range(tx0, tx1 + 1) for z in range(tz0, tz1 + 1)}
|
||||
tower_inner = _erode(tower, 2)
|
||||
tower_wall = tower - tower_inner
|
||||
for x, z in sorted(tower):
|
||||
put(x, 126, z, CREAM, "tower.base.seal")
|
||||
for x, z in sorted(tower_wall):
|
||||
corner = (x <= tx0 + 1 or x >= tx1 - 1) and (z <= tz0 + 1 or z >= tz1 - 1)
|
||||
for y in range(127, 148):
|
||||
state = PILLAR if corner else ASHLAR
|
||||
if y in (128, 130, 147):
|
||||
state = PALE
|
||||
put(x, y, z, state, "tower.masonry")
|
||||
# Clock disks have a stone backing, recessed cream face and four gold bezels.
|
||||
clock_faces = [("south", axis, -82), ("north", axis, -104),
|
||||
("west", -16, -93), ("east", 4, -93)]
|
||||
hour_marks = {(0, 6), (0, -6), (6, 0), (-6, 0), (4, 4), (-4, 4), (4, -4), (-4, -4)}
|
||||
hands = set(_line(0, 0, -3, 4)) | set(_line(0, 0, 3, 3))
|
||||
for facing, center, plane in clock_faces:
|
||||
nx, nz = {"south": (0, 1), "north": (0, -1), "west": (-1, 0), "east": (1, 0)}[facing]
|
||||
for u in range(-8, 9):
|
||||
for v in range(-8, 9):
|
||||
radius = hypot(u, v)
|
||||
if radius > 8.3:
|
||||
continue
|
||||
x, z = (center + u, plane) if nz else (center, plane + u)
|
||||
# All ornament is supported by one continuous backing layer.
|
||||
put(x - nx, 139 + v, z - nz, CREAM, "clock.backing")
|
||||
state = CREAM
|
||||
if radius > 7.4:
|
||||
state = PALE
|
||||
elif radius > 6.5:
|
||||
state = GOLD
|
||||
elif (u, v) in hour_marks:
|
||||
state = DEEP_GREEN
|
||||
elif (u, v) in hands:
|
||||
state = DARK
|
||||
if (u, v) == (0, 0):
|
||||
state = GOLD
|
||||
put(x, 139 + v, z, state, "clock.face." + facing)
|
||||
# Broad cream cap, steep patinated copper crown and a restrained gold finial.
|
||||
tower_cap = _dilate(tower, 1)
|
||||
for x, z in sorted(tower_cap):
|
||||
put(x, 148, z, PALE, "tower.cornice")
|
||||
for x, z in sorted(tower):
|
||||
radius = max(abs(x - axis), abs(z + 93))
|
||||
top = 149 + max(0, 7 - radius)
|
||||
for y in range(149, top):
|
||||
put(x, y, z, GREEN, "tower.roof.underlay")
|
||||
facing = "east" if x < axis else "west" if x > axis else "south" if z < -93 else "north"
|
||||
put(x, top, z, stair("waxed_oxidized_cut_copper_stairs", facing) if radius else GREEN, "tower.roof.copper")
|
||||
for x, z in ((tx0, tz0), (tx1, tz0), (tx0, tz1), (tx1, tz1)):
|
||||
put(x, 150, z, GREEN, "tower.corner.finial")
|
||||
put(x, 151, z, GOLD, "tower.corner.finial")
|
||||
put(x, 152, z, CHAIN, "tower.corner.finial")
|
||||
for y, state in ((157, GOLD), (158, CHAIN)):
|
||||
put(axis, y, -93, state, "tower.central.finial")
|
||||
for x, z in ((tx0 - 1, tz1), (tx1 + 1, tz1), (tx0 - 1, tz0), (tx1 + 1, tz0)):
|
||||
put(x, 148, z, PALE, "tower.lamp.bracket")
|
||||
put(x, 147, z, CHAIN, "tower.lamp.chain")
|
||||
put(x, 146, z, LAMP, "tower.lamp")
|
||||
|
||||
# Deterministic checks describe the shell; the root performs full merged navigation QA.
|
||||
entrance_columns = {(x, z) for x in range(entrance[0], entrance[1] + 1) for z in (-85, -84, -83)}
|
||||
assert all(states.get((x, y, z)) == AIR for x, z in entrance_columns for y in range(99, 103))
|
||||
assert all((x, z) in footprint for (x, y, z) in states if y == 98)
|
||||
assert all(states[x, 112, z] != AIR and states[x, 125, z] != AIR for x, z in footprint)
|
||||
assert {p for p, state in states.items() if state == AIR} == {tuple(p) for p in entrance_air}
|
||||
solid = {p for p, state in states.items() if state != AIR}
|
||||
remaining = set(solid)
|
||||
queue = [remaining.pop()]
|
||||
while queue:
|
||||
x, y, z = queue.pop()
|
||||
for p in ((x + 1, y, z), (x - 1, y, z), (x, y + 1, z),
|
||||
(x, y - 1, z), (x, y, z + 1), (x, y, z - 1)):
|
||||
if p in remaining:
|
||||
remaining.remove(p)
|
||||
queue.append(p)
|
||||
assert not remaining, "Exterior contains detached floating ornament"
|
||||
metadata = {
|
||||
"generator": "station-exterior-v1", "world_writes": 0,
|
||||
"foundation_columns": len(footprint), "two_block_wall_columns": len(walls),
|
||||
"bounds": {"min": [min(p[i] for p in states) for i in range(3)],
|
||||
"max": [max(p[i] for p in states) for i in range(3)]},
|
||||
"floor_block_y": [98, 112], "walk_y": [99, 113],
|
||||
"solid_intermediate_deck": [111, 112], "sealed_roof_ceiling": [124, 125],
|
||||
"main_roof_eaves_y": 126, "main_roof_ridge_y": 140, "clocktower_peak_y": 158,
|
||||
"entrance_clear_x": list(entrance), "entrance_clear_z": [-85, -84, -83],
|
||||
"entrance_minimum_clear_height": 8, "windows": windows, "pilasters": pilasters,
|
||||
"facade_lamps": facade_lamps, "pavilions": [list(p) for p in pavilions],
|
||||
"clock_faces": [{"facing": f, "center_or_x": c, "plane_or_z": p,
|
||||
"center_y": 139, "radius": 8} for f, c, p in clock_faces],
|
||||
"materials": sorted({state.split("[")[0] for state in states.values()}),
|
||||
"states_by_group": dict(sorted(Counter(groups.values()).items())),
|
||||
"blocks": len(states), "entrance_air_blocks": len(entrance_air),
|
||||
"checks": {"ground_floor_on_exact_footprint": True, "two_solid_separating_decks": True,
|
||||
"only_main_entrance_is_open": True, "roof_and_tower_have_no_doorway": True,
|
||||
"maximum_overhang_blocks": 3, "all_windows_full_block_glass": True,
|
||||
"all_solid_states_share_one_cardinally_connected_component": True},
|
||||
}
|
||||
return states, groups, metadata
|
||||
@@ -1,514 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compile the two furnished Shacraft station floors without world I/O.
|
||||
|
||||
The caller owns the exterior, authoritative survey and expected-state writes.
|
||||
This module owns only the two-block-eroded footprint at block Y 98..123.
|
||||
All six SMASH panels are decorative, unassigned slots. Functional signs, text
|
||||
displays and the individual lift transition are deliberately metadata only.
|
||||
"""
|
||||
|
||||
from collections import Counter, deque
|
||||
|
||||
|
||||
AIR = "minecraft:air"
|
||||
CREAM = "minecraft:smooth_sandstone"
|
||||
PALE = "minecraft:smooth_quartz"
|
||||
CUT = "minecraft:cut_sandstone"
|
||||
GREEN = "minecraft:waxed_oxidized_cut_copper"
|
||||
GOLD = "minecraft:gold_block"
|
||||
WOOD = "minecraft:spruce_planks"
|
||||
DARK = "minecraft:dark_oak_planks"
|
||||
CHAIN = "minecraft:iron_chain[axis=y,waterlogged=false]"
|
||||
LANTERN = "minecraft:lantern[hanging=true,waterlogged=false]"
|
||||
LEAF = "minecraft:spruce_leaves[distance=7,persistent=true,waterlogged=false]"
|
||||
LOG = "minecraft:spruce_log[axis=y]"
|
||||
|
||||
|
||||
def _slab(material, top=False):
|
||||
return f"minecraft:{material}[type={'top' if top else 'bottom'},waterlogged=false]"
|
||||
|
||||
|
||||
def _stair(material, facing, top=False):
|
||||
return (f"minecraft:{material}[facing={facing},half={'top' if top else 'bottom'},"
|
||||
"shape=straight,waterlogged=false]")
|
||||
|
||||
|
||||
def _rect(box):
|
||||
a, b, c, d = map(int, box)
|
||||
return {(x, z) for x in range(a, b + 1) for z in range(c, d + 1)}
|
||||
|
||||
|
||||
def _pose(x, y, z, text, facing="south", scale=0.7, **extra):
|
||||
return {"position": {"x": x, "y": y, "z": z}, "text": text,
|
||||
"facing": facing, "scale": scale, **extra}
|
||||
|
||||
|
||||
class _Interior:
|
||||
def __init__(self, footprint, layout):
|
||||
self.footprint = {(int(x), int(z)) for x, z in footprint}
|
||||
self.inside = {(x, z) for x, z in self.footprint
|
||||
if all((x + dx, z + dz) in self.footprint
|
||||
for dx in range(-2, 3) for dz in range(-2, 3))}
|
||||
if not self.inside:
|
||||
raise ValueError("The station needs an interior after its two-block setback")
|
||||
self.layout = layout
|
||||
self.states = {}
|
||||
self.groups = {}
|
||||
self.features = []
|
||||
self.texts = []
|
||||
self.targets = {99: [], 113: []}
|
||||
self.selections = []
|
||||
self.lift = {}
|
||||
self.floor_walk = {}
|
||||
|
||||
def put(self, x, y, z, state, group):
|
||||
p = (int(x), int(y), int(z))
|
||||
if (p[0], p[2]) not in self.inside or not 98 <= p[1] <= 123:
|
||||
raise ValueError(f"Interior write outside the owned volume: {p} ({group})")
|
||||
self.states[p] = state
|
||||
self.groups[p] = group
|
||||
|
||||
def box(self, bounds, lo, hi, state, group, clipped=False):
|
||||
for x, z in sorted(_rect(bounds)):
|
||||
if clipped and (x, z) not in self.inside:
|
||||
continue
|
||||
for y in range(lo, hi + 1):
|
||||
self.put(x, y, z, state, group)
|
||||
|
||||
def target(self, feet, x, z, name):
|
||||
self.targets[feet].append({"name": name, "x": x, "y": feet, "z": z})
|
||||
|
||||
def record(self, kind, bounds, feet, **extra):
|
||||
self.features.append({"kind": kind, "bounds_xz": list(bounds),
|
||||
"walk_y": feet, **extra})
|
||||
|
||||
def shell(self):
|
||||
for x, z in sorted(self.inside):
|
||||
for y in range(98, 124):
|
||||
if y in (98, 112):
|
||||
state, group = CREAM, "interior:continuous-floor"
|
||||
elif y == 111:
|
||||
state, group = "minecraft:stone_bricks", "interior:solid-intermediate-deck"
|
||||
else:
|
||||
state, group = AIR, "interior:owned-room-air"
|
||||
self.put(x, y, z, state, group)
|
||||
|
||||
def floors(self, feet):
|
||||
floor = feet - 1
|
||||
group = f"interior:{feet}:floor-inlay"
|
||||
# Large calm limestone panels, outlined by the architectural column grid.
|
||||
for x, z in sorted(self.inside):
|
||||
if x in (-51, -46, -21, -16, 15, 20) or z in (-129, -124, -106, -101):
|
||||
self.put(x, floor, z, PALE, group)
|
||||
if x in (-50, -17, 19) and -135 <= z <= -93:
|
||||
self.put(x, floor, z, CUT, group)
|
||||
for x in (-11, -10, -2, -1):
|
||||
for z in range(-115, -84):
|
||||
if (x, z) in self.inside:
|
||||
self.put(x, floor, z, GREEN if x in (-10, -2) else PALE, group)
|
||||
for z in (-111, -103, -95, -87):
|
||||
for x in (-10, -2):
|
||||
if (x, z) in self.inside:
|
||||
self.put(x, floor, z, GOLD, group)
|
||||
centres = [(-59, -117), (25, -117), (-34, -103), (5, -99)]
|
||||
if feet == 99:
|
||||
centres += [(-33, -117), (-31, -135), (5, -135)]
|
||||
for cx, cz in centres:
|
||||
for dx in range(-5, 6):
|
||||
for dz in range(-5, 6):
|
||||
if (cx + dx, cz + dz) not in self.inside:
|
||||
continue
|
||||
r = abs(dx) + abs(dz)
|
||||
if r == 5:
|
||||
state = GREEN
|
||||
elif r == 4:
|
||||
state = PALE
|
||||
elif r <= 2 and (dx == 0 or dz == 0):
|
||||
state = GOLD if r == 0 else CUT
|
||||
else:
|
||||
continue
|
||||
self.put(cx + dx, floor, cz + dz, state, group)
|
||||
if feet == 113:
|
||||
# A terracotta runner belongs to SMASH, while the centre axis stays pale.
|
||||
for x, z in sorted(_rect([-46, 11, -134, -130])):
|
||||
self.put(x, floor, z, "minecraft:orange_terracotta", group)
|
||||
for x in range(-46, 12):
|
||||
for z in (-135, -129):
|
||||
self.put(x, floor, z, PALE if x % 10 else GOLD, group)
|
||||
for x, z in sorted(_rect([22, 29, -124, -112])):
|
||||
edge = x in (22, 29) or z in (-124, -112)
|
||||
self.put(x, floor, z, GREEN if edge else "minecraft:orange_terracotta", group)
|
||||
|
||||
def arcade(self, feet, ceiling):
|
||||
group = f"interior:{feet}:arcade"
|
||||
panel_y = ceiling - 1
|
||||
# A solid coffer ceiling closes the upper floor below the inaccessible attic.
|
||||
for x, z in sorted(self.inside):
|
||||
self.put(x, panel_y, z, WOOD, group)
|
||||
if z in (-139, -128, -125, -105, -102, -91) or x in (-50, -47, -20, -17, 16, 19):
|
||||
self.put(x, panel_y, z, DARK, group)
|
||||
if z in (-128, -125, -105, -102):
|
||||
self.put(x, panel_y - 1, z, CREAM, group)
|
||||
for bounds in self.layout["aligned_columns"]:
|
||||
a, b, c, d = bounds
|
||||
self.box(bounds, feet, feet, CUT, group)
|
||||
self.box(bounds, feet + 1, feet + 1, PALE, group)
|
||||
self.box(bounds, feet + 2, ceiling - 4,
|
||||
"minecraft:quartz_pillar[axis=y]", group)
|
||||
self.box(bounds, ceiling - 3, ceiling - 1, CREAM, group)
|
||||
self.box([a - 1, b + 1, c - 1, d + 1], ceiling - 3, ceiling - 3,
|
||||
_slab("smooth_sandstone_slab", top=True), group, clipped=True)
|
||||
self.box([a - 1, b + 1, c - 1, d + 1], ceiling - 2, ceiling - 2,
|
||||
CREAM, group, clipped=True)
|
||||
self.record("aligned-column", bounds, feet)
|
||||
# High corbels make the two column rows read as a shallow cream arcade.
|
||||
for bounds in self.layout["aligned_columns"]:
|
||||
a, b, c, d = bounds
|
||||
for x, facing in ((a - 2, "east"), (b + 2, "west")):
|
||||
for z in (c, d):
|
||||
if (x, z) in self.inside:
|
||||
self.put(x, ceiling - 3, z,
|
||||
_stair("smooth_sandstone_stairs", facing, top=True), group)
|
||||
|
||||
def chandelier(self, x, z, feet, ceiling):
|
||||
group = f"interior:{feet}:chandelier"
|
||||
collar = ceiling - 4
|
||||
for y in range(collar + 1, ceiling):
|
||||
self.put(x, y, z, CHAIN, group)
|
||||
self.put(x, collar, z, GOLD, group)
|
||||
for dx, dz in ((-1, 0), (1, 0), (0, -1), (0, 1)):
|
||||
self.put(x + dx, collar, z + dz, GREEN, group)
|
||||
self.put(x + dx, collar - 1, z + dz, LANTERN, group)
|
||||
self.record("four-lantern-chandelier", [x - 1, x + 1, z - 1, z + 1],
|
||||
feet, lowest_block_y=collar - 1)
|
||||
|
||||
def bench(self, bounds, feet, facing="south", name="bench"):
|
||||
a, b, c, d = bounds
|
||||
if b - a < 3 or d - c != 1:
|
||||
raise ValueError("Station benches use a long, two-block-deep rectangle")
|
||||
group = f"interior:{feet}:bench"
|
||||
back_z, seat_z = (c, d) if facing == "south" else (d, c)
|
||||
stair_facing = "north" if facing == "south" else "south"
|
||||
for x in range(a, b + 1):
|
||||
if x in (a, b):
|
||||
for z in (c, d):
|
||||
self.put(x, feet, z, CUT, group)
|
||||
self.put(x, feet + 1, z, _slab("smooth_sandstone_slab"), group)
|
||||
else:
|
||||
self.put(x, feet, seat_z, _stair("spruce_stairs", stair_facing), group)
|
||||
self.put(x, feet, back_z, WOOD, group)
|
||||
self.put(x, feet + 1, back_z, _slab("spruce_slab"), group)
|
||||
front = d + 1 if facing == "south" else c - 1
|
||||
for x in range(a + 1, b):
|
||||
self.target(feet, x, front, f"{name}-access-{x}")
|
||||
self.record("spruce-bench", bounds, feet, facing=facing)
|
||||
|
||||
def planter(self, x, z, feet, name="topiary"):
|
||||
group = f"interior:{feet}:planter"
|
||||
bounds = [x - 1, x + 1, z - 1, z + 1]
|
||||
self.box(bounds, feet, feet, CUT, group)
|
||||
self.put(x, feet, z, "minecraft:dirt", group)
|
||||
self.put(x, feet + 1, z, LOG, group)
|
||||
self.put(x, feet + 2, z, LOG, group)
|
||||
for dx, dz in ((-1, 0), (0, -1), (0, 0), (0, 1), (1, 0)):
|
||||
self.put(x + dx, feet + 3, z + dz, LEAF, group)
|
||||
self.put(x, feet + 4, z, LEAF, group)
|
||||
self.record(name, bounds, feet)
|
||||
|
||||
def lift_cabin(self, feet, ceiling):
|
||||
group = f"interior:{feet}:lift"
|
||||
housing = self.layout["lift"]["housing"]
|
||||
cabin = self.layout["lift"]["clear_cabin"]
|
||||
inner = _rect(cabin)
|
||||
a, b, c, d = housing
|
||||
for x, z in sorted(_rect(housing)):
|
||||
if (x, z) in inner:
|
||||
self.put(x, feet - 1, z, "minecraft:polished_andesite", group)
|
||||
continue
|
||||
for y in range(feet, ceiling):
|
||||
state = GREEN
|
||||
if x in (a, b) and z in (c, d):
|
||||
state = CUT if y in (feet, feet + 5) else CREAM
|
||||
if y == feet + 5 and z == d:
|
||||
state = GOLD
|
||||
self.put(x, y, z, state, group)
|
||||
# The unused volume above each cabin is solid, never an open shaft.
|
||||
self.box(cabin, feet + 7, ceiling - 1, WOOD, group)
|
||||
self.box(cabin, feet, feet + 6, AIR, group)
|
||||
self.box([-8, -4, -117, -116], feet, feet + 4, AIR, group)
|
||||
self.box([-8, -4, -117, -116], feet + 5, feet + 5, GOLD, group)
|
||||
for x in (-8, -4):
|
||||
for z in range(-122, -117):
|
||||
self.put(x, feet - 1, z, GREEN, group)
|
||||
self.put(-6, feet + 6, -120, CHAIN, group)
|
||||
self.put(-6, feet + 5, -120, LANTERN, group)
|
||||
selector = (-6, feet + 2, -123)
|
||||
self.put(*selector, GOLD, group)
|
||||
number = 1 if feet == 99 else 2
|
||||
self.lift[str(number)] = {
|
||||
"housing_bounds_xz": housing, "clear_cabin_bounds_xz": cabin,
|
||||
"walk_y": feet, "door_bounds_xz": [-8, -4, -117, -116],
|
||||
"door_clear_height": 5, "selector_block": list(selector),
|
||||
"destination": {"x": -5.5, "y": 113 if feet == 99 else 99,
|
||||
"z": -119.5, "yaw": 0, "pitch": 0},
|
||||
"landing_block": [-6, feet, -120], "floor_is_solid": True,
|
||||
}
|
||||
self.texts.append(_pose(-5.5, feet + 6.2, -114.93,
|
||||
"1 ВЕСТИБЮЛЬ" if number == 1 else "2 SMASH",
|
||||
scale=0.9, id=f"lift-{number}-front"))
|
||||
self.texts.append(_pose(-5.5, feet + 3.3, -121.92,
|
||||
"Вверх · SMASH" if number == 1 else "Вниз · Вестибюль",
|
||||
scale=0.45, id=f"lift-{number}-selector"))
|
||||
self.target(feet, -6, -120, f"lift-{number}-landing")
|
||||
self.target(feet, -6, -114, f"lift-{number}-approach")
|
||||
self.record("enclosed-lift-cabin", housing, feet, floor_number=number)
|
||||
|
||||
def gallery_panel(self, x, z, feet, title, subtitle, facing="east"):
|
||||
group = f"interior:{feet}:gallery-panel"
|
||||
# East-facing panels stand along the western wall, clear of the arcade.
|
||||
for dz in range(-2, 3):
|
||||
for dy in range(0, 6):
|
||||
state = CUT if abs(dz) == 2 or dy in (0, 5) else DARK
|
||||
self.put(x, feet + dy, z + dz, state, group)
|
||||
for dz in (-2, 2):
|
||||
self.put(x, feet + 2, z + dz, GOLD, group)
|
||||
self.texts.append(_pose(x + 1.04, feet + 3.9, z + 0.5,
|
||||
title + "\n" + subtitle, facing=facing, scale=0.52,
|
||||
id=f"gallery-{feet}-{x}-{z}"))
|
||||
self.target(feet, x + 3, z, title)
|
||||
self.record("rules-panel" if feet == 113 else "station-gallery-panel",
|
||||
[x, x, z - 2, z + 2], feet)
|
||||
|
||||
def diorama(self):
|
||||
feet = 113
|
||||
a, b, c, d = self.layout["smash_display"]
|
||||
group = "interior:113:contained-island-diorama"
|
||||
self.box([a, b, c, d], feet, feet, "minecraft:black_concrete", group)
|
||||
for x, z in sorted(_rect([a, b, c, d])):
|
||||
if x in (a, b) or z in (c, d):
|
||||
self.put(x, feet, z, CUT, group)
|
||||
for y in (feet + 1, feet + 2):
|
||||
self.put(x, y, z, "minecraft:glass", group)
|
||||
self.put(x, feet + 3, z, _slab("smooth_sandstone_slab"), group)
|
||||
# Miniatures hover above an opaque, intact display base, inside a glass case.
|
||||
for ix, (cx, cz, radius) in enumerate(((-38, -115, 2), (-29, -115, 2), (-34, -120, 2))):
|
||||
self.put(cx, 114, cz, "minecraft:deepslate[axis=y]", group)
|
||||
for dx in range(-radius, radius + 1):
|
||||
for dz in range(-radius, radius + 1):
|
||||
dist = abs(dx) + abs(dz)
|
||||
if dist <= 2:
|
||||
self.put(cx + dx, 115, cz + dz, "minecraft:stone", group)
|
||||
if dist <= 3:
|
||||
self.put(cx + dx, 116, cz + dz, "minecraft:moss_block", group)
|
||||
self.put(cx, 117, cz, LOG, group)
|
||||
self.put(cx, 118, cz, LOG, group)
|
||||
for dx, dz in ((-1, 0), (0, -1), (0, 0), (0, 1), (1, 0)):
|
||||
self.put(cx + dx, 119, cz + dz, LEAF, group)
|
||||
self.put(cx, 120, cz, LEAF, group)
|
||||
self.put(cx + (1 if ix != 1 else -1), 117, cz + 1,
|
||||
"minecraft:red_concrete" if ix == 1 else "minecraft:light_blue_concrete", group)
|
||||
self.texts.append(_pose((a + b) / 2 + 0.5, 115, d + 1.05,
|
||||
"SMASH · УДЕРЖИСЬ НА ОСТРОВЕ", scale=0.6,
|
||||
id="smash-diorama-caption"))
|
||||
self.target(feet, -33, d + 2, "diorama-south-view")
|
||||
self.target(feet, a - 2, -117, "diorama-west-view")
|
||||
self.target(feet, b + 2, -117, "diorama-east-view")
|
||||
self.record("closed-display-only-diorama", [a, b, c, d], feet,
|
||||
enclosed=True, arena=False, floor_intact=True, top_y=120)
|
||||
|
||||
def selection_bays(self):
|
||||
feet = 113
|
||||
group = "interior:113:smash-selection-bays"
|
||||
# Solid infill behind the recessed panels prevents unfinished rear rooms.
|
||||
for x, z in sorted(self.inside):
|
||||
if -47 <= x <= 13 and z <= -140:
|
||||
for y in range(feet, 124):
|
||||
self.put(x, y, z, CREAM, group)
|
||||
for index, bounds in enumerate(self.layout["smash_selection_bays"], 1):
|
||||
a, b, c, d = bounds
|
||||
mid = (a + b) // 2
|
||||
for x in range(a, b + 1):
|
||||
for y in range(feet, feet + 8):
|
||||
self.put(x, y, c, DARK, group)
|
||||
for x in (a, b):
|
||||
for z in range(c + 1, d + 1):
|
||||
for y in range(feet, feet + 7):
|
||||
self.put(x, y, z, CUT if y == feet else CREAM, group)
|
||||
self.box([a, b, c + 1, d], feet + 7, feet + 7, CREAM, group)
|
||||
self.box([a + 1, b - 1, c + 1, c + 1], feet + 1, feet + 6, GOLD, group)
|
||||
# Five-wide miniature reliefs are explicitly generic, unassigned previews.
|
||||
for dx in range(-2, 3):
|
||||
for dy in range(4):
|
||||
material = "light_blue_concrete"
|
||||
summit = 1 + ((dx + index) % 3 == 0)
|
||||
if dy < summit:
|
||||
material = "stone" if dy == 0 else "moss_block"
|
||||
if dy == 3 and dx == (index % 3) - 1:
|
||||
material = "white_concrete"
|
||||
self.put(mid + dx, feet + 2 + dy, c + 2,
|
||||
"minecraft:" + material, group)
|
||||
for x in range(mid - 1, mid + 2):
|
||||
self.put(x, feet, d, CUT, group)
|
||||
self.put(x, feet + 1, d, WOOD, group)
|
||||
self.put(mid, feet + 6, c + 2, LANTERN, group)
|
||||
self.put(mid, feet + 7, c + 2, GREEN, group)
|
||||
sign = [mid, feet + 1, d + 1]
|
||||
support = [mid, feet + 1, d]
|
||||
self.selections.append({
|
||||
"slot": f"{index:02d}", "bounds_xz": bounds,
|
||||
"assigned": False, "destination": None,
|
||||
"relief_is_decorative": True,
|
||||
"sign_block": sign, "sign_support_block": support,
|
||||
"sign_facing": "south", "sign_lines": [f"АРЕНА {index:02d}", "Не назначено", "", ""],
|
||||
"standing_block": [mid, feet, d + 2],
|
||||
"label_pose": _pose(mid + 0.5, feet + 6.6, d + 1.05,
|
||||
f"{index:02d}", scale=0.55),
|
||||
})
|
||||
self.target(feet, mid, d + 2, f"arena-{index:02d}-sign")
|
||||
self.record("unassigned-arena-selection-bay", bounds, feet, slot=index)
|
||||
self.box([-35, 1, -139, -138], 121, 123, DARK, group)
|
||||
self.texts.append(_pose(-16.5, 122.2, -136.94, "S M A S H", scale=2.2,
|
||||
id="smash-header"))
|
||||
|
||||
def furniture(self, feet):
|
||||
for i, bounds in enumerate(self.layout["wing_benches"]):
|
||||
self.bench(bounds, feet, "south", f"wing-bench-{i + 1}")
|
||||
for x, z in ((-56, -131), (-56, -106), (21, -131), (21, -106)):
|
||||
self.planter(x, z, feet)
|
||||
# The north and south galleries are furnished public rooms, without doors
|
||||
# suggesting additional unfinished wings or upper-floor circulation.
|
||||
for i, (bounds, facing) in enumerate((([-22, -15, -94, -93], "north"),
|
||||
([0, 7, -94, -93], "north"))):
|
||||
self.bench(bounds, feet, facing, f"south-gallery-bench-{i + 1}")
|
||||
for x, z in ((-30, -94), (12, -94)):
|
||||
if _rect([x - 1, x + 1, z - 1, z + 1]) <= self.inside:
|
||||
self.planter(x, z, feet)
|
||||
if feet == 99:
|
||||
for i, bounds in enumerate(([-44, -37, -137, -136], [-26, -19, -137, -136],
|
||||
[1, 8, -137, -136])):
|
||||
self.bench(bounds, feet, "south", f"north-gallery-bench-{i + 1}")
|
||||
for x in (-31, 11):
|
||||
self.planter(x, -137, feet)
|
||||
self.gallery_panel(-72, -116, feet, "SHACRAFT", "Площадь прибытия")
|
||||
self.texts.append(_pose(-5.5, 108, -114.93,
|
||||
"SHACRAFT", scale=0.9,
|
||||
id="vestibule-heading"))
|
||||
else:
|
||||
for z, title, subtitle in ((-122, "УРОН", "Больше урона — сильнее отбрасывание"),
|
||||
(-115, "ОТБРАСЫВАНИЕ", "Удержись на острове"),
|
||||
(-108, "ДВОЙНОЙ ПРЫЖОК", "Вернись на площадку")):
|
||||
self.gallery_panel(-72, z, feet, title, subtitle)
|
||||
self.texts.append(_pose(26, 118.5, -105.9, "ЗОНА ОЖИДАНИЯ", scale=0.7,
|
||||
id="smash-waiting-heading"))
|
||||
|
||||
def leaf_distances(self):
|
||||
leaves = {p for p, s in self.states.items() if s.startswith("minecraft:spruce_leaves[")}
|
||||
distance = {}
|
||||
queue = deque()
|
||||
for p, s in self.states.items():
|
||||
if s.startswith("minecraft:spruce_log["):
|
||||
queue.append((p, 0))
|
||||
while queue:
|
||||
(x, y, z), d = queue.popleft()
|
||||
if d >= 6:
|
||||
continue
|
||||
for p in ((x - 1, y, z), (x + 1, y, z), (x, y - 1, z),
|
||||
(x, y + 1, z), (x, y, z - 1), (x, y, z + 1)):
|
||||
if p in leaves and d + 1 < distance.get(p, 7):
|
||||
distance[p] = d + 1
|
||||
queue.append((p, d + 1))
|
||||
for p in leaves:
|
||||
self.states[p] = (f"minecraft:spruce_leaves[distance={distance.get(p, 7)},"
|
||||
"persistent=true,waterlogged=false]")
|
||||
|
||||
def navigation(self):
|
||||
details = {}
|
||||
for feet, seed in ((99, (-6, -86)), (113, (-6, -120))):
|
||||
clear = {(x, z) for x, z in self.inside
|
||||
if all(self.states[(x, y, z)] == AIR for y in range(feet, feet + 4))}
|
||||
if seed not in clear:
|
||||
raise ValueError(f"Interior navigation start is obstructed at {feet}: {seed}")
|
||||
reached = {seed}
|
||||
queue = deque([seed])
|
||||
while queue:
|
||||
x, z = queue.popleft()
|
||||
for p in ((x - 1, z), (x + 1, z), (x, z - 1), (x, z + 1)):
|
||||
if p in clear and p not in reached:
|
||||
reached.add(p)
|
||||
queue.append(p)
|
||||
for target in self.targets[feet]:
|
||||
if (target["x"], target["z"]) not in reached:
|
||||
raise ValueError(f"Unreachable interior target: {target}")
|
||||
# Close genuine inaccessible air pockets; never leave a half-built room.
|
||||
sealed = clear - reached
|
||||
for x, z in sorted(sealed):
|
||||
for y in range(feet, 111 if feet == 99 else 124):
|
||||
self.put(x, y, z, CREAM, f"interior:{feet}:sealed-service-infill")
|
||||
reserved = [self.layout["reserved_clear_aisles"]["entry"]]
|
||||
if feet == 113:
|
||||
reserved.append(self.layout["reserved_clear_aisles"]["bay_front"])
|
||||
for bounds in reserved:
|
||||
required = _rect(bounds) & self.inside
|
||||
blocked = required - reached
|
||||
if blocked:
|
||||
raise ValueError(f"Reserved circulation is blocked at Y{feet}: {sorted(blocked)[:5]}")
|
||||
self.floor_walk[feet] = [{"x": x, "y": feet, "z": z}
|
||||
for x, z in sorted(reached)]
|
||||
details[str(feet)] = {"walk_columns": len(reached),
|
||||
"minimum_verified_headroom": 4,
|
||||
"named_targets": self.targets[feet],
|
||||
"sealed_inaccessible_columns": len(sealed),
|
||||
"all_named_targets_reachable": True,
|
||||
"reserved_aisles_clear": True}
|
||||
return details
|
||||
|
||||
|
||||
def compile_interior(footprint, layout):
|
||||
"""Return canonical voxel states, per-voxel groups, and JSON-safe metadata.
|
||||
|
||||
``footprint`` contains the actual surveyed foundation's (x, z) columns.
|
||||
Geometry is deterministic and absolute, as specified by the approved layout.
|
||||
The returned air states are intentional room clearance; callers must apply
|
||||
the normal expected-state and protected-volume checks before any live write.
|
||||
"""
|
||||
b = _Interior(footprint, layout)
|
||||
floors = [(int(f["walk_y"]), int(f["ceiling_underside_y"])) for f in layout["floors"]]
|
||||
if floors != [(99, 111), (113, 124)]:
|
||||
raise ValueError("This approved interior is defined only for walk Y99 and Y113")
|
||||
b.shell()
|
||||
for feet, ceiling in floors:
|
||||
b.floors(feet)
|
||||
b.arcade(feet, ceiling)
|
||||
b.furniture(feet)
|
||||
lights = [(-58, -116), (25, -116), (5, -107), (-32, -101)]
|
||||
if feet == 99:
|
||||
lights += [(-33, -116), (-30, -132)]
|
||||
for x, z in lights:
|
||||
b.chandelier(x, z, feet, ceiling)
|
||||
b.lift_cabin(feet, ceiling)
|
||||
b.diorama()
|
||||
b.selection_bays()
|
||||
b.leaf_distances()
|
||||
navigation = b.navigation()
|
||||
palette = sorted({s.partition("[")[0] for s in b.states.values()})
|
||||
metadata = {
|
||||
"schema_version": 1,
|
||||
"name": "Shacraft station furnished vestibule and SMASH gallery",
|
||||
"status": "compiled candidate; requires caller survey, collision and live verification",
|
||||
"owned_y": [98, 123], "perimeter_setback_blocks": 2,
|
||||
"foundation_columns": len(b.footprint), "interior_columns": len(b.inside),
|
||||
"state_count": len(b.states), "non_air_count": sum(s != AIR for s in b.states.values()),
|
||||
"group_counts": dict(sorted(Counter(b.groups.values()).items())),
|
||||
"palette": palette, "features": b.features, "lift": b.lift,
|
||||
"selection_bays": b.selections, "text_displays": b.texts,
|
||||
"walk_points": b.floor_walk[99] + b.floor_walk[113],
|
||||
"walk_points_by_floor": {str(k): v for k, v in b.floor_walk.items()},
|
||||
"navigation": navigation,
|
||||
"design_invariants": {
|
||||
"only_two_furnished_floors": True, "upper_floor_has_no_holes": True,
|
||||
"lift_has_no_open_shaft": True, "no_attic_or_clocktower_access": True,
|
||||
"diorama_is_closed_and_display_only": True, "arena_slots_are_unassigned": True,
|
||||
"functional_text_signs_and_lift_are_caller_owned": True,
|
||||
},
|
||||
}
|
||||
return b.states, b.groups, metadata
|
||||
@@ -1,19 +0,0 @@
|
||||
import com.google.gson.JsonParser;
|
||||
import io.github.minecraftbuilder.core.TerrainRecipe;
|
||||
import java.io.*;
|
||||
import java.nio.file.*;
|
||||
|
||||
/** Offline composition experiment, not a world writer or a production recipe. */
|
||||
public final class NaturalTerrainStudy {
|
||||
private static final io.github.minecraftbuilder.terrainworld.NaturalTerrain terrain =
|
||||
new io.github.minecraftbuilder.terrainworld.NaturalTerrain(28092005);
|
||||
static float height(int x,int z) { return terrain.height(x,z); }
|
||||
public static void main(String[] args) throws Exception {
|
||||
var old=new TerrainRecipe(JsonParser.parseString(Files.readString(Path.of(args[0]))).getAsJsonObject());
|
||||
Path output=Path.of(args[1]);Files.createDirectories(output);
|
||||
try(var a=new DataOutputStream(new BufferedOutputStream(Files.newOutputStream(output.resolve("before.f32"))));
|
||||
var b=new DataOutputStream(new BufferedOutputStream(Files.newOutputStream(output.resolve("natural.f32"))))) {
|
||||
for(int z=-384;z<384;z++)for(int x=-384;x<384;x++) {a.writeFloat(old.surfaceHeight(x,z));b.writeFloat(height(x,z));}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
# Shacraft terrain composition study
|
||||
|
||||
This is an **offline prototype** responding to the first lobby terrain's rectangular platforms and repetitive slopes. The original study did not alter a live world. Its exact height field is now available through the production `shacraft-natural-v1` initial-world profile; it still does not extend the production MCP recipe schema.
|
||||
|
||||
## Findings and design decisions
|
||||
|
||||
The original recipe combines three correlated octaves of grid-based value noise, then flattens large rectangular areas. It also paints grass on every exposed top step regardless of slope. The screenshots show both geometric platform edges and repetitive green/brown contour bands. Adding more octaves alone cannot remove those platform boundaries.
|
||||
|
||||
[Red Blob Games](https://www.redblobgames.com/maps/terrain-from-noise/) explains independent octave sampling, Simplex noise as a way to reduce directional artifacts, ridged transformations, and elevation redistribution. The study uses seeded OpenSimplex2S with distinct fields for broad relief, ridges, coordinate warping and fine detail.
|
||||
|
||||
[libnoise tutorial 5](https://libnoise.sourceforge.net/tutorials/tutorial5.html) separates the terrain-type control map from mountain and lowland height fields. Here explicit curved mountain corridors provide artistic control: north is the main skyline, side ranges are lower, and the central valley stays comparatively calm. Mountain detail is weighted by those corridors instead of covering the entire map uniformly.
|
||||
|
||||
[FastNoiseLite's documentation](https://github.com/Auburn/FastNoiseLite/wiki/Documentation) provides fBm, ridged fractals, octave weighting and domain warping. The prototype uses two independent low-frequency fields to displace X/Z coordinates before evaluating terrain, producing irregular ridge paths and water margins. This is a modest 27-block warp, not unlimited distortion.
|
||||
|
||||
There are no rectangular plateau features in the study. Broad smooth hills leave space for architecture without committing to enormous flat pads. Later construction should flatten only the actual footprint and blend the foundation into the slope. Natural terrain alone does not guarantee walkable routes; those need a separate route/grade pass after the silhouette is selected.
|
||||
|
||||
The lakes and connected river paths share Y=48. Shorelines arise from the intersection between the computed ground and that water plane. River cuts are curved and variable in width. The shape is authored and noise-modulated; it is **not** a drainage or hydraulic erosion simulation. Small remaining angular changes around path vertices should be replaced by spline sampling in a production implementation.
|
||||
|
||||
## Prototype parameters
|
||||
|
||||
- Broad lowland field: scale 220 blocks, amplitude 11, 4 octaves.
|
||||
- Mountain ridge field: scale 85, 5 octaves, gain 0.48, lacunarity 2.07, weighted strength 0.7.
|
||||
- Warp fields: scale 165, 3 octaves, displacement 27 blocks.
|
||||
- Fine detail: scale 17, amplitude about 1.2–4.4 depending on mountain influence.
|
||||
- Comparison palette: slope-dependent rock/grass and depth-dependent water, **identical for both versions**. This isolates geometry; it is not a Minecraft material or shader preview.
|
||||
|
||||
## Reproduce
|
||||
|
||||
From the repository root, with the project Java 25 runtime on PATH:
|
||||
|
||||
```bash
|
||||
mkdir -p .runtime/terrain-study
|
||||
javac -cp terrain-world-plugin/target/terrain-world-plugin-0.1.0-SNAPSHOT.jar -d .runtime/terrain-study \
|
||||
scripts/terrain-study/NaturalTerrainStudy.java
|
||||
java -cp .runtime/terrain-study:terrain-world-plugin/target/terrain-world-plugin-0.1.0-SNAPSHOT.jar \
|
||||
NaturalTerrainStudy examples/terrain/shacraft-lobby-world.json .runtime/terrain-study
|
||||
python3 scripts/terrain-study/render.py
|
||||
```
|
||||
|
||||
Rendering requires numpy and matplotlib. The binary samples are 768×768 big-endian float32, row-major Z then X. No upsampling, erosion or post-sculpting is hidden in the renderer. Perspective views sample every fourth column and use true vertical scale.
|
||||
|
||||
Outputs: `docs/references/shacraft-terrain-study-plan.png`, `shacraft-terrain-study-perspective.png`, and `shacraft-terrain-study.json`. These are computed previews, **not screenshots**. The original comparison images predate the version 2 world. The production height field is tested against the SHA-256 of all 589,824 original prototype float samples.
|
||||
|
||||
## Third-party source
|
||||
|
||||
`../../terrain-world-plugin/src/main/java/io/github/minecraftbuilder/terrainworld/noise/FastNoiseLite.java` is upstream Java source with only a package declaration added from [Auburn/FastNoiseLite](https://github.com/Auburn/FastNoiseLite), pinned to commit `785f37a9ad76e283586a379675085f2063ae03f7`. Its MIT license and copyright notice are preserved at the top of the file. The MIT notice is also bundled in the production jar under `META-INF/LICENSE-FastNoiseLite.txt`.
|
||||
@@ -1,63 +0,0 @@
|
||||
"""Render comparable scientific previews from sampled height fields; never edits screenshots."""
|
||||
from pathlib import Path
|
||||
import json
|
||||
import numpy as np
|
||||
import matplotlib
|
||||
matplotlib.use('Agg')
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.colors import LightSource
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
DATA = ROOT / '.runtime/terrain-study'
|
||||
OUT = ROOT / 'docs/references'
|
||||
WATER = 48
|
||||
fields = [np.fromfile(DATA / name, dtype='>f4').reshape(768, 768).astype(float)
|
||||
for name in ['before.f32', 'natural.f32']]
|
||||
names = ['V1 — rectangular platforms', 'Study — ridges, soft hills, winding water']
|
||||
|
||||
def colors(ground):
|
||||
dz, dx = np.gradient(ground)
|
||||
slope = np.hypot(dx, dz)
|
||||
rock = np.clip((slope - .55) / 1.5, 0, 1)[..., None]
|
||||
green = np.array([.35, .46, .25])
|
||||
stone = np.array([.54, .54, .49])
|
||||
rgb = green * (1-rock) + stone * rock
|
||||
# Identical materials and lighting for both fields: isolate the shape comparison.
|
||||
shaded = LightSource(315, 42).shade_rgb(rgb, ground, vert_exag=1, blend_mode='soft')
|
||||
depth = np.clip((WATER-ground)/25, 0, 1)[..., None]
|
||||
water = np.array([.19, .46, .50])*(1-depth) + np.array([.12, .30, .37])*depth
|
||||
return np.where((ground < WATER)[..., None], water, shaded)
|
||||
|
||||
fig, axes = plt.subplots(1, 2, figsize=(16, 8), facecolor='#f4f3ee')
|
||||
for ax, ground, name in zip(axes, fields, names):
|
||||
ax.imshow(colors(ground), extent=(-384,384,384,-384))
|
||||
ax.set_title(name, fontsize=16, loc='left', pad=16)
|
||||
ax.set_xlabel('X / blocks'); ax.set_ylabel('Z / blocks — north up')
|
||||
fig.suptitle('Shacraft / terrain composition study · 768 × 768 blocks', fontsize=20, x=.06, ha='left', y=.99)
|
||||
fig.text(.06,.025,'Computed height fields. Same scale, water level and shading. Right: offline prototype, not yet in Minecraft.',fontsize=11)
|
||||
fig.subplots_adjust(left=.06,right=.98,top=.90,bottom=.10,wspace=.18)
|
||||
fig.savefig(OUT/'shacraft-terrain-study-plan.png',dpi=130)
|
||||
plt.close(fig)
|
||||
|
||||
fig=plt.figure(figsize=(16,8),facecolor='#f4f3ee')
|
||||
for i,(ground,name) in enumerate(zip(fields,names)):
|
||||
ax=fig.add_subplot(1,2,i+1,projection='3d',facecolor='#f4f3ee')
|
||||
s=4; x=np.arange(-384,384,s); z=np.arange(-384,384,s); xx,zz=np.meshgrid(x,z)
|
||||
ax.plot_surface(xx,zz,np.maximum(ground[::s,::s],WATER),facecolors=colors(ground)[::s,::s],
|
||||
rstride=1,cstride=1,linewidth=0,antialiased=False,shade=False)
|
||||
ax.set(xlim=(-384,384),ylim=(-384,384),zlim=(25,220))
|
||||
ax.set_box_aspect((768,768,195));ax.view_init(elev=37,azim=-58);ax.set_axis_off()
|
||||
ax.set_title(name,loc='left',fontsize=16,pad=0)
|
||||
fig.suptitle('Shacraft / compare silhouettes before rebuilding',fontsize=20,x=.05,ha='left',y=.95)
|
||||
fig.text(.05,.07,'Geometric preview at true vertical scale. No rectangular plateaus in the new study. No erosion simulation.',fontsize=11)
|
||||
fig.subplots_adjust(left=.01,right=.99,top=.86,bottom=.10,wspace=-.06)
|
||||
fig.savefig(OUT/'shacraft-terrain-study-perspective.png',dpi=130)
|
||||
plt.close(fig)
|
||||
|
||||
stats=[]
|
||||
for ground,name in zip(fields,names):
|
||||
dz,dx=np.gradient(ground);s=np.hypot(dx,dz)
|
||||
stats.append({'name':name,'min_ground_y':float(ground.min()),'max_ground_y':float(ground.max()),
|
||||
'water_columns':int((ground<WATER).sum()),'slope_p95':float(np.percentile(s,95))})
|
||||
(OUT/'shacraft-terrain-study.json').write_text(json.dumps({'world_applied':False,'water_level':WATER,'fields':stats},indent=2)+'\n')
|
||||
print(json.dumps(stats))
|
||||
+2
-1
@@ -16,6 +16,7 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
spec = importlib.util.spec_from_file_location('mcb_launcher', ROOT / 'scripts/bridge.py')
|
||||
launcher = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(launcher)
|
||||
RUNTIME = launcher.RUNTIME
|
||||
|
||||
|
||||
class RpcError(RuntimeError):
|
||||
@@ -174,7 +175,7 @@ def main():
|
||||
for name in ('apply', 'undo'):
|
||||
p = sub.add_parser(name)
|
||||
p.add_argument('--manifest', type=Path, required=True)
|
||||
p.add_argument('--config', type=Path, default=ROOT / '.runtime/server/plugins/MinecraftBuilderMCP/config.yml')
|
||||
p.add_argument('--config', type=Path, default=RUNTIME / 'server/plugins/MinecraftBuilderMCP/config.yml')
|
||||
p.add_argument('--console', action='store_true', help='Only for an explicitly configured isolated fixture')
|
||||
p.add_argument('--execute', action='store_true', required=True, help='Explicitly perform this world edit')
|
||||
if name == 'apply':
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Offline validation regressions; no camera, server or credentials are contacted."""
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import zlib
|
||||
|
||||
spec = importlib.util.spec_from_file_location('camera_capture', Path(__file__).with_name('camera-capture.py'))
|
||||
camera = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(camera)
|
||||
|
||||
|
||||
def chunk(kind, data):
|
||||
return struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data) & 0xffffffff)
|
||||
|
||||
|
||||
PNG = b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', 1, 1, 8, 2, 0, 0, 0)) + chunk(b'IDAT', zlib.compress(b'\0\0\0\0')) + chunk(b'IEND', b'')
|
||||
TOKEN = 'private-test-token-' * 3
|
||||
CAPTURE_ID = '11111111-1111-4111-8111-111111111111'
|
||||
POSE = dict(x=0, y=100, z=0, yaw=0, pitch=30, dimension='minecraft:overworld', fov=80, width=1280, height=720)
|
||||
|
||||
|
||||
class CameraCaptureTest(unittest.TestCase):
|
||||
def reply(self):
|
||||
return dict(status='completed', captureId=CAPTURE_ID, mimeType='image/png', dimension=POSE['dimension'],
|
||||
capturedAt=datetime.now(timezone.utc).isoformat(), width=1, height=1, imageBase64=base64.b64encode(PNG).decode())
|
||||
|
||||
def test_invalid_request_fails_before_config_or_transport(self):
|
||||
for change in ({'x': float('nan')}, {'pitch': 91}, {'yaw': -361}, {'y': 2049}, {'fov': 29}, {'width': True}, {'dimension': 'https://example.com'}, {'x': '0'}):
|
||||
with self.subTest(change=change), patch.object(camera.checks, 'request_json') as request:
|
||||
with self.assertRaises(camera.checks.TestFailure):
|
||||
camera.capture('/does/not/exist', POSE | change, '/unused')
|
||||
request.assert_not_called()
|
||||
|
||||
def test_raw_image_is_unchanged_and_metadata_allowlisted(self):
|
||||
result = self.reply() | {'arbitrary': {'secret': TOKEN}}
|
||||
raw, receipt = camera.validated_image(result, CAPTURE_ID, POSE, TOKEN, time.time())
|
||||
self.assertEqual(raw, PNG)
|
||||
metadata = json.loads(receipt)
|
||||
self.assertTrue(metadata['operator_direct_worker'])
|
||||
self.assertEqual(metadata['imageBytes'], len(PNG))
|
||||
self.assertNotIn('arbitrary', metadata)
|
||||
self.assertNotIn('imageBase64', metadata)
|
||||
|
||||
def test_wrong_identity_dimension_timestamp_and_size_rejected(self):
|
||||
for change in ({'captureId': '22222222-2222-4222-8222-222222222222'}, {'dimension': 'minecraft:the_nether'}, {'width': 2}, {'mimeType': 'image/jpeg'}, {'capturedAt': '2020-01-01T00:00:00Z'}):
|
||||
with self.subTest(change=change), self.assertRaises(camera.checks.TestFailure):
|
||||
camera.validated_image(self.reply() | change, CAPTURE_ID, POSE, TOKEN, time.time())
|
||||
|
||||
def test_bad_png_and_private_metadata_rejected(self):
|
||||
corrupt = bytearray(PNG);corrupt[30] ^= 1
|
||||
for change in ({'imageBase64': 'bad!'}, {'imageBase64': base64.b64encode(corrupt).decode()}, {'readiness': TOKEN}, {'readiness': {}}, {'eyeY': float('nan')}):
|
||||
with self.subTest(change=change), self.assertRaises(camera.checks.TestFailure):
|
||||
camera.validated_image(self.reply() | change, CAPTURE_ID, POSE, TOKEN, time.time())
|
||||
|
||||
def test_polled_capture_cannot_switch_identity(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp);config = root / 'config.yml';output = root / 'frame.png'
|
||||
config.write_text(f'camera-token: {TOKEN}\ncamera-port: 8766\n')
|
||||
responses = [dict(status='pending', captureId=CAPTURE_ID), self.reply() | {'captureId': '22222222-2222-4222-8222-222222222222'}]
|
||||
with patch.object(camera.checks, 'request_json', side_effect=responses), patch.object(camera.time, 'sleep'):
|
||||
with self.assertRaises(camera.checks.TestFailure):camera.capture(config, POSE, output)
|
||||
self.assertFalse(output.exists())
|
||||
|
||||
def test_oversized_and_nonobject_http_response_rejected(self):
|
||||
class Response:
|
||||
def __init__(self, raw):self.raw=raw
|
||||
def __enter__(self):return self
|
||||
def __exit__(self, *args):pass
|
||||
def read(self, limit):return self.raw[:limit]
|
||||
class Opener:
|
||||
def __init__(self, raw):self.raw=raw
|
||||
def open(self, *args, **kwargs):return Response(self.raw)
|
||||
for raw in (b'[]', b'x' * (camera.checks.MAX_RESPONSE + 1)):
|
||||
with self.assertRaises(camera.checks.TestFailure):
|
||||
camera.checks.request_json(Opener(raw), 'http://127.0.0.1:8766/health', TOKEN)
|
||||
|
||||
|
||||
if __name__ == '__main__':unittest.main()
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Offline geometry regressions; no Minecraft world, MCP server or map fixtures."""
|
||||
from collections import deque
|
||||
import importlib.util
|
||||
import math
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
spec = importlib.util.spec_from_file_location('voxel_island', Path(__file__).with_name('voxel-island.py'))
|
||||
islands = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(islands)
|
||||
|
||||
|
||||
class FloatingIslandTests(unittest.TestCase):
|
||||
def test_same_input_is_reproducible_and_seed_changes_geometry(self):
|
||||
args = (120, -80, 18, 13, 144, 30)
|
||||
first = islands.columns(*args, 27)
|
||||
self.assertEqual(first, islands.columns(*args, 27))
|
||||
self.assertNotEqual(first, islands.columns(*args, 28))
|
||||
|
||||
def test_negative_coordinate_envelope_and_inclusive_solid_columns(self):
|
||||
cx, cz, rx, rz, top, depth = -71, -39, 12, 9, 140, 27
|
||||
columns = islands.columns(cx, cz, rx, rz, top, depth, 11, .2)
|
||||
self.assertIn((cx, cz), columns)
|
||||
for (x, z), column in columns.items():
|
||||
self.assertLessEqual(abs(x-cx), rx*1.3)
|
||||
self.assertLessEqual(abs(z-cz), rz*1.3)
|
||||
self.assertEqual(top, column['top'])
|
||||
self.assertIs(type(column['bottom']), int)
|
||||
thickness = column['top'] - column['bottom'] + 1
|
||||
self.assertGreaterEqual(thickness, 3)
|
||||
self.assertLessEqual(thickness, math.ceil(depth + 5.5))
|
||||
self.assertGreaterEqual(column['radial'], 0)
|
||||
self.assertLessEqual(column['radial'], 1)
|
||||
# Adjacent columns all overlap at these top three levels.
|
||||
self.assertTrue(all(column['bottom'] <= y <= column['top'] for y in range(top-2, top+1)))
|
||||
|
||||
def test_representative_lobed_ellipses_are_four_connected(self):
|
||||
for rx, rz in ((1, 1), (2, 8), (8, 2), (3, 17), (17, 3), (7, 11), (24, 22)):
|
||||
for contour in (0, .055, .2):
|
||||
for seed in range(12):
|
||||
with self.subTest(rx=rx, rz=rz, contour=contour, seed=seed):
|
||||
columns = islands.columns(0, 0, rx, rz, 140, 20, seed, contour)
|
||||
reached, queue = set(), deque([(0, 0)])
|
||||
while queue:
|
||||
point = queue.popleft()
|
||||
if point in reached or point not in columns:
|
||||
continue
|
||||
reached.add(point)
|
||||
x, z = point
|
||||
queue.extend(((x-1, z), (x+1, z), (x, z-1), (x, z+1)))
|
||||
self.assertEqual(set(columns), reached)
|
||||
|
||||
def test_translation_preserves_outline_but_not_world_anchored_underside(self):
|
||||
first = islands.columns(0, 0, 12, 9, 140, 20, 19)
|
||||
moved = islands.columns(43, -27, 12, 9, 140, 20, 19)
|
||||
self.assertEqual({(x+43, z-27) for x, z in first}, set(moved))
|
||||
self.assertTrue(any(first[x, z]['bottom'] != moved[x+43, z-27]['bottom'] for x, z in first))
|
||||
|
||||
def test_tiny_positive_radii_still_include_the_centre(self):
|
||||
self.assertEqual({(-5, 8)}, set(islands.columns(-5, 8, .01, .01, 140, 1, 2)))
|
||||
|
||||
def test_value_noise_is_bounded_and_continuous_at_negative_grid_edges(self):
|
||||
for seed in (0, 1, 100):
|
||||
for x, z in ((-3, .2), (.5, -7), (-4, -9), (0, 0)):
|
||||
value = islands.noise(x, z, seed)
|
||||
self.assertGreaterEqual(value, 0)
|
||||
self.assertLessEqual(value, 1)
|
||||
self.assertLess(abs(islands.noise(x-1e-7, z, seed)-islands.noise(x+1e-7, z, seed)), 1e-6)
|
||||
|
||||
def test_invalid_geometry_is_rejected_before_sampling(self):
|
||||
for rx, rz, depth, contour in ((0, 5, 10, .1), (5, -1, 10, .1), (5, 5, 0, .1),
|
||||
(math.inf, 5, 10, .1), (5, math.nan, 10, .1),
|
||||
(5, 5, math.inf, .1), (5, 5, 10, -.01), (5, 5, 10, .201)):
|
||||
with self.subTest(rx=rx, rz=rz, depth=depth, contour=contour):
|
||||
with self.assertRaises(ValueError):
|
||||
islands.columns(0, 0, rx, rz, 140, depth, 1, contour)
|
||||
with self.assertRaises(ValueError):
|
||||
islands.columns(.5, 0, 5, 5, 140, 10, 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,289 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only candidate and observed-volume QA for the arrival-square balustrade.
|
||||
|
||||
Checks stable capped-wall states, cardinal continuity, grounded footings, original
|
||||
road masks, preserved garden fixtures and remaining walkable floor. Actual mode
|
||||
also compares every captured voxel against baseline plus the desired overlay.
|
||||
"""
|
||||
import argparse
|
||||
from collections import Counter, deque
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SPEC = importlib.util.spec_from_file_location('balustrade_plaza_qa', ROOT / 'scripts/verify-plaza.py')
|
||||
plaza = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(plaza)
|
||||
survey = plaza.survey
|
||||
N = {'east': (1, 0), 'north': (0, -1), 'south': (0, 1), 'west': (-1, 0)}
|
||||
CAP = 'minecraft:smooth_sandstone_slab[type=bottom,waterlogged=false]'
|
||||
|
||||
|
||||
def coordinates(document):
|
||||
result = {}
|
||||
for row in document['blocks']:
|
||||
at = tuple(survey.point(row)[axis] for axis in ('x', 'y', 'z'))
|
||||
if at in result:
|
||||
raise ValueError(f'Duplicate desired position {at}')
|
||||
result[at] = row['block']
|
||||
return result
|
||||
|
||||
|
||||
def stable_wall_properties(connected, capped=True):
|
||||
"""Vanilla 26.2 WallBlock under a full bottom face (the bottom slab cap).
|
||||
|
||||
Cap coverage makes arms tall. shouldRaisePost suppresses the post for either
|
||||
opposite tall pair only after its endpoint/corner/T asymmetry condition.
|
||||
"""
|
||||
ns = connected['north'] != connected['south']
|
||||
ew = connected['east'] != connected['west']
|
||||
opposite = ((connected['north'] and connected['south']) or
|
||||
(connected['east'] and connected['west']))
|
||||
up = ns or ew or not opposite
|
||||
return {**{name: ('tall' if capped else 'low') if connected[name] else 'none' for name in N},
|
||||
'up': str(up).lower(), 'waterlogged': 'false'}
|
||||
|
||||
|
||||
def wall_neighbor(state):
|
||||
name, _ = plaza.parse(state)
|
||||
return name.endswith('_wall') or name in plaza.STATIC_CUBES
|
||||
|
||||
|
||||
def check_guard(reader, metadata):
|
||||
columns = set(map(tuple, metadata['fence_columns']))
|
||||
piers = set(map(tuple, metadata['pier_columns']))
|
||||
failures, listed = [], []
|
||||
if not columns or not piers <= columns:
|
||||
raise ValueError('Fence columns and piers must form a nonempty valid set')
|
||||
for index, raw in enumerate(metadata['path_runs']):
|
||||
run = list(map(tuple, raw))
|
||||
if len(run) < 2 or run[0] not in piers or run[-1] not in piers:
|
||||
failures.append({'reason': 'run_needs_pier_endpoints', 'run': index})
|
||||
for a, b in zip(run, run[1:]):
|
||||
if abs(a[0] - b[0]) + abs(a[1] - b[1]) != 1:
|
||||
failures.append({'reason': 'noncardinal_guard_gap', 'from': a, 'to': b})
|
||||
listed.extend(run)
|
||||
if len(listed) != len(set(listed)) or set(listed) != columns:
|
||||
failures.append({'reason': 'path_runs_do_not_cover_fence_once'})
|
||||
for x, z in sorted(columns):
|
||||
state = reader.state(x, 96, z)
|
||||
material, props = plaza.parse(state)
|
||||
if (x, z) in piers:
|
||||
if material != 'cut_sandstone':
|
||||
failures.append({'at': [x, 96, z], 'reason': 'pier_material', 'actual': state})
|
||||
else:
|
||||
connections = {name: wall_neighbor(reader.state(x + dx, 96, z + dz))
|
||||
for name, (dx, dz) in N.items()}
|
||||
expected = stable_wall_properties(connections)
|
||||
if material != 'stone_brick_wall' or props != expected:
|
||||
failures.append({'at': [x, 96, z], 'reason': 'unstable_wall_state',
|
||||
'actual': state, 'expected_properties': expected})
|
||||
if reader.state(x, 97, z) != CAP:
|
||||
failures.append({'at': [x, 97, z], 'reason': 'missing_continuous_bottom_cap'})
|
||||
if plaza.parse(reader.state(x, 95, z))[0] not in plaza.STATIC_CUBES:
|
||||
failures.append({'at': [x, 95, z], 'reason': 'guard_without_full_base'})
|
||||
for footing in metadata['footing_columns']:
|
||||
x, z, bottom = footing['x'], footing['z'], footing['support_y']
|
||||
if (x, z) not in columns or not 70 <= bottom < 95:
|
||||
raise ValueError('Unexpected footing coordinate or support height')
|
||||
if plaza.parse(reader.state(x, bottom, z))[0] == 'grass_block':
|
||||
failures.append({'at': [x, bottom, z], 'reason': 'grass_under_opaque_footing_will_decay_to_dirt'})
|
||||
for y in range(bottom, 96):
|
||||
if plaza.parse(reader.state(x, y, z))[0] not in plaza.STATIC_CUBES:
|
||||
failures.append({'at': [x, y, z], 'reason': 'footing_gap_or_unknown_support'})
|
||||
# A newly connected pier also changes the reciprocal side of an old road
|
||||
# rail. Inspect real neighbors, including walls absent from compiler masks.
|
||||
adjacent = {(x + dx, z + dz) for x, z in columns for dx, dz in N.values()
|
||||
if (x + dx, z + dz) not in columns and
|
||||
plaza.parse(reader.state(x + dx, 96, z + dz))[0] == 'stone_brick_wall'}
|
||||
for x, z in sorted(adjacent):
|
||||
above = reader.state(x, 97, z)
|
||||
if above not in survey.AIR and above != CAP:
|
||||
failures.append({'at': [x, 97, z], 'reason': 'unknown_neighbor_rail_top_shape', 'actual': above})
|
||||
continue
|
||||
connected = {name: wall_neighbor(reader.state(x + dx, 96, z + dz))
|
||||
for name, (dx, dz) in N.items()}
|
||||
actual = reader.state(x, 96, z)
|
||||
expected = stable_wall_properties(connected, above == CAP)
|
||||
if plaza.parse(actual)[1] != expected:
|
||||
failures.append({'at': [x, 96, z], 'reason': 'unstable_reciprocal_road_rail',
|
||||
'actual': actual, 'expected_properties': expected})
|
||||
pending, components = set(columns), []
|
||||
while pending:
|
||||
seen, queue = set(), deque([next(iter(pending))])
|
||||
while queue:
|
||||
p = queue.popleft()
|
||||
if p not in pending:
|
||||
continue
|
||||
pending.remove(p)
|
||||
seen.add(p)
|
||||
queue.extend((p[0] + dx, p[1] + dz) for dx, dz in N.values())
|
||||
components.append(len(seen))
|
||||
if sorted(components) != sorted(len(run) for run in metadata['path_runs']):
|
||||
failures.append({'reason': 'actual_cardinal_components_differ_from_runs', 'components': components})
|
||||
return {'passed': not failures, 'columns': len(columns), 'piers': len(piers),
|
||||
'cardinal_components': sorted(components), 'grounded_footings': len(metadata['footing_columns']),
|
||||
'adjacent_road_rails': len(adjacent),
|
||||
'failures': failures}
|
||||
|
||||
|
||||
def captured_voxels(snapshot):
|
||||
for cell in snapshot.cells.values():
|
||||
lo, hi, index = cell['min'], cell['max'], 0
|
||||
for y in range(lo['y'], hi['y'] + 1):
|
||||
for z in range(lo['z'], hi['z'] + 1):
|
||||
for x in range(lo['x'], hi['x'] + 1):
|
||||
yield (x, y, z), cell['palette'][cell['indices'][index]]
|
||||
index += 1
|
||||
|
||||
|
||||
def compare_volume(before, after, desired):
|
||||
mismatches, counts = [], Counter()
|
||||
for at, baseline in captured_voxels(before):
|
||||
changed = at in desired
|
||||
counts['desired_voxels' if changed else 'outside_desired_voxels'] += 1
|
||||
actual, expected = after.state(*at), desired.get(at, baseline)
|
||||
if actual != expected:
|
||||
counts['desired_mismatches' if changed else 'outside_desired_mismatches'] += 1
|
||||
if len(mismatches) < 30:
|
||||
mismatches.append({'at': list(at), 'expected': expected, 'actual': actual,
|
||||
'inside_desired': changed})
|
||||
if counts['desired_voxels'] != len(desired):
|
||||
raise ValueError('Desired blocks extend outside the captured baseline volume')
|
||||
return {'passed': not mismatches, **dict(counts), 'mismatch_examples': mismatches,
|
||||
'scope_note': 'Every voxel inside the supplied before snapshot; no claim for unobserved exterior voxels.'}
|
||||
|
||||
|
||||
def audit(before, layout, metadata, garden_plan, garden_metadata, garden_walk, nav, after=None):
|
||||
scope = survey.scope_of(layout['scope'])
|
||||
if any(value != scope for value in (before.scope, metadata['scope'], garden_plan['scope'],
|
||||
garden_metadata['scope'], garden_walk['scope'])) or (after and after.scope != scope):
|
||||
raise ValueError('Project/world/epoch differs between inputs')
|
||||
desired, failures = coordinates(layout), []
|
||||
for row in layout['blocks']:
|
||||
at = tuple(row[a] for a in ('x', 'y', 'z'))
|
||||
if before.state(*at) != row['expected']:
|
||||
raise ValueError(f'Stale expected block at {at}')
|
||||
fence = set(map(tuple, metadata['fence_columns']))
|
||||
road = {tuple(p) for route in nav['routes'] for p in route['clear_cells']}
|
||||
bench = set(map(tuple, garden_metadata['bench_access_columns']))
|
||||
for x, y, z in desired:
|
||||
if (x, z) in road or (x, z) in bench:
|
||||
failures.append({'at': [x, y, z], 'reason': 'protected_road_or_bench_column_edited'})
|
||||
if fence & (road | bench):
|
||||
failures.append({'reason': 'fence_declared_on_protected_road_or_bench'})
|
||||
reader = plaza.Reader(after or before, None if after else desired)
|
||||
guard = check_guard(reader, metadata)
|
||||
old_desired = coordinates(garden_plan)
|
||||
protected = {at: state for at, state in old_desired.items()
|
||||
if plaza.parse(state)[0] in plaza.FLOWERS | {'spruce_stairs', 'spruce_fence', 'lantern',
|
||||
'iron_chain', 'iron_bars', 'gold_block'} or any(part in state for part in ('_leaves', '_log', 'waxed_oxidized_cut_copper'))}
|
||||
for fixture in garden_metadata['fixtures']:
|
||||
if fixture['type'] in ('conifer', 'topiary'):
|
||||
at = fixture['x'], 95, fixture['z']
|
||||
protected[at] = before.state(*at)
|
||||
lost = [{'at': list(at), 'expected': state, 'actual': reader.state(*at)}
|
||||
for at, state in protected.items() if reader.state(*at) != state]
|
||||
fixture_report = plaza.check_fixtures(reader, old_desired, garden_metadata)
|
||||
blocked = set(map(tuple, garden_metadata['unwalkable_columns'])) | fence
|
||||
points = [p for p in garden_walk['points'] if (p['x'], p['z']) not in fence]
|
||||
walking = survey.verify_walkable(reader, points)
|
||||
navigation = json.loads(json.dumps(nav))
|
||||
for cell in navigation['cells']:
|
||||
if (cell['x'], cell['z']) in blocked:
|
||||
cell['clear'] = False
|
||||
nav_report = plaza.navigation.audit(navigation)
|
||||
graph = plaza.navigation.surface_graph(plaza.navigation.normalize_cells(navigation))
|
||||
reached = plaza.navigation.reachable(graph, (0, 9))
|
||||
bench_report = [plaza.navigation.endpoint_report(f'bench-access-{x}-{z}', (x, z), graph, reached)
|
||||
for x, z in sorted(bench)]
|
||||
nav_report['bench_access'] = bench_report
|
||||
nav_report['passed'] &= all(row['passed'] for row in bench_report)
|
||||
volume = compare_volume(before, after, desired) if after else None
|
||||
passed = (not failures and not lost and guard['passed'] and fixture_report['passed'] and
|
||||
walking['passed'] and nav_report['passed'] and (volume is None or volume['passed']))
|
||||
return {'version': 1, 'scope': scope, 'mode': 'actual_after_snapshot' if after else 'candidate_overlay',
|
||||
'passed': passed, 'world_edits': 0, 'desired_blocks': len(desired), 'guard': guard,
|
||||
'protected_road_columns': len(road), 'protection_failures': failures,
|
||||
'preserved_fixture_states': len(protected), 'lost_fixtures': lost[:30],
|
||||
'fixtures': fixture_report, 'walking': walking, 'navigation': nav_report,
|
||||
'volume': volume, 'note': 'Point-sampled body headroom and navigation; not a full moving-player collision simulation.'}
|
||||
|
||||
|
||||
class BalustradeTests(unittest.TestCase):
|
||||
def test_stable_capped_wall_corner_and_straight_and_cross(self):
|
||||
for sides, up in [({'north', 'south'}, 'false'), ({'east', 'west'}, 'false'),
|
||||
(set(N), 'false'), ({'north', 'east'}, 'true'),
|
||||
({'north', 'east', 'south'}, 'true'), ({'north'}, 'true')]:
|
||||
self.assertEqual(stable_wall_properties({n: n in sides for n in N})['up'], up)
|
||||
uncapped = stable_wall_properties({n: n in {'east', 'west'} for n in N}, False)
|
||||
self.assertEqual((uncapped['east'], uncapped['west'], uncapped['up']), ('low', 'low', 'false'))
|
||||
|
||||
def test_volume_detects_unrelated_change_and_exact_properties(self):
|
||||
before = type('Snapshot', (), {'cells': {0: {'min': dict(x=0, y=0, z=0),
|
||||
'max': dict(x=1, y=0, z=0), 'palette': ['minecraft:air'], 'indices': [0, 0]}}})()
|
||||
states = {(0, 0, 0): CAP, (1, 0, 0): 'minecraft:stone'}
|
||||
after = type('After', (), {'state': lambda self, x, y, z: states[x, y, z]})()
|
||||
result = compare_volume(before, after, {(0, 0, 0): CAP})
|
||||
self.assertFalse(result['passed'])
|
||||
self.assertEqual(result['outside_desired_mismatches'], 1)
|
||||
states[(1, 0, 0)] = 'minecraft:air'
|
||||
self.assertTrue(compare_volume(before, after, {(0, 0, 0): CAP})['passed'])
|
||||
states[(0, 0, 0)] = CAP.replace('bottom', 'top')
|
||||
self.assertEqual(compare_volume(before, after, {(0, 0, 0): CAP})['desired_mismatches'], 1)
|
||||
|
||||
def test_diagonal_run_is_rejected(self):
|
||||
states = {(x, y, z): 'minecraft:air' for x in range(-1, 3) for z in range(-1, 3) for y in range(95, 98)}
|
||||
for x, z in [(0, 0), (1, 1)]:
|
||||
states[x, 95, z] = 'minecraft:stone'
|
||||
states[x, 96, z] = 'minecraft:cut_sandstone'
|
||||
states[x, 97, z] = CAP
|
||||
reader = type('Reader', (), {'state': lambda self, x, y, z: states[x, y, z]})()
|
||||
meta = {'fence_columns': [[0, 0], [1, 1]], 'pier_columns': [[0, 0], [1, 1]],
|
||||
'path_runs': [[[0, 0], [1, 1]]], 'footing_columns': []}
|
||||
result = check_guard(reader, meta)
|
||||
self.assertIn('noncardinal_guard_gap', {f['reason'] for f in result['failures']})
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
stage, garden = ROOT / '.runtime/balustrade-stage04', ROOT / '.runtime/plaza-stage03'
|
||||
parser.add_argument('--before', type=Path, default=stage / 'before-full.json.gz')
|
||||
parser.add_argument('--after', type=Path)
|
||||
parser.add_argument('--layout', type=Path, default=stage / 'balustrade.json')
|
||||
parser.add_argument('--metadata', type=Path, default=stage / 'balustrade.metadata.json')
|
||||
parser.add_argument('--garden-plan', type=Path, default=garden / 'plaza-polished.json')
|
||||
parser.add_argument('--garden-metadata', type=Path, default=garden / 'plaza-polished.metadata.json')
|
||||
parser.add_argument('--garden-walk', type=Path, default=garden / 'plaza-polished.walk.json')
|
||||
parser.add_argument('--navigation', type=Path, default=ROOT / '.runtime/foundations-stage02/navigation-verified-walkable-input.json')
|
||||
parser.add_argument('--report', type=Path, default=stage / 'candidate-qa.json')
|
||||
parser.add_argument('--self-test', action='store_true')
|
||||
args = parser.parse_args()
|
||||
if args.self_test:
|
||||
result = unittest.TextTestRunner().run(unittest.defaultTestLoader.loadTestsFromTestCase(BalustradeTests))
|
||||
raise SystemExit(0 if result.wasSuccessful() else 1)
|
||||
paths = {key: getattr(args, key) for key in ('layout', 'metadata', 'garden_plan', 'garden_metadata', 'garden_walk', 'navigation')}
|
||||
documents = [json.loads(path.read_text()) for path in paths.values()]
|
||||
before = survey.load_snapshot(args.before)
|
||||
after = survey.load_snapshot(args.after, before.scope) if args.after else None
|
||||
report = audit(before, *documents, after=after)
|
||||
paths['before'] = args.before
|
||||
if args.after:
|
||||
paths['after'] = args.after
|
||||
report['input_sha256'] = {key: hashlib.sha256(path.read_bytes()).hexdigest() for key, path in paths.items()}
|
||||
args.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
survey.terrain.save(args.report, report)
|
||||
print(json.dumps({key: report[key] for key in ('passed', 'mode', 'desired_blocks', 'guard', 'protection_failures',
|
||||
'lost_fixtures', 'fixtures', 'volume')}
|
||||
| {'walk_points': report['walking']['checked_points'], 'walk_failures': report['walking']['failures'][:10],
|
||||
'navigation_passed': report['navigation']['passed'],
|
||||
'unreachable_columns': report['navigation']['unreachable_clear_columns'],
|
||||
'report': str(args.report.resolve())}, indent=2))
|
||||
raise SystemExit(0 if report['passed'] else 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,423 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify a foundation edit against actual before/after Paper surface maps.
|
||||
|
||||
Air cuts expose saved lower voxel states; an omitted voxel never becomes assumed
|
||||
air. The whole map is compared, including columns outside the edit. Optional PNG
|
||||
output is a fresh material/height render of the observed after map, not a mockup.
|
||||
"""
|
||||
import argparse
|
||||
from collections import Counter, defaultdict
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
_spec = importlib.util.spec_from_file_location('foundation_snapshot', ROOT / 'scripts/foundation-survey.py')
|
||||
survey = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(survey)
|
||||
AIR = {'minecraft:air', 'minecraft:cave_air', 'minecraft:void_air'}
|
||||
WATER = {'minecraft:water', 'minecraft:bubble_column'}
|
||||
MAP_SCOPE = ('world_uuid', 'world_key', 'min_x', 'max_x', 'min_z', 'max_z', 'width', 'length')
|
||||
|
||||
|
||||
def material(state):
|
||||
return state.split('[', 1)[0]
|
||||
|
||||
|
||||
def ignored_materials(document):
|
||||
"""Air stays implicit; absent metadata preserves legacy map semantics."""
|
||||
raw = document.get('ignored_materials', [])
|
||||
if (not isinstance(raw, list) or any(not isinstance(value, str) for value in raw)
|
||||
or len(raw) != len(set(raw))
|
||||
or not set(raw) <= AIR | {'minecraft:barrier'}):
|
||||
raise ValueError('Unsupported explicit ignored_materials map policy')
|
||||
return AIR | set(raw)
|
||||
|
||||
|
||||
def validate_map(document):
|
||||
if document.get('format') != 'minecraft-builder-surface-map-v1' or document.get('source') != 'paper_world_surface':
|
||||
raise ValueError('Expected a captured Paper world surface map')
|
||||
for key in ('min_x', 'max_x', 'min_z', 'max_z', 'width', 'length'):
|
||||
if type(document.get(key)) is not int:
|
||||
raise ValueError('Map bounds must be integers')
|
||||
count = document['width'] * document['length']
|
||||
if (not 0 < count <= 4_194_304 or document['width'] != document['max_x'] - document['min_x'] + 1
|
||||
or document['length'] != document['max_z'] - document['min_z'] + 1):
|
||||
raise ValueError('Map bounds and dimensions disagree')
|
||||
palette = document.get('palette')
|
||||
if not isinstance(palette, list) or not palette or any(not isinstance(m, str) or not survey.STATE.fullmatch(m) or '[' in m for m in palette):
|
||||
raise ValueError('Map palette must contain material names')
|
||||
heights, indices = document.get('surface_y'), document.get('material_index')
|
||||
if not isinstance(heights, list) or len(heights) != count or any(type(y) is not int for y in heights):
|
||||
raise ValueError('Map has missing or invalid surface heights')
|
||||
if not isinstance(indices, list) or len(indices) != count or any(type(i) is not int or not 0 <= i < len(palette) for i in indices):
|
||||
raise ValueError('Map has missing or invalid material indices')
|
||||
ignored = ignored_materials(document)
|
||||
if any(palette[index] in ignored - AIR for index in indices):
|
||||
raise ValueError('Map surface contains a material its explicit policy ignores')
|
||||
return [palette[index] for index in indices]
|
||||
|
||||
|
||||
def column_index(document, x, z):
|
||||
if not (document['min_x'] <= x <= document['max_x'] and document['min_z'] <= z <= document['max_z']):
|
||||
raise ValueError(f'Edited column {(x, z)} is outside the surface map')
|
||||
return (z - document['min_z']) * document['width'] + x - document['min_x']
|
||||
|
||||
|
||||
def expected_surface(before, snapshot, layout):
|
||||
original_materials = validate_map(before)
|
||||
ignored = ignored_materials(before)
|
||||
scope = survey.scope_of(layout.get('scope', {}))
|
||||
if snapshot.scope != scope or scope['world_id'] != before['world_uuid']:
|
||||
raise ValueError('Voxel snapshot, layout and map scopes differ')
|
||||
overlays = defaultdict(dict)
|
||||
actual_changes = Counter()
|
||||
water_replacements = []
|
||||
expected_states_checked = 0
|
||||
blocks = layout.get('blocks')
|
||||
if layout.get('version') != 1 or not isinstance(blocks, list) or not blocks:
|
||||
raise ValueError('Layout requires version 1 and explicit blocks')
|
||||
for block in blocks:
|
||||
pos = survey.point(block)
|
||||
x, y, z = pos['x'], pos['y'], pos['z']
|
||||
column_index(before, x, z)
|
||||
if y in overlays[(x, z)]:
|
||||
raise ValueError(f'Duplicate desired block {(x, y, z)}')
|
||||
desired, expected = block.get('block'), block.get('expected')
|
||||
if any(not isinstance(s, str) or not survey.STATE.fullmatch(s) for s in (desired, expected)):
|
||||
raise ValueError('Layout requires valid desired and expected block states')
|
||||
observed = snapshot.state(x, y, z)
|
||||
if observed != expected:
|
||||
raise ValueError(f'Layout expected state disagrees with baseline voxel snapshot at {(x, y, z)}')
|
||||
expected_states_checked += 1
|
||||
overlays[(x, z)][y] = desired
|
||||
if desired != observed:
|
||||
actual_changes['removed' if material(desired) in AIR else 'placed_or_replaced'] += 1
|
||||
if material(observed) in WATER and desired != observed:
|
||||
water_replacements.append({'x': x, 'y': y, 'z': z, 'before': observed, 'desired': desired})
|
||||
heights = before['surface_y'].copy()
|
||||
materials = original_materials.copy()
|
||||
exposed_snapshot_blocks = 0
|
||||
map_only_covered_tops = 0
|
||||
for (x, z), overlay in overlays.items():
|
||||
i = column_index(before, x, z)
|
||||
original_top = before['surface_y'][i]
|
||||
# A map and a block capture taken at different times must still agree
|
||||
# where reconstruction relies on their shared original surface.
|
||||
visible_overlay_top = max((y for y, state in overlay.items() if material(state) not in ignored),
|
||||
default=original_top)
|
||||
try:
|
||||
observed_original_top = material(snapshot.state(x, original_top, z))
|
||||
except KeyError:
|
||||
# An overhead addition can be surveyed independently of the ground
|
||||
# it covers. Its checked desired voxel proves the new surface lies
|
||||
# above the earlier captured top; no lower voxel is reconstructed.
|
||||
# Cuts and invisible-only additions still require that observation.
|
||||
if visible_overlay_top <= original_top:
|
||||
raise
|
||||
observed_original_top = original_materials[i]
|
||||
map_only_covered_tops += 1
|
||||
empty_fallback = original_materials[i] in AIR
|
||||
if (observed_original_top != original_materials[i]
|
||||
and not (empty_fallback and observed_original_top in ignored)):
|
||||
raise ValueError(f'Before map and voxel snapshot disagree at original surface {(x, original_top, z)}')
|
||||
top = max(original_top, visible_overlay_top)
|
||||
while True:
|
||||
if top in overlay:
|
||||
state = overlay[top]
|
||||
elif top == original_top:
|
||||
state = original_materials[i]
|
||||
elif top > original_top:
|
||||
# The captured surface proves higher untouched cells are
|
||||
# ignored by this explicit visibility policy. They may be
|
||||
# invisible barriers; this is no claim of collision-free air.
|
||||
state = 'minecraft:air'
|
||||
else:
|
||||
state = snapshot.state(x, top, z)
|
||||
exposed_snapshot_blocks += 1
|
||||
if material(state) not in ignored:
|
||||
heights[i], materials[i] = top, material(state)
|
||||
break
|
||||
if empty_fallback and top == original_top:
|
||||
# A captured all-transparent column reports air at world min Y.
|
||||
# Do not read below that explicit fallback or invent terrain.
|
||||
heights[i], materials[i] = top, original_materials[i]
|
||||
break
|
||||
top -= 1
|
||||
return heights, materials, overlays, {
|
||||
'expected_states_checked_against_baseline': expected_states_checked,
|
||||
'placed_or_replaced_voxels': actual_changes['placed_or_replaced'], 'removed_voxels': actual_changes['removed'],
|
||||
'lower_snapshot_voxels_consulted_after_cuts': exposed_snapshot_blocks,
|
||||
'covered_original_tops_known_only_from_before_map': map_only_covered_tops,
|
||||
'observed_water_voxels_replaced': water_replacements}
|
||||
|
||||
|
||||
def verify(before, after, snapshot, layout):
|
||||
after_materials = validate_map(after)
|
||||
if ignored_materials(before) != ignored_materials(after):
|
||||
raise ValueError('Before and after map ignored_materials policies differ; capture a matching baseline')
|
||||
for key in MAP_SCOPE:
|
||||
if before.get(key) != after.get(key):
|
||||
raise ValueError(f'Before and after map scopes differ: {key}')
|
||||
expected_y, expected_m, overlays, stats = expected_surface(before, snapshot, layout)
|
||||
original_materials = [before['palette'][i] for i in before['material_index']]
|
||||
affected = {column_index(before, x, z) for x, z in overlays}
|
||||
mismatches, outside_mismatches = [], 0
|
||||
changed_surface = 0
|
||||
water_columns = 0
|
||||
water_surface_changes = []
|
||||
for i, observed in enumerate(after_materials):
|
||||
x, z = i % before['width'] + before['min_x'], i // before['width'] + before['min_z']
|
||||
if before['surface_y'][i] != after['surface_y'][i] or original_materials[i] != observed:
|
||||
changed_surface += 1
|
||||
if expected_y[i] != after['surface_y'][i] or expected_m[i] != observed:
|
||||
mismatch = {'x': x, 'z': z, 'expected_y': expected_y[i], 'actual_y': after['surface_y'][i],
|
||||
'expected_material': expected_m[i], 'actual_material': observed, 'inside_edit_columns': i in affected}
|
||||
mismatches.append(mismatch)
|
||||
if i not in affected:
|
||||
outside_mismatches += 1
|
||||
if original_materials[i] in WATER:
|
||||
water_columns += 1
|
||||
if observed != original_materials[i] or before['surface_y'][i] != after['surface_y'][i]:
|
||||
water_surface_changes.append({'x': x, 'z': z, 'before_y': before['surface_y'][i],
|
||||
'after_y': after['surface_y'][i], 'after_material': observed})
|
||||
water_ok = not water_surface_changes and not stats['observed_water_voxels_replaced']
|
||||
return {'version': 1, 'world': before['world'], 'scope': snapshot.scope,
|
||||
'source': 'Full live Paper surface maps plus observed baseline voxels and desired edit overlay',
|
||||
'surface_ignored_materials': sorted(ignored_materials(after)),
|
||||
'verified_surface_columns': len(expected_y), 'affected_columns': len(affected),
|
||||
'outside_edit_columns_verified': len(expected_y) - len(affected),
|
||||
'changed_surface_columns': changed_surface, 'surface_mismatches': len(mismatches),
|
||||
'outside_edit_surface_mismatches': outside_mismatches,
|
||||
'mismatch_examples': mismatches[:40],
|
||||
'original_visible_water_columns': water_columns,
|
||||
'visible_water_surface_changes': len(water_surface_changes),
|
||||
'water_surface_change_examples': water_surface_changes[:20],
|
||||
**stats, 'water_preservation_passed': water_ok,
|
||||
'passed': not mismatches and water_ok,
|
||||
'capture_started_at': after.get('capture_started_at'), 'capture_finished_at': after.get('capture_finished_at'),
|
||||
'atomic_snapshot': False,
|
||||
'note': 'Sequential map captures; edits must be idle during each capture. This checks every visible surface and the observed water blocks in the edit. Hidden untouched blocks outside the voxel survey are not inferred. Live voxel and headroom checks are separate.'}
|
||||
|
||||
|
||||
def render(after, layout, report, path):
|
||||
import numpy as np
|
||||
import matplotlib
|
||||
matplotlib.use('Agg')
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.colors import to_rgb
|
||||
|
||||
heights = np.asarray(after['surface_y'], dtype=float).reshape(after['length'], after['width'])
|
||||
ids = np.asarray(after['material_index']).reshape(heights.shape)
|
||||
colors = after.get('palette_rgb')
|
||||
if not isinstance(colors, list) or len(colors) != len(after['palette']):
|
||||
raise ValueError('PNG rendering requires the actual map palette_rgb array')
|
||||
rgb = np.asarray([to_rgb(c) for c in colors])[ids]
|
||||
dz, dx = np.gradient(heights)
|
||||
light = (.55 * dx + .55 * dz + .63) / np.sqrt(dx * dx + dz * dz + 1)
|
||||
shade = np.clip(.76 + .36 * light, .44, 1.13)
|
||||
for index, name in enumerate(after['palette']):
|
||||
if name.endswith(('_concrete', '_wool', '_terracotta')):
|
||||
shade[ids == index] = 1
|
||||
if name in WATER:
|
||||
shade[ids == index] = .96
|
||||
rgb = np.clip(rgb * shade[:, :, None], 0, 1)
|
||||
blocks = layout['blocks']
|
||||
x0, x1 = min(b['x'] for b in blocks) - 20, max(b['x'] for b in blocks) + 20
|
||||
z0, z1 = min(b['z'] for b in blocks) - 20, max(b['z'] for b in blocks) + 20
|
||||
fig, ax = plt.subplots(figsize=(11, 14), facecolor='#f1eee4')
|
||||
ax.set_facecolor('#f1eee4')
|
||||
ax.imshow(rgb, origin='upper', interpolation='nearest',
|
||||
extent=(after['min_x'], after['max_x'] + 1, after['max_z'] + 1, after['min_z']))
|
||||
ax.set(xlim=(x0, x1), ylim=(z1, z0), xlabel='X · east →', ylabel='Z (positive south)')
|
||||
ax.set_aspect('equal')
|
||||
ax.tick_params(colors='#4f594d', labelsize=9)
|
||||
for spine in ax.spines.values():
|
||||
spine.set_color('#9a9e8c')
|
||||
labels = [
|
||||
((0, 9), (64, -10), '01 ARRIVAL SQUARE'),
|
||||
((-14, -117), (33, -156), '02 CLOCK STATION'),
|
||||
((-6, -79), (54, -75), 'Station steps'),
|
||||
((-7, -61), (37, -44), 'Forecourt'),
|
||||
((1, 91), (-57, 115), 'South approach'),
|
||||
((-75, 30), (-74, 62), 'Lake approach'),
|
||||
((-60, -36), (-98, -23), 'Portal approach'),
|
||||
((-63, -72), (-93, -103), 'Northwest promenade'),
|
||||
((67, -84), (77, -117), 'Northeast promenade'),
|
||||
((68, 10), (72, 42), 'East approach')]
|
||||
for (x, z), (tx, tz), label in labels:
|
||||
ax.annotate(label, xy=(x + .5, z + .5), xytext=(tx, tz),
|
||||
ha='center', va='center', fontsize=8.7 if label[:2] in ('01', '02') else 8,
|
||||
color='#263b31', weight='bold' if label[:2] in ('01', '02') else 'normal',
|
||||
bbox=dict(boxstyle='round,pad=.3', fc='#f8f5ea', ec='#849080', lw=.6, alpha=.96),
|
||||
arrowprops=dict(arrowstyle='-', color='#354b3c', lw=.8, shrinkA=3, shrinkB=2))
|
||||
ax.annotate('N', xy=(x1 - 8, z0 + 4), xytext=(x1 - 8, z0 + 18), ha='center', va='center',
|
||||
color='#20392d', fontsize=11, weight='bold', arrowprops=dict(arrowstyle='-|>', color='#20392d'))
|
||||
bar_x, bar_z = x0 + 8, z1 - 8
|
||||
ax.plot([bar_x, bar_x + 25], [bar_z, bar_z], color='#20392d', lw=2)
|
||||
ax.text(bar_x + 12.5, bar_z - 2, '25 blocks', ha='center', va='bottom', fontsize=8, color='#20392d',
|
||||
bbox=dict(fc='#f8f5ea', ec='none', alpha=.8, pad=1))
|
||||
fig.suptitle('SHACRAFT / FOUNDATIONS 01 + 02', x=.5, y=.975, fontsize=17, weight='bold', color='#20392d')
|
||||
fig.text(.5, .945, 'Actual server surface · north up · local roads and supported foundations',
|
||||
ha='center', fontsize=10, color='#52614e')
|
||||
status = 'surface check passed' if report['passed'] else f"{report['surface_mismatches']} surface mismatches"
|
||||
fig.text(.5, .025, f"{report['verified_surface_columns']:,} map columns checked · {status}\n"
|
||||
'Material colors and relief come from the captured world. Labels are annotations.',
|
||||
ha='center', fontsize=9, color='#52614e', linespacing=1.6)
|
||||
fig.subplots_adjust(left=.09, right=.96, top=.923, bottom=.077)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fig.savefig(path, dpi=180, facecolor=fig.get_facecolor())
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
class FakeSnapshot:
|
||||
scope = {'project_id': 'project', 'world_id': 'world', 'world_epoch': 'epoch'}
|
||||
|
||||
def __init__(self, states):
|
||||
self.states = states
|
||||
|
||||
def state(self, x, y, z):
|
||||
return self.states[(x, y, z)]
|
||||
|
||||
|
||||
def fixture_map(heights, materials):
|
||||
palette = list(dict.fromkeys(materials))
|
||||
return {'format': 'minecraft-builder-surface-map-v1', 'source': 'paper_world_surface',
|
||||
'world': 'test', 'world_uuid': 'world', 'world_key': 'minecraft:test',
|
||||
'min_x': 0, 'max_x': len(heights) - 1, 'min_z': 0, 'max_z': 0,
|
||||
'width': len(heights), 'length': 1, 'surface_y': heights,
|
||||
'palette': palette, 'material_index': [palette.index(m) for m in materials]}
|
||||
|
||||
|
||||
class FoundationMapTests(unittest.TestCase):
|
||||
def baseline(self):
|
||||
before = fixture_map([10, 12, 8], ['minecraft:grass_block', 'minecraft:gold_block', 'minecraft:water'])
|
||||
snapshot = FakeSnapshot({(0, 10, 0): 'minecraft:grass_block', (0, 9, 0): 'minecraft:air',
|
||||
(0, 8, 0): 'minecraft:stone'})
|
||||
layout = {'version': 1, 'scope': snapshot.scope,
|
||||
'blocks': [{'x': 0, 'y': 10, 'z': 0, 'block': 'minecraft:air', 'expected': 'minecraft:grass_block'}]}
|
||||
return before, snapshot, layout
|
||||
|
||||
def test_cut_exposes_lower_observed_block_and_preserves_other_columns(self):
|
||||
before, snapshot, layout = self.baseline()
|
||||
after = fixture_map([8, 12, 8], ['minecraft:stone', 'minecraft:gold_block', 'minecraft:water'])
|
||||
report = verify(before, after, snapshot, layout)
|
||||
self.assertTrue(report['passed'])
|
||||
self.assertEqual(report['lower_snapshot_voxels_consulted_after_cuts'], 2)
|
||||
self.assertEqual(report['outside_edit_columns_verified'], 2)
|
||||
self.assertEqual(report['original_visible_water_columns'], 1)
|
||||
|
||||
def test_unrelated_column_change_is_detected(self):
|
||||
before, snapshot, layout = self.baseline()
|
||||
after = fixture_map([8, 11, 8], ['minecraft:stone', 'minecraft:gold_block', 'minecraft:water'])
|
||||
report = verify(before, after, snapshot, layout)
|
||||
self.assertFalse(report['passed'])
|
||||
self.assertEqual(report['outside_edit_surface_mismatches'], 1)
|
||||
self.assertEqual(report['mismatch_examples'][0]['x'], 1)
|
||||
|
||||
def test_missing_lower_voxel_is_not_treated_as_air(self):
|
||||
before, snapshot, layout = self.baseline()
|
||||
del snapshot.states[(0, 9, 0)]
|
||||
with self.assertRaises(KeyError):
|
||||
expected_surface(before, snapshot, layout)
|
||||
|
||||
def test_new_surface_block_properties_reduce_to_material(self):
|
||||
before, snapshot, layout = self.baseline()
|
||||
snapshot.states[(0, 11, 0)] = 'minecraft:air'
|
||||
layout['blocks'].append({'x': 0, 'y': 11, 'z': 0, 'expected': 'minecraft:air',
|
||||
'block': 'minecraft:smooth_stone_slab[type=bottom,waterlogged=false]'})
|
||||
after = fixture_map([11, 12, 8], ['minecraft:smooth_stone_slab', 'minecraft:gold_block', 'minecraft:water'])
|
||||
self.assertTrue(verify(before, after, snapshot, layout)['passed'])
|
||||
|
||||
def test_stale_expected_state_and_surface_source_mismatch_rejected(self):
|
||||
before, snapshot, layout = self.baseline()
|
||||
layout['blocks'][0]['expected'] = 'minecraft:dirt'
|
||||
with self.assertRaisesRegex(ValueError, 'baseline'):
|
||||
expected_surface(before, snapshot, layout)
|
||||
layout['blocks'][0]['expected'] = 'minecraft:grass_block'
|
||||
before['palette'][0] = 'minecraft:dirt'
|
||||
with self.assertRaisesRegex(ValueError, 'original surface'):
|
||||
expected_surface(before, snapshot, layout)
|
||||
|
||||
def test_explicit_barrier_roof_is_invisible_but_legacy_roof_is_visible(self):
|
||||
before, snapshot, _ = self.baseline()
|
||||
snapshot.states[(0, 20, 0)] = 'minecraft:air'
|
||||
layout = {'version': 1, 'scope': snapshot.scope, 'blocks': [
|
||||
{'x': 0, 'y': 20, 'z': 0, 'block': 'minecraft:barrier', 'expected': 'minecraft:air'}]}
|
||||
legacy_after = fixture_map([20, 12, 8], ['minecraft:barrier', 'minecraft:gold_block', 'minecraft:water'])
|
||||
self.assertTrue(verify(before, legacy_after, snapshot, layout)['passed'])
|
||||
visible_after = json.loads(json.dumps(before))
|
||||
before['ignored_materials'] = visible_after['ignored_materials'] = ['minecraft:barrier']
|
||||
self.assertTrue(verify(before, visible_after, snapshot, layout)['passed'])
|
||||
|
||||
def test_replacing_visible_top_with_ignored_barrier_exposes_lower_observed_stone(self):
|
||||
before, snapshot, layout = self.baseline()
|
||||
layout['blocks'][0]['block'] = 'minecraft:barrier'
|
||||
after = fixture_map([8, 12, 8], ['minecraft:stone', 'minecraft:gold_block', 'minecraft:water'])
|
||||
before['ignored_materials'] = after['ignored_materials'] = sorted(AIR | {'minecraft:barrier'})
|
||||
self.assertTrue(verify(before, after, snapshot, layout)['passed'])
|
||||
|
||||
def test_policy_change_is_not_silently_compared_to_legacy_map(self):
|
||||
before, snapshot, layout = self.baseline()
|
||||
after = fixture_map([8, 12, 8], ['minecraft:stone', 'minecraft:gold_block', 'minecraft:water'])
|
||||
after['ignored_materials'] = sorted(AIR | {'minecraft:barrier'})
|
||||
with self.assertRaisesRegex(ValueError, 'policies differ'):
|
||||
verify(before, after, snapshot, layout)
|
||||
|
||||
def test_all_transparent_column_keeps_world_min_air_fallback(self):
|
||||
before = fixture_map([-64], ['minecraft:air'])
|
||||
before['ignored_materials'] = ['minecraft:barrier']
|
||||
after = json.loads(json.dumps(before))
|
||||
snapshot = FakeSnapshot({(0, -64, 0): 'minecraft:barrier', (0, 20, 0): 'minecraft:air'})
|
||||
layout = {'version': 1, 'scope': snapshot.scope, 'blocks': [
|
||||
{'x': 0, 'y': 20, 'z': 0, 'block': 'minecraft:barrier', 'expected': 'minecraft:air'}]}
|
||||
self.assertTrue(verify(before, after, snapshot, layout)['passed'])
|
||||
|
||||
def test_overhead_addition_uses_captured_map_below_tight_voxel_survey(self):
|
||||
before = fixture_map([10], ['minecraft:grass_block'])
|
||||
snapshot = FakeSnapshot({(0, 20, 0): 'minecraft:air'})
|
||||
layout = {'version': 1, 'scope': snapshot.scope, 'blocks': [
|
||||
{'x': 0, 'y': 20, 'z': 0, 'block': 'minecraft:stone', 'expected': 'minecraft:air'}]}
|
||||
after = fixture_map([20], ['minecraft:stone'])
|
||||
report = verify(before, after, snapshot, layout)
|
||||
self.assertTrue(report['passed'])
|
||||
self.assertEqual(report['covered_original_tops_known_only_from_before_map'], 1)
|
||||
before['ignored_materials'] = ['minecraft:barrier']
|
||||
layout['blocks'][0]['block'] = 'minecraft:barrier'
|
||||
with self.assertRaises(KeyError):
|
||||
expected_surface(before, snapshot, layout)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
maps = ROOT / '.runtime/server/plugins/ShacraftTerrain/maps'
|
||||
stage = ROOT / '.runtime/foundations-stage02'
|
||||
parser.add_argument('--before-map', type=Path, default=maps / 'foundations-before.json')
|
||||
parser.add_argument('--after-map', type=Path, default=maps / 'foundations-after.json')
|
||||
parser.add_argument('--snapshot', type=Path, default=stage / 'before.json.gz')
|
||||
parser.add_argument('--layout', type=Path, default=stage / 'foundations.json')
|
||||
parser.add_argument('--report', type=Path, default=stage / 'surface-verification.json')
|
||||
parser.add_argument('--png', type=Path)
|
||||
parser.add_argument('--self-test', action='store_true')
|
||||
args = parser.parse_args()
|
||||
if args.self_test:
|
||||
result = unittest.TextTestRunner().run(unittest.defaultTestLoader.loadTestsFromTestCase(FoundationMapTests))
|
||||
raise SystemExit(0 if result.wasSuccessful() else 1)
|
||||
before, after, layout = [json.loads(p.read_text()) for p in (args.before_map, args.after_map, args.layout)]
|
||||
snapshot = survey.load_snapshot(args.snapshot, survey.scope_of(layout['scope']))
|
||||
report = verify(before, after, snapshot, layout)
|
||||
report['inputs_sha256'] = {name: hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
for name, path in [('before_map', args.before_map), ('after_map', args.after_map),
|
||||
('baseline_voxels', args.snapshot), ('desired_layout', args.layout)]}
|
||||
args.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
survey.terrain.save(args.report, report)
|
||||
if args.png:
|
||||
render(after, layout, report, args.png)
|
||||
print(json.dumps({k: v for k, v in report.items() if k not in ('inputs_sha256', 'mismatch_examples', 'note', 'observed_water_voxels_replaced')}, indent=2))
|
||||
raise SystemExit(0 if report['passed'] else 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,60 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare every live map column to the planned visible result and inspect hidden water."""
|
||||
import argparse
|
||||
from collections import defaultdict
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--before',type=Path,required=True);p.add_argument('--after',type=Path,required=True)
|
||||
p.add_argument('--layout',type=Path,action='append',required=True);p.add_argument('--report',type=Path,required=True)
|
||||
args=p.parse_args();before=json.loads(args.before.read_text());after=json.loads(args.after.read_text())
|
||||
for key in ('world_uuid','min_x','max_x','min_z','max_z','width','length'):
|
||||
if before[key]!=after[key]:raise ValueError('Map scopes differ: '+key)
|
||||
w=before['width'];expected_y=before['surface_y'].copy()
|
||||
expected_m=[before['palette'][i] for i in before['material_index']]
|
||||
original_m=expected_m.copy();targets=set();planned_blocks=0
|
||||
for path in args.layout:
|
||||
layout=json.loads(path.read_text())
|
||||
if layout['scope']['world_id']!=before['world_uuid']:raise ValueError('Layout world differs')
|
||||
for b in sorted(layout['blocks'],key=lambda b:b['y']):
|
||||
i=(b['z']-before['min_z'])*w+b['x']-before['min_x'];targets.add((b['x'],b['y'],b['z']));planned_blocks+=1
|
||||
if b['y']>=expected_y[i]:expected_y[i]=b['y'];expected_m[i]=b['block']
|
||||
observed=[after['palette'][i] for i in after['material_index']]
|
||||
mismatches=[i for i in range(len(expected_y)) if expected_y[i]!=after['surface_y'][i] or expected_m[i]!=observed[i]]
|
||||
if mismatches:raise ValueError(f'{len(mismatches)} unexpected surface columns; first indices {mismatches[:8]}')
|
||||
hidden_water=defaultdict(list)
|
||||
for i,material in enumerate(original_m):
|
||||
if material=='minecraft:water' and observed[i]!='minecraft:water':
|
||||
x=i%w+before['min_x'];z=i//w+before['min_z'];y=before['surface_y'][i]
|
||||
hidden_water[(x//16,z//16,y)].append((x,y,z))
|
||||
spec=importlib.util.spec_from_file_location('terrain',ROOT/'scripts/terrain.py')
|
||||
terrain=importlib.util.module_from_spec(spec);spec.loader.exec_module(terrain)
|
||||
backend=terrain.Backend(ROOT/'.runtime/server/plugins/MinecraftBuilderMCP/config.yml')
|
||||
context=backend.call('project_context')
|
||||
if context['world_id']!=before['world_uuid']:raise ValueError('Live verification world differs')
|
||||
verified_water=0
|
||||
for points in hidden_water.values():
|
||||
lo={axis:min(point[j] for point in points) for j,axis in enumerate(('x','y','z'))}
|
||||
hi={axis:max(point[j] for point in points) for j,axis in enumerate(('x','y','z'))}
|
||||
read=backend.call('region_inspect',min=lo,max=hi,detail='blocks')
|
||||
states={tuple(b['pos'][axis] for axis in ('x','y','z')):b['state'] for b in read['blocks']}
|
||||
for point in points:
|
||||
if not states[point].startswith('minecraft:water['):raise ValueError('Water changed beneath a marker: '+str(point))
|
||||
verified_water+=1
|
||||
report={'world':before['world'],'source':'live Paper surface maps + bounded RPC water reads',
|
||||
'verified_surface_columns':len(expected_y),'surface_mismatches':0,
|
||||
'unique_marker_blocks':len(targets),'planned_writes':planned_blocks,
|
||||
'original_water_columns':original_m.count('minecraft:water'),'water_columns_obscured_by_markers':verified_water,
|
||||
'water_obscured_by_markers_verified_intact':True,
|
||||
'height_unchanged_columns':sum(a==b for a,b in zip(before['surface_y'],after['surface_y'])),
|
||||
'capture_started_at':after['capture_started_at'],'capture_finished_at':after['capture_finished_at'],
|
||||
'atomic_snapshot':False,'note':'World edits were idle during each capture. Terrain follows its original heights; raised markers represent future structures, not finished traversable paths.'}
|
||||
args.report.parent.mkdir(parents=True,exist_ok=True);args.report.write_text(json.dumps(report,indent=2)+'\n')
|
||||
print(json.dumps(report))
|
||||
|
||||
if __name__=='__main__':main()
|
||||
@@ -1,375 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only candidate/live QA for the composed Shacraft arrival gardens.
|
||||
|
||||
Candidate mode overlays desired states on the observed baseline. Live mode uses
|
||||
an observed after snapshot and compares every planned state exactly. The checks
|
||||
cover this toolkit's static single flowers, persistent leaves, rooted log trees,
|
||||
supported lanterns and supplied walking surfaces, not arbitrary Minecraft physics.
|
||||
"""
|
||||
import argparse
|
||||
from collections import Counter, deque
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def module(name, path):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
value = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(value)
|
||||
return value
|
||||
|
||||
|
||||
survey = module('plaza_qa_survey', ROOT / 'scripts/foundation-survey.py')
|
||||
navigation = module('plaza_qa_navigation', ROOT / 'scripts/foundation-study/verify_geometry.py')
|
||||
FLOWERS = {'allium', 'oxeye_daisy', 'azure_bluet', 'pink_tulip', 'white_tulip'}
|
||||
SOIL = {'dirt', 'grass_block'}
|
||||
STATIC_CUBES = survey.FULL | {'waxed_oxidized_cut_copper'}
|
||||
N3 = ((1, 0, 0), (-1, 0, 0), (0, 1, 0), (0, -1, 0), (0, 0, 1), (0, 0, -1))
|
||||
|
||||
|
||||
def parse(state):
|
||||
name, _, raw = state.removeprefix('minecraft:').partition('[')
|
||||
props = dict(part.split('=', 1) for part in raw.rstrip(']').split(',') if '=' in part)
|
||||
return name, props
|
||||
|
||||
|
||||
class Reader:
|
||||
def __init__(self, snapshot, overlay=None):
|
||||
self.snapshot, self.scope = snapshot, snapshot.scope
|
||||
self.overlay, self.cache = overlay or {}, {}
|
||||
|
||||
def state(self, x, y, z):
|
||||
key = (x, y, z)
|
||||
if key not in self.cache:
|
||||
self.cache[key] = self.overlay[key] if key in self.overlay else self.snapshot.state(x, y, z)
|
||||
return self.cache[key]
|
||||
|
||||
|
||||
def supported_center(state, face):
|
||||
"""Conservative face support for full cubes and simple dry shapes used here."""
|
||||
name, props = parse(state)
|
||||
if props.get('waterlogged') == 'true':
|
||||
return False
|
||||
if name in STATIC_CUBES:
|
||||
return True
|
||||
if name.endswith('_slab'):
|
||||
return props.get('type') == 'double' or props.get('type') == ('bottom' if face == 'down' else 'top')
|
||||
if name.endswith('_stairs'):
|
||||
return props.get('half') == ('bottom' if face == 'down' else 'top')
|
||||
if name == 'iron_chain':
|
||||
return props.get('axis') == 'y'
|
||||
if name in ('spruce_fence', 'iron_bars'):
|
||||
return True
|
||||
if name == 'stone_brick_wall':
|
||||
return props.get('up') == 'true'
|
||||
return False
|
||||
|
||||
|
||||
def leaf_distance(reader, start):
|
||||
"""Shortest face-connected path from a leaf to a log, capped at seven.
|
||||
|
||||
Read actual adjacent states, including unchanged leaves/logs, rather than
|
||||
trusting the compiler's desired-only propagation or saved distance values.
|
||||
"""
|
||||
pending, seen = deque([(start, 0)]), {start}
|
||||
while pending:
|
||||
(x, y, z), distance = pending.popleft()
|
||||
if distance >= 6:
|
||||
continue
|
||||
for dx, dy, dz in N3:
|
||||
neighbor = x + dx, y + dy, z + dz
|
||||
name, _ = parse(reader.state(*neighbor))
|
||||
if name.endswith('_log'):
|
||||
return distance + 1
|
||||
if name.endswith('_leaves') and neighbor not in seen:
|
||||
seen.add(neighbor)
|
||||
pending.append((neighbor, distance + 1))
|
||||
return 7
|
||||
|
||||
|
||||
def check_fixtures(reader, desired, metadata):
|
||||
failures, counts = [], Counter()
|
||||
for at, planned in desired.items():
|
||||
name, _ = parse(planned)
|
||||
if name not in FLOWERS and name != 'lantern' and not name.endswith('_leaves'):
|
||||
continue
|
||||
x, y, z = at
|
||||
state = reader.state(*at)
|
||||
actual_name, props = parse(state)
|
||||
if actual_name != name:
|
||||
failures.append({'at': list(at), 'reason': 'fixture_material_differs', 'planned': planned, 'actual': state})
|
||||
continue
|
||||
if name in FLOWERS:
|
||||
counts['flowers'] += 1
|
||||
below = reader.state(x, y - 1, z)
|
||||
if parse(below)[0] not in SOIL:
|
||||
failures.append({'at': list(at), 'reason': 'flower_without_valid_soil', 'below': below})
|
||||
elif name == 'lantern':
|
||||
counts['lanterns'] += 1
|
||||
hanging = props.get('hanging') == 'true'
|
||||
support = reader.state(x, y + (1 if hanging else -1), z)
|
||||
if props.get('waterlogged') != 'false' or not supported_center(support, 'down' if hanging else 'up'):
|
||||
failures.append({'at': list(at), 'reason': 'lantern_without_dry_center_support', 'support': support})
|
||||
else:
|
||||
counts['leaves'] += 1
|
||||
expected_distance = leaf_distance(reader, at)
|
||||
if (props.get('persistent') != 'true' or props.get('waterlogged') != 'false'
|
||||
or props.get('distance') != str(expected_distance)):
|
||||
failures.append({'at': list(at), 'reason': 'unstable_or_wrong_leaf_state',
|
||||
'actual': state, 'expected_distance': expected_distance})
|
||||
roots = set()
|
||||
for fixture in metadata['fixtures']:
|
||||
if fixture['type'] not in ('conifer', 'topiary'):
|
||||
continue
|
||||
counts['rooted_trees'] += 1
|
||||
x, z = fixture['x'], fixture['z']
|
||||
root = (x, 96, z)
|
||||
roots.add(root)
|
||||
root_name, root_props = parse(reader.state(*root))
|
||||
below = reader.state(x, 95, z)
|
||||
if not root_name.endswith('_log') or root_props.get('axis') != 'y' or parse(below)[0] not in SOIL:
|
||||
failures.append({'at': list(root), 'reason': 'tree_root_without_vertical_log_and_soil', 'below': below})
|
||||
elif parse(below)[0] == 'grass_block':
|
||||
failures.append({'at': [x, 95, z], 'reason': 'grass_under_opaque_trunk_will_decay_to_dirt'})
|
||||
trunk = sorted(p[1] for p, state in desired.items() if p[0] == x and p[2] == z and parse(state)[0].endswith('_log'))
|
||||
if not trunk or trunk != list(range(96, max(trunk) + 1)):
|
||||
failures.append({'at': list(root), 'reason': 'discontinuous_planned_trunk', 'log_y': trunk})
|
||||
else:
|
||||
for y in trunk:
|
||||
material, props = parse(reader.state(x, y, z))
|
||||
if not material.endswith('_log') or props.get('axis') != 'y':
|
||||
failures.append({'at': [x, y, z], 'reason': 'trunk_gap_or_wrong_axis'})
|
||||
log_positions = {p for p, s in desired.items() if parse(s)[0].endswith('_log')}
|
||||
pending, connected = deque(roots & log_positions), roots & log_positions
|
||||
while pending:
|
||||
x, y, z = pending.popleft()
|
||||
for dx, dy, dz in N3:
|
||||
p = x + dx, y + dy, z + dz
|
||||
if p in log_positions and p not in connected:
|
||||
pending.append(p)
|
||||
connected.add(p)
|
||||
if log_positions - connected:
|
||||
failures.append({'reason': 'logs_disconnected_from_declared_tree_roots', 'positions': [list(p) for p in sorted(log_positions - connected)[:20]]})
|
||||
counts['root_connected_logs'] = len(connected)
|
||||
return {'passed': not failures, 'checked': dict(counts), 'failures': failures}
|
||||
|
||||
|
||||
def audit(before, layout, metadata, walk, base_navigation, after=None):
|
||||
scope = survey.scope_of(layout['scope'])
|
||||
if any(value != scope for value in (before.scope, metadata['scope'], walk['scope'])) or (after and after.scope != scope):
|
||||
raise ValueError('Project/world/epoch differs between QA inputs')
|
||||
desired, mismatches = {}, []
|
||||
for block in layout['blocks']:
|
||||
pos = survey.point(block)
|
||||
at = tuple(pos[a] for a in ('x', 'y', 'z'))
|
||||
if at in desired:
|
||||
raise ValueError('Duplicate desired coordinate')
|
||||
if before.state(*at) != block['expected']:
|
||||
raise ValueError(f'Plan expected state differs from baseline at {at}')
|
||||
desired[at] = block['block']
|
||||
if after and after.state(*at) != block['block']:
|
||||
mismatches.append({'at': list(at), 'expected': block['block'], 'actual': after.state(*at)})
|
||||
reader = Reader(after or before, None if after else desired)
|
||||
fixtures = check_fixtures(reader, desired, metadata)
|
||||
walking = survey.verify_walkable(reader, walk['points'])
|
||||
nav = json.loads(json.dumps(base_navigation))
|
||||
blocked = {tuple(p) for p in metadata['unwalkable_columns']}
|
||||
if any((p['x'], p['z']) in blocked for p in walk['points']):
|
||||
raise ValueError('Walking samples include declared unwalkable ground')
|
||||
excluded = 0
|
||||
for cell in nav['cells']:
|
||||
if cell['clear'] and (cell['x'], cell['z']) in blocked:
|
||||
cell['clear'] = False
|
||||
excluded += 1
|
||||
navigation_result = navigation.audit(nav)
|
||||
navigation_result['decorated_ground_columns_excluded'] = excluded
|
||||
nav_cells = navigation.normalize_cells(nav)
|
||||
graph = navigation.surface_graph(nav_cells)
|
||||
reached = navigation.reachable(graph, (0, 9))
|
||||
bench_access = []
|
||||
vectors = {'north': (0, -1), 'south': (0, 1), 'west': (-1, 0), 'east': (1, 0)}
|
||||
for fixture in metadata['fixtures']:
|
||||
if fixture['type'] != 'bench':
|
||||
continue
|
||||
dx, dz = vectors[fixture['facing']]
|
||||
for offset in (-1, 0, 1):
|
||||
point = fixture['x'] + dx - dz * offset, fixture['z'] + dz + dx * offset
|
||||
bench_access.append(navigation.endpoint_report(
|
||||
f"bench-{fixture['x']}-{fixture['z']}-front-{offset}", point, graph, reached))
|
||||
navigation_result['bench_front_access'] = bench_access
|
||||
navigation_result['passed'] &= all(point['passed'] for point in bench_access)
|
||||
# A canopy stays traversable when it does not occupy the 1.8-block body space.
|
||||
# Only explicit low obstacles are removed; overhead leaves do not mask paths.
|
||||
return {'version': 1, 'mode': 'actual_after_snapshot' if after else 'candidate_overlay', 'scope': scope,
|
||||
'world_edits': 0, 'desired_blocks': len(desired), 'baseline_expected_states_verified': len(desired),
|
||||
'actual_exact_state_mismatches': len(mismatches) if after else None,
|
||||
'actual_mismatch_examples': mismatches[:30], 'fixtures': fixtures, 'walking': walking,
|
||||
'navigation': navigation_result,
|
||||
'passed': not mismatches and fixtures['passed'] and walking['passed'] and navigation_result['passed'],
|
||||
'note': 'Static support, leaf-distance and point-sampled navigation checks; not a complete moving-player collision simulation. Actual mode uses sequential observed snapshots.'}
|
||||
|
||||
|
||||
def render_map(document, metadata, report, path, boundary_columns=None, boundary_note=None):
|
||||
"""Render actual captured surface cells with semantic material colors."""
|
||||
import numpy as np
|
||||
import matplotlib
|
||||
matplotlib.use('Agg')
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.colors import to_rgb
|
||||
from matplotlib.collections import LineCollection
|
||||
|
||||
if document.get('source') != 'paper_world_surface' or document.get('world_uuid') != metadata['scope']['world_id']:
|
||||
raise ValueError('Rendering requires an actual Paper map of this project world')
|
||||
semantic = {'minecraft:smooth_sandstone': '#e8ddbd', 'minecraft:cut_sandstone': '#d5c7a5',
|
||||
'minecraft:smooth_sandstone_slab': '#e8ddbd', 'minecraft:grass_block': '#87a75f',
|
||||
'minecraft:oak_leaves': '#78924d', 'minecraft:spruce_leaves': '#3e6851',
|
||||
'minecraft:spruce_log': '#765637', 'minecraft:green_concrete': '#548338',
|
||||
'minecraft:spruce_fence': '#7b5839', 'minecraft:spruce_stairs': '#967147',
|
||||
'minecraft:allium': '#b388ce', 'minecraft:oxeye_daisy': '#faf3d6',
|
||||
'minecraft:azure_bluet': '#f2f0db', 'minecraft:pink_tulip': '#efa8b7',
|
||||
'minecraft:white_tulip': '#f7f3e6', 'minecraft:lantern': '#ecc565'}
|
||||
palette = []
|
||||
for name, fallback in zip(document['palette'], document['palette_rgb']):
|
||||
color = '#68a494' if 'waxed_oxidized_cut_copper' in name else semantic.get(name, fallback)
|
||||
palette.append(to_rgb(color))
|
||||
heights = np.asarray(document['surface_y'], dtype=float).reshape(document['length'], document['width'])
|
||||
indices = np.asarray(document['material_index']).reshape(heights.shape)
|
||||
rgb = np.asarray(palette)[indices]
|
||||
dz, dx = np.gradient(heights)
|
||||
shade = np.clip(.79 + .26 * (.55 * dx + .55 * dz + .63) / np.sqrt(1 + dx * dx + dz * dz), .64, 1.06)
|
||||
for index, name in enumerate(document['palette']):
|
||||
if name.removeprefix('minecraft:') in FLOWERS or name.endswith('_concrete'):
|
||||
shade[indices == index] = 1
|
||||
rgb = np.clip(rgb * shade[:, :, None], 0, 1)
|
||||
fig, ax = plt.subplots(figsize=(11, 12), facecolor='#f3f0e6')
|
||||
ax.imshow(rgb, interpolation='nearest', origin='upper',
|
||||
extent=(document['min_x'], document['max_x'] + 1, document['max_z'] + 1, document['min_z']))
|
||||
if boundary_columns:
|
||||
boundary = set(map(tuple, boundary_columns))
|
||||
segments = [[(x + .5, z + .5), (x + dx + .5, z + dz + .5)]
|
||||
for x, z in boundary for dx, dz in ((1, 0), (0, 1)) if (x + dx, z + dz) in boundary]
|
||||
ax.add_collection(LineCollection(segments, colors='#a74640', linewidths=1, alpha=.85, zorder=5))
|
||||
ax.scatter([x + .5 for x, z in boundary], [z + .5 for x, z in boundary],
|
||||
s=2, color='#a74640', alpha=.85, zorder=5, label='Invisible wall · observed barrier blocks')
|
||||
ax.legend(loc='upper left', fontsize=8, facecolor='#faf7ed', framealpha=.94, edgecolor='#bcbfac')
|
||||
ax.set(xlim=(-52, 53), ylim=(67, -47), xlabel='X · east →', ylabel='Z (positive south)')
|
||||
ax.set_aspect('equal')
|
||||
ax.tick_params(colors='#596451', labelsize=9)
|
||||
for spine in ax.spines.values():
|
||||
spine.set_color('#9ca28d')
|
||||
ax.annotate('N', xy=(47, -44), xytext=(47, -35), ha='center', va='center',
|
||||
fontsize=11, weight='bold', color='#254534', arrowprops=dict(arrowstyle='-|>', color='#254534'))
|
||||
ax.plot([-47, -37], [62, 62], color='#254534', lw=2)
|
||||
ax.text(-42, 60, '10 blocks', ha='center', fontsize=8, color='#254534',
|
||||
bbox=dict(fc='#faf7ed', ec='none', pad=2, alpha=.9))
|
||||
fig.suptitle('SHACRAFT / ARRIVAL GARDEN 01', x=.5, y=.975, fontsize=17, weight='bold', color='#244732')
|
||||
fig.text(.5, .945, 'Captured server surface · original brand inlay · planted gardens',
|
||||
ha='center', fontsize=10, color='#586751')
|
||||
fig.text(.5, .035, f"{metadata['planters']} garden beds · {metadata['trees']} conifers · {metadata['benches']} benches · {metadata['new_lamps']} copper-capped lamps\n"
|
||||
'Observed block positions and materials · semantic colors and height shading.'
|
||||
+ ('\n' + boundary_note if boundary_note else ''),
|
||||
ha='center', fontsize=9, color='#586751', linespacing=1.7)
|
||||
fig.subplots_adjust(left=.09, right=.97, top=.917, bottom=.135 if boundary_note else .105)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fig.savefig(path, dpi=180, facecolor=fig.get_facecolor())
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
class FakeSnapshot:
|
||||
scope = {'project_id': 'test', 'world_id': 'world', 'world_epoch': 'epoch'}
|
||||
|
||||
def __init__(self, states):
|
||||
self.states = states
|
||||
|
||||
def state(self, x, y, z):
|
||||
return self.states.get((x, y, z), 'minecraft:air')
|
||||
|
||||
|
||||
class PlazaQATests(unittest.TestCase):
|
||||
def test_flower_soil_and_hanging_lantern_support(self):
|
||||
desired = {(0, 96, 0): 'minecraft:white_tulip',
|
||||
(2, 100, 0): 'minecraft:lantern[hanging=true,waterlogged=false]'}
|
||||
states = {**desired, (0, 95, 0): 'minecraft:grass_block[snowy=false]', (2, 101, 0): 'minecraft:waxed_oxidized_cut_copper'}
|
||||
result = check_fixtures(Reader(FakeSnapshot(states)), desired, {'fixtures': []})
|
||||
self.assertTrue(result['passed'])
|
||||
del states[(2, 101, 0)]
|
||||
states[(0, 95, 0)] = 'minecraft:smooth_sandstone'
|
||||
result = check_fixtures(Reader(FakeSnapshot(states)), desired, {'fixtures': []})
|
||||
self.assertEqual({f['reason'] for f in result['failures']}, {'flower_without_valid_soil', 'lantern_without_dry_center_support'})
|
||||
|
||||
def test_leaf_distance_uses_unchanged_neighbors_and_requires_persistence(self):
|
||||
leaf = 'minecraft:spruce_leaves[distance=2,persistent=true,waterlogged=false]'
|
||||
desired = {(0, 100, 0): leaf}
|
||||
states = {**desired, (1, 100, 0): 'minecraft:oak_leaves[distance=1,persistent=true,waterlogged=false]',
|
||||
(2, 100, 0): 'minecraft:spruce_log[axis=y]'}
|
||||
result = check_fixtures(Reader(FakeSnapshot(states)), desired, {'fixtures': []})
|
||||
self.assertTrue(result['passed'])
|
||||
states[(0, 100, 0)] = leaf.replace('persistent=true', 'persistent=false')
|
||||
self.assertFalse(check_fixtures(Reader(FakeSnapshot(states)), desired, {'fixtures': []})['passed'])
|
||||
|
||||
def test_disconnected_tree_trunk_is_rejected(self):
|
||||
desired = {(0, 96, 0): 'minecraft:spruce_log[axis=y]', (0, 98, 0): 'minecraft:spruce_log[axis=y]'}
|
||||
states = {**desired, (0, 95, 0): 'minecraft:dirt'}
|
||||
result = check_fixtures(Reader(FakeSnapshot(states)), desired, {'fixtures': [{'type': 'conifer', 'x': 0, 'z': 0}]})
|
||||
self.assertFalse(result['passed'])
|
||||
self.assertIn('discontinuous_planned_trunk', {f['reason'] for f in result['failures']})
|
||||
|
||||
def test_grass_under_opaque_root_is_unstable_but_dirt_is_valid(self):
|
||||
desired = {(0, 96, 0): 'minecraft:spruce_log[axis=y]'}
|
||||
states = {**desired, (0, 95, 0): 'minecraft:grass_block[snowy=false]'}
|
||||
metadata = {'fixtures': [{'type': 'conifer', 'x': 0, 'z': 0}]}
|
||||
result = check_fixtures(Reader(FakeSnapshot(states)), desired, metadata)
|
||||
self.assertEqual(result['failures'][0]['reason'], 'grass_under_opaque_trunk_will_decay_to_dirt')
|
||||
states[(0, 95, 0)] = 'minecraft:dirt'
|
||||
self.assertTrue(check_fixtures(Reader(FakeSnapshot(states)), desired, metadata)['passed'])
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
stage = ROOT / '.runtime/plaza-stage03'
|
||||
parser.add_argument('--before', type=Path, default=stage / 'before.json.gz')
|
||||
parser.add_argument('--after', type=Path)
|
||||
parser.add_argument('--layout', type=Path, default=stage / 'plaza.json')
|
||||
parser.add_argument('--metadata', type=Path, default=stage / 'plaza.metadata.json')
|
||||
parser.add_argument('--walk', type=Path, default=stage / 'plaza.walk.json')
|
||||
parser.add_argument('--navigation', type=Path, default=ROOT / '.runtime/foundations-stage02/navigation-verified-walkable-input.json')
|
||||
parser.add_argument('--report', type=Path, default=stage / 'candidate-qa.json')
|
||||
parser.add_argument('--map', type=Path, help='Actual after surface map, used only with --png and --after')
|
||||
parser.add_argument('--png', type=Path, help='Focused map of the captured arrival garden')
|
||||
parser.add_argument('--self-test', action='store_true')
|
||||
args = parser.parse_args()
|
||||
if args.self_test:
|
||||
result = unittest.TextTestRunner().run(unittest.defaultTestLoader.loadTestsFromTestCase(PlazaQATests))
|
||||
raise SystemExit(0 if result.wasSuccessful() else 1)
|
||||
layout, metadata, walk, nav = [json.loads(p.read_text()) for p in (args.layout, args.metadata, args.walk, args.navigation)]
|
||||
before = survey.load_snapshot(args.before, layout['scope'])
|
||||
after = survey.load_snapshot(args.after, layout['scope']) if args.after else None
|
||||
report = audit(before, layout, metadata, walk, nav, after)
|
||||
paths = {'before': args.before, 'layout': args.layout, 'metadata': args.metadata, 'walk': args.walk, 'navigation': args.navigation}
|
||||
if args.after:
|
||||
paths['after'] = args.after
|
||||
report['input_sha256'] = {key: hashlib.sha256(path.read_bytes()).hexdigest() for key, path in paths.items()}
|
||||
args.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
survey.terrain.save(args.report, report)
|
||||
if args.png:
|
||||
if not args.map or not args.after:
|
||||
parser.error('--png requires an actual --map and --after snapshot')
|
||||
render_map(json.loads(args.map.read_text()), metadata, report, args.png)
|
||||
print(json.dumps({'passed': report['passed'], 'mode': report['mode'], 'desired_blocks': report['desired_blocks'],
|
||||
'exact_mismatches': report['actual_exact_state_mismatches'],
|
||||
'fixtures': report['fixtures'], 'walk_points': report['walking']['checked_points'],
|
||||
'walk_failures': report['walking']['failures'][:20],
|
||||
'navigation_passed': report['navigation']['passed'],
|
||||
'unreachable_clear_columns': len(report['navigation']['unreachable_clear_columns']),
|
||||
'route_failures': [r['id'] for r in report['navigation']['routes'] if not r['passed']],
|
||||
'report': str(args.report.resolve())}, indent=2))
|
||||
raise SystemExit(0 if report['passed'] else 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,389 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only candidate/live station block, public-floor and containment QA.
|
||||
|
||||
Recipes use the checked layout contract {version:1,scope,blocks:[x,y,z,block,
|
||||
expected]}. Metadata declares public_floors, air-only clear_regions and optional
|
||||
containment bounds/seeds/authorized_caps. Flooding treats partial/unknown shapes
|
||||
as passable, so a successful result does not rely on decorative collision shapes.
|
||||
"""
|
||||
import argparse
|
||||
from collections import Counter, deque
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SPEC = importlib.util.spec_from_file_location('station_base', ROOT / 'scripts/verify-balustrade.py')
|
||||
base = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(base)
|
||||
survey, plaza = base.survey, base.plaza
|
||||
FULL = plaza.STATIC_CUBES | {'barrier'}
|
||||
N2 = ((1, 0), (-1, 0), (0, 1), (0, -1))
|
||||
N3 = ((1, 0, 0), (-1, 0, 0), (0, 1, 0), (0, -1, 0), (0, 0, 1), (0, 0, -1))
|
||||
|
||||
|
||||
def full_cube(state):
|
||||
return plaza.parse(state)[0] in FULL
|
||||
|
||||
|
||||
def bounded_box(document):
|
||||
box = survey.box_of(document['min'], document['max'])
|
||||
if survey.volume(box) > survey.MAX_VOXELS:
|
||||
raise ValueError('Verification box exceeds the snapshot voxel limit')
|
||||
return box
|
||||
|
||||
|
||||
def positions(box):
|
||||
return ((x, y, z) for x in range(box['min']['x'], box['max']['x'] + 1)
|
||||
for y in range(box['min']['y'], box['max']['y'] + 1)
|
||||
for z in range(box['min']['z'], box['max']['z'] + 1))
|
||||
|
||||
|
||||
def inside(at, box):
|
||||
return all(box['min'][axis] <= value <= box['max'][axis]
|
||||
for axis, value in zip(('x', 'y', 'z'), at))
|
||||
|
||||
|
||||
def surface_shape(state):
|
||||
if full_cube(state):
|
||||
return [(0., 1.)]
|
||||
name, props = plaza.parse(state)
|
||||
if name in {'smooth_sandstone_slab', 'cut_sandstone_slab', 'waxed_oxidized_cut_copper_slab'}:
|
||||
if props.get('waterlogged') != 'false':
|
||||
return None
|
||||
return {'bottom': [(0., .5)], 'top': [(.5, 1.)], 'double': [(0., 1.)]}.get(props.get('type'))
|
||||
return survey.vertical_shape(state)
|
||||
|
||||
|
||||
def public_floors(reader, floors):
|
||||
reports = []
|
||||
for floor in floors:
|
||||
name, feet, raw = floor['id'], floor['standing_y'], floor['clear_columns']
|
||||
if feet not in (99, 113):
|
||||
raise ValueError('This station stage declares public feet at Y99 or Y113')
|
||||
if (not isinstance(raw, list) or not raw or any(not isinstance(p, list) or len(p) != 2
|
||||
or any(type(v) is not int for v in p) for p in raw)):
|
||||
raise ValueError('Each public floor requires integer clear_columns [x,z]')
|
||||
clear = set(map(tuple, raw))
|
||||
if len(clear) != len(raw):
|
||||
raise ValueError('Duplicate public floor column')
|
||||
source = floor['source']['x'], floor['source']['z']
|
||||
if source not in clear:
|
||||
raise ValueError('Floor source is not a declared clear column')
|
||||
height = floor.get('min_headroom', 4)
|
||||
if type(height) not in (int, float) or not math.isfinite(height) or height < 1.8:
|
||||
raise ValueError('Headroom must be finite and at least 1.8 blocks')
|
||||
failures, valid = [], set()
|
||||
for x, z in sorted(clear):
|
||||
support = surface_shape(reader.state(x, feet - 1, z))
|
||||
reason = None
|
||||
if support is None or not any(abs(high - 1.) < 1e-8 for low, high in support):
|
||||
reason = 'missing_or_unknown_support_at_public_feet'
|
||||
else:
|
||||
for y in range(feet, math.ceil(feet + height)):
|
||||
shape = surface_shape(reader.state(x, y, z))
|
||||
if shape is None:
|
||||
reason = 'unknown_shape_in_required_clearance'
|
||||
break
|
||||
if any(y + lo < feet + height and y + hi > feet for lo, hi in shape):
|
||||
reason = 'occupied_required_clearance'
|
||||
break
|
||||
if reason:
|
||||
failures.append({'x': x, 'z': z, 'standing_y': feet, 'reason': reason})
|
||||
else:
|
||||
valid.add((x, z))
|
||||
reached, queue = set(), deque([source])
|
||||
while queue:
|
||||
p = queue.popleft()
|
||||
if p in reached or p not in valid:
|
||||
continue
|
||||
reached.add(p)
|
||||
queue.extend((p[0] + dx, p[1] + dz) for dx, dz in N2)
|
||||
unreachable = sorted(clear - reached)
|
||||
reports.append({'id': name, 'standing_y': feet, 'required_headroom': height,
|
||||
'declared_columns': len(clear), 'physically_clear_columns': len(valid),
|
||||
'reachable_clear_columns': len(reached), 'failed_samples': len(failures),
|
||||
'sample_failure_examples': failures[:30], 'unreachable_columns': len(unreachable),
|
||||
'unreachable_examples': unreachable[:30], 'passed': not failures and not unreachable})
|
||||
return {'status': 'checked' if reports else 'not_provided', 'passed': bool(reports) and all(r['passed'] for r in reports),
|
||||
'floors': reports, 'method': 'Observed center supports and declared vertical headroom, then cardinal traversal across valid level-floor columns. No lift transitions are inferred.'}
|
||||
|
||||
|
||||
def clear_regions(reader, regions):
|
||||
reports = []
|
||||
for region in regions:
|
||||
box, failures, checked = bounded_box(region), [], 0
|
||||
for at in positions(box):
|
||||
checked += 1
|
||||
state = reader.state(*at)
|
||||
if state not in survey.AIR:
|
||||
failures.append({'at': list(at), 'actual': state})
|
||||
reports.append({'id': region['id'], 'bounds': box, 'checked_voxels': checked,
|
||||
'nonair_voxels': len(failures), 'examples': failures[:30], 'passed': not failures})
|
||||
return {'status': 'checked' if reports else 'not_provided', 'passed': bool(reports) and all(r['passed'] for r in reports),
|
||||
'regions': reports, 'method': 'Every declared doorway/cabin/aisle clearance voxel must be observed air.'}
|
||||
|
||||
|
||||
def named_access(reader, metadata):
|
||||
"""Ensure furnishing targets are not silently filtered out of public topology."""
|
||||
interior = metadata.get('interior')
|
||||
if interior is None:
|
||||
return {'status': 'not_provided', 'passed': True}
|
||||
failures, targets_by_floor, lift_reports = [], {}, []
|
||||
floors = {f['standing_y']: set(map(tuple, f['clear_columns'])) for f in metadata['public_floors']}
|
||||
for raw_feet, navigation in interior['navigation'].items():
|
||||
feet = int(raw_feet)
|
||||
targets = navigation['named_targets']
|
||||
targets_by_floor[raw_feet] = len(targets)
|
||||
if feet not in floors or not targets:
|
||||
failures.append({'reason': 'missing_floor_or_named_targets', 'standing_y': feet})
|
||||
for target in targets:
|
||||
x, z = target['x'], target['z']
|
||||
if target['y'] != feet or (x, z) not in floors.get(feet, set()):
|
||||
failures.append({'reason': 'named_target_missing_from_public_topology', 'target': target})
|
||||
if not full_cube(reader.state(x, feet - 1, z)) or any(
|
||||
reader.state(x, y, z) not in survey.AIR for y in range(feet, feet + 4)):
|
||||
failures.append({'reason': 'named_target_missing_full_support_or_four_air', 'target': target})
|
||||
for level, lift in interior['lift'].items():
|
||||
selector = tuple(lift['selector_block'])
|
||||
landing = tuple(lift['landing_block'])
|
||||
selector_state = reader.state(*selector)
|
||||
x, feet, z = landing
|
||||
passed = selector_state == 'minecraft:gold_block' and full_cube(reader.state(x, feet - 1, z)) and all(
|
||||
reader.state(x, y, z) in survey.AIR for y in range(feet, feet + 4))
|
||||
lift_reports.append({'floor': level, 'selector': selector, 'observed_selector': selector_state,
|
||||
'landing': landing, 'passed': passed})
|
||||
if not passed:
|
||||
failures.append({'reason': 'lift_selector_or_landing_obstructed', 'floor': level})
|
||||
return {'status': 'checked', 'passed': not failures, 'named_targets_per_floor': targets_by_floor,
|
||||
'lift_landings': lift_reports, 'failures': failures,
|
||||
'method': 'Named furnishing targets must remain in the independently verified public-floor topology with full support and four air blocks. Gold selector and landing voxels are checked; runtime lift operation is not inferred.'}
|
||||
|
||||
|
||||
def fixture_stability(reader, desired):
|
||||
fixtures = {p: s for p, s in desired.items() if plaza.parse(s)[0] in {'lantern', 'spruce_leaves'}}
|
||||
report = plaza.check_fixtures(reader, fixtures, {'fixtures': []})
|
||||
report['checked'].pop('root_connected_logs', None)
|
||||
logs = {p: s for p, s in desired.items() if plaza.parse(s)[0] == 'spruce_log'}
|
||||
roots = [p for p in logs if (p[0], p[1] - 1, p[2]) not in logs]
|
||||
for x, y, z in roots:
|
||||
below = reader.state(x, y - 1, z)
|
||||
if plaza.parse(below)[0] not in {'dirt', 'moss_block'}:
|
||||
report['failures'].append({'reason': 'station_topiary_or_diorama_root_without_soil',
|
||||
'at': [x, y, z], 'below': below})
|
||||
report['checked'].update({'spruce_logs': len(logs), 'topiary_or_diorama_roots': len(roots)})
|
||||
report['passed'] = not report['failures']
|
||||
report['status'] = 'checked'
|
||||
report['method'] = 'Planned lantern support, persistent leaf distances against the full observed neighborhood, and station topiary/diorama roots on stable dirt or moss.'
|
||||
return report
|
||||
|
||||
|
||||
def conservative_containment(reader, metadata):
|
||||
if metadata is None:
|
||||
return {'status': 'not_proven', 'passed': False, 'reason': 'No containment bounds, seeds and authorized entrance caps were supplied.'}
|
||||
box = bounded_box(metadata['bounds'])
|
||||
caps, cap_reports = set(), []
|
||||
for region in metadata.get('authorized_caps', []):
|
||||
cap_box = bounded_box(region)
|
||||
points = set(positions(cap_box))
|
||||
if not points or not all(inside(p, box) for p in points):
|
||||
raise ValueError('Authorized virtual cap lies outside surveyed containment bounds')
|
||||
caps |= points
|
||||
cap_reports.append({'id': region['id'], 'bounds': cap_box, 'voxels': len(points)})
|
||||
blocked, passable, unknown = set(), set(), Counter()
|
||||
for at in positions(box):
|
||||
state = reader.state(*at)
|
||||
if full_cube(state) or at in caps:
|
||||
blocked.add(at)
|
||||
else:
|
||||
passable.add(at)
|
||||
if state not in survey.AIR:
|
||||
unknown[plaza.parse(state)[0]] += 1
|
||||
regions = [(r['id'], bounded_box(r)) for r in metadata.get('forbidden_regions', [])]
|
||||
roof_y = metadata.get('forbidden_y_at_or_above', 126)
|
||||
if type(roof_y) is not int:
|
||||
raise ValueError('Forbidden roof threshold must be an integer Y')
|
||||
seeds = metadata.get('seeds')
|
||||
if not isinstance(seeds, list) or not seeds:
|
||||
raise ValueError('Containment requires explicit public aisle/cabin seeds')
|
||||
reports = []
|
||||
for seed in seeds:
|
||||
coordinates = [seed[axis] for axis in ('x', 'y', 'z')]
|
||||
if any(type(v) not in (int, float) or not math.isfinite(v) for v in coordinates):
|
||||
raise ValueError('Seed coordinates must be finite')
|
||||
source = tuple(math.floor(v) for v in coordinates)
|
||||
if not inside(source, box) or source in caps:
|
||||
raise ValueError('Containment source is outside its observed box or inside a virtual cap')
|
||||
low, high = seed.get('min_y'), seed.get('max_y')
|
||||
if any(v is not None and type(v) is not int for v in (low, high)):
|
||||
raise ValueError('Per-floor minimum/maximum reachable Y must be integers')
|
||||
reached, queue, violations, examples = set(), deque([source]), Counter(), []
|
||||
while queue:
|
||||
p = queue.popleft()
|
||||
if p in reached or p not in passable:
|
||||
continue
|
||||
reached.add(p)
|
||||
reasons = []
|
||||
if any(value in (box['min'][axis], box['max'][axis]) for axis, value in zip(('x', 'y', 'z'), p)):
|
||||
reasons.append('survey_boundary_reachable')
|
||||
if p[1] >= roof_y:
|
||||
reasons.append('roof_space_reachable')
|
||||
if low is not None and p[1] < low:
|
||||
reasons.append('below_public_floor_reachable')
|
||||
if high is not None and p[1] > high:
|
||||
reasons.append('above_public_room_reachable')
|
||||
reasons.extend('forbidden_region:' + name for name, bounds in regions if inside(p, bounds))
|
||||
for reason in reasons:
|
||||
violations[reason] += 1
|
||||
if len(examples) < 30:
|
||||
examples.append({'at': list(p), 'reason': reason})
|
||||
queue.extend((p[0] + dx, p[1] + dy, p[2] + dz) for dx, dy, dz in N3)
|
||||
source_air = reader.state(*source) in survey.AIR
|
||||
reports.append({'id': seed['id'], 'source': list(source), 'source_observed_air': source_air,
|
||||
'reachable_voxels': len(reached), 'minimum_reached_y': min((p[1] for p in reached), default=None),
|
||||
'maximum_reached_y': max((p[1] for p in reached), default=None),
|
||||
'violation_counts': dict(violations), 'examples': examples,
|
||||
'passed': source_air and bool(reached) and not violations})
|
||||
return {'status': 'checked', 'passed': all(r['passed'] for r in reports), 'bounds': box,
|
||||
'observed_voxels': len(blocked) + len(passable), 'authorized_virtual_caps': cap_reports,
|
||||
'partial_or_unknown_materials_treated_as_passable': dict(unknown), 'seeds': reports,
|
||||
'method': 'Independent 6-neighbor floods from each public room/cabin seed. Known full cubes including glass block movement; partial/unknown shapes are treated as empty. Authorized entrance caps are virtual audit boundaries only.',
|
||||
'limits': 'This overestimates continuous player movement and does not simulate teleportation, spectator, block removal or lift operation. Failed floods through partial shapes require review; they are not automatically proven playable escape paths.'}
|
||||
|
||||
|
||||
def audit(before, recipe, metadata, after=None):
|
||||
scope = survey.scope_of(recipe['scope'])
|
||||
if scope != before.scope or scope != metadata['scope'] or (after and after.scope != scope):
|
||||
raise ValueError('Recipe, metadata and snapshots differ in project/world/epoch')
|
||||
desired = base.coordinates(recipe)
|
||||
for row in recipe['blocks']:
|
||||
at = tuple(row[axis] for axis in ('x', 'y', 'z'))
|
||||
if before.state(*at) != row['expected']:
|
||||
raise ValueError(f'Recipe expected state differs from observed baseline at {at}')
|
||||
reader = plaza.Reader(after or before, None if after else desired)
|
||||
floor_report = public_floors(reader, metadata.get('public_floors', []))
|
||||
clearance = clear_regions(reader, metadata.get('clear_regions', []))
|
||||
access = named_access(reader, metadata)
|
||||
fixtures = fixture_stability(reader, desired)
|
||||
containment = conservative_containment(reader, metadata.get('containment'))
|
||||
volume = base.compare_volume(before, after, desired) if after else None
|
||||
unproven = []
|
||||
if floor_report['status'] == 'not_provided':
|
||||
unproven.append('Public floor support/headroom/reachability: no clear-column topology supplied.')
|
||||
if clearance['status'] == 'not_provided':
|
||||
unproven.append('Doorway and lift cabin clearances: no explicit clearance regions supplied.')
|
||||
if containment['status'] == 'not_proven':
|
||||
unproven.append('Windows, floor separation and roof/private-space containment: no flood contract supplied.')
|
||||
provided = [r for r in (floor_report, clearance, access, fixtures, containment) if r['status'] == 'checked']
|
||||
supported_passed = all(r['passed'] for r in provided) and (volume is None or volume['passed'])
|
||||
return {'version': 1, 'scope': scope, 'world_edits': 0,
|
||||
'mode': 'actual_after_snapshot' if after else 'candidate_overlay', 'desired_blocks': len(desired),
|
||||
'expected_states_verified_against_baseline': len(desired), 'public_floors': floor_report,
|
||||
'clear_regions': clearance, 'named_access': access, 'fixtures': fixtures,
|
||||
'containment': containment, 'volume': volume,
|
||||
'supported_checks_passed': supported_passed, 'unproven_checks': unproven,
|
||||
'passed': supported_passed and not unproven,
|
||||
'limits': 'Only observed block states are verified. Block entity text, display entities, gameplay/sign destinations and operational lift transitions require separate runtime checks. Actual snapshots are sequential, not atomic.'}
|
||||
|
||||
|
||||
class FakeReader:
|
||||
def __init__(self, states):
|
||||
self.states = states
|
||||
|
||||
def state(self, x, y, z):
|
||||
return self.states.get((x, y, z), 'minecraft:air')
|
||||
|
||||
|
||||
class StationTests(unittest.TestCase):
|
||||
def room(self):
|
||||
box = {'min': {'x': -1, 'y': 98, 'z': -1}, 'max': {'x': 5, 'y': 127, 'z': 5}}
|
||||
states = {(x, y, z): 'minecraft:glass' for x in range(5) for y in range(99, 104) for z in range(5)
|
||||
if x in (0, 4) or z in (0, 4) or y in (99, 103)}
|
||||
meta = {'bounds': box, 'seeds': [{'id': 'room', 'x': 2, 'y': 100, 'z': 2, 'min_y': 100, 'max_y': 102}]}
|
||||
return FakeReader(states), meta
|
||||
|
||||
def test_closed_glass_room_is_contained_and_window_hole_is_detected(self):
|
||||
reader, metadata = self.room()
|
||||
self.assertTrue(conservative_containment(reader, metadata)['passed'])
|
||||
del reader.states[0, 101, 2]
|
||||
result = conservative_containment(reader, metadata)
|
||||
self.assertFalse(result['passed'])
|
||||
self.assertIn('survey_boundary_reachable', result['seeds'][0]['violation_counts'])
|
||||
|
||||
def test_authorized_entrance_can_be_virtually_capped_without_changing_world(self):
|
||||
reader, metadata = self.room()
|
||||
del reader.states[0, 101, 2]
|
||||
metadata['authorized_caps'] = [{'id': 'entry', 'min': {'x': 0, 'y': 101, 'z': 2}, 'max': {'x': 0, 'y': 101, 'z': 2}}]
|
||||
self.assertTrue(conservative_containment(reader, metadata)['passed'])
|
||||
self.assertEqual(reader.state(0, 101, 2), 'minecraft:air')
|
||||
|
||||
def test_partial_window_is_treated_as_open_and_upper_floor_hole_is_detected(self):
|
||||
reader, metadata = self.room()
|
||||
reader.states[0, 101, 2] = 'minecraft:iron_bars[east=false,north=true,south=true,waterlogged=false,west=false]'
|
||||
self.assertFalse(conservative_containment(reader, metadata)['passed'])
|
||||
reader, metadata = self.room()
|
||||
del reader.states[2, 99, 2]
|
||||
result = conservative_containment(reader, metadata)
|
||||
self.assertIn('below_public_floor_reachable', result['seeds'][0]['violation_counts'])
|
||||
|
||||
def test_public_headroom_is_four_blocks_and_obstacle_disconnects_floor(self):
|
||||
reader = FakeReader({(x, 98, 0): 'minecraft:stone' for x in range(3)})
|
||||
floor = {'id': 'vestibule', 'standing_y': 99, 'source': {'x': 0, 'z': 0}, 'clear_columns': [[0, 0], [1, 0], [2, 0]]}
|
||||
self.assertTrue(public_floors(reader, [floor])['passed'])
|
||||
reader.states[1, 102, 0] = 'minecraft:lantern[hanging=true,waterlogged=false]'
|
||||
result = public_floors(reader, [floor])
|
||||
self.assertFalse(result['passed'])
|
||||
self.assertEqual(result['floors'][0]['unreachable_columns'], 2)
|
||||
|
||||
def test_clearance_region_reports_real_door_obstruction(self):
|
||||
reader = FakeReader({(0, 99, 0): 'minecraft:stone'})
|
||||
region = {'id': 'door', 'min': {'x': 0, 'y': 99, 'z': 0}, 'max': {'x': 0, 'y': 102, 'z': 0}}
|
||||
self.assertEqual(clear_regions(reader, [region])['regions'][0]['nonair_voxels'], 1)
|
||||
|
||||
def test_named_target_cannot_be_hidden_by_filtering_the_public_mask(self):
|
||||
reader = FakeReader({(x, 98, 0): 'minecraft:stone' for x in range(2)})
|
||||
metadata = {'public_floors': [{'standing_y': 99, 'clear_columns': [[0, 0]]}],
|
||||
'interior': {'navigation': {'99': {'named_targets': [
|
||||
{'name': 'bench-front', 'x': 1, 'y': 99, 'z': 0}]}}, 'lift': {}}}
|
||||
self.assertFalse(named_access(reader, metadata)['passed'])
|
||||
metadata['public_floors'][0]['clear_columns'].append([1, 0])
|
||||
self.assertTrue(named_access(reader, metadata)['passed'])
|
||||
|
||||
def test_fixture_leaf_distance_and_lantern_support_use_actual_states(self):
|
||||
desired = {(0, 100, 0): 'minecraft:lantern[hanging=true,waterlogged=false]',
|
||||
(2, 100, 0): 'minecraft:spruce_leaves[distance=1,persistent=true,waterlogged=false]'}
|
||||
reader = FakeReader(desired | {(0, 101, 0): 'minecraft:stone',
|
||||
(2, 99, 0): 'minecraft:spruce_log[axis=y]'})
|
||||
self.assertTrue(fixture_stability(reader, desired)['passed'])
|
||||
del reader.states[0, 101, 0]
|
||||
del reader.states[2, 99, 0]
|
||||
self.assertEqual(len(fixture_stability(reader, desired)['failures']), 2)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
for name in ('before', 'after', 'recipe', 'metadata', 'report'):
|
||||
parser.add_argument('--' + name, type=Path)
|
||||
parser.add_argument('--self-test', action='store_true')
|
||||
args = parser.parse_args()
|
||||
if args.self_test:
|
||||
result = unittest.TextTestRunner().run(unittest.defaultTestLoader.loadTestsFromTestCase(StationTests))
|
||||
raise SystemExit(0 if result.wasSuccessful() else 1)
|
||||
if any(getattr(args, name) is None for name in ('before', 'recipe', 'metadata', 'report')):
|
||||
parser.error('--before, --recipe, --metadata and --report are required')
|
||||
before = survey.load_snapshot(args.before)
|
||||
after = survey.load_snapshot(args.after, before.scope) if args.after else None
|
||||
report = audit(before, json.loads(args.recipe.read_text()), json.loads(args.metadata.read_text()), after)
|
||||
paths = {name: getattr(args, name) for name in ('before', 'after', 'recipe', 'metadata') if getattr(args, name)}
|
||||
report['input_sha256'] = {key: hashlib.sha256(path.read_bytes()).hexdigest() for key, path in paths.items()}
|
||||
args.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
survey.terrain.save(args.report, report)
|
||||
print(json.dumps(report, indent=2))
|
||||
raise SystemExit(0 if report['passed'] else 2 if report['supported_checks_passed'] and report['unproven_checks'] else 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only trapdoor panel/contact checks for actual or overlaid block readers.
|
||||
|
||||
Pass a ``state_reader(x, y, z)`` returning canonical block data. Each fixture is
|
||||
``{'pos': [x,y,z], 'support': [x,y,z]}`` for an adjacent support cell, or uses
|
||||
``'edge': 'south'`` for an intended edge within the trapdoor's own cell.
|
||||
Support materials and partial support shapes must be validated by the caller;
|
||||
this check measures contact with that cell's face and rejects an empty cell.
|
||||
|
||||
Shape source: pinned Paper 26.2 TrapDoorBlock.getShape / static initializer,
|
||||
Block.boxZ(16,13,16), Shapes.rotateAll. An open NORTH trapdoor occupies the
|
||||
SOUTH 3/16 strip. HALF affects closed panels only. No world/file/network writes.
|
||||
|
||||
>>> world = {(0, 0, 0): 'minecraft:spruce_trapdoor[facing=east,half=bottom,open=true]', (1, 0, 0): 'minecraft:spruce_planks'}
|
||||
>>> read = lambda x, y, z: world.get((x,y,z), 'minecraft:air')
|
||||
>>> check_contact(read, (0,0,0), support=(1,0,0))['gap_blocks']
|
||||
0.8125
|
||||
>>> world[(0,0,0)] = 'minecraft:spruce_trapdoor[open=true,facing=west,half=top]'
|
||||
>>> check_contact(read, (0,0,0), support=(1,0,0))['passed']
|
||||
True
|
||||
>>> check_contact(read, (0,0,0), edge='north')['passed']
|
||||
False
|
||||
>>> world[(0,0,0)] = 'minecraft:iron_trapdoor[open=false,facing=south,half=top]'
|
||||
>>> check_contact(read, (0,0,0), edge='up')['passed']
|
||||
True
|
||||
"""
|
||||
|
||||
NORMALS = {'west': (-1, 0, 0), 'east': (1, 0, 0),
|
||||
'down': (0, -1, 0), 'up': (0, 1, 0),
|
||||
'north': (0, 0, -1), 'south': (0, 0, 1)}
|
||||
THICKNESS = 3 / 16
|
||||
AIR = {'minecraft:air', 'minecraft:cave_air', 'minecraft:void_air'}
|
||||
|
||||
|
||||
def panel_bounds(state):
|
||||
"""Return local AABB minimum/maximum and its thickness axis (X=0,Y=1,Z=2)."""
|
||||
name, separator, raw = state.partition('[')
|
||||
if not name.startswith('minecraft:') or not name.endswith('_trapdoor') or not separator or not raw.endswith(']'):
|
||||
raise ValueError('Expected explicit trapdoor block data')
|
||||
values = dict(part.split('=', 1) for part in raw[:-1].split(','))
|
||||
if values.get('open') == 'true':
|
||||
# Bounds are in block units; the panel sits opposite its facing.
|
||||
strips = {'north': (2, 1 - THICKNESS, 1), 'south': (2, 0, THICKNESS),
|
||||
'east': (0, 0, THICKNESS), 'west': (0, 1 - THICKNESS, 1)}
|
||||
if values.get('facing') not in strips:
|
||||
raise ValueError('Open trapdoor needs a horizontal facing')
|
||||
axis, start, end = strips[values['facing']]
|
||||
elif values.get('open') == 'false' and values.get('half') in {'top', 'bottom'}:
|
||||
axis = 1
|
||||
start, end = (1 - THICKNESS, 1) if values['half'] == 'top' else (0, THICKNESS)
|
||||
else:
|
||||
raise ValueError('Trapdoor needs explicit open and valid half properties')
|
||||
minimum, maximum = [0., 0., 0.], [1., 1., 1.]
|
||||
minimum[axis], maximum[axis] = start, end
|
||||
return minimum, maximum, axis
|
||||
|
||||
|
||||
def _position(pos):
|
||||
result = tuple(pos)
|
||||
if len(result) != 3 or any(type(value) is not int for value in result):
|
||||
raise ValueError('Position must contain three integer block coordinates')
|
||||
return result
|
||||
|
||||
|
||||
def check_contact(state_reader, pos, *, support=None, edge=None):
|
||||
"""Measure a panel's gap/alignment to a requested edge or adjacent cell face.
|
||||
|
||||
``passed`` requires the broad panel face to be parallel and flush. Merely
|
||||
touching with a thin side edge does not pass. A support fixture also requires
|
||||
a nonair observed support; this is not a full support collision-shape audit.
|
||||
"""
|
||||
pos = _position(pos)
|
||||
if (support is None) == (edge is None):
|
||||
raise ValueError('Specify exactly one adjacent support or intended edge')
|
||||
support_state = None
|
||||
if support is not None:
|
||||
support = _position(support)
|
||||
delta = tuple(target - origin for origin, target in zip(pos, support))
|
||||
edge = next((name for name, normal in NORMALS.items() if normal == delta), None)
|
||||
if edge is None:
|
||||
raise ValueError('Support must share one face with the trapdoor cell')
|
||||
support_state = state_reader(*support)
|
||||
if edge not in NORMALS:
|
||||
raise ValueError('Unknown intended edge')
|
||||
state = state_reader(*pos)
|
||||
minimum, maximum, thickness_axis = panel_bounds(state)
|
||||
normal = NORMALS[edge]
|
||||
axis = next(i for i, value in enumerate(normal) if value)
|
||||
gap = minimum[axis] if normal[axis] < 0 else 1 - maximum[axis]
|
||||
parallel = axis == thickness_axis
|
||||
support_present = support_state is None or support_state.partition('[')[0] not in AIR
|
||||
result = {'pos': list(pos), 'state': state, 'edge': edge,
|
||||
'panel_bounds': {'min': minimum, 'max': maximum},
|
||||
'panel_parallel_to_face': parallel, 'gap_blocks': gap,
|
||||
'passed': parallel and gap == 0 and support_present}
|
||||
if support is not None:
|
||||
result.update(support=list(support), support_state=support_state,
|
||||
support_present=support_present)
|
||||
return result
|
||||
|
||||
|
||||
def audit_contacts(state_reader, fixtures):
|
||||
"""Return a compact summary and per-fixture geometric evidence."""
|
||||
checks = []
|
||||
for fixture in fixtures:
|
||||
result = check_contact(state_reader, fixture['pos'], support=fixture.get('support'), edge=fixture.get('edge'))
|
||||
if 'name' in fixture:
|
||||
result['name'] = fixture['name']
|
||||
checks.append(result)
|
||||
failures = [check for check in checks if not check['passed']]
|
||||
return {'passed': not failures, 'checked': len(checks), 'failed': len(failures),
|
||||
'max_gap_blocks': max((check['gap_blocks'] for check in checks), default=0),
|
||||
'checks': checks}
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import doctest
|
||||
result = doctest.testmod()
|
||||
raise SystemExit(bool(result.failed))
|
||||
@@ -1,384 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only proof of a closed, observed full-cube Minecraft zone enclosure.
|
||||
|
||||
The membrane is derived independently from an extruded XZ mask minus explicit
|
||||
excluded voxels: every six-neighbor exterior shell voxel must be a full cube. An
|
||||
exterior flood treats every unknown/partial block as empty, overestimating escape
|
||||
routes. Swept 0.6 x 1.8 player AABB probes additionally test cardinal/diagonal
|
||||
crossings, half-block stair rises, floor drops and roof ascent. These sample
|
||||
probes supplement the complete membrane proof; they are not the proof itself.
|
||||
|
||||
This verifies static continuous movement, not spectator, commands, breaking,
|
||||
plugin teleportation, or arbitrary discontinuous teleport/pearl behavior.
|
||||
"""
|
||||
import argparse
|
||||
from collections import Counter, deque
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import itertools
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SPEC = importlib.util.spec_from_file_location('containment_base', ROOT / 'scripts/verify-balustrade.py')
|
||||
base = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(base)
|
||||
survey, plaza = base.survey, base.plaza
|
||||
FULL = plaza.STATIC_CUBES | {'barrier'}
|
||||
N2 = ((1, 0), (-1, 0), (0, 1), (0, -1))
|
||||
N3 = ((1, 0, 0), (-1, 0, 0), (0, 1, 0), (0, -1, 0), (0, 0, 1), (0, 0, -1))
|
||||
WIDTH, HEIGHT = .6, 1.8
|
||||
|
||||
|
||||
def full_cube(state):
|
||||
return plaza.parse(state)[0] in FULL
|
||||
|
||||
|
||||
def mask_geometry(metadata):
|
||||
raw = metadata['interior_columns']
|
||||
if (not isinstance(raw, list) or not raw or any(not isinstance(p, list) or len(p) != 2
|
||||
or any(type(v) is not int for v in p) for p in raw)):
|
||||
raise ValueError('interior_columns must be nonempty integer [x,z] pairs')
|
||||
interior = set(map(tuple, raw))
|
||||
if len(interior) != len(raw):
|
||||
raise ValueError('Duplicate interior column')
|
||||
floor, roof = metadata['floor_y'], metadata['roof_y']
|
||||
if type(floor) is not int or type(roof) is not int or roof - floor < 4:
|
||||
raise ValueError('Integer floor_y and roof_y need at least three free interior rows')
|
||||
shell = {(x + dx, z + dz) for x, z in interior for dx, dz in N2} - interior
|
||||
footprint = interior | shell
|
||||
reached, queue = set(), deque([next(iter(interior))])
|
||||
while queue:
|
||||
p = queue.popleft()
|
||||
if p in reached or p not in interior:
|
||||
continue
|
||||
reached.add(p)
|
||||
queue.extend((p[0] + dx, p[1] + dz) for dx, dz in N2)
|
||||
if reached != interior:
|
||||
raise ValueError('Interior mask must be one cardinally connected component')
|
||||
for key in ('wall_columns', 'shell_columns'):
|
||||
if ('interior_excluded_voxels' not in metadata and key in metadata
|
||||
and set(map(tuple, metadata[key])) != shell):
|
||||
raise ValueError(f'Claimed {key} differ from the independently derived complete shell')
|
||||
bounds = ((min(x for x, z in footprint) - 1, floor - 1, min(z for x, z in footprint) - 1),
|
||||
(max(x for x, z in footprint) + 1, roof + 1, max(z for x, z in footprint) + 1))
|
||||
if math.prod(hi - lo + 1 for lo, hi in zip(*bounds)) > survey.MAX_VOXELS:
|
||||
raise ValueError('Enclosure verification exceeds the bounded snapshot voxel limit')
|
||||
raw_excluded = metadata.get('interior_excluded_voxels', [])
|
||||
if (not isinstance(raw_excluded, list) or any(not isinstance(p, list) or len(p) != 3
|
||||
or any(type(v) is not int for v in p) for p in raw_excluded)):
|
||||
raise ValueError('interior_excluded_voxels must contain integer [x,y,z] triples')
|
||||
excluded = set(map(tuple, raw_excluded))
|
||||
if len(excluded) != len(raw_excluded):
|
||||
raise ValueError('Duplicate excluded interior voxel')
|
||||
volume = {(x, y, z) for x, z in interior for y in range(floor + 1, roof)}
|
||||
if not excluded <= volume:
|
||||
raise ValueError('Excluded voxel is outside the base extruded interior')
|
||||
volume -= excluded
|
||||
if not volume:
|
||||
raise ValueError('Excluded voxels remove the whole enclosure interior')
|
||||
membrane_voxels = {(x + dx, y + dy, z + dz) for x, y, z in volume for dx, dy, dz in N3} - volume
|
||||
return interior, shell, footprint, floor, roof, bounds, volume, membrane_voxels, excluded
|
||||
|
||||
|
||||
def membrane(reader, geometry):
|
||||
interior, _, _, floor, roof, _ = geometry[:6]
|
||||
failures, materials, counts = [], Counter(), Counter()
|
||||
for x, y, z in sorted(geometry[7]):
|
||||
kind = ('floor' if y == floor else 'roof' if y == roof else
|
||||
'wall' if (x, z) not in interior else 'folded_boundary')
|
||||
counts[kind] += 1
|
||||
state = reader.state(x, y, z)
|
||||
materials[plaza.parse(state)[0]] += 1
|
||||
if not full_cube(state):
|
||||
failures.append({'at': [x, y, z], 'part': kind, 'actual': state})
|
||||
return {'passed': not failures, 'required_voxels': sum(counts.values()), 'parts': dict(counts),
|
||||
'observed_materials': dict(materials), 'nonfull_voxels': len(failures), 'examples': failures[:30],
|
||||
'method': 'Every voxel in N6(interior volume) minus interior volume must be an observed full collision cube.'}
|
||||
|
||||
|
||||
def conservative_exterior_flood(reader, geometry, source):
|
||||
lo, hi = geometry[5]
|
||||
blocked, passable, unknown = set(), set(), Counter()
|
||||
boundary = []
|
||||
for x in range(lo[0], hi[0] + 1):
|
||||
for y in range(lo[1], hi[1] + 1):
|
||||
for z in range(lo[2], hi[2] + 1):
|
||||
p = x, y, z
|
||||
state = reader.state(*p)
|
||||
if full_cube(state):
|
||||
blocked.add(p)
|
||||
continue
|
||||
passable.add(p)
|
||||
if state not in survey.AIR:
|
||||
unknown[plaza.parse(state)[0]] += 1
|
||||
if any(v in (low, high) for v, low, high in zip(p, lo, hi)):
|
||||
boundary.append(p)
|
||||
reached, queue = set(boundary), deque(boundary)
|
||||
while queue:
|
||||
x, y, z = queue.popleft()
|
||||
for dx, dy, dz in N3:
|
||||
p = x + dx, y + dy, z + dz
|
||||
if p in passable and p not in reached:
|
||||
reached.add(p)
|
||||
queue.append(p)
|
||||
leaks = sorted(reached & geometry[6])
|
||||
source_cell = tuple(math.floor(v) for v in source)
|
||||
return {'passed': not leaks, 'observed_voxels': len(blocked) + len(passable),
|
||||
'exterior_reached_voxels': len(reached), 'interior_reached_from_exterior': len(leaks),
|
||||
'source_reached_from_exterior': source_cell in reached,
|
||||
'unknown_or_partial_blocks_treated_as_empty': dict(unknown), 'leak_examples': leaks[:30],
|
||||
'method': 'Six-neighbor free-voxel exterior flood; only known full collision cubes obstruct it.'}
|
||||
|
||||
|
||||
def segment_box(start, finish, low, high):
|
||||
"""Slab intersection with the interior of an expanded block AABB.
|
||||
|
||||
Face contact is legal: a player's feet resting exactly on paving must not
|
||||
make every horizontal probe appear blocked before reaching the guard.
|
||||
"""
|
||||
enter, leave = 0., 1.
|
||||
for a, b, lo, hi in zip(start, finish, low, high):
|
||||
lo, hi = lo + 1e-9, hi - 1e-9
|
||||
delta = b - a
|
||||
if abs(delta) < 1e-12:
|
||||
if a < lo or a > hi:
|
||||
return False
|
||||
continue
|
||||
first, last = sorted(((lo - a) / delta, (hi - a) / delta))
|
||||
enter, leave = max(enter, first), min(leave, last)
|
||||
if enter > leave:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def swept_player_hits_full_cube(reader, start, finish):
|
||||
radius = WIDTH / 2
|
||||
low = [math.floor(min(start[i], finish[i]) - (radius if i != 1 else 0)) for i in range(3)]
|
||||
high = [math.floor(max(start[i], finish[i]) + (radius if i != 1 else HEIGHT)) for i in range(3)]
|
||||
for x in range(low[0], high[0] + 1):
|
||||
for y in range(low[1], high[1] + 1):
|
||||
for z in range(low[2], high[2] + 1):
|
||||
if full_cube(reader.state(x, y, z)) and segment_box(
|
||||
start, finish, (x - radius, y - HEIGHT, z - radius),
|
||||
(x + 1 + radius, y + 1, z + 1 + radius)):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def player_probes(reader, geometry, source):
|
||||
interior, _, _, floor, roof, _ = geometry[:6]
|
||||
counts, missed = Counter(), []
|
||||
source_clear = all(reader.state(x, y, z) in survey.AIR
|
||||
for x in range(math.floor(source[0] - WIDTH / 2), math.floor(source[0] + WIDTH / 2) + 1)
|
||||
for y in range(math.floor(source[1]), math.ceil(source[1] + HEIGHT))
|
||||
for z in range(math.floor(source[2] - WIDTH / 2), math.floor(source[2] + WIDTH / 2) + 1))
|
||||
# Cross each boundary in cardinal and diagonal directions, including half-
|
||||
# block stair rises/drops. Fixed-height samples also cover free flight rows.
|
||||
heights = sorted({float(floor + 1), float(source[1]), roof - HEIGHT - .05})
|
||||
for x, z in sorted(interior):
|
||||
for dx, dz in itertools.product((-1, 0, 1), repeat=2):
|
||||
if (dx == dz == 0) or (x + dx, z + dz) in interior:
|
||||
continue
|
||||
for y in heights:
|
||||
for dy in (-.5, 0., .5):
|
||||
start = (x + .5, y, z + .5)
|
||||
finish = (x + dx + .5, y + dy, z + dz + .5)
|
||||
kind = 'diagonal' if dx and dz else 'cardinal'
|
||||
counts[kind] += 1
|
||||
if not swept_player_hits_full_cube(reader, start, finish):
|
||||
missed.append({'kind': kind, 'from': start, 'to': finish})
|
||||
# Test the locally folded boundary as well as the original outer perimeter.
|
||||
# The complete membrane check covers every height even when a sample starts
|
||||
# in an already obstructed partial-height pocket.
|
||||
for x, y, z in sorted(geometry[8] & geometry[7]):
|
||||
for dx, dy, dz in N3:
|
||||
p = x + dx, y + dy, z + dz
|
||||
if p not in geometry[6]:
|
||||
continue
|
||||
start, finish = (p[0] + .5, float(p[1]), p[2] + .5), (x + .5, float(y), z + .5)
|
||||
counts['folded_boundary'] += 1
|
||||
if not swept_player_hits_full_cube(reader, start, finish):
|
||||
missed.append({'kind': 'folded_boundary', 'from': start, 'to': finish})
|
||||
for kind, start, finish in [('floor', (source[0], floor + 1.1, source[2]),
|
||||
(source[0], floor - .5, source[2])),
|
||||
('roof', (source[0], roof - HEIGHT - .1, source[2]),
|
||||
(source[0], roof - .5, source[2]))]:
|
||||
counts[kind] += 1
|
||||
if not swept_player_hits_full_cube(reader, start, finish):
|
||||
missed.append({'kind': kind, 'from': start, 'to': finish})
|
||||
return {'passed': source_clear and not missed, 'player_width': WIDTH, 'player_height': HEIGHT,
|
||||
'source_headroom_observed_air': source_clear, 'swept_crossing_probes': dict(counts),
|
||||
'unblocked_probes': len(missed), 'examples': missed[:30],
|
||||
'note': 'Supplementary swept-AABB boundary probes; the independently derived full membrane is the complete static containment criterion.'}
|
||||
|
||||
|
||||
def volume_components(reader, geometry, source):
|
||||
"""Report geometric components; every one is still checked for containment."""
|
||||
pending, components = set(geometry[6]), []
|
||||
source_cell = tuple(math.floor(v) for v in source)
|
||||
while pending:
|
||||
queue, count, nonfull, contains_source = deque([next(iter(pending))]), 0, 0, False
|
||||
while queue:
|
||||
p = queue.popleft()
|
||||
if p not in pending:
|
||||
continue
|
||||
pending.remove(p)
|
||||
count += 1
|
||||
nonfull += not full_cube(reader.state(*p))
|
||||
contains_source |= p == source_cell
|
||||
queue.extend((p[0] + dx, p[1] + dy, p[2] + dz) for dx, dy, dz in N3)
|
||||
components.append({'voxels': count, 'nonfull_voxels': nonfull, 'contains_source': contains_source})
|
||||
return {'components': sorted(components, key=lambda c: c['voxels'], reverse=True),
|
||||
'source_in_volume': any(c['contains_source'] for c in components),
|
||||
'note': 'Disconnected sealed components are reported, not treated as escapes. The entire required membrane and exterior flood are checked.'}
|
||||
|
||||
|
||||
def preserved_decor(before, reader, desired):
|
||||
protected, failures = 0, []
|
||||
for at, state in base.captured_voxels(before):
|
||||
name, _ = plaza.parse(state)
|
||||
if (name not in plaza.FLOWERS | {'lantern', 'spruce_fence', 'spruce_stairs', 'iron_chain'}
|
||||
and not any(suffix in name for suffix in ('_leaves', '_log', 'waxed_oxidized_cut_copper'))):
|
||||
continue
|
||||
protected += 1
|
||||
if reader.state(*at) != state:
|
||||
failures.append({'at': list(at), 'before': state, 'after': reader.state(*at), 'in_plan': at in desired})
|
||||
return {'passed': not failures, 'protected_observed_states': protected, 'changed_states': len(failures),
|
||||
'examples': failures[:30]}
|
||||
|
||||
|
||||
def audit(before, layout, metadata, after=None):
|
||||
scope = survey.scope_of(layout['scope'])
|
||||
if scope != before.scope or scope != metadata['scope'] or (after and after.scope != scope):
|
||||
raise ValueError('Project/world/epoch differs between containment inputs')
|
||||
desired = base.coordinates(layout)
|
||||
for row in layout['blocks']:
|
||||
p = tuple(row[a] for a in ('x', 'y', 'z'))
|
||||
if before.state(*p) != row['expected']:
|
||||
raise ValueError(f'Expected plan state differs from observed baseline at {p}')
|
||||
source = tuple(metadata['source'][axis] for axis in ('x', 'y', 'z'))
|
||||
if any(type(v) not in (int, float) or not math.isfinite(v) for v in source):
|
||||
raise ValueError('Source must contain finite x/y/z player feet coordinates')
|
||||
geometry = mask_geometry(metadata)
|
||||
if tuple(math.floor(v) for v in source) not in geometry[6] or not geometry[3] < source[1] < geometry[4] - HEIGHT:
|
||||
raise ValueError('Source is outside the enclosure interior')
|
||||
reader = plaza.Reader(after or before, None if after else desired)
|
||||
closed = membrane(reader, geometry)
|
||||
flood = conservative_exterior_flood(reader, geometry, source)
|
||||
probes = player_probes(reader, geometry, source)
|
||||
components = volume_components(reader, geometry, source)
|
||||
decor = preserved_decor(before, reader, desired)
|
||||
volume = base.compare_volume(before, after, desired) if after else None
|
||||
return {'version': 1, 'scope': scope, 'world_edits': 0,
|
||||
'mode': 'actual_after_snapshot' if after else 'candidate_overlay',
|
||||
'desired_blocks': len(desired), 'interior_columns': len(geometry[0]),
|
||||
'independently_derived_wall_columns': len(geometry[1]),
|
||||
'interior_voxels': len(geometry[6]), 'interior_excluded_voxels': len(geometry[8]),
|
||||
'volume_components': components,
|
||||
'membrane': closed, 'exterior_flood': flood, 'player_probes': probes,
|
||||
'preserved_decor': decor, 'volume': volume,
|
||||
'passed': all(r['passed'] for r in (closed, flood, probes, decor)) and (volume is None or volume['passed']),
|
||||
'limitations': 'Static continuous collision containment for normal nonspectator players only. No protection against spectator, breaking/removing blocks, operator commands, plugin teleports or arbitrary discontinuous teleport/pearl behavior. Actual snapshots are sequential, not atomic.'}
|
||||
|
||||
|
||||
class ContainmentTests(unittest.TestCase):
|
||||
def scene(self):
|
||||
meta = {'interior_columns': [[0, 0], [1, 0], [0, 1], [1, 1]], 'floor_y': 0, 'roof_y': 5}
|
||||
geo = mask_geometry(meta)
|
||||
states = {}
|
||||
for x, z in geo[2]:
|
||||
for y in ([0, 5] if (x, z) in geo[0] else range(6)):
|
||||
states[x, y, z] = 'minecraft:barrier'
|
||||
reader = type('Reader', (), {'state': lambda self, x, y, z: states.get((x, y, z), 'minecraft:air')})()
|
||||
return meta, geo, states, reader
|
||||
|
||||
def test_complete_shell_blocks_flight_diagonals_stairs_and_drops(self):
|
||||
_, geo, _, reader = self.scene()
|
||||
self.assertTrue(membrane(reader, geo)['passed'])
|
||||
self.assertTrue(conservative_exterior_flood(reader, geo, (.5, 1., .5))['passed'])
|
||||
self.assertTrue(player_probes(reader, geo, (.5, 1., .5))['passed'])
|
||||
|
||||
def test_one_missing_wall_or_roof_voxel_leaks(self):
|
||||
for at in [(-1, 2, 0), (0, 5, 0), (0, 0, 0)]:
|
||||
_, geo, states, reader = self.scene()
|
||||
del states[at]
|
||||
self.assertFalse(membrane(reader, geo)['passed'])
|
||||
self.assertFalse(conservative_exterior_flood(reader, geo, (.5, 1., .5))['passed'])
|
||||
|
||||
def test_floor_need_not_extend_under_exterior_wall(self):
|
||||
_, geo, states, reader = self.scene()
|
||||
for x, z in geo[1]:
|
||||
states.pop((x, 0, z), None)
|
||||
self.assertTrue(membrane(reader, geo)['passed'])
|
||||
self.assertTrue(conservative_exterior_flood(reader, geo, (.5, 1., .5))['passed'])
|
||||
|
||||
def test_inward_fold_preserves_partial_stair_outside_and_missing_fold_leaks(self):
|
||||
meta, _, states, reader = self.scene()
|
||||
meta['interior_excluded_voxels'] = [[0, 2, 0]]
|
||||
geo = mask_geometry(meta)
|
||||
states[-1, 2, 0] = 'minecraft:stone_brick_stairs[facing=east,half=bottom,shape=straight,waterlogged=false]'
|
||||
states[0, 2, 0] = 'minecraft:barrier'
|
||||
self.assertNotIn((-1, 2, 0), geo[7])
|
||||
self.assertTrue(membrane(reader, geo)['passed'])
|
||||
self.assertTrue(conservative_exterior_flood(reader, geo, (1.5, 1., 1.5))['passed'])
|
||||
self.assertTrue(player_probes(reader, geo, (1.5, 1., 1.5))['passed'])
|
||||
del states[0, 2, 0]
|
||||
self.assertFalse(membrane(reader, geo)['passed'])
|
||||
self.assertFalse(conservative_exterior_flood(reader, geo, (1.5, 1., 1.5))['passed'])
|
||||
|
||||
def test_exclusions_must_be_unique_voxels_inside_original_volume(self):
|
||||
meta, _, _, _ = self.scene()
|
||||
for exclusions in [[[0, 2, 0], [0, 2, 0]], [[100, 2, 100]], [[0, 0, 0]]]:
|
||||
meta['interior_excluded_voxels'] = exclusions
|
||||
with self.assertRaises(ValueError):
|
||||
mask_geometry(meta)
|
||||
|
||||
def test_stair_or_slab_cannot_substitute_for_solid_membrane(self):
|
||||
_, geo, states, reader = self.scene()
|
||||
states[-1, 2, 0] = 'minecraft:stone_brick_slab[type=bottom,waterlogged=false]'
|
||||
self.assertFalse(membrane(reader, geo)['passed'])
|
||||
self.assertFalse(conservative_exterior_flood(reader, geo, (.5, 1., .5))['passed'])
|
||||
states[-1, 2, 0] = 'minecraft:stone'
|
||||
self.assertTrue(membrane(reader, geo)['passed'])
|
||||
|
||||
def test_swept_aabb_detects_diagonal_corner_and_vertical_collision(self):
|
||||
reader = type('Reader', (), {'state': lambda self, x, y, z: 'minecraft:barrier' if (x, y, z) == (1, 1, 1) else 'minecraft:air'})()
|
||||
self.assertTrue(swept_player_hits_full_cube(reader, (.5, 1., .5), (2.5, 1., 2.5)))
|
||||
self.assertFalse(swept_player_hits_full_cube(reader, (.2, 1., .2), (.2, 2., .2)))
|
||||
|
||||
def test_standing_contact_with_floor_does_not_mask_an_open_horizontal_route(self):
|
||||
reader = type('Reader', (), {'state': lambda self, x, y, z: 'minecraft:stone' if y == 0 else 'minecraft:air'})()
|
||||
self.assertFalse(swept_player_hits_full_cube(reader, (.5, 1., .5), (2.5, 1., 2.5)))
|
||||
self.assertTrue(swept_player_hits_full_cube(reader, (.5, 1., .5), (.5, .5, .5)))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--before', type=Path)
|
||||
parser.add_argument('--after', type=Path)
|
||||
parser.add_argument('--layout', type=Path)
|
||||
parser.add_argument('--metadata', type=Path)
|
||||
parser.add_argument('--report', type=Path)
|
||||
parser.add_argument('--self-test', action='store_true')
|
||||
args = parser.parse_args()
|
||||
if args.self_test:
|
||||
result = unittest.TextTestRunner().run(unittest.defaultTestLoader.loadTestsFromTestCase(ContainmentTests))
|
||||
raise SystemExit(0 if result.wasSuccessful() else 1)
|
||||
if any(getattr(args, key) is None for key in ('before', 'layout', 'metadata', 'report')):
|
||||
parser.error('--before, --layout, --metadata and --report are required')
|
||||
before = survey.load_snapshot(args.before)
|
||||
after = survey.load_snapshot(args.after, before.scope) if args.after else None
|
||||
report = audit(before, json.loads(args.layout.read_text()), json.loads(args.metadata.read_text()), after)
|
||||
paths = {key: getattr(args, key) for key in ('before', 'after', 'layout', 'metadata') if getattr(args, key)}
|
||||
report['input_sha256'] = {key: hashlib.sha256(path.read_bytes()).hexdigest() for key, path in paths.items()}
|
||||
args.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
survey.terrain.save(args.report, report)
|
||||
print(json.dumps(report, indent=2))
|
||||
raise SystemExit(0 if report['passed'] else 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic floating-island columns, independent of any map or server.
|
||||
|
||||
Large contour harmonics shape the silhouette; correlated low-frequency fields
|
||||
shape the underside. Surface grading and gameplay layout belong to the caller.
|
||||
This is an offline geometry helper, not an additional MCP tool or live edit API.
|
||||
"""
|
||||
import math
|
||||
|
||||
|
||||
def smooth(value):
|
||||
value = max(0., min(1., value))
|
||||
return value * value * (3 - 2 * value)
|
||||
|
||||
|
||||
def hash_value(x, z, seed):
|
||||
n = (x * 374761393 + z * 668265263 + seed * 1442695041) & 0xffffffff
|
||||
n = ((n ^ (n >> 13)) * 1274126177) & 0xffffffff
|
||||
return (n ^ (n >> 16)) / 0xffffffff
|
||||
|
||||
|
||||
def noise(x, z, seed):
|
||||
x0, z0 = math.floor(x), math.floor(z)
|
||||
u, v = smooth(x - x0), smooth(z - z0)
|
||||
a = hash_value(x0, z0, seed) * (1 - u) + hash_value(x0 + 1, z0, seed) * u
|
||||
b = hash_value(x0, z0 + 1, seed) * (1 - u) + hash_value(x0 + 1, z0 + 1, seed) * u
|
||||
return a * (1 - v) + b * v
|
||||
|
||||
|
||||
def columns(cx, cz, radius_x, radius_z, surface_y, depth, seed, contour=.055):
|
||||
"""Return {(x,z): {bottom, top, radial}} with inclusive occupied Y ranges.
|
||||
|
||||
``radial`` is normalized distance from the warped boundary (0 centre,1 rim).
|
||||
The terrain is a connected solid below each top, with no inferred live air.
|
||||
Neighbouring columns have coherent variation; random per-block noise is left
|
||||
out deliberately. Integers give reproducible inclusive world coordinates.
|
||||
"""
|
||||
values = (radius_x, radius_z, depth, contour)
|
||||
if not all(math.isfinite(v) for v in values) or min(radius_x, radius_z, depth) <= 0 or not 0 <= contour <= .2:
|
||||
raise ValueError('Use positive finite radii/depth and contour in0..0.2')
|
||||
if any(type(v) is not int for v in (cx, cz, surface_y, seed)):
|
||||
raise ValueError('Centre, surface and seed must be integers')
|
||||
result = {}
|
||||
for x in range(math.floor(cx - radius_x * 1.3), math.ceil(cx + radius_x * 1.3) + 1):
|
||||
for z in range(math.floor(cz - radius_z * 1.3), math.ceil(cz + radius_z * 1.3) + 1):
|
||||
dx, dz = (x - cx) / radius_x, (z - cz) / radius_z
|
||||
angle = math.atan2(dz, dx)
|
||||
boundary = 1 + contour * (math.sin(3 * angle + seed * .07)
|
||||
+ .45 * math.sin(7 * angle - seed * .11))
|
||||
radial = math.hypot(dx, dz) / boundary
|
||||
if radial > 1:
|
||||
continue
|
||||
# A broad taper and coherent hanging buttresses give a recognisable
|
||||
# mass even when viewed in silhouette, before material decoration.
|
||||
taper = max(0., 1 - radial ** 1.55) ** .62
|
||||
buttress = .63 + .37 * noise(x / 7.5, z / 7.5, seed + 31)
|
||||
ribs = 2.5 * smooth(noise(x / 3.8, z / 3.8, seed + 57))
|
||||
thickness = max(3, round(3 + depth * taper * buttress + ribs))
|
||||
result[x, z] = {'top': surface_y, 'bottom': surface_y - thickness + 1,
|
||||
'radial': radial}
|
||||
return result
|
||||
Reference in New Issue
Block a user