Expand material support and checkpoint the completed building toolkit
Expose the runtime block/item registry through compact material search and single-material descriptions. Preserve private block-entity data through checked edits and durable undo without sending payloads to model context. Include the completed terrain tools, isolated world/map plugin, station lift, ACP streaming and guidance fixes, local camera auto-connect, construction scripts, and their public documentation, references and verification records. Active station decoration and private runtime data remain outside this commit. Validation: 145 Maven tests, 47 Bridge tests, successful camera Gradle build, and isolated Paper verification of all 1,196 block defaults plus 5,392 independent property cases for placement, same-material edits and restoration.
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,226 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,294 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/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()
|
||||
@@ -22,6 +22,9 @@ def main():
|
||||
except (OSError, ValueError):
|
||||
raise SystemExit('Cannot read a valid camera token/port from the private Paper config.')
|
||||
environment = dict(os.environ, MCB_CAMERA_TOKEN=token, MCB_CAMERA_PORT=str(port))
|
||||
auto = re.search(r'^camera-auto-connect:[ \t]*[\'\"]?(127\.0\.0\.1:[0-9]{1,5})[\'\"]?[ \t]*$', data, re.M)
|
||||
if auto:
|
||||
environment['MCB_CAMERA_AUTO_CONNECT'] = auto.group(1)
|
||||
os.execvpe(sys.argv[1], sys.argv[1:], environment)
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/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()
|
||||
@@ -4,6 +4,7 @@ import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -40,8 +41,11 @@ def prepare():
|
||||
if __name__=='__main__':
|
||||
ap=argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument('--run',action='store_true')
|
||||
ap.add_argument('--heap',default='2G',help='Maximum Java heap, e.g. 4G for the large lobby')
|
||||
ap.add_argument('--accept-eula',action='store_true',help='Explicitly accept https://www.minecraft.net/eula before starting this private test server')
|
||||
args=ap.parse_args()
|
||||
if not re.fullmatch(r'[1-9][0-9]*[MG]', args.heap):
|
||||
ap.error('--heap must be a positive integer followed by M or G')
|
||||
server=prepare()
|
||||
if args.accept_eula: (server/'eula.txt').write_text('# Accepted explicitly by the operator for this development server.\neula=true\n')
|
||||
if args.run:
|
||||
@@ -49,4 +53,4 @@ if __name__=='__main__':
|
||||
raise SystemExit('Minecraft EULA acceptance required: https://www.minecraft.net/eula ; review then rerun with --accept-eula if you agree.')
|
||||
java=Path(os.environ.get('MCB_JAVA_HOME',str(CACHE/'jdk-25.0.2')))/'bin/java'
|
||||
if not java.exists(): raise SystemExit('Run python3 scripts/bootstrap-tools.py first')
|
||||
raise SystemExit(subprocess.call([str(java),'-Dterminal.jline=false','-Dterminal.ansi=false','-Xms512M','-Xmx2G','-jar','paper.jar','--nogui'],cwd=server))
|
||||
raise SystemExit(subprocess.call([str(java),'-Dterminal.jline=false','-Dterminal.ansi=false','-Xms512M','-Xmx'+args.heap,'-jar','paper.jar','--nogui'],cwd=server))
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/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))
|
||||
@@ -0,0 +1,276 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,238 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,405 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only, scoped block surveys for checked foundations and half-block path clearance.
|
||||
|
||||
Snapshots contain actual complete block states, never inferred terrain or implicit air.
|
||||
Reads are sequential and non-atomic: keep edits idle during a survey. Cached reads may
|
||||
only be reused explicitly with --resume; the original observation times are retained.
|
||||
"""
|
||||
import argparse
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
import fcntl
|
||||
import gzip
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
_spec = importlib.util.spec_from_file_location('foundation_terrain', Path(__file__).with_name('terrain.py'))
|
||||
terrain = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(terrain)
|
||||
SCOPE_KEYS = ('project_id', 'world_id', 'world_epoch')
|
||||
AXES = ('x', 'y', 'z')
|
||||
STATE = re.compile(r'minecraft:[a-z0-9_]+(?:\[[a-z0-9_=,]+\])?\Z')
|
||||
MAX_VOXELS = 4_000_000
|
||||
AIR = {'minecraft:air', 'minecraft:cave_air', 'minecraft:void_air'}
|
||||
# Conservative shape set. Unknown blocks never count as air or safe support.
|
||||
FULL = set(('stone cobblestone mossy_cobblestone stone_bricks mossy_stone_bricks '
|
||||
'cracked_stone_bricks chiseled_stone_bricks smooth_stone granite polished_granite '
|
||||
'diorite polished_diorite andesite polished_andesite deepslate cobbled_deepslate '
|
||||
'polished_deepslate deepslate_bricks deepslate_tiles bricks quartz_block quartz_pillar '
|
||||
'smooth_quartz sandstone cut_sandstone smooth_sandstone red_sandstone terracotta '
|
||||
'glass tinted_glass obsidian dirt grass_block bedrock oak_planks spruce_planks '
|
||||
'birch_planks dark_oak_planks oak_log spruce_log birch_log dark_oak_log '
|
||||
'stripped_oak_log stripped_spruce_log moss_block glowstone gold_block '
|
||||
'waxed_oxidized_cut_copper barrier').split())
|
||||
SLABS = {'stone_brick_slab', 'cobblestone_slab', 'oak_slab', 'spruce_slab', 'smooth_stone_slab'}
|
||||
COLORS = ('white orange magenta light_blue yellow lime pink gray light_gray cyan purple blue brown green red black').split()
|
||||
FULL.update(color + suffix for color in COLORS for suffix in ('_concrete', '_terracotta', '_stained_glass'))
|
||||
|
||||
|
||||
def utc_now():
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def point(value):
|
||||
if not isinstance(value, dict) or any(type(value.get(a)) is not int for a in AXES):
|
||||
raise ValueError('Coordinates must contain integer x, y, z')
|
||||
if any(not -(2 ** 31) <= value[a] < 2 ** 31 for a in AXES):
|
||||
raise ValueError('Coordinates must fit signed 32-bit integers')
|
||||
return {a: value[a] for a in AXES}
|
||||
|
||||
|
||||
def scope_of(context):
|
||||
scope = {k: context.get(k) for k in SCOPE_KEYS}
|
||||
if any(not isinstance(v, str) or not v for v in scope.values()):
|
||||
raise ValueError('Project context must identify project, world and epoch')
|
||||
return scope
|
||||
|
||||
|
||||
def volume(box):
|
||||
return math.prod(box['max'][a] - box['min'][a] + 1 for a in AXES)
|
||||
|
||||
|
||||
def box_of(lo, hi):
|
||||
box = {'min': point(lo), 'max': point(hi)}
|
||||
if any(box['min'][a] > box['max'][a] for a in AXES):
|
||||
raise ValueError('Minimum exceeds maximum')
|
||||
return box
|
||||
|
||||
|
||||
def cell_key(box):
|
||||
return tuple(box['min'][a] // 16 for a in AXES)
|
||||
|
||||
|
||||
def box_cells(lo, hi):
|
||||
box = box_of(lo, hi)
|
||||
if volume(box) > MAX_VOXELS:
|
||||
raise ValueError(f'Survey exceeds {MAX_VOXELS:,} voxels; split it explicitly')
|
||||
cells = []
|
||||
for cx in range(lo['x'] // 16, hi['x'] // 16 + 1):
|
||||
for cy in range(lo['y'] // 16, hi['y'] // 16 + 1):
|
||||
for cz in range(lo['z'] // 16, hi['z'] // 16 + 1):
|
||||
origin = dict(zip(AXES, (cx * 16, cy * 16, cz * 16)))
|
||||
cells.append({'min': {a: max(lo[a], origin[a]) for a in AXES},
|
||||
'max': {a: min(hi[a], origin[a] + 15) for a in AXES}})
|
||||
return cells
|
||||
|
||||
|
||||
def column_cells(document):
|
||||
if not isinstance(document, dict) or document.get('version') != 1 or not isinstance(document.get('columns'), list):
|
||||
raise ValueError('Column plan requires version: 1 and columns: [{x,z,min_y,max_y}]')
|
||||
if not 1 <= len(document['columns']) <= MAX_VOXELS:
|
||||
raise ValueError('Column plan is empty or too large')
|
||||
merged = {}
|
||||
for column in document['columns']:
|
||||
if not isinstance(column, dict) or any(type(column.get(k)) is not int for k in ('x', 'z', 'min_y', 'max_y')):
|
||||
raise ValueError('Every column requires integer x, z, min_y and max_y')
|
||||
lo = point({'x': column['x'], 'z': column['z'], 'y': column['min_y']})
|
||||
hi = point({**lo, 'y': column['max_y']})
|
||||
for box in box_cells(lo, hi):
|
||||
key = cell_key(box)
|
||||
old = merged.get(key)
|
||||
merged[key] = box if old is None else {
|
||||
'min': {a: min(old['min'][a], box['min'][a]) for a in AXES},
|
||||
'max': {a: max(old['max'][a], box['max'][a]) for a in AXES}}
|
||||
cells = [merged[key] for key in sorted(merged)]
|
||||
if sum(volume(b) for b in cells) > MAX_VOXELS:
|
||||
raise ValueError('Expanded column survey exceeds voxel limit')
|
||||
return cells
|
||||
|
||||
|
||||
def assert_in_scope(cells, context):
|
||||
region = context.get('region', {})
|
||||
area = box_of(region.get('min'), region.get('max'))
|
||||
for cell in cells:
|
||||
if any(cell['min'][a] < area['min'][a] or cell['max'][a] > area['max'][a] for a in AXES):
|
||||
raise ValueError('Requested survey exceeds the selected project area')
|
||||
|
||||
|
||||
def index_at(box, x, y, z):
|
||||
lo, hi = box['min'], box['max']
|
||||
if not (lo['x'] <= x <= hi['x'] and lo['y'] <= y <= hi['y'] and lo['z'] <= z <= hi['z']):
|
||||
raise KeyError(f'No observed block at {(x, y, z)}; air is never inferred')
|
||||
return ((y - lo['y']) * (hi['z'] - lo['z'] + 1) + z - lo['z']) * (hi['x'] - lo['x'] + 1) + x - lo['x']
|
||||
|
||||
|
||||
def capture_cell(backend, box, epoch):
|
||||
started = utc_now()
|
||||
result = backend.call('region_inspect', **box, detail='blocks')
|
||||
if result.get('world_epoch') != epoch or result.get('truncated') is not False:
|
||||
raise RuntimeError('Inspection was truncated or returned another world epoch')
|
||||
palette, lookup = [], {}
|
||||
indices = [None] * volume(box)
|
||||
for item in result.get('blocks', []):
|
||||
pos = point(item.get('pos'))
|
||||
index = index_at(box, **pos)
|
||||
if indices[index] is not None:
|
||||
raise RuntimeError('Inspection returned a duplicate position')
|
||||
state = item.get('state')
|
||||
if not isinstance(state, str) or not STATE.fullmatch(state):
|
||||
raise RuntimeError('Inspection returned an invalid block state')
|
||||
if state not in lookup:
|
||||
lookup[state] = len(palette)
|
||||
palette.append(state)
|
||||
indices[index] = lookup[state]
|
||||
if any(i is None for i in indices):
|
||||
raise RuntimeError('Inspection omitted blocks; missing blocks are not air')
|
||||
return {**box, 'palette': palette, 'indices': indices,
|
||||
'observed_from': started, 'observed_until': utc_now()}
|
||||
|
||||
|
||||
def digest(value):
|
||||
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
|
||||
|
||||
|
||||
def save_gzip(path, document):
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(prefix=path.name + '.', suffix='.tmp', dir=path.parent, delete=False) as raw:
|
||||
temporary = Path(raw.name)
|
||||
with gzip.GzipFile(fileobj=raw, mode='wb', mtime=0) as zipped:
|
||||
zipped.write(json.dumps(document, separators=(',', ':')).encode())
|
||||
raw.flush()
|
||||
os.fsync(raw.fileno())
|
||||
temporary.replace(path)
|
||||
directory = os.open(path.parent, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(directory)
|
||||
finally:
|
||||
os.close(directory)
|
||||
finally:
|
||||
if temporary is not None and temporary.exists():
|
||||
temporary.unlink()
|
||||
|
||||
|
||||
class Snapshot:
|
||||
def __init__(self, document, expected_scope=None):
|
||||
if not isinstance(document, dict) or document.get('version') != 1 or document.get('complete') is not True:
|
||||
raise ValueError('Only complete version-1 snapshots may be used')
|
||||
self.scope = scope_of(document.get('scope', {}))
|
||||
if expected_scope is not None and self.scope != expected_scope:
|
||||
raise ValueError('Snapshot belongs to another project/world/epoch')
|
||||
self.document, self.cells = document, {}
|
||||
requested = document.get('requested_cells')
|
||||
captured = document.get('cells')
|
||||
if not isinstance(requested, list) or not requested or not isinstance(captured, list) or len(requested) != len(captured):
|
||||
raise ValueError('Snapshot does not cover every requested cell')
|
||||
if sum(volume(box_of(b.get('min'), b.get('max'))) for b in requested) > MAX_VOXELS:
|
||||
raise ValueError('Snapshot exceeds maximum voxel count')
|
||||
for expected, cell in zip(requested, captured):
|
||||
box = box_of(cell.get('min'), cell.get('max'))
|
||||
if box != expected or cell_key(box) != tuple(box['max'][a] // 16 for a in AXES):
|
||||
raise ValueError('Snapshot cell differs from request or crosses a 16³ cell')
|
||||
key = cell_key(box)
|
||||
if key in self.cells:
|
||||
raise ValueError('Snapshot contains duplicate cells')
|
||||
palette, indices = cell.get('palette'), cell.get('indices')
|
||||
if not isinstance(palette, list) or not palette or any(not isinstance(s, str) or not STATE.fullmatch(s) for s in palette):
|
||||
raise ValueError('Snapshot palette contains invalid states')
|
||||
if not isinstance(indices, list) or len(indices) != volume(box) or any(type(i) is not int or not 0 <= i < len(palette) for i in indices):
|
||||
raise ValueError('Snapshot has missing or invalid block indices')
|
||||
self.cells[key] = cell
|
||||
|
||||
def state(self, x, y, z):
|
||||
point({'x': x, 'y': y, 'z': z})
|
||||
cell = self.cells.get((x // 16, y // 16, z // 16))
|
||||
if cell is None:
|
||||
raise KeyError(f'No observed block at {(x, y, z)}; air is never inferred')
|
||||
return cell['palette'][cell['indices'][index_at(cell, x, y, z)]]
|
||||
|
||||
def states_for(self, blocks):
|
||||
return {tuple(b[a] for a in AXES): self.state(**point(b)) for b in blocks}
|
||||
|
||||
|
||||
def load_snapshot(path, expected_scope=None):
|
||||
with gzip.open(path, 'rt') as handle:
|
||||
return Snapshot(json.load(handle), expected_scope)
|
||||
|
||||
|
||||
def scan(backend, cells, path, resume=False, progress=lambda value: None):
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
context = backend.call('project_context')
|
||||
scope = scope_of(context)
|
||||
assert_in_scope(cells, context)
|
||||
cache = path.with_name(path.name + '.parts')
|
||||
with path.with_name(path.name + '.lock').open('a') as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
if path.exists():
|
||||
raise ValueError('Complete snapshot already exists; choose a fresh path for a fresh survey')
|
||||
if cache.exists() and not resume:
|
||||
raise ValueError('Survey cache already exists; --resume explicitly reuses older observations')
|
||||
cache.mkdir(exist_ok=True)
|
||||
identity = {'version': 1, 'scope': scope, 'requested_cells': cells}
|
||||
header = cache / 'manifest.json'
|
||||
if header.exists():
|
||||
saved = json.loads(header.read_text())
|
||||
if saved.get('identity') != identity or saved.get('digest') != digest(identity):
|
||||
raise ValueError('Survey cache scope or requested cells differ')
|
||||
else:
|
||||
saved = {'identity': identity, 'digest': digest(identity), 'started_at': utc_now()}
|
||||
terrain.save(header, saved)
|
||||
captured = []
|
||||
for number, box in enumerate(cells):
|
||||
part = cache / f'{number:06d}.json.gz'
|
||||
if part.exists():
|
||||
with gzip.open(part, 'rt') as handle:
|
||||
cell = json.load(handle)
|
||||
# Reject incomplete, reordered, or damaged cached observations before reuse.
|
||||
Snapshot({'version': 1, 'complete': True, 'scope': scope,
|
||||
'requested_cells': [box], 'cells': [cell]}, scope)
|
||||
else:
|
||||
cell = capture_cell(backend, box, scope['world_epoch'])
|
||||
save_gzip(part, cell)
|
||||
captured.append(cell)
|
||||
progress({'cells': number + 1, 'total_cells': len(cells)})
|
||||
final_context = backend.call('project_context')
|
||||
if scope_of(final_context) != scope or final_context.get('region') != context.get('region'):
|
||||
raise RuntimeError('Project scope changed during capture; no complete snapshot written')
|
||||
document = {**identity, 'complete': True, 'atomic_snapshot': False,
|
||||
'started_at': saved['started_at'], 'finished_at': utc_now(),
|
||||
'resumed_cache': resume, 'cells': captured,
|
||||
'note': 'Sequential observations. Exact states must be checked again atomically when editing.'}
|
||||
result = Snapshot(document, scope)
|
||||
save_gzip(path, document)
|
||||
return result
|
||||
|
||||
|
||||
def vertical_shape(state, sub_x=.5, sub_z=.5):
|
||||
"""Occupied Y intervals at a surface sample, or None for unknown geometry.
|
||||
|
||||
A straight stair has two levels. Samples on the riser boundary are ambiguous
|
||||
and rejected, including the default center, rather than choosing one level.
|
||||
"""
|
||||
if state in AIR:
|
||||
return []
|
||||
base, _, properties = state.removeprefix('minecraft:').partition('[')
|
||||
props = dict(piece.split('=', 1) for piece in properties.rstrip(']').split(',') if '=' in piece)
|
||||
if base in FULL:
|
||||
return [(0.0, 1.0)]
|
||||
if base in SLABS and props.get('waterlogged', 'false') == 'false':
|
||||
return {'bottom': [(0.0, .5)], 'top': [(.5, 1.0)], 'double': [(0.0, 1.0)]}.get(props.get('type'))
|
||||
if (base.endswith('_stairs') and props.get('shape') == 'straight'
|
||||
and props.get('half') == 'bottom' and props.get('waterlogged', 'false') == 'false'):
|
||||
facing = props.get('facing')
|
||||
if facing not in ('north', 'south', 'east', 'west'):
|
||||
return None
|
||||
offset = sub_z if facing in ('north', 'south') else sub_x
|
||||
if abs(offset - .5) <= 1e-7:
|
||||
return None
|
||||
high = offset < .5 if facing in ('north', 'west') else offset > .5
|
||||
return [(0.0, 1.0 if high else .5)]
|
||||
return None
|
||||
|
||||
|
||||
def verify_walkable(snapshot, points):
|
||||
"""Check observed surface samples and their 1.8-block vertical clearance.
|
||||
|
||||
`standing_y` is the feet height, not the supporting block Y. Full blocks and
|
||||
horizontal slabs support a centered player's full footprint. Explicit sub_x
|
||||
and sub_z fractions additionally sample straight bottom stairs on each side
|
||||
of their riser. These are point samples, not a full moving-player collision
|
||||
simulation. Optional ordered `route` points check physical cardinal steps of
|
||||
at most one block and rises/drops of at most half a block.
|
||||
"""
|
||||
failures, routes = [], defaultdict(list)
|
||||
for number, p in enumerate(points):
|
||||
if not isinstance(p, dict) or type(p.get('x')) is not int or type(p.get('z')) is not int:
|
||||
raise ValueError('Walk points require integer x and z')
|
||||
feet = p.get('standing_y')
|
||||
if type(feet) not in (int, float) or not math.isfinite(feet) or feet * 2 != round(feet * 2):
|
||||
raise ValueError('standing_y must be a finite half-block height')
|
||||
x, z = p['x'], p['z']
|
||||
sub_x, sub_z = p.get('sub_x', .5), p.get('sub_z', .5)
|
||||
if any(type(value) not in (int, float) or not math.isfinite(value) or not 0 < value < 1
|
||||
for value in (sub_x, sub_z)):
|
||||
raise ValueError('sub_x and sub_z must be finite fractions strictly between 0 and 1')
|
||||
support_y = math.ceil(feet) - 1
|
||||
reason = None
|
||||
try:
|
||||
support = vertical_shape(snapshot.state(x, support_y, z), sub_x, sub_z)
|
||||
if support is None:
|
||||
reason = 'unknown_support_shape'
|
||||
elif not any(abs(support_y + top - feet) < 1e-8 for _, top in support):
|
||||
reason = 'missing_support_at_feet'
|
||||
if reason is None:
|
||||
for y in range(math.floor(feet), math.ceil(feet + 1.8)):
|
||||
occupied = vertical_shape(snapshot.state(x, y, z), sub_x, sub_z)
|
||||
if occupied is None:
|
||||
reason = 'unknown_clearance_shape'
|
||||
break
|
||||
if any(y + bottom < feet + 1.8 and y + top > feet for bottom, top in occupied):
|
||||
reason = 'blocked_headroom'
|
||||
break
|
||||
except KeyError:
|
||||
reason = 'unobserved_block'
|
||||
if reason:
|
||||
failures.append({'index': number, 'x': x, 'z': z, 'sub_x': sub_x, 'sub_z': sub_z,
|
||||
'standing_y': feet, 'reason': reason})
|
||||
if 'route' in p:
|
||||
route = p['route']
|
||||
if not isinstance(route, str) or not route:
|
||||
raise ValueError('route must be a nonempty string')
|
||||
routes[route].append((number, x + sub_x, z + sub_z, feet))
|
||||
edges = 0
|
||||
for route, row in routes.items():
|
||||
for previous, current in zip(row, row[1:]):
|
||||
edges += 1
|
||||
dx, dz = abs(previous[1] - current[1]), abs(previous[2] - current[2])
|
||||
if (dx > 1e-7 and dz > 1e-7) or not 1e-7 < dx + dz <= 1 + 1e-7:
|
||||
failures.append({'index': current[0], 'route': route, 'reason': 'non_cardinal_route_step'})
|
||||
elif abs(previous[3] - current[3]) > .5:
|
||||
failures.append({'index': current[0], 'route': route, 'reason': 'route_step_exceeds_half_block'})
|
||||
return {'checked_points': len(points), 'checked_route_edges': edges, 'failures': failures,
|
||||
'passed': not failures, 'scope': snapshot.scope,
|
||||
'note': 'Observed surface samples and vertical headroom; full cubes, slabs and explicit straight bottom-stair samples. Stair samples are a surface profile, not a full player-width collision simulation. Cached snapshot is not a live re-read.'}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
commands = parser.add_subparsers(dest='command', required=True)
|
||||
capture = commands.add_parser('scan', help='Read actual blocks; never edits or loads chunks')
|
||||
capture.add_argument('--min', type=int, nargs=3, metavar=('X', 'Y', 'Z'))
|
||||
capture.add_argument('--max', type=int, nargs=3, metavar=('X', 'Y', 'Z'))
|
||||
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')
|
||||
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?}]}')
|
||||
verify.add_argument('--report', type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
if args.command == 'scan':
|
||||
if args.columns:
|
||||
if args.min or args.max:
|
||||
parser.error('Use --columns or --min/--max, not both')
|
||||
cells = column_cells(json.loads(args.columns.read_text()))
|
||||
else:
|
||||
if not args.min or not args.max:
|
||||
parser.error('--min and --max are required without --columns')
|
||||
cells = box_cells(dict(zip(AXES, args.min)), dict(zip(AXES, args.max)))
|
||||
result = scan(terrain.Backend(args.config), cells, args.out, args.resume,
|
||||
progress=lambda p: print(json.dumps(p), flush=True) if p['cells'] % 16 == 0 or p['cells'] == p['total_cells'] else None)
|
||||
print(json.dumps({'status': 'captured', 'scope': result.scope, 'cells': len(cells),
|
||||
'voxels': sum(volume(c) for c in cells), 'path': str(args.out.resolve())}))
|
||||
else:
|
||||
document = json.loads(args.points.read_text())
|
||||
result = verify_walkable(load_snapshot(args.snapshot, scope_of(document.get('scope', {}))), document['points'])
|
||||
args.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
terrain.save(args.report, result)
|
||||
print(json.dumps({'passed': result['passed'], 'checked_points': result['checked_points'],
|
||||
'failures': len(result['failures']), 'report': str(args.report.resolve())}))
|
||||
if not result['passed']:
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,19 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,188 @@
|
||||
"""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'])
|
||||
@@ -0,0 +1,326 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Place an explicit block layout with checked snapshots, resumable receipts, and guarded undo."""
|
||||
import argparse
|
||||
from collections import Counter, defaultdict
|
||||
import fcntl
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
spec = importlib.util.spec_from_file_location('layout_terrain', Path(__file__).with_name('terrain.py'))
|
||||
terrain = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(terrain)
|
||||
ROOT = terrain.ROOT
|
||||
MAX_BLOCKS = 4096
|
||||
MAX_OPERATIONS = 256
|
||||
MAX_READ_CELLS = 32
|
||||
SCOPE_KEYS = ('project_id', 'world_id', 'world_epoch')
|
||||
FAILED = ('conflict', 'cancelled', 'failed', 'recovery_required')
|
||||
STATE = re.compile(r'minecraft:[a-z0-9_]+(?:\[[a-z0-9_=,]+\])?\Z')
|
||||
|
||||
|
||||
def position(block):
|
||||
return tuple(block[axis] for axis in ('x', 'y', 'z'))
|
||||
|
||||
|
||||
def coordinates(at):
|
||||
return dict(zip(('x', 'y', 'z'), at))
|
||||
|
||||
|
||||
def normalize(value):
|
||||
if not isinstance(value, dict) or type(value.get('version')) is not int or value['version'] != 1:
|
||||
raise ValueError('Layout version must be 1')
|
||||
scope = value.get('scope', {})
|
||||
if not isinstance(scope, dict) or any(not isinstance(scope.get(k), str) or not scope[k] for k in SCOPE_KEYS):
|
||||
raise ValueError('Layout requires project_id, world_id, and world_epoch')
|
||||
source = value.get('blocks')
|
||||
if not isinstance(source, list) or not 1 <= len(source) <= 1_000_000:
|
||||
raise ValueError('Layout must contain 1..1,000,000 explicit blocks')
|
||||
blocks, seen = [], set()
|
||||
for raw in source:
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError('Every block must be an object')
|
||||
if any(type(raw.get(a)) is not int or not -2**31 <= raw[a] < 2**31 for a in ('x', 'y', 'z')):
|
||||
raise ValueError('Block coordinates must be signed 32-bit integers')
|
||||
at = position(raw)
|
||||
if at in seen:
|
||||
raise ValueError(f'Duplicate block position: {at}')
|
||||
seen.add(at)
|
||||
block, expected = raw.get('block'), raw.get('expected', 'minecraft:air')
|
||||
if any(not isinstance(s, str) or len(s) > 512 or not STATE.fullmatch(s) for s in (block, expected)):
|
||||
raise ValueError(f'Invalid block state at {at}; use full minecraft: names')
|
||||
blocks.append({**coordinates(at), 'block': block, 'expected': expected})
|
||||
blocks.sort(key=position)
|
||||
return {'version': 1, 'scope': {k: scope[k] for k in SCOPE_KEYS}, 'blocks': blocks}
|
||||
|
||||
|
||||
def digest(value):
|
||||
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
|
||||
|
||||
|
||||
def read_cell(block):
|
||||
return (block['x'] // 16, block['y'] // 16, block['z'] // 16)
|
||||
|
||||
|
||||
def bounds(blocks):
|
||||
return {'min': {a: min(b[a] for b in blocks) for a in ('x', 'y', 'z')},
|
||||
'max': {a: max(b[a] for b in blocks) for a in ('x', 'y', 'z')}}
|
||||
|
||||
|
||||
def volume(box):
|
||||
return (box['max']['x'] - box['min']['x'] + 1) * (box['max']['y'] - box['min']['y'] + 1) * (box['max']['z'] - box['min']['z'] + 1)
|
||||
|
||||
|
||||
def read_groups(blocks):
|
||||
cells = defaultdict(list)
|
||||
for block in blocks:
|
||||
cells[read_cell(block)].append(block)
|
||||
return [cells[key] for key in sorted(cells)]
|
||||
|
||||
|
||||
def compressed_runs(blocks):
|
||||
"""Runs never cross a 16³ read cell, so every inspection is bounded to 4096 voxels."""
|
||||
rows = defaultdict(list)
|
||||
for block in blocks:
|
||||
rows[(*read_cell(block), block['y'], block['z'], block['block'])].append(block)
|
||||
for key in sorted(rows):
|
||||
row = sorted(rows[key], key=lambda b: b['x'])
|
||||
run = []
|
||||
for block in row:
|
||||
if run and block['x'] != run[-1]['x'] + 1:
|
||||
yield run
|
||||
run = []
|
||||
run.append(block)
|
||||
if run:
|
||||
yield run
|
||||
|
||||
|
||||
def make_batches(blocks):
|
||||
batches, batch, cells = [], [], set()
|
||||
for run in compressed_runs(blocks):
|
||||
cell = read_cell(run[0])
|
||||
if batch and (len(batch) >= MAX_OPERATIONS or sum(len(r) for r in batch) + len(run) > MAX_BLOCKS
|
||||
or len(cells | {cell}) > MAX_READ_CELLS):
|
||||
batches.append(batch)
|
||||
batch, cells = [], set()
|
||||
batch.append(run)
|
||||
cells.add(cell)
|
||||
if batch:
|
||||
batches.append(batch)
|
||||
result = []
|
||||
for index, runs in enumerate(batches):
|
||||
selected = [b for run in runs for b in run]
|
||||
operations = [{'type': 'box', 'min': coordinates(position(run[0])),
|
||||
'max': coordinates(position(run[-1])), 'block': run[0]['block']} for run in runs]
|
||||
result.append({'index': index, 'blocks': selected, 'recipe': {'version': 1, 'operations': operations}})
|
||||
return result
|
||||
|
||||
|
||||
def check_context(context, layout, require_checked=True):
|
||||
if {key: context.get(key) for key in SCOPE_KEYS} != layout['scope']:
|
||||
raise RuntimeError('Layout belongs to another project/world/epoch')
|
||||
if require_checked and context.get('checked_expected_blocks') is not True:
|
||||
raise RuntimeError('Server lacks atomic checked_expected_blocks; update the plugin before placing this layout')
|
||||
area = context.get('region', {})
|
||||
for block in layout['blocks']:
|
||||
if any(not area.get('min', {}).get(a, 1) <= block[a] <= area.get('max', {}).get(a, 0) for a in ('x', 'y', 'z')):
|
||||
raise RuntimeError(f'Layout exceeds selected project area at {position(block)}')
|
||||
|
||||
|
||||
def inspect_states(backend, blocks, epoch):
|
||||
states = {}
|
||||
for group in read_groups(blocks):
|
||||
box = bounds(group)
|
||||
result = backend.call('region_inspect', **box, detail='blocks')
|
||||
if result.get('world_epoch') != epoch or result.get('truncated') is not False:
|
||||
raise RuntimeError('Inspection was truncated or returned a different world epoch')
|
||||
found = {}
|
||||
for item in result.get('blocks', []):
|
||||
at = position(item['pos'])
|
||||
if at in found:
|
||||
raise RuntimeError('Inspection contained duplicate positions')
|
||||
found[at] = item['state']
|
||||
if len(found) != volume(box):
|
||||
raise RuntimeError('Inspection did not return the complete bounded region')
|
||||
for block in group:
|
||||
at = position(block)
|
||||
if at not in found:
|
||||
raise RuntimeError(f'Inspection omitted {at}')
|
||||
states[at] = found[at]
|
||||
return states
|
||||
|
||||
|
||||
def verify(backend, blocks, epoch, field):
|
||||
states = inspect_states(backend, blocks, epoch)
|
||||
mismatches = [(position(b), b[field], states[position(b)]) for b in blocks if states[position(b)] != b[field]]
|
||||
if mismatches:
|
||||
at, expected, actual = mismatches[0]
|
||||
raise RuntimeError(f'Block mismatch at {at}: expected {expected}, found {actual} ({len(mismatches)} mismatches); no blind overwrite')
|
||||
return len(blocks)
|
||||
|
||||
|
||||
def load_or_create(path, layout, batches):
|
||||
expected_digest = digest(layout)
|
||||
snapshot = path.with_suffix(path.suffix + '.input.json')
|
||||
if path.exists():
|
||||
manifest = json.loads(path.read_text())
|
||||
if (manifest.get('version') != 1 or manifest.get('planning_version') != 1
|
||||
or manifest.get('scope') != layout['scope'] or manifest.get('input_digest') != expected_digest):
|
||||
raise RuntimeError('Manifest layout digest, planning version, or project/world/epoch differs; do not reuse it')
|
||||
if not snapshot.exists() or digest(normalize(json.loads(snapshot.read_text()))) != expected_digest:
|
||||
raise RuntimeError('Saved layout input is missing or changed')
|
||||
if len(manifest.get('batches', [])) != len(batches):
|
||||
raise RuntimeError('Saved batch count differs from deterministic planner')
|
||||
for saved, planned in zip(manifest['batches'], batches):
|
||||
if saved.get('index') != planned['index'] or saved.get('digest') != digest(planned):
|
||||
raise RuntimeError('Saved batch differs from deterministic planner')
|
||||
return manifest
|
||||
if snapshot.exists() and digest(normalize(json.loads(snapshot.read_text()))) != expected_digest:
|
||||
raise RuntimeError('An orphaned layout input differs; choose a new manifest path')
|
||||
terrain.save(snapshot, layout)
|
||||
manifest = {'version': 1, 'planning_version': 1, 'scope': layout['scope'],
|
||||
'input_digest': expected_digest, 'blocks': len(layout['blocks']),
|
||||
'batches': [{'index': b['index'], 'digest': digest(b), 'blocks': len(b['blocks'])} for b in batches]}
|
||||
terrain.save(path, manifest)
|
||||
return manifest
|
||||
|
||||
|
||||
def apply_layout(backend, layout, path, progress=print):
|
||||
check_context(backend.call('project_context'), layout)
|
||||
batches = make_batches(layout['blocks'])
|
||||
manifest = load_or_create(path, layout, batches)
|
||||
if manifest.get('undo_started') or manifest.get('undone') or any('undo' in e for e in manifest['batches']):
|
||||
raise RuntimeError('Layout has begun undo; finish undo and use a new manifest for new work')
|
||||
epoch = layout['scope']['world_epoch']
|
||||
for batch, entry in zip(batches, manifest['batches']):
|
||||
if entry.get('status') in FAILED:
|
||||
raise RuntimeError(f"Batch {entry['index']} stopped on {entry['status']}; inspect or undo it instead of creating a new plan")
|
||||
if entry.get('status') == 'applied':
|
||||
verify(backend, batch['blocks'], epoch, 'block')
|
||||
continue
|
||||
# An unknown apply may already have changed the world: resolve its same stable key first.
|
||||
if 'idempotency_key' not in entry and 'operation_id' not in entry:
|
||||
verify(backend, batch['blocks'], epoch, 'expected')
|
||||
if 'plan_id' not in entry:
|
||||
prepared = backend.call('build_prepare', recipe=batch['recipe'], expected_blocks=[
|
||||
{'pos': coordinates(position(b)), 'state': b['expected']} for b in batch['blocks']])
|
||||
entry.update(prepared)
|
||||
terrain.save(path, manifest)
|
||||
terrain.apply_plan(backend, entry, manifest, path)
|
||||
verify(backend, batch['blocks'], epoch, 'block')
|
||||
entry['verified_at'] = int(time.time())
|
||||
terrain.save(path, manifest)
|
||||
progress(json.dumps({'batch': entry['index'] + 1, 'batches': len(batches), 'status': 'verified',
|
||||
'written': entry['written'], 'operation_id': entry['operation_id']}))
|
||||
manifest['completed'] = True
|
||||
terrain.save(path, manifest)
|
||||
return {'status': 'completed', 'blocks': len(layout['blocks']), 'batches': len(batches), 'manifest': str(path)}
|
||||
|
||||
|
||||
def undo_layout(backend, path, progress=print):
|
||||
snapshot = path.with_suffix(path.suffix + '.input.json')
|
||||
if not path.exists() or not snapshot.exists():
|
||||
raise RuntimeError('Manifest or saved layout input not found')
|
||||
layout = normalize(json.loads(snapshot.read_text()))
|
||||
check_context(backend.call('project_context'), layout, require_checked=False)
|
||||
batches = make_batches(layout['blocks'])
|
||||
manifest = load_or_create(path, layout, batches)
|
||||
for entry in manifest['batches']:
|
||||
if 'idempotency_key' in entry and 'operation_id' not in entry:
|
||||
raise RuntimeError('Uncertain apply response. Resume apply with the same manifest first; undo will not start an unconfirmed write')
|
||||
manifest['undo_started'] = True
|
||||
terrain.save(path, manifest)
|
||||
for batch, entry in reversed(list(zip(batches, manifest['batches']))):
|
||||
if 'operation_id' not in entry:
|
||||
continue
|
||||
source = terrain.finish(backend, entry['operation_id'])
|
||||
if source['status'] == 'recovery_required':
|
||||
raise RuntimeError('Interrupted server operation requires recovery review before undo')
|
||||
if not source['written']:
|
||||
continue
|
||||
if 'undo' not in entry:
|
||||
entry['undo'] = backend.call('operation_undo_prepare', operation_id=entry['operation_id'])
|
||||
terrain.save(path, manifest)
|
||||
if entry['undo'].get('status') in FAILED:
|
||||
raise RuntimeError(f"Undo stopped on {entry['undo']['status']}; inspect conflicts before taking further action")
|
||||
terrain.apply_plan(backend, entry['undo'], manifest, path)
|
||||
# Only a fully applied source has receipts for every changed position. A guarded partial
|
||||
# undo intentionally leaves conflicting manual edits alone; the server verifies its receipts.
|
||||
if source['status'] == 'applied':
|
||||
verify(backend, batch['blocks'], layout['scope']['world_epoch'], 'expected')
|
||||
entry['undo']['verified_at'] = int(time.time())
|
||||
else:
|
||||
entry['undo']['verification'] = 'server_receipts_only_for_partial_source'
|
||||
terrain.save(path, manifest)
|
||||
progress(json.dumps({'batch': entry['index'] + 1, 'status': 'undone', 'written': entry['undo']['written']}))
|
||||
manifest['undone'] = True
|
||||
terrain.save(path, manifest)
|
||||
return {'status': 'undone', 'manifest': str(path)}
|
||||
|
||||
|
||||
def report(layout):
|
||||
batches = make_batches(layout['blocks'])
|
||||
boxes = [bounds(group) for b in batches for group in read_groups(b['blocks'])]
|
||||
return {'status': 'offline_report', 'scope': layout['scope'], 'input_digest': digest(layout),
|
||||
'blocks': len(layout['blocks']), 'batches': len(batches), 'bounds': bounds(layout['blocks']),
|
||||
'materials': dict(sorted(Counter(b['block'] for b in layout['blocks']).items())),
|
||||
'inspection_requests_per_pass': len(boxes), 'inspection_voxels_per_pass': sum(map(volume, boxes)),
|
||||
'max_inspection_volume': max(map(volume, boxes)),
|
||||
'max_batch_blocks': max(len(b['blocks']) for b in batches),
|
||||
'max_batch_operations': max(len(b['recipe']['operations']) for b in batches)}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
sub = parser.add_subparsers(dest='command', required=True)
|
||||
p = sub.add_parser('report', help='Report deterministic batching offline; no server required')
|
||||
p.add_argument('layout', type=Path)
|
||||
descriptions = {'prepare': 'Save immutable input and check current blocks without editing the world',
|
||||
'apply': 'Place or resume the layout through checked, journalled RPC',
|
||||
'undo': 'Undo recorded layout operations in reverse order, preserving later edits'}
|
||||
for name, description in descriptions.items():
|
||||
p = sub.add_parser(name, help=description)
|
||||
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('--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')
|
||||
args = parser.parse_args()
|
||||
if args.command == 'report':
|
||||
print(json.dumps(report(normalize(json.loads(args.layout.read_text()))), indent=2))
|
||||
return
|
||||
path = args.manifest.resolve()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.with_suffix(path.suffix + '.lock').open('a') as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
backend = terrain.Backend(args.config, args.console)
|
||||
if args.command == 'undo':
|
||||
result = undo_layout(backend, path, progress=lambda s: print(s, flush=True))
|
||||
else:
|
||||
layout = normalize(json.loads(args.layout.read_text()))
|
||||
if args.command == 'prepare':
|
||||
check_context(backend.call('project_context'), layout)
|
||||
batches = make_batches(layout['blocks'])
|
||||
manifest = load_or_create(path, layout, batches)
|
||||
if any('plan_id' in e for e in manifest['batches']):
|
||||
raise RuntimeError('This manifest already has plans; use apply to resume, or report for an offline summary')
|
||||
for batch in batches:
|
||||
verify(backend, batch['blocks'], layout['scope']['world_epoch'], 'expected')
|
||||
result = {**report(layout), 'status': 'prepared_input', 'manifest': str(path)}
|
||||
else:
|
||||
result = apply_layout(backend, layout, path, progress=lambda s: print(s, flush=True))
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except (RuntimeError, ValueError, OSError) as error:
|
||||
print(f'Layout stopped: {error}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,158 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,183 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,114 @@
|
||||
package probe;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.bukkit.command.*;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import java.lang.reflect.*;
|
||||
import java.nio.file.*;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
|
||||
/** Test-only, console-only helper; never installed in the user's lobby server. */
|
||||
public final class MaterialRegistryProbe extends JavaPlugin {
|
||||
private final List<Map<String,Object>> failures = new ArrayList<>();
|
||||
private int failureCount;
|
||||
@Override public void onEnable() { getLogger().info("Use console registryprobe to run the isolated registry test"); }
|
||||
@Override public boolean onCommand(CommandSender sender, Command cmd, String label, String[] args) {
|
||||
if (sender instanceof ConsoleCommandSender) runProbe(); return true;
|
||||
}
|
||||
private static Method method(Class<?> c, String name, Class<?>... args) throws Exception {
|
||||
Method result=c.getDeclaredMethod(name,args); result.setAccessible(true); return result;
|
||||
}
|
||||
private static Object call(Method m, Object receiver, Object... args) throws Throwable {
|
||||
try { return m.invoke(receiver,args); } catch(InvocationTargetException e) { throw e.getCause(); }
|
||||
}
|
||||
private static void equal(Object expected,Object actual) { if (!Objects.equals(expected,actual)) throw new AssertionError("snapshot_mismatch"); }
|
||||
private void failure(String id,String state,String phase,Throwable error) {
|
||||
failureCount++;
|
||||
if(failures.size()<128) failures.add(Map.of("block",id,"state",state,"phase",phase,"reason",error.getClass().getSimpleName()));
|
||||
}
|
||||
private void runProbe() {
|
||||
long start=System.nanoTime(); failures.clear(); failureCount=0;
|
||||
int total=0,passed=0,entityDefaults=0,catalogDescribed=0,registryCount=0,maxDescriptionBytes=0,maxProperties=0,maxPropertyValues=0;
|
||||
int propertyCases=0,propertyPassed=0,freshPassed=0,sameMaterialPassed=0,privateUndoPassed=0;
|
||||
World anchorWorld=null; BlockData anchorBefore=null; boolean anchorRestored=false;
|
||||
String fatal=null; Map<?,?> catalogSummary=Map.of();
|
||||
try {
|
||||
var plugin=Bukkit.getPluginManager().getPlugin("MinecraftBuilderMCP"); var loader=plugin.getClass().getClassLoader();
|
||||
Class<?> accessClass=Class.forName("io.github.minecraftbuilder.paper.BuildingWorld",true,loader);
|
||||
Class<?> catalogClass=Class.forName("io.github.minecraftbuilder.paper.MaterialCatalog",true,loader);
|
||||
Constructor<?> catalogConstructor=catalogClass.getDeclaredConstructor(); catalogConstructor.setAccessible(true);
|
||||
Object catalog=catalogConstructor.newInstance(); Method describe=method(catalogClass,"describe",String.class);
|
||||
catalogSummary=(Map<?,?>)call(method(catalogClass,"summary"),catalog); registryCount=(int)Registry.BLOCK.stream().count();
|
||||
Class<?> posClass=Class.forName("io.github.minecraftbuilder.core.BlockPos",true,loader);
|
||||
Constructor<?> constructor=accessClass.getDeclaredConstructor(World.class); constructor.setAccessible(true);
|
||||
World world=Bukkit.getWorld("world");
|
||||
if(world==null||Bukkit.getPort()!=25576) throw new IllegalStateException("IsolatedServerRequired");
|
||||
world.loadChunk(0,0); Object pos=posClass.getConstructor(int.class,int.class,int.class).newInstance(4,100,4);
|
||||
if(!world.getBlockAt(4,100,4).getType().isAir()||!world.getBlockAt(5,100,4).getType().isAir()) throw new IllegalStateException("AirFixtureRequired");
|
||||
anchorWorld=world; anchorBefore=world.getBlockAt(5,100,4).getBlockData();
|
||||
world.getBlockAt(5,100,4).setBlockData(Material.STONE.createBlockData(),false);
|
||||
Object access=constructor.newInstance(world);
|
||||
Method capture=method(accessClass,"captureBlock",posClass),prepare=method(accessClass,"prepareBlock",posClass,String.class,String.class),place=method(accessClass,"setCapturedBlock",posClass,String.class);
|
||||
String before=(String)call(capture,access,pos);
|
||||
for(Material material:Material.values()) {
|
||||
if(material.isLegacy()||!material.isBlock()) continue;
|
||||
total++; String id=material.getKey().toString(); Map<?,?> properties=Map.of();
|
||||
try {
|
||||
Map<?,?> description=(Map<?,?>)call(describe,catalog,id);
|
||||
maxDescriptionBytes=Math.max(maxDescriptionBytes,new com.google.gson.Gson().toJson(description).getBytes(java.nio.charset.StandardCharsets.UTF_8).length);
|
||||
properties=(Map<?,?>)description.get("properties"); maxProperties=Math.max(maxProperties,properties.size());
|
||||
for(Object values:properties.values()) maxPropertyValues=Math.max(maxPropertyValues,((Collection<?>)values).size());
|
||||
catalogDescribed++;
|
||||
} catch(Throwable e) { failure(id,id,"catalog_description",e); }
|
||||
String defaultState=material.createBlockData().getAsString(),phase="prepare_default",base=null;
|
||||
try {
|
||||
base=(String)call(prepare,access,pos,defaultState,before); if(base.startsWith("\u0000")) entityDefaults++;
|
||||
equal(before,call(capture,access,pos));
|
||||
phase="place_default"; call(place,access,pos,base);
|
||||
phase="verify_default"; equal(base,call(capture,access,pos)); passed++;
|
||||
} catch(Throwable e) { failure(id,defaultState,phase,e); }
|
||||
finally { call(place,access,pos,before); equal(before,call(capture,access,pos)); }
|
||||
if(base==null) continue;
|
||||
Set<String> variants=new LinkedHashSet<>();
|
||||
for(var property:properties.entrySet()) for(Object value:(Collection<?>)property.getValue())
|
||||
variants.add(Bukkit.createBlockData(id+"["+property.getKey()+"="+value+"]").getAsString());
|
||||
for(String variant:variants) {
|
||||
if(++propertyCases>30_000||System.nanoTime()-start>35_000_000_000L) throw new IllegalStateException("ProbeBudgetExceeded");
|
||||
phase="prepare_fresh_property";
|
||||
try {
|
||||
String desired=(String)call(prepare,access,pos,variant,before); equal(before,call(capture,access,pos));
|
||||
phase="place_fresh_property"; call(place,access,pos,desired);
|
||||
phase="verify_fresh_property"; equal(desired,call(capture,access,pos)); freshPassed++;
|
||||
call(place,access,pos,before); equal(before,call(capture,access,pos));
|
||||
phase="place_existing_default"; call(place,access,pos,base); equal(base,call(capture,access,pos));
|
||||
phase="prepare_same_material"; String changed=(String)call(prepare,access,pos,variant,base); equal(base,call(capture,access,pos));
|
||||
phase="place_same_material"; call(place,access,pos,changed);
|
||||
phase="verify_same_material"; equal(changed,call(capture,access,pos)); sameMaterialPassed++;
|
||||
phase="private_undo"; call(place,access,pos,base); equal(base,call(capture,access,pos)); privateUndoPassed++; propertyPassed++;
|
||||
} catch(Throwable e) { failure(id,variant,phase,e); }
|
||||
finally { call(place,access,pos,before); equal(before,call(capture,access,pos)); }
|
||||
}
|
||||
}
|
||||
} catch(Throwable e) { fatal=e.getClass().getSimpleName(); }
|
||||
finally {
|
||||
if(anchorWorld!=null&&anchorBefore!=null) {
|
||||
anchorWorld.getBlockAt(5,100,4).setBlockData(anchorBefore,false);
|
||||
anchorRestored=anchorBefore.equals(anchorWorld.getBlockAt(5,100,4).getBlockData());
|
||||
}
|
||||
}
|
||||
double millis=(System.nanoTime()-start)/1_000_000.0;
|
||||
Map<String,Object> result=new LinkedHashMap<>();
|
||||
result.put("timestamp",Instant.now().toString());result.put("server_version",Bukkit.getVersion());
|
||||
result.put("fixture","Air target (4,100,4); temporary stone section anchor (5,100,4)");result.put("anchor_restored",anchorRestored);
|
||||
result.put("catalog_summary",catalogSummary);result.put("catalog_described",catalogDescribed);result.put("registry_blocks",registryCount);
|
||||
result.put("max_description_bytes",maxDescriptionBytes);result.put("max_properties",maxProperties);result.put("max_property_values",maxPropertyValues);
|
||||
result.put("total",total);result.put("passed",passed);result.put("entity_defaults",entityDefaults);
|
||||
result.put("property_cases",propertyCases);result.put("property_passed",propertyPassed);result.put("fresh_property_passed",freshPassed);
|
||||
result.put("same_material_property_passed",sameMaterialPassed);result.put("private_undo_passed",privateUndoPassed);
|
||||
result.put("duration_ms",millis);result.put("fatal",fatal);result.put("failure_count",failureCount);result.put("failures",failures);
|
||||
try { getDataFolder().mkdirs(); Files.writeString(getDataFolder().toPath().resolve("report.json"),new com.google.gson.GsonBuilder().serializeNulls().create().toJson(result)); }
|
||||
catch(Exception e) { getLogger().severe("Could not save registry probe report"); }
|
||||
getLogger().info("Registry probe: "+passed+"/"+total+", property cases "+propertyPassed+"/"+propertyCases+", failures "+failureCount+", fatal "+fatal+", "+Math.round(millis)+" ms");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
# Isolated material registry regression probe
|
||||
|
||||
This optional test plugin exercises the **installed production plugin** through reflection. It is excluded from Maven modules and never installed by the development server launcher or this builder.
|
||||
|
||||
It verifies every registered block default, every catalog description, and each distinct state obtained by varying one property at a time. Property cases cover fresh placement, changing an existing block of the same material, and exact private snapshot restoration. It also verifies that preparation does not modify the world. This is not a Cartesian enumeration of all property combinations or a test of later game ticks.
|
||||
|
||||
Build from the repository root after the development Paper runtime and JDK have been prepared:
|
||||
|
||||
```bash
|
||||
python3 scripts/material-registry-probe/build.py
|
||||
```
|
||||
|
||||
The only build outputs are under `.runtime/material-registry-probe/`. Use `--paper-home PATH` to select a prepared Paper installation for its dependency libraries and `--java-home PATH` to select another JDK 25 or newer.
|
||||
|
||||
Install **only into a disposable isolated test server**, using the pinned Paper version and the production plugin JAR being tested:
|
||||
|
||||
```bash
|
||||
cp .runtime/material-registry-probe/material-registry-probe.jar .runtime/material-test-server/plugins/
|
||||
```
|
||||
|
||||
Start or restart that isolated server, then enter `registryprobe` in its server console. The plugin does nothing on startup and ignores player invocations. It requires port **25576**, world **`world`**, and air at **(4, 100, 4)** and **(5, 100, 4)**. It temporarily uses the second position as a stone anchor, then restores both positions; the anchor makes `cave_air` and `void_air` observable because Minecraft treats entirely empty sections as ordinary air. Do not use a valuable world just because it happens to match these guards.
|
||||
|
||||
Read `plugins/MaterialRegistryProbe/report.json` in the isolated server directory. A successful report has matching default/property/catalog counts, `anchor_restored: true`, `fatal: null`, and zero failures. Reports contain only material/state identifiers, counts, timings and exception types—no block-entity payloads. The probe caps property cases at 30,000, execution at 35 seconds, and detailed failures at 128. The console command runs on the server thread; the test server should have no players. Remove the test plugin after use.
|
||||
|
||||
The initial Paper 26.2 verification covered 1,196 block defaults, 186 block-entity defaults, and 5,392 distinct property cases in approximately three seconds. These counts are observations, not hardcoded expectations; the probe follows the runtime registry.
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the opt-in isolated Paper regression probe; never install or run it."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
OUTPUT = ROOT / ".runtime" / "material-registry-probe"
|
||||
PLUGIN = """name: MaterialRegistryProbe
|
||||
version: '1.0'
|
||||
main: probe.MaterialRegistryProbe
|
||||
api-version: '26.2'
|
||||
depend: [MinecraftBuilderMCP]
|
||||
commands:
|
||||
registryprobe:
|
||||
description: Run isolated all-registry block snapshot verification
|
||||
"""
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--paper-home", type=Path, default=ROOT / ".runtime" / "server",
|
||||
help="Prepared Paper installation whose libraries supply the compile classpath")
|
||||
parser.add_argument("--java-home", type=Path,
|
||||
default=Path(os.environ.get("MCB_JAVA_HOME", str(Path.home() / ".cache" / "minecraft-builder-mcp" / "jdk-25.0.2"))),
|
||||
help="JDK 25 or newer; defaults to the project's downloaded JDK")
|
||||
args = parser.parse_args()
|
||||
libraries = sorted((args.paper_home / "libraries").rglob("*.jar"))
|
||||
if not libraries:
|
||||
parser.error("No Paper dependency jars found; prepare the local development server first")
|
||||
javac, jar = args.java_home / "bin" / "javac", args.java_home / "bin" / "jar"
|
||||
if not javac.is_file() or not jar.is_file():
|
||||
parser.error("JDK javac/jar unavailable; supply --java-home or MCB_JAVA_HOME")
|
||||
classes = OUTPUT / "classes"
|
||||
if classes.exists():
|
||||
shutil.rmtree(classes)
|
||||
classes.mkdir(parents=True)
|
||||
subprocess.run([str(javac), "--release", "25", "-cp", os.pathsep.join(str(p.resolve()) for p in libraries),
|
||||
"-d", str(classes), str(Path(__file__).with_name("MaterialRegistryProbe.java"))], check=True)
|
||||
(classes / "plugin.yml").write_text(PLUGIN, encoding="utf-8")
|
||||
output = OUTPUT / "material-registry-probe.jar"
|
||||
subprocess.run([str(jar), "--create", "--file", str(output), "-C", str(classes), "."], check=True)
|
||||
print(output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,177 @@
|
||||
"""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))
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,423 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,514 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,19 @@
|
||||
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));}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
# 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`.
|
||||
@@ -0,0 +1,63 @@
|
||||
"""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))
|
||||
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Offline heightmap previews and bounded, resumable terrain batches. Uses the normal checked RPC editor."""
|
||||
import argparse
|
||||
import fcntl
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class RpcError(RuntimeError):
|
||||
def __init__(self, code, message):
|
||||
super().__init__(f'{code}: {message}')
|
||||
self.code = code
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
raise RpcError('redirect_rejected', 'Backend redirects are not permitted')
|
||||
|
||||
|
||||
class Backend:
|
||||
def __init__(self, config, console=False):
|
||||
text = config.read_text()
|
||||
self.token = launcher.scalar(text, 'agent-token')
|
||||
self.scope = {'player_id': 'console' if console else launcher.scalar(text, 'owner-uuid'),
|
||||
'project_id': launcher.scalar(text, 'project-id')}
|
||||
port = int(launcher.scalar(text, 'http-port'))
|
||||
if not 1 <= port <= 65535 or not self.token or not self.scope['player_id']:
|
||||
raise ValueError('Invalid backend configuration or unbound owner')
|
||||
self.url = f'http://127.0.0.1:{port}/v1/rpc'
|
||||
self.opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect())
|
||||
|
||||
def call(self, method, **params):
|
||||
body = json.dumps({'method': method, 'params': {**params, **self.scope}, 'requestId': str(uuid.uuid4())}).encode()
|
||||
for _ in range(100):
|
||||
request = urllib.request.Request(self.url, data=body, headers={'Content-Type': 'application/json', 'Authorization': 'Bearer ' + self.token})
|
||||
try:
|
||||
with self.opener.open(request, timeout=30) as response:
|
||||
data = json.load(response)
|
||||
except urllib.error.HTTPError as error:
|
||||
data = json.load(error)
|
||||
if data.get('ok'):
|
||||
return data['result']
|
||||
failure = data.get('error', {})
|
||||
if failure.get('code') == 'busy':
|
||||
time.sleep(.1)
|
||||
continue
|
||||
raise RpcError(failure.get('code', 'invalid_response'), failure.get('message', 'Backend rejected request').replace(self.token, '[REDACTED]'))
|
||||
raise RpcError('busy', 'Backend did not become ready')
|
||||
|
||||
|
||||
def save(path, value):
|
||||
temporary = path.with_suffix(path.suffix + '.tmp')
|
||||
with temporary.open('w') as f:
|
||||
json.dump(value, f, indent=2)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
temporary.replace(path)
|
||||
directory = os.open(path.parent, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(directory)
|
||||
finally:
|
||||
os.close(directory)
|
||||
|
||||
|
||||
def finish(backend, operation):
|
||||
deadline = time.monotonic() + 180
|
||||
while time.monotonic() < deadline:
|
||||
result = backend.call('operation_status', operation_id=operation)
|
||||
if result['status'] in ('applied', 'conflict', 'failed', 'cancelled', 'recovery_required'):
|
||||
return result
|
||||
time.sleep(.15)
|
||||
raise RuntimeError(f'Operation still running: {operation}; resume with the same manifest')
|
||||
|
||||
|
||||
def apply_plan(backend, entry, manifest, path):
|
||||
# Persist plan and stable key BEFORE transmission. Unknown outcomes reuse this exact request.
|
||||
entry.setdefault('idempotency_key', 'terrain-' + str(uuid.uuid4()))
|
||||
save(path, manifest)
|
||||
if 'operation_id' not in entry:
|
||||
result = backend.call('build_apply', plan_id=entry['plan_id'], plan_hash=entry['plan_hash'], idempotency_key=entry['idempotency_key'])
|
||||
entry['operation_id'] = result['operation_id']
|
||||
save(path, manifest)
|
||||
result = finish(backend, entry['operation_id'])
|
||||
entry['status'] = result['status']
|
||||
entry['written'] = result['written']
|
||||
save(path, manifest)
|
||||
if result['status'] != 'applied':
|
||||
raise RuntimeError(f"Stopped on {result['status']}: {entry['operation_id']}. Inspect conflicts; no new plan or blind retry was created.")
|
||||
|
||||
|
||||
def run_batch(args):
|
||||
backend = Backend(args.config, args.console)
|
||||
context = backend.call('project_context')
|
||||
scope = {key: context[key] for key in ('project_id', 'world_id', 'world_epoch')}
|
||||
path = args.manifest.resolve()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.with_suffix(path.suffix + '.lock').open('a') as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
manifest = json.loads(path.read_text()) if path.exists() else None
|
||||
if manifest and manifest['scope'] != scope:
|
||||
raise RuntimeError('Manifest belongs to another project/world/epoch')
|
||||
if args.command == 'undo':
|
||||
if manifest is None:
|
||||
raise RuntimeError('Manifest not found')
|
||||
# Resolve an apply whose response was lost BEFORE assuming there is nothing to undo.
|
||||
for entry in reversed(manifest['tiles']):
|
||||
if 'idempotency_key' in entry and 'operation_id' not in entry:
|
||||
raise RuntimeError('Uncertain apply response. Resume the original apply with this manifest to resolve its stable key before undo; undo will not start an unconfirmed write.')
|
||||
if 'operation_id' not in entry:
|
||||
continue
|
||||
result = finish(backend, entry['operation_id'])
|
||||
if result['status'] == 'recovery_required':
|
||||
raise RuntimeError('Interrupted server operation requires recovery review first')
|
||||
if not result['written']:
|
||||
continue
|
||||
if 'undo' not in entry:
|
||||
entry['undo'] = backend.call('operation_undo_prepare', operation_id=entry['operation_id'])
|
||||
save(path, manifest)
|
||||
apply_plan(backend, entry['undo'], manifest, path)
|
||||
manifest['undone'] = True; save(path, manifest)
|
||||
print(json.dumps({'status': 'undone', 'manifest': str(path)}))
|
||||
return
|
||||
recipe = json.loads(args.recipe.read_text())
|
||||
preview = backend.call('terrain_preview', recipe=recipe, resolution=64)
|
||||
terrain_id = preview['terrain_id']
|
||||
if manifest:
|
||||
if manifest['terrain_id'] != terrain_id or manifest['start'] != args.start or manifest['count'] != args.count or manifest['tile_budget'] != preview['tile_budget']:
|
||||
raise RuntimeError('Recipe or tile range differs from saved batch')
|
||||
if manifest.get('undone') or any('undo' in entry for entry in manifest['tiles']):
|
||||
raise RuntimeError('Batch has begun undo; finish undo and use a NEW manifest for new work')
|
||||
else:
|
||||
if args.start < 0 or not 1 <= args.count <= 64 or args.start + args.count > preview['tile_count']:
|
||||
raise RuntimeError('Choose 1..64 existing tiles per batch')
|
||||
manifest = {'version': 1, 'scope': scope, 'terrain_id': terrain_id, 'recipe': recipe,
|
||||
'start': args.start, 'count': args.count, 'tile_budget': preview['tile_budget'], 'tiles': []}
|
||||
save(path, manifest)
|
||||
# The entire requested envelope must fit; never silently clip to an unrelated current project.
|
||||
for axis in ('x', 'y', 'z'):
|
||||
if recipe['min'][axis] < context['region']['min'][axis] or recipe['max'][axis] > context['region']['max'][axis]:
|
||||
raise RuntimeError('Recipe exceeds selected project area; choose a dedicated terrain site first')
|
||||
for index in range(args.start, args.start + args.count):
|
||||
entry = next((e for e in manifest['tiles'] if e['tile_index'] == index), None)
|
||||
if entry is None:
|
||||
entry = backend.call('terrain_prepare', terrain_id=terrain_id, tile_index=index)
|
||||
manifest['tiles'].append(entry)
|
||||
save(path, manifest)
|
||||
if entry.get('status') == 'empty' or entry.get('changed_blocks') == 0:
|
||||
continue
|
||||
if entry.get('status') in ('conflict', 'cancelled', 'failed', 'recovery_required'):
|
||||
raise RuntimeError('Batch stopped previously; inspect or undo it instead of forcing through')
|
||||
apply_plan(backend, entry, manifest, path)
|
||||
print(json.dumps({'tile': index, 'status': entry['status'], 'written': entry['written']}), flush=True)
|
||||
print(json.dumps({'status': 'completed', 'manifest': str(path), 'tiles': len(manifest['tiles'])}))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
sub = parser.add_subparsers(dest='command', required=True)
|
||||
preview = sub.add_parser('preview', help='Render the exact height field OFFLINE; no server or world mutation')
|
||||
preview.add_argument('recipe', type=Path)
|
||||
preview.add_argument('--output', type=Path, required=True)
|
||||
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('--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':
|
||||
p.add_argument('recipe', type=Path)
|
||||
p.add_argument('--start', type=int, required=True)
|
||||
p.add_argument('--count', type=int, required=True)
|
||||
args = parser.parse_args()
|
||||
if args.command == 'preview':
|
||||
java = Path(os.environ.get('MCB_JAVA_HOME', str(Path.home() / '.cache/minecraft-builder-mcp/jdk-25.0.2'))) / 'bin/java'
|
||||
jar = ROOT / 'paper-plugin/target/paper-plugin-0.1.0-SNAPSHOT.jar'
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
subprocess.run([str(java), '-Djava.awt.headless=true', '-cp', str(jar), 'io.github.minecraftbuilder.paper.TerrainPreview', str(args.recipe.resolve()), str(args.output.resolve()), str(args.output.with_suffix('.json').resolve())], check=True)
|
||||
print(args.output)
|
||||
else:
|
||||
run_batch(args)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Opt-in live integration checks against the isolated material-integration Paper world.
|
||||
|
||||
Requires .runtime/material-test-server/test-access.json provisioned by the operator.
|
||||
Never targets the lobby: both scope and project identity are checked before mutation.
|
||||
Run phase 'before-restart', restart the isolated server, then run 'after-restart'.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import socket
|
||||
import struct
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
RUNTIME = ROOT / '.runtime/material-test-server'
|
||||
|
||||
|
||||
class TestServer:
|
||||
def __init__(self):
|
||||
self.auth = json.loads((RUNTIME / 'test-access.json').read_text())
|
||||
context = self.rpc('project_context')
|
||||
assert context['project_id'] == 'material-integration', 'Refusing a non-test project'
|
||||
assert context['region']['max']['x'] == 511, 'Unexpected test region'
|
||||
|
||||
def rpc(self, method, **params):
|
||||
body = json.dumps({'method': method, 'params': dict(params, player_id='console', project_id='material-integration')}).encode()
|
||||
request = urllib.request.Request('http://127.0.0.1:18765/v1/rpc', data=body,
|
||||
headers={'Authorization': 'Bearer ' + self.auth['agent'], 'Content-Type': 'application/json'})
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=25) as response:
|
||||
value = json.load(response)
|
||||
except urllib.error.HTTPError as error:
|
||||
value = json.load(error)
|
||||
if not value['ok']:
|
||||
raise RuntimeError(json.dumps(value['error']))
|
||||
return value['result']
|
||||
|
||||
def rcon(self, command):
|
||||
def receive(stream):
|
||||
def exact(count):
|
||||
result = b''
|
||||
while len(result) < count:
|
||||
piece = stream.recv(count-len(result))
|
||||
if not piece:
|
||||
raise EOFError('RCON disconnected')
|
||||
result += piece
|
||||
return result
|
||||
length, = struct.unpack('<i', exact(4))
|
||||
if not 10 <= length <= 1048576:
|
||||
raise ValueError('Invalid RCON packet size')
|
||||
packet = exact(length)
|
||||
return struct.unpack('<ii', packet[:8]), packet[8:-2].decode()
|
||||
def send(stream, request_id, kind, text):
|
||||
packet = struct.pack('<ii', request_id, kind) + text.encode() + b'\0\0'
|
||||
stream.sendall(struct.pack('<i', len(packet)) + packet)
|
||||
with socket.create_connection(('127.0.0.1', 25586), timeout=10) as stream:
|
||||
send(stream, 1, 3, self.auth['rcon'])
|
||||
while True:
|
||||
(request_id, kind), _ = receive(stream)
|
||||
assert request_id != -1, 'RCON authentication failed'
|
||||
if kind == 2:
|
||||
break
|
||||
send(stream, 2, 2, command)
|
||||
while True:
|
||||
(request_id, _), text = receive(stream)
|
||||
if request_id == 2:
|
||||
return text
|
||||
|
||||
def block(self, x, y, z):
|
||||
pos = dict(x=x, y=y, z=z)
|
||||
return self.rpc('region_inspect', min=pos, max=pos, detail='blocks')['blocks'][0]
|
||||
|
||||
def prepare(self, targets, expected=None):
|
||||
operations = [dict(type='box', min=pos, max=pos, block=state) for pos, state in targets]
|
||||
params = {'recipe': dict(version=1, operations=operations)}
|
||||
if expected is not None:
|
||||
params['expected_blocks'] = expected
|
||||
return self.rpc('build_prepare', **params)
|
||||
|
||||
def apply(self, plan, expected_status='applied'):
|
||||
result = self.rpc('build_apply', plan_id=plan['plan_id'], plan_hash=plan['plan_hash'], idempotency_key=str(uuid.uuid4()))
|
||||
deadline = time.monotonic()+40
|
||||
while result['status'] in ('queued', 'applying'):
|
||||
assert time.monotonic() < deadline, 'Operation did not finish'
|
||||
time.sleep(.08)
|
||||
result = self.rpc('operation_status', operation_id=result['operation_id'])
|
||||
assert result['status'] == expected_status, result
|
||||
time.sleep(.12) # allow the terminal receipt to flush before starting another operation
|
||||
return result
|
||||
|
||||
def undo(self, operation_id):
|
||||
return self.apply(self.rpc('operation_undo_prepare', operation_id=operation_id))
|
||||
|
||||
|
||||
def expect_error(action, code):
|
||||
try:
|
||||
action()
|
||||
except RuntimeError as error:
|
||||
assert code in str(error), str(error)
|
||||
else:
|
||||
raise AssertionError('Expected ' + code)
|
||||
|
||||
|
||||
def before_restart(server):
|
||||
context = server.rpc('project_context')
|
||||
assert 'supported_materials' not in context
|
||||
assert context['material_catalog']['blocks'] > 1000
|
||||
assert len(json.dumps(context['material_catalog'])) < 256
|
||||
first = server.rpc('material_search', query='trapdoor')
|
||||
second = server.rpc('material_search', query='trapdoor', cursor=first['next_cursor'])
|
||||
ids = [e['id'] for e in first['results']+second['results']]
|
||||
assert len(first['results']) == 16 and len(ids) == len(set(ids)) == first['total']
|
||||
expect_error(lambda: server.rpc('material_search', query='stairs', cursor=first['next_cursor']), 'invalid_cursor')
|
||||
expect_error(lambda: server.rpc('material_search', limit=33), 'invalid_material_query')
|
||||
descriptions = [server.rpc('material_describe', id=material) for material in (
|
||||
'minecraft:cherry_trapdoor', 'minecraft:water', 'minecraft:oak_wall_sign',
|
||||
'minecraft:redstone_wire', 'minecraft:wheat', 'minecraft:decorated_pot')]
|
||||
assert set(descriptions[0]['properties']['open']) == {'true', 'false'}
|
||||
assert len(descriptions[1]['properties']['level']) == 16
|
||||
assert server.rpc('material_describe', id='minecraft:diamond_sword')['placeable'] is False
|
||||
expect_error(lambda: server.rpc('material_describe', id='minecraft:not_a_real_material'), 'invalid_material')
|
||||
server.rcon('forceload add 0 0 79 79')
|
||||
server.rcon('gamerule minecraft:random_tick_speed 0')
|
||||
server.rcon('fill 28 99 28 60 99 44 minecraft:stone')
|
||||
|
||||
# Newly available ordinary states, paired door halves, water and waterlogging.
|
||||
samples = [
|
||||
((32,100,32),'minecraft:cherry_trapdoor[facing=north,half=bottom,open=true,powered=false,waterlogged=true]'),
|
||||
((34,100,32),'minecraft:green_carpet'),
|
||||
((36,100,32),'minecraft:potted_oxeye_daisy'),
|
||||
((38,100,32),'minecraft:white_candle[candles=3,lit=true,waterlogged=false]'),
|
||||
((40,100,32),'minecraft:water[level=0]'),
|
||||
((42,100,32),'minecraft:oak_door[facing=north,half=lower,hinge=left,open=false,powered=false]'),
|
||||
((42,101,32),'minecraft:oak_door[facing=north,half=upper,hinge=left,open=false,powered=false]'),
|
||||
]
|
||||
targets = [(dict(zip(('x','y','z'),pos)),state) for pos,state in samples]
|
||||
baseline = [server.block(*pos) for pos,_ in samples]
|
||||
result = server.apply(server.prepare(targets, baseline))
|
||||
for (pos, state) in targets:
|
||||
assert server.block(**pos)['state'] == state
|
||||
server.undo(result['operation_id'])
|
||||
assert [server.block(*pos) for pos,_ in samples] == baseline
|
||||
|
||||
# Fixture data is known test content; private snapshots must stay out of model responses.
|
||||
server.rcon('setblock 32 100 36 minecraft:chest[facing=north]')
|
||||
server.rcon('data merge block 32 100 36 {Items:[{Slot:0b,id:"minecraft:diamond",count:7}]}')
|
||||
server.rcon('setblock 36 100 36 minecraft:oak_sign')
|
||||
server.rcon('data merge block 36 100 36 {front_text:{messages:["MCP snapshot test","","",""]}}')
|
||||
server.rcon('setblock 40 100 36 minecraft:decorated_pot')
|
||||
chest = server.block(32,100,36)
|
||||
sign = server.block(36,100,36)
|
||||
pot = server.block(40,100,36)
|
||||
assert all(len(v['snapshot_id']) == 64 for v in (chest,sign,pot))
|
||||
assert 'minecraft:diamond' in server.rcon('data get block 32 100 36 Items')
|
||||
assert 'MCP snapshot test' in server.rcon('data get block 36 100 36 front_text')
|
||||
assert 'MCP snapshot test' not in json.dumps(sign)
|
||||
expect_error(lambda:server.prepare([(chest['pos'],'minecraft:stone')], [dict(pos=chest['pos'],state=chest['state'])]), 'snapshot_id')
|
||||
|
||||
rotate = server.apply(server.prepare([(chest['pos'],chest['state'].replace('facing=north','facing=east'))],[chest]))
|
||||
assert 'minecraft:diamond' in server.rcon('data get block 32 100 36 Items')
|
||||
server.undo(rotate['operation_id'])
|
||||
assert server.block(32,100,36) == chest
|
||||
|
||||
stale = server.prepare([(chest['pos'],'minecraft:stone')],[chest])
|
||||
server.rcon('data modify block 32 100 36 Items[0].count set value 9')
|
||||
changed = server.block(32,100,36)
|
||||
assert changed['snapshot_id'] != chest['snapshot_id']
|
||||
expect_error(lambda:server.prepare([(chest['pos'],'minecraft:stone')],[chest]), 'stale_snapshot')
|
||||
conflict = server.apply(stale, 'conflict')
|
||||
assert 'minecraft:diamond' not in json.dumps(conflict)
|
||||
assert '\u0000mcb-block' not in json.dumps(conflict)
|
||||
assert conflict['conflicts'][0]['current_snapshot_id'] == changed['snapshot_id']
|
||||
server.rcon('data modify block 32 100 36 Items[0].count set value 7')
|
||||
assert server.block(32,100,36) == chest
|
||||
|
||||
# Block-entity export must fail rather than silently strip contents.
|
||||
expect_error(lambda:server.rpc('schematic_export',name='reject-tile',min=chest['pos'],max=chest['pos']), 'unsupported_block_entity')
|
||||
trap_pos=dict(x=44,y=100,z=36)
|
||||
trap='minecraft:cherry_trapdoor[facing=north,half=bottom,open=true,powered=false,waterlogged=true]'
|
||||
placed=server.apply(server.prepare([(trap_pos,trap)]))
|
||||
asset=server.rpc('schematic_export',name='new-material-rotation',min=trap_pos,max=trap_pos)
|
||||
target=dict(x=46,y=100,z=36)
|
||||
imported=server.apply(server.rpc('schematic_import_prepare',asset_id=asset['assetId'],target=target,rotation=90))
|
||||
assert 'facing=east' in server.block(**target)['state']
|
||||
server.undo(imported['operation_id']);server.undo(placed['operation_id'])
|
||||
|
||||
before = [chest, sign, pot]
|
||||
replaced = server.apply(server.prepare([(v['pos'],'minecraft:stone') for v in before],before))
|
||||
item_check=server.rcon('execute if entity @e[type=minecraft:item,x=28,y=98,z=32,dx=16,dy=6,dz=8] run say UNEXPECTED_ITEM_DROP')
|
||||
assert 'UNEXPECTED_ITEM_DROP' not in item_check
|
||||
receipt={'catalog':context['material_catalog'],'catalog_summary_bytes':len(json.dumps(context['material_catalog'],separators=(',',':')).encode()),'search_page_bytes':len(json.dumps(first).encode()),'description_bytes':[len(json.dumps(v).encode()) for v in descriptions],
|
||||
'checks':['bounded search/pagination','property domains','item-only distinction','new blocks and fluids apply/undo','paired doors','private block-entity digests','same-material inventory preservation','manual-content stale snapshot and apply conflict','no inventory drops','schematic new-material native rotation','schematic block-entity export rejection'],
|
||||
'restart_operation_id':replaced['operation_id'],'before_restart_blocks':before,'phase':'awaiting_restart'}
|
||||
(RUNTIME/'live-receipt.json').write_text(json.dumps(receipt,indent=2)+'\n')
|
||||
server.rcon('save-all flush')
|
||||
print(json.dumps({k:v for k,v in receipt.items() if k not in ('before_restart_blocks','restart_operation_id')},indent=2),flush=True)
|
||||
|
||||
|
||||
def after_restart(server):
|
||||
receipt=json.loads((RUNTIME/'live-receipt.json').read_text())
|
||||
server.rcon('forceload add 0 0 79 79')
|
||||
server.undo(receipt['restart_operation_id'])
|
||||
for expected in receipt['before_restart_blocks']:
|
||||
assert server.block(**expected['pos']) == expected
|
||||
assert 'minecraft:diamond' in server.rcon('data get block 32 100 36 Items')
|
||||
assert 'MCP snapshot test' in server.rcon('data get block 36 100 36 front_text')
|
||||
receipt['checks'].append('complete chest/sign/pot undo after Paper restart')
|
||||
receipt['phase']='complete'
|
||||
(RUNTIME/'live-receipt.json').write_text(json.dumps(receipt,indent=2)+'\n')
|
||||
print('Complete: chest contents, sign text and decorated-pot snapshot restored exactly after restart.',flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('phase',choices=('before-restart','after-restart'))
|
||||
args=parser.parse_args()
|
||||
server=TestServer()
|
||||
(before_restart if args.phase=='before-restart' else after_restart)(server)
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Read-only survey completeness, cache isolation and path-height regression tests."""
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
_spec = importlib.util.spec_from_file_location('foundation_survey', Path(__file__).with_name('foundation-survey.py'))
|
||||
survey = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(survey)
|
||||
SCOPE = {'project_id': 'project', 'world_id': 'world', 'world_epoch': 'epoch'}
|
||||
|
||||
|
||||
class Backend:
|
||||
def __init__(self, states=None):
|
||||
self.states = states or {}
|
||||
self.calls = []
|
||||
self.mutate = None
|
||||
self.fail_call = None
|
||||
self.context = {**SCOPE, 'region': {'min': {'x': -64, 'y': -64, 'z': -64},
|
||||
'max': {'x': 63, 'y': 127, 'z': 63}}}
|
||||
|
||||
def call(self, method, **params):
|
||||
self.calls.append(method)
|
||||
if method == 'project_context':
|
||||
return self.context
|
||||
if method != 'region_inspect':
|
||||
raise AssertionError('Only read-only RPC is allowed')
|
||||
if self.fail_call == self.calls.count('region_inspect'):
|
||||
raise RuntimeError('chunk_not_loaded')
|
||||
if survey.volume(params) > 4096:
|
||||
raise AssertionError('Unbounded inspection')
|
||||
lo, hi = params['min'], params['max']
|
||||
result = {'world_epoch': self.context['world_epoch'], 'truncated': False,
|
||||
'blocks': [{'pos': {'x': x, 'y': y, 'z': z}, 'state': self.states.get((x, y, z), 'minecraft:air')}
|
||||
for x in range(lo['x'], hi['x'] + 1) for y in range(lo['y'], hi['y'] + 1)
|
||||
for z in range(lo['z'], hi['z'] + 1)]}
|
||||
if self.mutate:
|
||||
self.mutate(result)
|
||||
return result
|
||||
|
||||
|
||||
def box(lo=(-2, 64, -2), hi=(2, 68, 2)):
|
||||
return survey.box_cells(dict(zip(survey.AXES, lo)), dict(zip(survey.AXES, hi)))
|
||||
|
||||
|
||||
class SurveyTests(unittest.TestCase):
|
||||
def capture(self, backend=None, cells=None):
|
||||
backend = backend or Backend()
|
||||
cells = cells or box()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'survey.json.gz'
|
||||
result = survey.scan(backend, cells, path)
|
||||
loaded = survey.load_snapshot(path, SCOPE)
|
||||
self.assertEqual(loaded.document, result.document)
|
||||
return loaded
|
||||
|
||||
def test_negative_cell_partition_and_exact_states(self):
|
||||
states = {(-2, 64, -2): 'minecraft:smooth_stone_slab[type=bottom,waterlogged=false]'}
|
||||
result = self.capture(Backend(states))
|
||||
self.assertEqual(result.state(-2, 64, -2), states[(-2, 64, -2)])
|
||||
self.assertEqual(result.state(2, 68, 2), 'minecraft:air')
|
||||
self.assertEqual(len(result.cells), 4)
|
||||
with self.assertRaises(KeyError):
|
||||
result.state(-3, 64, -2)
|
||||
with self.assertRaises(KeyError):
|
||||
result.state(17, 64, 0)
|
||||
|
||||
def test_columns_do_not_assume_air_in_unread_cells(self):
|
||||
cells = survey.column_cells({'version': 1, 'columns': [
|
||||
{'x': -20, 'z': 1, 'min_y': 64, 'max_y': 68},
|
||||
{'x': 20, 'z': 1, 'min_y': 66, 'max_y': 72}]})
|
||||
result = self.capture(cells=cells)
|
||||
self.assertEqual(result.state(20, 69, 1), 'minecraft:air')
|
||||
with self.assertRaises(KeyError):
|
||||
result.state(0, 69, 1)
|
||||
with self.assertRaises(KeyError):
|
||||
result.state(20, 65, 1)
|
||||
|
||||
def test_rejects_missing_duplicate_truncated_and_wrong_epoch(self):
|
||||
changes = [lambda r: r['blocks'].pop(),
|
||||
lambda r: r['blocks'].append(r['blocks'][0]),
|
||||
lambda r: r.update(truncated=True),
|
||||
lambda r: r.update(world_epoch='different')]
|
||||
for mutate in changes:
|
||||
with self.subTest(mutate=mutate), tempfile.TemporaryDirectory() as directory:
|
||||
backend = Backend()
|
||||
backend.mutate = mutate
|
||||
path = Path(directory) / 'survey.json.gz'
|
||||
with self.assertRaises(RuntimeError):
|
||||
survey.scan(backend, box(), path)
|
||||
self.assertFalse(path.exists())
|
||||
|
||||
def test_out_of_area_fails_before_any_inspection(self):
|
||||
backend = Backend()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with self.assertRaises(ValueError):
|
||||
survey.scan(backend, box((-65, 64, 0), (-60, 68, 0)), Path(directory) / 'survey.json.gz')
|
||||
self.assertEqual(backend.calls, ['project_context'])
|
||||
|
||||
def test_explicit_resume_keeps_readonly_cache_and_checks_scope(self):
|
||||
backend = Backend()
|
||||
backend.fail_call = 2
|
||||
cells = box((-16, 64, 0), (16, 68, 0))
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'survey.json.gz'
|
||||
with self.assertRaisesRegex(RuntimeError, 'chunk_not_loaded'):
|
||||
survey.scan(backend, cells, path)
|
||||
self.assertFalse(path.exists())
|
||||
with self.assertRaisesRegex(ValueError, '--resume'):
|
||||
survey.scan(backend, cells, path)
|
||||
backend.context['world_epoch'] = 'another-epoch'
|
||||
with self.assertRaisesRegex(ValueError, 'scope'):
|
||||
survey.scan(backend, cells, path, resume=True)
|
||||
backend.context['world_epoch'] = 'epoch'
|
||||
backend.fail_call = None
|
||||
result = survey.scan(backend, cells, path, resume=True)
|
||||
self.assertTrue(result.document['resumed_cache'])
|
||||
self.assertEqual(backend.calls.count('region_inspect'), 4)
|
||||
with self.assertRaisesRegex(ValueError, 'fresh path'):
|
||||
survey.scan(backend, cells, path, resume=True)
|
||||
|
||||
def test_corrupt_cache_and_missing_snapshot_indices_are_rejected(self):
|
||||
snapshot = self.capture()
|
||||
snapshot.document['cells'][0]['indices'].pop()
|
||||
with self.assertRaisesRegex(ValueError, 'indices'):
|
||||
survey.Snapshot(snapshot.document, SCOPE)
|
||||
|
||||
def test_foreign_scope_rejected(self):
|
||||
snapshot = self.capture()
|
||||
with self.assertRaisesRegex(ValueError, 'another project'):
|
||||
survey.Snapshot(snapshot.document, {**SCOPE, 'project_id': 'other'})
|
||||
|
||||
def test_walkability_half_slab_fullblock_clearance_and_gradient(self):
|
||||
states = {(0, 64, 0): 'minecraft:stone',
|
||||
(1, 65, 0): 'minecraft:smooth_stone_slab[type=bottom,waterlogged=false]',
|
||||
(2, 65, 0): 'minecraft:stone'}
|
||||
snapshot = self.capture(Backend(states), box((0, 64, 0), (3, 69, 0)))
|
||||
points = [{'x': x, 'z': 0, 'standing_y': feet, 'route': 'main'}
|
||||
for x, feet in [(0, 65), (1, 65.5), (2, 66)]]
|
||||
result = survey.verify_walkable(snapshot, points)
|
||||
self.assertTrue(result['passed'])
|
||||
self.assertEqual(result['checked_route_edges'], 2)
|
||||
points[1]['standing_y'] = 66
|
||||
result = survey.verify_walkable(snapshot, points)
|
||||
self.assertFalse(result['passed'])
|
||||
self.assertIn('missing_support_at_feet', [f['reason'] for f in result['failures']])
|
||||
self.assertIn('route_step_exceeds_half_block', [f['reason'] for f in result['failures']])
|
||||
|
||||
def test_headroom_unknown_stairs_and_absent_observations_fail(self):
|
||||
states = {(0, 64, 0): 'minecraft:stone', (0, 66, 0): 'minecraft:stone',
|
||||
(1, 64, 0): 'minecraft:stone_brick_stairs[facing=north,half=bottom,shape=straight,waterlogged=false]'}
|
||||
snapshot = self.capture(Backend(states), box((0, 64, 0), (1, 68, 0)))
|
||||
result = survey.verify_walkable(snapshot, [{'x': x, 'z': 0, 'standing_y': 65} for x in (0, 1, 2)])
|
||||
self.assertEqual([f['reason'] for f in result['failures']],
|
||||
['blocked_headroom', 'unknown_support_shape', 'unobserved_block'])
|
||||
|
||||
def test_straight_bottom_stair_profile_all_directions(self):
|
||||
sides = {'north': ((.5, .75), (.5, .25)), 'south': ((.5, .25), (.5, .75)),
|
||||
'east': ((.25, .5), (.75, .5)), 'west': ((.75, .5), (.25, .5))}
|
||||
for facing, (low, high) in sides.items():
|
||||
with self.subTest(facing=facing):
|
||||
state = f'minecraft:stone_brick_stairs[facing={facing},half=bottom,shape=straight,waterlogged=false]'
|
||||
snapshot = self.capture(Backend({(0, 64, 0): state}), box((0, 64, 0), (0, 68, 0)))
|
||||
points = [{'x': 0, 'z': 0, 'sub_x': sub[0], 'sub_z': sub[1], 'standing_y': feet, 'route': facing}
|
||||
for sub, feet in [(low, 64.5), (high, 65)]]
|
||||
result = survey.verify_walkable(snapshot, points)
|
||||
self.assertTrue(result['passed'], result)
|
||||
self.assertEqual(result['checked_route_edges'], 1)
|
||||
points[0]['standing_y'] = 65
|
||||
self.assertEqual(survey.verify_walkable(snapshot, points)['failures'][0]['reason'], 'missing_support_at_feet')
|
||||
|
||||
def test_stair_headroom_riser_boundary_and_unsupported_shapes(self):
|
||||
base = 'minecraft:stone_brick_stairs[facing=north,half=bottom,shape=straight,waterlogged=false]'
|
||||
snapshot = self.capture(Backend({(0, 64, 0): base, (0, 66, 0): 'minecraft:stone'}),
|
||||
box((0, 64, 0), (0, 68, 0)))
|
||||
result = survey.verify_walkable(snapshot, [{'x': 0, 'z': 0, 'sub_z': .25, 'standing_y': 65}])
|
||||
self.assertEqual(result['failures'][0]['reason'], 'blocked_headroom')
|
||||
self.assertIsNone(survey.vertical_shape(base, .5, .5))
|
||||
self.assertIsNone(survey.vertical_shape(base.replace('straight', 'inner_left'), .5, .25))
|
||||
self.assertIsNone(survey.vertical_shape(base.replace('half=bottom', 'half=top'), .5, .25))
|
||||
self.assertIsNone(survey.vertical_shape(base.replace('waterlogged=false', 'waterlogged=true'), .5, .25))
|
||||
|
||||
def test_route_subsamples_use_physical_positions_across_blocks(self):
|
||||
state = 'minecraft:stone_brick_stairs[facing=north,half=bottom,shape=straight,waterlogged=false]'
|
||||
snapshot = self.capture(Backend({(0, 64, 0): state, (0, 65, -1): state}),
|
||||
box((0, 64, -1), (0, 70, 0)))
|
||||
points = [{'x': 0, 'z': z, 'sub_z': sub_z, 'standing_y': feet, 'route': 'stair'}
|
||||
for z, sub_z, feet in [(0, .75, 64.5), (0, .25, 65), (-1, .75, 65.5), (-1, .25, 66)]]
|
||||
result = survey.verify_walkable(snapshot, points)
|
||||
self.assertTrue(result['passed'], result)
|
||||
self.assertEqual(result['checked_route_edges'], 3)
|
||||
points[1]['sub_x'] = .6
|
||||
result = survey.verify_walkable(snapshot, points)
|
||||
self.assertIn('non_cardinal_route_step', [f['reason'] for f in result['failures']])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Layout placement safety and resume checks; uses a simulated RPC world, never Minecraft."""
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
spec = importlib.util.spec_from_file_location('layout', Path(__file__).with_name('layout.py'))
|
||||
layout = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(layout)
|
||||
|
||||
SCOPE = {'project_id': 'project', 'world_id': 'world', 'world_epoch': 'epoch'}
|
||||
|
||||
|
||||
def document(blocks):
|
||||
return layout.normalize({'version': 1, 'scope': SCOPE, 'blocks': blocks})
|
||||
|
||||
|
||||
def block(x, y=60, z=0, material='minecraft:lime_concrete', expected='minecraft:air'):
|
||||
return {'x': x, 'y': y, 'z': z, 'block': material, 'expected': expected}
|
||||
|
||||
|
||||
class FakeBackend:
|
||||
def __init__(self):
|
||||
self.world, self.plans, self.operations, self.keys, self.calls = {}, {}, {}, {}, []
|
||||
self.lose_apply = False
|
||||
self.corrupt_after_apply = False
|
||||
self.context = {**SCOPE, 'checked_expected_blocks': True,
|
||||
'region': {'min': {'x': -512, 'y': -64, 'z': -512}, 'max': {'x': 511, 'y': 319, 'z': 511}}}
|
||||
|
||||
def state(self, at):
|
||||
return self.world.get(at, 'minecraft:air')
|
||||
|
||||
def new_plan(self, changes, undo_of=None):
|
||||
plan_id = 'p' + str(len(self.plans))
|
||||
self.plans[plan_id] = {'changes': changes, 'undo_of': undo_of}
|
||||
return {'plan_id': plan_id, 'plan_hash': 'hash-' + plan_id,
|
||||
'changed_blocks': sum(e != d for _, e, d in changes)}
|
||||
|
||||
def call(self, method, **params):
|
||||
self.calls.append((method, params))
|
||||
if method == 'project_context':
|
||||
return self.context
|
||||
if method == 'region_inspect':
|
||||
lo, hi = params['min'], params['max']
|
||||
self.assert_bounded(lo, hi)
|
||||
return {'world_epoch': SCOPE['world_epoch'], 'truncated': False,
|
||||
'blocks': [{'pos': {'x': x, 'y': y, 'z': z}, 'state': self.state((x, y, z))}
|
||||
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)]}
|
||||
if method == 'build_prepare':
|
||||
desired = {}
|
||||
for op in params['recipe']['operations']:
|
||||
for y in range(op['min']['y'], op['max']['y'] + 1):
|
||||
for z in range(op['min']['z'], op['max']['z'] + 1):
|
||||
for x in range(op['min']['x'], op['max']['x'] + 1):
|
||||
desired[(x, y, z)] = op['block']
|
||||
expected = {layout.position(e['pos']): e['state'] for e in params['expected_blocks']}
|
||||
if desired.keys() != expected.keys():
|
||||
raise AssertionError('Expected states must cover exactly the desired mask')
|
||||
if any(self.state(at) != state for at, state in expected.items()):
|
||||
raise RuntimeError('stale_snapshot')
|
||||
return self.new_plan([(at, expected[at], state) for at, state in desired.items()])
|
||||
if method == 'build_apply':
|
||||
key = params['idempotency_key']
|
||||
if key in self.keys:
|
||||
return self.operations[self.keys[key]]
|
||||
operation = 'o' + str(len(self.operations))
|
||||
plan = self.plans[params['plan_id']]
|
||||
written, status = 0, 'applied'
|
||||
receipts = []
|
||||
for at, expected, desired in plan['changes']:
|
||||
if self.state(at) != expected:
|
||||
status = 'conflict'
|
||||
break
|
||||
if expected != desired:
|
||||
self.world[at] = desired
|
||||
receipts.append((at, expected, desired))
|
||||
written += 1
|
||||
self.operations[operation] = {'operation_id': operation, 'status': status, 'written': written,
|
||||
'plan_id': params['plan_id'], 'receipts': receipts}
|
||||
self.keys[key] = operation
|
||||
if self.corrupt_after_apply:
|
||||
self.world[plan['changes'][0][0]] = 'minecraft:gold_block'
|
||||
if self.lose_apply:
|
||||
self.lose_apply = False
|
||||
raise TimeoutError('Simulated response lost after the world changed')
|
||||
return self.operations[operation]
|
||||
if method == 'operation_status':
|
||||
return self.operations[params['operation_id']]
|
||||
if method == 'operation_undo_prepare':
|
||||
source = self.operations[params['operation_id']]
|
||||
changes = [(at, desired, expected) for at, expected, desired in source['receipts']]
|
||||
if any(self.state(at) != expected for at, expected, _ in changes):
|
||||
raise RuntimeError('Manual edit conflicts with guarded undo')
|
||||
return self.new_plan(changes, params['operation_id'])
|
||||
raise AssertionError(f'Unexpected RPC {method}')
|
||||
|
||||
@staticmethod
|
||||
def assert_bounded(lo, hi):
|
||||
if layout.volume({'min': lo, 'max': hi}) > 4096:
|
||||
raise AssertionError('Unbounded inspection')
|
||||
|
||||
|
||||
class LayoutTests(unittest.TestCase):
|
||||
def test_dense_layout_preserves_every_position_and_respects_all_budgets(self):
|
||||
value = document([block(x, y, z) for x in range(-20, 20) for y in range(63, 67) for z in range(-20, 20)])
|
||||
batches = layout.make_batches(value['blocks'])
|
||||
actual = [b for batch in batches for b in batch['blocks']]
|
||||
self.assertEqual(sorted(actual, key=layout.position), value['blocks'])
|
||||
self.assertGreater(len(batches), 1)
|
||||
for batch in batches:
|
||||
self.assertLessEqual(len(batch['blocks']), 4096)
|
||||
self.assertLessEqual(len(batch['recipe']['operations']), 256)
|
||||
self.assertLessEqual(len(layout.read_groups(batch['blocks'])), 32)
|
||||
for group in layout.read_groups(batch['blocks']):
|
||||
self.assertLessEqual(layout.volume(layout.bounds(group)), 4096)
|
||||
self.assertEqual(layout.make_batches(list(reversed(value['blocks']))), batches)
|
||||
|
||||
def test_compression_does_not_fill_holes_or_merge_materials(self):
|
||||
value = document([block(0), block(1), block(3), block(4, material='minecraft:red_concrete')])
|
||||
operations = layout.make_batches(value['blocks'])[0]['recipe']['operations']
|
||||
self.assertEqual(len(operations), 3)
|
||||
lime = [o for o in operations if o['block'] == 'minecraft:lime_concrete']
|
||||
self.assertEqual([(o['min']['x'], o['max']['x']) for o in lime], [(0, 1), (3, 3)])
|
||||
|
||||
def test_duplicate_and_invalid_coordinates_are_rejected(self):
|
||||
with self.assertRaisesRegex(ValueError, 'Duplicate'):
|
||||
document([block(0), block(0)])
|
||||
with self.assertRaisesRegex(ValueError, '32-bit'):
|
||||
document([block(True)])
|
||||
|
||||
def test_manual_edit_stops_before_prepare(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
backend = FakeBackend()
|
||||
backend.world[(0, 60, 0)] = 'minecraft:gold_block'
|
||||
with self.assertRaisesRegex(RuntimeError, 'Block mismatch'):
|
||||
layout.apply_layout(backend, document([block(0)]), Path(directory) / 'ledger.json')
|
||||
self.assertNotIn('build_prepare', [method for method, _ in backend.calls])
|
||||
self.assertEqual(backend.state((0, 60, 0)), 'minecraft:gold_block')
|
||||
|
||||
def test_expected_surface_replacement_is_sent_atomically_and_verified(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
backend = FakeBackend()
|
||||
backend.world[(0, 60, 0)] = 'minecraft:grass_block'
|
||||
path = Path(directory) / 'ledger.json'
|
||||
result = layout.apply_layout(backend, document([block(0, expected='minecraft:grass_block')]), path, progress=lambda _: None)
|
||||
self.assertEqual(result['status'], 'completed')
|
||||
prepared = next(params for method, params in backend.calls if method == 'build_prepare')
|
||||
self.assertEqual(prepared['expected_blocks'], [{'pos': {'x': 0, 'y': 60, 'z': 0}, 'state': 'minecraft:grass_block'}])
|
||||
self.assertIn('verified_at', json.loads(path.read_text())['batches'][0])
|
||||
|
||||
def test_lost_apply_response_resumes_same_key_without_failing_old_expected_read(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
backend, path = FakeBackend(), Path(directory) / 'ledger.json'
|
||||
backend.lose_apply = True
|
||||
value = document([block(0)])
|
||||
with self.assertRaises(TimeoutError):
|
||||
layout.apply_layout(backend, value, path, progress=lambda _: None)
|
||||
self.assertEqual(backend.state((0, 60, 0)), 'minecraft:lime_concrete')
|
||||
before = json.loads(path.read_text())['batches'][0]
|
||||
self.assertIn('idempotency_key', before)
|
||||
self.assertNotIn('operation_id', before)
|
||||
layout.apply_layout(backend, value, path, progress=lambda _: None)
|
||||
applies = [params for method, params in backend.calls if method == 'build_apply']
|
||||
self.assertEqual(len(applies), 2)
|
||||
self.assertEqual(applies[0], applies[1])
|
||||
self.assertEqual(len(backend.plans), 1)
|
||||
self.assertTrue(json.loads(path.read_text())['completed'])
|
||||
|
||||
def test_existing_conflict_is_not_reprepared(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
backend, path = FakeBackend(), Path(directory) / 'ledger.json'
|
||||
value = document([block(0)])
|
||||
manifest = layout.load_or_create(path, value, layout.make_batches(value['blocks']))
|
||||
manifest['batches'][0]['status'] = 'conflict'
|
||||
layout.terrain.save(path, manifest)
|
||||
with self.assertRaisesRegex(RuntimeError, 'stopped on conflict'):
|
||||
layout.apply_layout(backend, value, path)
|
||||
self.assertEqual([method for method, _ in backend.calls], ['project_context'])
|
||||
|
||||
def test_old_server_scope_and_changed_input_are_rejected_without_write(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
backend, path = FakeBackend(), Path(directory) / 'ledger.json'
|
||||
value = document([block(0)])
|
||||
backend.context['checked_expected_blocks'] = False
|
||||
with self.assertRaisesRegex(RuntimeError, 'atomic checked_expected_blocks'):
|
||||
layout.apply_layout(backend, value, path)
|
||||
backend.context['checked_expected_blocks'] = True
|
||||
backend.context['world_epoch'] = 'other'
|
||||
with self.assertRaisesRegex(RuntimeError, 'another project'):
|
||||
layout.apply_layout(backend, value, path)
|
||||
backend.context['world_epoch'] = 'epoch'
|
||||
layout.load_or_create(path, value, layout.make_batches(value['blocks']))
|
||||
with self.assertRaisesRegex(RuntimeError, 'digest'):
|
||||
layout.apply_layout(backend, document([block(1)]), path)
|
||||
self.assertFalse(backend.plans)
|
||||
|
||||
def test_post_apply_verification_stops_before_next_batch(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
backend, path = FakeBackend(), Path(directory) / 'ledger.json'
|
||||
# More than 32 separate read cells creates several small, cheap batches.
|
||||
value = document([block(x * 16, z=z * 16) for x in range(-4, 5) for z in range(-4, 5)])
|
||||
backend.corrupt_after_apply = True
|
||||
with self.assertRaisesRegex(RuntimeError, 'Block mismatch'):
|
||||
layout.apply_layout(backend, value, path)
|
||||
self.assertEqual(len(backend.plans), 1)
|
||||
saved = json.loads(path.read_text())
|
||||
self.assertNotIn('verified_at', saved['batches'][0])
|
||||
self.assertNotIn('completed', saved)
|
||||
|
||||
def test_undo_is_reverse_order_and_refuses_resume_apply_after_undo(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
backend, path = FakeBackend(), Path(directory) / 'ledger.json'
|
||||
value = document([block(x * 16, z=z * 16) for x in range(-4, 5) for z in range(-4, 5)])
|
||||
layout.apply_layout(backend, value, path, progress=lambda _: None)
|
||||
source_ids = [e['operation_id'] for e in json.loads(path.read_text())['batches']]
|
||||
result = layout.undo_layout(backend, path, progress=lambda _: None)
|
||||
self.assertEqual(result['status'], 'undone')
|
||||
undo_ids = [params['operation_id'] for method, params in backend.calls if method == 'operation_undo_prepare']
|
||||
self.assertEqual(undo_ids, list(reversed(source_ids)))
|
||||
self.assertTrue(all(backend.state(layout.position(b)) == b['expected'] for b in value['blocks']))
|
||||
with self.assertRaisesRegex(RuntimeError, 'begun undo'):
|
||||
layout.apply_layout(backend, value, path)
|
||||
|
||||
def test_undo_does_not_start_an_unknown_apply(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
backend, path = FakeBackend(), Path(directory) / 'ledger.json'
|
||||
backend.lose_apply = True
|
||||
with self.assertRaises(TimeoutError):
|
||||
layout.apply_layout(backend, document([block(0)]), path)
|
||||
calls_before = len(backend.calls)
|
||||
with self.assertRaisesRegex(RuntimeError, 'Uncertain apply'):
|
||||
layout.undo_layout(backend, path)
|
||||
self.assertEqual([m for m, _ in backend.calls[calls_before:]], ['project_context'])
|
||||
|
||||
def test_undo_preserves_later_manual_edits(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
backend, path = FakeBackend(), Path(directory) / 'ledger.json'
|
||||
layout.apply_layout(backend, document([block(0)]), path, progress=lambda _: None)
|
||||
backend.world[(0, 60, 0)] = 'minecraft:gold_block'
|
||||
with self.assertRaisesRegex(RuntimeError, 'Manual edit'):
|
||||
layout.undo_layout(backend, path)
|
||||
self.assertEqual(backend.state((0, 60, 0)), 'minecraft:gold_block')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Regression checks for resumable local batches; no live world needed."""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
spec = importlib.util.spec_from_file_location('terrain', Path(__file__).with_name('terrain.py'))
|
||||
terrain = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(terrain)
|
||||
|
||||
|
||||
class BatchTests(unittest.TestCase):
|
||||
def test_lost_apply_response_reuses_persisted_key(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'batch.json'
|
||||
entry = {'plan_id': 'plan', 'plan_hash': 'hash'}
|
||||
manifest = {'tiles': [entry]}
|
||||
calls = []
|
||||
class Backend:
|
||||
def call(self, method, **params):
|
||||
calls.append((method, params))
|
||||
if len(calls) == 1:
|
||||
raise TimeoutError('lost response')
|
||||
return {'operation_id': 'operation'}
|
||||
backend = Backend()
|
||||
with self.assertRaises(TimeoutError):
|
||||
terrain.apply_plan(backend, entry, manifest, path)
|
||||
saved = terrain.json.loads(path.read_text())
|
||||
self.assertIn('idempotency_key', saved['tiles'][0])
|
||||
with patch.object(terrain, 'finish', return_value={'status': 'applied', 'written': 7}):
|
||||
terrain.apply_plan(backend, saved['tiles'][0], saved, path)
|
||||
self.assertEqual(calls[0], calls[1])
|
||||
self.assertEqual(terrain.json.loads(path.read_text())['tiles'][0]['operation_id'], 'operation')
|
||||
|
||||
def test_conflict_is_persisted_and_reported_not_reprepared(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'batch.json'
|
||||
entry = {'plan_id': 'p', 'plan_hash': 'h', 'operation_id': 'o'}
|
||||
class Backend:
|
||||
def call(self, *args, **kwargs):
|
||||
raise AssertionError('Must not start another apply')
|
||||
with patch.object(terrain, 'finish', return_value={'status': 'conflict', 'written': 3}):
|
||||
with self.assertRaisesRegex(RuntimeError, 'conflict'):
|
||||
terrain.apply_plan(Backend(), entry, {'tiles': [entry]}, path)
|
||||
self.assertEqual(terrain.json.loads(path.read_text())['tiles'][0]['status'], 'conflict')
|
||||
|
||||
def test_undo_never_starts_an_unknown_apply(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'batch.json'
|
||||
scope = {'project_id': 'p', 'world_id': 'w', 'world_epoch': 'e'}
|
||||
terrain.save(path, {'scope': scope, 'tiles': [{'idempotency_key': 'k', 'plan_id': 'p', 'plan_hash': 'h'}]})
|
||||
class Backend:
|
||||
def __init__(self, *args):
|
||||
pass
|
||||
def call(self, method, **params):
|
||||
if method != 'project_context':
|
||||
raise AssertionError('Undo must not initiate a write to discover unknown apply outcome')
|
||||
return scope
|
||||
args = terrain.argparse.Namespace(config=Path('unused'), console=True, manifest=path, command='undo')
|
||||
with patch.object(terrain, 'Backend', Backend):
|
||||
with self.assertRaisesRegex(RuntimeError, 'Uncertain apply'):
|
||||
terrain.run_batch(args)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,289 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,423 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,375 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,389 @@
|
||||
#!/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,384 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user