Initial minecraft-builder-mcp prototype and Gothic hall build

This commit is contained in:
Emil
2026-09-12 23:22:20 +03:00
commit 6682668c16
103 changed files with 10064 additions and 0 deletions
+30
View File
@@ -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)
+75
View File
@@ -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())
+136
View File
@@ -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()
+10
View File
@@ -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
+291
View File
@@ -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)
+157
View File
@@ -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}
+176
View File
@@ -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"))
+190
View File
@@ -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]")
+28
View File
@@ -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()
+52
View File
@@ -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))
+278
View File
@@ -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
+319
View File
@@ -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())
+188
View File
@@ -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)