diff --git a/README.md b/README.md index 055e362..6dc5aac 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,10 @@ A Minecraft building editor for collaboration between a human and an AI agent. Working prototype: a Paper plugin, an editing core, an MCP/ACP Bridge, and a Fabric camera mod. Building, conflicts, undo, crash recovery, and `.schem` have been tested against local Paper through HTTP and real MCP stdio. The camera has been tested in Prism with one client: a real 1280×720 PNG was delivered through MCP. Signing in to the separate Codex profile and completing the first model turn through ACP still need verification. +![Reference-based Gothic hall built and inspected in Minecraft](docs/builds/gothic-hall-polished-evening.png) + +[Gothic hall build report](docs/builds/GOTHIC_HALL.md): 30,125 blocks, 53 lanterns, checked incremental edits, and real day, dusk, and interior captures. + ## Features - Reads bounded regions and builds boxes, lines, cylinders, and repeated elements. @@ -90,7 +94,7 @@ JAVA_HOME="$HOME/.cache/minecraft-builder-mcp/jdk-25.0.2" camera-mod/gradlew --p - [Project design and future phases](docs/DESIGN.md). - [Implementation status and verification](docs/IMPLEMENTATION.md). -- [Gothic hall from a reference: 29,354 blocks in the live world](docs/builds/GOTHIC_HALL.md). +- [Gothic hall from a reference: 30,125 blocks in the live world](docs/builds/GOTHIC_HALL.md). - [Protocol](docs/PROTOCOL.md), [editing core and journal](world-core/README.md). - [Bridge, sign-in, and ACP limitations](bridge/README.md). diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 06d2e02..9b636e8 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -29,6 +29,7 @@ The following scenarios passed on real Paper in a separately created test world: - A hollow cube through real MCP: 26 blocks; `.schem` export, the asset library, undo, import at the same anchor, and another undo restoring 27 blocks of air. - A missing camera returns an error without substituting an image. - After installing the mod in Prism, a 575-block tower was built and four real 1280×720 captures were obtained. The last completed the full Camera → Paper → Bridge → MCP ImageContent path. The first request failed when the viewpoint changed; a retry with the client stationary succeeded. [Test report](ONE_CLIENT_TEST.md). +- A reference-based Gothic hall was built, photographed, and polished through checked recipes: 30,125 final blocks and 53 lanterns. Final verification covered 30,843 positions, including 718 removals; grass growth on eight newly planted soil blocks was explicitly recorded. Day, dusk, and interior captures were inspected. Nonpersistent leaves and waterlogged lantern states were rejected by the updated live Paper policy. [Build report](builds/GOTHIC_HALL.md). Real `codex-acp` completed `initialize` in a separate profile without login: ACP v1 and session loading support are confirmed. This does not yet test a model turn or tool calls after authentication. The tests did not invoke a model or consume model tokens. @@ -54,4 +55,4 @@ Real `codex-acp` completed `initialize` in a separate profile without login: ACP 1. The user logs in to the dedicated Codex profile, connects to Paper, and binds the owner. Test a small build requested through in-game `/ai`, response streaming, MCP use, and cancellation. 2. Extend the verified single-client Prism scenario: test disconnection/freezing during capture and convenient switching between building and camera use. -3. Complete the build → inspect → correct cycle, then reassess release readiness, limits, context deltas, and geometry extensions. +3. Extend the demonstrated build → inspect → correct cycle to an authenticated ACP model turn, then reassess release readiness, limits, context deltas, and geometry extensions. diff --git a/docs/builds/GOTHIC_HALL.md b/docs/builds/GOTHIC_HALL.md index 9e7d211..d9fd96b 100644 --- a/docs/builds/GOTHIC_HALL.md +++ b/docs/builds/GOTHIC_HALL.md @@ -1,5 +1,35 @@ # Gothic Hall +## Polished version + +The current build contains **30,125 blocks**, including **53 lanterns** and **642 persistent leaf blocks**. The polishing pass changed 4,569 positions across 87 checked operations: 1,489 additions, 718 removals, and 2,362 replacements. + +The main roof now has six small dormers instead of ten large ones. A thin iron ridge and window mullions, quieter gray stone bands, and a gold block bell bring the architecture closer to the reference. Portal lights, courtyard lamps, three nave chandeliers, wing and tower lights, low planted beds, and six narrow trees complete the scene. The existing entrance, stairs, timber structure, and connecting passage remain usable. + +![Polished Gothic Hall in daylight](gothic-hall-polished-day.png) + +![Polished Gothic Hall at dusk](gothic-hall-polished-evening.png) + +![Lanterns and chandeliers inside the great hall](gothic-hall-polished-interior.png) + +These are unprocessed Minecraft framebuffer captures without shaders: [day metadata](gothic-hall-polished-day.capture.json), [evening metadata](gothic-hall-polished-evening.capture.json), and [interior metadata](gothic-hall-polished-interior.capture.json). The exterior views use the same position and angles as the original capture below, with the server view distance increased to 12 chunks. + +The new geometry lives in `polish_architecture.py` and `polish_scene.py` alongside the frozen original modules. After constructing the baseline and its 18-block finishing pass, run: + +```bash +python3 scripts/polish-gothic-hall.py plan +python3 scripts/polish-gothic-hall.py apply +python3 scripts/polish-gothic-hall.py verify +``` + +The manifest, ledger, and verification report are in `.runtime/gothic-hall/polish/`. Preparation checks the canonical baseline and the current blocks; a saved plan must still expect the same states. Every operation receives a durable idempotency key before application. The original manifests and ledgers are preserved. + +Final verification checked **30,843 positions**, including all 30,125 occupied positions and 718 removals to air, across 90 inspection tiles. It explicitly reported eight planted soil blocks observed as grass rather than dirt. This allowance is limited to the newly introduced planter soil during verification; preparation and application still require exact current states, and other mismatches stop the script without overwriting them. The final operation was `e7f0371d-e64c-48e0-86ae-13ac813dab98`. + +The world and journal were backed up locally before polishing, and the finished world was saved with `save-all flush`. The camera remains honest about its heuristic readiness: `serverRevisionVerified` is `false`; block verification is a separate server read. The old baseline verification commands below describe their historical stages and will report expected differences after this polishing pass. + +## Original baseline + A reproducible build based on the [visual reference](../references/gothic-hall-v1.png): a large hall with galleries, a smaller two-story wing, a connecting passage, and a bell tower. Modules in `scripts/builds/gothic_hall/` generate the geometry; `scripts/build-gothic-hall.py` applies it through the Paper plugin's checked operations. This report records the original baseline before subsequent decorative polishing. It adapts the reference to the available palette: stone bricks, andesite, diorite, dark deepslate, wood, and tinted glass. No shaders were used. Detailed furnishing and landscaping were not part of this baseline; it includes a terrace, stairs, simple benches, and a raised platform. The bell is built from blocks, without a bell entity. diff --git a/docs/builds/gothic-hall-polished-day.capture.json b/docs/builds/gothic-hall-polished-day.capture.json new file mode 100644 index 0000000..d401d9e --- /dev/null +++ b/docs/builds/gothic-hall-polished-day.capture.json @@ -0,0 +1,29 @@ +{ + "capturedAt": "2026-09-12T20:42:08.334749638Z", + "dimension": "minecraft:overworld", + "x": 0.0, + "y": -20.0, + "z": -63.0, + "eyeY": -18.380000114440918, + "yaw": 25.0, + "pitch": 16.0, + "fov": 85, + "readiness": "local_chunks_and_render_queue_stable", + "serverRevisionVerified": false, + "loadedChunkRadius": 1, + "stabilizationTicks": 24, + "stabilizationFrames": 3, + "afterOperationId": "e7f0371d-e64c-48e0-86ae-13ac813dab98", + "status": "completed", + "mimeType": "image/png", + "width": 1280, + "height": 720, + "sourceWidth": 1280, + "sourceHeight": 720, + "captureId": "c219512d-ce8a-4e75-ba24-77e470abb061", + "imageSha256": "bbefeae9f00e33adbda3a98fa85c91fae43d2590339a6036b0a352ddd75f7a7a", + "imageBytes": 813940, + "elapsedSeconds": 2.063, + "transport": "authenticated Paper HTTP camera_capture", + "testMode": "one owner/spectator client" +} diff --git a/docs/builds/gothic-hall-polished-day.png b/docs/builds/gothic-hall-polished-day.png new file mode 100644 index 0000000..25b7d48 Binary files /dev/null and b/docs/builds/gothic-hall-polished-day.png differ diff --git a/docs/builds/gothic-hall-polished-evening.capture.json b/docs/builds/gothic-hall-polished-evening.capture.json new file mode 100644 index 0000000..c70959e --- /dev/null +++ b/docs/builds/gothic-hall-polished-evening.capture.json @@ -0,0 +1,29 @@ +{ + "capturedAt": "2026-09-12T20:40:31.617476861Z", + "dimension": "minecraft:overworld", + "x": 0.0, + "y": -20.0, + "z": -63.0, + "eyeY": -18.380000114440918, + "yaw": 25.0, + "pitch": 16.0, + "fov": 85, + "readiness": "local_chunks_and_render_queue_stable", + "serverRevisionVerified": false, + "loadedChunkRadius": 1, + "stabilizationTicks": 24, + "stabilizationFrames": 3, + "afterOperationId": "e7f0371d-e64c-48e0-86ae-13ac813dab98", + "status": "completed", + "mimeType": "image/png", + "width": 1280, + "height": 720, + "sourceWidth": 1280, + "sourceHeight": 720, + "captureId": "c1ac75c3-f21e-484e-be35-d783790cdeae", + "imageSha256": "637bd82eb213ed609cbc1625e62fec96858218df000f18998e22c96294222dfa", + "imageBytes": 677364, + "elapsedSeconds": 2.019, + "transport": "authenticated Paper HTTP camera_capture", + "testMode": "one owner/spectator client" +} diff --git a/docs/builds/gothic-hall-polished-evening.png b/docs/builds/gothic-hall-polished-evening.png new file mode 100644 index 0000000..92fdc6f Binary files /dev/null and b/docs/builds/gothic-hall-polished-evening.png differ diff --git a/docs/builds/gothic-hall-polished-interior.capture.json b/docs/builds/gothic-hall-polished-interior.capture.json new file mode 100644 index 0000000..3072d56 --- /dev/null +++ b/docs/builds/gothic-hall-polished-interior.capture.json @@ -0,0 +1,29 @@ +{ + "capturedAt": "2026-09-12T20:37:56.024582621Z", + "dimension": "minecraft:overworld", + "x": -31.0, + "y": -51.0, + "z": -29.0, + "eyeY": -49.38000011444092, + "yaw": 0.0, + "pitch": -8.0, + "fov": 70, + "readiness": "local_chunks_and_render_queue_stable", + "serverRevisionVerified": false, + "loadedChunkRadius": 1, + "stabilizationTicks": 21, + "stabilizationFrames": 3, + "afterOperationId": "e7f0371d-e64c-48e0-86ae-13ac813dab98", + "status": "completed", + "mimeType": "image/png", + "width": 1280, + "height": 720, + "sourceWidth": 1280, + "sourceHeight": 720, + "captureId": "8c11e828-4678-40ee-8037-91dd50f11ec9", + "imageSha256": "4587c6169b11f94044bb46daea310d8d02f4ff1980106c896fc82298c656e516", + "imageBytes": 875403, + "elapsedSeconds": 1.478, + "transport": "authenticated Paper HTTP camera_capture", + "testMode": "one owner/spectator client" +} diff --git a/docs/builds/gothic-hall-polished-interior.png b/docs/builds/gothic-hall-polished-interior.png new file mode 100644 index 0000000..a2d413d Binary files /dev/null and b/docs/builds/gothic-hall-polished-interior.png differ diff --git a/paper-plugin/src/main/java/io/github/minecraftbuilder/paper/BuildingWorld.java b/paper-plugin/src/main/java/io/github/minecraftbuilder/paper/BuildingWorld.java index a8f9de5..3008605 100644 --- a/paper-plugin/src/main/java/io/github/minecraftbuilder/paper/BuildingWorld.java +++ b/paper-plugin/src/main/java/io/github/minecraftbuilder/paper/BuildingWorld.java @@ -19,7 +19,9 @@ final class BuildingWorld implements WorldAccess, BlockPolicy { "dirt", "grass_block", "bedrock", "oak_planks", "spruce_planks", "birch_planks", "dark_oak_planks", "oak_log", "spruce_log", "birch_log", "dark_oak_log", "stripped_oak_log", "stripped_spruce_log", "stone_brick_stairs", "cobblestone_stairs", "oak_stairs", "spruce_stairs", "deepslate_tile_stairs", - "stone_brick_slab", "cobblestone_slab", "oak_slab", "spruce_slab", "smooth_stone_slab"); + "stone_brick_slab", "cobblestone_slab", "oak_slab", "spruce_slab", "smooth_stone_slab", + "lantern", "iron_chain", "iron_bars", "stone_brick_wall", "oak_leaves", "moss_block", + "gray_stained_glass", "brown_stained_glass", "glowstone", "gold_block"); BuildingWorld(World world) { this.world = world; } static List supportedMaterials() { return MATERIALS.stream().sorted().map(s->"minecraft:"+s).toList(); } BlockData data(String state) { return parsed.computeIfAbsent(state, Bukkit::createBlockData).clone(); } @@ -28,7 +30,8 @@ final class BuildingWorld implements WorldAccess, BlockPolicy { try { BlockData data = data(state); return MATERIALS.contains(data.getMaterial().getKey().getKey()) - && !(data instanceof org.bukkit.block.data.Waterlogged w && w.isWaterlogged()); + && !(data instanceof org.bukkit.block.data.Waterlogged w && w.isWaterlogged()) + && !(data instanceof org.bukkit.block.data.type.Leaves leaves && !leaves.isPersistent()); } catch (IllegalArgumentException e) { return false; } } private void ready(BlockPos p) { diff --git a/scripts/builds/gothic_hall/polish_architecture.py b/scripts/builds/gothic_hall/polish_architecture.py new file mode 100644 index 0000000..ba772f3 --- /dev/null +++ b/scripts/builds/gothic_hall/polish_architecture.py @@ -0,0 +1,120 @@ +"""Add the architecture polish layer to the finished v1 scene, without I/O. + +The input is scene.Voxels after the eighteen finishing corrections. V1 modules +remain immutable; removed blocks disappear from this sparse voxel composition. +""" + + +def build(v): + """Restore the roof silhouette and add restrained Gothic stone/iron detail.""" + stone = "minecraft:stone_bricks" + trim = "minecraft:polished_andesite" + dark = "minecraft:deepslate_tiles" + glass = "minecraft:gray_stained_glass" + wall = ("minecraft:stone_brick_wall[east=none,north=none,south=none," + "up=true,waterlogged=false,west=none]") + + def stair(material, facing, half="bottom"): + return (f"minecraft:{material}_stairs[facing={facing},half={half}," + "shape=straight,waterlogged=false]") + + def bars(north=False, south=False, east=False, west=False): + flags = {"east": east, "north": north, "south": south, "west": west} + return "minecraft:iron_bars[" + ",".join( + f"{name}={str(value).lower()}" for name, value in flags.items() + ) + ",waterlogged=false]" + + def roof_height(x): + return 43 - (abs(x - 19) * 16 + 13) // 14 + + # Use the actual input mask so a material replacement cannot fill an opening. + # Preserve the six pale blocks of the heraldic cross on the north facade. + crest = {(19, y, 10) for y in range(23, 27)} | { + (x, 25, 10) for x in range(18, 21) + } + timber = { + p: block for p, block in v.blocks.items() + if 9 <= p[0] <= 29 and 28 <= p[1] <= 40 and 15 <= p[2] <= 54 + and (block.startswith("minecraft:dark_oak_log[") + or block == "minecraft:dark_oak_planks") + } + for p, block in tuple(v.blocks.items()): + if block == "minecraft:polished_diorite" and p not in crest: + v.set(*p, trim) + elif block == "minecraft:tinted_glass": + v.set(*p, glass) + elif (block == "minecraft:cut_sandstone" + and 39 <= p[0] <= 41 and 34 <= p[1] <= 37 and 48 <= p[2] <= 50): + v.set(*p, "minecraft:gold_block") + + # Remove only the ten old dormer envelopes, then reconstruct the same + # two-cell roof section. These envelopes exclude both gables and all trusses. + for z in (18, 26, 34, 42, 50): + for x1, x2 in ((10, 14), (24, 28)): + v.clear(x1, 32, z - 2, x2, 40, z + 2) + for x in range(x1, x2 + 1): + high = roof_height(x) + for y in (high - 1, high): + if 32 <= y <= 40: + block = dark if y < high else stair( + "deepslate_tile", "east" if x < 19 else "west") + v.box(x, y, z - 2, x, y, z + 2, block) + + # Three narrow, low dormers per slope: a one-by-two recessed light, three + # blocks of facade width, and a short roof merging into the original slope. + for z in (22, 34, 46): + for face, inner, back in ((10, 11, 13), (28, 27, 25)): + x1, x2 = sorted((face, back)) + near = back - 1 if face < back else back + 1 + v.clear(min(face, near), 33, z, max(face, near), 34, z) + for dz in (-1, 1): + v.box(x1, 32, z + dz, x2, 34, z + dz, dark) + facing = "south" if dz < 0 else "north" + v.box(x1, 35, z + dz, x2, 35, z + dz, + stair("deepslate_tile", facing)) + v.box(face, 33, z + dz, face, 34, z + dz, trim) + v.set(face, 35, z + dz, stair("stone_brick", facing)) + v.box(x1, 36, z, x2, 36, z, dark) + v.box(face, 32, z - 1, face, 32, z + 1, trim) + v.box(inner, 33, z, inner, 34, z, glass) + v.set(face, 35, z, stone) + v.set(face, 36, z, wall) + + # The old bright, chunky ridge is now a dark backing for one thin iron rail. + v.box(19, 43, 14, 19, 43, 55, dark) + for z in range(14, 56): + v.set(19, 44, z, bars(north=z > 14, south=z < 55)) + + # Replace full-block mullions with slender rods in their original glazing + # plane. Connected horizontal bars provide a clear transom without a grille. + for center in (18, 26, 34, 42, 50): + for inner, outside in ((8, 6), (30, 32)): + for y in range(10, 21): + v.set(inner, y, center, bars()) + for dz in range(-2, 3): + v.set(inner, 15, center + dz, + bars(north=dz > -2, south=dz < 2)) + # A bevel on the underside of each stepped arch reduces square caps. + for dz, top in ((-2, 19), (-1, 21), (1, 21), (2, 19)): + v.set(outside, top + 1, center + dz, + stair("stone_brick", "north" if dz < 0 else "south", "top")) + for x in (18, 20): + for y in range(29, 37): + v.set(x, y, 14, bars()) + for x in range(16, 23): + v.set(x, 33, 14, bars(east=x < 22, west=x > 16)) + + # Taper the tips, retaining the full support blocks installed by finish v1. + for x in (6, 32): + for z in (14, 22, 30, 38, 46, 54): + v.box(x, 32, z, x, 33, z, wall) + for x in (7, 31): + v.box(x, 36, 12, x, 37, 12, wall) + for x in (35, 45): + for z in (43, 55): + v.box(x, 49, z, x, 50, z, wall) + + # Two new dormer centers cross truss planes. Keep every original timber + # voxel, including the inclined members, instead of cutting a frame for glass. + for p, block in timber.items(): + v.set(*p, block) diff --git a/scripts/builds/gothic_hall/polish_scene.py b/scripts/builds/gothic_hall/polish_scene.py new file mode 100644 index 0000000..b755471 --- /dev/null +++ b/scripts/builds/gothic_hall/polish_scene.py @@ -0,0 +1,159 @@ +"""Reference-driven finishing layer; the original scene and its ledgers stay immutable.""" +from .scene import build_scene as original_scene +from .polish_architecture import build as architecture + +STONE = 'minecraft:stone_bricks' +TRIM = 'minecraft:polished_andesite' +LEAVES = 'minecraft:oak_leaves[distance=7,persistent=true,waterlogged=false]' +CHAIN = 'minecraft:iron_chain[axis=y,waterlogged=false]' +SLAB = 'minecraft:stone_brick_slab[type=bottom,waterlogged=false]' + + +def stair(facing, half='top'): + return f'minecraft:stone_brick_stairs[facing={facing},half={half},shape=straight,waterlogged=false]' + + +def bars(north=False, east=False, south=False, west=False): + return ('minecraft:iron_bars[' + ','.join(f'{key}={str(value).lower()}' for key, value in + [('north', north), ('east', east), ('south', south), ('west', west)]) + ',waterlogged=false]') + + +def finished_scene(): + v = original_scene() + for x in (6, 32): + for z in (14, 22, 30, 38, 46, 54): + v.set(x, 30, z, STONE) + for x in (7, 31): + v.set(x, 34, 12, STONE) + for z in range(36, 40): + v.set(35, 8, z, stair('east', 'bottom')) + return v + + +def lamp(v, x, y, z, hanging=True): + v.set(x, y, z, f'minecraft:lantern[hanging={str(hanging).lower()},waterlogged=false]') + + +def lighting(v): + v.stage = 'lanterns and interior lighting' + # Portal fixtures attach to the existing masonry jambs. + for x in (13, 25): + v.set(x, 14, 9, stair('south')) + v.set(x, 13, 9, CHAIN) + lamp(v, x, 12, 9) + for x, facing, bays in ((4, 'east', (14, 22, 30, 38, 46, 54)), + (34, 'west', (14, 22, 30))): + for z in bays: + v.set(x, 16, z, stair(facing)) + v.set(x, 15, z, CHAIN) + lamp(v, x, 14, z) + for x in (43, 52): + v.set(x, 8, 14, stair('south')) + v.set(x, 7, 14, CHAIN) + lamp(v, x, 6, 14) + for z in (21, 29, 37): + v.set(59, 14, z, TRIM) + v.set(60, 14, z, stair('west')) + v.set(60, 13, z, CHAIN) + lamp(v, 60, 12, z) + # Existing low piers become courtyard lamps, with a full supporting cap. + for x in (8, 30, 35, 61): + for z in (6, 59): + v.set(x, 4, z, TRIM) + lamp(v, x, 5, z, False) + # Three chandeliers hang in the clear nave, below the preserved timber ties. + for z in (22, 38, 46): + v.box(19, 19, z, 19, 27, z, CHAIN) + for dx in range(-2, 3): + v.set(19 + dx, 18, z, bars(dx == 0, dx < 2, dx == 0, dx > -2)) + for dz in (-2, -1, 1, 2): + v.set(19, 18, z + dz, bars(dz > -2, False, dz < 2, False)) + for x, zz in ((17, z), (21, z), (19, z - 2), (19, z + 2)): + lamp(v, x, 17, zz) + for x, facing in ((13, 'west'), (25, 'east')): + for z in (22, 38, 46): + v.set(x, 12, z, stair(facing)) + v.set(x, 11, z, CHAIN) + lamp(v, x, 10, z) + # Wing ground floor: lights hang from the existing floor beams. + for x in (43, 50): + for z in (23, 31): + v.set(x, 6, z, CHAIN) + lamp(v, x, 5, z) + for z in (23, 35): + v.box(38, 17, z, 57, 17, z, 'minecraft:spruce_log[axis=x]') + v.box(47, 14, z, 47, 16, z, CHAIN) + lamp(v, 47, 13, z) + # Tower landings and the block-built bell receive a restrained warm light. + for y in (7, 16, 25): + v.box(40, y + 1, 46, 40, y + 2, 46, CHAIN) + lamp(v, 40, y, 46) + for x in (37, 43): + v.box(x, 39, 49, x, 40, 49, CHAIN) + lamp(v, x, 38, 49) + + +def landscape(v): + v.stage = 'planted stone terrace' + + def bed(x1, z1, x2, z2, high=3): + # Soil replaces only the known terrace surface, with a low dressed rim. + for x in range(x1, x2 + 1): + for z in range(z1, z2 + 1): + edge = x in (x1, x2) or z in (z1, z2) + if edge: + if (x, 1, z) not in v.blocks: + v.set(x, 1, z, SLAB) + else: + v.set(x, 0, z, 'minecraft:dirt') + height = high - int((x * 7 + z * 11) % 5 == 0) + for y in range(1, height + 1): + if (x, y, z) not in v.blocks: + v.set(x, y, z, LEAVES) + + for z in (18, 26, 34, 42, 50): + bed(1, z - 2, 5, z + 2, 2) + for z in (21, 29, 37): + bed(60, z - 2, 64, z + 2, 2) + bed(5, 9, 12, 11, 2) + bed(26, 9, 32, 11, 2) + bed(39, 11, 43, 13, 2) + bed(52, 11, 56, 13, 2) + bed(9, 59, 28, 61, 2) + bed(48, 45, 59, 47, 2) + + def narrow_tree(x, z, height): + # Persistent leaves cannot decay; every addition respects existing masonry. + if (x, 0, z) not in v.blocks or v.blocks[(x, 0, z)].split('[')[0] in ( + 'minecraft:smooth_stone', 'minecraft:polished_andesite', 'minecraft:stone_bricks'): + v.set(x, 0, z, 'minecraft:moss_block') + for y in range(1, height - 1): + if (x, y, z) not in v.blocks or v.blocks[(x, y, z)] == LEAVES: + v.set(x, y, z, 'minecraft:spruce_log[axis=y]') + for y in range(2, height + 1): + radius = 2 if y <= height - 4 else 1 if y <= height - 1 else 0 + for dx in range(-radius, radius + 1): + for dz in range(-radius, radius + 1): + if abs(dx) + abs(dz) > radius + 1: + continue + p = x + dx, y, z + dz + if p not in v.blocks or v.blocks[p] == LEAVES: + v.set(*p, LEAVES) + for x, z, h in ((10, 9, 8), (28, 9, 8), (62, 16, 7), (62, 44, 7), + (6, 60, 8), (32, 60, 7)): + narrow_tree(x, z, h) + # Slight wear follows the edges, leaving the main approach calm and readable. + for (x, y, z), block in list(v.blocks.items()): + if y == 0 and block == 'minecraft:smooth_stone' and (x < 6 or x > 59 or z > 57): + if (x * 31 + z * 17) % 29 == 0: + v.set(x, y, z, 'minecraft:andesite') + if y in (1, 2, 3) and block == STONE and (x < 8 or x > 56) and (x * 13 + y * 7 + z * 19) % 31 == 0: + v.set(x, y, z, 'minecraft:mossy_stone_bricks') + + +def build_scene(): + v = finished_scene() + architecture(v) + lighting(v) + landscape(v) + return v diff --git a/scripts/polish-gothic-hall.py b/scripts/polish-gothic-hall.py new file mode 100644 index 0000000..3cd6582 --- /dev/null +++ b/scripts/polish-gothic-hall.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +"""Plan, apply, or verify the Gothic hall polish against its saved canonical baseline.""" +import argparse +from collections import defaultdict +import fcntl +import importlib.util +import json +from pathlib import Path +import time + +from builds.gothic_hall.scene import boxes + +ROOT = Path(__file__).resolve().parents[1] +OUTPUT = ROOT / '.runtime/gothic-hall/polish' +MANIFEST = OUTPUT / 'manifest.json' +LEDGER = OUTPUT / 'ledger.json' +AIR = 'minecraft:air' +spec = importlib.util.spec_from_file_location('gothic_finish', ROOT / 'scripts/finish-gothic-hall.py') +finish = importlib.util.module_from_spec(spec) +spec.loader.exec_module(finish) +require, digest, point, position = finish.require, finish.digest, finish.point, finish.position + + +class Backend(finish.Backend): + def rpc(self, method, params): + try: + return super().rpc(method, params) + except RuntimeError as error: + raise RuntimeError(str(error).replace('finish ledger', 'polish ledger')) from None + + +def matches(actual, requested): + """Match explicit requested properties while accepting registry defaults.""" + def split(state): + name, _, suffix = state.partition('[') + properties = dict(item.split('=', 1) for item in suffix.rstrip(']').split(',') if item) + return name, properties + actual_name, actual_properties = split(actual) + requested_name, requested_properties = split(requested) + return actual_name == requested_name and all( + actual_properties.get(key) == value for key, value in requested_properties.items()) + + +def baseline(): + model, originals, plans = finish.references() + patch = json.loads(finish.LEDGER.read_text()) + finish.validate_saved_finish(patch, finish.targets(model, originals), model, plans) + states = dict(originals) + states.update({point(c['pos']): c['desired'] for c in patch['changes']}) + return model, states, plans, patch + + +def bounds(positions): + lo = tuple(min(p[i] for p in positions) for i in range(3)) + hi = tuple(max(p[i] for p in positions) for i in range(3)) + require((hi[0] - lo[0] + 1) * (hi[1] - lo[1] + 1) * (hi[2] - lo[2] + 1) <= 2048, + 'Inspection volume exceeds the 16x8x16 tile budget') + return {'min': position(lo), 'max': position(hi)} + + +def manifest(model, states, plans, patch): + from builds.gothic_hall.polish_scene import finished_scene, build_scene + before, after = finished_scene().blocks, build_scene().blocks + origin = model['origin'] + + def world(local): + return tuple(a + b for a, b in zip(local, origin)) + + require({world(p) for p in before} == set(states), 'Finished scene baseline mask changed') + require(all(matches(states[world(p)], value) for p, value in before.items()), + 'Finished scene differs from the original plans plus the 18 finish states') + changes, tiles = {}, defaultdict(dict) + for local in sorted(before.keys() | after.keys()): + old, desired = before.get(local, AIR), after.get(local, AIR) + if finish.canonical(old) == finish.canonical(desired): + continue + require(-2 <= local[0] <= 68 and -1 <= local[1] <= 64 and -2 <= local[2] <= 68, + 'Polish extends beyond the agreed local bounds') + at = world(local) + expected = states.get(at, AIR) + # Different spellings that only make existing default properties explicit + # have no world effect and are omitted before requesting a server plan. + if finish.canonical(expected) == finish.canonical(desired): + continue + changes[local] = {'pos': position(at), 'expected': expected, 'desired': desired} + tiles[(local[0] // 16, local[1] // 8, local[2] // 16)][local] = desired + batches = [] + for tile, cells in sorted(tiles.items()): + compressed = boxes(cells) + for start in range(0, len(compressed), 256): + operations, selected = [], [] + for x1, y1, z1, x2, y2, z2, block in compressed[start:start + 256]: + operations.append({'type': 'box', 'min': position(world((x1, y1, z1))), + 'max': position(world((x2, y2, z2))), 'block': block}) + selected.extend(changes[(x, y, z)] + for x in range(x1, x2 + 1) for y in range(y1, y2 + 1) + for z in range(z1, z2 + 1)) + selected.sort(key=lambda c: point(c['pos'])) + require(len(selected) == len({point(c['pos']) for c in selected}) <= 2048, + 'Compressed batch contains duplicates or exceeds the block budget') + batches.append({'key': '_'.join(map(str, (*tile, start // 256))), + **bounds([point(c['pos']) for c in selected]), 'changes': selected, + 'recipe': {'version': 1, 'operations': operations}}) + require(sum(len(b['changes']) for b in batches) == len(changes), 'Incomplete polish batch mask') + return {'version': 1, 'name': 'Gothic hall polish', 'origin': origin, + 'world_id': model['world_id'], 'world_epoch': model['world_epoch'], + 'baseline_manifest_sha256': digest(model), 'finish_ledger_sha256': digest(patch), + 'source_plans': {key: {'plan_id': value['plan_id'], 'sha256': value['sha256']} + for key, value in plans.items()}, + 'before_blocks': len(before), 'after_blocks': len(after), 'changes': len(changes), + 'added': sum(p not in before for p in changes), + 'removed': sum(p not in after for p in changes), 'batches': batches} + + +def prepared(record, batch, model, scope): + plan, checksum = finish.envelope( + finish.CONFIG.parent / 'journal/plans' / (record['plan']['plan_id'] + '.json'), 'plan') + require(checksum == record['plan']['plan_hash'], 'Prepared polish plan hash changed') + require(plan['worldEpoch'] == model['world_epoch'] and plan['region']['worldId'] == model['world_id'], + 'Prepared polish world identity changed') + require(plan['projectId'] == scope['project_id'], 'Prepared polish project changed') + expected = {point(c['pos']): c for c in batch['changes']} + actual = {point(c['pos']): c for c in plan['changes']} + require(len(plan['changes']) == len(actual) == len(expected) and actual.keys() == expected.keys(), + 'Prepared polish mask differs from the reviewed diff') + for at, change in actual.items(): + require(change['expected'] == expected[at]['expected'], + f'Prepared expected state changed at world {at}; preserve current world') + require(matches(change['desired'], expected[at]['desired']), + f'Prepared desired properties differ at world {at}') + require(record['idempotency_key'] == 'gothic-hall-polish-' + plan['id'], + 'Polish idempotency identity changed') + return {at: c['desired'] for at, c in actual.items()} + + +def inspect(backend, region, states, planter_soil=frozenset()): + result = backend.rpc('region_inspect', {**region, 'detail': 'blocks'}) + live = {point(block['pos']): block['state'] for block in result['blocks']} + variations = [] + for at, expected in states.items(): + # Verification may observe grass spreading over the newly planted soil. + # Preparation and application pass no such allowance and remain exact. + if at in planter_soil and expected == 'minecraft:dirt' and live.get(at) == 'minecraft:grass_block[snowy=false]': + variations.append({'pos': position(at), 'expected': expected, 'observed': live[at]}) + continue + require(live.get(at) == expected, + f'Live state differs at world {at}; preserve current world and inspect before continuing') + return variations + + +def completed(backend, record, apply=False): + ident = finish.operation_id(record) + if ident is None and apply: + # The record and key have already been fsynced. A transport exception + # escapes immediately; a later invocation first inspects the journal. + result = backend.rpc('build_apply', {**record['plan'], 'idempotency_key': record['idempotency_key']}) + ident = result['operation_id'] + require(ident is not None, 'A required operation has not been applied') + for _ in range(600 if apply else 1): + status = backend.rpc('operation_status', {'operation_id': ident}) + if status['status'] not in ('queued', 'applying'): + break + if apply: + time.sleep(.1) + require(status['status'] == 'applied', + f'Operation {ident} stopped at {status["status"]}; inspect before continuing') + return ident + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('action', choices=['plan', 'apply', 'verify']) + args = parser.parse_args() + model, states, source_plans, patch = baseline() + wanted = manifest(model, states, source_plans, patch) + if MANIFEST.exists(): + require(digest(json.loads(MANIFEST.read_text())) == digest(wanted), + 'Immutable polish manifest differs from the current generator or baseline') + else: + require(args.action == 'plan', 'Run plan before apply or verify') + finish.save(MANIFEST, wanted, immutable=True) + backend = Backend() + context = backend.rpc('project_context', {}) + require(context['world_id'] == model['world_id'] and context['world_epoch'] == model['world_epoch'], + 'Live world identity changed') + require(patch['scope'] == backend.scope, 'Finished baseline owner/project scope changed') + completed(backend, patch) + if LEDGER.exists(): + ledger = json.loads(LEDGER.read_text()) + else: + require(args.action == 'plan', 'The polish ledger is missing; run plan first') + ledger = {'version': 1, 'manifest_sha256': digest(wanted), 'scope': backend.scope, 'batches': {}} + finish.save(LEDGER, ledger, immutable=True) + require(ledger['version'] == 1 and ledger['manifest_sha256'] == digest(wanted) + and ledger['scope'] == backend.scope, 'Polish ledger identity changed') + require(set(ledger['batches']) <= {b['key'] for b in wanted['batches']}, 'Unknown polish ledger batch') + canonical_targets = {} + for batch in wanted['batches']: + key = batch['key'] + if key not in ledger['batches']: + require(args.action == 'plan', 'Some polish batches are not prepared; run plan first') + inspect(backend, {'min': batch['min'], 'max': batch['max']}, + {point(c['pos']): c['expected'] for c in batch['changes']}) + summary = backend.rpc('build_prepare', {'recipe': batch['recipe']}) + record = {'plan': summary, 'idempotency_key': 'gothic-hall-polish-' + summary['plan_id']} + prepared(record, batch, wanted, backend.scope) + # Existing records are never replaced. Atomic publication follows + # server-plan validation and always precedes any build_apply call. + ledger['batches'][key] = record + finish.save(LEDGER, ledger) + canonical_targets.update(prepared(ledger['batches'][key], batch, wanted, backend.scope)) + if args.action == 'plan': + print(json.dumps({'status': 'prepared', 'changes': wanted['changes'], + 'batches': len(wanted['batches']), 'ledger': str(LEDGER)})) + return + operations = [] + for index, batch in enumerate(wanted['batches'], 1): + ident = completed(backend, ledger['batches'][batch['key']], apply=args.action == 'apply') + operations.append(ident) + if args.action == 'apply': + inspect(backend, {'min': batch['min'], 'max': batch['max']}, + {point(c['pos']): canonical_targets[point(c['pos'])] for c in batch['changes']}) + print(json.dumps({'status': 'applied', 'batch': index, 'batches': len(wanted['batches']), + 'operation_id': ident}), flush=True) + if args.action == 'apply': + return + final, tiles = {**states, **canonical_targets}, defaultdict(dict) + for at, value in final.items(): + local = tuple(a - b for a, b in zip(at, wanted['origin'])) + tiles[(local[0] // 16, local[1] // 8, local[2] // 16)][at] = value + planter_soil = frozenset(at for at, state in canonical_targets.items() + if state == 'minecraft:dirt' and at[1] == wanted['origin'][1]) + soil_variations = [] + for _, group in sorted(tiles.items()): + soil_variations.extend(inspect(backend, bounds(list(group)), group, planter_soil)) + report = {'status': 'verified', 'checked_positions': len(final), + 'blocks': sum(value != AIR for value in final.values()), + 'checked_air': sum(value == AIR for value in final.values()), + 'changes': wanted['changes'], 'inspection_tiles': len(tiles), + 'accepted_planter_grass': soil_variations, + 'manifest_sha256': digest(wanted), 'ledger_sha256': digest(ledger), + 'operations': operations, 'scope': backend.scope, 'verified_at': time.time()} + finish.save(OUTPUT / 'verification.json', report) + print(json.dumps(report)) + + +if __name__ == '__main__': + try: + OUTPUT.mkdir(parents=True, exist_ok=True) + with (OUTPUT / '.lock').open('a') as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + main() + except (OSError, ValueError, KeyError, RuntimeError) as error: + raise SystemExit('Polish stopped: ' + str(error)) from None