Initial minecraft-builder-mcp prototype and Gothic hall build
This commit is contained in:
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Install checksum-pinned local tools without changing system Java (Linux x64)."""
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import tarfile
|
||||
import urllib.request
|
||||
|
||||
ROOT = Path(os.environ.get('MCB_TOOL_CACHE', str(Path.home()/'.cache/minecraft-builder-mcp')))
|
||||
TOOLS = [
|
||||
('jdk25.tar.gz', 'https://download.java.net/java/GA/jdk25.0.2/b1e0dfa218384cb9959bdcb897162d4e/10/GPL/openjdk-25.0.2_linux-x64_bin.tar.gz', 'sha256', '555ce0821e4fe175ea50d54518cd6fbece9663c1998de529bc6ce429534457df', 'jdk-25.0.2'),
|
||||
('maven.tar.gz', 'https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.tar.gz', 'sha512', 'bcfe4fe305c962ace56ac7b5fc7a08b87d5abd8b7e89027ab251069faebee516b0ded8961445d6d91ec1985dfe30f8153268843c89aa392733d1a3ec956c9978', 'apache-maven-3.9.11'),
|
||||
]
|
||||
if __name__ == '__main__':
|
||||
if platform.system() != 'Linux' or platform.machine() != 'x86_64':
|
||||
raise SystemExit('Automatic JDK bootstrap supports Linux x64; set JAVA_HOME and use Maven 3.9.11 on your platform.')
|
||||
ROOT.mkdir(parents=True, exist_ok=True)
|
||||
for name, url, algorithm, checksum, directory in TOOLS:
|
||||
archive = ROOT/name
|
||||
if not archive.exists():
|
||||
temporary = archive.with_suffix('.download')
|
||||
urllib.request.urlretrieve(url, temporary)
|
||||
temporary.replace(archive)
|
||||
if hashlib.new(algorithm, archive.read_bytes()).hexdigest() != checksum:
|
||||
raise SystemExit(f'Checksum mismatch: {archive}; remove the corrupt archive and retry.')
|
||||
if not (ROOT/directory).exists():
|
||||
with tarfile.open(archive) as tf:
|
||||
tf.extractall(ROOT, filter='data')
|
||||
print(ROOT/directory)
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the Bridge against the private local development server without displaying secrets."""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def scalar(text, key):
|
||||
"""Read only the top-level scalar fields emitted by the Paper configuration writer."""
|
||||
match = re.search(r'^' + re.escape(key) + r':[ \t]*(.*?)[ \t]*$', text, re.M)
|
||||
if not match:
|
||||
return ''
|
||||
raw = match.group(1).strip()
|
||||
if raw.startswith("'"):
|
||||
quoted = re.fullmatch(r"'((?:[^']|'')*)'[ \t]*(?:#.*)?", raw)
|
||||
if quoted:
|
||||
return quoted.group(1).replace("''", "'")
|
||||
elif raw.startswith('"'):
|
||||
quoted = re.fullmatch(r'("(?:[^"\\]|\\.)*")[ \t]*(?:#.*)?', raw)
|
||||
if quoted:
|
||||
try:
|
||||
return json.loads(quoted.group(1))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
else:
|
||||
return re.split(r'[ \t]+#', raw, maxsplit=1)[0].strip()
|
||||
raise SystemExit(f'Invalid scalar for {key}; use the Paper-generated configuration format.')
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('mode', choices=['doctor', 'chat', 'mcp', 'login', 'status'])
|
||||
parser.add_argument('action', nargs='?', choices=['status'], help='Only for login: check authentication without starting a login flow')
|
||||
parser.add_argument('--status', action='store_true', help='With login: check authentication without starting login')
|
||||
parser.add_argument('--config', type=Path, default=ROOT / '.runtime/server/plugins/MinecraftBuilderMCP/config.yml')
|
||||
parser.add_argument('--console', action='store_true', help='Use console scope only with allow-local-automation in the disposable fixture')
|
||||
args = parser.parse_args()
|
||||
if (args.action or args.status) and args.mode != 'login':
|
||||
parser.error('The status action is accepted only after login; alternatively use mode status.')
|
||||
|
||||
# Login/authentication status needs no Paper config or Paper token. Always use the same
|
||||
# default state directory as the chat daemon, including when called from another cwd.
|
||||
env = dict(os.environ)
|
||||
env.setdefault('MCB_STATE_DIR', str(ROOT / '.runtime/bridge-state'))
|
||||
needs_backend = args.mode in ('chat', 'mcp')
|
||||
if needs_backend and not args.config.is_file():
|
||||
raise SystemExit('Start the Paper plugin once to generate its private config.')
|
||||
if args.mode in ('doctor', 'chat', 'mcp') and args.config.is_file():
|
||||
text = args.config.read_text()
|
||||
actor = 'console' if args.console else scalar(text, 'owner-uuid')
|
||||
if args.mode == 'mcp' and not actor:
|
||||
raise SystemExit('Bind the owner in game with /ai setup first.')
|
||||
port = scalar(text, 'http-port')
|
||||
if not port.isdigit() or not 1 <= int(port) <= 65535:
|
||||
raise SystemExit('Paper http-port must be an integer in 1..65535.')
|
||||
env.update(MCB_BACKEND_URL='http://127.0.0.1:' + port,
|
||||
MCB_TOKEN=scalar(text, 'admin-token'), MCB_AGENT_TOKEN=scalar(text, 'agent-token'),
|
||||
MCB_PLAYER_ID=actor, MCB_PROJECT_ID=scalar(text, 'project-id'))
|
||||
|
||||
target = 'dist/' + ('login' if args.mode in ('login', 'status') else args.mode) + '.js'
|
||||
if not (ROOT / 'bridge' / target).is_file():
|
||||
raise SystemExit('Build the Bridge first: cd bridge && npm ci --ignore-scripts && npm run build')
|
||||
command = ['node', target]
|
||||
if args.mode == 'status' or args.action == 'status' or args.status:
|
||||
command.append('status')
|
||||
return subprocess.call(command, cwd=ROOT / 'bridge', env=env)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Plan/apply/verify the Gothic reference build through checked Paper editor operations."""
|
||||
import argparse
|
||||
from collections import Counter
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
from builds.gothic_hall.scene import build_scene, manifest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
OUTPUT = ROOT / '.runtime/gothic-hall'
|
||||
CONFIG = ROOT / '.runtime/server/plugins/MinecraftBuilderMCP/config.yml'
|
||||
|
||||
def save(path, data):
|
||||
temp = path.with_suffix('.tmp')
|
||||
with temp.open('w') as stream:
|
||||
json.dump(data, stream, indent=2)
|
||||
stream.write('\n'); stream.flush(); os.fsync(stream.fileno())
|
||||
temp.replace(path)
|
||||
|
||||
def digest(data):
|
||||
return hashlib.sha256(json.dumps(data, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
|
||||
|
||||
def point_key(value):
|
||||
return value['x'], value['y'], value['z']
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('action', choices=['plan', 'apply', 'verify'])
|
||||
args = parser.parse_args()
|
||||
OUTPUT.mkdir(exist_ok=True)
|
||||
config = CONFIG.read_text()
|
||||
def scalar(key):
|
||||
return re.search(r'^' + re.escape(key) + r':[ \t]*(.*?)$', config, re.M).group(1).strip().strip("'\"")
|
||||
scope = {'player_id': scalar('owner-uuid'), 'project_id': scalar('project-id')}
|
||||
assert scope['player_id'], 'An online project owner is required'
|
||||
token = scalar('agent-token')
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
def rpc(method, params):
|
||||
for attempt in range(100):
|
||||
data = {'method': method, 'params': {**params, **scope}, 'requestId': str(uuid.uuid4())}
|
||||
req = urllib.request.Request('http://127.0.0.1:' + scalar('http-port') + '/v1/rpc',
|
||||
data=json.dumps(data).encode(), headers={'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json'})
|
||||
try:
|
||||
with opener.open(req, timeout=30) as response: body = json.load(response)
|
||||
except urllib.error.HTTPError as error:
|
||||
body = json.load(error)
|
||||
if body.get('ok'): return body['result']
|
||||
code = body.get('error', {}).get('code', 'unknown')
|
||||
if code == 'busy': time.sleep(.1); continue
|
||||
raise RuntimeError(f'{method}: {code}; no blind overwrite or replay performed')
|
||||
raise RuntimeError('Paper remained busy')
|
||||
|
||||
context = rpc('project_context', {})
|
||||
if args.action == 'plan':
|
||||
voxels = build_scene(); model = manifest(voxels)
|
||||
allowed = set(context['supported_materials'])
|
||||
assert all(block.split('[')[0] in allowed for block in model['palette']), 'Unsupported material in blueprint'
|
||||
for batch in model['batches']:
|
||||
assert batch['blocks'] <= 4096
|
||||
expanded = {}
|
||||
for operation in batch['recipe']['operations']:
|
||||
a, b = operation['min'], operation['max']
|
||||
for y in range(a['y'], b['y'] + 1):
|
||||
for z in range(a['z'], b['z'] + 1):
|
||||
for x in range(a['x'], b['x'] + 1):
|
||||
assert (x, y, z) not in expanded, 'Compression overlapped a voxel'
|
||||
local = tuple(n - o for n, o in zip((x, y, z), model['origin']))
|
||||
assert voxels.blocks[local] == operation['block'], 'Compression changed the blueprint'
|
||||
expanded[(x, y, z)] = operation['block']
|
||||
assert len(expanded) == batch['blocks']
|
||||
for axis in ('x', 'y', 'z'):
|
||||
assert context['region']['min'][axis] <= batch['min'][axis] <= batch['max'][axis] <= context['region']['max'][axis]
|
||||
model['world_id'] = context['world_id']; model['world_epoch'] = context['world_epoch']
|
||||
path = OUTPUT / 'manifest.json'
|
||||
if (OUTPUT / 'ledger.json').exists() and path.exists():
|
||||
assert digest(json.loads(path.read_text())) == digest(model), 'Existing live build has a different blueprint; preserve it'
|
||||
save(path, model)
|
||||
print(json.dumps({'blocks': model['blocks'], 'batches': len(model['batches']), 'stages': model['stages'],
|
||||
'max_recipe_operations': max(len(b['recipe']['operations']) for b in model['batches'])}), flush=True)
|
||||
return
|
||||
|
||||
model = json.loads((OUTPUT / 'manifest.json').read_text())
|
||||
assert model['world_id'] == context['world_id'] and model['world_epoch'] == context['world_epoch'], 'World identity changed'
|
||||
ledger_path = OUTPUT / 'ledger.json'
|
||||
ledger = json.loads(ledger_path.read_text()) if ledger_path.exists() else {'manifest_sha256': digest(model), 'batches': {}}
|
||||
assert ledger['manifest_sha256'] == digest(model), 'Blueprint changed after writing began'
|
||||
for index, batch in enumerate(model['batches'], 1):
|
||||
record = ledger['batches'].get(batch['key'])
|
||||
if args.action == 'apply':
|
||||
if record is None:
|
||||
before = rpc('region_inspect', {'min': batch['min'], 'max': batch['max'], 'detail': 'summary'})
|
||||
assert set(before['palette']) == {'minecraft:air'}, f"Occupied site in batch {batch['key']}; preserve and review"
|
||||
plan = rpc('build_prepare', {'recipe': batch['recipe']})
|
||||
# The immutable persisted plan must still agree with the reviewed empty site.
|
||||
envelope = json.loads((CONFIG.parent / 'journal/plans' / (plan['plan_id'] + '.json')).read_text())
|
||||
persisted = json.loads(envelope['payload'])
|
||||
assert all(change['expected'] == 'minecraft:air' for change in persisted['changes']), 'Concurrent edit before prepare; stop'
|
||||
record = {'plan': plan, 'idempotency_key': 'gothic-hall-' + plan['plan_id']}
|
||||
ledger['batches'][batch['key']] = record; save(ledger_path, ledger)
|
||||
if not record.get('operation_id'):
|
||||
operation = rpc('build_apply', {**record['plan'], 'idempotency_key': record['idempotency_key']})
|
||||
record['operation_id'] = operation['operation_id']; save(ledger_path, ledger)
|
||||
for _ in range(600):
|
||||
status = rpc('operation_status', {'operation_id': record['operation_id']})
|
||||
if status['status'] not in ('queued', 'applying'): break
|
||||
time.sleep(.1)
|
||||
record['status'] = status; save(ledger_path, ledger)
|
||||
assert status['status'] == 'applied', f"Batch {batch['key']} stopped: {status['status']}; inspect the operation"
|
||||
print(json.dumps({'batch': index, 'of': len(model['batches']), 'key': batch['key'], 'written': status['written'],
|
||||
'stages': batch['stages'], 'operation_id': record['operation_id']}), flush=True)
|
||||
else:
|
||||
assert record and record.get('status', {}).get('status') == 'applied', 'Build is not fully applied'
|
||||
envelope = json.loads((CONFIG.parent / 'journal/plans' / (record['plan']['plan_id'] + '.json')).read_text())
|
||||
planned = json.loads(envelope['payload'])
|
||||
current = rpc('region_inspect', {'min': batch['min'], 'max': batch['max'], 'detail': 'blocks'})
|
||||
states = {point_key(block['pos']): block['state'] for block in current['blocks']}
|
||||
mismatches = [change['pos'] for change in planned['changes'] if states[point_key(change['pos'])] != change['desired']]
|
||||
assert not mismatches, f"Live geometry differs in batch {batch['key']} at {mismatches[:5]}; preserve edits"
|
||||
if args.action == 'verify':
|
||||
report = {'status': 'verified', 'blocks': model['blocks'], 'batches': len(model['batches']), 'verified_at': time.time(),
|
||||
'manifest_sha256': digest(model), 'scope': scope}
|
||||
save(OUTPUT / 'verification.json', report)
|
||||
print(json.dumps(report), flush=True)
|
||||
else:
|
||||
print('GOTHIC HALL BUILD APPLIED', flush=True)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
project_root="$(cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
cd "$project_root"
|
||||
tool_cache="${MCB_TOOL_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/minecraft-builder-mcp}"
|
||||
./mvnw package
|
||||
npm --prefix bridge ci --ignore-scripts
|
||||
npm --prefix bridge test
|
||||
export JAVA_HOME="${MCB_JAVA_HOME:-$tool_cache/jdk-25.0.2}"
|
||||
camera-mod/gradlew --project-dir camera-mod build
|
||||
@@ -0,0 +1,291 @@
|
||||
"""Main Gothic hall geometry in local coordinates; pure voxel composition, no IO."""
|
||||
|
||||
|
||||
def build(v):
|
||||
"""Compose the north-facing hall, hollow interior, roof, and attached masonry."""
|
||||
stone = "minecraft:stone_bricks"
|
||||
ashlar = "minecraft:polished_andesite"
|
||||
plinth = "minecraft:andesite"
|
||||
pale = "minecraft:polished_diorite"
|
||||
dark = "minecraft:deepslate_tiles"
|
||||
glass = "minecraft:tinted_glass"
|
||||
wood = "minecraft:dark_oak_planks"
|
||||
oak = "minecraft:spruce_planks"
|
||||
|
||||
def slab(material="stone_brick", half="bottom"):
|
||||
return f"minecraft:{material}_slab[type={half},waterlogged=false]"
|
||||
|
||||
def stair(material, facing, half="bottom"):
|
||||
return (f"minecraft:{material}_stairs[facing={facing},half={half},"
|
||||
"shape=straight,waterlogged=false]")
|
||||
|
||||
def log(axis="y"):
|
||||
return f"minecraft:dark_oak_log[axis={axis}]"
|
||||
|
||||
def roof_height(x):
|
||||
return 43 - (abs(x - 19) * 16 + 13) // 14
|
||||
|
||||
def pinnacle(x, z, base, height=8):
|
||||
"""Square masonry shaft with a cap, taper, and slender stone finial."""
|
||||
v.box(x - 1, base, z - 1, x + 1, base, z + 1, ashlar)
|
||||
v.box(x, base + 1, z, x, base + height - 3, z, stone)
|
||||
v.box(x - 1, base + height - 4, z - 1,
|
||||
x + 1, base + height - 4, z + 1, slab())
|
||||
v.set(x, base + height - 2, z, ashlar)
|
||||
v.set(x, base + height - 1, z, slab())
|
||||
|
||||
def side_window(face, inner, center, bottom, tops, trim_face):
|
||||
"""Carved pointed aperture with glass one block behind the wall face."""
|
||||
half = max(tops)
|
||||
lo, hi = sorted((face, inner))
|
||||
for dz in range(-half, half + 1):
|
||||
top = tops[abs(dz)]
|
||||
v.clear(lo, bottom, center + dz, hi, top, center + dz)
|
||||
v.box(inner, bottom, center + dz, inner, top, center + dz, glass)
|
||||
v.set(trim_face, top + 1, center + dz, ashlar)
|
||||
for dz in (-half - 1, half + 1):
|
||||
v.box(trim_face, bottom - 1, center + dz,
|
||||
trim_face, tops[half] + 1, center + dz, ashlar)
|
||||
v.box(trim_face, bottom - 1, center - half - 1,
|
||||
trim_face, bottom - 1, center + half + 1, pale)
|
||||
# Alternating vertical/horizontal pieces describe the arch, rather than
|
||||
# colouring an arch outline on an otherwise flat facade.
|
||||
for dz in range(-half, half + 1):
|
||||
top = tops[abs(dz)]
|
||||
if dz:
|
||||
facing = "south" if dz < 0 else "north"
|
||||
v.set(trim_face, top + 2, center + dz, stair("stone_brick", facing))
|
||||
v.set(trim_face, tops[0] + 2, center, slab())
|
||||
|
||||
def front_aperture(z_face, z_glass, center, bottom, tops):
|
||||
for dx in range(-max(tops), max(tops) + 1):
|
||||
top = tops[abs(dx)]
|
||||
v.clear(center + dx, bottom, z_face, center + dx, top, z_glass)
|
||||
v.box(center + dx, bottom, z_glass, center + dx, top, z_glass, glass)
|
||||
|
||||
# Dense foundations; two genuinely hollow occupied storeys above them.
|
||||
v.box(7, 0, 13, 31, 0, 56, plinth)
|
||||
v.box(7, 1, 13, 31, 1, 56, ashlar)
|
||||
v.box(7, 2, 13, 31, 27, 56, stone)
|
||||
v.clear(9, 2, 15, 29, 5, 54)
|
||||
v.clear(9, 7, 15, 29, 43, 54)
|
||||
v.box(9, 6, 15, 29, 6, 54, wood)
|
||||
v.box(17, 6, 15, 21, 6, 53, ashlar)
|
||||
for x in (9, 29):
|
||||
v.box(x, 6, 15, x, 6, 54, stone)
|
||||
for z in (15, 54):
|
||||
v.box(9, 6, z, 29, 6, z, stone)
|
||||
|
||||
# Continuous ledges make the raised main floor legible from the courtyard.
|
||||
for x in (6, 32):
|
||||
v.box(x, 0, 13, x, 1, 56, ashlar)
|
||||
v.box(x, 5, 13, x, 5, 56, stone)
|
||||
v.box(x, 6, 13, x, 6, 56, slab("smooth_stone"))
|
||||
v.box(x, 25, 12, x, 25, 57, ashlar)
|
||||
v.box(x, 26, 12, x, 26, 57, pale)
|
||||
v.box(x, 27, 12, x, 27, 57, slab())
|
||||
for z in (12, 57):
|
||||
v.box(6, 5, z, 32, 5, z, stone)
|
||||
v.box(6, 6, z, 32, 6, z, slab("smooth_stone"))
|
||||
v.box(6, 25, z, 32, 25, z, ashlar)
|
||||
v.box(6, 26, z, 32, 26, z, pale)
|
||||
# Timber is visible in the short shadow immediately below the roof edge.
|
||||
for x in (7, 31):
|
||||
v.box(x, 26, 15, x, 26, 54, log("z"))
|
||||
for z in range(15, 55, 2):
|
||||
v.set(x, 25, z, stair("spruce", "east" if x == 7 else "west", "top"))
|
||||
|
||||
bays = (18, 26, 34, 42, 50)
|
||||
piers = (14, 22, 30, 38, 46, 54)
|
||||
for center in bays:
|
||||
for face, inner, trim in ((7, 8, 6), (31, 30, 32)):
|
||||
side_window(face, inner, center, 10, {0: 22, 1: 21, 2: 19}, trim)
|
||||
# The recessed central mullion and transom divide four dark lancets.
|
||||
v.box(inner, 10, center, inner, 20, center, ashlar)
|
||||
v.box(inner, 15, center - 2, inner, 15, center + 2, ashlar)
|
||||
v.set(inner, 21, center, wood)
|
||||
# A modest three-wide basement window below each high bay.
|
||||
side_window(face, inner, center, 2, {0: 4, 1: 3}, trim)
|
||||
v.set(inner, 2, center, wood)
|
||||
# Deep, bevelled main-window sill; its ends connect to the piers.
|
||||
outside = trim - 1 if trim < 19 else trim + 1
|
||||
v.box(outside, 8, center - 3, outside, 8, center + 3, slab())
|
||||
v.box(trim, 9, center - 3, trim, 9, center + 3, stone)
|
||||
|
||||
# Each buttress has an outward foot and two real setbacks.
|
||||
for z in piers:
|
||||
for left in (True, False):
|
||||
if left:
|
||||
foot, middle, shaft, outside = (3, 7), (4, 7), (5, 7), 5
|
||||
else:
|
||||
foot, middle, shaft, outside = (31, 35), (31, 34), (31, 33), 33
|
||||
v.box(foot[0], 0, z - 1, foot[1], 1, z + 1, plinth)
|
||||
v.box(middle[0], 2, z - 1, middle[1], 5, z + 1, stone)
|
||||
v.box(middle[0], 6, z - 1, middle[1], 6, z + 1, slab("smooth_stone"))
|
||||
v.box(shaft[0], 7, z - 1, shaft[1], 11, z + 1, stone)
|
||||
v.box(shaft[0], 12, z - 1, shaft[1], 12, z + 1, ashlar)
|
||||
v.box(min(outside, 7 if left else 31), 13, z,
|
||||
max(outside, 7 if left else 31), 24, z, stone)
|
||||
v.set(outside, 19, z, ashlar)
|
||||
v.box(shaft[0], 25, z - 1, shaft[1], 25, z + 1, ashlar)
|
||||
v.set(outside, 26, z, slab("smooth_stone"))
|
||||
pinnacle(outside + (1 if left else -1), z, 27, 7)
|
||||
|
||||
# Recessed rear lancets, kept separate from the side gallery connection.
|
||||
for center in (12, 19, 26):
|
||||
front_aperture(55, 56, center, 10, {0: 22, 1: 20})
|
||||
for dx in (-2, 2):
|
||||
v.box(center + dx, 9, 57, center + dx, 21, 57, ashlar)
|
||||
v.set(center, 24, 57, ashlar)
|
||||
v.box(center - 2, 9, 57, center + 2, 9, 57, pale)
|
||||
|
||||
# Steep, two-cell-thick stepped roof. Both sides slope to the central ridge.
|
||||
for x in range(5, 34):
|
||||
high = roof_height(x)
|
||||
if x == 19:
|
||||
v.box(x, 42, 12, x, 43, 57, dark)
|
||||
else:
|
||||
v.box(x, high - 1, 12, x, high, 57, dark)
|
||||
v.box(x, high, 12, x, high, 57,
|
||||
stair("deepslate_tile", "east" if x < 19 else "west"))
|
||||
# A restrained ridge crest uses slabs and piers instead of unsupported fences.
|
||||
v.box(19, 43, 14, 19, 43, 55, slab("stone_brick"))
|
||||
for z in range(15, 56, 4):
|
||||
v.set(19, 43, z, ashlar)
|
||||
|
||||
# Front and rear stepped gables: substantial masonry underneath the coping.
|
||||
for z in (12, 57):
|
||||
for x in range(6, 33):
|
||||
high = roof_height(x)
|
||||
v.box(x, 27, z, x, high, z, stone)
|
||||
v.set(x, high, z, ashlar)
|
||||
if x != 19:
|
||||
v.set(x, high + 1, z, stair("stone_brick", "east" if x < 19 else "west"))
|
||||
v.box(19, 44, z, 19, 46, z, ashlar)
|
||||
v.box(18, 47, z, 20, 47, z, stone)
|
||||
v.set(19, 48, z, slab())
|
||||
|
||||
# Tall north facade, projecting three-layer porch, and its open pointed door.
|
||||
v.box(13, 6, 9, 25, 6, 14, ashlar)
|
||||
v.box(14, 6, 8, 24, 6, 8, ashlar)
|
||||
portal_top = {0: 19, 1: 18, 2: 17, 3: 15, 4: 13}
|
||||
for dx, top in portal_top.items():
|
||||
for sign in (-1, 1) if dx else (1,):
|
||||
x = 19 + sign * dx
|
||||
v.clear(x, 7, 9, x, top, 15)
|
||||
for z, material, expand in ((9, ashlar, 1), (10, stone, 0), (11, pale, 0)):
|
||||
for dx in (-5, 5):
|
||||
v.box(19 + dx, 7, z, 19 + dx, 14, z, material)
|
||||
for dx, top in portal_top.items():
|
||||
for sign in (-1, 1) if dx else (1,):
|
||||
x = 19 + sign * dx
|
||||
v.box(x, top + 1, z, x, top + 1 + expand, z, material)
|
||||
for x in (13, 25):
|
||||
v.box(x, 7, 10, x, 14, 12, stone)
|
||||
v.box(x, 15, 10, x, 15, 12, slab())
|
||||
# Open wooden leaves are flush with the inner jambs, not across the entrance.
|
||||
v.box(15, 7, 14, 15, 12, 15, log())
|
||||
v.box(23, 7, 14, 23, 12, 15, log())
|
||||
v.clear(16, 7, 9, 22, 12, 15)
|
||||
|
||||
# Two slim flanking lancets and a large upper traceried front window.
|
||||
for center in (10, 28):
|
||||
front_aperture(12, 14, center, 10, {0: 23, 1: 21})
|
||||
for x in (center - 2, center + 2):
|
||||
v.box(x, 9, 11, x, 22, 11, ashlar)
|
||||
v.set(center, 25, 11, slab())
|
||||
v.box(center - 2, 9, 11, center + 2, 9, 11, pale)
|
||||
front_aperture(12, 14, 19, 29, {0: 39, 1: 38, 2: 36, 3: 33})
|
||||
for dx, top in {0: 39, 1: 38, 2: 36, 3: 33}.items():
|
||||
for sign in (-1, 1) if dx else (1,):
|
||||
v.set(19 + dx * sign, top + 1, 11, ashlar)
|
||||
for x in (15, 23):
|
||||
v.box(x, 28, 11, x, 34, 11, ashlar)
|
||||
for x in (18, 20):
|
||||
v.box(x, 29, 14, x, 36, 14, ashlar)
|
||||
v.box(16, 33, 14, 22, 33, 14, ashlar)
|
||||
v.box(15, 28, 11, 23, 28, 11, pale)
|
||||
# Small heraldic mosaic echoes the reference's central hanging cross.
|
||||
v.box(17, 22, 11, 21, 27, 11, dark)
|
||||
v.box(19, 23, 10, 19, 26, 10, pale)
|
||||
v.box(18, 25, 10, 20, 25, 10, pale)
|
||||
v.set(19, 21, 11, dark)
|
||||
for x in (7, 31):
|
||||
v.box(x - 1, 2, 11, x + 1, 9, 13, stone)
|
||||
v.box(x, 10, 11, x, 28, 12, ashlar)
|
||||
for y in (6, 13, 25):
|
||||
v.box(x - 1, y, 10, x + 1, y, 13, slab("smooth_stone"))
|
||||
pinnacle(x, 12, 29, 9)
|
||||
|
||||
# Five small pointed dormers on each long roof slope.
|
||||
for z in bays:
|
||||
for left in (True, False):
|
||||
lo, hi, face, inner = (10, 14, 10, 11) if left else (24, 28, 28, 27)
|
||||
v.clear(lo, 33, z - 1, hi, 36, z + 1)
|
||||
for dz in (-2, 2):
|
||||
v.box(lo, 32, z + dz, hi, 37, z + dz, dark)
|
||||
v.box(face, 32, z - 1, face, 37, z + 1, ashlar)
|
||||
v.clear(min(face, inner), 34, z, max(face, inner), 36, z)
|
||||
v.box(inner, 34, z, inner, 36, z, glass)
|
||||
for dz in range(-2, 3):
|
||||
top = 39 - abs(dz)
|
||||
v.box(lo, top, z + dz, hi, top, z + dz, dark)
|
||||
v.set(face, top, z + dz, ashlar)
|
||||
v.set(face, 40, z, slab())
|
||||
|
||||
# Galleries retain a broad, open central nave and three-block walkways.
|
||||
for x1, x2 in ((9, 12), (26, 29)):
|
||||
v.box(x1, 16, 16, x2, 16, 52, wood)
|
||||
rail = x2 if x1 < 19 else x1
|
||||
v.box(rail, 17, 16, rail, 17, 52, slab("stone_brick"))
|
||||
for z in range(16, 53, 4):
|
||||
v.set(rail, 17, z, ashlar)
|
||||
v.set(rail, 18, z, slab())
|
||||
for z in piers[1:]:
|
||||
if z > 52:
|
||||
continue
|
||||
v.box(rail, 7, z, rail, 15, z, stone)
|
||||
v.box(rail - 1, 7, z - 1, rail + 1, 7, z + 1, ashlar)
|
||||
v.box(rail - 1, 15, z, rail + 1, 15, z, ashlar)
|
||||
# A rear bridge connects both galleries to the single staircase.
|
||||
v.box(9, 16, 51, 29, 16, 53, wood)
|
||||
v.box(12, 17, 50, 25, 17, 50, slab())
|
||||
for x in (12, 19, 25):
|
||||
v.set(x, 17, 50, ashlar)
|
||||
v.set(x, 18, 50, slab())
|
||||
for x in (12, 26):
|
||||
v.box(x, 7, 52, x, 15, 52, stone)
|
||||
# Transverse oak roof frames are visible from the nave between the dormers.
|
||||
for z in (22, 30, 38, 46, 54):
|
||||
v.box(9, 28, z, 29, 28, z, log("x"))
|
||||
for x in range(9, 30):
|
||||
y = 40 - abs(x - 19)
|
||||
v.set(x, y, z, wood)
|
||||
for x in (9, 29):
|
||||
v.box(x, 19, z, x, 28, z, log())
|
||||
|
||||
# Rear right gallery stair: ten rises, with its complete headroom shaft.
|
||||
v.clear(26, 7, 39, 28, 19, 49)
|
||||
for step in range(10):
|
||||
y, z = 7 + step, 40 + step
|
||||
if y > 7:
|
||||
v.box(26, 7, z, 28, y - 1, z, wood)
|
||||
v.box(26, y, z, 28, y, z, stair("spruce", "south"))
|
||||
# Separate basement access under the west gallery; the main floor is pierced.
|
||||
v.clear(10, 2, 43, 11, 9, 48)
|
||||
for step in range(5):
|
||||
y, z = 2 + step, 44 + step
|
||||
if y > 2:
|
||||
v.box(10, 2, z, 11, y - 1, z, stone)
|
||||
v.box(10, y, z, 11, y, z, stair("stone_brick", "south"))
|
||||
|
||||
# Modest furnishings preserve a five-block processional route to the dais.
|
||||
v.box(14, 7, 50, 24, 7, 53, wood)
|
||||
v.box(16, 7, 49, 22, 7, 49, stair("spruce", "south"))
|
||||
for z in (24, 30, 36, 42):
|
||||
for x1, x2 in ((14, 16), (22, 24)):
|
||||
v.box(x1, 7, z, x2, 7, z, stair("spruce", "north"))
|
||||
v.set(x1, 8, z, slab("spruce"))
|
||||
v.set(x2, 8, z, slab("spruce"))
|
||||
# Agreed lower-level passage to the east gallery/wing, cut after decoration.
|
||||
v.clear(30, 7, 36, 35, 11, 39)
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Deterministic voxel blueprint for the generated Gothic hall reference."""
|
||||
from collections import Counter, defaultdict
|
||||
import importlib
|
||||
import re
|
||||
|
||||
ORIGIN = (-50, -60, -40)
|
||||
|
||||
class Voxels:
|
||||
def __init__(self):
|
||||
self.blocks = {}
|
||||
self.stage = 'site'
|
||||
self.owners = {}
|
||||
|
||||
def set(self, x, y, z, block):
|
||||
assert all(isinstance(v, int) for v in (x, y, z)), (x, y, z)
|
||||
assert -2 <= x <= 68 and -1 <= y <= 64 and -2 <= z <= 68, (x, y, z)
|
||||
if not block.startswith('minecraft:'):
|
||||
block = 'minecraft:' + block
|
||||
assert re.fullmatch(r'minecraft:[a-z0-9_]+(?:\[[a-z0-9_=,]+\])?', block), block
|
||||
if block == 'minecraft:air':
|
||||
self.blocks.pop((x, y, z), None)
|
||||
self.owners.pop((x, y, z), None)
|
||||
else:
|
||||
self.blocks[(x, y, z)] = block
|
||||
self.owners[(x, y, z)] = self.stage
|
||||
|
||||
def box(self, x1, y1, z1, x2, y2, z2, block):
|
||||
x1, x2 = sorted((x1, x2))
|
||||
y1, y2 = sorted((y1, y2))
|
||||
z1, z2 = sorted((z1, z2))
|
||||
for y in range(y1, y2 + 1):
|
||||
for z in range(z1, z2 + 1):
|
||||
for x in range(x1, x2 + 1):
|
||||
self.set(x, y, z, block)
|
||||
|
||||
def clear(self, x1, y1, z1, x2, y2, z2):
|
||||
self.box(x1, y1, z1, x2, y2, z2, 'air')
|
||||
|
||||
def site(v):
|
||||
v.box(1, 0, 1, 63, 0, 62, 'smooth_stone')
|
||||
for z in (1, 62):
|
||||
v.box(1, 0, z, 63, 0, z, 'polished_andesite')
|
||||
for x in (1, 63):
|
||||
v.box(x, 0, 1, x, 0, 62, 'polished_andesite')
|
||||
# A few continuous paving bands give the terrace scale without noisy texture.
|
||||
for x in (5, 33, 60):
|
||||
v.box(x, 0, 2, x, 0, 61, 'stone_bricks')
|
||||
for z in (4, 59):
|
||||
v.box(2, 0, z, 62, 0, z, 'stone_bricks')
|
||||
for step in range(1, 7):
|
||||
z = step + 1
|
||||
v.box(13, 1, z, 25, step, z, 'stone_bricks')
|
||||
v.box(14, step, z, 24, step, z,
|
||||
'stone_brick_stairs[facing=south,half=bottom,shape=straight,waterlogged=false]')
|
||||
for x in (12, 26):
|
||||
v.box(x, 1, z, x, step + 1, z, 'polished_andesite')
|
||||
v.set(x, step + 2, z, 'stone_brick_slab[type=bottom,waterlogged=false]')
|
||||
v.box(13, 1, 8, 25, 6, 9, 'stone_bricks')
|
||||
for x in (8, 30, 35, 61):
|
||||
for z in (6, 59):
|
||||
v.box(x, 1, z, x + 1, 1, z + 1, 'polished_andesite')
|
||||
v.box(x, 2, z, x + 1, 3, z + 1, 'stone_bricks')
|
||||
v.box(x, 4, z, x + 1, 4, z + 1,
|
||||
'stone_brick_slab[type=bottom,waterlogged=false]')
|
||||
|
||||
def connection(v):
|
||||
# Two-level enclosed passage between the great hall and companion wing.
|
||||
v.stage = 'connecting gallery'
|
||||
v.box(31, 0, 34, 40, 6, 42, 'stone_bricks')
|
||||
v.box(31, 7, 34, 40, 13, 34, 'stone_bricks')
|
||||
v.box(31, 7, 42, 40, 13, 42, 'stone_bricks')
|
||||
v.box(31, 13, 33, 40, 13, 43, 'polished_andesite')
|
||||
v.box(31, 14, 34, 40, 14, 42, 'deepslate_tiles')
|
||||
v.box(31, 15, 36, 40, 15, 40, 'deepslate_tiles')
|
||||
v.box(31, 16, 38, 40, 16, 38, 'deepslate_tiles')
|
||||
for z, facing, y in [(34, 'south', 14), (35, 'south', 14),
|
||||
(36, 'south', 15), (37, 'south', 15),
|
||||
(39, 'north', 15), (40, 'north', 15),
|
||||
(41, 'north', 14), (42, 'north', 14)]:
|
||||
v.box(31, y, z, 40, y, z,
|
||||
f'deepslate_tile_stairs[facing={facing},half=bottom,shape=straight,waterlogged=false]')
|
||||
v.clear(30, 7, 36, 32, 11, 39)
|
||||
v.clear(33, 8, 36, 34, 11, 39)
|
||||
v.clear(35, 9, 36, 40, 12, 39)
|
||||
v.box(31, 6, 36, 32, 6, 39, 'spruce_planks')
|
||||
for x, y in [(33, 6), (34, 7)]:
|
||||
v.box(x, y, 36, x, y, 39,
|
||||
'spruce_stairs[facing=east,half=bottom,shape=straight,waterlogged=false]')
|
||||
v.box(35, 8, 36, 40, 8, 39, 'spruce_planks')
|
||||
|
||||
def build_scene():
|
||||
v = Voxels()
|
||||
site(v)
|
||||
for name, stage in [('main_hall', 'great hall'), ('side_wing', 'side wing'), ('tower', 'bell tower')]:
|
||||
v.stage = stage
|
||||
importlib.import_module('builds.gothic_hall.' + name).build(v)
|
||||
connection(v)
|
||||
return v
|
||||
|
||||
def boxes(blocks):
|
||||
"""Merge runs on X, then Z and Y, preserving the exact sparse block mask."""
|
||||
rows = defaultdict(list)
|
||||
for (x, y, z), block in blocks.items():
|
||||
rows[(y, z, block)].append(x)
|
||||
runs = []
|
||||
for (y, z, block), xs in sorted(rows.items()):
|
||||
xs.sort(); start = end = xs[0]
|
||||
for x in xs[1:]:
|
||||
if x == end + 1:
|
||||
end = x
|
||||
else:
|
||||
runs.append((start, y, z, end, y, z, block)); start = end = x
|
||||
runs.append((start, y, z, end, y, z, block))
|
||||
merged = []
|
||||
groups = defaultdict(list)
|
||||
for x1, y1, z1, x2, y2, z2, block in runs:
|
||||
groups[(x1, x2, y1, block)].append(z1)
|
||||
for (x1, x2, y, block), zs in sorted(groups.items()):
|
||||
zs.sort(); start = end = zs[0]
|
||||
for z in zs[1:]:
|
||||
if z == end + 1: end = z
|
||||
else:
|
||||
merged.append((x1, y, start, x2, y, end, block)); start = end = z
|
||||
merged.append((x1, y, start, x2, y, end, block))
|
||||
result = []; groups = defaultdict(list)
|
||||
for x1, y1, z1, x2, y2, z2, block in merged:
|
||||
groups[(x1, x2, z1, z2, block)].append(y1)
|
||||
for (x1, x2, z1, z2, block), ys in sorted(groups.items()):
|
||||
ys.sort(); start = end = ys[0]
|
||||
for y in ys[1:]:
|
||||
if y == end + 1: end = y
|
||||
else:
|
||||
result.append((x1, start, z1, x2, end, z2, block)); start = end = y
|
||||
result.append((x1, start, z1, x2, end, z2, block))
|
||||
return result
|
||||
|
||||
def manifest(v):
|
||||
tiles = defaultdict(dict)
|
||||
for p, block in v.blocks.items():
|
||||
tiles[(p[1] // 8, p[2] // 16, p[0] // 16)][p] = block
|
||||
batches = []
|
||||
for key, data in sorted(tiles.items()):
|
||||
recipe = []
|
||||
for x1, y1, z1, x2, y2, z2, block in boxes(data):
|
||||
lo = dict(zip(('x', 'y', 'z'), (x1 + ORIGIN[0], y1 + ORIGIN[1], z1 + ORIGIN[2])))
|
||||
hi = dict(zip(('x', 'y', 'z'), (x2 + ORIGIN[0], y2 + ORIGIN[1], z2 + ORIGIN[2])))
|
||||
recipe.append({'type': 'box', 'min': lo, 'max': hi, 'block': block})
|
||||
positions = list(data)
|
||||
minimum = [min(p[i] for p in positions) + ORIGIN[i] for i in range(3)]
|
||||
maximum = [max(p[i] for p in positions) + ORIGIN[i] for i in range(3)]
|
||||
batches.append({'key': '-'.join(map(str, key)), 'blocks': len(data),
|
||||
'min': dict(zip(('x', 'y', 'z'), minimum)), 'max': dict(zip(('x', 'y', 'z'), maximum)),
|
||||
'recipe': {'version': 1, 'operations': recipe},
|
||||
'stages': dict(Counter(v.owners[p] for p in data))})
|
||||
return {'name': 'Gothic hall reference reconstruction', 'origin': ORIGIN,
|
||||
'blocks': len(v.blocks), 'stages': dict(Counter(v.owners.values())),
|
||||
'palette': dict(Counter(v.blocks.values())), 'batches': batches}
|
||||
@@ -0,0 +1,176 @@
|
||||
"""The smaller, two-storey gabled companion to the main Gothic hall.
|
||||
|
||||
Coordinates are inclusive and local to the complete composition. ``build``
|
||||
only writes through the supplied voxel API; it does not contact Minecraft.
|
||||
"""
|
||||
|
||||
STONE = "minecraft:stone_bricks"
|
||||
TRIM = "minecraft:polished_andesite"
|
||||
LIGHT = "minecraft:polished_diorite"
|
||||
GLASS = "minecraft:tinted_glass"
|
||||
WOOD = "minecraft:spruce_planks"
|
||||
BEAM = "minecraft:stripped_spruce_log[axis=y]"
|
||||
ROOF = "minecraft:deepslate_tiles"
|
||||
AIR = "minecraft:air"
|
||||
|
||||
|
||||
def _stair(material, facing, half="bottom"):
|
||||
return (
|
||||
f"minecraft:{material}[facing={facing},half={half},"
|
||||
"shape=straight,waterlogged=false]"
|
||||
)
|
||||
|
||||
|
||||
def _wall_block(x, y, z):
|
||||
"""Sparse, deterministic masonry variation, without a checkerboard."""
|
||||
n = (x * 31 + y * 17 + z * 47 + x * z * 3) % 53
|
||||
if n < 3:
|
||||
return "minecraft:cracked_stone_bricks"
|
||||
if n < 8:
|
||||
return "minecraft:andesite"
|
||||
return STONE
|
||||
|
||||
|
||||
def _lancet(v, left, right, bottom, top, wall, outside, north=False):
|
||||
"""A stone pointed surround, open reveal, and recessed dark glass."""
|
||||
center = (left + right) / 2
|
||||
|
||||
def point(u, y, depth, block):
|
||||
if north:
|
||||
v.set(u, y, depth, block)
|
||||
else:
|
||||
v.set(depth, y, u, block)
|
||||
|
||||
for u in range(left - 1, right + 2):
|
||||
cap = top + 1 - int(abs(u - center))
|
||||
for y in range(bottom - 1, cap + 1):
|
||||
point(u, y, outside, LIGHT if y == cap else TRIM)
|
||||
for u in range(left, right + 1):
|
||||
cap = top - int(abs(u - center))
|
||||
for y in range(bottom, cap + 1):
|
||||
point(u, y, outside, AIR)
|
||||
point(u, y, wall, GLASS)
|
||||
# The sill projects one block; the glass stays in the wall behind it.
|
||||
for u in range(left - 1, right + 2):
|
||||
point(u, bottom - 1, outside, LIGHT)
|
||||
|
||||
|
||||
def _buttress(v, west, z):
|
||||
"""Three diminishing stages, with projecting pale weathering caps."""
|
||||
outer, inner = (36, 38) if west else (57, 59)
|
||||
v.box(outer, 0, z - 1, inner, 1, z + 1, TRIM)
|
||||
a, b = (37, 38) if west else (57, 58)
|
||||
v.box(a, 2, z - 1, b, 6, z + 1, STONE)
|
||||
v.box(outer, 7, z - 1, inner, 7, z + 1, LIGHT)
|
||||
v.box(a, 8, z, b, 14, z, STONE)
|
||||
v.box(a, 15, z - 1, b, 15, z + 1, TRIM)
|
||||
v.box(a, 16, z, b, 18, z, STONE)
|
||||
v.box(a, 19, z, b, 19, z, LIGHT)
|
||||
|
||||
|
||||
def _entrance(v):
|
||||
# Two nested pointed archivolts give the entrance visible depth.
|
||||
for z, left, right, peak, block in (
|
||||
(14, 44, 51, 8, TRIM),
|
||||
(15, 45, 50, 7, LIGHT),
|
||||
):
|
||||
for x in range(left, right + 1):
|
||||
cap = peak - int(abs(x - 47.5))
|
||||
v.box(x, 1, z, x, cap, z, block)
|
||||
for x in range(46, 50):
|
||||
cap = 6 - int(abs(x - 47.5))
|
||||
v.clear(x, 1, 14, x, cap, 17)
|
||||
# Timber inner jambs suggest the tall wooden doorway, leaving it usable.
|
||||
v.box(45, 1, 16, 45, 4, 16, BEAM)
|
||||
v.box(50, 1, 16, 50, 4, 16, BEAM)
|
||||
v.box(44, 0, 13, 51, 0, 16, TRIM)
|
||||
v.box(46, 0, 14, 49, 0, 17, "minecraft:smooth_stone")
|
||||
|
||||
|
||||
def build(v):
|
||||
"""Build within x=36..59, y=0..28, z=13..43."""
|
||||
# Hollow masonry shell, an accessible ground floor and an upper floor.
|
||||
v.box(38, 0, 16, 57, 0, 41, TRIM)
|
||||
v.clear(39, 1, 17, 56, 27, 40)
|
||||
for y in range(1, 18):
|
||||
for x in range(38, 58):
|
||||
for z in (16, 41):
|
||||
v.set(x, y, z, _wall_block(x, y, z))
|
||||
for z in range(17, 41):
|
||||
for x in (38, 57):
|
||||
v.set(x, y, z, _wall_block(x, y, z))
|
||||
v.box(39, 0, 17, 56, 0, 40, WOOD)
|
||||
v.box(39, 8, 17, 56, 8, 40, WOOD)
|
||||
|
||||
# Floor bands and the projecting eaves visually join the two storeys.
|
||||
for y, block in ((0, TRIM), (7, TRIM), (8, LIGHT), (16, TRIM)):
|
||||
v.box(37, y, 15, 58, y, 15, block)
|
||||
v.box(37, y, 42, 58, y, 42, block)
|
||||
v.box(58, y, 16, 58, y, 41, block)
|
||||
for z in range(16, 42):
|
||||
# The main composition will connect the gallery through here.
|
||||
if not (7 <= y <= 11 and 36 <= z <= 39):
|
||||
v.set(37, y, z, block)
|
||||
|
||||
for z in (17, 25, 33, 41):
|
||||
_buttress(v, True, z)
|
||||
_buttress(v, False, z)
|
||||
for x in (38, 57):
|
||||
v.box(x - 1, 0, 14, x + 1, 1, 16, TRIM)
|
||||
v.box(x, 2, 14, x, 16, 16, STONE)
|
||||
v.box(x - 1, 17, 14, x + 1, 17, 16, LIGHT)
|
||||
v.box(x, 18, 15, x, 20, 15, STONE)
|
||||
v.set(x, 21, 15, LIGHT)
|
||||
|
||||
# A paired rhythm of small lower and tall upper side windows.
|
||||
for z in (21, 29, 37):
|
||||
for wall, outside in ((38, 37), (57, 58)):
|
||||
if wall == 38 and z == 37:
|
||||
continue # Keep the gallery doorway and its approach clear.
|
||||
_lancet(v, z - 1, z + 1, 2, 6, wall, outside)
|
||||
_lancet(v, z - 1, z + 1, 10, 15, wall, outside)
|
||||
for left, right in ((41, 42), (53, 54)):
|
||||
_lancet(v, left, right, 2, 6, 16, 15, north=True)
|
||||
for left, right in ((42, 45), (50, 53)):
|
||||
_lancet(v, left, right, 10, 15, 16, 15, north=True)
|
||||
_entrance(v)
|
||||
|
||||
# Timber supports remain against the edges, leaving the rooms traversable.
|
||||
for x in (40, 55):
|
||||
for z in (18, 39):
|
||||
v.box(x, 1, z, x, 7, z, BEAM)
|
||||
for z in (23, 31, 39):
|
||||
v.box(39, 7, z, 56, 7, z, "minecraft:stripped_spruce_log[axis=x]")
|
||||
v.clear(53, 8, 28, 55, 11, 35)
|
||||
for step in range(8):
|
||||
y, z = step + 1, 28 + step
|
||||
if y > 1:
|
||||
v.box(53, 1, z, 55, y - 1, z, WOOD)
|
||||
v.box(53, y, z, 55, y, z, _stair("spruce_stairs", "south"))
|
||||
v.clear(53, y + 1, z, 55, y + 2, z)
|
||||
v.box(52, 9, 28, 52, 9, 35, "minecraft:spruce_slab[type=bottom,waterlogged=false]")
|
||||
|
||||
# A steep roof, dark in the middle and bounded by pale stepped gables.
|
||||
for x in range(37, 59):
|
||||
rise = min(x - 37, 58 - x)
|
||||
y = 17 + rise
|
||||
facing = "east" if x <= 47 else "west"
|
||||
if 38 <= x <= 57 and y >= 18:
|
||||
for z in (16, 41):
|
||||
v.box(x, 18, z, x, y, z, STONE)
|
||||
v.box(x, y, 15, x, y, 42, _stair("deepslate_tile_stairs", facing))
|
||||
for z in (14, 43):
|
||||
v.set(x, y, z, _stair("stone_brick_stairs", facing))
|
||||
if y > 17:
|
||||
v.set(x, y - 1, z, TRIM)
|
||||
v.box(47, 28, 15, 48, 28, 42, ROOF)
|
||||
for z in (14, 43):
|
||||
v.box(47, 27, z, 48, 28, z, LIGHT)
|
||||
_lancet(v, 46, 49, 19, 24, 16, 15, north=True)
|
||||
for x in (47, 48):
|
||||
v.box(x, 26, 15, x, 28, 15, TRIM)
|
||||
|
||||
# Wooden cornice brackets are visible under the long roof slopes.
|
||||
for z in (19, 23, 27, 31, 35, 39):
|
||||
for x, facing in ((37, "east"), (58, "west")):
|
||||
v.set(x, 16, z, _stair("spruce_stairs", facing, half="top"))
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Reference bell tower, in the shared scene's inclusive local coordinates.
|
||||
|
||||
The scene supplies set(x, y, z, block), box(x1, y1, z1, x2, y2, z2,
|
||||
block), and clear(x1, y1, z1, x2, y2, z2). This module has no I/O.
|
||||
All geometry stays inside x=34..46, y=0..60, z=42..56.
|
||||
"""
|
||||
|
||||
|
||||
STONE = "minecraft:stone_bricks"
|
||||
TRIM = "minecraft:polished_andesite"
|
||||
LIGHT = "minecraft:polished_diorite"
|
||||
DARK = "minecraft:deepslate_tiles"
|
||||
GLASS = "minecraft:tinted_glass"
|
||||
AIR = "minecraft:air"
|
||||
|
||||
|
||||
def _face(face, u, y, depth=0):
|
||||
"""Positive depth projects outward; -1 is the recessed glazing plane."""
|
||||
if face == "north":
|
||||
return u, y, 43 - depth
|
||||
if face == "south":
|
||||
return u, y, 55 + depth
|
||||
if face == "west":
|
||||
return 35 - depth, y, u
|
||||
return 45 + depth, y, u
|
||||
|
||||
|
||||
def _put(v, face, u, y, block, depth=0):
|
||||
v.set(*_face(face, u, y, depth), block)
|
||||
|
||||
|
||||
def _ring(v, x1, z1, x2, z2, y, block):
|
||||
v.box(x1, y, z1, x2, y, z1, block)
|
||||
v.box(x1, y, z2, x2, y, z2, block)
|
||||
v.box(x1, y, z1, x1, y, z2, block)
|
||||
v.box(x2, y, z1, x2, y, z2, block)
|
||||
|
||||
|
||||
def _lancet(v, face, center, bottom, top, half_width):
|
||||
"""A pointed opening through a two-block wall, glass one block behind it."""
|
||||
for y in range(bottom, top + 1):
|
||||
radius = 0 if y >= top - 1 else half_width
|
||||
for u in range(center - radius, center + radius + 1):
|
||||
_put(v, face, u, y, AIR)
|
||||
_put(v, face, u, y, GLASS, -1)
|
||||
# The projecting dressings are stone; the glazing never projects.
|
||||
for u in {center - radius - 1, center + radius + 1}:
|
||||
_put(v, face, u, y, TRIM, 1)
|
||||
for u in range(center - half_width - 1, center + half_width + 2):
|
||||
_put(v, face, u, bottom - 1, "minecraft:stone_brick_slab[type=top]", 1)
|
||||
_put(v, face, center, top + 1, LIGHT, 1)
|
||||
|
||||
|
||||
def _belfry_arch(v, face, center):
|
||||
# Seven blocks wide below, narrowing to a high, single-block apex.
|
||||
for y in range(32, 41):
|
||||
radius = 3 if y <= 37 else 40 - y
|
||||
for u in range(center - radius, center + radius + 1):
|
||||
for depth in (0, -1):
|
||||
_put(v, face, u, y, AIR, depth)
|
||||
for u in (center - radius - 1, center + radius + 1):
|
||||
_put(v, face, u, y, TRIM, 1)
|
||||
_put(v, face, center, 41, LIGHT, 1)
|
||||
for u in range(center - 4, center + 5):
|
||||
_put(v, face, u, 31, "minecraft:stone_brick_slab[type=top]", 1)
|
||||
|
||||
|
||||
def _stairs(v):
|
||||
# Three narrow alternating flights join the interior floors. A landing at
|
||||
# each end leaves a three-block-wide central well open through the tower.
|
||||
for y_floor, xs, reverse in ((1, (37, 38), False),
|
||||
(10, (42, 43), True),
|
||||
(19, (37, 38), False)):
|
||||
facing = "north" if reverse else "south"
|
||||
state = f"minecraft:stone_brick_stairs[facing={facing},half=bottom,shape=straight]"
|
||||
for i in range(9):
|
||||
z = 53 - i if reverse else 45 + i
|
||||
y = y_floor + i + 1
|
||||
for x in xs:
|
||||
# Clearance through the destination floor, including headroom.
|
||||
v.clear(x, y + 1, z, x, y + 3, z)
|
||||
v.set(x, y, z, state)
|
||||
# The flight exits sideways onto the destination floor.
|
||||
v.clear(39, 29, 53, 41, 32, 53)
|
||||
v.set(41, 29, 52, "minecraft:stone_brick_stairs[facing=north]")
|
||||
v.set(41, 30, 51, "minecraft:stone_brick_stairs[facing=north]")
|
||||
v.clear(41, 30, 52, 41, 33, 52)
|
||||
v.clear(41, 31, 51, 41, 33, 51)
|
||||
|
||||
|
||||
def _spire(v):
|
||||
# A hollow, steep rectangular hip roof; alternate contractions avoid a
|
||||
# stack of disconnected floating rings while giving the spire a fine tip.
|
||||
for y in range(45, 57):
|
||||
step = y - 45
|
||||
rx = 5 - step // 2
|
||||
rz = 6 - (step + 1) // 2
|
||||
if rx < 0:
|
||||
rx = 0
|
||||
if rz < 0:
|
||||
rz = 0
|
||||
x1, x2, z1, z2 = 40 - rx, 40 + rx, 49 - rz, 49 + rz
|
||||
v.box(x1, y, z1, x2, y, z2, DARK)
|
||||
if rx > 0 and rz > 0:
|
||||
v.clear(x1 + 1, y, z1 + 1, x2 - 1, y, z2 - 1)
|
||||
# Stair edges soften the one-block roof steps without adding width.
|
||||
if rx > 0 and rz > 0:
|
||||
for x in range(x1 + 1, x2):
|
||||
v.set(x, y, z1, "minecraft:deepslate_tile_stairs[facing=south]")
|
||||
v.set(x, y, z2, "minecraft:deepslate_tile_stairs[facing=north]")
|
||||
for z in range(z1 + 1, z2):
|
||||
v.set(x1, y, z, "minecraft:deepslate_tile_stairs[facing=east]")
|
||||
v.set(x2, y, z, "minecraft:deepslate_tile_stairs[facing=west]")
|
||||
# The cross is plain masonry, with no unsupported entities or thin blocks.
|
||||
v.box(40, 57, 49, 40, 60, 49, TRIM)
|
||||
v.box(39, 59, 49, 41, 59, 49, TRIM)
|
||||
|
||||
|
||||
def build(v):
|
||||
"""Add a hollow Gothic bell tower and its pointed roof to a scene."""
|
||||
# Foundation and shell: two-block walls give the apertures real reveals.
|
||||
v.box(35, 0, 43, 45, 43, 55, STONE)
|
||||
v.clear(37, 2, 45, 43, 42, 53)
|
||||
_ring(v, 34, 42, 46, 56, 0, "minecraft:deepslate_bricks")
|
||||
_ring(v, 34, 42, 46, 56, 1, TRIM)
|
||||
_ring(v, 34, 42, 46, 56, 3, "minecraft:stone_brick_slab[type=bottom]")
|
||||
|
||||
# Dressed corners and stepped buttress feet, all within the tower envelope.
|
||||
corners = ((34, 42, 35, 43), (45, 42, 46, 43),
|
||||
(34, 55, 35, 56), (45, 55, 46, 56))
|
||||
for x1, z1, x2, z2 in corners:
|
||||
v.box(x1, 1, z1, x2, 5, z2, TRIM)
|
||||
v.box(x1, 6, z1, x2, 29, z2, STONE)
|
||||
for y in (6, 15, 28):
|
||||
v.box(x1, y, z1, x2, y, z2, TRIM)
|
||||
for x, z in ((35, 43), (45, 43), (35, 55), (45, 55)):
|
||||
v.box(x, 4, z, x, 43, z, TRIM)
|
||||
|
||||
# Strong horizontal courses separate the shaft from the open belfry.
|
||||
for y in (15, 16, 29, 30, 42, 43):
|
||||
_ring(v, 34, 42, 46, 56, y, TRIM if y % 2 else STONE)
|
||||
for y in (10, 19, 28, 30):
|
||||
v.box(37, y, 45, 43, y, 53, "minecraft:oak_planks")
|
||||
if y != 30:
|
||||
v.clear(39, y, 47, 41, y, 51)
|
||||
v.box(35, 44, 43, 45, 44, 55, STONE)
|
||||
_ring(v, 34, 42, 46, 56, 44, "minecraft:stone_brick_slab[type=bottom]")
|
||||
|
||||
# Paired narrow lower lights and broader, pointed upper lancets.
|
||||
for face, centers in (("north", (38, 42)), ("south", (38, 42)),
|
||||
("west", (47, 51)), ("east", (47, 51))):
|
||||
for center in centers:
|
||||
_lancet(v, face, center, 6, 12, 0)
|
||||
_lancet(v, face, center, 19, 26, 1)
|
||||
_belfry_arch(v, face, 40 if face in ("north", "south") else 49)
|
||||
|
||||
# An actual walk-in doorway toward the wing; no decorative solid door.
|
||||
v.clear(39, 2, 42, 41, 5, 44)
|
||||
v.clear(40, 6, 42, 40, 6, 44)
|
||||
for x in (38, 42):
|
||||
v.box(x, 2, 42, x, 5, 42, TRIM)
|
||||
v.set(39, 6, 42, TRIM)
|
||||
v.set(41, 6, 42, TRIM)
|
||||
v.set(40, 7, 42, LIGHT)
|
||||
_stairs(v)
|
||||
|
||||
# A block-built ochre bell echoes the reference using allowlisted sandstone.
|
||||
# Its open mouth and central clapper remain visible through all four arches.
|
||||
v.box(35, 41, 49, 45, 41, 49, "minecraft:spruce_log[axis=x]")
|
||||
v.box(40, 38, 49, 40, 40, 49, "minecraft:dark_oak_log[axis=y]")
|
||||
v.box(40, 36, 49, 40, 37, 49, "minecraft:cut_sandstone")
|
||||
for x, z in ((39, 49), (40, 48), (40, 49), (40, 50), (41, 49)):
|
||||
v.set(x, 35, z, "minecraft:cut_sandstone")
|
||||
_ring(v, 39, 48, 41, 50, 34, "minecraft:cut_sandstone")
|
||||
v.set(40, 34, 49, "minecraft:dark_oak_log[axis=y]")
|
||||
v.set(40, 33, 49, TRIM)
|
||||
|
||||
# A row of small corbels under the cornice is readable from a distance.
|
||||
for face, us in (("north", (37, 39, 41, 43)), ("south", (37, 39, 41, 43)),
|
||||
("west", (45, 47, 49, 51, 53)), ("east", (45, 47, 49, 51, 53))):
|
||||
for u in us:
|
||||
_put(v, face, u, 41, f"minecraft:stone_brick_stairs[facing={face},half=top]", 1)
|
||||
_spire(v)
|
||||
|
||||
# Four light corner pinnacles frame the darker central spire.
|
||||
for x, z in ((35, 43), (45, 43), (35, 55), (45, 55)):
|
||||
v.box(x, 44, z, x, 49, z, TRIM)
|
||||
for dx, dz in ((-1, 0), (1, 0), (0, -1), (0, 1)):
|
||||
v.set(x + dx, 46, z + dz, "minecraft:stone_brick_slab[type=top]")
|
||||
v.set(x, 50, z, "minecraft:stone_brick_slab[type=bottom]")
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prism Java wrapper: inject the camera secret without putting it in launcher arguments/logs."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
raise SystemExit('This helper is a Prism WrapperCommand; it requires the Java command.')
|
||||
config = Path(os.environ.get('MCB_CAMERA_PAPER_CONFIG', str(ROOT / '.runtime/server/plugins/MinecraftBuilderMCP/config.yml')))
|
||||
try:
|
||||
data = config.read_text()
|
||||
token_match = re.search(r'^camera-token:[ \t]*([^\r\n]+)$', data, re.M)
|
||||
port_match = re.search(r'^camera-port:[ \t]*(\d+)[ \t]*$', data, re.M)
|
||||
token = token_match.group(1).strip().strip("'\"") if token_match else ''
|
||||
port = int(port_match.group(1)) if port_match else 8766
|
||||
if not re.fullmatch(r'[A-Za-z0-9._~-]{32,512}', token) or not 1024 <= port <= 65535:
|
||||
raise ValueError('Invalid camera configuration')
|
||||
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))
|
||||
os.execvpe(sys.argv[1], sys.argv[1:], environment)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prepare/run a private disposable Paper fixture. Never accepts Minecraft EULA implicitly."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import urllib.request
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
CACHE=Path(os.environ.get('MCB_TOOL_CACHE', str(Path.home()/'.cache/minecraft-builder-mcp')))
|
||||
URL='https://fill-data.papermc.io/v1/objects/7b7b3b43c009103e1971a0576c26f655a7dd9b56a0a2a4438e352c03a7fecd08/paper-26.2-123.jar'
|
||||
SHA='7b7b3b43c009103e1971a0576c26f655a7dd9b56a0a2a4438e352c03a7fecd08'
|
||||
|
||||
def prepare():
|
||||
server=ROOT/'.runtime/server'
|
||||
server.mkdir(parents=True, exist_ok=True)
|
||||
jar=server/'paper.jar'
|
||||
if not jar.exists():
|
||||
tmp=jar.with_suffix('.download')
|
||||
request=urllib.request.Request(URL,headers={'User-Agent':'minecraft-builder-mcp/0.1 (local-development)'})
|
||||
with urllib.request.urlopen(request,timeout=60) as source, tmp.open('wb') as out:
|
||||
shutil.copyfileobj(source,out)
|
||||
tmp.replace(jar)
|
||||
if hashlib.sha256(jar.read_bytes()).hexdigest()!=SHA:
|
||||
raise SystemExit('Paper SHA256 mismatch; remove .runtime/server/paper.jar and retry')
|
||||
plugin=ROOT/'paper-plugin/target/paper-plugin-0.1.0-SNAPSHOT.jar'
|
||||
if not plugin.exists(): raise SystemExit('Build the plugin first: ./mvnw package')
|
||||
(server/'plugins').mkdir(exist_ok=True)
|
||||
shutil.copy2(plugin,server/'plugins/minecraft-builder-mcp.jar')
|
||||
properties=server/'server.properties'
|
||||
if not properties.exists():
|
||||
properties.write_text('server-ip=127.0.0.1\nserver-port=25575\nonline-mode=true\nlevel-name=world\nlevel-type=minecraft:flat\ngenerator-settings={"layers":[{"block":"minecraft:bedrock","height":1},{"block":"minecraft:dirt","height":2},{"block":"minecraft:grass_block","height":1}],"biome":"minecraft:plains"}\ngenerate-structures=false\nspawn-protection=0\nview-distance=5\nsimulation-distance=3\nmax-players=4\ngamemode=creative\ndifficulty=peaceful\nenable-rcon=false\nmotd=minecraft-builder-mcp private development world\n')
|
||||
if not (server/'eula.txt').exists(): (server/'eula.txt').write_text('eula=false\n')
|
||||
print(f'Prepared {server}; Paper SHA256 verified. No existing worlds modified.', flush=True)
|
||||
return server
|
||||
|
||||
if __name__=='__main__':
|
||||
ap=argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument('--run',action='store_true')
|
||||
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()
|
||||
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:
|
||||
if 'eula=true' not in (server/'eula.txt').read_text():
|
||||
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))
|
||||
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prepare/apply 18 checked Gothic-hall corrections; verify the entire original build plus this patch."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
from builds.gothic_hall.scene import build_scene
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
OUTPUT = ROOT / '.runtime/gothic-hall'
|
||||
CONFIG = ROOT / '.runtime/server/plugins/MinecraftBuilderMCP/config.yml'
|
||||
LEDGER = OUTPUT / 'finish-ledger.json'
|
||||
STAIR = 'minecraft:stone_brick_stairs[facing=east,half=bottom,shape=straight,waterlogged=false]'
|
||||
|
||||
|
||||
def require(condition, message):
|
||||
if not condition:
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
def digest(value):
|
||||
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
|
||||
|
||||
|
||||
def point(value):
|
||||
return value['x'], value['y'], value['z']
|
||||
|
||||
|
||||
def position(values):
|
||||
return dict(zip(('x', 'y', 'z'), values))
|
||||
|
||||
|
||||
def canonical(state):
|
||||
if '[' not in state:
|
||||
return state
|
||||
name, properties = state.rstrip(']').split('[', 1)
|
||||
return name + '[' + ','.join(sorted(properties.split(','))) + ']'
|
||||
|
||||
|
||||
def envelope(path, kind):
|
||||
data = json.loads(path.read_text())
|
||||
require(data.get('format') == 1 and data.get('kind') == kind, 'Unexpected journal envelope')
|
||||
require(data.get('id') == path.stem, 'Journal identity mismatch')
|
||||
require(hashlib.sha256(data['payload'].encode()).hexdigest() == data['sha256'], 'Journal checksum mismatch')
|
||||
value = json.loads(data['payload'])
|
||||
require(value['id'] == path.stem, 'Journal payload identity mismatch')
|
||||
return value, data['sha256']
|
||||
|
||||
|
||||
def save(path, data, immutable=False):
|
||||
"""Fully persist a complete JSON file; immutable files are never replaced."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temporary = tempfile.mkstemp(prefix='.finish-', suffix='.tmp', dir=path.parent)
|
||||
try:
|
||||
with os.fdopen(fd, 'w') as stream:
|
||||
json.dump(data, stream, indent=2)
|
||||
stream.write('\n')
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
if immutable:
|
||||
os.link(temporary, path) # Atomic publication with fail-if-existing semantics.
|
||||
else:
|
||||
os.replace(temporary, path)
|
||||
directory = os.open(path.parent, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(directory)
|
||||
finally:
|
||||
os.close(directory)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
raise RuntimeError('Paper RPC redirect refused')
|
||||
|
||||
|
||||
class Backend:
|
||||
def __init__(self):
|
||||
config = CONFIG.read_text()
|
||||
|
||||
def scalar(name):
|
||||
match = re.search(r'^' + re.escape(name) + r':[ \t]*(.*?)$', config, re.M)
|
||||
require(match is not None, 'Required Paper configuration field is missing')
|
||||
return match.group(1).strip().strip("'\"")
|
||||
|
||||
self.scope = {'player_id': scalar('owner-uuid'), 'project_id': scalar('project-id')}
|
||||
require(self.scope['player_id'], 'An online bound owner is required')
|
||||
self.token = scalar('agent-token')
|
||||
port = scalar('http-port')
|
||||
require(port.isdigit() and 1 <= int(port) <= 65535, 'Invalid Paper port')
|
||||
self.url = 'http://127.0.0.1:' + port + '/v1/rpc'
|
||||
self.opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect())
|
||||
|
||||
def rpc(self, method, params):
|
||||
for _ in range(100):
|
||||
payload = {'method': method, 'params': {**params, **self.scope}, 'requestId': str(uuid.uuid4())}
|
||||
request = urllib.request.Request(self.url, data=json.dumps(payload).encode(), headers={
|
||||
'Authorization': 'Bearer ' + self.token, 'Content-Type': 'application/json'})
|
||||
try:
|
||||
with self.opener.open(request, timeout=30) as response:
|
||||
raw = response.read(2_097_153)
|
||||
except urllib.error.HTTPError as error:
|
||||
raw = error.read(2_097_153)
|
||||
except (OSError, urllib.error.URLError):
|
||||
raise RuntimeError(f'{method}: connection interrupted; reuse the existing finish ledger') from None
|
||||
require(len(raw) <= 2_097_152, 'Paper response exceeded limit')
|
||||
body = json.loads(raw)
|
||||
if body.get('ok'):
|
||||
return body['result']
|
||||
code = body.get('error', {}).get('code', 'unknown')
|
||||
if code == 'busy':
|
||||
time.sleep(.1)
|
||||
continue
|
||||
raise RuntimeError(f'{method}: {code}; no blind overwrite or new idempotency key was used')
|
||||
raise RuntimeError('Paper remained busy')
|
||||
|
||||
|
||||
def references():
|
||||
model = json.loads((OUTPUT / 'manifest.json').read_text())
|
||||
base = json.loads((OUTPUT / 'ledger.json').read_text())
|
||||
require(base['manifest_sha256'] == digest(model), 'Original manifest no longer matches its ledger')
|
||||
require(model['blocks'] == 29354 and len(model['batches']) == len(base['batches']) == 87,
|
||||
'Expected the original completed 29,354-block, 87-plan build')
|
||||
require({b['key'] for b in model['batches']} == set(base['batches']), 'Original batch set changed')
|
||||
states, plans = {}, {}
|
||||
for batch in model['batches']:
|
||||
record = base['batches'][batch['key']]
|
||||
require(record.get('status', {}).get('status') == 'applied', 'Original build has an incomplete batch')
|
||||
summary = record['plan']
|
||||
plan, checksum = envelope(CONFIG.parent / 'journal/plans' / (summary['plan_id'] + '.json'), 'plan')
|
||||
require(checksum == summary['plan_hash'], 'Original saved plan hash changed')
|
||||
require(plan['worldEpoch'] == model['world_epoch'] and plan['region']['worldId'] == model['world_id'],
|
||||
'Original plan world identity differs')
|
||||
require(len(plan['changes']) == batch['blocks'], 'Original plan block count differs')
|
||||
for change in plan['changes']:
|
||||
at = point(change['pos'])
|
||||
require(at not in states, 'Original plans overlap')
|
||||
states[at] = change['desired']
|
||||
plans[batch['key']] = {'plan_id': plan['id'], 'sha256': checksum, 'plan': plan}
|
||||
require(len(states) == model['blocks'], 'Original saved plans are incomplete')
|
||||
return model, states, plans
|
||||
|
||||
|
||||
def targets(model, states):
|
||||
scene = build_scene()
|
||||
local = [(x, 30, z) for x in (6, 32) for z in (14, 22, 30, 38, 46, 54)]
|
||||
local += [(x, 34, 12) for x in (7, 31)]
|
||||
local += [(35, 8, z) for z in range(36, 40)]
|
||||
require(len(local) == len(set(local)) == 18, 'Finish mask must contain exactly 18 cells')
|
||||
result = []
|
||||
for at in local:
|
||||
world = tuple(a + b for a, b in zip(at, model['origin']))
|
||||
before = states.get(world)
|
||||
require(before is not None and canonical(scene.blocks.get(at, 'minecraft:air')) == canonical(before),
|
||||
f'Frozen scene differs from original canonical plan at local {at}')
|
||||
desired = STAIR if at[0] == 35 else 'minecraft:stone_bricks'
|
||||
if at[0] != 35:
|
||||
require(before == 'minecraft:stone_brick_slab[type=bottom,waterlogged=false]',
|
||||
f'Expected the original pinnacle cap at local {at}')
|
||||
else:
|
||||
require(before == 'minecraft:spruce_planks', f'Expected the original gallery floor at local {at}')
|
||||
result.append({'local': list(at), 'pos': position(world), 'expected': before, 'desired': desired})
|
||||
return result
|
||||
|
||||
|
||||
def inspect_patch(backend, changes, field):
|
||||
for group in ([c for c in changes if c['local'][1] == y] for y in (30, 34, 8)):
|
||||
locations = [point(c['pos']) for c in group]
|
||||
lo = position([min(p[i] for p in locations) for i in range(3)])
|
||||
hi = position([max(p[i] for p in locations) for i in range(3)])
|
||||
result = backend.rpc('region_inspect', {'min': lo, 'max': hi, 'detail': 'blocks'})
|
||||
live = {point(b['pos']): b['state'] for b in result['blocks']}
|
||||
for change in group:
|
||||
require(live.get(point(change['pos'])) == change[field],
|
||||
f'Live {field} mismatch at local {tuple(change["local"])}; preserve current world')
|
||||
|
||||
|
||||
def validate_saved_finish(ledger, changes, model, plans):
|
||||
require(ledger['version'] == 1 and ledger['manifest_sha256'] == digest(model), 'Finish ledger blueprint mismatch')
|
||||
require(ledger['changes'] == changes, 'Immutable finish mask changed')
|
||||
sources = {key: {'plan_id': value['plan_id'], 'sha256': value['sha256']} for key, value in plans.items()}
|
||||
require(ledger['source_plans'] == sources, 'Original plan references changed')
|
||||
plan, checksum = envelope(CONFIG.parent / 'journal/plans' / (ledger['plan']['plan_id'] + '.json'), 'plan')
|
||||
require(checksum == ledger['plan']['plan_hash'], 'Prepared finish plan hash mismatch')
|
||||
require(plan['worldEpoch'] == model['world_epoch'] and plan['region']['worldId'] == model['world_id'],
|
||||
'Finish plan world identity changed')
|
||||
expected = {point(c['pos']): (c['expected'], c['desired']) for c in changes}
|
||||
actual = {point(c['pos']): (c['expected'], c['desired']) for c in plan['changes']}
|
||||
require(len(plan['changes']) == 18 and actual == expected,
|
||||
'Prepared expected/desired states differ from reviewed original states; do not apply')
|
||||
|
||||
|
||||
def operation_id(ledger):
|
||||
matches = []
|
||||
for path in (CONFIG.parent / 'journal/operations').glob('*.json'):
|
||||
operation, _ = envelope(path, 'operation')
|
||||
if operation['planId'] == ledger['plan']['plan_id'] and operation['idempotencyKey'] == ledger['idempotency_key']:
|
||||
matches.append(operation['id'])
|
||||
require(len(matches) <= 1, 'Multiple operations share the finish identity')
|
||||
return matches[0] if matches else None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('action', choices=['plan', 'apply', 'verify'])
|
||||
args = parser.parse_args()
|
||||
model, originals, plans = references()
|
||||
changes = targets(model, originals)
|
||||
backend = Backend()
|
||||
context = backend.rpc('project_context', {})
|
||||
require(context['world_id'] == model['world_id'] and context['world_epoch'] == model['world_epoch'],
|
||||
'Live world identity changed')
|
||||
if args.action == 'plan' and not LEDGER.exists():
|
||||
inspect_patch(backend, changes, 'expected')
|
||||
recipe = {'version': 1, 'operations': [
|
||||
{'type': 'box', 'min': c['pos'], 'max': c['pos'], 'block': c['desired']} for c in changes]}
|
||||
summary = backend.rpc('build_prepare', {'recipe': recipe})
|
||||
ledger = {'version': 1, 'manifest_sha256': digest(model), 'scope': backend.scope,
|
||||
'source_plans': {k: {'plan_id': p['plan_id'], 'sha256': p['sha256']} for k, p in plans.items()},
|
||||
'changes': changes, 'plan': summary, 'idempotency_key': 'gothic-hall-finish-' + summary['plan_id']}
|
||||
validate_saved_finish(ledger, changes, model, plans)
|
||||
save(LEDGER, ledger, immutable=True)
|
||||
require(LEDGER.is_file(), 'Run plan first; finish-ledger.json must be durably saved before applying')
|
||||
ledger = json.loads(LEDGER.read_text())
|
||||
validate_saved_finish(ledger, changes, model, plans)
|
||||
require(ledger['scope'] == backend.scope, 'Finish owner/project scope changed')
|
||||
if args.action == 'plan':
|
||||
print(json.dumps({'status': 'prepared', 'changes': 18, 'plan_id': ledger['plan']['plan_id'],
|
||||
'expires_at': ledger['plan']['expires_at'], 'immutable_ledger': str(LEDGER)}))
|
||||
return
|
||||
ident = operation_id(ledger)
|
||||
if args.action == 'apply':
|
||||
# Always keep the persisted key. A lost response must never cause a fresh operation.
|
||||
if ident is None:
|
||||
result = backend.rpc('build_apply', {**ledger['plan'], 'idempotency_key': ledger['idempotency_key']})
|
||||
ident = result['operation_id']
|
||||
for _ in range(600):
|
||||
status = backend.rpc('operation_status', {'operation_id': ident})
|
||||
if status['status'] not in ('queued', 'applying'):
|
||||
break
|
||||
time.sleep(.1)
|
||||
require(status['status'] == 'applied', f'Finish operation stopped: {status["status"]}; inspect before continuing')
|
||||
inspect_patch(backend, changes, 'desired')
|
||||
print(json.dumps({'status': 'applied', 'changes': 18, 'written': status['written'], 'operation_id': ident}))
|
||||
return
|
||||
require(ident is not None, 'Finish operation has not been applied')
|
||||
status = backend.rpc('operation_status', {'operation_id': ident})
|
||||
require(status['status'] == 'applied', 'Finish operation is not fully applied')
|
||||
final = dict(originals)
|
||||
final.update({point(c['pos']): c['desired'] for c in changes})
|
||||
for batch in model['batches']:
|
||||
result = backend.rpc('region_inspect', {'min': batch['min'], 'max': batch['max'], 'detail': 'blocks'})
|
||||
live = {point(b['pos']): b['state'] for b in result['blocks']}
|
||||
for change in plans[batch['key']]['plan']['changes']:
|
||||
at = point(change['pos'])
|
||||
require(live.get(at) == final[at], f'Final build differs at world {at}; preserve current world')
|
||||
report = {'status': 'verified', 'blocks': len(final), 'batches': len(model['batches']), 'finish_changes': 18,
|
||||
'operation_id': ident, 'manifest_sha256': digest(model), 'finish_ledger_sha256': digest(ledger),
|
||||
'verified_at': time.time(), 'scope': backend.scope}
|
||||
save(OUTPUT / 'finish-verification.json', report)
|
||||
print(json.dumps(report))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except (OSError, ValueError, KeyError, RuntimeError) as error:
|
||||
raise SystemExit('Finish stopped: ' + str(error)) from None
|
||||
Executable
+319
@@ -0,0 +1,319 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Capture a real PNG through Paper and the Fabric worker using one online owner/spectator.
|
||||
|
||||
Does not start Minecraft/Paper, change configuration, or fabricate/alter images. By default,
|
||||
uses the pose saved in game with /ai camera save test. Run with --delay 8 and return to the
|
||||
Minecraft window, close menus, and keep the player still until the capture completes.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import binascii
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
import zlib
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MAX_RESPONSE = 12 * 1024 * 1024
|
||||
PNG_SIGNATURE = b'\x89PNG\r\n\x1a\n'
|
||||
SAFE_METADATA = {
|
||||
'status', 'captureId', 'capturedAt', 'dimension', 'x', 'y', 'z', 'eyeY', 'yaw', 'pitch',
|
||||
'fov', 'readiness', 'serverRevisionVerified', 'loadedChunkRadius', 'stabilizationTicks',
|
||||
'stabilizationFrames', 'afterOperationId', 'width', 'height', 'sourceWidth', 'sourceHeight',
|
||||
'mimeType',
|
||||
}
|
||||
|
||||
|
||||
class TestFailure(Exception):
|
||||
"""Messages are selected locally and never contain raw HTTP bodies or header values."""
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, request, response, code, message, headers, new_url):
|
||||
raise TestFailure('Refusing an HTTP redirect from the local camera/Paper endpoint.')
|
||||
|
||||
|
||||
def scalar(text, key):
|
||||
matches = re.findall(r'^' + re.escape(key) + r':[ \t]*(.*?)[ \t]*$', text, re.M)
|
||||
if len(matches) != 1:
|
||||
raise TestFailure('Missing or duplicate top-level configuration field: ' + key)
|
||||
raw = matches[0].strip()
|
||||
if raw.startswith("'"):
|
||||
match = re.fullmatch(r"'((?:[^']|'')*)'[ \t]*(?:#.*)?", raw)
|
||||
if match:
|
||||
return match.group(1).replace("''", "'")
|
||||
elif raw.startswith('"'):
|
||||
match = re.fullmatch(r'("(?:[^"\\]|\\.)*")[ \t]*(?:#.*)?', raw)
|
||||
if match:
|
||||
try:
|
||||
value = json.loads(match.group(1))
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
else:
|
||||
return re.split(r'[ \t]+#', raw, maxsplit=1)[0].strip()
|
||||
raise TestFailure('Use a single Paper-generated scalar for configuration field: ' + key)
|
||||
|
||||
|
||||
def identifier(value, label):
|
||||
try:
|
||||
parsed = str(uuid.UUID(value))
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
raise TestFailure(label + ' must be configured as a UUID.') from None
|
||||
return parsed
|
||||
|
||||
|
||||
def port(value, label):
|
||||
if not value.isdigit() or not 1 <= int(value) <= 65535:
|
||||
raise TestFailure(label + ' must be a port in 1..65535.')
|
||||
return int(value)
|
||||
|
||||
|
||||
def instant(value, label):
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace('Z', '+00:00'))
|
||||
if parsed.tzinfo is None:
|
||||
raise ValueError()
|
||||
return parsed.timestamp()
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
raise TestFailure(label + ' is missing or invalid.') from None
|
||||
|
||||
|
||||
def request_json(opener, url, token, body=None, timeout=10):
|
||||
headers = {'Authorization': 'Bearer ' + token, 'Accept': 'application/json'}
|
||||
if body is not None:
|
||||
headers['Content-Type'] = 'application/json'
|
||||
request = urllib.request.Request(url, headers=headers,
|
||||
data=None if body is None else json.dumps(body, allow_nan=False).encode('utf-8'),
|
||||
method='GET' if body is None else 'POST')
|
||||
try:
|
||||
with opener.open(request, timeout=timeout) as response:
|
||||
raw = response.read(MAX_RESPONSE + 1)
|
||||
if len(raw) > MAX_RESPONSE:
|
||||
raise TestFailure('Backend response exceeds the 12 MiB limit.')
|
||||
value = json.loads(raw)
|
||||
if not isinstance(value, dict):
|
||||
raise TestFailure('Backend returned an invalid JSON object.')
|
||||
return value
|
||||
except urllib.error.HTTPError as error:
|
||||
# Do not relay the body, reason, request object, headers, or arbitrary exception text.
|
||||
raise TestFailure('Local endpoint rejected the request (HTTP ' + str(error.code) + ').') from None
|
||||
except (urllib.error.URLError, TimeoutError, ConnectionError, OSError):
|
||||
raise TestFailure('Local endpoint is unavailable or timed out; check the running Paper/Fabric services.') from None
|
||||
except (json.JSONDecodeError, UnicodeError):
|
||||
raise TestFailure('Local endpoint returned invalid JSON.') from None
|
||||
|
||||
|
||||
def rpc(opener, endpoint, token, scope, method, params=None, timeout=10):
|
||||
reply = request_json(opener, endpoint + '/v1/rpc', token,
|
||||
{'method': method, 'params': {**(params or {}), **scope}}, timeout=timeout)
|
||||
if reply.get('ok') is not True or not isinstance(reply.get('result'), dict):
|
||||
code = reply.get('error', {}).get('code', '') if isinstance(reply.get('error'), dict) else ''
|
||||
safe_code = code if isinstance(code, str) and re.fullmatch(r'[a-z_]{1,64}', code) else 'request_failed'
|
||||
raise TestFailure('Paper rejected the scoped request: ' + safe_code)
|
||||
return reply['result']
|
||||
|
||||
|
||||
def check_health(health, owner):
|
||||
if health.get('status') != 'ok' or health.get('connected') is not True:
|
||||
raise TestFailure('The Fabric camera is not connected to a world.')
|
||||
if health.get('spectator') is not True:
|
||||
raise TestFailure('The owner must already be in spectator mode; this script does not change game modes.')
|
||||
if identifier(health.get('playerId'), 'Worker player ID') != owner:
|
||||
raise TestFailure('The worker is using a different account from the configured owner.')
|
||||
age = time.time() - instant(health.get('updatedAt'), 'Worker heartbeat')
|
||||
if age < -5 or age > 5:
|
||||
raise TestFailure('The worker heartbeat is stale; ensure Minecraft is ticking and the clocks agree.')
|
||||
if health.get('busy') is True:
|
||||
raise TestFailure('The camera is already busy; finish its current capture before running the test.')
|
||||
|
||||
|
||||
def validate_pose(values):
|
||||
x, y, z, yaw, pitch = values
|
||||
if not all(math.isfinite(value) for value in values):
|
||||
raise TestFailure('Pose values must all be finite numbers.')
|
||||
if abs(x) > 29_999_984 or abs(z) > 29_999_984 or not -2048 <= y <= 2048:
|
||||
raise TestFailure('Pose is outside the camera coordinate limits.')
|
||||
if not -360 <= yaw <= 360 or not -90 <= pitch <= 90:
|
||||
raise TestFailure('Yaw must be -360..360 and pitch -90..90.')
|
||||
return dict(zip(('x', 'y', 'z', 'yaw', 'pitch'), values))
|
||||
|
||||
|
||||
def inspect_png(raw):
|
||||
"""Verify bytes received from the worker, without re-encoding or generating an image."""
|
||||
if not raw.startswith(PNG_SIGNATURE) or len(raw) > 8 * 1024 * 1024:
|
||||
raise TestFailure('Worker did not return a bounded PNG image.')
|
||||
offset, dimensions, saw_pixels = 8, None, False
|
||||
while offset + 12 <= len(raw):
|
||||
length = struct.unpack_from('>I', raw, offset)[0]
|
||||
kind = raw[offset + 4:offset + 8]
|
||||
if length > len(raw) - offset - 12:
|
||||
raise TestFailure('PNG chunk is truncated.')
|
||||
data = raw[offset + 8:offset + 8 + length]
|
||||
expected = struct.unpack_from('>I', raw, offset + 8 + length)[0]
|
||||
if zlib.crc32(kind + data) & 0xffffffff != expected:
|
||||
raise TestFailure('PNG checksum does not match the received data.')
|
||||
if offset == 8:
|
||||
if kind != b'IHDR' or length != 13:
|
||||
raise TestFailure('PNG has no valid IHDR header.')
|
||||
dimensions = struct.unpack_from('>II', data)
|
||||
if not 1 <= dimensions[0] <= 1920 or not 1 <= dimensions[1] <= 1080:
|
||||
raise TestFailure('PNG dimensions exceed the output limits.')
|
||||
if kind == b'IDAT':
|
||||
saw_pixels = True
|
||||
offset += length + 12
|
||||
if kind == b'IEND':
|
||||
if length != 0 or offset != len(raw) or not saw_pixels:
|
||||
raise TestFailure('PNG is incomplete or contains trailing data.')
|
||||
return dimensions
|
||||
raise TestFailure('PNG does not contain a complete image.')
|
||||
|
||||
|
||||
def write_private(path, data):
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
with os.fdopen(fd, 'wb') as out:
|
||||
out.write(data)
|
||||
out.flush()
|
||||
os.fsync(out.fileno())
|
||||
|
||||
|
||||
def run(args):
|
||||
try:
|
||||
text = args.config.read_text(encoding='utf-8')
|
||||
except (OSError, UnicodeError):
|
||||
raise TestFailure('Cannot read the private Paper config; start/configure the plugin first.') from None
|
||||
owner = identifier(scalar(text, 'owner-uuid'), 'Owner UUID')
|
||||
camera_player = identifier(scalar(text, 'camera-player-uuid'), 'Camera player UUID')
|
||||
if owner != camera_player:
|
||||
raise TestFailure('This single-client test requires camera-player-uuid to equal owner-uuid.')
|
||||
agent_token, camera_token = scalar(text, 'agent-token'), scalar(text, 'camera-token')
|
||||
if any(not re.fullmatch(r'[A-Za-z0-9._~-]{32,512}', value) for value in (agent_token, camera_token)) or agent_token == camera_token:
|
||||
raise TestFailure('Agent and camera tokens must be distinct safe values; contents are not displayed.')
|
||||
project = scalar(text, 'project-id')
|
||||
if not project or len(project) > 256:
|
||||
raise TestFailure('A valid project-id is required.')
|
||||
endpoint = 'http://127.0.0.1:' + str(port(scalar(text, 'http-port'), 'http-port'))
|
||||
worker = 'http://127.0.0.1:' + str(port(scalar(text, 'camera-port'), 'camera-port'))
|
||||
scope = {'project_id': project, 'player_id': owner}
|
||||
# Never let HTTP_PROXY/HTTPS_PROXY forward private capability headers away from loopback.
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect())
|
||||
health = request_json(opener, worker + '/health', camera_token)
|
||||
check_health(health, owner)
|
||||
# This authenticates the real in-game owner and confirms the configured Paper server is reachable.
|
||||
context = rpc(opener, endpoint, agent_token, scope, 'project_context')
|
||||
if context.get('project_id') != project:
|
||||
raise TestFailure('Paper returned a different project scope.')
|
||||
if args.pose:
|
||||
params = {'pose': validate_pose(args.pose)}
|
||||
if args.fov is not None:
|
||||
params['pose']['fov'] = args.fov
|
||||
pose_label = 'explicit pose'
|
||||
else:
|
||||
cameras = rpc(opener, endpoint, agent_token, scope, 'camera_list')
|
||||
if not any(isinstance(camera, dict) and camera.get('camera_id') == args.camera_id for camera in cameras.get('cameras', [])):
|
||||
raise TestFailure('Saved camera was not found. In Minecraft run /ai camera save test, or pass --pose X Y Z YAW PITCH.')
|
||||
params = {'camera_id': args.camera_id}
|
||||
pose_label = 'saved camera'
|
||||
if args.after_operation_id:
|
||||
params['after_operation_id'] = identifier(args.after_operation_id, 'Operation ID')
|
||||
print('Ready: online owner/spectator and scoped Paper route verified; using ' + pose_label + '.', flush=True)
|
||||
print('Return to Minecraft, close chat/menus, keep the window rendering, and do not move the mouse or player. Capture starts in '
|
||||
+ str(args.delay) + ' seconds.', flush=True)
|
||||
time.sleep(args.delay)
|
||||
check_health(request_json(opener, worker + '/health', camera_token), owner)
|
||||
started = time.time()
|
||||
deadline = time.monotonic() + args.timeout
|
||||
result = rpc(opener, endpoint, agent_token, scope, 'camera_capture', params, timeout=min(35, args.timeout))
|
||||
capture_id = identifier(result.get('captureId'), 'Capture ID')
|
||||
print('Paper accepted the capture; waiting for the real rendered frame.', flush=True)
|
||||
while result.get('status') == 'pending':
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise TestFailure('Capture did not complete within the test deadline; no image was saved.')
|
||||
time.sleep(min(0.5, remaining))
|
||||
result = rpc(opener, endpoint, agent_token, scope, 'camera_capture', {'capture_id': capture_id}, timeout=min(10, max(0.5, remaining)))
|
||||
if result.get('status') != 'completed':
|
||||
code = result.get('error', '')
|
||||
safe_code = code if isinstance(code, str) and re.fullmatch(r'[a-z_]{1,64}', code) else 'capture_failed'
|
||||
raise TestFailure('The camera returned ' + safe_code + '; no image was saved.')
|
||||
if result.get('captureId') != capture_id or result.get('mimeType') != 'image/png':
|
||||
raise TestFailure('Completed capture identity or MIME type did not match.')
|
||||
captured_at = instant(result.get('capturedAt'), 'Capture timestamp')
|
||||
if captured_at < started - 1 or captured_at > time.time() + 5:
|
||||
raise TestFailure('The frame timestamp does not correspond to this capture request.')
|
||||
encoded = result.get('imageBase64')
|
||||
if not isinstance(encoded, str) or len(encoded) > 12_000_000:
|
||||
raise TestFailure('Completed capture did not include a bounded image payload.')
|
||||
try:
|
||||
raw = base64.b64decode(encoded, validate=True)
|
||||
except (ValueError, binascii.Error):
|
||||
raise TestFailure('Completed capture contained invalid image encoding.') from None
|
||||
width, height = inspect_png(raw)
|
||||
if result.get('width') != width or result.get('height') != height:
|
||||
raise TestFailure('PNG dimensions disagree with the capture metadata.')
|
||||
metadata = {key: value for key, value in result.items() if key in SAFE_METADATA}
|
||||
# Include only known primitive fields; a backend must not smuggle an arbitrary nested payload here.
|
||||
if any(not isinstance(value, (str, int, float, bool, type(None))) for value in metadata.values()):
|
||||
raise TestFailure('Capture metadata contains unexpected structured values.')
|
||||
metadata.update(imageSha256=hashlib.sha256(raw).hexdigest(), imageBytes=len(raw), elapsedSeconds=round(time.time() - started, 3),
|
||||
transport='authenticated Paper HTTP camera_capture', testMode='one owner/spectator client')
|
||||
safe_json = json.dumps(metadata, ensure_ascii=False, allow_nan=False, indent=2).encode('utf-8') + b'\n'
|
||||
if agent_token.encode() in safe_json or camera_token.encode() in safe_json:
|
||||
raise TestFailure('Refusing to save metadata containing a component secret.')
|
||||
directory = ROOT / '.runtime/camera-test'
|
||||
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
prefix = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ') + '-' + capture_id[:8]
|
||||
image_path, metadata_path = directory / (prefix + '.png'), directory / (prefix + '.json')
|
||||
write_private(image_path, raw)
|
||||
write_private(metadata_path, safe_json)
|
||||
print('Saved real PNG: ' + str(image_path), flush=True)
|
||||
print('Saved sanitized metadata: ' + str(metadata_path), flush=True)
|
||||
print('Frame size: ' + str(width) + 'x' + str(height) + '. Server revision verified: '
|
||||
+ str(result.get('serverRevisionVerified') is True).lower() + '.', flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument('--config', type=Path, default=ROOT / '.runtime/server/plugins/MinecraftBuilderMCP/config.yml')
|
||||
source = parser.add_mutually_exclusive_group()
|
||||
source.add_argument('--camera-id', default='test', help='Saved in-game camera name; default: test')
|
||||
source.add_argument('--pose', type=float, nargs=5, metavar=('X', 'Y', 'Z', 'YAW', 'PITCH'), help='Explicit feet position and viewing angles')
|
||||
parser.add_argument('--fov', type=int, help='Field of view for an explicit --pose (30..110 degrees)')
|
||||
parser.add_argument('--after-operation-id', help='Optional completed operation UUID for Paper validation and correlation')
|
||||
parser.add_argument('--delay', type=int, default=8, help='Seconds to return to the Minecraft window before capture (0..60)')
|
||||
parser.add_argument('--timeout', type=int, default=45, help='Total capture/poll budget in seconds (20..120)')
|
||||
args = parser.parse_args()
|
||||
if args.fov is not None and (not args.pose or not 30 <= args.fov <= 110):
|
||||
parser.error('--fov requires --pose and must be 30..110')
|
||||
if not 0 <= args.delay <= 60 or not 20 <= args.timeout <= 120:
|
||||
parser.error('--delay must be 0..60 and --timeout must be 20..120')
|
||||
if args.camera_id and (not args.camera_id.strip() or len(args.camera_id) > 64):
|
||||
parser.error('--camera-id must contain 1..64 characters')
|
||||
try:
|
||||
return run(args)
|
||||
except TestFailure as error:
|
||||
print('Camera test failed: ' + str(error), file=sys.stderr)
|
||||
return 1
|
||||
except KeyboardInterrupt:
|
||||
print('Camera test interrupted; the worker will expire its current request.', file=sys.stderr)
|
||||
return 130
|
||||
except Exception:
|
||||
# Unexpected errors can include HTTP header details; never print a traceback with private state.
|
||||
print('Camera test failed unexpectedly; no private configuration or raw exception is printed.', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Actual Paper integration tests in the disposable .runtime/server fixture (EULA must already be accepted)."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
SERVER=ROOT/'.runtime/server'
|
||||
TOKEN=''
|
||||
ADMIN_TOKEN=''
|
||||
SCOPE={'player_id':'console','project_id':'default'}
|
||||
|
||||
def request(method, params=None, expect_error=False, admin=False):
|
||||
payload={'method':method,'params':{**(params or {}),**SCOPE},'requestId':'live-test'}
|
||||
req=urllib.request.Request('http://127.0.0.1:8765/v1/rpc',data=json.dumps(payload).encode(),headers={'Authorization':'Bearer '+(ADMIN_TOKEN if admin else TOKEN),'Content-Type':'application/json'})
|
||||
try:
|
||||
with urllib.request.urlopen(req,timeout=25) as r: body=json.load(r)
|
||||
except urllib.error.HTTPError as e: body=json.load(e)
|
||||
if expect_error:
|
||||
assert not body['ok'],body
|
||||
return body['error']
|
||||
assert body['ok'],body
|
||||
return body['result']
|
||||
|
||||
def pos(x=8,y=96,z=8):return {'x':x,'y':y,'z':z}
|
||||
def inspect(at):return request('region_inspect',{'min':at,'max':at,'detail':'blocks'})['blocks'][0]['state']
|
||||
def prepare(lo=None,hi=None,block='minecraft:stone_bricks',dependencies=None):
|
||||
return request('build_prepare',{'recipe':{'version':1,'operations':[{'type':'box','min':lo or pos(),'max':hi or pos(10,98,10),'block':block}]},'dependencies':dependencies or []})
|
||||
def apply(plan,key):return request('build_apply',{'plan_id':plan['plan_id'],'plan_hash':plan['plan_hash'],'idempotency_key':key})
|
||||
def wait(predicate,timeout=30):
|
||||
end=time.monotonic()+timeout
|
||||
while time.monotonic()<end:
|
||||
try:
|
||||
value=predicate()
|
||||
if value:return value
|
||||
except (OSError,AssertionError):pass
|
||||
time.sleep(.1)
|
||||
raise AssertionError('Timed out waiting for expected test condition')
|
||||
def terminal(op):
|
||||
state=request('operation_status',{'operation_id':op['operation_id']})
|
||||
return state if state['status'] not in ('queued','applying') else None
|
||||
|
||||
if __name__=='__main__':
|
||||
if not (SERVER/'eula.txt').exists() or 'eula=true' not in (SERVER/'eula.txt').read_text():
|
||||
raise SystemExit('First review/accept Minecraft EULA using scripts/dev-server.py --accept-eula. This test does not accept it.')
|
||||
try:
|
||||
urllib.request.urlopen('http://127.0.0.1:8765/health',timeout=1)
|
||||
except OSError:pass
|
||||
else:raise SystemExit('Stop the current dev server before this test; it owns its server process.')
|
||||
config=SERVER/'plugins/MinecraftBuilderMCP/config.yml'
|
||||
if not config.exists():raise SystemExit('Start the prepared server once to generate its private config first.')
|
||||
previous=config.read_text()
|
||||
metadata_path=config.parent/'metadata.json'
|
||||
previous_metadata=metadata_path.read_bytes() if metadata_path.exists() else None
|
||||
config.write_text(previous.replace('allow-local-automation: false','allow-local-automation: true'))
|
||||
TOKEN=re.search(r'^agent-token: (.+)$',previous,re.M).group(1).strip().strip("'\"")
|
||||
ADMIN_TOKEN=re.search(r'^admin-token: (.+)$',previous,re.M).group(1).strip().strip("'\"")
|
||||
logpath=ROOT/'.runtime/live-server-test.log'
|
||||
results=[]
|
||||
log=logpath.open('w')
|
||||
def launch():return subprocess.Popen(['python3','scripts/dev-server.py','--run'],cwd=ROOT,stdin=subprocess.PIPE,stdout=log,stderr=subprocess.STDOUT,text=True,start_new_session=True)
|
||||
process=launch()
|
||||
def console(command):
|
||||
process.stdin.write(command+'\n');process.stdin.flush()
|
||||
def report(name,**data):
|
||||
result={'test':name,**data};results.append(result);print(json.dumps(result),flush=True)
|
||||
try:
|
||||
wait(lambda:urllib.request.urlopen('http://127.0.0.1:8765/health',timeout=1).status==200,90)
|
||||
console('forceload add 0 0 31 31')
|
||||
wait(lambda:inspect(pos())=='minecraft:air')
|
||||
initial=request('region_inspect',{'min':pos(),'max':pos(23,99,11),'detail':'summary'})
|
||||
assert initial['palette']=={'minecraft:air':256},initial['palette']
|
||||
|
||||
plan=prepare()
|
||||
console('setblock 8 96 8 minecraft:granite')
|
||||
wait(lambda:inspect(pos())=='minecraft:granite')
|
||||
conflict=wait(lambda:terminal(apply(plan,'live-conflict-'+plan['plan_id'])))
|
||||
assert conflict['status']=='conflict' and conflict['written']==0,conflict
|
||||
assert inspect(pos())=='minecraft:granite'
|
||||
report('external-change-before-apply',status=conflict['status'],written=conflict['written'])
|
||||
console('setblock 8 96 8 minecraft:air');wait(lambda:inspect(pos())=='minecraft:air')
|
||||
|
||||
dependency=pos(11,96,8)
|
||||
plan=prepare(dependencies=[dependency])
|
||||
console('setblock 11 96 8 minecraft:granite');wait(lambda:inspect(dependency)=='minecraft:granite')
|
||||
changed=wait(lambda:terminal(apply(plan,'live-dependency-'+plan['plan_id'])))
|
||||
assert changed['status']=='conflict' and changed['written']==0,changed
|
||||
report('read-dependency-conflict',status=changed['status'])
|
||||
console('setblock 11 96 8 minecraft:air');wait(lambda:inspect(dependency)=='minecraft:air')
|
||||
|
||||
plan=prepare()
|
||||
op=apply(plan,'live-build-'+plan['plan_id']);built=wait(lambda:terminal(op));assert built['status']=='applied' and built['written']==27,built
|
||||
duplicate=apply(plan,'live-build-'+plan['plan_id']);assert duplicate['operation_id']==op['operation_id']
|
||||
console('setblock 8 96 8 minecraft:granite');wait(lambda:inspect(pos())=='minecraft:granite')
|
||||
error=request('operation_undo_prepare',{'operation_id':op['operation_id']},expect_error=True)
|
||||
assert inspect(pos())=='minecraft:granite'
|
||||
report('undo-preserves-later-external-change',error_code=error['code'])
|
||||
console('setblock 8 96 8 minecraft:stone_bricks');wait(lambda:inspect(pos())=='minecraft:stone_bricks')
|
||||
undo=request('operation_undo_prepare',{'operation_id':op['operation_id']})
|
||||
undone=wait(lambda:terminal(apply(undo,'live-undo-'+undo['plan_id'])));assert undone['status']=='applied',undone
|
||||
assert request('region_inspect',{'min':pos(),'max':pos(10,98,10),'detail':'summary'})['palette']=={'minecraft:air':27}
|
||||
report('build-idempotency-checked-undo',written=built['written'],restored=undone['written'])
|
||||
|
||||
plan=prepare(pos(12,96,8),pos(23,99,11))
|
||||
op=apply(plan,'live-cancel-'+plan['plan_id'])
|
||||
request('operation_cancel',{'operation_id':op['operation_id']})
|
||||
cancelled=wait(lambda:terminal(op));assert cancelled['status']=='cancelled',cancelled
|
||||
if cancelled['written']:
|
||||
undo=request('operation_undo_prepare',{'operation_id':op['operation_id']})
|
||||
assert wait(lambda:terminal(apply(undo,'live-cancel-undo-'+undo['plan_id'])))['status']=='applied'
|
||||
report('cancellation',status=cancelled['status'],written=cancelled['written'])
|
||||
|
||||
console('setblock 8 96 8 minecraft:chest');wait(lambda:inspect(pos()).startswith('minecraft:chest'))
|
||||
failure=request('build_prepare',{'recipe':{'version':1,'operations':[{'type':'box','min':pos(),'max':pos(),'block':'minecraft:stone'}]}},expect_error=True)
|
||||
assert inspect(pos()).startswith('minecraft:chest')
|
||||
console('setblock 8 96 8 minecraft:air');wait(lambda:inspect(pos())=='minecraft:air')
|
||||
report('unsupported-existing-block-protected',code=failure['code'])
|
||||
assert request('region_inspect',{'min':pos(),'max':pos(23,99,11),'detail':'summary'})['palette']=={'minecraft:air':256}
|
||||
report('fixture-cleanup',air_blocks=256)
|
||||
|
||||
# Kill only the process group created by this harness, in the disposable fixture.
|
||||
lo,hi=pos(8,100,8),pos(23,115,23)
|
||||
assert request('region_inspect',{'min':lo,'max':hi,'detail':'summary'})['palette']=={'minecraft:air':4096}
|
||||
plan=prepare(lo,hi);op=apply(plan,'live-crash-'+plan['plan_id'])
|
||||
def in_progress():
|
||||
s=request('operation_status',{'operation_id':op['operation_id']})
|
||||
return s if s['written']>0 and s['status']=='applying' else None
|
||||
progress=wait(in_progress)
|
||||
os.killpg(process.pid,signal.SIGKILL);process.wait(timeout=10)
|
||||
time.sleep(.5)
|
||||
# Simulate an administrator protecting an affected part while offline.
|
||||
# Recovery must retain authorization without requiring write permission.
|
||||
metadata=json.loads(previous_metadata) if previous_metadata else {'parts':[],'cameras':{}}
|
||||
metadata['parts'].append({'id':'live-recovery-protection','name':'Recovery test protection',
|
||||
'operationId':op['operation_id'],'positions':[lo],'locked':True})
|
||||
metadata_path.write_text(json.dumps(metadata))
|
||||
process=launch()
|
||||
wait(lambda:urllib.request.urlopen('http://127.0.0.1:8765/health',timeout=1).status==200,90)
|
||||
console('forceload add 0 0 31 31')
|
||||
recovered=wait(lambda:terminal(op));assert recovered['status']=='recovery_required',recovered
|
||||
review=request('recovery_review',{'operation_id':op['operation_id']},admin=True)
|
||||
request('recovery_review',{'operation_id':op['operation_id']},expect_error=True)
|
||||
console('setblock 8 100 8 minecraft:granite');wait(lambda:inspect(lo)=='minecraft:granite')
|
||||
request('recovery_abandon',{'operation_id':op['operation_id'],'expected_digest':review['currentDigest']},expect_error=True,admin=True)
|
||||
fresh=request('recovery_review',{'operation_id':op['operation_id']},admin=True)
|
||||
abandoned=request('recovery_abandon',{'operation_id':op['operation_id'],'expected_digest':fresh['currentDigest']},admin=True)
|
||||
assert abandoned['status']=='failed' and inspect(lo)=='minecraft:granite',abandoned
|
||||
request('operation_undo_prepare',{'operation_id':op['operation_id']},expect_error=True)
|
||||
report('crash-recovery-abandon',written_before_crash=progress['written'],recovery_positions=fresh['positions'],foreign_block='preserved',replay=False,protected_part='recovery-allowed')
|
||||
|
||||
console('fill 8 100 8 23 115 23 minecraft:air')
|
||||
wait(lambda:request('region_inspect',{'min':lo,'max':hi,'detail':'summary'})['palette']=={'minecraft:air':4096})
|
||||
protected_plan=prepare(lo,lo,block='minecraft:stone')
|
||||
blocked=request('build_apply',{'plan_id':protected_plan['plan_id'],'plan_hash':protected_plan['plan_hash'],
|
||||
'idempotency_key':'live-protected-'+protected_plan['plan_id']},expect_error=True)
|
||||
assert blocked['code']=='permission_denied' and inspect(lo)=='minecraft:air',blocked
|
||||
report('recovery-retains-part-write-protection',status='permission_denied')
|
||||
plan=prepare(pos(9,100,8),pos(9,100,8));new=wait(lambda:terminal(apply(plan,'live-after-recovery-'+plan['plan_id'])))
|
||||
assert new['status']=='applied',new
|
||||
undo=request('operation_undo_prepare',{'operation_id':new['operation_id']})
|
||||
assert wait(lambda:terminal(apply(undo,'live-after-recovery-undo-'+undo['plan_id'])))['status']=='applied'
|
||||
report('editing-after-durable-recovery',status='applied-and-undone')
|
||||
# Exercise the actual MCP stdio transport, including .schem roundtrip.
|
||||
smoke_env=dict(os.environ,MCB_AGENT_TOKEN=TOKEN,MCB_PLAYER_ID='console',
|
||||
MCB_PROJECT_ID='default',MCB_TEST_X='8',MCB_TEST_Y='96',MCB_TEST_Z='8',
|
||||
MCB_BACKEND_URL='http://127.0.0.1:8765')
|
||||
smoke_env.pop('MCB_TOKEN',None)
|
||||
smoke=subprocess.run(['node','test/live-paper.mjs'],cwd=ROOT/'bridge',env=smoke_env,
|
||||
text=True,stdout=subprocess.PIPE,stderr=subprocess.STDOUT,timeout=90)
|
||||
(ROOT/'.runtime/live-mcp-results.log').write_text(smoke.stdout)
|
||||
print(smoke.stdout,flush=True)
|
||||
assert smoke.returncode==0,'Live MCP smoke failed; inspect .runtime/live-mcp-results.log'
|
||||
report('mcp-stdio-schematic-roundtrip',status='passed')
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
try:console('stop');process.wait(timeout=30)
|
||||
except (OSError,subprocess.TimeoutExpired):process.terminate();process.wait(timeout=15)
|
||||
log.close();config.write_text(previous)
|
||||
if previous_metadata is None:metadata_path.unlink(missing_ok=True)
|
||||
else:metadata_path.write_bytes(previous_metadata)
|
||||
(ROOT/'.runtime/live-server-results.json').write_text(json.dumps(results,indent=2)+'\n')
|
||||
print('LIVE PAPER CONFLICT/UNDO/CANCEL/CRASH TESTS PASSED',flush=True)
|
||||
Reference in New Issue
Block a user