diff --git a/README.md b/README.md index cae1b59..4084cfa 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A Minecraft building editor for collaboration between a human and an AI agent. An unedited in-game capture of our Gothic hall: 30,125 blocks and 53 lanterns. See the [build report](docs/builds/GOTHIC_HALL.md) for the reference, checked incremental edits, and dusk and interior photos. -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. +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. Dedicated ChatGPT sign-in, in-game text replies, and a real one-block build → inspect → undo cycle through ACP have also been verified. ## Features @@ -14,12 +14,24 @@ Working prototype: a Paper plugin, an editing core, an MCP/ACP Bridge, and a Fab - Saves a plan before applying it in slices with live block checks. Manual changes stop conflicting writes; undo also checks the current world state. - Keeps an on-disk journal, distinguishes request retries from new operations, and stops ambiguous operations after a crash. - Saves named parts and their protection, and exports/imports a limited Sponge v2 `.schem` subset through the same planning engine. -- Provides 14 MCP tools and `/ai` game chat through a pinned `codex-acp` adapter. +- Provides 19 MCP tools and `/ai` game chat through a pinned `codex-acp` adapter. +- Discovers the running server's complete vanilla block and item catalog on demand; ordinary recipes accept every registered block type and its valid states. - Captures real images through a local worker on a spectator client. A single client with the owner temporarily in spectator has also been tested. +- Exports real north-up world surface maps without a player through the optional terrain plugin; applies reversible, terrain-following site layouts with checked snapshots. -The current scope is one owner, one project, and one world, with at most 4096 blocks per plan, loaded chunks only, and a limited vanilla material palette. A complete delta history, automatic merging of manual edits, and the full design document are not implemented yet. [Detailed status and limitations](docs/IMPLEMENTATION.md). +The current scope is one owner, one project, and one world, with at most 4096 blocks per plan and loaded chunks only. A complete delta history, automatic merging of manual edits, and the full design document are not implemented yet. [Detailed status and limitations](docs/IMPLEMENTATION.md). -The building palette contains 71 materials. Decorative additions include lanterns, `iron_chain` (the Minecraft 26.2 ID), iron bars, stone brick walls, persistent oak leaves, moss, gray/brown stained glass, glowstone, and gold blocks. Leaves require `persistent=true`; waterlogged states remain unsupported. The strict `.schem` codec currently retains the original 61-material subset. +Material discovery stays compact: `project_context` returns catalog counts and a version, `material_search` returns 16 results by default (32 maximum), and `material_describe` returns the exact default state and property values for one chosen material. No full registry or list of every state combination is injected into the model context. This includes carpets, trapdoors, pots, candles, doors, redstone, fluids and waterlogged states when registered by the running Minecraft version. Item-only materials are searchable but cannot be placed as blocks. Existing block-entity data is preserved for same-material state edits and checked through opaque snapshot hashes; there is no arbitrary NBT, inventory or sign-text editor. [Material discovery and building rules](docs/MATERIALS.md). + +Block support does not replace placement design: doors, beds and tall plants need all their parts, attachments need support, and decorative leaves should normally use `persistent=true`. Terrain brushes still reject fluids and structures in their scan. The `.schem` codec accepts registered block states but rejects entity/block-entity payloads and refuses to export block entities. World checkpoints remain necessary for complete landscaped builds and their extra data. + +## Shacraft lobby construction + +The clock station in zone 02 now contains a furnished vestibule, a SMASH selection floor and a two-stop lift, with unfinished upper spaces closed. See the [station photographs and verification](docs/SHACRAFT-STATION.md). For future work, use the [decoration playbook](docs/DECORATION-PLAYBOOK.md), [14 practical recipes](docs/DECORATION-RECIPES.md) and [structured recipe catalog](docs/recipes/decorations.json). + +The arrival square is finished with the real Shacraft emblem, hexagonal paving, evergreen flower beds, benches, copper-hood lanterns, stone urns and a welcome board. A cream-capped balustrade frames the square. An independently reversible invisible enclosure currently keeps normal players inside zone 01, including at its five road approaches and above the square; the configured lobby spawn remains inside. The station foundation, local roads and broad stairs are ready; other districts remain marked reservations. [Completed zone 01 and containment verification](docs/SHACRAFT-ZONE01-COMPLETE.md) · [Balustrade](docs/SHACRAFT-BALUSTRADE.md) · [Arrival garden](docs/SHACRAFT-ARRIVAL-GARDEN.md) · [Foundations and elevations](docs/SHACRAFT-FOUNDATIONS.md) · [Earlier site survey](docs/SHACRAFT-LOBBY-LAYOUT.md). + +![Completed Shacraft arrival square, real Minecraft capture](docs/references/shacraft-zone01-overview.png) ## Build @@ -36,6 +48,7 @@ Build outputs: - `paper-plugin/target/paper-plugin-0.1.0-SNAPSHOT.jar` — server plugin, including the editing core. - `camera-mod/build/libs/minecraft-builder-camera-0.1.0-SNAPSHOT.jar` — client mod. - `bridge/dist/` — executable MCP/ACP components. +- `terrain-world-plugin/target/terrain-world-plugin-0.1.0-SNAPSHOT.jar` — optional separate-world generator and read-only surface map exporter. ## Local setup @@ -54,7 +67,7 @@ Build outputs: /ai area here ``` - The second command selects an area around the player. Chunks must be loaded, and blocks next to writes must be supported. Set exact bounds with `/ai area minX minY minZ maxX maxY maxZ`. + The second command selects an area around the player. Chunks needed for the edit and its checked surroundings must be loaded. Set exact bounds with `/ai area minX minY minZ maxX maxY maxZ`. 3. From another terminal at the project root, check the settings and sign in to the separate Codex profile: @@ -64,7 +77,7 @@ Build outputs: python3 scripts/bridge.py login --status ``` - The user completes sign-in using a device code. The helper reads local Paper tokens without printing them. It does not copy the normal `~/.codex` profile; project state lives in `.runtime/bridge-state`. The first real turn still needs to confirm authentication and MCP permissions in the pinned adapter. + The user completes sign-in using a device code. The helper reads local Paper tokens without printing them. It does not copy the normal `~/.codex` profile; project state lives in `.runtime/bridge-state`. After starting chat, test an actual MCP read to verify the local connection and model tool access. 4. Start chat: @@ -94,8 +107,21 @@ 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). +- [Runtime materials and compact discovery](docs/MATERIALS.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). Git is initialized on branch `main`. Generated worlds, secrets, dependencies, and build outputs are excluded by `.gitignore`. + +## Terrain toolkit + +Deterministic terrain recipes now support hills, ridges, plateaus, dry basins/channels and terraces. `terrain_preview` returns a native heightmap and cached recipe ID; `terrain_prepare` creates a checked tile plan for the existing apply/undo pipeline. Offline previews and bounded resumable batches are available through `scripts/terrain.py`. See [Terraforming tools](docs/TERRAFORMING.md) for examples, safety semantics and scale limits. + +For a fresh map, the optional [initial terrain world plugin](terrain-world-plugin/README.md) generates a separate world directly from a recipe, with optional lakes and rivers at a configured water level. The [Shacraft lobby recipe](examples/terrain/shacraft-lobby-world.json) lays out a 768×768 mountain basin with building platforms. Initial generation has its own immutable manifest; subsequent construction uses the normal scoped editor. + +`terrain_brush_prepare` additionally edits existing terrain relatively: raise/lower, flatten and snapshot-based smoothing, with soft edges, preserved columns, native before/after previews and checked read dependencies. See the relative-brush section of the terraforming guide. + +The natural Shacraft v2 world replaces rectangular terrain platforms with a warped ridged mountain basin, soft hills and connected water courses. The live generation pass checked every column, including surface materials and water fill. + +![Shacraft natural v2 terrain, real in-game capture](docs/references/shacraft-natural-v2-overview.png) diff --git a/bridge/README.md b/bridge/README.md index b9e44f0..fa1f253 100644 --- a/bridge/README.md +++ b/bridge/README.md @@ -33,17 +33,23 @@ Optional settings: - `MCB_ACP_COMMAND`: path to an alternative ACP agent. Otherwise, the current Node executable and pinned `codex-acp` are used. - `MCB_ACP_ARGS`: JSON array of arguments, with no shell interpretation. - `MCB_STATE_DIR`: state directory, defaulting to `.state/chat` relative to the working directory. -- `MCB_CODEX_HOME`: explicit path to a separate Codex home for sign-in. If a different `config.toml` already exists there, the bridge refuses to start and preserves that file; use a separate empty directory rather than the normal Codex profile. +- `MCB_CODEX_HOME`: explicit path to a separate Codex home for sign-in. The exact older bridge-managed config is migrated with a `config.toml.before-code-mode-host` backup and without changing sign-in. If a custom `config.toml` exists there, the bridge refuses to start and preserves that file; use a separate empty directory rather than the normal Codex profile. The child process receives only allowlisted environment variables, separate HOME/XDG/CODEX_HOME directories, and a minimal configuration. Settings request `read-only`, `on-request`, user review, no network inside the command sandbox, and disabled shell, apps, browser, computer use, hooks, plugins, and additional agents. Sources and settings are in `src/security.ts`. Inspection of the pinned CLI confirmed that `shell_tool` and the listed integrations were disabled; that CLI keeps `unified_exec` enabled even when explicitly disabled, which doctor reports. -There is a limitation in `codex-acp` 1.11.0 itself: its `read-only` mode sends a `workspace-write` sandbox with networking disabled for every turn, rather than a literal read-only sandbox. As a result, the individual session directory and temporary paths may remain writable. The code does not claim complete OS isolation and has not yet been verified on a real model turn. Bridge/ACP/MCP processes also remain trusted local programs; Paper permissions are checked separately by the server. +The bundled Code Mode host is enabled because models with `tool_mode=code_mode_only` need it to invoke MCP, even when `features.code_mode=false`. Disabling the host leaves text chat working but makes tool calls fail with `code-mode host is disabled`. This host does not enable the separately disabled shell or integrations. -The Bridge denies additional permission requests and reports this in the game. Interactive permission approval through Minecraft is not implemented. It does not advertise ACP file or terminal capabilities. Paper's administrator token is not passed to the child agent; MCP receives a separate restricted agent token. Check configuration with doctor after version changes or when system-wide Codex policies are present. The dynamically supplied Minecraft MCP override does not set `default_tools_approval_mode`: the pinned adapter passes only command/args/env and replaces the corresponding configuration table. MCP permission behavior therefore remains a check for the first real turn after user sign-in; a successful build and initialize do not prove that model-driven building already works. +There is a limitation in `codex-acp` 1.11.0 itself: its `read-only` mode sends a `workspace-write` sandbox with networking disabled for every turn, rather than a literal read-only sandbox. As a result, the individual session directory and temporary paths may remain writable. The code does not claim complete OS isolation; successful MCP calls do not prove a complete operating-system sandbox. Bridge/ACP/MCP processes also remain trusted local programs; Paper permissions are checked separately by the server. + +During an active owner request, the Bridge grants a one-use approval only for one of its 19 known Minecraft tools. It requires a matching tool-call notification from the current adapter and session, the exact Minecraft server/tool identity, MCP approval metadata, and an `allow_once` option. Unknown tools, other servers, shell requests, stale calls, and persistent approvals remain denied. Paper independently checks the owner, project region, protected parts, and expected block states. The administrator token stays in the Bridge; MCP receives the restricted agent token. Interactive approval for additional capabilities is not implemented. + +Explicit Minecraft MCP startup failures stop the request with a safe error message. Raw adapter diagnostics and tool arguments are not displayed in game. A missing failure notification alone does not establish readiness; verify an actual tool call after setup. ## MCP -Tools: `project_context`, `region_inspect`, `build_prepare`, `build_apply`, `operation_status`, `operation_cancel`, `operation_undo_prepare`, `part_get`, `part_define`, `camera_list`, `camera_capture`, `asset_list`, `schematic_export`, `schematic_import_prepare`. +Tools: `project_context`, `material_search`, `material_describe`, `region_inspect`, `build_prepare`, `build_apply`, `operation_status`, `operation_cancel`, `operation_undo_prepare`, `part_get`, `part_define`, `camera_list`, `camera_capture`, `asset_list`, `schematic_export`, `schematic_import_prepare`, `terrain_preview`, `terrain_prepare`, `terrain_brush_prepare`. + +`project_context` contains only a material catalog summary. `material_search` filters IDs with a query of at most 96 characters, a `kind` of `block` (default), `item` or `all`, and a bounded page size (default 16, maximum 32). Its optional cursor is at most 100 characters and is bound to the catalog version and filter. `material_describe` accepts one exact namespaced Minecraft ID and returns its default state, separate allowed values for each property and compact behavior hints. Item-only entries have no placeable block state. Search first, describe 1–3 selected materials, then reuse those results while the catalog version is unchanged. [Complete workflow and capability boundaries](../docs/MATERIALS.md). Version 1 recipe: @@ -61,11 +67,13 @@ Version 1 recipe: An optional `part_id` in `build_prepare` restricts writes to the exact mask of a registered part; extensions use a separate part. -Tools send data to Paper for final validation and computation. The bridge does not store blocks or write to the world. MCP accepts one level of `repeat`; deeply nested repeats, arbitrary code, arches, and general transforms are not advertised yet. Supported block states and limits come from `project_context`. +Tools send data to Paper for final validation and computation. The bridge does not store blocks or write to the world. MCP accepts one level of `repeat`; deeply nested repeats, arbitrary code, arches, and general transforms are not advertised yet. Limits come from `project_context`; exact block properties come from `material_describe`. Ordinary recipes accept all registered vanilla block states, including fluids and waterlogged states, in strings of at most 1024 characters. The state-string grammar excludes raw NBT. + +For a plan based on an earlier `region_inspect` block read, `expected_blocks` must cover every desired position exactly once. Copy any returned `snapshot_id` unchanged alongside `pos` and `state`; block entities require that 64-character lowercase SHA-256 digest so a change to their extra data also causes a conflict. Same-material state edits preserve existing block-entity data. New block entities use their defaults; recipes do not configure sign text or inventories. `build_prepare` returns `plan_id` and `plan_hash`. Pass both to `build_apply` with a stable `idempotency_key`. After a timeout, writing may already have started: check `operation_status` first and reuse the same key. The bridge does not automatically retry writes. -The local `.schem` library supports up to 64 files, Sponge v2, dense regions of at most 4096 blocks, and rotations of 0/90/180/270°. Place files manually in the `schematics` directory inside the Paper plugin's data directory. `asset_list` returns metadata without invented previews; `schematic_export` saves a region and returns its ID; `schematic_import_prepare` creates a normal checked plan that is then applied through `build_apply`. Paths, entities, block entities, and unsupported blocks are rejected. The strict codec currently supports the original 61-material subset; the ten newer decorative materials in the building palette are not yet supported by `.schem`. +The local `.schem` library supports up to 64 files, Sponge v2, dense regions of at most 4096 blocks, and rotations of 0/90/180/270°. Place files manually in the `schematics` directory inside the Paper plugin's data directory. `asset_list` returns metadata without invented previews; `schematic_export` saves a region and returns its ID; `schematic_import_prepare` creates a normal checked plan that is then applied through `build_apply`. Registered block states use the runtime validator and rotation behavior. Arbitrary paths, entity/block-entity NBT payloads and unknown states are rejected. Export refuses block entities, including empty ones, rather than silently discarding their data. A palette entry for a block-entity block type without an NBT payload can be imported through the normal default/preservation rules. Preserve complete landscaped builds with world checkpoints and checked voxel recipes. `camera_capture` returns `pending` and `captureId`; a request with `capture_id` reads the result. Only `completed` with a real image becomes MCP `ImageContent`. If the camera is absent or the capture is not ready, no image is fabricated. Ordinary text responses are limited to 64 KiB; reading an oversized region fails with a request to reduce its size. HTTP has time, input-size, and streaming response-size limits. @@ -75,14 +83,28 @@ One active turn per project, with up to eight queued messages. Different project Each request includes the player position, viewing direction, and targeted block supplied by the server, when available. Message output is limited to four messages per second per player. +Streamed text is collected into complete sentences or lines. Long passages are split at word boundaries into messages of at most 240 characters; a single longer word is split without breaking a Unicode surrogate pair. An unfinished short phrase waits for more text or the end of the turn. Delivery is serialized, so slow HTTP responses cannot turn individual tokens into separate chat messages or let the completion marker overtake the reply. + The ACP session ID and latest compact summary are persisted by atomically replacing `session.json` with permissions `0600`. After restarting, the bridge tries `session/load`; if that fails, it starts a new conversation with the summary and explicitly reports the fallback. Replayed history is not shown in chat. An unfinished previous turn is marked separately; operations already started must be checked on Paper. The summary stores the latest request and outcome and does not replace `project_context`. `/ai stop` must both set the server's write-stop flag and deliver a `type:"cancel"` event to the bridge. ACP cancels generation; blocks already changed remain in the journal. An interrupted agent that does not respond to cancellation within five seconds is terminated. On each launch, the bridge creates a `client_id` and passes it to `chat_poll`. A changed ID lets Paper stop active writes and finish outstanding leased requests while notifying the user; those requests are not replayed automatically. Paper's incoming message queue is currently in memory. After `/ai stop`, the server keeps writing paused until a new owner request or `/ai resume`. ## Verification -`npm test` runs HTTP tests and a real stdio MCP handshake, and uses a separate mock ACP process to test sessions, summaries, history suppression, permission denial, and cancellation. Queue tests verify serialization within a project and independence between projects. The real pinned `codex-acp` also passed a free `initialize`: ACP v1, `loadSession: true`, and authentication methods `api-key` and `chat-gpt` before environment restrictions. A repeat check in a separate home also passed; with `NO_BROWSER=1`, the adapter advertises only `api-key`, while ChatGPT sign-in uses the separate `login` helper. This does not prove ChatGPT authentication, model quality, or Minecraft behavior: those require the complete system running against a real server/client. +`npm test` runs HTTP tests and a real stdio MCP handshake, and uses a separate mock ACP process to test sessions, summaries, history suppression, permission denial, and cancellation. Queue tests verify serialization within a project and independence between projects. The real pinned `codex-acp` also passed a free `initialize`: ACP v1, `loadSession: true`, and authentication methods `api-key` and `chat-gpt` before environment restrictions. A repeat check in a separate home also passed; with `NO_BROWSER=1`, the adapter advertises only `api-key`, while ChatGPT sign-in uses the separate `login` helper. The initial handshake did not invoke a model. Subsequently, dedicated ChatGPT sign-in and real ACP model calls to `project_context` and `region_inspect` were verified against the running Paper server. A real model also prepared and applied one oak-plank block at (24, -60, -45), inspected it, prepared and applied checked undo, and verified air. A local test proxy restricted writes to that one cell and its recorded undo. Ordinary automated tests still use mocks and do not invoke a model. The optional `node test/live-paper.mjs` runs only against a separate real test Paper server: it checks a hollow cube, applying again with the same key, `.schem` export, the asset library, undo, import at the same anchor, a second undo back to 27 air blocks, bounds, and a truthful unavailable-camera response. It leaves the exported test asset in the local library. This test changes the world and is not part of ordinary `npm test`. The original APIs were checked against the [ACP SDK](https://github.com/agentclientprotocol/typescript-sdk), [codex-acp](https://github.com/agentclientprotocol/codex-acp), [MCP SDK](https://github.com/modelcontextprotocol/typescript-sdk), [Codex configuration reference](https://learn.chatgpt.com/docs/config-file/config-reference), and [Codex MCP documentation](https://learn.chatgpt.com/docs/extend/mcp?surface=cli). + +## Terrain toolkit + +Deterministic terrain recipes now support hills, ridges, plateaus, dry basins/channels and terraces. `terrain_preview` returns a native heightmap and cached recipe ID; `terrain_prepare` creates a checked tile plan for the existing apply/undo pipeline. Offline previews and bounded resumable batches are available through `scripts/terrain.py`. See [Terraforming tools](../docs/TERRAFORMING.md) for examples, safety semantics and scale limits. + +`terrain_brush_prepare` additionally edits existing terrain relatively: raise/lower, flatten and snapshot-based smoothing, with soft edges, preserved columns, native before/after previews and checked read dependencies. See the relative-brush section of the terraforming guide. + +## Shared building guidance + +`src/building-guidance.ts` is the shared source for material discovery, terrain art direction and decoration used by MCP server instructions and the ACP agent. It prioritizes focused material searches and reuse of defaults, deliberate silhouettes, calm walking areas, localized mountain detail, organic foundations, slope-aware materials, preservation and real camera verification. Tool descriptions state the exact schema boundaries: the current MCP terrain recipe offers amplitude/scale value noise and a limited palette; the advanced `shacraft-natural-v1` profile and initial water generation are operator-configured features. Ordinary building plans can place registered fluid states; terrain brushes still cannot scan through water. + +ACP stores a fingerprint of successfully delivered instructions with its session state. An older or missing fingerprint causes the updated guidance to be sent on the next prompt in a resumed session, preserving history. Once delivered, unchanged guidance is not repeated on every turn or restart. Transport failures leave the old fingerprint so delivery can be retried. diff --git a/bridge/src/acp.ts b/bridge/src/acp.ts index c5d408f..e59937a 100644 --- a/bridge/src/acp.ts +++ b/bridge/src/acp.ts @@ -8,6 +8,9 @@ import { fileURLToPath } from 'node:url'; import { client, ndJsonStream, PROTOCOL_VERSION, type ClientConnection, type McpServer, type SessionNotification } from '@agentclientprotocol/sdk'; import { agentEnvironment, codexPaths, prepareCodexHome } from './security.js'; +import { BackendError } from './backend.js'; +import { AGENT_INSTRUCTIONS as INSTRUCTIONS } from './building-guidance.js'; +const INSTRUCTIONS_HASH = createHash('sha256').update(INSTRUCTIONS).digest('hex'); export { agentEnvironment } from './security.js'; const require = createRequire(import.meta.url); @@ -20,9 +23,11 @@ export interface AcpOptions { timeoutMs?: number; startupTimeoutMs?: number; env?: NodeJS.ProcessEnv; } -interface SavedSession { sessionId: string; summary: string; interrupted: boolean } +interface SavedSession { sessionId: string; summary: string; interrupted: boolean; instructionsHash?: string } -const INSTRUCTIONS = `You are the Minecraft builder for the current authorized project. Respond in the player's language using short game-chat messages. Use only minecraft-builder-mcp to inspect and edit the world. Begin each new task with project_context; read relevant world data before designing. Only implemented server capabilities may be used. Prepare compact geometry, inspect statistics, apply using stable idempotency keys, and poll operation_status. Preserve manual edits: conflict requires localized redesign or the user's decision, never blindly overwrite fresh snapshots. A cancelled or failed operation may have partial writes. Camera requests are asynchronous; poll capture_id and inspect actual image. If unavailable, explicitly say visually unverified. Never use console commands, shell, files, or other MCP servers to modify Minecraft. Do not claim any action completed without server evidence.`; + +const MCP_STARTUP_MESSAGE = 'Minecraft MCP не запустился: инструменты строительства недоступны. Запрос остановлен; проверь настройки и процесс моста.'; +const MINECRAFT_TOOLS = new Set(['project_context', 'material_search', 'material_describe', 'region_inspect', 'build_prepare', 'build_apply', 'operation_status', 'operation_cancel', 'operation_undo_prepare', 'part_get', 'part_define', 'camera_list', 'camera_capture', 'asset_list', 'schematic_export', 'schematic_import_prepare', 'terrain_preview', 'terrain_prepare', 'terrain_brush_prepare']); export class CodexSession implements AgentSession { private child?: ChildProcessWithoutNullStreams; @@ -33,12 +38,15 @@ export class CodexSession implements AgentSession { private cancelled = false; private buffer = ''; private finalText = ''; - private lastSent = 0; private sentChars = 0; + private delivery: Promise = Promise.resolve(); private firstPrompt = true; + private instructionsHash = ''; private summary = ''; private reportReset = false; private reportInterrupted = false; + private readonly mcpStartupFailures = new Set(); + private readonly minecraftCalls = new Set(); private readonly dir: string; private readonly stateFile: string; constructor(private readonly message: Pick, private readonly options: AcpOptions) { @@ -58,19 +66,32 @@ export class CodexSession implements AgentSession { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') this.reportReset = true; } this.summary = saved?.summary.slice(0, 5000) ?? ''; + this.instructionsHash = saved?.instructionsHash ?? ''; this.reportInterrupted = saved?.interrupted ?? false; const executable = this.options.command ?? process.execPath; const args = this.options.args ?? (this.options.command ? [] : [require.resolve('@agentclientprotocol/codex-acp')]); + this.mcpStartupFailures.clear(); const child = spawn(executable, args, { cwd: this.dir, stdio: ['pipe','pipe','pipe'], env: agentEnvironment(this.options.env ?? process.env, paths), shell: false, detached: process.platform !== 'win32' }); this.child = child; // Adapter stderr may contain prompts or secrets. Drain it without logging raw content. child.stderr.resume(); const app = client({ name: 'minecraft-builder-mcp-chat' }); - app.onRequest('session/request_permission', async () => { + app.onRequest('session/request_permission', async ({ params }) => { + // Only correlate a one-use approval to a known Minecraft call advertised + // by this adapter in this active owner turn. Paper enforces the scope. + if (this.child === child && this.active && !this.cancelled && !this.mcpStartupError() && params.sessionId === this.sessionId + && params._meta?.is_mcp_tool_approval === true + && this.minecraftCalls.has(params.toolCall.toolCallId) + && params.options.some(option => option.kind === 'allow_once' && option.optionId === 'allow_once')) { + this.minecraftCalls.delete(params.toolCall.toolCallId); + return { outcome: { outcome: 'selected', optionId: 'allow_once' } }; + } await this.currentReply?.('Codex запросил дополнительное разрешение. Оно отклонено: подтверждение через игровой чат пока не реализовано.', false, true); return { outcome: { outcome: 'cancelled' } }; }); - app.onNotification('session/update', ({ params }) => this.onUpdate(params)); + app.onNotification('session/update', ({ params }) => { + if (this.child === child) return this.onUpdate(params); + }); this.connection = app.connect(ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout) as unknown as ReadableStream)); const connection = this.connection; child.once('error', () => connection.close(new Error('Cannot start the ACP process. Check MCB_ACP_COMMAND.'))); @@ -102,55 +123,114 @@ export class CodexSession implements AgentSession { this.sessionId = created.sessionId; configOptions = created.configOptions; this.firstPrompt = true; if (saved) this.reportReset = true; } + const mcpError = this.mcpStartupError(); + if (mcpError) throw mcpError; if (this.options.model) { const config = configOptions?.find(option => option.category === 'model' || option.id === 'model'); if (!config) throw new Error('Agent did not advertise a model selector; unset MCB_MODEL or use a compatible adapter.'); await connection.agent.request('session/set_config_option', { sessionId: this.sessionId, configId: config.id, value: this.options.model }); } await this.save(false); + const lateMcpError = this.mcpStartupError(); + if (lateMcpError) throw lateMcpError; } catch (error) { this.close(); throw error; } finally { clearTimeout(setupTimeout); } } + private mcpStartupError(): BackendError | undefined { + return this.sessionId && this.mcpStartupFailures.has(this.sessionId) + ? new BackendError('mcp_unavailable', MCP_STARTUP_MESSAGE) : undefined; + } private async onUpdate(params: SessionNotification): Promise { - // History replay from session/load is suppressed; another player's chat never receives it. - if (!this.active || params.sessionId !== this.sessionId || !this.currentReply) return; const update = params.update; + // codex-acp 1.11.0 synthesizes this reserved startup event separately from + // thread history. It can arrive before session/new or session/load returns. + // Never expose its raw content: startup errors may contain tokens or paths. + if (update.sessionUpdate === 'tool_call' && update.toolCallId === 'mcp_startup.minecraft-builder-mcp' + && update.title === 'mcp__minecraft-builder-mcp__startup' && update.kind === 'other' && update.status === 'failed') { + this.mcpStartupFailures.add(params.sessionId); + const error = this.mcpStartupError(); + if (error && this.active) this.connection?.close(error); + return; + } + // History replay from session/load is suppressed; another player's chat never receives it. + if (!this.active || params.sessionId !== this.sessionId || !this.currentReply || this.mcpStartupError()) return; + if (update.sessionUpdate === 'tool_call') { + const input = update.rawInput as Record | undefined; + if (update._meta?.is_mcp_tool_call === true && update.kind === 'execute' + && (update.status === 'pending' || update.status === 'in_progress') + && input?.server === 'minecraft-builder-mcp' && typeof input.tool === 'string' + && MINECRAFT_TOOLS.has(input.tool) && update.title === `mcp.minecraft-builder-mcp.${input.tool}` + && this.minecraftCalls.size < 128) this.minecraftCalls.add(update.toolCallId); + } else if (update.sessionUpdate === 'tool_call_update' + && (update.status === 'completed' || update.status === 'failed')) this.minecraftCalls.delete(update.toolCallId); if (update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text') { this.finalText = (this.finalText + update.content.text).slice(0, 8000); - this.buffer += update.content.text; - if (this.buffer.length >= 180 || Date.now() - this.lastSent >= 1500) await this.flush(false); + const remaining = Math.max(0, 8000 - this.sentChars - this.buffer.length); + this.buffer += update.content.text.slice(0, safeTextEnd(update.content.text, remaining)); + await this.flush(false); } // Deliberately do not forward thought chunks, tool arguments, or raw terminal output. } private async flush(done: boolean): Promise { - const remaining = Math.max(0, 8000 - this.sentChars); - const clean = this.buffer.replace(/[\u0000-\u001f\u007f§]/g, ' ').trim().slice(0, remaining); - this.buffer = ''; - if (clean) { - for (let offset = 0; offset < clean.length; offset += 240) await this.currentReply?.(clean.slice(offset, offset + 240), false); + const reply = this.currentReply; + if (!reply) return; + const { messages, remainder } = chatMessages(this.buffer, done); + // Reserve text synchronously: ACP notifications can arrive while a reply is + // awaiting HTTP delivery. They must not flush or account for the same text. + this.buffer = remainder; + for (const message of messages) { + const clean = message.slice(0, safeTextEnd(message, Math.max(0, 8000 - this.sentChars))); + if (!clean) continue; this.sentChars += clean.length; + this.delivery = this.delivery.then(() => reply(clean, false)); } - this.lastSent = Date.now(); - if (done) await this.currentReply?.(this.cancelled ? 'Остановлено. Уже изменённые блоки остаются в истории.' : this.sentChars ? '' : 'Ход Codex завершён без текстового ответа; состояние мира доступно через /ai status.', true); + if (done) { + const status = this.cancelled ? 'Остановлено. Уже изменённые блоки остаются в истории.' : this.sentChars ? '' : 'Ход Codex завершён без текстового ответа; состояние мира доступно через /ai status.'; + this.delivery = this.delivery.then(() => reply(status, true)); + } + await this.delivery; } async prompt(text: string, reply: Reply): Promise { if (this.active) throw new Error('This ACP session already has an active turn.'); this.currentReply = reply; this.cancelled = false; - await this.start(); + try { + await this.start(); + const mcpError = this.mcpStartupError(); + if (mcpError) throw mcpError; + } catch (error) { + if (error instanceof BackendError && error.code === 'mcp_unavailable') await reply(MCP_STARTUP_MESSAGE, false, true); + this.currentReply = undefined; + throw error; + } if (this.cancelled) { await reply('Запрос остановлен до отправки Codex.', true); this.currentReply = undefined; return; } if (this.reportReset) { await reply('Начат новый диалог Codex с сохранённой краткой сводкой проекта.', false); this.reportReset = false; } if (this.reportInterrupted) { await reply('Предыдущий ход был прерван перезапуском. Проверю состояние операций перед новым строительством.', false); this.reportInterrupted = false; } - this.active = true; this.buffer = ''; this.finalText = ''; this.sentChars = 0; - const prefix = this.firstPrompt ? `${INSTRUCTIONS}\n${this.summary ? `Previous compact summary (historical, verify world): ${this.summary}\n` : ''}\nPlayer request:\n` : ''; + this.active = true; this.buffer = ''; this.finalText = ''; this.sentChars = 0; this.delivery = Promise.resolve(); this.minecraftCalls.clear(); + const prefix = (this.firstPrompt || this.instructionsHash !== INSTRUCTIONS_HASH) ? `${INSTRUCTIONS}\n${this.summary ? `Previous compact summary (historical, verify world): ${this.summary}\n` : ''}\nPlayer request:\n` : ''; await this.save(true); const timeout = setTimeout(() => { void this.cancel().catch(() => this.close()); }, this.options.timeoutMs ?? 15 * 60_000); try { + const startupError = this.mcpStartupError(); + if (startupError) throw startupError; const result = await this.connection!.agent.request('session/prompt', { sessionId: this.sessionId!, prompt: [{ type: 'text', text: `${prefix}${text}` }] }); + const mcpError = this.mcpStartupError(); + if (mcpError) throw mcpError; this.firstPrompt = false; + this.instructionsHash = INSTRUCTIONS_HASH; this.cancelled ||= result.stopReason === 'cancelled'; this.summary = `Last player request: ${text.slice(0,2000)}\nLast agent response: ${this.finalText.slice(0,3000)}`; await this.save(false); + const lateMcpError = this.mcpStartupError(); + if (lateMcpError) throw lateMcpError; await this.flush(true); + } catch (error) { + const mcpError = this.mcpStartupError(); + if (mcpError) { + await this.delivery; + await reply(MCP_STARTUP_MESSAGE, false, true); + throw mcpError; + } + throw error; } finally { clearTimeout(timeout); this.active = false; this.currentReply = undefined; } } async cancel(): Promise { @@ -164,7 +244,7 @@ export class CodexSession implements AgentSession { } private async save(interrupted: boolean): Promise { const temp = `${this.stateFile}.tmp`; - await writeFile(temp, JSON.stringify({ sessionId: this.sessionId, summary: this.summary, interrupted }), { mode: 0o600 }); + await writeFile(temp, JSON.stringify({ sessionId: this.sessionId, summary: this.summary, interrupted, instructionsHash: this.instructionsHash }), { mode: 0o600 }); await rename(temp, this.stateFile); } close(): void { @@ -178,6 +258,42 @@ export class CodexSession implements AgentSession { } } +/** Keep unfinished words until more tokens arrive; never emit a token on a timer. */ +function chatMessages(buffer: string, done: boolean): { messages: string[]; remainder: string } { + let text = buffer.replace(/[\u0000-\u0009\u000b-\u001f\u007f§]/g, ' ').trimStart(); + const messages: string[] = []; + while (text) { + const limit = safeTextEnd(text, 240); + // A following separator confirms the sentence boundary. A punctuation token + // alone may still be followed by closing quotes or another punctuation mark. + const sentence = /[.!?…]["'»”\])]*(?=\s)|\n/u.exec(text); + let end = sentence ? sentence.index + sentence[0].length : 0; + if (!end || end > limit) { + if (text.length <= limit) { + if (!done) break; + end = text.length; + } else { + end = 0; + for (let i = limit; i > 0; i--) { + if (/\s/u.test(text[i]!)) { end = i; break; } + } + if (!end) end = limit; // A single overlong word still needs a bounded message. + } + } + const message = text.slice(0, end).replace(/\s+/gu, ' ').trim(); + text = text.slice(end).trimStart(); + if (message) messages.push(message); + } + return { messages, remainder: text }; +} + +function safeTextEnd(text: string, max: number): number { + let end = Math.min(text.length, max); + const last = text.charCodeAt(end - 1); + if (end < text.length && last >= 0xd800 && last <= 0xdbff) end--; + return end; +} + function terminateAgentTree(child: ChildProcessWithoutNullStreams, signal: NodeJS.Signals): void { try { // The adapter launches Codex and MCP children. On Unix all are in our own process group. diff --git a/bridge/src/building-guidance.ts b/bridge/src/building-guidance.ts new file mode 100644 index 0000000..f67b749 --- /dev/null +++ b/bridge/src/building-guidance.ts @@ -0,0 +1,12 @@ +/** Shared guidance for MCP clients and the in-game ACP agent. Keep capability claims exact. */ +export const MATERIAL_GUIDANCE = `Material discovery: project_context contains only a catalog summary. All vanilla block states registered by the running server are available to ordinary build recipes, including water; item-only materials can be discovered but cannot be placed as blocks. Use material_search with a focused query, then material_describe for 1–3 chosen materials to obtain exact default states and allowed property values. Reuse those results while the catalog version is unchanged; do not enumerate the registry or repeatedly describe known materials. Omitted properties use server defaults. Block states do not accept raw NBT or configure inventories/sign text; same-material edits preserve existing block-entity data. Pass through region_inspect snapshot_id hashes in expected_blocks to detect changes to that data. Terrain recipes and brushes retain their separate limited palettes and fluid restrictions.`; + +export const TERRAIN_GUIDANCE = `Terrain art direction: compose a readable skyline and calm playable valleys before adding detail. Prefer broad low-frequency relief, localized ridged mountain detail and modest coordinate warping when the available generator supports them. Independent seeded noise layers reduce repetition; more octaves alone do not fix bad composition. Keep fine detail weak along walking routes. Preserve sightlines, water courses and space for architecture. Avoid large rectangular plateaus unless explicitly requested: adapt buildings to the landscape and flatten only their actual foundations with wide, irregular transitions. Preserve existing terrain instead of repeatedly flattening whole districts. Use rock on steep slopes and soil/grass on gentle ground; avoid grass-and-dirt contour stripes across cliffs. Grade routes and entrances separately; natural-looking terrain is not automatically walkable. +Capability boundary: MCP terrain recipes currently expose three-octave value noise through amplitude/scale only. OpenSimplex2S, ridged noise and domain warping are available in the separately configured shacraft-natural-v1 initial-world profile, NOT as terrain_preview parameters or callable MCP tools. Do not invent noise, spline, erosion, biome or fluid APIs. MCP terrain tools do not place water; the initial-world generator can fill lakes/rivers at a configured level. Brushes reject water in their scan. Preserve waterways and their beds; request operator-side generation work if the task requires unsupported capabilities. There is no hydraulic erosion simulation or initial-generation block undo. +Workflow: inspect a small relevant region, preserve structures and their foundations, preview composition, then apply bounded checked plans. A target heightmap is not a Minecraft screenshot. Inspect terrain from an overview and a player-height view when the camera is available; check silhouettes, chunk seams, shores, materials and route slopes. Poll pending captures by capture_id until completed or a reported error; report that error rather than claiming visual verification. On a shared camera client, ask the player to close menus and stay still. Distinguish proposed, prepared, server-applied and visually inspected work. Keep summaries compact; reuse terrain_id and operation IDs instead of dumping blocks or regenerating equivalent recipes.`; + +export const DECORATION_GUIDANCE = `Decoration art direction: Establish purpose and silhouette before adding detail. Use a coherent palette, grouped texture variation and structural depth; avoid random high-contrast block noise. Preserve quiet surfaces, walking clearance, entrances and important sightlines. Build one small prototype, inspect it, then repeat its motifs with controlled variation. Discover selected materials and exact block states through material_search and material_describe. Verify orientation, attachment, support and stable leaves; decorative partial blocks do not guarantee containment. Keep unfinished rooms closed. Inspect actual captures from player height and an overview, in daylight and at night when available; state which views or lighting conditions remain unverified. Confirm server-applied changes before reporting completion. Documented decoration recipes are design specifications, not callable tools; translate selected details into supported bounded plans and preserve manual edits.`; + +export const AGENT_BASE = `You are the Minecraft builder for the current authorized project. Respond in the player's language using short game-chat messages. Use only minecraft-builder-mcp to inspect and edit the world. Begin each new task with project_context; read relevant world data before designing. Only implemented server capabilities may be used. Prepare compact geometry, inspect statistics, apply using stable idempotency keys, and poll operation_status. Preserve manual edits: conflict requires localized redesign or the user's decision, never blindly overwrite fresh snapshots. A cancelled or failed operation may have partial writes. Camera requests are asynchronous; poll capture_id and inspect actual image. If unavailable, explicitly say visually unverified. Never use console commands, shell, files, or other MCP servers to modify Minecraft. Do not claim any action completed without server evidence.`; + +export const AGENT_INSTRUCTIONS = `${AGENT_BASE}\n\n${MATERIAL_GUIDANCE}\n\n${TERRAIN_GUIDANCE}\n\n${DECORATION_GUIDANCE}`; diff --git a/bridge/src/security.ts b/bridge/src/security.ts index eec6861..60b4fde 100644 --- a/bridge/src/security.ts +++ b/bridge/src/security.ts @@ -1,4 +1,5 @@ -import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { mkdir, open, readFile, rename, unlink, writeFile } from 'node:fs/promises'; import { delimiter, dirname, join, resolve } from 'node:path'; // These are supported by the pinned Codex 0.153.4 runtime and official configuration reference. @@ -11,12 +12,82 @@ export const CODEX_CONFIG = { shell_tool: false, unified_exec: false, shell_snapshot: false, apps: false, hooks: false, multi_agent: false, plugins: false, remote_plugin: false, browser_use: false, browser_use_external: false, browser_use_full_cdp_access: false, - computer_use: false, image_generation: false, code_mode: false, code_mode_host: false, + // Models whose catalog tool_mode is code_mode_only need this host even with code_mode=false. + // The host executes tool orchestration; it does not enable shell, apps, or other integrations. + computer_use: false, image_generation: false, code_mode: false, code_mode_host: true, skill_mcp_dependency_install: false, }, }; export const CODEX_CONFIG_TOML = `# Managed by minecraft-builder-mcp; use a dedicated CODEX_HOME.\nsandbox_mode = "read-only"\napproval_policy = "on-request"\napprovals_reviewer = "user"\ncli_auth_credentials_store = "file"\nallow_login_shell = false\nweb_search = "disabled"\n\n[sandbox_workspace_write]\nnetwork_access = false\nwritable_roots = []\nexclude_slash_tmp = true\nexclude_tmpdir_env_var = true\n\n[shell_environment_policy]\ninherit = "none"\n\n[features]\n${Object.entries(CODEX_CONFIG.features).map(([key,value]) => `${key} = ${value}`).join('\n')}\n`; +// Frozen previous managed configuration: migration must never recognize arbitrary user settings. +const PRE_CODE_MODE_HOST_CONFIG = `# Managed by minecraft-builder-mcp; use a dedicated CODEX_HOME. +sandbox_mode = "read-only" +approval_policy = "on-request" +approvals_reviewer = "user" +cli_auth_credentials_store = "file" +allow_login_shell = false +web_search = "disabled" + +[sandbox_workspace_write] +network_access = false +writable_roots = [] +exclude_slash_tmp = true +exclude_tmpdir_env_var = true + +[shell_environment_policy] +inherit = "none" + +[features] +shell_tool = false +unified_exec = false +shell_snapshot = false +apps = false +hooks = false +multi_agent = false +plugins = false +remote_plugin = false +browser_use = false +browser_use_external = false +browser_use_full_cdp_access = false +computer_use = false +image_generation = false +code_mode = false +code_mode_host = false +skill_mcp_dependency_install = false +`; + +function differentConfig(): Error { + return new Error('MCB_CODEX_HOME contains a different config.toml. Choose a dedicated empty Codex home or the bridge-managed home; existing settings will not be overwritten.'); +} + +async function migrateCodeModeHost(configPath: string): Promise { + const backupPath = `${configPath}.before-code-mode-host`; + try { + const backup = await open(backupPath, 'wx', 0o600); + try { await backup.writeFile(PRE_CODE_MODE_HOST_CONFIG); await backup.sync(); } + finally { await backup.close(); } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + if (await readFile(backupPath, 'utf8') !== PRE_CODE_MODE_HOST_CONFIG) { + throw new Error('The managed Codex config backup already contains different settings; it will not be overwritten.'); + } + } + const temporaryPath = `${configPath}.${randomUUID()}.tmp`; + try { + const temporary = await open(temporaryPath, 'wx', 0o600); + try { await temporary.writeFile(CODEX_CONFIG_TOML); await temporary.sync(); } + finally { await temporary.close(); } + // Recheck after preparing the backup; do not replace settings edited during migration. + const current = await readFile(configPath, 'utf8'); + if (current === CODEX_CONFIG_TOML) return; + if (current !== PRE_CODE_MODE_HOST_CONFIG) throw differentConfig(); + await rename(temporaryPath, configPath); + } finally { + await unlink(temporaryPath).catch(error => { if (error.code !== 'ENOENT') throw error; }); + } +} + export interface CodexPaths { home: string; codexHome: string } export function codexPaths(stateDir: string, codexHome?: string): CodexPaths { const state = resolve(stateDir); @@ -29,7 +100,8 @@ export async function prepareCodexHome(paths: CodexPaths): Promise { try { existing = await readFile(configPath, 'utf8'); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } if (existing !== undefined && existing !== CODEX_CONFIG_TOML) { - throw new Error('MCB_CODEX_HOME contains a different config.toml. Choose a dedicated empty Codex home or the bridge-managed home; existing settings will not be overwritten.'); + if (existing !== PRE_CODE_MODE_HOST_CONFIG) throw differentConfig(); + await migrateCodeModeHost(configPath); } if (existing === undefined) { try { await writeFile(configPath, CODEX_CONFIG_TOML, { flag: 'wx', mode: 0o600 }); } @@ -62,14 +134,16 @@ export function securityReport(paths: CodexPaths) { configuredSandbox: 'read-only', adapterMode: 'read-only', adapterVersion: '1.11.0', adapterTurnSandbox: 'workspace-write', approvalPolicy: 'on-request', approvalsReviewer: 'user', sandboxedCommandNetwork: false, shellToolRequested: false, unifiedExecRequested: false, + codeModeHostRequested: true, codeModeHostRequiredForModelToolMode: 'code_mode_only', pinnedCliFeatureProbe: { codexVersion: '0.153.4', platform: 'linux', shell_tool: false, unified_exec: true }, inheritedMcpServers: false, acpFilesystem: false, acpTerminal: false, runtimeVerified: false, limitations: [ - 'Pinned Codex reports unified_exec enabled even when disabled explicitly; shell_tool is disabled. A real model tool-availability check has not run.', + 'Pinned Codex reports unified_exec enabled even when disabled explicitly; shell_tool is disabled. Doctor does not invoke a model to verify effective tool access; use an explicit end-to-end test.', + 'Models with tool_mode=code_mode_only require the bundled code-mode host even with features.code_mode=false; enabling that host does not enable the separately disabled integrations.', 'codex-acp 1.11.0 overrides turn sandbox to workspace-write even in its read-only mode; the session directory and temporary paths may remain writable.', 'Sandboxed-command network restrictions do not sandbox the bridge, ACP adapter or MCP server processes; these remain trusted local programs.', - 'No real model turn or end-to-end sandbox probe has run; configuration and initialization alone do not prove OS-level isolation.', + 'No end-to-end sandbox probe has run; authenticated text replies and configuration alone do not prove OS-level isolation.', 'Administrator-managed Codex settings and repository-local configuration can also apply; use the dedicated project/state directory and inspect doctor output.', ], }; diff --git a/bridge/src/terrain-schema.ts b/bridge/src/terrain-schema.ts new file mode 100644 index 0000000..2b601e8 --- /dev/null +++ b/bridge/src/terrain-schema.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +const coordinate = z.number().int().min(-30_000_000).max(30_000_000); +const point = z.object({ x: coordinate, z: coordinate }).strict(); +const position = z.object({ x: coordinate, y: z.number().int().min(-4096).max(4096), z: coordinate }).strict(); +const elevation = z.number().finite().min(-4096).max(4096); +const falloff = z.number().finite().min(1).max(2048); +const material = z.enum(['minecraft:stone','minecraft:andesite','minecraft:granite','minecraft:diorite','minecraft:deepslate', + 'minecraft:cobbled_deepslate','minecraft:dirt','minecraft:grass_block','minecraft:moss_block','minecraft:sandstone','minecraft:terracotta']); +const feature = z.discriminatedUnion('type', [ + z.object({ type: z.literal('hill'), center: point, radius: z.number().min(1).max(2048), height: elevation, falloff }).strict(), + z.object({ type: z.literal('basin'), center: point, radius: z.number().min(1).max(2048), height: elevation, falloff }).strict(), + z.object({ type: z.literal('plateau'), min: point, max: point, height: elevation, falloff }).strict(), + z.object({ type: z.literal('ridge'), points: z.array(point).min(2).max(32), width: z.number().min(1).max(1024), height: elevation, falloff }).strict(), + z.object({ type: z.literal('channel'), points: z.array(point).min(2).max(32), width: z.number().min(1).max(1024), height: elevation, falloff }).strict(), + z.object({ type: z.literal('terrace'), step: z.number().min(1).max(128), strength: z.number().min(0).max(1) }).strict(), +]); +export const terrainRecipe = z.object({ + version: z.literal(1), min: position, max: position, base_height: z.number().int().min(-4096).max(4096), + seed: z.number().int().min(-2147483648).max(2147483647), mode: z.enum(['sculpt','fill','cut']), + noise: z.object({ amplitude: z.number().min(0).max(256), scale: z.number().min(1).max(4096) }).strict(), + palette: z.object({ rock: material, soil: material, surface: material, soil_depth: z.number().int().min(0).max(16) }).strict(), + features: z.array(feature).max(64), preserve: z.array(z.object({ min: position, max: position }).strict()).max(64), +}).strict(); + + +export const terrainBrush = z.object({ + min: position, max: position, center: point, radius: z.number().int().min(1).max(16), + action: z.enum(['raise','lower','flatten','smooth']), + amount: z.number().int().min(1).max(32).optional(), height: z.number().int().min(-4096).max(4096).optional(), + strength: z.number().min(0).max(1).default(1), falloff: z.number().min(0).max(1).default(0.5), + smooth_radius: z.number().int().min(1).max(3).optional(), + preserve: z.array(z.object({min: position,max: position}).strict()).max(64).default([]), +}).strict(); diff --git a/bridge/src/tools.ts b/bridge/src/tools.ts index b940c52..92d0270 100644 --- a/bridge/src/tools.ts +++ b/bridge/src/tools.ts @@ -1,11 +1,14 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { z } from 'zod'; +import { terrainRecipe, terrainBrush } from './terrain-schema.js'; +import { DECORATION_GUIDANCE, MATERIAL_GUIDANCE, TERRAIN_GUIDANCE } from './building-guidance.js'; import { BackendError, type RpcBackend } from './backend.js'; const id = z.string().min(1).max(200); const position = z.object({ x: z.number().int().min(-30_000_000).max(30_000_000), y: z.number().int().min(-4096).max(4096), z: z.number().int().min(-30_000_000).max(30_000_000) }).strict(); -const block = z.string().regex(/^minecraft:[a-z0-9_]+(?:\[[a-z0-9_=,]+\])?$/).max(200); +const block = z.string().regex(/^minecraft:[a-z0-9_]+(?:\[[a-z0-9_=,]+\])?$/).max(1024); +const materialId = z.string().regex(/^minecraft:[a-z0-9_]+$/).max(128); // Deliberate small declarative language: the Paper compiler enforces all resource limits again. const shape = z.discriminatedUnion('type', [ z.object({ type: z.literal('box'), min: position, max: position, block, hollow: z.boolean().optional() }).strict(), @@ -22,7 +25,7 @@ export function toolResult(result: unknown): CallToolResult { if (result && typeof result === 'object' && 'imageBase64' in result) { const { imageBase64, mimeType, ...rest } = result as Record; if (rest.status !== 'completed' || typeof imageBase64 !== 'string' || imageBase64.length > 12_000_000 || !['image/png', 'image/jpeg'].includes(String(mimeType)) || !/^[A-Za-z0-9+/]*={0,2}$/.test(imageBase64)) { - throw new BackendError('invalid_image', 'Camera returned an invalid or oversized capture.'); + throw new BackendError('invalid_image', 'Backend returned an invalid or oversized image.'); } metadata = rest; content.push({ type: 'image', data: imageBase64, mimeType: String(mimeType) }); @@ -35,7 +38,7 @@ export function toolResult(result: unknown): CallToolResult { export function createMcpServer(backend: RpcBackend): McpServer { const server = new McpServer({ name: 'minecraft-builder-mcp', version: '0.1.0' }, { instructions: - 'Build only through these tools in the server-authorized project. Start with project_context and region_inspect. Prepare a compact recipe, inspect its summary, then apply with the returned plan ID/hash and a stable unique idempotency key. Conflicts preserve manual edits: never re-read and blindly overwrite them. Query operation_status until terminal; cancellation can leave partial edits. Camera unavailable means visually unverified. Never claim success from preparation alone.' }); + 'Build only through these tools in the server-authorized project. Start with project_context and region_inspect. Prepare a compact recipe, inspect its summary, then apply with the returned plan ID/hash and a stable unique idempotency key. Conflicts preserve manual edits: never re-read and blindly overwrite them. Query operation_status until terminal; cancellation can leave partial edits. Camera unavailable means visually unverified. Never claim success from preparation alone. For terrain, terrain_preview caches one declarative recipe and returns a target heightmap, not a world capture. Reuse terrain_id with terrain_prepare tile_index; review each tile summary and apply through build_apply. Keep the recipe locally for restart recovery. Stop on conflicts; never regenerate a conflicting plan to force it through. Terrain tools do not place water.' + '\n\n' + MATERIAL_GUIDANCE + '\n\n' + TERRAIN_GUIDANCE + '\n\n' + DECORATION_GUIDANCE }); function register(name: string, description: string, inputSchema: z.ZodRawShape, readOnly: boolean, idempotent = false) { server.registerTool(name, { description, inputSchema, annotations: { readOnlyHint: readOnly, destructiveHint: !readOnly, idempotentHint: idempotent, openWorldHint: false } }, async (args) => { try { return toolResult(await backend.call(name, args as Record)); } @@ -46,19 +49,24 @@ export function createMcpServer(backend: RpcBackend): McpServer { } }); } - register('project_context', 'Get authorized world, project, area, capabilities, supported blocks, and operation summaries. No full-world dump.', {}, true, true); - register('region_inspect', 'Inspect a bounded inclusive region. Prefer summary; blocks detail is only for a small local area. Both min and max are required; inspect a small section of the project area.', { min: position, max: position, detail: z.enum(['summary', 'blocks']).default('summary') }, true, true); - register('build_prepare', 'Prepare immutable geometry without changing the world. Use supported recipe operations from project_context. Returns plan_id, plan_hash and compact statistics.', { recipe, part_id: id.optional(), dependencies: z.array(position).max(512).optional() }, false); + register('project_context', 'Get authorized world, project, area, capabilities, a compact material catalog summary, and operation summaries. No registry or full-world dump.', {}, true, true); + register('material_search', 'Search the running server material catalog by name. Returns a bounded page of IDs and block/item flags, without block properties. Prefer a focused query; reuse catalog version and page only when needed. Item-only materials cannot be placed as blocks.', { query: z.string().max(96).optional(), kind: z.enum(['block', 'item', 'all']).default('block'), limit: z.number().int().min(1).max(32).default(16), cursor: z.string().min(1).max(100).optional() }, true, true); + register('material_describe', 'Get one exact material ID, its default block state and allowed property values from the running server. Describe only 1–3 selected materials, then reuse their defaults; item-only materials have no placeable block state. This does not expose inventory, entity or NBT editing.', { id: materialId }, true, true); + register('region_inspect', 'Inspect a bounded inclusive region. Prefer summary; blocks detail is only for a small local area and includes opaque snapshot_id hashes for block entities, without their NBT. Both min and max are required; inspect a small section of the project area.', { min: position, max: position, detail: z.enum(['summary', 'blocks']).default('summary') }, true, true); + register('build_prepare', 'Prepare immutable geometry without changing the world. Use supported recipe operations from project_context. Returns plan_id, plan_hash and compact statistics. Optional expected_blocks must cover every desired position exactly once; preserve each returned snapshot_id to detect block-entity data changes. The server rejects a changed caller snapshot atomically before preparation. Same-material state edits preserve existing block-entity data; recipes do not configure inventories, sign text or raw NBT.', { recipe, part_id: id.optional(), dependencies: z.array(position).max(512).optional(), expected_blocks: z.array(z.object({ pos: position, state: block, snapshot_id: z.string().regex(/^[a-f0-9]{64}$/).optional() }).strict()).min(1).max(4096).optional() }, false); register('build_apply', 'Apply a prepared plan with compare-before-write protection. Reuse the SAME idempotency_key after uncertain transport outcome; query project_context to recover an unknown operation ID, then operation_status first.', { plan_id: id, plan_hash: id, idempotency_key: id }, false, true); register('operation_status', 'Get exact state, changed count, conflicts and completion. A terminal cancelled/conflict state can include partial writes.', { operation_id: id }, true, true); register('operation_cancel', 'Request cancellation before the next server batch. Already applied changes remain journaled.', { operation_id: id }, false, true); register('operation_undo_prepare', 'Prepare checked undo of a recorded operation. Manual edits after construction become conflicts. Apply returned undo plan with build_apply.', { operation_id: id }, false); register('part_get', 'Get named part metadata and protected status, without expanding every block.', { part_id: id }, true, true); register('part_define', 'Register an exact part mask from a completed operation. Server rejects unsupported membership or overlap.', { name: z.string().min(1).max(64), operation_id: id }, false); - register('camera_list', 'List saved camera poses and whether a camera client is connected.', {}, true, true); + register('camera_list', 'List saved camera poses and camera configuration. This does not prove that the observer is online or ready.', {}, true, true); register('camera_capture', 'Request a real capture by saved camera_id or pose. A pending response returns captureId; poll using capture_id. Only completed results contain an image. Camera unavailable is not a successful visual check.', { camera_id: id.optional(), pose: pose.optional(), after_operation_id: id.optional(), capture_id: id.optional() }, true); register('asset_list', 'List up to 64 local schematic assets with dimensions and metadata, optionally filtered by query. No network library or generated thumbnails. Place .schem files manually in the plugin data/schematics directory; asset IDs never accept arbitrary paths.', { query: z.string().max(64).optional() }, true, true); - register('schematic_export', 'Export a dense inclusive region of at most 4096 supported blocks as a local Sponge v2 .schem asset. Optional origin is the clipboard anchor. Returns an asset ID and metadata; files remain in the plugin data/schematics directory.', { name: z.string().min(1).max(64).regex(/^[^\u0000-\u001f\u007f]+$/), min: position, max: position, origin: position.optional() }, false); - register('schematic_import_prepare', 'Prepare a checked import of a local .schem asset at target, optionally rotating 0/90/180/270 degrees. Rejects entities, block entities and unsupported blocks. Returns a normal plan; inspect it and use build_apply to edit the world.', { asset_id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/), target: position, rotation: z.union([z.literal(0),z.literal(90),z.literal(180),z.literal(270)]).default(0) }, false); + register('schematic_export', 'Export a dense inclusive region of at most 4096 registered block states as a local Sponge v2 .schem asset. Rejects block entities because their extra data cannot yet be exported. Optional origin is the clipboard anchor. Returns an asset ID and metadata; files remain in the plugin data/schematics directory.', { name: z.string().min(1).max(64).regex(/^[^\u0000-\u001f\u007f]+$/), min: position, max: position, origin: position.optional() }, false); + register('schematic_import_prepare', 'Prepare a checked import of a local .schem asset at target, optionally rotating 0/90/180/270 degrees. Accepts registered block states; rejects entity and block-entity NBT payloads. Returns a normal plan; inspect it and use build_apply to edit the world.', { asset_id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/), target: position, rotation: z.union([z.literal(0),z.literal(90),z.literal(180),z.literal(270)]).default(0) }, false); + register('terrain_preview', 'Preview and cache a deterministic terrain recipe without reading or editing the world. Returns a native heightmap image, terrain_id and tile_count. North (-Z) is up. Features run in order: hill/ridge add height; plateau blends to an absolute height; basin/channel only lower to an absolute height; terrace quantizes elevations. Radius/width is the flat core radius/half-width, falloff is the smooth outer bank. Noise is three-octave value noise with amplitude/scale only; no warp or ridged parameters. Large rectangular plateaus should be reserved for explicit architectural needs; prefer organic landforms and small blended foundations. Noise uses the seed and world coordinates. Preserve boxes omit writes. sculpt fills below and clears above the field; fill only targets below; cut only clears above. Save the recipe: cache holds 32 recipes until restart. Bounds are inclusive; large concepts can be previewed outside the current project.', { recipe: terrainRecipe, resolution: z.number().int().min(32).max(256).default(128) }, true, true); + register('terrain_prepare', 'Prepare ONE bounded terrain tile from a previously previewed terrain_id. tile_index is zero-based; X advances first, then Z, then Y. Returns a normal immutable plan or status=empty; no world writes. Replaces only natural terrain/air; bedrock, structures and unsupported blocks reject the tile. Use preserve boxes for existing work, including natural-material builds. Apply with build_apply and poll operation_status; undo with operation_undo_prepare. Each tile uses current live contents and existing area/loaded-chunk/protected-part checks. Multi-tile changes are not atomic.', { terrain_id: z.string().regex(/^[a-f0-9]{64}$/), tile_index: z.number().int().min(0).max(134_217_727) }, false); + register('terrain_brush_prepare', 'Read EXISTING terrain and prepare a relative brush with a native before/after/delta preview. No world edits until build_apply. action raise/lower requires amount; flatten requires absolute height; smooth averages the ORIGINAL snapshot using smooth_radius (1..3, default 1). strength 0..1 scales displacement; falloff 0..1 is the outer fraction of the radius (0=hard edge). min/max is the inclusive scan window: <=4096 cells, full circular footprint plus smoothing halo, terrain in every column, air above both old and new surfaces, enough depth for cutting. No structures/fluids in scan; smaller brushes for constrained sites. All scanned cells become checked dependencies. Preserve boxes skip an intersecting edited column. Reuses existing surface and subsoil; it does not automatically expose rock on cliffs. Use small local refinements, keep routes gentle and avoid flattening whole districts. Returns plan_state=prepared with normal plan ID/hash or plan_state=empty; review and apply, then poll status. Never reprepare to force a conflicting brush through.', { brush: terrainBrush }, false); return server; } diff --git a/bridge/test/acp.test.mjs b/bridge/test/acp.test.mjs index c102b06..bb014f0 100644 --- a/bridge/test/acp.test.mjs +++ b/bridge/test/acp.test.mjs @@ -1,10 +1,12 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { setTimeout as pause } from 'node:timers/promises'; import { CodexSession, agentEnvironment } from '../dist/acp.js'; +import { longReply, overlongWord } from './fixtures/streamed-replies.mjs'; async function fixture(t){ const stateDir=await mkdtemp(join(tmpdir(),'mcb-acp-'));t.after(()=>rm(stateDir,{recursive:true,force:true})); const log=join(stateDir,'protocol.jsonl'); @@ -12,6 +14,34 @@ async function fixture(t){ const records=async()=> (await readFile(log,'utf8').catch(()=>'' )).trim().split('\n').filter(Boolean).map(JSON.parse); return {options,records}; } +test('updated terrain and material guidance reaches resumed ACP sessions once without resetting history',async t=>{ + const {options,records}=await fixture(t); + let session=new CodexSession({projectId:'p',playerId:'u'},options);t.after(()=>session.close()); + await session.prompt('first request',async()=>{});session.close(); + const key=createHash('sha256').update('p\0u').digest('hex').slice(0,32); + const file=join(options.stateDir,key,'session.json'); + const saved=JSON.parse(await readFile(file,'utf8'));saved.instructionsHash='older-prompt'; + await writeFile(file,JSON.stringify(saved)); + session=new CodexSession({projectId:'p',playerId:'u'},options); + await session.prompt('after update',async()=>{}); + await session.prompt('next request',async()=>{});session.close(); + session=new CodexSession({projectId:'p',playerId:'u'},options); + await session.prompt('same guidance after restart',async()=>{}); + const calls=await records();const prompts=calls.filter(v=>v.method==='session/prompt').map(v=>v.params.prompt[0].text); + assert.ok(prompts[1].includes('shacraft-natural-v1')); + assert.ok(prompts[1].includes('Avoid large rectangular plateaus')); + assert.ok(prompts[1].includes('NOT as terrain_preview parameters')); + for(const prompt of [prompts[0],prompts[1]]) { + assert.ok(prompt.includes('project_context contains only a catalog summary')); + assert.ok(prompt.includes('material_search')); + assert.ok(prompt.includes('material_describe for 1–3 chosen materials')); + assert.ok(prompt.includes('do not enumerate the registry')); + assert.ok(prompt.includes('item-only materials can be discovered but cannot be placed')); + } + assert.equal(prompts[2],'next request');assert.equal(prompts[3],'same guidance after restart'); + assert.equal(calls.filter(v=>v.method==='session/new').length,1); + assert.equal(calls.filter(v=>v.method==='session/load').length,2); +}); test('ACP initializes, injects scoped MCP, preserves sessions, suppresses private replay and thoughts',async t=>{ const {options,records}=await fixture(t);const messages=[]; let session=new CodexSession({projectId:'project',playerId:'player'},options);t.after(()=>session.close()); @@ -34,6 +64,38 @@ test('permission requests fail closed and explain in chat',async t=>{ assert.ok(messages.some(text=>text.includes('отклонено'))); assert.equal((await records()).find(item=>item.id==='approval').result.outcome.outcome,'cancelled'); }); +test('MCP startup failure before session creation stops the prompt and redacts diagnostics',async t=>{ + const {options,records}=await fixture(t);options.args.push('mcp-fail-new');const messages=[]; + const session=new CodexSession({projectId:'p',playerId:'u'},options);t.after(()=>session.close()); + await assert.rejects(session.prompt('Must not run',async text=>messages.push(text)),error=>error.code==='mcp_unavailable'); + assert.ok(!(await records()).some(item=>item.method==='session/prompt')); + assert.deepEqual(messages,['Minecraft MCP не запустился: инструменты строительства недоступны. Запрос остановлен; проверь настройки и процесс моста.']); + assert.ok(!messages.join(' ').includes('agent-secret')); +}); +test('MCP startup failure during load stops the new turn while historical failures stay private',async t=>{ + const {options,records}=await fixture(t);const messages=[]; + let session=new CodexSession({projectId:'p',playerId:'u'},options);t.after(()=>session.close()); + await session.prompt('First turn',async()=>{});session.close();options.args.push('mcp-fail-load'); + session=new CodexSession({projectId:'p',playerId:'u'},options); + await assert.rejects(session.prompt('Must not run',async text=>messages.push(text)),error=>error.code==='mcp_unavailable'); + assert.equal((await records()).filter(item=>item.method==='session/prompt').length,1); + assert.ok(messages.some(text=>text.includes('Minecraft MCP не запустился'))); + assert.ok(!messages.join(' ').includes('PRIVATE')); +}); +test('MCP startup failures from another session do not block the selected session',async t=>{ + const {options}=await fixture(t);options.args.push('mcp-fail-other-session');const messages=[]; + const session=new CodexSession({projectId:'p',playerId:'u'},options);t.after(()=>session.close()); + await session.prompt('Continue selected session',async text=>messages.push(text)); + assert.ok(messages.some(text=>text.includes('Built turn 1'))); + assert.ok(!messages.some(text=>text.includes('MCP не запустился'))); +}); +test('an asynchronous MCP startup failure interrupts an active turn with a safe explanation',async t=>{ + const {options}=await fixture(t);const messages=[]; + const session=new CodexSession({projectId:'p',playerId:'u'},options);t.after(()=>session.close()); + await assert.rejects(session.prompt('mcp-fail-active',async(text,done)=>messages.push({text,done})),error=>error.code==='mcp_unavailable'); + assert.ok(messages.some(item=>item.text.includes('Minecraft MCP не запустился'))); + assert.ok(!messages.some(item=>item.done || item.text.includes('agent-secret'))); +}); test('ACP cancel completes active prompt without ending the whole daemon',async t=>{ const {options,records}=await fixture(t);const messages=[]; const session=new CodexSession({projectId:'p',playerId:'u'},options);t.after(()=>session.close()); @@ -52,3 +114,69 @@ test('cancel during startup does not send a prompt after initialization complete const work=session.prompt('must-not-send',async()=>{});await session.cancel();await work; assert.ok(!(await records()).some(item=>item.method==='session/prompt')); }); + +test('delayed token streams keep words intact and flush the next turn independently',async t=>{ + const {options}=await fixture(t); + const session=new CodexSession({projectId:'p',playerId:'u'},options);t.after(()=>session.close()); + const first=[]; + await session.prompt('stream-greeting',async(text,done)=>first.push({text,done})); + const visible=items=>items.filter(item=>item.text).map(item=>item.text).join(' ').replace(/\s+/g,' ').trim(); + assert.equal(visible(first),'Привет! Что построим?','ACP token boundaries must not become game-chat word boundaries'); + assert.equal(first.at(-1).done,true); + assert.equal(first.filter(item=>item.done).length,1); + await pause(1600); + const second=[]; + await session.prompt('stream-follow-up',async(text,done)=>second.push({text,done})); + assert.equal(visible(second),'Понятно. Проверяю освещение','a new prompt resets buffering and completion flushes an unfinished sentence'); + assert.equal(second.at(-1).done,true); + assert.equal(second.filter(item=>item.done).length,1); +}); + +test('slow chat delivery preserves streamed text order and completes after the final reply',async t=>{ + const {options}=await fixture(t); + const session=new CodexSession({projectId:'p',playerId:'u'},options);t.after(()=>session.close()); + const messages=[];let inFlight=0;let maxInFlight=0;let calls=0; + await session.prompt('stream-slow-delivery',async(text,done)=>{ + const call=++calls; + maxInFlight=Math.max(maxInFlight,++inFlight); + // A slower first HTTP reply must not let later chunks or the done marker overtake it. + await pause(call===1 ? 100 : 3); + messages.push({text,done}); + inFlight--; + }); + assert.equal(inFlight,0,'prompt completion must await all chat replies'); + assert.equal(maxInFlight,1,'chat replies must be serialized'); + assert.equal(messages.at(-1).done,true); + assert.equal(messages.filter(item=>item.done).length,1); + const visible=messages.filter(item=>item.text).map(item=>item.text); + assert.ok(visible.length>1,'long replies need multiple Minecraft chat messages'); + assert.ok(visible.every(text=>text.length<=240),'chat messages must respect the UTF-16 length bound'); + assert.ok(visible.every(text=>text.isWellFormed()),'chunking must not split a surrogate pair'); + assert.equal(visible.join(' ').replace(/\s+/g,' ').trim(),longReply,'visible text must retain every word exactly once and in order'); +}); + +test('a word longer than one chat message is bounded without splitting emoji',async t=>{ + const {options}=await fixture(t); + const session=new CodexSession({projectId:'p',playerId:'u'},options);t.after(()=>session.close()); + const messages=[]; + await session.prompt('stream-overlong-word',async(text,done)=>messages.push({text,done})); + const visible=messages.filter(item=>item.text).map(item=>item.text); + assert.ok(visible.length>1); + assert.ok(visible.every(text=>text.length<=240 && text.isWellFormed())); + assert.equal(visible.join(''),overlongWord,'an unavoidable long-word split must preserve all Unicode text'); + assert.equal(messages.at(-1).done,true); +}); + + +test('only correlated Minecraft tools receive one-use approval in the active session',async t=>{ + for(const mode of ['valid','material-search','material-describe','terrain-preview','terrain-prepare','terrain-brush','foreign','unknown','wrong-session','uncorrelated','completed','no-meta','persistent']) { + await t.test(mode,async t=>{ + const {options,records}=await fixture(t);const messages=[]; + const session=new CodexSession({projectId:'p',playerId:'u'},options);t.after(()=>session.close()); + await session.prompt('mcp-approval-'+mode,async text=>messages.push(text)); + const response=(await records()).find(item=>item.id==='approval').result.outcome; + assert.deepEqual(response,['valid','material-search','material-describe','terrain-preview','terrain-prepare','terrain-brush'].includes(mode)?{outcome:'selected',optionId:'allow_once'}:{outcome:'cancelled'}); + assert.equal(messages.some(text=>text.includes('отклонено')),!['valid','material-search','material-describe','terrain-preview','terrain-prepare','terrain-brush'].includes(mode)); + }); + } +}); diff --git a/bridge/test/fixtures/mock-acp.mjs b/bridge/test/fixtures/mock-acp.mjs index 6096dea..193c7aa 100644 --- a/bridge/test/fixtures/mock-acp.mjs +++ b/bridge/test/fixtures/mock-acp.mjs @@ -1,21 +1,52 @@ import { createInterface } from 'node:readline'; import { appendFileSync } from 'node:fs'; +import { setTimeout as pause } from 'node:timers/promises'; +import { greetingChunks, followUpChunks, longReply, overlongWord, tokenChunks } from './streamed-replies.mjs'; const send = value => process.stdout.write(JSON.stringify({jsonrpc:'2.0',...value})+'\n'); +const startupFailure = (sessionId = 'session-one') => send({method:'session/update',params:{sessionId,update:{sessionUpdate:'tool_call',toolCallId:'mcp_startup.minecraft-builder-mcp',title:'mcp__minecraft-builder-mcp__startup',kind:'other',status:'failed',content:[{type:'content',content:{type:'text',text:'PRIVATE STARTUP DETAIL agent-secret'}}]}}}); let promptId;let cancelled=false;let turn=0; for await (const line of createInterface({input:process.stdin})) { const msg=JSON.parse(line); if(process.argv[2]) appendFileSync(process.argv[2],JSON.stringify(msg)+'\n'); if(msg.method==='initialize') send({id:msg.id,result:{protocolVersion:1,agentCapabilities:{loadSession:true},authMethods:[]}}); - else if(msg.method==='session/new') send({id:msg.id,result:{sessionId:'session-one'}}); + else if(msg.method==='session/new') { + if(process.argv[3]==='mcp-fail-new') startupFailure(); + if(process.argv[3]==='mcp-fail-other-session') startupFailure('unrelated-session'); + send({id:msg.id,result:{sessionId:'session-one'}}); + } else if(msg.method==='session/load') { send({method:'session/update',params:{sessionId:'session-one',update:{sessionUpdate:'agent_message_chunk',content:{type:'text',text:'PRIVATE HISTORY'}}}}); + send({method:'session/update',params:{sessionId:'session-one',update:{sessionUpdate:'tool_call',toolCallId:'old-mcp-tool-call',title:'mcp__minecraft-builder-mcp__project_context',kind:'other',status:'failed',content:[{type:'content',content:{type:'text',text:'PRIVATE HISTORY FAILED TOOL'}}]}}}); + if(process.argv[3]==='mcp-fail-load') startupFailure(); send({id:msg.id,result:{}}); } else if(msg.method==='session/prompt') { turn++;promptId=msg.id; const content=msg.params.prompt[0].text; + if(content.includes('mcp-fail-active')) {startupFailure();continue;} if(content.includes('wait-for-cancel')) continue; + if(content.includes('mcp-approval-')) { + const mode=content.match(/mcp-approval-([a-z-]+)/)?.[1]; + const server=mode==='foreign'?'other-server':'minecraft-builder-mcp'; + const tool=mode==='unknown'?'run_shell':mode==='material-search'?'material_search':mode==='material-describe'?'material_describe':mode==='terrain-preview'?'terrain_preview':mode==='terrain-prepare'?'terrain_prepare':mode==='terrain-brush'?'terrain_brush_prepare':'build_prepare'; + const sid=mode==='wrong-session'?'other-session':'session-one'; + if(mode!=='uncorrelated') send({method:'session/update',params:{sessionId:sid,update:{sessionUpdate:'tool_call',toolCallId:'mc-call',kind:'execute',title:`mcp.${server}.${tool}`,status:'in_progress',rawInput:{server,tool,arguments:{}},_meta:{is_mcp_tool_call:true}}}}); + if(mode==='completed') send({method:'session/update',params:{sessionId:'session-one',update:{sessionUpdate:'tool_call_update',toolCallId:'mc-call',status:'completed'}}}); + send({id:'approval',method:'session/request_permission',params:{sessionId:'session-one',toolCall:{toolCallId:'mc-call',kind:'execute',status:'pending'},...(mode==='no-meta'?{}:{_meta:{is_mcp_tool_approval:true}}),options:[{optionId:mode==='persistent'?'allow_always':'allow_once',name:'Allow',kind:mode==='persistent'?'allow_always':'allow_once'}]}});continue; + } if(content.includes('request-permission')) {send({id:'approval',method:'session/request_permission',params:{sessionId:'session-one',toolCall:{toolCallId:'dangerous',title:'Permission',kind:'execute'},options:[{optionId:'yes',name:'Allow',kind:'allow_once'}]}});continue;} + if(content.includes('stream-greeting') || content.includes('stream-follow-up') || content.includes('stream-slow-delivery') || content.includes('stream-overlong-word')) { + const greeting = content.includes('stream-greeting'); + const chunks = greeting ? greetingChunks : content.includes('stream-follow-up') ? followUpChunks : tokenChunks(content.includes('stream-overlong-word') ? overlongWord : longReply); + // Real models can think for seconds before emitting their first incomplete token. + if(greeting) await pause(1600); + for(const text of chunks) { + send({method:'session/update',params:{sessionId:'session-one',update:{sessionUpdate:'agent_message_chunk',content:{type:'text',text}}}}); + await pause(greeting ? 12 : 1); + } + send({id:msg.id,result:{stopReason:'end_turn'}}); + continue; + } send({method:'session/update',params:{sessionId:'session-one',update:{sessionUpdate:'agent_thought_chunk',content:{type:'text',text:'SECRET THOUGHT'}}}}); send({method:'session/update',params:{sessionId:'session-one',update:{sessionUpdate:'agent_message_chunk',content:{type:'text',text:`Built turn ${turn}.`}}}}); send({id:msg.id,result:{stopReason:'end_turn'}}); diff --git a/bridge/test/fixtures/streamed-replies.mjs b/bridge/test/fixtures/streamed-replies.mjs new file mode 100644 index 0000000..24ab2f2 --- /dev/null +++ b/bridge/test/fixtures/streamed-replies.mjs @@ -0,0 +1,17 @@ +export const greetingChunks = ['Пр', 'ивет! ', 'Что пост', 'роим?']; +export const followUpChunks = ['По', 'нятно. ', 'Проверяю ', 'осве', 'щение']; +export const longReply = 'Осматриваю зал. ' + Array.from({ length: 28 }, (_, i) => + `Арка${i + 1} цела, фонарь${i + 1} 🏮 установлен ровно` +).join(', ') + '. Проверка завершена'; +export const overlongWord = 'А'.repeat(239) + '🏮'.repeat(150) + 'конец'; + +export function tokenChunks(text) { + const sizes = [2, 3, 7, 4, 11]; + const chunks = []; + for (let offset = 0, i = 0; offset < text.length; i++) { + const end = Math.min(text.length, offset + sizes[i % sizes.length]); + chunks.push(text.slice(offset, end)); + offset = end; + } + return chunks; +} diff --git a/bridge/test/live-brush.mjs b/bridge/test/live-brush.mjs new file mode 100644 index 0000000..1608ce3 --- /dev/null +++ b/bridge/test/live-brush.mjs @@ -0,0 +1,61 @@ +// Opt-in isolated Paper test: relative edits, native preview, halo conflicts, full restoration. +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { resolve } from 'node:path'; +import { writeFile } from 'node:fs/promises'; +import { setTimeout as pause } from 'node:timers/promises'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +const client=new Client({name:'brush-live-test',version:'1'}); +const transport=new StdioClientTransport({command:process.execPath,args:[resolve('dist/mcp.js')],env:Object.fromEntries(Object.entries(process.env).filter(([k,v])=>v!==undefined&&k!=='MCB_TOKEN')),stderr:'pipe'}); +async function call(name,args={},allowError=false){for(let i=0;i<100;i++){ + const result=await client.callTool({name,arguments:args});const data=JSON.parse(result.content.find(c=>c.type==='text').text); + if(result.isError&&data.code==='busy'){await pause(100);continue;} + if(result.isError&&!allowError)throw new Error(`${name}: ${data.code}: ${data.message}`); + return {data,result}; +}throw new Error('Still busy');} +async function done(id){for(let i=0;i<1000;i++){const {data}=await call('operation_status',{operation_id:id});if(['applied','conflict','cancelled','failed','recovery_required'].includes(data.status))return data;await pause(50);}throw new Error(`Still running: ${id}`);} +async function apply(plan){const args={plan_id:plan.plan_id,plan_hash:plan.plan_hash,idempotency_key:'brush-test-'+randomUUID()};const {data}=await call('build_apply',args);return await done(data.operation_id);} +async function undo(id){const {data}=await call('operation_undo_prepare',{operation_id:id});const result=await apply(data);assert.equal(result.status,'applied');} +const min={x:-4,y:80,z:-4},max={x:4,y:92,z:4}; +async function air(){const {data}=await call('region_inspect',{min,max,detail:'summary'});assert.deepEqual(data.palette,{'minecraft:air':1053});} +async function surface(){const {data}=await call('region_inspect',{min:{x:0,y:80,z:0},max:{x:0,y:92,z:0},detail:'blocks'});return Math.max(...data.blocks.filter(b=>b.state!=='minecraft:air').map(b=>b.pos.y));} +const brush={min,max,center:{x:0,z:0},radius:3,strength:1,falloff:0.5}; +let base; +try{ + await client.connect(transport); + assert.ok((await call('project_context')).data.capabilities.includes('terrain_brush_prepare'));await air(); + const {data:basePlan}=await call('build_prepare',{recipe:{version:1,operations:[ + {type:'box',min,max:{x:4,y:83,z:4},block:'minecraft:stone'}, + {type:'box',min:{x:-4,y:84,z:-4},max:{x:4,y:84,z:4},block:'minecraft:grass_block'}, + ]}});base=await apply(basePlan);assert.equal(base.status,'applied');assert.equal(await surface(),84); + for(const [action,extra,expected] of [['raise',{amount:3},87],['lower',{amount:2},82],['flatten',{height:86},86]]){ + const {data:plan,result}=await call('terrain_brush_prepare',{brush:{...brush,action,...extra}}); + assert.equal(plan.plan_state,'prepared');assert.equal(plan.dependency_blocks,1053);assert.equal(plan.world_edited,false);assert.equal(await surface(),84); + const png=result.content.find(c=>c.type==='image');assert.ok(png); + if(action==='raise')await writeFile('../docs/references/terrain-brush-live-preview.png',Buffer.from(png.data,'base64')); + const applied=await apply(plan);assert.equal(applied.status,'applied');assert.equal(await surface(),expected); + await undo(applied.operation_id);assert.equal(await surface(),84); + console.log(JSON.stringify({action,status:'passed',written:applied.written,center_after:expected,undo_height:84})); + } + const {data:spikePlan}=await call('terrain_brush_prepare',{brush:{...brush,radius:1,action:'raise',amount:4,falloff:0}});const spike=await apply(spikePlan);assert.equal(spike.status,'applied'); + const {data:smoothPlan}=await call('terrain_brush_prepare',{brush:{...brush,action:'smooth',smooth_radius:1}});const smooth=await apply(smoothPlan);assert.equal(smooth.status,'applied');assert.ok(await surface()<88);await undo(smooth.operation_id);assert.equal(await surface(),88); + const spikeMin={x:-1,y:84,z:-1},spikeMax={x:1,y:88,z:1}; + const spikeSnapshot=(await call('region_inspect',{min:spikeMin,max:spikeMax,detail:'blocks'})).data.blocks; + for(const b of spikeSnapshot){const inside=b.pos.x*b.pos.x+b.pos.z*b.pos.z<=1;assert.equal(b.state,inside?(b.pos.y===88?'minecraft:grass_block[snowy=false]':'minecraft:stone'):(b.pos.y===84?'minecraft:grass_block[snowy=false]':'minecraft:air'));} + const {data:removeSpike}=await call('build_prepare',{recipe:{version:1,operations:[{type:'box',min:{x:-1,y:85,z:-1},max:spikeMax,block:'minecraft:air'},{type:'box',min:spikeMin,max:{x:1,y:84,z:1},block:'minecraft:grass_block'}]}}); + assert.equal((await apply(removeSpike)).status,'applied');assert.equal(await surface(),84); + console.log(JSON.stringify({action:'smooth',status:'passed',written:smooth.written})); + const {data:stale}=await call('terrain_brush_prepare',{brush:{...brush,action:'raise',amount:2}}); + const point={x:4,y:86,z:0};const {data:foreignPlan}=await call('build_prepare',{recipe:{version:1,operations:[{type:'box',min:point,max:point,block:'minecraft:gold_block'}]}});const foreign=await apply(foreignPlan);assert.equal(foreign.status,'applied'); + const conflict=await apply(stale);assert.equal(conflict.status,'conflict');assert.equal(conflict.written,0); + const denied=await call('terrain_brush_prepare',{brush:{...brush,action:'raise',amount:2}},true);assert.ok(denied.result.isError);await undo(foreign.operation_id); + const {data:empty}=await call('terrain_brush_prepare',{brush:{...brush,action:'flatten',height:84}});assert.equal(empty.plan_state,'empty');assert.ok(!empty.plan_id); + // Base ownership has been superseded by checked brush undos. Verify every fixture cell, + // then prepare a fresh checked cleanup of this isolated fixture; never force the old undo. + const baseMax={x:4,y:84,z:4}; + const restored=(await call('region_inspect',{min,max:baseMax,detail:'blocks'})).data.blocks; + assert.equal(restored.length,405);for(const b of restored)assert.equal(b.state,b.pos.y===84?'minecraft:grass_block[snowy=false]':'minecraft:stone'); + const {data:cleanup}=await call('build_prepare',{recipe:{version:1,operations:[{type:'box',min,max:baseMax,block:'minecraft:air'}]}}); + assert.equal((await apply(cleanup)).status,'applied');base=undefined;await air();console.log('LIVE BRUSH MCP PASSED; full fixture restored to air'); +}finally{if(base)console.error(`Fixture needs inspection; base operation: ${base.operation_id}`);await client.close();} diff --git a/bridge/test/live-terrain.mjs b/bridge/test/live-terrain.mjs new file mode 100644 index 0000000..3fb509d --- /dev/null +++ b/bridge/test/live-terrain.mjs @@ -0,0 +1,58 @@ +// Opt-in live test in an isolated all-air fixture. No credentials are printed. +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { resolve } from 'node:path'; +import { readFile } from 'node:fs/promises'; +import { setTimeout as pause } from 'node:timers/promises'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +const client=new Client({name:'terrain-live-test',version:'1'}); +const transport=new StdioClientTransport({command:process.execPath,args:[resolve('dist/mcp.js')],env:Object.fromEntries(Object.entries(process.env).filter(([k,v])=>v!==undefined&&k!=='MCB_TOKEN')),stderr:'pipe'}); +async function call(name,args={},allowError=false){for(let i=0;i<100;i++){ + const result=await client.callTool({name,arguments:args});const data=JSON.parse(result.content.find(c=>c.type==='text').text); + if(result.isError&&data.code==='busy'){await pause(100);continue;} + if(result.isError&&!allowError)throw new Error(`${name}: ${data.code}: ${data.message}`); + return {data,result}; +}throw new Error('Still busy');} +async function done(id){for(let i=0;i<1000;i++){const {data}=await call('operation_status',{operation_id:id});if(['applied','conflict','cancelled','failed','recovery_required'].includes(data.status))return data;await pause(50);}throw new Error(`Still running: ${id}`);} +async function apply(plan){const args={plan_id:plan.plan_id,plan_hash:plan.plan_hash,idempotency_key:'terrain-test-'+randomUUID()};const {data}=await call('build_apply',args);const result=await done(data.operation_id);return {args,result};} +async function undo(id){const {data}=await call('operation_undo_prepare',{operation_id:id});const {result}=await apply(data);assert.equal(result.status,'applied');return result;} +const recipe=JSON.parse(await readFile('../examples/terrain/small-hill.json','utf8')); +const {min,max}=recipe; +const inspect=async()=> (await call('region_inspect',{min,max,detail:'blocks'})).data.blocks; +const air=blocks=>assert.ok(blocks.every(b=>b.state==='minecraft:air')); +try{ + await client.connect(transport); + const {data:context}=await call('project_context');assert.ok(context.capabilities.includes('terrain_prepare')); + air(await inspect()); + const {data:preview,result:previewResult}=await call('terrain_preview',{recipe}); + assert.equal(preview.kind,'terrain_heightmap_preview');assert.equal(preview.world_verified,false);assert.equal(preview.tile_count,1); + const png=previewResult.content.find(c=>c.type==='image');assert.ok(png);assert.equal(Buffer.from(png.data,'base64').subarray(1,4).toString(),'PNG'); + air(await inspect()); + const {data:plan}=await call('terrain_prepare',{terrain_id:preview.terrain_id,tile_index:0});air(await inspect());assert.ok(plan.changed_blocks>0); + const {args,result}=await apply(plan);assert.equal(result.status,'applied'); + assert.equal((await call('build_apply',args)).data.operation_id,result.operation_id); + const built=await inspect();assert.ok(built.some(b=>b.state.startsWith('minecraft:grass_block')));assert.ok(built.some(b=>b.state==='minecraft:dirt')); + assert.equal(built.filter(b=>b.state!=='minecraft:air').length,result.written); + assert.equal((await call('terrain_prepare',{terrain_id:preview.terrain_id,tile_index:0})).data.status,'empty'); + await undo(result.operation_id);air(await inspect()); + console.log(JSON.stringify({check:'terrain-native-preview-prepare-apply-idempotency-undo',status:'passed',written:result.written})); + const {data:stale}=await call('terrain_prepare',{terrain_id:preview.terrain_id,tile_index:0}); + const {data:goldPlan}=await call('build_prepare',{recipe:{version:1,operations:[{type:'box',min,max:min,block:'minecraft:gold_block'}]}}); + const {result:gold}=await apply(goldPlan);assert.equal(gold.status,'applied'); + const denied=await call('terrain_prepare',{terrain_id:preview.terrain_id,tile_index:0},true);assert.equal(denied.data.code,'protected_terrain'); + const {result:conflict}=await apply(stale);assert.equal(conflict.status,'conflict');assert.equal(conflict.written,0); + const masked=structuredClone(recipe);masked.preserve=[{min,max:min}]; + const {data:maskedPreview}=await call('terrain_preview',{recipe:masked}); + const {data:maskedPlan}=await call('terrain_prepare',{terrain_id:maskedPreview.terrain_id,tile_index:0}); + const {result:maskedDone}=await apply(maskedPlan);assert.equal(maskedDone.status,'applied'); + assert.equal((await call('region_inspect',{min,max:min,detail:'blocks'})).data.blocks[0].state,'minecraft:gold_block'); + await undo(maskedDone.operation_id);await undo(gold.operation_id);air(await inspect()); + console.log(JSON.stringify({check:'protected-existing-build-preserve-mask-and-stale-plan-conflict',status:'passed'})); + const empty=structuredClone(recipe);empty.preserve=[{min,max}]; + const {data:emptyPreview}=await call('terrain_preview',{recipe:empty});assert.equal((await call('terrain_prepare',{terrain_id:emptyPreview.terrain_id,tile_index:0})).data.status,'empty'); + const outside=structuredClone(recipe);outside.min.x=200;outside.max.x=207; + const {data:outsidePreview}=await call('terrain_preview',{recipe:outside});assert.equal((await call('terrain_prepare',{terrain_id:outsidePreview.terrain_id,tile_index:0},true)).data.code,'out_of_bounds'); + assert.equal((await call('terrain_prepare',{terrain_id:'0'.repeat(64),tile_index:0},true)).data.code,'not_found'); + air(await inspect());console.log('LIVE TERRAIN MCP PASSED; test area restored to air'); +}finally{await client.close();} diff --git a/bridge/test/mcp.test.mjs b/bridge/test/mcp.test.mjs index 6dec7e7..e51bf8e 100644 --- a/bridge/test/mcp.test.mjs +++ b/bridge/test/mcp.test.mjs @@ -19,7 +19,9 @@ test('real stdio MCP lists tools, validates recipes, calls backend and returns c const client=new Client({name:'test',version:'1'}); t.after(async()=>{await client.close();backend.closeAllConnections();backend.close();}); await client.connect(transport); - const list=await client.listTools();assert.equal(list.tools.length,14); + assert.ok(client.getInstructions().includes('Avoid large rectangular plateaus')); + assert.ok(client.getInstructions().includes('NOT as terrain_preview parameters')); + const list=await client.listTools();assert.equal(list.tools.length,19); assert.ok(list.tools.some(tool=>tool.name==='schematic_import_prepare'));assert.ok(!list.tools.some(tool=>tool.name==='chat_poll')); const context=await client.callTool({name:'project_context',arguments:{player_id:'forged'}}); assert.equal(JSON.parse(context.content[0].text).project_id,'project'); @@ -35,6 +37,75 @@ test('real stdio MCP lists tools, validates recipes, calls backend and returns c const invalidRotation=await client.callTool({name:'schematic_import_prepare',arguments:{asset_id:'asset',target:{x:0,y:64,z:0},rotation:45}});assert.equal(invalidRotation.isError,true);assert.equal(seen.length,beforeAssets); await client.callTool({name:'schematic_import_prepare',arguments:{asset_id:'asset',target:{x:0,y:64,z:0},rotation:90}});assert.equal(seen.at(-1).params.rotation,90); await client.callTool({name:'asset_list',arguments:{query:'tower'}});assert.equal(seen.at(-1).params.query,'tower'); + const recipe={version:1,min:{x:0,y:0,z:0},max:{x:31,y:31,z:31},base_height:8,seed:1,mode:'sculpt',noise:{amplitude:3,scale:16},palette:{rock:'minecraft:stone',soil:'minecraft:dirt',surface:'minecraft:grass_block',soil_depth:2},features:[{type:'plateau',min:{x:2,z:2},max:{x:8,z:8},height:14,falloff:4}],preserve:[]}; + await client.callTool({name:'terrain_preview',arguments:{recipe}});assert.equal(seen.at(-1).method,'terrain_preview');assert.equal(seen.at(-1).params.resolution,128); + await client.callTool({name:'terrain_prepare',arguments:{terrain_id:'a'.repeat(64),tile_index:1}});assert.equal(seen.at(-1).method,'terrain_prepare'); + const beforeTerrain=seen.length; + for(const args of [{recipe:{...recipe,script:'execute'}},{recipe:{...recipe,features:[{type:'channel',points:[],width:2,falloff:3,height:0}]}},{recipe:{...recipe,palette:{...recipe.palette,rock:'minecraft:water'}}}]) assert.equal((await client.callTool({name:'terrain_preview',arguments:args})).isError,true); + assert.equal((await client.callTool({name:'terrain_prepare',arguments:{terrain_id:'../../file',tile_index:-1}})).isError,true); + assert.equal(seen.length,beforeTerrain); + const brush={min:{x:-4,y:0,z:-4},max:{x:4,y:12,z:4},center:{x:0,z:0},radius:3,action:'raise',amount:2}; + await client.callTool({name:'terrain_brush_prepare',arguments:{brush}}); + assert.equal(seen.at(-1).method,'terrain_brush_prepare');assert.equal(seen.at(-1).params.brush.falloff,0.5); + const beforeBrush=seen.length; + for(const bad of [{...brush,radius:50},{...brush,strength:2},{...brush,action:'execute'},{...brush,script:'run'}])assert.equal((await client.callTool({name:'terrain_brush_prepare',arguments:{brush:bad}})).isError,true); + assert.equal(seen.length,beforeBrush); +}); +test('material discovery uses bounded read-only calls and never expands registry in schemas or instructions', async t => { + const seen=[]; + const backend=createServer(async(req,res)=>{ + let body='';for await(const chunk of req) body+=chunk; + const rpc=JSON.parse(body);seen.push(rpc); + const result=rpc.method==='material_search' + ?{catalog_version:'v1',query:rpc.params.query??'',kind:rpc.params.kind,total:2,results:[{id:'minecraft:copper_door',block:true,item:true}],next_cursor:'next-page'} + :rpc.method==='material_describe' + ?{catalog_version:'v1',id:'minecraft:copper_door',block:true,item:true,placeable:true,default_state:'minecraft:copper_door[facing=north,half=lower,hinge=left,open=false,powered=false]',properties:{facing:['north','south','east','west'],half:['lower','upper'],hinge:['left','right'],open:['false','true'],powered:['false','true']}} + :{status:'prepared'}; + res.setHeader('content-type','application/json');res.end(JSON.stringify({ok:true,result})); + });backend.listen(0,'127.0.0.1');await once(backend,'listening'); + const transport=new StdioClientTransport({command:process.execPath,args:[resolve('dist/mcp.js')],env:{MCB_BACKEND_URL:`http://127.0.0.1:${backend.address().port}`,MCB_AGENT_TOKEN:'agent',MCB_PROJECT_ID:'project',MCB_PLAYER_ID:'player'},stderr:'pipe'}); + const client=new Client({name:'materials-test',version:'1'}); + t.after(async()=>{await client.close();backend.closeAllConnections();backend.close();}); + await client.connect(transport); + const list=await client.listTools(); + const discovery=list.tools.filter(tool=>tool.name.startsWith('material_')); + assert.deepEqual(discovery.map(tool=>tool.name),['material_search','material_describe']); + for(const tool of discovery){assert.equal(tool.annotations.readOnlyHint,true);assert.equal(tool.annotations.idempotentHint,true);assert.equal(tool.annotations.destructiveHint,false);} + assert.ok(client.getInstructions().includes('project_context contains only a catalog summary')); + assert.ok(client.getInstructions().includes('do not enumerate the registry')); + assert.ok(!JSON.stringify(discovery).includes('minecraft:copper_door'),'registry entries must be discovered, not embedded in the MCP tool catalog'); + const found=await client.callTool({name:'material_search',arguments:{query:'copper door'}}); + assert.equal(seen.at(-1).method,'material_search');assert.equal(seen.at(-1).params.limit,16);assert.equal(seen.at(-1).params.kind,'block'); + assert.equal(JSON.parse(found.content[0].text).results[0].id,'minecraft:copper_door'); + await client.callTool({name:'material_search',arguments:{query:'copper door',kind:'all',limit:32,cursor:'next-page'}}); + assert.equal(seen.at(-1).params.cursor,'next-page');assert.equal(seen.at(-1).params.kind,'all'); + const described=JSON.parse((await client.callTool({name:'material_describe',arguments:{id:'minecraft:copper_door'}})).content[0].text); + assert.equal(seen.at(-1).method,'material_describe');assert.deepEqual(described.properties.half,['lower','upper']); + const beforeInvalid=seen.length; + for(const arguments_ of [{query:'x'.repeat(97)},{kind:'entity'},{limit:0},{limit:33},{limit:1.5},{cursor:''},{cursor:'x'.repeat(101)}]){ + assert.equal((await client.callTool({name:'material_search',arguments:arguments_})).isError,true); + } + for(const id of ['copper_door','minecraft:stone[foo=bar]','minecraft:chest{Items:[]}','other:stone','minecraft:../stone',`minecraft:${'x'.repeat(119)}`]){ + assert.equal((await client.callTool({name:'material_describe',arguments:{id}})).isError,true); + } + assert.equal(seen.length,beforeInvalid,'invalid discovery arguments must be rejected before backend transport'); + const box=block=>({version:1,operations:[{type:'box',min:{x:0,y:64,z:0},max:{x:0,y:64,z:0},block}]}); + for(const block of ['minecraft:copper_door[facing=west,half=lower,hinge=left,open=false,powered=false]','minecraft:water[level=0]']){ + assert.ok(!(await client.callTool({name:'build_prepare',arguments:{recipe:box(block)}})).isError,'ordinary recipes must not retain the old material allowlist'); + } + const snapshot={pos:{x:0,y:64,z:0},state:'minecraft:chest[facing=north,type=single,waterlogged=false]',snapshot_id:'a'.repeat(64)}; + assert.ok(!(await client.callTool({name:'build_prepare',arguments:{recipe:box('minecraft:chest[facing=west]'),expected_blocks:[snapshot]}})).isError); + assert.equal(seen.at(-1).params.expected_blocks[0].snapshot_id,snapshot.snapshot_id,'block-entity snapshot digest must survive MCP transport unchanged'); + const beforeBadSnapshot=seen.length; + for(const snapshot_id of ['a'.repeat(63),'A'.repeat(64),'not-a-snapshot']){ + assert.equal((await client.callTool({name:'build_prepare',arguments:{recipe:box('minecraft:stone'),expected_blocks:[{...snapshot,snapshot_id}]}})).isError,true); + } + assert.equal(seen.length,beforeBadSnapshot); + const beforeUnsafe=seen.length; + for(const block of ['minecraft:command_block{Command:"say hello"}',`minecraft:stone[p=${'x'.repeat(1024)}]`]){ + assert.equal((await client.callTool({name:'build_prepare',arguments:{recipe:box(block)}})).isError,true); + } + assert.equal(seen.length,beforeUnsafe,'state length and no-NBT boundaries must hold before backend transport'); }); test('camera image is a native MCP image rather than a text context dump',()=>{ const result=toolResult({status:'completed',captureId:'c1',imageBase64:'YWJj',mimeType:'image/png'}); diff --git a/bridge/test/security.test.mjs b/bridge/test/security.test.mjs index b77da55..e6eb7b8 100644 --- a/bridge/test/security.test.mjs +++ b/bridge/test/security.test.mjs @@ -1,9 +1,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import {mkdtemp,readFile,writeFile,rm} from 'node:fs/promises'; +import {mkdtemp,readFile,writeFile,readdir,rm,stat} from 'node:fs/promises'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; -import {codexPaths,prepareCodexHome,CODEX_CONFIG_TOML,securityReport} from '../dist/security.js'; +import {codexPaths,prepareCodexHome,CODEX_CONFIG,CODEX_CONFIG_TOML,securityReport} from '../dist/security.js'; +const oldManagedConfig=CODEX_CONFIG_TOML.replace('code_mode_host = true\n','code_mode_host = false\n'); test('dedicated home gets known config and never overwrites existing user configuration',async t=>{ const root=await mkdtemp(join(tmpdir(),'mcb-security-'));t.after(()=>rm(root,{recursive:true,force:true})); const paths=codexPaths(root);await prepareCodexHome(paths); @@ -13,9 +14,62 @@ test('dedicated home gets known config and never overwrites existing user config await assert.rejects(prepareCodexHome(paths),/will not be overwritten/); assert.equal(await readFile(join(paths.codexHome,'config.toml'),'utf8'),'sandbox_mode = "danger-full-access"\n'); }); +test('migrates only the known old managed config atomically, backs it up and preserves authentication',async t=>{ + const root=await mkdtemp(join(tmpdir(),'mcb-security-'));t.after(()=>rm(root,{recursive:true,force:true})); + const paths=codexPaths(root);await prepareCodexHome(paths); + const configPath=join(paths.codexHome,'config.toml'); + const authPath=join(paths.codexHome,'auth.json'); + const syntheticAuth='{"test_fixture":"preserve-me-byte-for-byte"}\n'; + await writeFile(configPath,oldManagedConfig);await writeFile(authPath,syntheticAuth,{mode:0o600}); + await prepareCodexHome(paths); + assert.equal(await readFile(configPath,'utf8'),CODEX_CONFIG_TOML); + assert.equal(await readFile(`${configPath}.before-code-mode-host`,'utf8'),oldManagedConfig); + assert.equal(await readFile(authPath,'utf8'),syntheticAuth); + if(process.platform!=='win32'){ + assert.equal((await stat(configPath)).mode&0o777,0o600); + assert.equal((await stat(`${configPath}.before-code-mode-host`)).mode&0o777,0o600); + } + await prepareCodexHome(paths); + assert.equal(await readFile(configPath,'utf8'),CODEX_CONFIG_TOML); + assert.equal(await readFile(`${configPath}.before-code-mode-host`,'utf8'),oldManagedConfig); + assert.equal(await readFile(authPath,'utf8'),syntheticAuth); + assert.equal((await readdir(paths.codexHome)).filter(name=>name.endsWith('.tmp')).length,0); +}); +test('refuses even small custom changes to old managed configuration without creating a backup',async t=>{ + const root=await mkdtemp(join(tmpdir(),'mcb-security-'));t.after(()=>rm(root,{recursive:true,force:true})); + const paths=codexPaths(root);await prepareCodexHome(paths); + const configPath=join(paths.codexHome,'config.toml'); + for(const custom of [oldManagedConfig+'# my settings\n',oldManagedConfig.replace('shell_tool = false','shell_tool = true')]){ + await writeFile(configPath,custom); + await assert.rejects(prepareCodexHome(paths),/will not be overwritten/); + assert.equal(await readFile(configPath,'utf8'),custom); + await assert.rejects(readFile(`${configPath}.before-code-mode-host`),{code:'ENOENT'}); + } +}); +test('resumes with an exact previous backup but refuses to overwrite a different backup',async t=>{ + const root=await mkdtemp(join(tmpdir(),'mcb-security-'));t.after(()=>rm(root,{recursive:true,force:true})); + const paths=codexPaths(root);await prepareCodexHome(paths); + const configPath=join(paths.codexHome,'config.toml');const backupPath=`${configPath}.before-code-mode-host`; + await writeFile(configPath,oldManagedConfig);await writeFile(backupPath,'different backup\n'); + await assert.rejects(prepareCodexHome(paths),/backup.*will not be overwritten/); + assert.equal(await readFile(configPath,'utf8'),oldManagedConfig); + assert.equal(await readFile(backupPath,'utf8'),'different backup\n'); + await writeFile(backupPath,oldManagedConfig);await prepareCodexHome(paths); + assert.equal(await readFile(configPath,'utf8'),CODEX_CONFIG_TOML); + assert.equal(await readFile(backupPath,'utf8'),oldManagedConfig); +}); +test('code-mode host is enabled without enabling the separately restricted integrations',()=>{ + assert.equal(CODEX_CONFIG.features.code_mode_host,true); + for(const [feature,enabled] of Object.entries(CODEX_CONFIG.features)){ + if(feature!=='code_mode_host')assert.equal(enabled,false,feature); + } + assert.equal(CODEX_CONFIG.sandbox_mode,'read-only');assert.equal(CODEX_CONFIG.web_search,'disabled'); +}); test('doctor security report describes upstream workspace-write limitation honestly',()=>{ const report=securityReport(codexPaths('/state')); assert.equal(report.configuredSandbox,'read-only');assert.equal(report.adapterTurnSandbox,'workspace-write');assert.equal(report.runtimeVerified,false); assert.equal(report.pinnedCliFeatureProbe.unified_exec,true); + assert.equal(report.codeModeHostRequested,true);assert.equal(report.codeModeHostRequiredForModelToolMode,'code_mode_only'); + assert.ok(report.limitations.some(text=>text.includes('Doctor does not invoke a model'))); assert.ok(report.limitations.some(text=>text.includes('temporary paths'))); }); diff --git a/camera-mod/README.md b/camera-mod/README.md index a168ff5..27ba490 100644 --- a/camera-mod/README.md +++ b/camera-mod/README.md @@ -98,3 +98,11 @@ A real graphical test ran on September 12, 2026: one Prism client, the project o The first request ended with `view_changed`: actual rotation differed from the requested rotation. A retry after stabilization produced a **1280×720 PNG in 2.052 seconds**, with yaw **140°**, pitch **31°**, after **20 ticks** and **3 frames** of readiness. This is a verified local measurement of one request, not a timing guarantee for other scenes or computers. Verification artifacts: `.runtime/camera-test/20260912T192813Z-2b100930.png` and its JSON metadata; they remain local and are excluded from Git. The successful image was captured after the building operation completed and includes its `afterOperationId`, but **`serverRevisionVerified` remains `false`**: processing of a specific server revision is not yet acknowledged. Remaining checks include restoration of all view settings, timeout with a minimized window, disconnection during capture, third-party shaders, and a separate camera account. The working graphical cycle is verified for the single-client scenario described above. + +## Opt-in local auto-connect + +Set `camera-auto-connect: '127.0.0.1:25575'` in the private Paper config used by `scripts/camera-wrapper.py`; the wrapper passes `MCB_CAMERA_AUTO_CONNECT` to Minecraft. Restart the client once after installing this mod update. Only a literal loopback address and an unprivileged port are accepted. The mod retries from the title, multiplayer or disconnected screen at most once every ten seconds, never during an active connection or another menu. Joining a different server or a single-player world suspends auto-connect for that client process. + +Create `config/minecraft-builder-camera.autojoin-disabled` inside the Minecraft instance to pause retries without restarting; remove it to resume. The health endpoint reports enabled/paused state, target and attempt count. Remove the opt-in setting and restart the client to disable permanently. Opt-in deliberately includes reconnecting after a manual disconnect from the configured server. + +Auto-connect uses Minecraft's normal connection flow. It does not authenticate new accounts, launch Prism, execute chat commands, change game mode, move the player, or grant editing rights. A trusted local operator can separately use the Paper console for `lobby ` and `gamemode spectator `. Graphical rendering is still required for photographs; menus and user input can invalidate a capture. diff --git a/camera-mod/src/client/java/dev/minecraftbuilder/camera/AutoConnectPolicy.java b/camera-mod/src/client/java/dev/minecraftbuilder/camera/AutoConnectPolicy.java new file mode 100644 index 0000000..3698547 --- /dev/null +++ b/camera-mod/src/client/java/dev/minecraftbuilder/camera/AutoConnectPolicy.java @@ -0,0 +1,21 @@ +package dev.minecraftbuilder.camera; + +/** Opt-in loopback connection policy. No game or network dependencies. */ +public final class AutoConnectPolicy { + private final String target; + private long nextAttempt; + private int attempts; + public AutoConnectPolicy(String target, long now) { + if (!target.matches("127\\.0\\.0\\.1:[0-9]{1,5}")) throw new IllegalArgumentException("Auto-connect requires a literal loopback address and port"); + int port = Integer.parseInt(target.substring(target.indexOf(':')+1)); + if (port < 1024 || port > 65535) throw new IllegalArgumentException("Invalid local server port"); + this.target = target; nextAttempt = now + 10_000; + } + public String target() { return target; } + public int attempts() { return attempts; } + public boolean due(long now, boolean connected, boolean eligibleScreen, boolean paused) { + if (connected) { nextAttempt = now + 10_000; return false; } + if (paused || !eligibleScreen || now < nextAttempt) return false; + nextAttempt = now + 10_000; attempts++; return true; + } +} diff --git a/camera-mod/src/client/java/dev/minecraftbuilder/camera/CameraClient.java b/camera-mod/src/client/java/dev/minecraftbuilder/camera/CameraClient.java index 326f72b..38e645c 100644 --- a/camera-mod/src/client/java/dev/minecraftbuilder/camera/CameraClient.java +++ b/camera-mod/src/client/java/dev/minecraftbuilder/camera/CameraClient.java @@ -10,6 +10,12 @@ import net.minecraft.client.Minecraft; import net.minecraft.client.Screenshot; import net.minecraft.client.multiplayer.ClientLevel; import net.minecraft.client.player.LocalPlayer; +import net.minecraft.client.gui.screens.ConnectScreen; +import net.minecraft.client.gui.screens.DisconnectedScreen; +import net.minecraft.client.gui.screens.TitleScreen; +import net.minecraft.client.gui.screens.multiplayer.JoinMultiplayerScreen; +import net.minecraft.client.multiplayer.ServerData; +import net.minecraft.client.multiplayer.resolver.ServerAddress; import net.minecraft.world.level.chunk.status.ChunkStatus; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -42,6 +48,10 @@ public final class CameraClient implements ClientModInitializer, CameraHttpServe .daemon(true).name("mcb-camera-watchdog").factory()); private volatile JsonObject cachedHealth = CameraHttpServer.error("starting", "Waiting for client tick"); private CameraHttpServer http; + private AutoConnectPolicy autoConnect; + private boolean autoConnectSuspended; + private boolean autoConnectPaused; + private long nextPauseCheck; @Override public void onInitializeClient() { String token = System.getenv("MCB_CAMERA_TOKEN"); @@ -55,6 +65,8 @@ public final class CameraClient implements ClientModInitializer, CameraHttpServe int port = Integer.parseInt(System.getenv().getOrDefault("MCB_CAMERA_PORT", "8766")); if (port < 1024 || port > 65535) throw new IllegalArgumentException("Invalid camera port"); http = new CameraHttpServer(port, token, this); + String target = System.getenv("MCB_CAMERA_AUTO_CONNECT"); + if (target != null && !target.isBlank()) autoConnect = new AutoConnectPolicy(target, System.currentTimeMillis()); instance = this; ClientTickEvents.END_CLIENT_TICK.register(this::tick); ClientLifecycleEvents.CLIENT_STOPPING.register(this::stop); @@ -93,12 +105,19 @@ public final class CameraClient implements ClientModInitializer, CameraHttpServe } private void tick(Minecraft client) { + autoConnect(client); Job job = active.get(); JsonObject health = new JsonObject(); health.addProperty("status", "ok"); health.addProperty("connected", client.level != null && client.player != null); health.addProperty("spectator", client.player != null && client.player.isSpectator()); health.addProperty("busy", job != null); + health.addProperty("autoConnectEnabled", autoConnect != null); + if (autoConnect != null) { + health.addProperty("autoConnectTarget", autoConnect.target()); + health.addProperty("autoConnectAttempts", autoConnect.attempts()); + health.addProperty("autoConnectPaused", autoConnectPaused || autoConnectSuspended); + } health.addProperty("updatedAt", Instant.now().toString()); if (client.level != null) health.addProperty("dimension", dimension(client)); if (client.player != null) health.addProperty("playerId", client.player.getUUID().toString()); @@ -138,6 +157,26 @@ public final class CameraClient implements ClientModInitializer, CameraHttpServe if (chunksLoaded(client)) job.stableTicks++; else { job.stableTicks = 0; job.readyFrames = 0; } } + private void autoConnect(Minecraft client) { + if (autoConnect == null) return; + long now = System.currentTimeMillis(); + if (now >= nextPauseCheck) { + autoConnectPaused = java.nio.file.Files.exists(client.gameDirectory.toPath().resolve("config/minecraft-builder-camera.autojoin-disabled")); + nextPauseCheck = now + 1000; + } + if (client.level != null) { + ServerData server = client.getCurrentServer(); + if (server == null || !server.ip.equals(autoConnect.target())) autoConnectSuspended = true; + } + var screen = client.gui.screen(); + boolean eligible = screen instanceof TitleScreen || screen instanceof DisconnectedScreen || screen instanceof JoinMultiplayerScreen; + if (!autoConnect.due(now, client.level != null || client.getConnection() != null, + eligible, autoConnectPaused || autoConnectSuspended || client.gui.overlay() != null)) return; + LOGGER.info("Observer connecting to configured local server (attempt {})", autoConnect.attempts()); + ConnectScreen.startConnecting(new TitleScreen(), client, ServerAddress.parseString(autoConnect.target()), + new ServerData("Minecraft Builder local camera", autoConnect.target(), ServerData.Type.OTHER), false, null); + } + /** Called after GameRenderer.render, on Minecraft's render thread. Uses the supported GPU screenshot API. */ public static void afterRender(boolean renderWorld) { CameraClient worker = instance; diff --git a/camera-mod/src/test/java/dev/minecraftbuilder/camera/AutoConnectPolicyTest.java b/camera-mod/src/test/java/dev/minecraftbuilder/camera/AutoConnectPolicyTest.java new file mode 100644 index 0000000..5c50431 --- /dev/null +++ b/camera-mod/src/test/java/dev/minecraftbuilder/camera/AutoConnectPolicyTest.java @@ -0,0 +1,25 @@ +package dev.minecraftbuilder.camera; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class AutoConnectPolicyTest { + @Test void onlyExplicitUnprivilegedLoopbackTargetsAreAccepted() { + assertEquals("127.0.0.1:25575", new AutoConnectPolicy("127.0.0.1:25575",0).target()); + for(String target:new String[]{"example.com:25565","localhost:25575","127.0.0.1:80","127.0.0.1:99999","127.0.0.1:25575/","127.0.0.1:25575\n"}) + assertThrows(IllegalArgumentException.class,()->new AutoConnectPolicy(target,0)); + } + @Test void retriesAreThrottledAndNeverInterruptAConnectionOrOtherScreen() { + var policy=new AutoConnectPolicy("127.0.0.1:25575",0); + assertFalse(policy.due(9999,false,true,false)); + assertTrue(policy.due(10000,false,true,false)); + assertFalse(policy.due(10001,false,true,false)); + assertFalse(policy.due(20000,false,false,false)); + assertFalse(policy.due(20000,false,true,true)); + assertTrue(policy.due(20000,false,true,false)); + assertFalse(policy.due(40000,true,true,false)); + assertFalse(policy.due(40001,false,true,false)); + assertTrue(policy.due(50000,false,true,false)); + assertEquals(3,policy.attempts()); + } +} diff --git a/docs/DECORATION-PLAYBOOK.md b/docs/DECORATION-PLAYBOOK.md new file mode 100644 index 0000000..50b487b --- /dev/null +++ b/docs/DECORATION-PLAYBOOK.md @@ -0,0 +1,115 @@ +# Minecraft decoration playbook + +Research reviewed **13 September 2026** for the Shacraft lobby. These notes combine primary Minecraft articles, interviews with builders and official Java release notes with our own measured-building workflow. They record reusable design knowledge; this research stage makes no changes to the world. + +Start with this page, then open only the relevant entry in the [14-recipe cookbook](DECORATION-RECIPES.md). The [structured catalog](recipes/decorations.json) is the authoritative source for recipe dimensions, materials, assembly and acceptance checks. Recipes are original design proposals, not copied tutorial schematics, executable `build_prepare` payloads or claims of completed construction. + +## Short working memory + +1. Give the place a purpose and identify the main arrival, decision point and place to pause. +2. Resolve silhouette, structural rhythm and major material fields before surface details. +3. Assign each material a role. Keep a dominant family, supporting material and selective accent. +4. Make depth with real layers: a recessed window, a projecting sill, a supported eave. Keep the closure behind decorative partial blocks intact. +5. Cluster texture changes where wear, moisture, construction or planting explains them. Preserve calm surfaces between accents. +6. Arrange furniture as useful groups. Keep entrances, lift controls and destination signs visible. +7. Measure the actual ground, floor and route before placing a standard module. Preserve established widths and headroom. +8. Check material availability, exact states, rotation, attachment, substrate and stability after updates. +9. Build one sample, inspect it at player height and from the approach, then repeat with controlled variation. +10. Compare real day/night photographs and inspect concealed sides. A map, plan or successful placement receipt is not a lighting review. +11. Keep unfinished rooms closed. Decoration must not create a passage or a way around an existing enclosure. +12. Save recipe IDs, anchors, rotations, seeds, operation receipts and selected photographs; load only the local context needed for the next edit. + +These are project working rules. Numeric dimensions below are starting proposals for this lobby, not universal Minecraft laws. + +## What the research changes in our practice + +### Shape and depth before micro-detail + +Aegos and Shannooty describe working out roofs and the overall silhouette before detailing. Milosz explains using a consistent palette and comparatively simple textures at large scale. For Shacraft, the clock tower should remain the first readable feature; a dormer, finial or cornice is useful only if it strengthens that composition. Review a proposed feature from the main approach before copying it around the roof. [Tranquil Towers](https://www.minecraft.net/en-us/article/tranquil-towers), [The Sky is No Limit](https://www.minecraft.net/en-us/article/sky-no-limit). + +The glass-pane article explicitly connects windows with variation in wall depth. Our adaptation uses a recessed **full-glass closure** and a projecting stone surround, since public containment is part of this project. Thin panes can be useful elsewhere, but their appearance does not prove a sealed boundary. Quartz also provides a consistent family for architecture and small furnishings. [Taking Inventory: Glass Pane](https://www.minecraft.net/en-us/article/taking-inventory--glass-pane), [Build With It: Quartz](https://www.minecraft.net/en-us/article/build-with-it--quartz). + +### Material variation should explain the surface + +Rough diorite and its polished form demonstrate that texture intensity changes the character of a surface; a brighter material is not automatically a quieter one. Mossy cobblestone can suggest age. Our design inference is to place texture by cause: moisture and earth contact at a retaining wall, wear near an approach, clean edges around a maintained station entrance. Use connected patches rather than an independent random material choice for every block. [Build with It: Diorite!](https://www.minecraft.net/en-us/article/build-it--diorite-), [Build With It: Cobblestone!](https://www.minecraft.net/en-us/article/build-with-it--cobblestone-). + +A color gradient is optional. If used, it should reinforce a chosen mass or light direction and read at the intended distance. Do not impose an arbitrary weathering percentage or a fixed color-ratio formula on every building. We should compare a small clean sample and a restrained textured sample before committing a facade. + +### Interior groups should tell players how to use the room + +The builders in *Interior Motives* emphasize purpose and agreement between inside and outside. Jonathan “SnugSites” discusses livable furniture arrangements and the value of two-block partitions when rooms need different finishes on each face. We should build waiting groups, information points and viewing pockets, reserving a clear route between them; an empty section of floor can be necessary circulation. [Interior Motives](https://www.minecraft.net/en-us/article/interior-motives), [Fantastic Furniture](https://www.minecraft.net/en-us/article/fantastic-furniture). + +Spruce, plants and lanterns are useful companions to copper and masonry in *Cozy Chambers*. For our station this means warmth at the scale of benches, counters and exhibits, with a limited number of focal groups. A Minecraft article also documents a flowerpot above a decorated pot as a large planter. The original cookbook used a small stone planter; the runtime catalog now makes registered pot block types discoverable, while custom pot decoration and stored-item data remain outside the ordinary state editor. [Cozy Chambers](https://www.minecraft.net/en-us/article/cozy-chambers), [Decorated Pot](https://www.minecraft.net/en-us/article/decorated-pot). + +### Decorate the transition between architecture and landscape + +The official presentation of BlueNerd's landscaping work connects paths, banks, soil and vegetation. Zaypixel's path examples show how material families and nearby objects communicate different settings. Our application is a clean, formal station axis that gradually gives way to local rock, soil and planted pockets farther out. Curve a route when its destination or terrain calls for it. Do not force a kink every seven blocks: that number belongs to one organic-building tutorial, not the engine or every architectural style. [Landscaping and Terraforming](https://www.minecraft.net/en-us/article/tutorial--tips-for-landscaping-and-terraforming), [Five simple path designs](https://www.minecraft.net/en-us/article/five-simple-path-designs). + +Kelpie the Fox's outdoor groups provide useful roles for benches, small structures and hanging lights. The article identifies **Mizuno's 16 Craft** in its reference imagery. Borrow composition and purpose, then judge our result in the actual client textures; matching block names alone cannot reproduce a resource-pack image. [Cottagecore Decor](https://www.minecraft.net/en-us/article/tutorial--cottagecore-decor). + +### Lighting requires its own review + +Regular lanterns are a useful recurring fixture, but a fixture count does not establish room legibility. Look at the spaces between lights, stair landings, sign faces and exhibit fronts. We already used real station photographs to find dark interiors and refine concealed lighting; see the [completed station record](SHACRAFT-STATION.md). [Taking Inventory: Lantern](https://www.minecraft.net/en-us/article/taking-inventory--lantern). + +**Tinted glass is not stained glass:** the Java release notes describe tinted glass as blocking light. Do not use it as a substitute for a transparent lighting lens. The same release notes document waxed copper as preventing oxidation. These facts explain material choices; exact accepted states still come from our current server. [Java Caves & Cliffs Part I](https://www.minecraft.net/en-us/article/caves---cliffs--part-i-out-today-java). + +## Shacraft palette and composition + +The following assignments are our design direction based on the existing arrival garden and station, rather than claims made by the sources: + +- **Large cream masses:** `smooth_sandstone`, with `cut_sandstone` for purposeful masonry bands. +- **Clean prominent edges:** `smooth_quartz` and `quartz_pillar`; use supported sandstone stairs/slabs when a partial shape is needed. +- **Roofs and identity:** the `waxed_oxidized_cut_copper` family. Keep broad roof planes visible. +- **Human-scale warmth:** spruce for benches, counters, ceilings and small structural posts. +- **Selective emphasis:** gold at a major identity or control point, with green backing. Repeated small gold accents must not compete with the main sign. +- **Grounded base:** stone bricks, andesite and restrained moss near actual soil or damp recesses. +- **Planting:** a dominant evergreen mass and small pink/white flower groups, continuing zone 01's established vocabulary. +- **SMASH:** preserve the station frame; use a contained island exhibit and restrained warm accent to distinguish this floor. + +Place more detail at an entrance, destination or stopping point. Leave broader quiet surfaces and uninterrupted paving between those locations. This is a distribution choice, not a ban on ornament. + +## Recipe index + +Each cookbook entry includes dimensions, palette, assembly order, clearance, controlled variants and failure checks: + +- Facade: `arched-window-bay`, `cornice-bracket`. +- Roof: `closed-dormer`. +- Interior: `waiting-pocket`, `information-counter`, `smash-exhibit`. +- Routes: `avenue-edge`, `retaining-stair`. +- Lighting: `copper-lantern`, `concealed-light-lens`. +- Planting and landscape: `layered-planter`, `shore-pocket`. +- Small props and surfaces: `luggage-bench`, `masonry-weathering`. + +## Current capability boundary + +The saved Stage 07 `project_context` lists **100 material IDs**. All base recipe material lists were checked against that historical snapshot. The structured recipes retain that dated capability basis so earlier designs remain reproducible; it is no longer the building allowlist. The runtime implementation in [`BuildingWorld.java`](../paper-plugin/src/main/java/io/github/minecraftbuilder/paper/BuildingWorld.java) now accepts all registered vanilla block types and their valid states. Read the live catalog summary, then discover only the materials needed for the next detail through `material_search` and `material_describe`. [Material workflow](MATERIALS.md). + +The original recipe catalog uses flush colored paving instead of carpet, masonry planters instead of pots, full stained glass instead of panes, and wooden block props instead of item entities. These remain deliberate design variants. Registered carpets, trapdoors, flowerpots, decorated pots, banners, candles, wildflowers, leaf litter and firefly bushes can now be selected through the runtime catalog. Block-type availability does not add arbitrary banner patterns, pot decorations, sign text, inventories or text-display creation; the station's existing labels use a separate project-specific plugin. Item-only materials are searchable but cannot be placed by a block recipe. + +Java 1.21.5 introduced useful plant density/orientation options; its firefly bush emits only light level 2, so it should be treated as an atmospheric accent rather than primary street illumination. Java 26.2 also adds sulfur and cinnabar material families. Material IDs and exact properties come from the running server's registry, so search before using a version-specific feature. Additional behavior is not a new callable MCP API. [Java 1.21.5 release notes](https://feedback.minecraft.net/hc/en-us/articles/35298208390797-Minecraft-Java-Edition-1-21-5-Spring-to-Life), [Java 26.2 release notes](https://www.minecraft.net/en-us/article/minecraft-java-edition-26-2). + +For this runtime, discover the chain ID rather than assuming an older name; the saved station uses `minecraft:iron_chain`. Use `persistent=true` for decorative leaves unless deliberate natural decay is intended, and check distance state on read-back. Flowers need accepted substrates, logs need real support, and stairs/slabs/lanterns need the correct directional and attachment states. Place every half or part of doors, beds and tall plants explicitly. Ordinary building plans can place fluids and waterlogged states, but terrain brushes still reject water in their scan. The existing shore recipe remains a land-side design that preserves water. Fluid flow, random ticks and other later game behavior are not fully reversed by undoing only a plan's direct writes. + +## Applying a recipe economically + +The Markdown cookbook is for human review; the JSON makes one entry easy to retrieve without reading the entire document. For a local repository agent: + +```bash +jq '.recipes[] | {id, category, size}' docs/recipes/decorations.json +jq '.recipes[] | select(.id == "copper-lantern")' docs/recipes/decorations.json +``` + +Save a placement record with recipe ID, recipe-catalog version and runtime material-catalog version, local origin, rotation, chosen variant, terrain/floor anchor, palette substitutions, variation seed and operation IDs. Use a focused `material_search` page (16 results by default, 32 maximum), then describe only 1–3 selected materials. Reuse their defaults and property domains while the runtime catalog version is unchanged. Inspect the target area and its support/clearance neighborhood, then compile a bounded expected-state patch. Copy each block-entity `snapshot_id` from `region_inspect` into `expected_blocks` to detect data edits without reading inventories or NBT into context. Keep the checked operation receipts and a small set of matching photographs. Large-scale repetition comes after a successful sample. + +The shared [MCP/ACP guidance](../bridge/src/building-guidance.ts) includes short material-discovery and decoration summaries. Neither the full recipe catalog nor the material registry is injected into every model turn. The recipe JSON is not a new MCP resource or tool: the in-game ACP agent has no repository filesystem access, so its instructions must not pretend it can retrieve this file. New bridge processes receive the updated summary, and resumed ACP sessions receive changed guidance once through its stored fingerprint. Deployment and validation of the registry expansion are recorded in [MATERIALS.md](MATERIALS.md#validation). + +## Acceptance before calling a decoration complete + +1. **Fit:** same survey anchors, preserved structure, route width and four-block public headroom where that is the existing Shacraft contract. Four blocks is our design margin, not Minecraft's minimum player height. +2. **Stability:** supports, substrates, leaves, attached pieces and rotated states remain valid after block updates. +3. **Movement:** inspect approaches, corners, stairs and ceilings. A decorative fence or hedge does not replace the checked enclosure. Keep unfinished interiors inaccessible. +4. **Visuals:** matched overview, approach and player-height views; day/night where lighting matters. Check sign visibility, density, material contrast and dark exhibit faces in the actual client. +5. **Evidence:** distinguish proposed design, server-applied blocks, measured geometry and visually inspected results. Record an unavailable camera honestly. +6. **Preservation:** keep the before state, checked edits and useful photographs. A later manual edit is a conflict to resolve, not an invitation to overwrite it. + +The 14 catalog entries have been reviewed as design specifications and checked for material-name availability. They have **not** been instantiated or visually approved as a new set of in-world samples. diff --git a/docs/DECORATION-RECIPES.md b/docs/DECORATION-RECIPES.md new file mode 100644 index 0000000..4de0dcc --- /dev/null +++ b/docs/DECORATION-RECIPES.md @@ -0,0 +1,512 @@ +# Shacraft decoration recipes + +Generated from [decorations.json](recipes/decorations.json), the authoritative catalog. Read the [playbook](DECORATION-PLAYBOOK.md) for principles, current capabilities and the acceptance workflow. + +**14 original design proposals, reviewed 13 September 2026.** Material IDs match the saved 100-material Stage 07 palette. These are design references, not executable Minecraft recipes or completed in-world samples. Recheck live capabilities before applying. + +Dimensions below are **width × height × depth** in blocks. They exclude separately specified circulation and buried support. Local +X is right, +Y is up, +Z points into the object; the front faces -Z. Measurements and spacing are project proposals, not engine limits. + +## arched-window-bay + +**Recessed arched window bay** · facade · **7 × 10 × 3 blocks** + +Give a large cream facade readable shadow and a consistent structural rhythm. + +Materials: `smooth_sandstone`, `cut_sandstone`, `smooth_sandstone_stairs`, `smooth_sandstone_slab`, `smooth_quartz`, `brown_stained_glass`. + +Assembly: + +1. Reserve a seven-wide bay between the actual structural axes; keep adjacent columns and floor decks intact. +2. At the glazing plane, use five-wide glass rows for the straight portion, then three and one at the crown. Fill every surrounding cell with full masonry so the plane stays sealed. +3. Place glazing one block behind the front wall plane; use the remaining front depth for a sill and paired jamb brackets. +4. Use sandstone stairs only as outer trim. Maintain full masonry or full glass behind partial-block trim. +5. Repeat the approved bay on the established grid; redesign corners and the main doorway deliberately. + +**Clearance:** Keep projections outside the existing public route and its four-block headroom; do not narrow the seven-block station entry. + +Variants: + +- Plain rectangular head on a secondary facade. +- One gold accent above the principal entrance only. + +Acceptance: + +- Closure plane has no unfilled cells, including arch shoulders. +- Recess reads from a diagonal player-height camera. +- No sill hides a sign or clips furniture. +- Stair rotation and corner shape checked after neighboring blocks update. + +Avoid: + +- Different window silhouettes in every bay. +- Assuming a pane or stair is a full containment wall. + +Conceptual sources: [The Sky is No Limit](https://www.minecraft.net/en-us/article/sky-no-limit), [Taking Inventory: Glass Pane](https://www.minecraft.net/en-us/article/taking-inventory--glass-pane), [Build With It: Quartz](https://www.minecraft.net/en-us/article/build-with-it--quartz). Recipe geometry and sequence are our own proposal. + +## cornice-bracket + +**Cornice and paired bracket** · facade · **9 × 3 × 2 blocks** + +Explain the floor/eave line without filling the wall with ornament. + +Materials: `smooth_sandstone`, `smooth_sandstone_stairs`, `smooth_sandstone_slab`, `smooth_quartz`. + +Assembly: + +1. Place a continuous stone band on the existing floor or eave line. +2. Use an inverted-stair underside and a thin slab cap, with at most two blocks of total outward depth. +3. Put the two bracket groups over structural supports and leave the central field quiet. +4. Continue the band around corners with an explicitly resolved return; prototype the corner before repeating. + +**Clearance:** Remain above route headroom and outside signs' viewing rays; preserve roof drainage shapes and window tops. + +Variants: + +- Single-block projection on small wings. +- A stronger cap at the tower base, using the same materials. + +Acceptance: + +- Horizontal band remains readable from the main approach. +- Brackets align with actual supports. +- Cap does not overwhelm the window opening. + +Avoid: + +- A thick shelf over every horizontal edge. +- Unrelated gold trim on every bracket. + +Conceptual sources: [Tranquil Towers](https://www.minecraft.net/en-us/article/tranquil-towers), [The Sky is No Limit](https://www.minecraft.net/en-us/article/sky-no-limit), [Build With It: Quartz](https://www.minecraft.net/en-us/article/build-with-it--quartz). Recipe geometry and sequence are our own proposal. + +## closed-dormer + +**Closed green-copper dormer** · roof · **7 × 7 × 7 blocks** + +Add a secondary roof feature while keeping the clock tower dominant. + +Materials: `smooth_sandstone`, `brown_stained_glass`, `waxed_oxidized_cut_copper`, `waxed_oxidized_cut_copper_stairs`, `waxed_oxidized_cut_copper_slab`. + +Assembly: + +1. Fit a seven-wide sample to the actual roof pitch; use cream side cheeks and three-wide recessed glass. +2. Build the green pitched cap with a one-block overhang. +3. Keep a solid back and floor closure behind the apparent window; retain the existing public ceiling. +4. Test one dormer from the main approach before any repetition. Consider 14-18 blocks between centers only where the measured structural grid permits it. + +**Clearance:** No opening into an unfinished attic, no overlap with tower faces or main roof ridge. + +Variants: + +- One larger central feature on a secondary wing. +- Omit the dormer if it competes with the clock silhouette. + +Acceptance: + +- Tower remains the primary skyline feature. +- Quiet copper roof planes remain visible between details. +- Full closure survives the added roof cutout. + +Avoid: + +- Treating proposed spacing as a universal rule. +- Adding attic access as part of a cosmetic edit. + +Conceptual sources: [Tranquil Towers](https://www.minecraft.net/en-us/article/tranquil-towers), [The Sky is No Limit](https://www.minecraft.net/en-us/article/sky-no-limit). Recipe geometry and sequence are our own proposal. + +## waiting-pocket + +**Paired waiting benches** · interior · **9 × 4 × 7 blocks** + +Create a believable place to wait beside the main hall. + +Materials: `spruce_stairs`, `spruce_slab`, `spruce_planks`, `smooth_sandstone`, `green_concrete`, `lantern`, `oak_leaves`, `dirt`. + +Assembly: + +1. Place two three-seat spruce benches facing a shared open pocket, with cream end supports. +2. Keep a compact table at one side rather than on the main desire line. +3. Use a flush green paving inset to group the furniture; this substitutes for unsupported carpet. +4. Add one planter and one supported lantern near the back, leaving the front visually open. + +**Clearance:** Reserve an additional three-block-wide clear passing strip and four blocks of route headroom. Existing wider routes retain their width. + +Variants: + +- Single bench and planter in a smaller side recess. +- Mirror the group around the room axis while varying the small prop. + +Acceptance: + +- Lift sign remains visible from the approach. +- Seat fronts and table can be approached without stepping onto furniture. +- Lamp and leaves remain stable after updates. + +Avoid: + +- Rows of isolated decorative chairs. +- Plants in front of navigation signs. + +Conceptual sources: [Interior Motives](https://www.minecraft.net/en-us/article/interior-motives), [Fantastic Furniture](https://www.minecraft.net/en-us/article/fantastic-furniture), [Cozy Chambers](https://www.minecraft.net/en-us/article/cozy-chambers). Recipe geometry and sequence are our own proposal. + +## information-counter + +**Information counter** · interior · **9 × 5 × 4 blocks** + +Provide a clear information point that belongs in a station. + +Materials: `spruce_planks`, `spruce_slab`, `smooth_sandstone`, `smooth_sandstone_slab`, `green_concrete`, `glowstone`, `brown_stained_glass`. + +Assembly: + +1. Frame a seven-block counter between cream end piers; use spruce body and a thin cream slab top. +2. Align one green sign backing and one lit recess behind the counter. +3. Keep the back wall solid. Any future staff door remains a finished wall panel until the room exists. +4. Add actual text only through an existing authorized label mechanism; the block catalog does not create text entities. + +**Clearance:** Reserve three clear queue blocks in front, separated from the entrance-to-lift route and its full width. + +Variants: + +- Short five-wide counter for a side room. +- A small gold emblem only above the principal information point. + +Acceptance: + +- Queue does not cross the main route. +- Counter is recognizable before reading the sign. +- No false usable door into unfinished space. + +Avoid: + +- Several equally bright competing headers. +- Claiming matchmaking or staff interaction exists merely because the counter is built. + +Conceptual sources: [Interior Motives](https://www.minecraft.net/en-us/article/interior-motives), [Build With It: Quartz](https://www.minecraft.net/en-us/article/build-with-it--quartz), [Fantastic Furniture](https://www.minecraft.net/en-us/article/fantastic-furniture). Recipe geometry and sequence are our own proposal. + +## smash-exhibit + +**SMASH exhibit niche** · interior · **7 × 6 × 5 blocks** + +Communicate floating islands and knockback through one readable display. + +Materials: `deepslate`, `smooth_sandstone`, `green_concrete`, `gold_block`, `grass_block`, `dirt`, `glowstone`, `brown_stained_glass`. + +Assembly: + +1. Build a full dark base, full back and light stone frame around a five-wide exhibit. +2. Choose one miniature island or one abstract impact motif as the subject. +3. Put one small gold focal detail near the subject and keep the rest of the frame consistent with adjacent niches. +4. Light the subject from concealed side, base or overhead recesses; preserve full-block base and closure. +5. Use one shared frame for all six future arena places; vary the subject and real destination text only when available. + +**Clearance:** Reserve a three-block viewing strip outside the main circulation aisle; preserve the existing selection aisle. + +Variants: + +- Closed trophy pedestal with a single object. +- Small landscape model for a future named arena. + +Acceptance: + +- Subject is legible from player eye level without entering the display. +- No dark face hides the model's silhouette. +- Placeholders clearly remain unassigned. + +Avoid: + +- Six unrelated palettes. +- A visual void that is also an accidental physical hole. + +Conceptual sources: [Interior Motives](https://www.minecraft.net/en-us/article/interior-motives), [Fantastic Furniture](https://www.minecraft.net/en-us/article/fantastic-furniture), [Cozy Chambers](https://www.minecraft.net/en-us/article/cozy-chambers). Recipe geometry and sequence are our own proposal. + +## avenue-edge + +**Formal avenue edge** · path · **9 × 1 × 12 blocks** + +Connect station stonework to the garden through a continuous legible route. + +Materials: `stone_bricks`, `smooth_stone`, `smooth_sandstone`, `mossy_stone_bricks`. + +Assembly: + +1. Keep seven center columns as continuous clear paving and one flush border column on each side. +2. Survey elevations along the whole segment before decoration; grade transitions as separate geometry. +3. Place furniture in additional two-to-three-block shoulders or wider pockets, not inside the nine-block strip. +4. Keep the station approach clean. Introduce small connected worn patches at outer edges beside planting. +5. Widen at entrances and follow actual destinations rather than adding arbitrary bends. + +**Clearance:** Seven-block clear walking width and four-block headroom; this example must not narrow a wider established route. + +Variants: + +- Use local gray stone farther from the station. +- Curve the outer border while keeping the tread continuous. + +Acceptance: + +- Entire route walkable at all grade changes. +- Flush borders do not introduce a lip. +- No lamp or planter creates a pinch point. + +Avoid: + +- Uniform random scatter of five paving materials. +- Applying an organic-path zigzag rule to a formal station axis. + +Conceptual sources: [Tutorial: Tips For Landscaping and Terraforming](https://www.minecraft.net/en-us/article/tutorial--tips-for-landscaping-and-terraforming), [Five simple path designs](https://www.minecraft.net/en-us/article/five-simple-path-designs), [Build With It: Cobblestone!](https://www.minecraft.net/en-us/article/build-with-it--cobblestone-). Recipe geometry and sequence are our own proposal. + +## copper-lantern + +**Supported copper-arm lantern** · lighting · **3 × 7 × 1 blocks** + +Establish one recognizable outdoor fixture family. + +Materials: `stone_bricks`, `stripped_spruce_log`, `waxed_oxidized_cut_copper`, `iron_chain`, `lantern`. + +Assembly: + +1. With the sample's first above-ground cell at y=0, place a full stone foot at (0,0,0), rooted into surveyed ground below. +2. Raise a post at x=0,z=0 through y=1..5; place a full-block copper arm across x=0..2 at y=6. +3. Place a vertical iron chain at (2,5,0) attached beneath the full arm, then a hanging lantern at (2,4,0). +4. Place support before attached details and inspect resulting block states. +5. Keep the entire post outside the clear path; use a mirrored pair only at a major entry. + +**Clearance:** Four empty cells below the hanging lantern if ground is level with the sample. Recalculate for actual terrain; do not assume the same clearance over a rising stair. + +Variants: + +- Shorter ground-mounted fixture in a planted pocket with no walk-under route. +- One gold marker at a major junction, outside this base recipe. + +Acceptance: + +- Full arm, chain axis and hanging lantern state remain valid after updates. +- Night view shows the next step, intersection and sign between lamps. +- Post cannot be used to bypass an existing containment boundary. + +Avoid: + +- Unsupported lanterns. +- Treating a fixed lamp spacing as guaranteed illumination coverage. + +Conceptual sources: [Tutorial: Cottagecore Decor](https://www.minecraft.net/en-us/article/tutorial--cottagecore-decor), [Taking Inventory: Lantern](https://www.minecraft.net/en-us/article/taking-inventory--lantern), [Caves & Cliffs: Part I out today on Java](https://www.minecraft.net/en-us/article/caves---cliffs--part-i-out-today-java). Recipe geometry and sequence are our own proposal. + +## layered-planter + +**Layered planting pocket** · planting · **7 × 6 × 5 blocks** + +Produce a planted mass with a readable front, middle and background. + +Materials: `dirt`, `grass_block`, `spruce_log`, `spruce_leaves`, `oak_leaves`, `white_tulip`, `pink_tulip`, `oxeye_daisy`, `smooth_sandstone`. + +Assembly: + +1. Shape a low cream rim with solid soil behind it; support the entire pocket down to the existing terrain. +2. Put one small rooted tree toward the rear and two unequal shrub groups below it. +3. Use one dominant foliage species and two restrained flower colors near the front. +4. Leave patches of visible grass between groups; taper density toward the edge. +5. Save a seed and cluster anchors for repeatable variation; change group positions rather than every block independently. + +**Clearance:** Keep the highest mass outside entrance and sign sightlines; no leaves overhang the protected walking clearance. + +Variants: + +- Formal clipped evergreen beside the station. +- Looser shrubs without a tree beside the mountain path. + +Acceptance: + +- Flowers have valid soil. +- Leaves use persistent=true and a stable read-back distance state. +- Trunk has stable soil under it. +- Foliage tint checked in the actual biome. + +Avoid: + +- A flower on every grass block. +- Using leaves as the only containment boundary. + +Conceptual sources: [Cozy Chambers](https://www.minecraft.net/en-us/article/cozy-chambers), [Tutorial: Tips For Landscaping and Terraforming](https://www.minecraft.net/en-us/article/tutorial--tips-for-landscaping-and-terraforming), [Tutorial: Cottagecore Decor](https://www.minecraft.net/en-us/article/tutorial--cottagecore-decor). Recipe geometry and sequence are our own proposal. + +## shore-pocket + +**Quiet shore with a stony recess** · landscape · **12 × 4 × 8 blocks** + +Connect existing water and terrain with a believable local bank. + +Materials: `stone`, `andesite`, `cobblestone`, `mossy_cobblestone`, `dirt`, `grass_block`, `oak_leaves`. + +Assembly: + +1. Sample a short existing shoreline together with its water level and submerged bed. +2. Shape one quiet bank and one small stony recess; maintain continuous ground beneath the visible soil skin. +3. Put exposed rock at the outcrop and moss only in selected damp recesses. +4. Keep vegetation in sheltered land pockets and leave a clear view of the water. +5. This catalog specifies only land-side decor. Preserve water cells and the connected bed; stop if execution requires fluid placement or an unsupported brush through water. + +**Clearance:** Preserve public route width, foundations, water level and any existing waterfront containment. + +Variants: + +- Grass bank with one isolated outcrop. +- Stone recess beside a retaining wall, without changing its footing. + +Acceptance: + +- Water network and visible level preserved. +- No new flow reaches a road or foundation after updates. +- A below-water view shows a continuous bed; top-down appearance alone is insufficient. + +Avoid: + +- Repeating identical coves as tiles. +- Assuming water exists in the MCP material palette. + +Conceptual sources: [Tutorial: Tips For Landscaping and Terraforming](https://www.minecraft.net/en-us/article/tutorial--tips-for-landscaping-and-terraforming), [Build With It: Cobblestone!](https://www.minecraft.net/en-us/article/build-with-it--cobblestone-). Recipe geometry and sequence are our own proposal. + +## retaining-stair + +**Retaining wall and short stair** · path · **9 × 5 × 10 blocks** + +Make a grade change feel supported and intentionally connected. + +Materials: `stone_bricks`, `stone_brick_stairs`, `stone_brick_slab`, `smooth_sandstone`, `smooth_sandstone_slab`, `mossy_stone_bricks`, `dirt`. + +Assembly: + +1. Survey lower and upper approaches first; adapt the proposed envelope to the actual rise. +2. Reserve five clear stair columns and two shoulder columns per side. +3. Use three or four one-block rises followed by a three-block-deep landing; fill every tread and retaining wall down to solid ground. +4. Return the side walls into the upper terrace instead of ending them as disconnected pillars. +5. Keep the cream cap continuous; limit moss to local soil-contact or shaded recesses. + +**Clearance:** Five clear stair blocks and four-block headroom along the full ascending path, including caps and any overhead fixture. + +Variants: + +- A shorter flight for a lower terrace. +- An additional landing and separate second flight when the actual rise requires it. + +Acceptance: + +- Stair facing matches direction of ascent. +- Approach and landing elevations agree with the survey. +- No hollow exposed underside or unsupported cap. +- Shoulders do not create a climb-over route around containment. + +Avoid: + +- Solving a height mismatch with a decorative slab at the doorway. +- Placing a standard stair module without measuring the destination. + +Conceptual sources: [Tutorial: Tips For Landscaping and Terraforming](https://www.minecraft.net/en-us/article/tutorial--tips-for-landscaping-and-terraforming), [Five simple path designs](https://www.minecraft.net/en-us/article/five-simple-path-designs), [Build With It: Cobblestone!](https://www.minecraft.net/en-us/article/build-with-it--cobblestone-). Recipe geometry and sequence are our own proposal. + +## luggage-bench + +**Bench and luggage group** · props · **7 × 3 × 4 blocks** + +Tell a station story with a small, useful-looking prop group. + +Materials: `spruce_stairs`, `spruce_planks`, `stripped_spruce_log`, `smooth_sandstone`, `green_concrete`, `gold_block`. + +Assembly: + +1. Put a three-seat bench against the back of a widened pocket, facing the plaza. +2. Group two wood-toned luggage shapes at one end, leaving seat fronts clear. +3. Reserve a green backing for one real destination or station identity plaque. +4. Use at most one small metallic accent in the group; omit it if it attracts more attention than the navigation. + +**Clearance:** Provide a separate three-block passing strip in front and preserve any wider existing route. + +Variants: + +- Bench alone. +- Bench with one planter instead of luggage. + +Acceptance: + +- Object reads as a resting place from the walking path. +- Luggage does not intrude into the bench approach. +- No label advertises an unfinished accessible destination. + +Avoid: + +- Crates in every empty corner. +- Unsupported decorative item entities or invented text APIs. + +Conceptual sources: [Interior Motives](https://www.minecraft.net/en-us/article/interior-motives), [Fantastic Furniture](https://www.minecraft.net/en-us/article/fantastic-furniture), [Five simple path designs](https://www.minecraft.net/en-us/article/five-simple-path-designs). Recipe geometry and sequence are our own proposal. + +## concealed-light-lens + +**Full-block concealed light lens** · lighting · **3 × 2 × 3 blocks** + +Illuminate a large room without adding a hanging fixture to every bay. + +Materials: `glowstone`, `brown_stained_glass`, `smooth_sandstone`. + +Assembly: + +1. Reserve a three-by-three floor sample outside patterned paving, tree roots, landings and interactive controls. +2. Keep full paving in the eight perimeter columns; in the center put glowstone below a full brown stained-glass block flush with the walking surface. +3. Keep supporting construction below the sample intact and record expected states before replacement. +4. Prototype one lens; increase coverage only after matching night and interior photographs reveal dark areas. +5. A ceiling variant reverses the emitter and visible lens positions within an intact full-block ceiling. + +**Clearance:** Top of the floor lens remains flush. Ceiling version preserves all existing route headroom and sealed upper rooms. + +Variants: + +- Use ordinary full glass if the brown lens makes the sample too dark. +- Concealed side lighting for an exhibit rather than more floor lights. + +Acceptance: + +- Full-block collision remains at the floor/ceiling surface. +- Actual captured room and exhibit faces become legible. +- No new bright fixture competes with the entrance or lift. + +Avoid: + +- Tinted glass: it blocks light and is not interchangeable with stained glass. +- Assuming more fixtures automatically produce a better interior. + +Local precedent: Stage 07 used concealed brown-glass lighting and real camera review; this reusable sample has not itself been instantiated. + +Conceptual sources: [Cozy Chambers](https://www.minecraft.net/en-us/article/cozy-chambers), [Caves & Cliffs: Part I out today on Java](https://www.minecraft.net/en-us/article/caves---cliffs--part-i-out-today-java). Recipe geometry and sequence are our own proposal. + +## masonry-weathering + +**Cause-based masonry weathering** · surface · **12 × 6 × 1 blocks** + +Add age and material variation while retaining large calm wall fields. + +Materials: `stone_bricks`, `cracked_stone_bricks`, `mossy_stone_bricks`, `andesite`, `smooth_sandstone`, `cut_sandstone`. + +Assembly: + +1. Choose either the gray retaining-wall family or the cream facade family; do not mix both randomly. +2. Save a mask for soil contact, sheltered joints and selected worn edges. +3. For gray masonry, use small connected mossy patches in the mask and a few connected cracked areas away from structural focal edges. +4. For cream masonry, keep the field mostly smooth and use cut sandstone to express masonry bands or quiet local variation; do not add green flecks everywhere. +5. Preserve trim, signs and dominant shapes, then inspect at near, approach and whole-building distances. + +**Clearance:** Surface substitution only; preserve structural collision, public closure and existing manual edits. + +Variants: + +- Clean maintained station entrance with minimal weathering. +- More aged retaining wall near shaded soil contact. + +Acceptance: + +- Weathering locations have a visible cause. +- Large forms still read from the approach. +- Exact seed and patch anchors recorded for reproducibility. + +Avoid: + +- Independent random choice at every voxel. +- A universal percentage of damage on every material. +- Replacing load-bearing full cubes with stairs merely for texture. + +Conceptual sources: [The Sky is No Limit](https://www.minecraft.net/en-us/article/sky-no-limit), [Build With It: Cobblestone!](https://www.minecraft.net/en-us/article/build-with-it--cobblestone-), [Build with It: Diorite!](https://www.minecraft.net/en-us/article/build-it--diorite-). Recipe geometry and sequence are our own proposal. diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 9b636e8..74f53ac 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -1,6 +1,6 @@ # Implementation status -The first prototype has been built and tested on a real local Paper 26.2 server, including the graphical camera client in Prism and image delivery through MCP. It does not yet cover all of v0.1 in the [design](DESIGN.md): the first authenticated Codex turn over ACP remains untested. +The first prototype has been built and tested on a real local Paper 26.2 server, including the graphical camera client in Prism and image delivery through MCP. It does not yet cover all of v0.1 in the [design](DESIGN.md): authenticated ACP text replies, MCP reads, and a one-block build/inspect/undo cycle are verified; broader model-driven construction and cancellation tests remain. ## Implemented components @@ -8,13 +8,15 @@ The first prototype has been built and tested on a real local Paper 26.2 server, `paper-plugin` binds the core to the server thread and checks the owner, world, epoch, region, and protected parts. HTTP is available only on loopback, with separate administrator and agent keys. The plugin registers `/ai`, maintains a small chat queue, sends replies only to the initiator, and controls observer teleportation. The journal and metadata live in the plugin directory; game blocks remain vanilla. -`bridge` exposes 14 MCP tools over stdio, accepts Paper chat events, and launches the pinned `codex-acp`. Conversations are separated by player and project, queues are serialized, and stopping propagates to ACP. The session ID and a compact summary are persisted. The agent receives a separate MCP token; the administrator key stays with the Bridge. Large responses are bounded, and images are delivered as MCP image content. +`bridge` exposes 19 MCP tools over stdio, accepts Paper chat events, and launches the pinned `codex-acp`. Conversations are separated by player and project, queues are serialized, and stopping propagates to ACP. The session ID and a compact summary are persisted. The agent receives a separate MCP token; the administrator key stays with the Bridge. Large responses are bounded, and images are delivered as MCP image content. The two material discovery tools return bounded search pages and property domains on demand, without placing the full registry in context. `camera-mod` contains a local HTTP Worker and Minecraft 26.2 framebuffer capture. The camera waits for spectator mode, the requested position and dimension, neighboring chunk availability, and frame stabilization. HUD/FOV settings are restored after capture or failure. Mixin integration, server-side teleportation, and valid PNGs from several viewpoints have been tested in a real Prism client. The owner and observer used the same UUID. ## Verified checks -Current build: **100 automated tests passing** — 52 in the core, 17 in the Paper module, 20 in the Bridge, and 11 in the camera module. Both JARs have been built; Java results are in Maven/Gradle XML reports, and the combined log for this run is `.runtime/build-final.log`. +The original full build passed **100 automated tests** — 52 in the core, 17 in the Paper module, 20 in the Bridge, and 11 in the camera module. A later Bridge run passed **40 checks**, including nested cases. Those historical regressions cover streamed text, Code Mode configuration migration, MCP startup failures, and narrowly scoped one-use approvals. Both JARs were built; Java results are in Maven/Gradle XML reports, and the combined log for that run is `.runtime/build-final.log`. Validation of the current registry expansion is recorded separately in [MATERIALS.md](MATERIALS.md#validation). + +The registry expansion passed **145 Maven tests and 47 Bridge checks**. Its isolated Paper 26.2 probe described and roundtripped all **1,196 registered block defaults**, including 186 block-entity defaults, and passed **5,392 independent-property variants** through fresh placement, same-material changes and snapshot restoration. Live HTTP tests additionally verified inventory-preserving edits, conflicts on changed block-entity data, new-material schematic rotation and exact chest/sign/pot restoration after a server restart. These results cover the isolated integration server; lobby deployment is tracked in the material validation report. Automated Java tests cover geometry, limits, idempotency, read dependencies, conflicts, interrupted slices, cancellation, undo, journaling, disk failures, recovery, and individual HTTP/NBT contracts. Bridge tests use real MCP stdio and a mock ACP agent for conversations, permissions, cancellation, and resumption. Camera tests cover HTTP authentication and validation without launching a graphical client. @@ -23,36 +25,44 @@ The following scenarios passed on real Paper in a separately created test world: - A block changed after plan preparation: the operation ends in a conflict and preserves the other edit. - An explicit dependency changed: application stops before writing. - Construction, a retry with the same key, and checked undo; undo is rejected after a later external edit. -- Operation cancellation, protection of an unsupported source block, and rejection outside the allowed area. +- Operation cancellation, protection of a then-unsupported source block under the original palette policy, and rejection outside the allowed area. - Forced termination of the test's own server process during a 4096-block operation, restart, and `recovery_required` without automatic replay. - Administrative recovery review: the agent key is denied, a stale digest is rejected, and abandonment preserves current world contents and permits new operations after the decision is written to disk. Part protection does not prevent review, but continues to prohibit writes to the part. - 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). +- 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. At that stage, the narrower Paper policy rejected nonpersistent leaves and waterlogged lantern states; the runtime registry expansion removes that material restriction. [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. +Real `codex-acp` completed `initialize` in a separate profile without login: ACP v1 and session loading support are confirmed. That initial check did not invoke a model or consume model tokens. Dedicated ChatGPT sign-in and real authenticated ACP calls to `project_context` and `region_inspect` have since passed against Paper. A subsequent model turn placed and verified one oak-plank block at (24, -60, -45), then used checked undo and verified air. Place operation: `ae0166c8-6d03-45ab-a80a-48b644819a2b`; undo: `751017ba-8e07-433b-b38d-71dcc8cf2330`. A local proxy restricted writes to the fixture and its recorded undo. ## Significant limitations -**Manual edits.** Comparing expected and current states protects against a different block immediately before writing. Known player place/break events also invalidate block ownership for undo, even if the player restores the previous material. There is no complete interception of changes from other plugins, commands, physics, or all A→B→A transitions; revisions of known external events are not yet persisted across restarts. This prototype cannot be treated as a universal system for merging arbitrary concurrent edits. +**Manual edits.** Comparing expected and current states protects against a different block immediately before writing, including a block entity's extra data. A block-level `region_inspect` returns opaque `snapshot_id` digests for block entities; callers using `expected_blocks` must preserve those digests. Without a caller snapshot, preparation reads the current full state itself. Known player place/break events also invalidate block ownership for undo, even if the player restores the previous material. There is no complete interception of changes from other plugins, commands, physics, or all A→B→A transitions; revisions of known external events are not yet persisted across restarts. This prototype cannot be treated as a universal system for merging arbitrary concurrent edits. **Crashes.** A JSON journal with fsync replaces the planned SQLite storage. The Minecraft world and our journal do not form a single transaction. An ambiguous operation after a crash blocks new writes. Explicit administrative review and abandonment using a fresh digest are available, without modifying the world and with ambiguous undo disabled. There is no automatic replay/rollback. The full journal is loaded at startup and does not yet support archival. **Scope and performance.** One owner, project, and world; at most 4096 blocks and 512 explicit dependencies per plan. Writes are limited to 128 blocks with a target budget of up to 5 ms per slice; verification costs and the JVM prevent a hard tick-time guarantee. Full preparation of a bounded plan still runs on the server thread. Long-running load tests on a large server have not been conducted. -**Context.** Project context contains brief metadata, up to 20 operations, and up to 64 parts; exact blocks are read separately. `region_changes` currently returns `resync_required`. The agent therefore rereads selected areas; the promise of reading only deltas is not implemented yet. ACP conversations support persistence and summaries, but model usage has not been measured here. +**Context.** Project context contains brief metadata, catalog counts/version, up to 20 operations, and up to 64 parts; exact blocks are read separately. Material IDs are discovered with `material_search` (16 results by default, 32 maximum). `material_describe` returns the default and each property's allowed values for one selected material, never every state permutation. Results can be reused while `catalog_version` is unchanged. `region_changes` currently returns `resync_required`. The agent therefore rereads selected areas; the promise of reading only deltas is not implemented yet. ACP conversations support persistence and summaries, but model usage has not been measured here. -**Blocks and schematics.** A limited set of 71 vanilla materials is allowed, including selected stairs and slabs, lanterns, iron chains (`iron_chain` in Minecraft 26.2), iron bars, stone brick walls, oak leaves, moss, gray and brown glass, glowstone, and gold blocks; `project_context` returns the exact list. Leaves are allowed only with `persistent=true` so that they do not decay without a tree. Waterlogged blocks, containers, doors, redstone, and other complex blocks are unsupported. Checks of surrounding blocks intentionally restrict use near unsupported environments. Sponge v2 `.schem` is implemented without a WorldEdit dependency: up to 4096 blocks, up to 64 files, rotations in multiples of 90°, no entities, block entities, or biomes, and no conversion between game versions. Its strict codec currently supports the original 61-material palette; the ten new decorative materials are available through normal building operations. Unsupported content is rejected rather than removed during export. +**Blocks and schematics.** Ordinary building recipes use the running server's complete registered vanilla block catalog and exact state parser, including waterlogged blocks, fluids, containers, doors and redstone. Item-only catalog entries cannot be placed. Same-material block-state edits preserve existing block-entity data; new block entities use defaults. There is no raw NBT, sign-text, entity or inventory editor. Doors, beds and tall plants require explicit placement of every part; attachment, gravity and support rules still matter. Random ticks, fluid flow and redstone reactions are game behavior, not a fully journaled extension of direct block writes; checked undo does not promise to reverse all their consequences. Terrain recipes and brushes retain their narrower palette and scan policies. + +Sponge v2 `.schem` is implemented without a WorldEdit dependency: up to 4096 blocks, up to 64 files and rotations in multiples of 90°. Its palette now uses registered block states and runtime rotation. Entity/block-entity NBT payloads, biomes and conversion between game versions remain unsupported. Export rejects every block entity rather than losing its extra data; import can place a block-entity block type from a state-only palette through the normal default/preservation rules. [Material workflow and precise boundaries](MATERIALS.md). **Building language.** Box, line, cylinder, and repeat are available. Arches, arbitrary transforms, decorative palettes, JavaScript/Python execution, and automatic reconciliation of recipes with manual edits are not yet available. A registered part contains an exact mask of blocks actually written, not its entire bounding box. **Camera.** A configured spectator client is required; in the tested scenario this is the same player as the project owner. During capture, that player cannot continue normal building. Game mode and original position are not restored automatically. Frame readiness is heuristic: `serverRevisionVerified: false`. Neighboring chunk availability and stable rendering do not prove receipt of all server updates. Real construction and captures have been tested; third-party shaders, disconnection during capture, and all forms of window freezing have not yet been tested. -**ACP.** Separate HOME/CODEX_HOME directories are used; extra integrations, shell, and forwarding of the administrator token are disabled. This is not OS-level isolation. The pinned adapter maps `read-only` mode to `workspace-write`; temporary paths may remain accessible, and the pinned CLI keeps the `unified_exec` flag enabled when `shell_tool` is disabled. Additional permission requests are currently rejected. Permissions for the dynamically supplied Minecraft MCP need testing in the first real turn. Details and diagnostics are in the [Bridge README](../bridge/README.md). +**ACP.** Separate HOME/CODEX_HOME directories are used; extra integrations, shell, and forwarding of the administrator token are disabled. This is not OS-level isolation. The pinned adapter maps `read-only` mode to `workspace-write`; temporary paths may remain accessible, and the pinned CLI keeps the `unified_exec` flag enabled when `shell_tool` is disabled. The bundled Code Mode host is enabled for models that require it. Correlated calls to known Minecraft tools receive one-use approval during the active owner request; other permission requests remain rejected. Details and diagnostics are in the [Bridge README](../bridge/README.md). ## Next acceptance stage -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. +1. Extend the verified ACP one-block roundtrip to larger in-game `/ai` builds, real cancellation, and reconnect recovery. 2. Extend the verified single-client Prism scenario: test disconnection/freezing during capture and convenient switching between building and camera use. -3. Extend the demonstrated build → inspect → correct cycle to an authenticated ACP model turn, then reassess release readiness, limits, context deltas, and geometry extensions. +3. Extend the demonstrated procedural Gothic-hall workflow to larger authenticated ACP builds, then reassess release readiness, limits, context deltas, and geometry extensions. + +## Terrain toolkit + +Deterministic terrain recipes now support hills, ridges, plateaus, dry basins/channels and terraces. `terrain_preview` returns a native heightmap and cached recipe ID; `terrain_prepare` creates a checked tile plan for the existing apply/undo pipeline. Offline previews and bounded resumable batches are available through `scripts/terrain.py`. See [Terraforming tools](TERRAFORMING.md) for examples, safety semantics and scale limits. + +`terrain_brush_prepare` additionally edits existing terrain relatively: raise/lower, flatten and snapshot-based smoothing, with soft edges, preserved columns, native before/after previews and checked read dependencies. See the relative-brush section of the terraforming guide. diff --git a/docs/MATERIALS.md b/docs/MATERIALS.md new file mode 100644 index 0000000..0dfb0e1 --- /dev/null +++ b/docs/MATERIALS.md @@ -0,0 +1,100 @@ +# Runtime materials + +Ordinary building recipes accept every vanilla block type and valid block state registered by the running Minecraft server. The old fixed building palette has been removed. The same catalog also discovers items, but an item without a block form cannot be placed by `build_prepare`. + +This does not require loading the registry into the agent's context. `project_context.material_catalog` returns only the catalog version, counts and search limits. The block and item counts overlap: a material can have both forms. `material_search` retrieves a small page of candidate IDs; `material_describe` retrieves details for one selected material. + +## Discover a detail, then build it + +1. Read `project_context` for the authorized area, limits and material-catalog version. Inspect the local construction area. +2. Search a relevant family, for example `material_search({"query":"spruce trapdoor"})`. Search is case-insensitive and every whitespace-separated token must occur in the material ID. +3. Describe the chosen ID with `material_describe({"id":"minecraft:spruce_trapdoor"})`. Use the returned `default_state` and allowed property values to select its facing, half, open and waterlogged state. +4. Compile bounded geometry with that state. Keep attachments, supports, surrounding clearance and every required half or part explicit. +5. Prepare, inspect the returned plan summary, apply with a stable idempotency key, and poll `operation_status`. Read back the relevant states and inspect an actual camera image when available. + +Search for 1–3 details at a time. Reuse already described defaults and property values while `catalog_version` is unchanged; repeat discovery after a version change or an unknown-state error. An unchanged catalog does not make a previous world snapshot current. + +`material_search` accepts a query of at most 96 printable characters, `kind: "block" | "item" | "all"` (default `block`), `limit: 1..32` (default 16), and an optional cursor of at most 100 characters. A response contains `catalog_version`, normalized `query`, `kind`, `total`, a bounded `results` array of `{id, block, item}`, and an optional `next_cursor`. Cursors belong to the catalog version and query/kind; start a new search if either changes. An empty query still returns only one page. + +`material_describe` accepts one exact `minecraft:` ID of at most 128 characters. A block entry returns `default_state`, `properties` mapping each property name to its allowed values, and optional compact `behavior` hints. These are individual property domains, not every Cartesian state combination. An item-only result returns `placeable: false` and no block state. Unknown materials and unavailable runtime property introspection fail explicitly. + +No tool descriptions contain the complete registry. No call needs to describe all materials in advance. State strings are bounded to 1024 characters; ordinary MCP text results retain the 64 KiB response limit. The guidance shared by MCP and ACP explicitly requests focused searches and reuse of results. The full recipe cookbook is also kept outside the default prompt. + +## Block states and extra data + +A recipe state has the form `minecraft:block[property=value,...]`. The server validates IDs, property names and values and canonicalizes the result. Omitted properties use the server defaults. Fluids, waterlogged blocks, nonpersistent leaves, carpets, doors, redstone and block-entity block types are accepted when present in the runtime registry. + +Block-entity data is separate from the state string. A chest's orientation is a state; its inventory is extra data. A sign's block type and rotation are states; its text is extra data. New block entities use their default data. A same-material state edit preserves existing extra data. Replacing a block entity with another material uses the normal checked snapshot and undo path; the original data stays in the server-side history, not in the agent prompt. + +With `region_inspect(detail: "blocks")`, a block entity includes an opaque `snapshot_id` alongside `pos` and `state`. When supplying `expected_blocks`, include every desired position exactly once and copy each returned digest unchanged. A digest is mandatory for an existing block entity, even if it is empty or has default data. The server rejects omitted required digests and stale states/data before preparation. The digest is a lowercase 64-character SHA-256 value; it does not reveal inventory or NBT contents. If no `expected_blocks` array is supplied, preparation captures the current full state itself. + +```json +{ + "pos": {"x": 0, "y": 64, "z": 0}, + "state": "minecraft:chest[facing=north,type=single,waterlogged=false]", + "snapshot_id": "" +} +``` + +The digest above is a placeholder, not a valid request. Copy the complete inspected entry instead of inventing or regenerating its digest. A conflict is evidence of a changed world and requires a localized decision; do not read a new snapshot merely to force the old design through. + +There is no arbitrary NBT, item-inventory, entity, sign-text, banner-pattern or decorated-pot-data editor. Discovering an item does not create an item stack or an item entity. Existing project-specific text displays and game-selection logic remain separate from these generic building tools. + +## Placement behavior + +Support for a block state does not construct its neighbors. Explicitly place both door halves, both bed parts, every tall-plant half and all other pieces required by a design. Give attached blocks valid support, supply valid flower substrates and check directional states. Decorative leaves should normally use `persistent=true`; accepting natural decay states does not make them suitable for a permanent facade. + +Fluid flow, gravity, random ticks, plant growth, oxidation and redstone reactions are Minecraft simulation behavior. The operation journal and checked undo cover direct recorded writes and their checked snapshots, not every later simulation consequence. Use a bounded prototype with the required containment and support, then inspect after game updates before repeating a motif. A prepared plan alone is not evidence that a decorative assembly remains stable. + +Terrain generation and relative brushes have separate policies. Their existing limited terrain palettes remain; brushes still reject fluids and structures in the scan window. Placing water through ordinary checked geometry does not add a fluid-aware terrain brush or hydraulic erosion API. + +## Schematics and preservation + +The Sponge v2 codec accepts registered block states and uses the runtime's block-state rotation behavior. Limits remain 4096 positions per asset, 64 local files, 1 MiB compressed input, 4 MiB decompressed NBT and rotations of 0/90/180/270 degrees. + +Entity and block-entity NBT payloads are rejected on import. A state-only palette can name a block-entity block type; it follows ordinary new-default or same-material-preservation rules when prepared. Export refuses every block entity, including empty ones, rather than silently discarding its extra data. Biomes, unknown top-level fields, required mods and game-version conversion remain unsupported. A `.schem` therefore does not replace a complete world checkpoint. + +Keep the recipe, inspected snapshots, operation receipts and useful photographs with an appropriate world backup. The historical Stage 07 snapshot contained 100 material IDs; the decoration recipe catalog preserves that dated capability basis for reproducibility. It does not limit the current server registry or need to be expanded into thousands of entries. + +## Validation + +The registry expansion passed validation against a separate Paper 26.2 build 123 integration server on 2026-09-13. Its catalog version is `00474abd322518089309784c`: **1,691 materials, 1,196 block types and 1,537 item types**. Block/item counts overlap. These are observations from that runtime, not fixed protocol constants. + +Completed checks: + +- Real MCP stdio advertised 19 tools and delivered catalog discovery responses. The catalog summary was 130 bytes of compact JSON; the measured full `project_context`, including recent operation history, was 3,375 bytes. A focused three-result copper-trapdoor search was 374 bytes and one candle description was 324 bytes. A default 16-result HTTP search page was 1,256 bytes. These are response byte measurements, not model token counts or a model-usage benchmark. +- The runtime property probe described all 1,196 block types successfully. The largest description was 682 bytes, with at most seven properties on one block and at most 27 values in a property domain. No Cartesian state list was returned. +- The final registry probe placed and read back all 1,196 default block states, including 186 block-entity defaults, with zero failures. It also passed 5,392 deduplicated cases that vary individual properties: every case passed fresh placement, a same-material state edit, and restoration of the original snapshot. The temporary fixture anchor was restored. +- Eleven live HTTP scenarios passed: pagination and bounds, property domains, item-only distinction, new block/fluid apply and undo, paired door halves, private block-entity digests, same-material chest rotation with inventory preservation, stale inventory conflicts before preparation and during application, replacement without inventory drops, a waterlogged cherry-trapdoor schematic rotated 90 degrees, and refusal to export block entities. +- A further check after a real Paper restart used checked undo to restore exact chest, sign and decorated-pot snapshot digests. The chest retained its seven diamonds and the sign retained the known fixture text `MCP snapshot test`. +- The full Maven suite passed 145 tests and the Bridge suite passed 47 checks, including nested cases. Bridge coverage includes bounded discovery transport, state/NBT limits, block-entity snapshot digests, scoped tool approvals and one-time delivery of changed instructions to resumed sessions. After the final narrow structure-block mode fix, the Paper/core package and its tests passed again. + +The probe covers every registered default state and individual-property variants, not the Cartesian product of all properties or long-running redstone/fluid behavior. The live HTTP scenarios separately verify the operation journal, stale-data conflicts and restoration after restart. + +**Lobby deployment:** the updated plugin is running in `shacraft-lobby-v2`. A fresh backup was made while the server was stopped. After restart, the project, world ID and epoch, selected region, operation count and part count matched the previous context; recent operations remained applied. Real MCP stdio advertised all 19 tools and successfully returned the new catalog, search and descriptor. The chat Bridge was restarted with its existing state directory, and its ChatGPT login remained available. These deployment checks were read-only and did not modify the lobby's construction. + +The [verification receipt](references/material-registry-verification.json) preserves the registry counts, test coverage, response sizes and deployed plugin digest without private credentials or block-entity contents. On the lobby, the full `project_context` measured 4,840 bytes with its history; its catalog summary remained 130 bytes. Context size still depends on the requested world information, rather than just the catalog. + +Local evidence lives in `.runtime/material-test-server/live-receipt.json`, `.runtime/material-test-server/mcp-receipt.json` and `.runtime/material-test-server/plugins/MaterialRegistryProbe/report.json`. These test-runtime paths are not distributed assets or links to files in Git. + +## Repeating the live fixture + +The separate [registry probe](../scripts/material-registry-probe/README.md) retains the source and builder for the exhaustive default-state and individual-property checks. Its helper plugin is opt-in and belongs only in the disposable test server; it is not part of the production plugin. + +[`scripts/test-materials-live.py`](../scripts/test-materials-live.py) is an opt-in integration fixture, separate from ordinary automated tests. It changes and restores bounded test blocks, uses known chest/sign data, and exercises a real server restart. It does not create or launch a server. + +Provision a separate Paper test server in `.runtime/material-test-server` with project `material-integration`, a region whose maximum X is 511, local automation enabled for the `console` principal, HTTP on `127.0.0.1:18765`, and RCON on `127.0.0.1:25586`. The operator supplies that server's private agent and RCON credentials in its local `test-access.json`; keep the file outside Git. The fixture checks the project and region before mutation and is intentionally not configurable to point at the lobby. + +With that isolated server running, invoke from the repository root: + +```bash +python3 scripts/test-materials-live.py before-restart +``` + +After the phase finishes and writes `live-receipt.json`, cleanly stop and restart **that same isolated test server**, preserving its world, plugin data and journal. Then run: + +```bash +python3 scripts/test-materials-live.py after-restart +``` + +The second phase consumes the saved operation receipt and checks restoration after restart; running it twice is not a fresh test. The fixture leaves known test fixtures and an exported schematic in the isolated world/library. It must not be run against a shared lobby or treated as a read-only health check. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 4540948..f87c14b 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -12,12 +12,26 @@ Success: `{ "ok": true, "result": { ... } }`. Error: `{ "ok": false, "error": { The administrator key is required for `chat_poll`, `chat_reply`, `recovery_review`, and `recovery_abandon`. The agent key permits world tools within the bound owner's project, but not administrative recovery. The MCP process injects `player_id/project_id` from its configuration; the model is not asked to supply them. The plugin rechecks the owner and their current permissions. An isolated test world can explicitly enable `allow-local-automation` with the `console` principal; administrative recovery operations also pass through this scope check. +## Material discovery + +`project_context.material_catalog` contains `{version, materials, blocks, items, search_default_limit, search_max_limit}`. Block and item counts overlap for materials that have both forms. This replaces the expanded `supported_materials` list; no material registry or property list is sent in project context. + +`material_search` and `material_describe` are read-only, idempotent MCP tools available within the same authorized project scope. `material_search` accepts optional `query` (at most 96 printable characters), `kind: "block" | "item" | "all"` (default `block`), `limit: 1..32` (default 16), and an opaque `cursor` of at most 100 characters. Search matches all whitespace-separated query tokens against material IDs, case-insensitively. It returns `{catalog_version, query, kind, total, results: [{id, block, item}], next_cursor?}`. A cursor belongs to its catalog version and normalized query/kind; changed filters or stale catalogs require a new search. Empty queries remain paginated. + +`material_describe` accepts `{id: "minecraft:material_name"}` with a maximum ID length of 128 characters. It returns `{catalog_version, id, block, item, placeable, default_state?, properties?, behavior?}`. Block entries include the full default state and `properties: {name: [allowed_value, ...]}`; the response never enumerates the Cartesian product of all property combinations. Optional behavior hints identify features such as gravity, fluids, attachment, multiple parts or block-entity data. Item-only entries return `placeable: false` without block states. Direct RPC also accepts an ID without the `minecraft:` prefix; MCP requires the explicit namespace. Unknown IDs or unavailable property introspection fail explicitly. + +Callers should search a focused family and describe only 1–3 selected materials, reusing those results while the catalog version remains unchanged. See [MATERIALS.md](MATERIALS.md) for the building workflow and behavior limits. + ## Reading and writing -`region_inspect` requires `min/max` as `{x,y,z}` and `detail: "summary" | "blocks"`. Coordinates are inclusive integers. The prototype limit is 4096 positions. The response contains a palette with counts and, for `blocks`, exact states. Data comes from loaded chunks without implicit world generation. +`region_inspect` requires `min/max` as `{x,y,z}` and `detail: "summary" | "blocks"`. Coordinates are inclusive integers. The prototype limit is 4096 positions. The response contains a palette with counts and, for `blocks`, exact `{pos, state, snapshot_id?}` entries. Block entities include a lowercase 64-character SHA-256 `snapshot_id` covering their captured data; NBT and inventory contents are not returned to the model. Data comes from loaded chunks without implicit world generation. `build_prepare` accepts `recipe: {version: 1, operations: [...]}`, an optional `dependencies` array, and an optional `part_id` for an exact part mask. Geometry: `box(min,max,block,hollow?)`, `line(from,to,block)`, `cylinder(center,radius,height,block,hollow?)`, `repeat(count,offset,operations)`. This is a JSON description, not executable JavaScript. +Ordinary recipes accept registered vanilla block IDs and their valid properties as `minecraft:block[property=value,...]`, at most 1024 characters. Omitted properties use the runtime defaults; the server validates and canonicalizes the result. Item-only materials, unknown IDs/properties/values and raw NBT are rejected. Same-material state edits preserve existing block-entity data; a newly created block entity uses defaults. There is no inventory, sign-text or entity-data editor. + +Callers planning from an earlier survey can supply `expected_blocks: [{pos: {x,y,z}, state: "minecraft:grass_block", snapshot_id?: "..."}, ...]`. When present, the array must cover every compiled desired position exactly once (maximum 4096). Copy each inspected `snapshot_id` unchanged: it is required for existing block entities, including those with default or empty data. The server checks these original states and digests in the same main-thread task that prepares the plan, returning `stale_snapshot` if any have changed; a required digest that was omitted is an invalid request. Without `expected_blocks`, preparation captures the current full state itself. `project_context.checked_expected_blocks` advertises support. This closes the caller-inspection-to-preparation gap; the usual comparison before applying still protects the prepared plan. + Preparation returns `plan_id`, `plan_hash`, `changed_blocks`, `region`, and `expires_at`. The full plan and original blocks stay in the journal. `build_apply` accepts this ID, hash, and an `idempotency_key` that remains constant for that logical call. Repeating the same call returns the same operation. A different plan requires a different key. `operation_status(operation_id)` returns status, counters, and a bounded sample of conflicts. `operation_cancel` stops subsequent slices. `operation_undo_prepare` creates a reverse plan; it goes through the normal `build_apply` and checks. Losing the response to an apply request does not justify creating a different key and repeating the write: first check `project_context` or repeat the original call with the same key. @@ -26,6 +40,8 @@ Preparation returns `plan_id`, `plan_hash`, `changed_blocks`, `region`, and `exp Before a write, the actual states of target blocks and declared dependencies are checked; the check runs again after the intent is persisted. A mismatch is never overwritten automatically. Undo also accounts for known subsequent writes by our operations, even when a block's value again matches the earlier result. +Multi-block structures require explicit states for every part: placing a door's lower half does not construct its upper half. Support and attachment constraints remain the caller's design responsibility. Fluids, gravity, random ticks, plant growth and redstone may cause later game changes. The journal and checked undo cover the direct operation's recorded writes, not every resulting simulation effect. + The plugin observes uncancelled `BlockPlaceEvent` and `BlockBreakEvent` events in the configured world. Such an event revokes the earlier operation's right to undo that block, including an edit followed by a change back to the original state (ABA). These notifications are held only in the current process's memory. After a restart, and for external paths without an observed event, current-content checks remain in place; a complete history of other plugins' actions is not promised. A full server-side journal of external changes is not implemented yet. `region_changes` returns `resync_required`; the agent uses fresh, bounded reads. This is an explicit prototype limitation. @@ -48,11 +64,11 @@ The implemented RPC names are `asset_list`, `schematic_export`, and `schematic_i `asset_list` accepts an optional `query` for case-insensitive name search and returns `{ "assets": [...] }`. Each entry contains `assetId`, `name`, `width`, `height`, `length`, `blockCount`, `dataVersion`, `offset`, `sha256`, and `bytes`. The catalog holds up to 64 files; every file is validated when read, so a corrupt schematic may cause the entire list request to fail. -`schematic_export` accepts `name`, inclusive `min/max`, and an optional `origin`. The name contains 1–64 printable characters. `origin` defines the schematic's anchor point and defaults to `min`. The server reads a dense rectangular region, including air, within the current project area and the `max_plan_blocks` limit (at most 4096). Unsupported blocks and entities other than players cause rejection; players are not written to the schematic. The result is one object with the same metadata fields as `asset_list`. The file remains in the `schematics` subdirectory of the plugin's data directory; the RPC returns neither its contents nor an arbitrary path. +`schematic_export` accepts `name`, inclusive `min/max`, and an optional `origin`. The name contains 1–64 printable characters. `origin` defines the schematic's anchor point and defaults to `min`. The server reads a dense rectangular region, including air, within the current project area and the `max_plan_blocks` limit (at most 4096). Registered block states are accepted. Block entities, including empty ones, and entities other than players cause rejection; their data is never silently discarded, and players are not written to the schematic. The result is one object with the same metadata fields as `asset_list`. The file remains in the `schematics` subdirectory of the plugin's data directory; the RPC returns neither its contents nor an arbitrary path. -`schematic_import_prepare` accepts `asset_id`, `target: {x,y,z}`, and an optional `rotation: 0 | 90 | 180 | 270` (default 0). Rotation is clockwise when viewed from above, around the `target` anchor; the saved `offset` is taken into account. Supported stair directions and log/pillar axes are also transformed. The result is the usual `plan_id/plan_hash/changed_blocks/region/expires_at`; writing requires a separate `build_apply`. Air in the schematic is part of the plan and can remove existing supported blocks. The area, original contents, surroundings, block policy, and conflicts are checked through the normal preparation and application path. +`schematic_import_prepare` accepts `asset_id`, `target: {x,y,z}`, and an optional `rotation: 0 | 90 | 180 | 270` (default 0). Rotation is clockwise when viewed from above, around the `target` anchor; the saved `offset` is taken into account. Block-state orientation uses the runtime rotation behavior, including directional and axis properties. The result is the usual `plan_id/plan_hash/changed_blocks/region/expires_at`; writing requires a separate `build_apply`. Air in the schematic is part of the plan and can remove existing blocks. The area, original contents, surroundings, block policy, and conflicts are checked through the normal preparation and application path. State-only palette entries for block-entity block types use the same new-default or same-material-preservation rules as ordinary recipes. -A limited subset of Sponge Schematic v2 is supported: gzip and NBT with a palette of vanilla building block states. Limits are 4096 positions, a 1 MiB compressed file, and 4 MiB of decompressed NBT. Entities, block entities, biomes, unknown top-level fields, required mods, and other format versions are rejected. A `DataVersion` newer than the current server is not accepted; there is no DataFixer conversion. Full compatibility with every WorldEdit schematic is not claimed. +A limited subset of Sponge Schematic v2 is supported: gzip and NBT with a palette of registered vanilla block states. Limits are 4096 positions, a 1 MiB compressed file, and 4 MiB of decompressed NBT. Entity and block-entity NBT payloads, biomes, unknown top-level fields, required mods, and other format versions are rejected. A `DataVersion` newer than the current server is not accepted; there is no DataFixer conversion. Full compatibility with every WorldEdit schematic is not claimed. An external `.schem` can be placed in the local schematic directory in advance with a name matching `[A-Za-z0-9][A-Za-z0-9_-]{0,63}.schem`; its filename stem then serves as `asset_id`. Arbitrary paths, network URLs, and symbolic links are not accepted. The prototype does not support file uploads through RPC. @@ -71,3 +87,9 @@ Paper serializes requests, moves the configured spectator observer, and sends a Captures include a heuristic assessment of chunk/frame readiness. `serverRevisionVerified: false` remains in place until strict client acknowledgement is implemented. A missing camera, a timeout, or failure to obtain a fresh frame never counts as visual success. Worker details: [camera-mod/README.md](../camera-mod/README.md). Threading and journal: [world-core/README.md](../world-core/README.md). ACP and isolation: [bridge/README.md](../bridge/README.md). + +## Terrain + +`terrain_preview(recipe,resolution?)` renders and caches a bounded deterministic height-field recipe without reading or writing world blocks. It returns a native PNG through the Bridge, a content-addressed `terrain_id`, tile layout and sampled statistics. The cache holds 32 recipes until restart. `terrain_prepare(terrain_id,tile_index)` prepares one bounded tile against live natural terrain and returns either the usual immutable plan summary plus `tile_bounds`, or `status: empty` with zero changes. Existing `build_apply`, cancellation, conflict checks and checked undo apply unchanged. The terrain language, limits, preserve masks and local batch workflow are specified in [TERRAFORMING.md](TERRAFORMING.md). Preview output is explicitly marked `world_verified: false`. + +`terrain_brush_prepare(brush)` prepares a relative edit of an existing live surface. Its bounded scan is stored as read dependencies (up to 4096), and the response combines a native before/after/delta image with a normal plan summary. `plan_state: empty` has no plan ID. Actions are raise, lower, flatten and smooth. Input, scan-window semantics and limitations are documented in [TERRAFORMING.md](TERRAFORMING.md#relative-brushes-on-existing-terrain). diff --git a/docs/SHACRAFT-ARRIVAL-GARDEN.md b/docs/SHACRAFT-ARRIVAL-GARDEN.md new file mode 100644 index 0000000..4ae73a3 --- /dev/null +++ b/docs/SHACRAFT-ARRIVAL-GARDEN.md @@ -0,0 +1,47 @@ +# Shacraft arrival square — garden finish + +Stage 03 finishes **zone 01** on the existing foundations. The clock station and other districts keep their previous construction state. + +The main reference is sheet 03, *Clock station and arrival square*, supported by sheets 01, 02, 10 and 11. Its defining features are a pale open apron, the green Shacraft emblem, concentric hexagonal paving bands, evergreen planting islands, pink and white flowers, benches and warm garden lighting. The concept images guide the composition; they are not measured Minecraft drawings. + +![Finished arrival garden in Minecraft](references/shacraft-arrival-garden-overview.png) + +## Built composition + +- The former square setting-out grid is replaced with a cream paved field and three flush hexagonal contour bands. +- The actual green artwork from the preserved `logo-180.png` is sampled into a 25 × 35 block footprint. The open hexagonal shield and S replace the earlier placeholder medallion. +- Six low stone-edged planting islands contain six custom conifers, six smaller topiary trees, low evergreen clusters and 258 flowers. The southern beds extend to the balustrade, eliminating isolated strips of paving behind them. +- Six spruce benches face the square. Each has a three-block level approach through the planter edge. The benches are decorative; no sitting interaction is added. +- Sixteen supported lantern fixtures use slender posts and green copper hoods. Eight former light piers are replaced, including a balanced pair beside the arrival stair. +- Obsolete zone 01 survey stakes, its block number and its floating survey label are retired. Other district markings remain. + +Paving remains at block Y95, with the main walking plane at Y96. The central sightline toward the clock station and all existing road connections are preserved. No terrain platform, station construction or new fountain is introduced by this stage. + +![Planting, a bench and copper lantern fixtures](references/shacraft-arrival-garden-detail.png) + +![Evening lighting in the actual Minecraft world](references/shacraft-arrival-garden-evening.png) + +## Checked construction + +Before the work, the full server surface matched Stage 02 at all 589,824 columns. A fresh 359,964-voxel plaza survey included soil, foundations, fixtures and canopy air; 294,516 overlapping voxel states also matched the previous checkpoint. Unexpected manual edits stop the compiler, and the normal checked editor protects against changes between preparation and application. + +The final candidate consists of 4,734 changed blocks. Its review checks persistent leaf distances, valid soil under flowers, grounded trunks, lantern support, bench approaches and 1.8 blocks of walking headroom. Grass beneath opaque tree trunks is replaced with dirt explicitly so random ticks do not change the recorded foundation state. The road audit starts with the previously verified usable surface, excludes actual planting and furniture, and checks the original eight route widths and their connectivity. + +Final verification uses fresh observed blocks and a new full server map. These sequential surveys are not atomic world snapshots; construction remains idle during them. Perspective screenshots are real Fabric client captures, with heuristic render readiness. Geometry checks do not represent a human playtest or a full simulation of moving-player collisions. + +The [final live report](references/shacraft-arrival-garden-verification.json) passed after **4,811 checked writes in 12 batches**, including a 77-block finishing pass that gives the conifers continuous green tips. All **4,734 final changed states** matched the latest observation. It checked 537 persistent leaf states, 258 supported flowers, 73 connected trunk blocks in 12 trees, 16 lantern supports and **4,955 clear walking samples**. All eight inherited road widths and 18 bench-front positions passed, with no isolated usable paving. The full **589,824-column map** matched the expected world, and all 41,467 visible water columns remained unchanged. + +![Actual arrival garden surface map](references/shacraft-arrival-garden-map.png) + +## Reusable tools + +- `scripts/plaza-assets.py`: deterministic conifers, oriented benches and supported copper-hood lantern fixtures. Functions return local voxel states and never write to a world. +- `scripts/build-shacraft-plaza.py`: composes the approved garden geometry, real brand mask and static assets into a checked recipe. It preserves foundation elevations and existing road treads. +- `scripts/verify-plaza.py`: independent candidate/live validation of planting, fixtures, walking clearance and the inherited road network. +- `scripts/layout.py`: bounded checked placement, receipts and conflict-aware reverse undo. + +The Paper policy now supports 99 materials. The added flowers are single-block decorative species; new copper forms are waxed. Existing restrictions on fluids, waterlogged states and nonpersistent leaves remain in force. The strict schematic codec retains its earlier material subset, so complete world checkpoints and voxel recipes are the preservation format for these gardens. + +The final recipe, original observations and applied ledger are retained locally under `.runtime/plaza-stage03/`. The reference study and its earlier candidate are preserved separately from the finished state. Stage 02 remains available in the local Git archive and its full world backup. + +The restorable final world checkpoint is `.runtime/projects/shacraft-arrival-garden-20260913/`, captured after an explicit save flush with automatic saving temporarily disabled and then re-enabled. It includes the full world container, matching plugin state, both stage ledgers and the earlier foundation receipts. Undo the apex polish ledger before the main garden ledger. The removed zone 01 text display is an independently recorded operator action rather than a block-journal change. diff --git a/docs/SHACRAFT-BALUSTRADE.md b/docs/SHACRAFT-BALUSTRADE.md new file mode 100644 index 0000000..101884a --- /dev/null +++ b/docs/SHACRAFT-BALUSTRADE.md @@ -0,0 +1,46 @@ +# Shacraft arrival square — perimeter balustrade + +Stage 04 completes the edge of the finished zone 01 garden with a continuous pale handrail, regularly spaced sandstone piers and connected stone balusters. It follows the existing hexagonal terrace while keeping the road approaches open. The garden, logo apron, lighting and clock-station construction retain their previous state. + +![Completed arrival-square balustrade in Minecraft](references/shacraft-balustrade-overview.png) + +## Boundary and heights + +The balustrade contains **302 perimeter columns** split into **five uninterrupted fence runs**, separated by the existing approach openings. The runs contain 46, 80, 74, 71 and 31 columns. Forty-six cut-sandstone piers mark endpoints, principal corners and intermediate bays. + +Paving remains at block Y95, with its walking plane at Y96. Balusters and piers occupy Y96. A bottom smooth-sandstone slab at Y97 forms a continuous handrail with its upper surface at **Y97.5**, 1.5 blocks above the plaza walking plane. Wall connections are explicit, including tall side connections beneath the handrail and reciprocal connections to six adjoining road-rail columns. + +The boundary forms a cardinal block chain. Elbows close the diagonal steps so adjacent railing pieces meet along block faces. Where planting occupies the inner choice, the connector moves to the outer side. Twenty-two small outer columns have stone-brick footings rising from observed full support to cut-sandstone coping at Y95. They support individual elbows without adding another terrace. + +Four existing southern planter-rim slab columns are incorporated into the new boundary. Planting contents, existing lamps, road treads and the clear approach widths are preserved. + +![Connected railing, pale coping and sandstone piers](references/shacraft-balustrade-detail.png) + +![Grounded outer elbows and the terrace edge](references/shacraft-balustrade-exterior.png) + +## Checked construction + +`scripts/build-shacraft-balustrade.py` reads captured blocks and compiles a plan without changing the world. It refuses unexpected decoration at fence or handrail positions, protects inherited road cells, and finds observed footing support. `scripts/layout.py` checks expected states during preparation, journals bounded applications and verifies their results. + +The editor made **692 checked writes in four batches**: 682 initial writes and ten finishing writes for the road-rail joints and buried soil. Twenty-one grass blocks beneath the new footings had already changed naturally to dirt; the remaining buried grass block was explicitly stabilized. The recorded final comparison covers **710 distinct expected states**, including those observed soil transitions. Their positions are preserved in `settled-soil.json` alongside the stage records. + +The [final observed-world report](references/shacraft-balustrade-verification.json) passed. It checks the complete **556,308-voxel captured volume** against the baseline plus final plan, including **555,598 voxels outside the final changes**, with no mismatches. All 302 handrail columns, 46 piers, 22 grounded footings and six reciprocal rail joins passed. It also confirms all 1,172 preserved garden fixture states, **4,815 walking/headroom samples**, the original eight route widths and 32 bench-access columns. + +The separate complete surface comparison matched all **589,824 map columns**, including 589,516 columns outside the edited area. All 41,467 previously visible water columns remained unchanged. The navigation graph reached all 57,352 surface samples across 14,338 usable columns. + +These sequential voxel and surface captures are not atomic snapshots; construction was idle during final measurements. The volume check covers the supplied observed bounds. Route checks verify geometry, support and headroom, without simulating a moving player's complete collision body. Perspective images are actual Fabric client captures with heuristic render readiness. + +![Actual server surface after the balustrade finish](references/shacraft-balustrade-map.png) + +## Tools and preservation + +- `scripts/build-shacraft-balustrade.py` compiles the continuous perimeter, grounded elbows and explicit connections. +- `scripts/verify-balustrade.py` independently checks the final rail, protected garden, approaches, navigation and complete observed volume. +- `scripts/verify-foundation-survey.py` checks the full before/after server surface against the final column states. +- `scripts/layout.py` provides checked placement, journal receipts and conflict-aware reverse undo. + +Stage inputs, captured observations, final recipes and application receipts remain under `.runtime/balustrade-stage04/`. The archive separates initial computed previews and candidate checks from final Minecraft images and fresh observed-world reports. + +The restorable checkpoint is `.runtime/projects/shacraft-plaza-balustrade-20260913/`. It contains the complete world container, matching plugin state and stage receipts. The world was explicitly flushed with saving temporarily disabled for the copy, and automatic saving was re-enabled afterward. All 137 recorded world-file hashes passed verification. + +Previous references and stages remain in `/home/emil/Desktop/Shacraft-Lobby-Archive`. Raw observations, complete world saves, private plugin configuration, authentication data and journals stay outside its Git history. Undo the finishing ledger before the initial balustrade ledger, then process older stages only if requested. Natural soil transitions are recorded explicitly and must be considered during restoration; manual-edit conflicts stop checked undo. diff --git a/docs/SHACRAFT-FOUNDATIONS.md b/docs/SHACRAFT-FOUNDATIONS.md new file mode 100644 index 0000000..5c46d8e --- /dev/null +++ b/docs/SHACRAFT-FOUNDATIONS.md @@ -0,0 +1,61 @@ +# Shacraft — foundations for zones 01 and 02 + +This document preserves the Stage 02 foundation checkpoint. Zone 01 has since received its [Stage 03 arrival garden finish](SHACRAFT-ARRIVAL-GARDEN.md). + +This construction stage develops the arrival square and clock station in the approved natural v2 world. It also builds their local approaches. The other district reservations and future bridge spans remain at the survey stage. + +![Completed foundations and local approaches in Minecraft](references/shacraft-foundations-overview.png) + +## Levels and circulation + +The arrival square and oval station forecourt use paving blocks at **Y95**, with the walking surface at Y96. The station floor uses blocks at **Y98**, with the walking surface at Y99. The station follows its actual hall, tower and pavilion footprint. Its western retaining wall has shallow blind arches and stone pilasters; solid backing remains behind the recesses. + +A nine-block clear avenue links the square to the forecourt. A **21-block clear stair** rises to the station in half-block tread increments, with full landings. The local southern approach is nine blocks wide, the district connections seven, and the lake approach five. Curved roads use full transverse rows of correctly facing stairs, with separate edge coping. Lamps and balustrades sit outside the reserved clear lanes. + +Finished road endpoints, expressed as X/Z and the supporting block's Y: + +- Southern approach: `(1, 110)`, Y79, joining native ground at the same level. +- Portal approach: `(-72, -46)`, Y89, joining native ground at the same level. +- Lake approach: `(-90, 32)`, an east-facing bottom stair at Y84, stepping down to native full blocks at Y83. +- Northwest station approach: `(-80, -77)`, Y85, joining native ground at the same level. +- Eastern bridge approach: `(82, 8)`, Y87. +- Northeast bridge approach: `(82, -78)`, Y86. + +The two bridge approaches end at temporary balustrades beyond the completed walking deck. Those barriers must be removed when the corresponding bridge spans are constructed. Beyond the other local endpoints, the remaining route markings reserve later work; they are not finished roads. + +![Actual server surface with the completed roads and foundations](references/shacraft-foundations-map.png) + +## Materials and construction + +The plaza has pale sandstone paving, restrained stone courses and a flush green Shacraft medallion. The station has a stone structural floor with setting-out bands for the future hall, clock tower and end pavilions. Exposed bases combine deepslate, stone brick and andesite coping. Twenty-seven lantern piers light the edges. + +Every deck has structural courses and solid support down to the observed original terrain. The generator does not create a new rectangular terrain platform outside the building footprint. Small local cuts clear the paving and headroom; the original mountains and watercourses remain outside this construction scope. + +![Western station base after arcade refinement](references/shacraft-foundations-west.png) + +![Broad station entrance stair, captured in Minecraft](references/shacraft-foundations-stairs.png) + +## Checked workflow + +The design was compiled against a **1,981,407-voxel live before-survey**, including underground support and headroom. Unexpected differences from the original terrain plus known survey markers stop compilation. The placement driver also checks the caller's expected states atomically during preparation, journals bounded operations and verifies each applied batch. + +The stage inputs and receipts are retained locally under `.runtime/foundations-stage02/`. A fresh post-build voxel survey checks standing heights and 1.8 blocks of vertical clearance on the actual full blocks and both stair treads. An independent half-block navigation graph checks route connectivity and full-width cross sections. These are geometric and block-state checks, not a simulated moving player or a manual playtest. + +The full server-derived before/after surface maps are compared across the 768 × 768 world. The verifier applies air edits before finding the new top block, so lowered surfaces are checked correctly. Surface maps and sequential voxel surveys are not atomic; construction is kept idle while final measurements are taken. Perspective images use the real Fabric client, whose render readiness remains heuristic. + +The [completed verification report](references/shacraft-foundations-verification.json) records **79,081 checked writes across 67 journalled batches**, including a 408-block refinement that moved the western pilasters clear of the arches and darkened their intact backing. All **78,673 final changed voxel states** matched the post-build survey; **15,708 standing-height and headroom samples** passed. All **589,824 surface columns** matched the expected result, including **572,923 columns outside the edited area**. All 41,467 previously visible water columns remained unchanged, and no observed water blocks were replaced. + +## Tools and preservation + +- `scripts/foundation-study/design.py` and `geometry.py` define the elevations, contours, stairs and road corridors. +- `scripts/build-shacraft-foundations.py` compiles observed terrain, approved geometry and materials into a checked block plan; it does not write to the world. +- `scripts/layout.py` applies bounded batches and supports checked reverse undo through the stage ledger. +- `scripts/foundation-survey.py` records actual voxel states and checks floor support and headroom. +- `scripts/foundation-study/verify_geometry.py` independently checks planned navigation. +- `scripts/verify-foundation-survey.py` compares actual surface maps with the complete edited column state, including excavation. + +Original references and the marked-site checkpoint are preserved in the separate local Git repository `/home/emil/Desktop/Shacraft-Lobby-Archive`. Each completed stage has its own directory, source provenance and SHA-256 manifest. Restorable world backups remain outside Git, together with their matching private plugin state and operation journals. + +The completed world checkpoint is `.runtime/projects/shacraft-foundations-01-02-20260913/`, captured with saving disabled after an explicit flush; normal saving was then re-enabled. It contains the full world container, including `world/dimensions/minecraft/shacraft_lobby_v2`, both relevant plugins and the construction ledgers. World-file checksums are recorded in `checkpoint.json`. + +An undo must run newer stage ledgers before older ones and stop on manual-edit conflicts. Floating labels are separately managed entities; their operator commands are recorded with the stage artifacts rather than in the block journal. diff --git a/docs/SHACRAFT-LOBBY-LAYOUT.md b/docs/SHACRAFT-LOBBY-LAYOUT.md new file mode 100644 index 0000000..6531e96 --- /dev/null +++ b/docs/SHACRAFT-LOBBY-LAYOUT.md @@ -0,0 +1,68 @@ +# Shacraft lobby site marking + +This document preserves the **Stage 01 survey checkpoint**. The current world has progressed to [Stage 02: foundations and local roads for zones 01 and 02](SHACRAFT-FOUNDATIONS.md). + +The existing `shacraft_lobby_v2` alpine terrain is now marked for the future lobby. This is a site survey: building contours, entrances, courtyards, paths, bridge deck reservations, height stakes and wayfinding. Buildings, playable minigames and finished paths have not been constructed. + +![Live world map and layout legend](references/shacraft-layout-final.png) + +## Visit + +Connect to the private local server at `127.0.0.1:25575` using the **26.2 MCP Building** Prism profile, then use `/lobby`. Spectator mode is useful for inspecting the complete arrangement. The configured camera client can reconnect autonomously after a local server restart. + +The planning envelope is 768 × 768 blocks. Natural mountains frame the developed valley; the terrain was not flattened to fit district rectangles. Surface contours replace a single observed top block. Raised lines reserve future bridge, pier and airship elevations while leaving the river below intact. + +## Legend and coordinates + +Coordinates below identify the districts in X/Z. The same two-digit numbers are drawn with blocks; lit colored posts carry nearby floating names. + +- **01 · lime · arrival square · (0, 8).** Open hexagonal plaza and Shacraft medallion, with a direct view toward the clock station. +- **02 · yellow · clock station · (-18, -119).** Hall, projecting tower, end pavilions and a smaller oval forecourt. +- **03 · purple · portal concourse · (-201, -156).** Chamfered hall, six separate portal bays and a garden approach. +- **04 · cyan · airship harbor · (179, -137).** Terminal, three west-facing piers at Y79 and a flagship reservation at Y92. +- **05 · red · sky gardens · (207, 88).** Palm glasshouse, winter garden and observatory. White paths follow their surrounding courts. +- **06 · orange · market quarter · (-195, 225).** Six small building plots, an open market square and individual entrance connections. These plots use the calmer southern shoulder. +- **07 · white · arrival avenue · (8, 284).** Long southern approach and a final viaduct at Y65. +- **08 · blue · lake waterworks · (-135, 43).** Pumping house, viewing terrace, Y51 boardwalk and two marked future stair approaches. +- **09 · pink · scenic overlooks.** Five small viewpoints with optional trails, preserving the mountain slopes. + +White paired lines reserve path edges. Main avenues are designed for nine-block clear corridors, the district loop generally seven, and smaller access paths three to five. Colored building boundaries are reservations, not final material choices. White cross-ties and tall end stakes show bridge deck widths and elevations. Labels identify the larger level changes where stairs or staged access will be needed. The current block contours alone do not certify walking access. + +## What was placed and checked + +- 41 geometric reservations and 48 route segments, including six bridges, three airship piers, six portal bays and six market buildings. +- 16,967 distinct marker positions, applied with 17,040 checked writes across 36 journalled batches. The small second pass adds missing access links and extends bridge height stakes. +- Eight district labels and eleven access/elevation labels, verified again after the server restarted. +- Every batch was checked before planning, atomically against its caller snapshot during preparation, and after application. Stable operation keys and persisted receipts support resumption and checked undo. +- Full before/after world surface comparison and targeted reads beneath obscuring markers are recorded in the [verification report](references/shacraft-layout-verification.json). The map is read from actual Paper blocks, not reconstructed from the noise generator. + +![In-game view of the arrival reservation and station axis](references/shacraft-layout-arrival.png) + +## Autonomous map exporter + +The optional terrain plugin implements this **server-console** command: + +```text +lobby map survey-name +lobby status +``` + +It writes `plugins/ShacraftTerrain/maps/survey-name.png` and `.json`. The PNG has one pixel per world column, north up, with material colors and height shading. The JSON includes actual surface heights, material palette and coordinates. Existing names are never overwritten. The exporter loads only existing chunks, reads one chunk per tick, and renders/writes the detached result asynchronously. It requires no Minecraft client or account. + +The exporter reads chunks sequentially, so a map is not an atomic world snapshot. Construction was idle during the verification captures. A top surface also hides the water beneath a bridge; separate bounded RPC reads verified that water. Text display entities appear in game but are not included in this block-surface map. + +This is an orthographic world map. Perspective screenshots still use the Fabric spectator client and its heuristic render readiness. + +## Reproduce and revise + +The reusable [spatial plan](../examples/layout/shacraft-lobby-layout.json) and [access refinement](../examples/layout/shacraft-access.json) describe reservations, routes, heights and intent. Their geometry was studied against the natural terrain before live surface data was used for placement. + +- `scripts/mark-shacraft-layout.py` compiles the primary survey from a current surface map and authorized world scope. +- `scripts/compile-layout-access.py` compiles the access refinements against the original survey and known primary marker states. +- `scripts/layout.py report` describes bounded batches without touching a server. `prepare` checks the input without allocating expiring plans. `apply --execute` writes and verifies; `undo --execute` uses reverse journalled undo. +- `scripts/render-layout-map.py` renders the labelled atlas from live surface data. Supplying `--blocks` explicitly produces a labelled **design preview** instead. +- `scripts/verify-layout-survey.py` compares every visible column and checks hidden water through bounded live reads. + +The local applied ledgers are `.runtime/layout-study/placement-final.json` and `.runtime/layout-study/access-ledger.json`, with adjacent immutable input snapshots. Undo the access ledger before the primary ledger. Manual changes can block undo and must be preserved and reviewed. The 19 text displays are a separate operator-managed layer with tag `shacraft_layout_v1`; they are not part of the block journal. An explicit undo of the whole survey also requires removing that tagged display layer in the same world. + +A complete stopped-world backup, including plugin state and layout ledgers, is stored locally in `.runtime/projects/shacraft-marked-lobby-20260913/`. The earlier unmarked natural world remains backed up separately. Private configuration and authentication material are excluded from the public examples and report. diff --git a/docs/SHACRAFT-STATION.md b/docs/SHACRAFT-STATION.md new file mode 100644 index 0000000..61b7f7d --- /dev/null +++ b/docs/SHACRAFT-STATION.md @@ -0,0 +1,65 @@ +# Shacraft clock station — vestibule and SMASH gallery + +The clock station is built, furnished and verified in the local lobby world. Its two public floors contain the arrival vestibule and SMASH selection gallery, with enclosed personal lift cabins, warm architectural lighting and a closed island display. The complete world and matching plugin state are backed up. + +![Completed clock station in Minecraft](references/station-stage07/station-overview.png) + +The station occupies the existing irregular zone 02 foundation. Its 5,764 surveyed columns fit inside X **-74..40**, Z **-145..-83**. Pale stone arcades, green copper roofs, spruce ceilings, brass accents and warm lanterns continue the original clock-station reference. The roof silhouette rises from eaves at Y126 to a main ridge at Y140 and a clock-tower peak at Y158. Roof overhangs extend the complete building envelope to X **-76..42**, Z **-147..-81**. + +The coordinate drawings and generated perspectives remain in `docs/references/zone02-interior-v1/`. Those images establish the visual direction; the saved layout and actual foundation geometry govern placement. They are not photographs of the completed building. + +![South entrance and completed Shacraft nameplate](references/station-stage07/station-front.png) + +## Two complete public floors + +The vestibule floor occupies block Y98, with players standing at **Y99**. The SMASH floor occupies block Y112, with players standing at **Y113**. The intermediate deck at Y111..112 is continuous. Decorative coffer ceilings close each room; the upper structural ceiling at Y124..125 separates both public floors from the inaccessible roof and clock tower. + +The south entrance follows the existing X=-6 approach axis. Its clear opening spans X=-9..-3, retaining seven blocks of width. Matching square columns align vertically on both floors. Shallow cream corbels and spruce ceiling panels remain above the circulation space, while inset green and brass-colored floor bands guide the central approach. Side galleries contain spruce benches, small planted stone boxes and restrained floor mosaics. There are no additional unfinished public rooms, stairs to the attic or open shafts. + +The interior generator owns only the two-block setback inside the actual foundation, at block Y98..123. It leaves the exterior wall and window layers to the exterior compiler. Its 5,064 interior columns contain fifteen benches, ten small topiary planters and 48 hanging lanterns, with three additional miniature trees in the diorama. The complete recipe combines the exterior and interior before selecting only changed states against the captured baseline. + +Real Minecraft photographs prompted a lighting refinement. Sparse brown-glass floor tiles reveal concealed glowstone below the paving; matching small lenses recess into the plain spruce ceiling panels. The regular nine-block layout adjusts locally around beams, mosaics, furniture, controls and landing points. All lens and emitter replacements retain full-block collision and solid floor/ceiling separation. The enclosed diorama receives its own concealed lighting. + +![Furnished and illuminated ground-floor vestibule](references/station-stage07/station-vestibule.png) + +## Enclosed lift cabins + +The lift housing is **X=-10..-2, Z=-124..-116** on both floors. Each stationary cabin has a five-by-five clear interior at X=-8..-4, Z=-122..-118 and a five-block-wide south opening. Both cabin floors remain solid. The space above each cabin is closed, so the lift provides no passage to a shaft or unfinished upper rooms. + +Gold selector blocks sit on the north interior wall at **(-6, 101, -123)** and **(-6, 115, -123)**. Right-clicking a selector from inside the matching cabin changes floors for that player alone, arriving at **(-5.5, 113, -119.5)** or **(-5.5, 99, -119.5)** facing south. The listener checks the destination support and two air blocks before teleporting. The independent geometry audit checks four blocks of headroom. + +`/station` enters the ground-floor vestibule. `/station 1` and `/station 2` select the two completed cabin stops. The commands were exercised on the local server and both resulting player positions were read back; a third stop was rejected. The physical mouse-click path is covered by listener and cabin-selection tests but was not exercised through an automated client click. + +Zone 01's previous invisible boundary remains in place. Use the station entry command while that earlier garden enclosure is retained; opening the physical route is a separate checked edit. + +Twenty-four persistent text displays provide the exterior name, lift instructions, floor names, numbered arena placeholders and rules panels. A repeated installation replaced the previous set without increasing the count. These separately managed entities are recorded outside the block recipe. Arena placeholders do not perform world transfers or matchmaking. + +## SMASH gallery + +Six seven-by-five selection alcoves line the north gallery at Z=-140..-136, with centres at X **-42, -32, -22, -12, -2 and 8**. A seven-block-deep clear aisle at Z=-135..-129 connects them. Each alcove has a small decorative island relief, a framed display and a reachable selection surface. Slots **01–06 remain unassigned**; they do not advertise invented arena worlds, live queues or active minigames. + +The western display occupies **X=-42..-24, Z=-124..-110**, a 19-by-15 rectangle. Miniature planted islands stand above a solid illuminated base, enclosed by continuous pale stone and glass. Twelve full brown-glass floor lenses admit concealed light inside the display while maintaining its intact base. It is a closed display on an intact floor, with no playable void below it. The nearby west gallery explains damage, knockback and double jumping. The eastern wing provides waiting benches and an orange terracotta floor accent. No SMASH arenas are included in this construction stage. + +![Completed SMASH gallery and enclosed island display](references/station-stage07/station-smash.png) + +## Verification and preservation + +The completed stage required **84,446 checked writes across 98 batches** and has **83,924 final distinct changed states**. The final observed world matched every planned state. All **572,326 observed voxels outside the edits** also matched the baseline, completing the check of the full 656,250-voxel survey. + +Independent observed-block checks pass for **4,796 vestibule walking columns** and **4,176 SMASH walking columns**, each with four blocks of clear headroom, and all **107 named furnishing and lift approaches**. Those counts include the exterior doorway columns; the interior generator alone reports 4,775 vestibule columns. Both cabins, both lift doorways, the main approach and the complete selection aisle were checked voxel by voxel. The fixture audit passed for 72 planned lantern supports, 78 persistent leaf states and thirteen rooted station/display trees. + +The enclosure audit uses a temporary virtual cap at the intentional south entrance, then floods from both public floors and lift cabins. The cap is an analysis boundary, not a block placed across the doorway. Full glass and known full cubes stop the flood; partial and unknown shapes are treated as passable. The observed building has no connection from either furnished floor to the attic or clock tower. This static geometry audit does not prove lift behavior, spectator restrictions or administrative access control. + +The complete visible-surface comparison passed for all **589,824 map columns**: 6,496 changed station columns and 583,328 unchanged outside columns. All **41,467 previously visible water columns** were preserved. The map was captured before the last lighting and nameplate polish. A separate final voxel comparison confirmed that all 602 subsequent polish differences lie below the exported roof surfaces, and that the actual final top blocks and surveyed blocks above them match the map for every station column. + +The final plugin build passed 97 Maven tests; seven station-verifier tests and ten foundation tests also passed. World and surface captures are sequential rather than atomic. The four final photographs are actual Minecraft client captures with heuristic render readiness; their metadata does not claim a verified server revision. Observed block checks and runtime interaction receipts provide the separate measurement evidence. + +`scripts/station-exterior.py` and `scripts/station-interior.py` generate the geometry. `scripts/build-shacraft-station.py` combines their owned regions, checks the observed baseline against the prior foundation checkpoint and compiles expected-state edits. `scripts/light-shacraft-station.py` generates the concealed lighting grid against the observed built station. `scripts/verify-station.py` independently checks the resulting circulation, enclosure and captured volume. Stage records are kept under `.runtime/station-stage07/`. + +One interrupted batch verification exposed Minecraft's explicit `deepslate[axis=y]` state. The original receipt and manifest were retained. A fresh partial survey confirmed the applied prefix, and a separate remaining recipe continued from that observed state. This was a state-serialization correction at three miniature island tips; the retained recovery report records the exact positions and hashes. + +The authoritative final recipe and metadata are `.runtime/station-stage07/final-v2/station.json` and `station.metadata.json`. The final observation is `after-complete.json.gz`, with `actual-complete-qa.json`, `complete-surface-verification.json`, `complete-surface-invariance.json` and `runtime-verification.json` recording the checks. The owner was returned to the vestibule at **(-5.5, 99, -92.5)** with spectator mode preserved. + +The restorable backup is `.runtime/projects/shacraft-station-complete-20260913/`: **650 world and plugin files, 237,341,328 bytes**, with every recorded SHA-256 hash verified. Saving was flushed and temporarily disabled during copying, then automatic saving was re-enabled at 15:42:17 local time. `backup-receipt.json` records its location and verification. + +The local Git archive preserves the final documents, generator and verifier snapshots, compact reports, reference plans, Minecraft photographs and surface maps. Complete world saves, raw voxel observations, private configuration, authentication, plugin binaries and placement journals remain in the separate local runtime backup. Earlier reference and construction checkpoints remain unchanged. diff --git a/docs/SHACRAFT-ZONE01-COMPLETE.md b/docs/SHACRAFT-ZONE01-COMPLETE.md new file mode 100644 index 0000000..6690ccb --- /dev/null +++ b/docs/SHACRAFT-ZONE01-COMPLETE.md @@ -0,0 +1,53 @@ +# Shacraft zone 01 — finishing details and invisible boundary + +The arrival square is finished and its five approaches are closed with an invisible collision boundary. Players can explore the completed garden while the station and other districts remain outside the current playable area. The existing emblem, six planting islands, benches, lights and broad central sightline remain the focus. + +![Completed arrival square in Minecraft](references/shacraft-zone01-overview.png) + +## Visible finish + +Two low pale-stone urns with white flowers stand at X/Z **(-10, 35)** and **(10, 35)**. A low green nameboard at **(-8, 43)** carries `SHACRAFT` and `Площадь прибытия`. Its text is a separately managed display entity attached visually to the board, with no directional arrows toward closed roads. + +Five flush threshold bands use stone with small green-copper insets at the north, south, east, west and northwest approaches. Paving remains at block Y95, with the walking plane at Y96. Three full sandstone joint piers complete the road-to-plaza boundary, bringing the perimeter pier count to 49. + +The visible recipe contains 106 changed block states, followed by a sixteen-write urn refinement. Together with the enclosure, the editor made **11,518 checked writes in twenty batches**. All **11,502 final distinct changed states** matched the final observed world. + +![Welcome nameboard and planted stone urns](references/shacraft-zone01-welcome.png) + +## Invisible enclosure + +The boundary uses **11,396 barrier blocks**, recorded in a separate placement ledger so the enclosure can later be removed while keeping the garden finish. It includes side walls and a roof at block **Y116**, above the existing trees. The full-block floor at Y95 contributes to the enclosure. + +The compiler bends the boundary inward around existing partial-block stairs and rails instead of replacing those details. The intended interior excludes 87 boundary-adjacent voxels for these local folds. The independently derived collision membrane contains **17,276 full-cube positions**: 11,396 barriers and 5,880 existing or finished structural blocks. + +The [final observed containment report](references/shacraft-zone01-containment-verification.json) passed. The intended interior volume has one connected component containing 117,673 voxels. No interior voxel was reached from an exterior flood, and all **5,748 swept player-box boundary probes** were blocked. The probes use a 0.6 × 1.8 block player body; the complete static containment criterion is the full independently derived membrane. Unknown and partial blocks are conservatively treated as empty during the exterior flood. + +This guarantee concerns normal continuous collision-based movement. Spectator mode, block removal, operator commands, plugin teleports and arbitrary discontinuous teleport or pearl behavior are outside its scope. The boundary is not an administrative access-control system. + +![Arrival from the configured point inside the enclosure](references/shacraft-zone01-arrival.png) + +## Arrival, respawn and maps + +The configured `/lobby` destination is **(0.5, 96, 43.5)**, inside the southern welcome area. A scoped death-respawn handler returns players who died in the lobby world to that configured point, preventing Minecraft's highest-surface search from placing them on the invisible roof. It leaves other worlds and non-death transitions alone. The lobby spawn radius is zero. The scoped event behavior was unit tested; no player was killed for verification. + +The owner was returned to creative mode at the verified arrival point after inspection. The nameboard entity, spawn settings and owner return are recorded separately from the block journals in `operator-actions.json`. + +The server map exporter explicitly ignores `minecraft:barrier`, alongside air, when finding each visible surface. Exported metadata declares this policy, so maps show the garden beneath its invisible roof. Collision verification reads actual blocks separately and includes barriers. + +![Actual visible-surface map, with invisible barriers excluded by policy](references/shacraft-zone01-map.png) + +## Final verification and preservation + +The complete **622,098-voxel captured volume** matched the baseline plus final plan, including **610,596 voxels outside the edits**. The finishing audit passed for 537 persistent leaves, 260 supported flowers, sixteen lanterns, twelve rooted trees, **4,794 walking/headroom samples** and 32 bench-access columns. + +The complete visible-surface comparison matched all **589,824 map columns**. It covered 6,164 affected columns, with 87 visible surface changes; all 583,660 columns outside the affected area matched their baseline. All 41,467 previously visible water columns remained unchanged. The distinction between actual collision blocks and the declared visible-surface policy is preserved in the separate reports. + +These sequential captures are not atomic snapshots. The full-volume statement applies to the supplied observed bounds, and static geometric checks are not a human playtest. Perspective images are actual Minecraft client captures with heuristic render readiness. + +`scripts/finish-shacraft-zone01.py` compiles the visible finish, enclosure and boundary metadata. `scripts/verify-zone-containment.py` independently derives the collision membrane, checks containment and protects existing decoration. Stage records remain under `.runtime/zone01-stage05/`. + +The barrier ledger is separate from the finishing and urn-polish ledgers. Remove barriers through their checked ledger when reopening the other districts; the visible garden finish can remain. Undo the urn polish before undoing the earlier visible finish. The nameboard text and scoped spawn settings are separately recorded operator/configuration actions. + +The restorable backup is `.runtime/projects/shacraft-zone01-complete-20260913/`. The complete world container and matching plugin/receipt state were copied with saving temporarily disabled after an explicit flush, then automatic saving was re-enabled. All 137 recorded world-file hashes passed verification. + +The local Git archive preserves compact design and final reports, source snapshots, maps, photographs and the world-backup location. Raw voxel observations, complete world saves, private configuration, authentication, plugin binaries and journals remain outside Git. All previous references and construction checkpoints remain unchanged. diff --git a/docs/TERRAFORMING.md b/docs/TERRAFORMING.md new file mode 100644 index 0000000..3687ee5 --- /dev/null +++ b/docs/TERRAFORMING.md @@ -0,0 +1,115 @@ +# Terraforming tools + +The terrain toolkit turns one small JSON recipe into a repeatable height field, a raster preview, and bounded edit plans. It prepares the terrain foundation for the Shacraft lobby: mountain ridges, cliff-backed building platforms, a lake bed, garden terraces and ravines. Buildings and fluids are separate work. + +## Design and boundaries + +A recipe describes an **absolute target surface**, not a brush that reads and smooths the existing world. Features operate in the listed order on `base_height` plus seeded three-octave value noise. World coordinates determine the noise and surface layers; neither tile boundaries nor a cropped envelope change their phase. The write envelope is inclusive and clips writes, not the underlying field. + +Implemented features: + +- `hill`: adds `height` inside a circular core, with a smooth outer falloff. A negative height makes a depression. +- `ridge`: adds `height` along a polyline. `width` is the core half-width; repeated points are allowed. +- `plateau`: blends to absolute `height` inside a rectangular footprint and outward through `falloff`. Place these after noise/hills to obtain level building pads. +- `basin`: only lowers the surface to absolute `height` inside a circular core and banks. It never raises a low area. +- `channel`: only lowers to absolute `height` along a polyline, with a flat bed and smooth banks. +- `terrace`: blends toward elevations rounded down to multiples of `step`; `strength` is 0..1. This affects the whole field at that step in the recipe, so subsequent plateaus can restore exact platform heights. + +`radius` and `width` describe a flat core radius/half-width in blocks. `falloff` is the additional outside transition distance; all distances are positive. A small core with a large falloff produces a rounded hill. Polyline paths may have 2–32 points. Heights can be negative. All feature heights are absolute except the additive hill/ridge heights. + +Modes: + +- `sculpt`: terrain material below the target surface, air above it, within the envelope. +- `fill`: targets only cells at/below the target surface. It can replace natural material there, but does not clear above it. +- `cut`: targets only air above the target surface. It does not add terrain or repaint the remaining surface. + +The palette contains `rock`, `soil`, `surface`, and `soil_depth` (0–16). The top block uses `surface`, the next `soil_depth` blocks use `soil`, and deeper cells use `rock`. Layering follows the global surface even when it is outside a particular vertical tile. No new topsoil is invented at a tile boundary. + +Supported natural materials: stone, andesite, granite, diorite, deepslate, cobbled deepslate, dirt, grass block, moss block, sandstone and terracotta. Cave/void air can also be replaced. Bedrock, water, lava, trees, buildings made of other materials, and block entities are rejected. **Natural-material buildings cannot be distinguished from terrain by material alone.** Exclude existing work with inclusive 3D `preserve` boxes and use the existing part protection system. A preserve mask omits writes; it does not infer or protect the structural supports beneath a build. Include its foundations and a suitable margin. + +This first implementation is a 2.5D height field. It does not create caves, overhangs, imported heightmaps, vegetation, biomes, automatic erosion or fluid flow. Lake beds and ravines are dry. Neighbor/environment checks still apply. Terraforming does not weaken the normal write or undo policy. + +## MCP workflow + +1. Call `project_context` and inspect the intended site. Select a dedicated area before writing. +2. Call `terrain_preview` with `recipe` and optional `resolution` (32–256, default 128). It does not read or alter world blocks. The native PNG shows the target field; it is not a Minecraft screenshot or a before/after comparison. North (-Z) is up. Pink marks a preserved cell at the sampled surface. Narrow/subsurface masks may not appear in the sampled image. +3. Keep the returned `terrain_id`, `tile_count`, and `tile_budget`. Save the recipe locally. The ID is a SHA-256 of the recipe with sorted object keys; feature order remains significant. The plugin caches up to 32 recipes until restart. Regenerate a preview from the same JSON after eviction/restart. +4. Call `terrain_prepare(terrain_id, tile_index)`. Indices start at zero. Tiles advance X first, then Z, then Y, from the envelope minimum. At the normal 4096-block budget, tiles are at most 16×16×16. A smaller configured budget changes their edge and numbering; never resume a batch under a different budget. +5. Review `tile_bounds` and `changed_blocks`. `status: empty` means there are no changed targets, either because of the mask/mode or because the sampled targets already match. No plan is journaled for these tiles. Otherwise use the returned `plan_id` and `plan_hash` with `build_apply` and a stable unique `idempotency_key`. +6. Poll `operation_status` to completion. Stop on conflict, cancelled, failed or recovery-required status. Do not prepare a replacement to force a conflicting tile through. +7. Undo with `operation_undo_prepare` and normal checked apply. For a batch, reverse the operation order. Cancellation/failure can leave partial edits; tiles are not one atomic transaction. + +Recipes are bounded to 64 features, 64 preserve boxes, 2048 blocks per horizontal side, 1,048,576 columns, 384 blocks vertically, and 134,217,728 voxels in the envelope. Preview statistics are sampled, not exhaustive extrema. `clipped_samples` reports surfaces outside the vertical write envelope; such surfaces need a wider envelope or deliberate clipping. + +The terrain compiler and preview renderer run outside the server tick. Live reads, natural-material checks, area checks, protected parts and plan snapshots run through Paper's main thread. Writes use the existing sliced, durable intent/receipt journal. Manual modifications after preparation are checked again on application; existing undo ownership rules remain in force. Only already-loaded chunks may be edited; this toolkit does not silently generate or force-load a whole map. + +## Local tools and examples + +Build once with `./mvnw package` and `npm --prefix bridge test`. Offline preview needs the Java runtime installed by the project's bootstrap script; `MCB_JAVA_HOME` can override its path. + +Render the proposed Shacraft terrain without a running server: + +```bash +python3 scripts/terrain.py preview examples/terrain/shacraft-massif.json \ + --output docs/references/shacraft-terrain-heightmap.png +``` + +![Generated target heightmap](references/shacraft-terrain-heightmap.png) + +The example proposes a 768×768×192 envelope and therefore 27,648 possible tiles at 4096 blocks each. This is a layout experiment, not an instruction to apply all tiles to the current world. It must be assigned a new, suitable project area before use. The current Gothic hall site is not a valid destination for it. + +For an authorized small site, a local batch avoids sending every tile's polling traffic through model context. This Linux runner uses an exclusive manifest lock, saves requests before sending them, stops on conflicts and processes at most 64 explicitly selected tiles per invocation: + +```bash +python3 scripts/terrain.py apply examples/terrain/small-hill.json \ + --start 0 --count 1 --manifest .runtime/my-terrain-batch.json --execute + +# Resume the exact same command and manifest after a transport interruption. +# Do not delete or edit a manifest to bypass a conflict. + +python3 scripts/terrain.py undo --manifest .runtime/my-terrain-batch.json --execute +``` + +The small example requires a loaded, authorized all-air test area at x/z 0..7, y 80..87. It is not automatically applied. The default backend configuration belongs to the local development server; `--config` selects another local plugin config. `--console` is only for an isolated server explicitly configured with `allow-local-automation: true`. Credentials never enter the manifest or printed results. + +A manifest binds the recipe, project, world, epoch, tile budget, range, immutable plans, stable keys, operation IDs and undo progress. Lost apply responses are resolved by resuming the original apply with its saved key. Undo refuses to start an unconfirmed apply merely to discover whether it ran. Expired plans and recovery-required operations require inspection; the runner does not regenerate or force them automatically. An interrupted undo resumes from its recorded reverse plans. Once undo has started, the manifest cannot be reused for new construction. + +## Verification and current scale limit + +Automated tests cover ordered features, negative elevations/coordinates, deterministic noise, horizontal and vertical tiling, layer continuity, preserve masks, input/resource rejection, PNG metadata, real journal apply/undo/conflicts, native MCP transport and validation, and persistence before a potentially lost apply response. + +The opt-in `bridge/test/live-terrain.mjs` exercises the real MCP → Paper path in an isolated fixture: preview without writes, a 201-block hill, idempotent replay, checked undo, protected existing gold block, preserved cell, stale-plan conflict, empty mask, area rejection and unknown recipe ID. The local batch runner is also tested by apply → resume → undo with full air restoration. + +A compact recipe and native PNG reduce context cost; they do not eliminate server work. Large sites still need many live reads and potentially large journals. No full 768×768 application, throughput target or crash/disk benchmark has been completed. This toolkit is ready for small terrain sections and progressive landscape work; a whole-map rollout should be benchmarked and staged separately. + +## Relative brushes on existing terrain + +`terrain_brush_prepare` reads an actual bounded world snapshot, calculates a circular brush, and returns an immutable plan together with a native **BEFORE / AFTER / HEIGHT CHANGE** image. It does not edit the world. The existing `build_apply`, `operation_status`, cancellation and checked undo tools apply the result. `plan_state: empty` means that the brush has no effect and there is no plan to apply; the image is still returned. `source: live_snapshot` identifies this preview, in contrast to an absolute recipe preview that has no world reads. The image is a height diagram, not a Minecraft camera capture. + +Input is `{ "brush": { ... } }`. The example [relative-brush.json](../examples/terrain/relative-brush.json) is for the isolated live-test terrain at Y=84; choose bounds from the actual project before using it elsewhere. + +- `min/max`: inclusive scan and edit window, at most **4096 cells**. Every scanned column must contain natural terrain with air above it. Include the full circular footprint, enough depth for cutting, and air above both the old and proposed top. If this window is too small, the tool rejects the request instead of silently clipping the effect or guessing a surface beyond its boundary. +- `center`: integer `{x,z}` in world coordinates. `radius`: 1–16 blocks; the volume and halo constraints can require a smaller radius. +- `action: raise | lower`: `amount` is a positive integer, 1–32 blocks, relative to each original column's own elevation. +- `action: flatten`: `height` is the absolute target Y coordinate. It can raise low ground and cut high ground in the same stroke. +- `action: smooth`: the target is the uniform average of the original heights in a square `(2*smooth_radius+1)` neighborhood. `smooth_radius` is 1–3, default 1. Include that halo beyond the circular footprint on every side. All targets use the same original snapshot, so smoothing is independent of processing order. For another smoothing pass, inspect the completed result and create a new stroke deliberately. +- `strength`: 0–1, default 1, scales the vertical displacement. Block displacements round symmetrically away from zero at half a block; very weak strokes may have no effect. +- `falloff`: 0–1, default 0.5, is the fraction of the radius used for the smooth outer fade. Zero makes a hard edge. With a positive falloff the effect reaches zero at the outer boundary; the inner core has full strength. +- `preserve`: up to 64 inclusive boxes inside the scan window. If a box intersects any cell in a column's proposed vertical edit, that whole column is left unchanged to avoid tearing a hole around the mask. + +Raised columns move the original top block to the new surface and extend the material immediately below the old top. If that material is unavailable/air, grass or moss falls back to dirt; other natural tops extend themselves. Lowered columns remove material above the target and put the original surface material on the new top. Cutting refuses to cap a void inside the snapshot. There is no general soil-depth simulation or geological layer translation. + +All scanned cells, including unchanged columns and the smoothing halo, are stored as **read dependencies** in the plan. The engine rechecks them before slices and after journal IO, accounting for this operation's own writes. A changed dependency stops the brush; earlier completed slices can remain and must be inspected. This is a checked operation, not an atomic whole-region transaction. The Paper dependency budget is 4096 for brushes; the separate explicit dependency input to `build_prepare` remains capped at 512. Full snapshots stay on the server and in the journal; the model receives the image and compact statistics. + +The whole scan window must contain only the supported natural materials/air, including in preserved and halo columns. Structures, fluids, bedrock, vegetation and unsupported blocks in that window reject the brush. Masks do not bypass this scan policy. Natural stone constructions need explicit exclusion or protected parts, just as with absolute recipes. Only loaded chunks inside the selected project are read. These brushes find the uppermost surface **inside the provided window**; they do not prove that there is no separate roof or terrain above that window. + +Overlapping strokes retain the editor's strict undo-ownership rules: undoing a newer stroke does not automatically restore an older operation's right to undo the same blocks. This is not a multi-stroke undo stack. Never force an old undo or silently recreate a plan after a conflict. + +In-game requests can be ordinary `/ai` text, for example: + +```text +/ai Raise the existing terrain in front of me by 3 blocks, radius 3, with a soft edge. Use terrain_brush_prepare, verify the scan area is free of structures, then apply and check the result. +/ai Smooth this slope with a small relative terrain brush. Preserve nearby buildings. +``` + +The opt-in `bridge/test/live-brush.mjs` verifies the real MCP → Paper route for raise, lower, flatten and smooth; preview before writes, all-snapshot dependencies, checked undo, a conflict caused by a halo edit, no-effect plans and complete isolated-fixture cleanup. It saves `docs/references/terrain-brush-live-preview.png` from the actual prepared plan. Core tests also cover symmetric falloff, scan/halo limits, clipping rejection, preserve columns and a dependency change during journal persistence. diff --git a/docs/recipes/decorations.json b/docs/recipes/decorations.json new file mode 100644 index 0000000..b6102b1 --- /dev/null +++ b/docs/recipes/decorations.json @@ -0,0 +1,201 @@ +{ + "schema_version": 1, + "kind": "design_reference_catalog", + "reviewed_on": "2026-09-13", + "project": "Shacraft lobby", + "status": "Original design proposals; not executable build_prepare recipes and not newly built or playtested.", + "units": "Dimensions are width (X), height (Y), depth (Z) in blocks. Envelopes exclude separately specified circulation and buried support. Front is -Z; +Z points into the object. Adapt to surveyed world coordinates and rotate directional block states with the geometry.", + "capability_basis": "Material names checked against the saved Stage 07 project_context palette on 2026-09-13. Re-read live project_context before building. Material availability does not validate states, placement support or geometry.", + "source_notes": "source_ids identify conceptual inspiration. All dimensions, placement sequences and Shacraft adaptations below are original proposals, not source schematics.", + "sources": [ + {"id":"S01","title":"Interior Motives","url":"https://www.minecraft.net/en-us/article/interior-motives","use":"Purposeful interiors and agreement between interior and exterior architecture."}, + {"id":"S02","title":"Tranquil Towers","url":"https://www.minecraft.net/en-us/article/tranquil-towers","use":"Develop roof shape and silhouette before details."}, + {"id":"S03","title":"The Sky is No Limit","url":"https://www.minecraft.net/en-us/article/sky-no-limit","use":"Consistent palette and simple textures for a large composition."}, + {"id":"S04","title":"Taking Inventory: Glass Pane","url":"https://www.minecraft.net/en-us/article/taking-inventory--glass-pane","use":"Window depth; our sealed variant uses recessed full glass."}, + {"id":"S05","title":"Build With It: Quartz","url":"https://www.minecraft.net/en-us/article/build-with-it--quartz","use":"A pale material family can connect architecture and furnishings."}, + {"id":"S06","title":"Fantastic Furniture","url":"https://www.minecraft.net/en-us/article/fantastic-furniture","use":"Habitable furniture groups and independently finished partition faces."}, + {"id":"S07","title":"Cozy Chambers","url":"https://www.minecraft.net/en-us/article/cozy-chambers","use":"Wood, plants and lighting soften stone and copper interiors."}, + {"id":"S08","title":"Block of the Month: Decorated Pot","url":"https://www.minecraft.net/en-us/article/decorated-pot","use":"Flowerpot above decorated pot is a future material option, not supported by the current palette."}, + {"id":"S09","title":"Tutorial: Tips For Landscaping and Terraforming","url":"https://www.minecraft.net/en-us/article/tutorial--tips-for-landscaping-and-terraforming","use":"BlueNerd: connect paths, banks, vegetation and shaped terrain."}, + {"id":"S10","title":"Five simple path designs","url":"https://www.minecraft.net/en-us/article/five-simple-path-designs","use":"Zaypixel: route materials and nearby objects communicate place."}, + {"id":"S11","title":"Tutorial: Cottagecore Decor","url":"https://www.minecraft.net/en-us/article/tutorial--cottagecore-decor","use":"Kelpie the Fox: outdoor places to rest; reference uses Mizuno's 16 Craft."}, + {"id":"S12","title":"Taking Inventory: Lantern","url":"https://www.minecraft.net/en-us/article/taking-inventory--lantern","use":"Lanterns as recurring fixtures; historical spawning advice is not used."}, + {"id":"S13","title":"Build With It: Cobblestone!","url":"https://www.minecraft.net/en-us/article/build-with-it--cobblestone-","use":"Moss and vegetation can suggest age in masonry."}, + {"id":"S14","title":"Caves & Cliffs: Part I out today on Java","url":"https://www.minecraft.net/en-us/article/caves---cliffs--part-i-out-today-java","use":"Waxed copper, tinted glass light blocking, and introduced decorative blocks."}, + {"id":"S15","title":"Minecraft Java Edition 1.21.5: Spring to Life","url":"https://feedback.minecraft.net/hc/en-us/articles/35298208390797-Minecraft-Java-Edition-1-21-5-Spring-to-Life","use":"New plant orientations/densities and low-emission firefly bushes; not current MCP capabilities."}, + {"id":"S16","title":"Build with It: Diorite!","url":"https://www.minecraft.net/en-us/article/build-it--diorite-","use":"Texture intensity matters; polished variants can produce calmer surfaces."}, + {"id":"S17","title":"Minecraft Java Edition 26.2","url":"https://www.minecraft.net/en-us/article/minecraft-java-edition-26-2","use":"Current game has additional material families; game availability is distinct from MCP support."} + ], + "recipes": [ + { + "id":"arched-window-bay","category":"facade","name":"Recessed arched window bay", + "size":{"width":7,"height":10,"depth":3}, + "purpose":"Give a large cream facade readable shadow and a consistent structural rhythm.", + "materials":["minecraft:smooth_sandstone","minecraft:cut_sandstone","minecraft:smooth_sandstone_stairs","minecraft:smooth_sandstone_slab","minecraft:smooth_quartz","minecraft:brown_stained_glass"], + "steps":["Reserve a seven-wide bay between the actual structural axes; keep adjacent columns and floor decks intact.","At the glazing plane, use five-wide glass rows for the straight portion, then three and one at the crown. Fill every surrounding cell with full masonry so the plane stays sealed.","Place glazing one block behind the front wall plane; use the remaining front depth for a sill and paired jamb brackets.","Use sandstone stairs only as outer trim. Maintain full masonry or full glass behind partial-block trim.","Repeat the approved bay on the established grid; redesign corners and the main doorway deliberately."], + "clearance":"Keep projections outside the existing public route and its four-block headroom; do not narrow the seven-block station entry.", + "variants":["Plain rectangular head on a secondary facade.","One gold accent above the principal entrance only."], + "acceptance":["Closure plane has no unfilled cells, including arch shoulders.","Recess reads from a diagonal player-height camera.","No sill hides a sign or clips furniture.","Stair rotation and corner shape checked after neighboring blocks update."], + "avoid":["Different window silhouettes in every bay.","Assuming a pane or stair is a full containment wall."], + "source_ids":["S03","S04","S05"] + }, + { + "id":"cornice-bracket","category":"facade","name":"Cornice and paired bracket", + "size":{"width":9,"height":3,"depth":2}, + "purpose":"Explain the floor/eave line without filling the wall with ornament.", + "materials":["minecraft:smooth_sandstone","minecraft:smooth_sandstone_stairs","minecraft:smooth_sandstone_slab","minecraft:smooth_quartz"], + "steps":["Place a continuous stone band on the existing floor or eave line.","Use an inverted-stair underside and a thin slab cap, with at most two blocks of total outward depth.","Put the two bracket groups over structural supports and leave the central field quiet.","Continue the band around corners with an explicitly resolved return; prototype the corner before repeating."], + "clearance":"Remain above route headroom and outside signs' viewing rays; preserve roof drainage shapes and window tops.", + "variants":["Single-block projection on small wings.","A stronger cap at the tower base, using the same materials."], + "acceptance":["Horizontal band remains readable from the main approach.","Brackets align with actual supports.","Cap does not overwhelm the window opening."], + "avoid":["A thick shelf over every horizontal edge.","Unrelated gold trim on every bracket."], + "source_ids":["S02","S03","S05"] + }, + { + "id":"closed-dormer","category":"roof","name":"Closed green-copper dormer", + "size":{"width":7,"height":7,"depth":7}, + "purpose":"Add a secondary roof feature while keeping the clock tower dominant.", + "materials":["minecraft:smooth_sandstone","minecraft:brown_stained_glass","minecraft:waxed_oxidized_cut_copper","minecraft:waxed_oxidized_cut_copper_stairs","minecraft:waxed_oxidized_cut_copper_slab"], + "steps":["Fit a seven-wide sample to the actual roof pitch; use cream side cheeks and three-wide recessed glass.","Build the green pitched cap with a one-block overhang.","Keep a solid back and floor closure behind the apparent window; retain the existing public ceiling.","Test one dormer from the main approach before any repetition. Consider 14-18 blocks between centers only where the measured structural grid permits it."], + "clearance":"No opening into an unfinished attic, no overlap with tower faces or main roof ridge.", + "variants":["One larger central feature on a secondary wing.","Omit the dormer if it competes with the clock silhouette."], + "acceptance":["Tower remains the primary skyline feature.","Quiet copper roof planes remain visible between details.","Full closure survives the added roof cutout."], + "avoid":["Treating proposed spacing as a universal rule.","Adding attic access as part of a cosmetic edit."], + "source_ids":["S02","S03"] + }, + { + "id":"waiting-pocket","category":"interior","name":"Paired waiting benches", + "size":{"width":9,"height":4,"depth":7}, + "purpose":"Create a believable place to wait beside the main hall.", + "materials":["minecraft:spruce_stairs","minecraft:spruce_slab","minecraft:spruce_planks","minecraft:smooth_sandstone","minecraft:green_concrete","minecraft:lantern","minecraft:oak_leaves","minecraft:dirt"], + "steps":["Place two three-seat spruce benches facing a shared open pocket, with cream end supports.","Keep a compact table at one side rather than on the main desire line.","Use a flush green paving inset to group the furniture; this substitutes for unsupported carpet.","Add one planter and one supported lantern near the back, leaving the front visually open."], + "clearance":"Reserve an additional three-block-wide clear passing strip and four blocks of route headroom. Existing wider routes retain their width.", + "variants":["Single bench and planter in a smaller side recess.","Mirror the group around the room axis while varying the small prop."], + "acceptance":["Lift sign remains visible from the approach.","Seat fronts and table can be approached without stepping onto furniture.","Lamp and leaves remain stable after updates."], + "avoid":["Rows of isolated decorative chairs.","Plants in front of navigation signs."], + "source_ids":["S01","S06","S07"] + }, + { + "id":"information-counter","category":"interior","name":"Information counter", + "size":{"width":9,"height":5,"depth":4}, + "purpose":"Provide a clear information point that belongs in a station.", + "materials":["minecraft:spruce_planks","minecraft:spruce_slab","minecraft:smooth_sandstone","minecraft:smooth_sandstone_slab","minecraft:green_concrete","minecraft:glowstone","minecraft:brown_stained_glass"], + "steps":["Frame a seven-block counter between cream end piers; use spruce body and a thin cream slab top.","Align one green sign backing and one lit recess behind the counter.","Keep the back wall solid. Any future staff door remains a finished wall panel until the room exists.","Add actual text only through an existing authorized label mechanism; the block catalog does not create text entities."], + "clearance":"Reserve three clear queue blocks in front, separated from the entrance-to-lift route and its full width.", + "variants":["Short five-wide counter for a side room.","A small gold emblem only above the principal information point."], + "acceptance":["Queue does not cross the main route.","Counter is recognizable before reading the sign.","No false usable door into unfinished space."], + "avoid":["Several equally bright competing headers.","Claiming matchmaking or staff interaction exists merely because the counter is built."], + "source_ids":["S01","S05","S06"] + }, + { + "id":"smash-exhibit","category":"interior","name":"SMASH exhibit niche", + "size":{"width":7,"height":6,"depth":5}, + "purpose":"Communicate floating islands and knockback through one readable display.", + "materials":["minecraft:deepslate","minecraft:smooth_sandstone","minecraft:green_concrete","minecraft:gold_block","minecraft:grass_block","minecraft:dirt","minecraft:glowstone","minecraft:brown_stained_glass"], + "steps":["Build a full dark base, full back and light stone frame around a five-wide exhibit.","Choose one miniature island or one abstract impact motif as the subject.","Put one small gold focal detail near the subject and keep the rest of the frame consistent with adjacent niches.","Light the subject from concealed side, base or overhead recesses; preserve full-block base and closure.","Use one shared frame for all six future arena places; vary the subject and real destination text only when available."], + "clearance":"Reserve a three-block viewing strip outside the main circulation aisle; preserve the existing selection aisle.", + "variants":["Closed trophy pedestal with a single object.","Small landscape model for a future named arena."], + "acceptance":["Subject is legible from player eye level without entering the display.","No dark face hides the model's silhouette.","Placeholders clearly remain unassigned."], + "avoid":["Six unrelated palettes.","A visual void that is also an accidental physical hole."], + "source_ids":["S01","S06","S07"] + }, + { + "id":"avenue-edge","category":"path","name":"Formal avenue edge", + "size":{"width":9,"height":1,"depth":12}, + "purpose":"Connect station stonework to the garden through a continuous legible route.", + "materials":["minecraft:stone_bricks","minecraft:smooth_stone","minecraft:smooth_sandstone","minecraft:mossy_stone_bricks"], + "steps":["Keep seven center columns as continuous clear paving and one flush border column on each side.","Survey elevations along the whole segment before decoration; grade transitions as separate geometry.","Place furniture in additional two-to-three-block shoulders or wider pockets, not inside the nine-block strip.","Keep the station approach clean. Introduce small connected worn patches at outer edges beside planting.","Widen at entrances and follow actual destinations rather than adding arbitrary bends."], + "clearance":"Seven-block clear walking width and four-block headroom; this example must not narrow a wider established route.", + "variants":["Use local gray stone farther from the station.","Curve the outer border while keeping the tread continuous."], + "acceptance":["Entire route walkable at all grade changes.","Flush borders do not introduce a lip.","No lamp or planter creates a pinch point."], + "avoid":["Uniform random scatter of five paving materials.","Applying an organic-path zigzag rule to a formal station axis."], + "source_ids":["S09","S10","S13"] + }, + { + "id":"copper-lantern","category":"lighting","name":"Supported copper-arm lantern", + "size":{"width":3,"height":7,"depth":1}, + "purpose":"Establish one recognizable outdoor fixture family.", + "materials":["minecraft:stone_bricks","minecraft:stripped_spruce_log","minecraft:waxed_oxidized_cut_copper","minecraft:iron_chain","minecraft:lantern"], + "steps":["With the sample's first above-ground cell at y=0, place a full stone foot at (0,0,0), rooted into surveyed ground below.","Raise a post at x=0,z=0 through y=1..5; place a full-block copper arm across x=0..2 at y=6.","Place a vertical iron chain at (2,5,0) attached beneath the full arm, then a hanging lantern at (2,4,0).", + "Place support before attached details and inspect resulting block states.","Keep the entire post outside the clear path; use a mirrored pair only at a major entry."], + "clearance":"Four empty cells below the hanging lantern if ground is level with the sample. Recalculate for actual terrain; do not assume the same clearance over a rising stair.", + "variants":["Shorter ground-mounted fixture in a planted pocket with no walk-under route.","One gold marker at a major junction, outside this base recipe."], + "acceptance":["Full arm, chain axis and hanging lantern state remain valid after updates.","Night view shows the next step, intersection and sign between lamps.","Post cannot be used to bypass an existing containment boundary."], + "avoid":["Unsupported lanterns.","Treating a fixed lamp spacing as guaranteed illumination coverage."], + "source_ids":["S11","S12","S14"] + }, + { + "id":"layered-planter","category":"planting","name":"Layered planting pocket", + "size":{"width":7,"height":6,"depth":5}, + "purpose":"Produce a planted mass with a readable front, middle and background.", + "materials":["minecraft:dirt","minecraft:grass_block","minecraft:spruce_log","minecraft:spruce_leaves","minecraft:oak_leaves","minecraft:white_tulip","minecraft:pink_tulip","minecraft:oxeye_daisy","minecraft:smooth_sandstone"], + "steps":["Shape a low cream rim with solid soil behind it; support the entire pocket down to the existing terrain.","Put one small rooted tree toward the rear and two unequal shrub groups below it.","Use one dominant foliage species and two restrained flower colors near the front.","Leave patches of visible grass between groups; taper density toward the edge.","Save a seed and cluster anchors for repeatable variation; change group positions rather than every block independently."], + "clearance":"Keep the highest mass outside entrance and sign sightlines; no leaves overhang the protected walking clearance.", + "variants":["Formal clipped evergreen beside the station.","Looser shrubs without a tree beside the mountain path."], + "acceptance":["Flowers have valid soil.","Leaves use persistent=true and a stable read-back distance state.","Trunk has stable soil under it.","Foliage tint checked in the actual biome."], + "avoid":["A flower on every grass block.","Using leaves as the only containment boundary."], + "source_ids":["S07","S09","S11"] + }, + { + "id":"shore-pocket","category":"landscape","name":"Quiet shore with a stony recess", + "size":{"width":12,"height":4,"depth":8}, + "purpose":"Connect existing water and terrain with a believable local bank.", + "materials":["minecraft:stone","minecraft:andesite","minecraft:cobblestone","minecraft:mossy_cobblestone","minecraft:dirt","minecraft:grass_block","minecraft:oak_leaves"], + "steps":["Sample a short existing shoreline together with its water level and submerged bed.","Shape one quiet bank and one small stony recess; maintain continuous ground beneath the visible soil skin.","Put exposed rock at the outcrop and moss only in selected damp recesses.","Keep vegetation in sheltered land pockets and leave a clear view of the water.","This catalog specifies only land-side decor. Preserve water cells and the connected bed; stop if execution requires fluid placement or an unsupported brush through water."], + "clearance":"Preserve public route width, foundations, water level and any existing waterfront containment.", + "variants":["Grass bank with one isolated outcrop.","Stone recess beside a retaining wall, without changing its footing."], + "acceptance":["Water network and visible level preserved.","No new flow reaches a road or foundation after updates.","A below-water view shows a continuous bed; top-down appearance alone is insufficient."], + "avoid":["Repeating identical coves as tiles.","Assuming water exists in the MCP material palette."], + "source_ids":["S09","S13"] + }, + { + "id":"retaining-stair","category":"path","name":"Retaining wall and short stair", + "size":{"width":9,"height":5,"depth":10}, + "purpose":"Make a grade change feel supported and intentionally connected.", + "materials":["minecraft:stone_bricks","minecraft:stone_brick_stairs","minecraft:stone_brick_slab","minecraft:smooth_sandstone","minecraft:smooth_sandstone_slab","minecraft:mossy_stone_bricks","minecraft:dirt"], + "steps":["Survey lower and upper approaches first; adapt the proposed envelope to the actual rise.","Reserve five clear stair columns and two shoulder columns per side.","Use three or four one-block rises followed by a three-block-deep landing; fill every tread and retaining wall down to solid ground.","Return the side walls into the upper terrace instead of ending them as disconnected pillars.","Keep the cream cap continuous; limit moss to local soil-contact or shaded recesses."], + "clearance":"Five clear stair blocks and four-block headroom along the full ascending path, including caps and any overhead fixture.", + "variants":["A shorter flight for a lower terrace.","An additional landing and separate second flight when the actual rise requires it."], + "acceptance":["Stair facing matches direction of ascent.","Approach and landing elevations agree with the survey.","No hollow exposed underside or unsupported cap.","Shoulders do not create a climb-over route around containment."], + "avoid":["Solving a height mismatch with a decorative slab at the doorway.","Placing a standard stair module without measuring the destination."], + "source_ids":["S09","S10","S13"] + }, + { + "id":"luggage-bench","category":"props","name":"Bench and luggage group", + "size":{"width":7,"height":3,"depth":4}, + "purpose":"Tell a station story with a small, useful-looking prop group.", + "materials":["minecraft:spruce_stairs","minecraft:spruce_planks","minecraft:stripped_spruce_log","minecraft:smooth_sandstone","minecraft:green_concrete","minecraft:gold_block"], + "steps":["Put a three-seat bench against the back of a widened pocket, facing the plaza.","Group two wood-toned luggage shapes at one end, leaving seat fronts clear.","Reserve a green backing for one real destination or station identity plaque.","Use at most one small metallic accent in the group; omit it if it attracts more attention than the navigation."], + "clearance":"Provide a separate three-block passing strip in front and preserve any wider existing route.", + "variants":["Bench alone.","Bench with one planter instead of luggage."], + "acceptance":["Object reads as a resting place from the walking path.","Luggage does not intrude into the bench approach.","No label advertises an unfinished accessible destination."], + "avoid":["Crates in every empty corner.","Unsupported decorative item entities or invented text APIs."], + "source_ids":["S01","S06","S10"] + }, + { + "id":"concealed-light-lens","category":"lighting","name":"Full-block concealed light lens", + "size":{"width":3,"height":2,"depth":3}, + "purpose":"Illuminate a large room without adding a hanging fixture to every bay.", + "materials":["minecraft:glowstone","minecraft:brown_stained_glass","minecraft:smooth_sandstone"], + "steps":["Reserve a three-by-three floor sample outside patterned paving, tree roots, landings and interactive controls.","Keep full paving in the eight perimeter columns; in the center put glowstone below a full brown stained-glass block flush with the walking surface.","Keep supporting construction below the sample intact and record expected states before replacement.","Prototype one lens; increase coverage only after matching night and interior photographs reveal dark areas.","A ceiling variant reverses the emitter and visible lens positions within an intact full-block ceiling."], + "clearance":"Top of the floor lens remains flush. Ceiling version preserves all existing route headroom and sealed upper rooms.", + "variants":["Use ordinary full glass if the brown lens makes the sample too dark.","Concealed side lighting for an exhibit rather than more floor lights."], + "acceptance":["Full-block collision remains at the floor/ceiling surface.","Actual captured room and exhibit faces become legible.","No new bright fixture competes with the entrance or lift."], + "avoid":["Tinted glass: it blocks light and is not interchangeable with stained glass.","Assuming more fixtures automatically produce a better interior."], + "source_ids":["S07","S14"], + "local_precedent":"Stage 07 used concealed brown-glass lighting and real camera review; this reusable sample has not itself been instantiated." + }, + { + "id":"masonry-weathering","category":"surface","name":"Cause-based masonry weathering", + "size":{"width":12,"height":6,"depth":1}, + "purpose":"Add age and material variation while retaining large calm wall fields.", + "materials":["minecraft:stone_bricks","minecraft:cracked_stone_bricks","minecraft:mossy_stone_bricks","minecraft:andesite","minecraft:smooth_sandstone","minecraft:cut_sandstone"], + "steps":["Choose either the gray retaining-wall family or the cream facade family; do not mix both randomly.","Save a mask for soil contact, sheltered joints and selected worn edges.","For gray masonry, use small connected mossy patches in the mask and a few connected cracked areas away from structural focal edges.","For cream masonry, keep the field mostly smooth and use cut sandstone to express masonry bands or quiet local variation; do not add green flecks everywhere.","Preserve trim, signs and dominant shapes, then inspect at near, approach and whole-building distances."], + "clearance":"Surface substitution only; preserve structural collision, public closure and existing manual edits.", + "variants":["Clean maintained station entrance with minimal weathering.","More aged retaining wall near shaded soil contact."], + "acceptance":["Weathering locations have a visible cause.","Large forms still read from the approach.","Exact seed and patch anchors recorded for reproducibility."], + "avoid":["Independent random choice at every voxel.","A universal percentage of damage on every material.","Replacing load-bearing full cubes with stairs merely for texture."], + "source_ids":["S03","S13","S16"] + } + ] +} diff --git a/docs/references/material-registry-verification.json b/docs/references/material-registry-verification.json new file mode 100644 index 0000000..a487070 --- /dev/null +++ b/docs/references/material-registry-verification.json @@ -0,0 +1,105 @@ +{ + "verified_at": "2026-09-13T13:47:22.221969+00:00", + "server_version": "26.2-123-5001879 (MC: 26.2)", + "catalog": { + "items": 1537, + "search_default_limit": 16, + "blocks": 1196, + "version": "00474abd322518089309784c", + "materials": 1691, + "search_max_limit": 32 + }, + "registry_probe": { + "catalog_described": 1196, + "max_description_bytes": 682, + "total": 1196, + "passed": 1196, + "entity_defaults": 186, + "property_cases": 5392, + "fresh_property_passed": 5392, + "same_material_property_passed": 5392, + "private_undo_passed": 5392, + "anchor_restored": true, + "failure_count": 0 + }, + "scope": "All block defaults and independent property variants; not the Cartesian product or later simulation effects.", + "live_integration": { + "phase": "complete", + "checks": [ + "bounded search/pagination", + "property domains", + "item-only distinction", + "new blocks and fluids apply/undo", + "paired doors", + "private block-entity digests", + "same-material inventory preservation", + "manual-content stale snapshot and apply conflict", + "no inventory drops", + "schematic new-material native rotation", + "schematic block-entity export rejection", + "complete chest/sign/pot undo after Paper restart" + ], + "catalog_summary_bytes": 130 + }, + "tests": { + "maven": { + "world-core": { + "tests": 76, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + "paper-plugin": { + "tests": 43, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + "terrain-world-plugin": { + "tests": 26, + "failures": 0, + "errors": 0, + "skipped": 0 + } + }, + "bridge": { + "tests": 47, + "failures": 0 + } + }, + "deployment": { + "project": "shacraft-lobby-v2", + "scope_and_history_preserved": true, + "plugin_sha256": "7cb8abcc3e8127d60cbdb8925b531bfef856fe2dc4abdef9cc6191c5c715fc4d", + "mcp": { + "timestamp": "2026-09-13T13:45:31.378Z", + "tool_count": 19, + "checks": [ + { + "tool": "project_context", + "bytes": 4840, + "catalog": { + "materials": 1691, + "version": "00474abd322518089309784c", + "blocks": 1196, + "search_default_limit": 16, + "items": 1537, + "search_max_limit": 32 + }, + "project_id": "shacraft-lobby-v2" + }, + { + "tool": "material_search", + "bytes": 374 + }, + { + "tool": "material_describe", + "bytes": 324 + } + ] + }, + "chat_bridge_restarted": true, + "chatgpt_login_preserved": true, + "pre_upgrade_backup": true + } +} diff --git a/docs/references/shacraft-arrival-garden-detail.png b/docs/references/shacraft-arrival-garden-detail.png new file mode 100644 index 0000000..aba84b9 Binary files /dev/null and b/docs/references/shacraft-arrival-garden-detail.png differ diff --git a/docs/references/shacraft-arrival-garden-evening.png b/docs/references/shacraft-arrival-garden-evening.png new file mode 100644 index 0000000..a4df83b Binary files /dev/null and b/docs/references/shacraft-arrival-garden-evening.png differ diff --git a/docs/references/shacraft-arrival-garden-map.png b/docs/references/shacraft-arrival-garden-map.png new file mode 100644 index 0000000..082bbf8 Binary files /dev/null and b/docs/references/shacraft-arrival-garden-map.png differ diff --git a/docs/references/shacraft-arrival-garden-overview.png b/docs/references/shacraft-arrival-garden-overview.png new file mode 100644 index 0000000..626b955 Binary files /dev/null and b/docs/references/shacraft-arrival-garden-overview.png differ diff --git a/docs/references/shacraft-arrival-garden-verification.json b/docs/references/shacraft-arrival-garden-verification.json new file mode 100644 index 0000000..829039c --- /dev/null +++ b/docs/references/shacraft-arrival-garden-verification.json @@ -0,0 +1,589 @@ +{ + "stage": "03-arrival-garden", + "writes": 4811, + "batches": 12, + "final_distinct_changed_blocks": 4734, + "actual_block_checks": { + "version": 1, + "mode": "actual_after_snapshot", + "scope": { + "project_id": "shacraft-lobby-v2", + "world_id": "6679385a-b67f-4e88-b6be-33836ced6f2d", + "world_epoch": "b7626ccf-9c75-4b00-9cf3-b8a42231c754" + }, + "world_edits": 0, + "desired_blocks": 4734, + "baseline_expected_states_verified": 4734, + "actual_exact_state_mismatches": 0, + "actual_mismatch_examples": [], + "fixtures": { + "passed": true, + "checked": { + "leaves": 537, + "lanterns": 16, + "flowers": 258, + "rooted_trees": 12, + "root_connected_logs": 73 + }, + "failures": [] + }, + "walking": { + "checked_points": 4955, + "checked_route_edges": 0, + "failures": [], + "passed": true, + "scope": { + "project_id": "shacraft-lobby-v2", + "world_id": "6679385a-b67f-4e88-b6be-33836ced6f2d", + "world_epoch": "b7626ccf-9c75-4b00-9cf3-b8a42231c754" + }, + "note": "Observed surface samples and vertical headroom; full cubes, slabs and explicit straight bottom-stair samples. Stair samples are a surface profile, not a full player-width collision simulation. Cached snapshot is not a live re-read." + }, + "navigation": { + "version": 1, + "source": { + "x": 0, + "z": 9 + }, + "world_edits": 0, + "method": "Four quarter-center surface samples per clear cell; cardinal half-block BFS, rise/drop <= 0.5 block.", + "limitations": "Planned surface topology only. No headroom, body-width collision, material state or live-world verification. Width checks are cardinal cross-sections using the dominant local tangent; full declared masks are also checked.", + "geometry_sha256": "b58820302ac02852731c49ac482b0b6684b2aa7fe319d7e9dfc3d8374c972491", + "planned_columns": 16309, + "clear_columns": 14478, + "surface_samples": 57912, + "reachable_samples": 57912, + "unreachable_clear_columns": [], + "unreachable_by_group": {}, + "station": { + "name": "clock-station", + "x": -6, + "z": -105, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 119.0 + }, + "routes": [ + { + "id": "station-axis", + "passed": true, + "declared_clear_width": 9, + "corridor_columns": 593, + "missing_corridor_columns": [], + "declared_clear_columns": 486, + "nonclear_declared_columns": [], + "unreachable_declared_columns": [], + "cross_sections": 47, + "minimum_structural_cross_section": 9, + "minimum_clear_cross_section": 9, + "minimum_reachable_cross_section": 9, + "narrow_cross_sections": [], + "centerline_invalid_steps": [], + "centerline_maximum_boundary_step": 0, + "centerline_jumps": [], + "endpoints": [ + { + "name": "station-axis:start", + "x": 0, + "z": -37, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 45.5 + }, + { + "name": "station-axis:end", + "x": -6, + "z": -77, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 91.0 + } + ] + }, + { + "id": "south-axis-local", + "passed": true, + "declared_clear_width": 9, + "corridor_columns": 685, + "missing_corridor_columns": [], + "declared_clear_columns": 565, + "nonclear_declared_columns": [], + "unreachable_declared_columns": [], + "cross_sections": 59, + "minimum_structural_cross_section": 9, + "minimum_clear_cross_section": 9, + "minimum_reachable_cross_section": 9, + "narrow_cross_sections": [], + "centerline_invalid_steps": [], + "centerline_maximum_boundary_step": 0.5, + "centerline_jumps": [], + "endpoints": [ + { + "name": "south-axis-local:start", + "x": 0, + "z": 57, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 48.0 + }, + { + "name": "south-axis-local:end", + "x": 1, + "z": 110, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 102.0 + } + ] + }, + { + "id": "portal-radial-local", + "passed": true, + "declared_clear_width": 7, + "corridor_columns": 531, + "missing_corridor_columns": [], + "declared_clear_columns": 376, + "nonclear_declared_columns": [], + "unreachable_declared_columns": [], + "cross_sections": 70, + "minimum_structural_cross_section": 7, + "minimum_clear_cross_section": 7, + "minimum_reachable_cross_section": 7, + "narrow_cross_sections": [], + "centerline_invalid_steps": [], + "centerline_maximum_boundary_step": 0.5, + "centerline_jumps": [], + "endpoints": [ + { + "name": "portal-radial-local:start", + "x": -35, + "z": -14, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 57.0 + }, + { + "name": "portal-radial-local:end", + "x": -72, + "z": -46, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 126.0 + } + ] + }, + { + "id": "lake-radial-local", + "passed": true, + "declared_clear_width": 5, + "corridor_columns": 403, + "missing_corridor_columns": [], + "declared_clear_columns": 297, + "nonclear_declared_columns": [], + "unreachable_declared_columns": [], + "cross_sections": 63, + "minimum_structural_cross_section": 5, + "minimum_clear_cross_section": 5, + "minimum_reachable_cross_section": 5, + "narrow_cross_sections": [], + "centerline_invalid_steps": [], + "centerline_maximum_boundary_step": 0.5, + "centerline_jumps": [], + "endpoints": [ + { + "name": "lake-radial-local:start", + "x": -42, + "z": 18, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 50.5 + }, + { + "name": "lake-radial-local:end", + "x": -90, + "z": 32, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 112.5 + } + ] + }, + { + "id": "east-radial-local", + "passed": true, + "declared_clear_width": 7, + "corridor_columns": 451, + "missing_corridor_columns": [], + "declared_clear_columns": 341, + "nonclear_declared_columns": [], + "unreachable_declared_columns": [], + "cross_sections": 49, + "minimum_structural_cross_section": 7, + "minimum_clear_cross_section": 7, + "minimum_reachable_cross_section": 7, + "narrow_cross_sections": [], + "centerline_invalid_steps": [], + "centerline_maximum_boundary_step": 0.5, + "centerline_jumps": [], + "endpoints": [ + { + "name": "east-radial-local:start", + "x": 42, + "z": 16, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 49.0 + }, + { + "name": "east-radial-local:end", + "x": 82, + "z": 8, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 90.5 + } + ] + }, + { + "id": "ring-northwest-local", + "passed": true, + "declared_clear_width": 7, + "corridor_columns": 468, + "missing_corridor_columns": [], + "declared_clear_columns": 350, + "nonclear_declared_columns": [], + "unreachable_declared_columns": [], + "cross_sections": 53, + "minimum_structural_cross_section": 7, + "minimum_clear_cross_section": 7, + "minimum_reachable_cross_section": 7, + "narrow_cross_sections": [], + "centerline_invalid_steps": [], + "centerline_maximum_boundary_step": 0.5, + "centerline_jumps": [], + "endpoints": [ + { + "name": "ring-northwest-local:start", + "x": -40, + "z": -65, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 113.0 + }, + { + "name": "ring-northwest-local:end", + "x": -80, + "z": -77, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 165.0 + } + ] + }, + { + "id": "ring-northeast-local", + "passed": true, + "declared_clear_width": 7, + "corridor_columns": 505, + "missing_corridor_columns": [], + "declared_clear_columns": 374, + "nonclear_declared_columns": [], + "unreachable_declared_columns": [], + "cross_sections": 60, + "minimum_structural_cross_section": 7, + "minimum_clear_cross_section": 7, + "minimum_reachable_cross_section": 7, + "narrow_cross_sections": [], + "centerline_invalid_steps": [], + "centerline_maximum_boundary_step": 0.5, + "centerline_jumps": [], + "endpoints": [ + { + "name": "ring-northeast-local:start", + "x": 40, + "z": -95, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 151.0 + }, + { + "name": "ring-northeast-local:end", + "x": 82, + "z": -78, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 210.0 + } + ] + }, + { + "id": "station-entrance-stair", + "passed": true, + "declared_clear_width": 21, + "corridor_columns": 161, + "missing_corridor_columns": [], + "declared_clear_columns": 147, + "nonclear_declared_columns": [], + "unreachable_declared_columns": [], + "cross_sections": 7, + "minimum_structural_cross_section": 21, + "minimum_clear_cross_section": 21, + "minimum_reachable_cross_section": 21, + "narrow_cross_sections": [], + "centerline_invalid_steps": [], + "centerline_maximum_boundary_step": 0.5, + "centerline_jumps": [], + "endpoints": [ + { + "name": "station-entrance-stair:start", + "x": -6, + "z": -77, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 91.0 + }, + { + "name": "station-entrance-stair:end", + "x": -6, + "z": -83, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 97.0 + } + ] + } + ], + "passed": true, + "decorated_ground_columns_excluded": 662, + "bench_front_access": [ + { + "name": "bench--17--17-front--1", + "x": -16, + "z": -16, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 40.0 + }, + { + "name": "bench--17--17-front-0", + "x": -17, + "z": -16, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 41.0 + }, + { + "name": "bench--17--17-front-1", + "x": -18, + "z": -16, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 42.0 + }, + { + "name": "bench-17--17-front--1", + "x": 18, + "z": -16, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 42.5 + }, + { + "name": "bench-17--17-front-0", + "x": 17, + "z": -16, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 41.5 + }, + { + "name": "bench-17--17-front-1", + "x": 16, + "z": -16, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 40.5 + }, + { + "name": "bench--32-2-front--1", + "x": -31, + "z": 1, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 38.0 + }, + { + "name": "bench--32-2-front-0", + "x": -31, + "z": 2, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 37.0 + }, + { + "name": "bench--32-2-front-1", + "x": -31, + "z": 3, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 36.0 + }, + { + "name": "bench-32-2-front--1", + "x": 31, + "z": 3, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 36.5 + }, + { + "name": "bench-32-2-front-0", + "x": 31, + "z": 2, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 37.5 + }, + { + "name": "bench-32-2-front-1", + "x": 31, + "z": 1, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 38.5 + }, + { + "name": "bench--22-35-front--1", + "x": -23, + "z": 34, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 47.5 + }, + { + "name": "bench--22-35-front-0", + "x": -22, + "z": 34, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 46.5 + }, + { + "name": "bench--22-35-front-1", + "x": -21, + "z": 34, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 45.5 + }, + { + "name": "bench-22-35-front--1", + "x": 21, + "z": 34, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 46.0 + }, + { + "name": "bench-22-35-front-0", + "x": 22, + "z": 34, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 47.0 + }, + { + "name": "bench-22-35-front-1", + "x": 23, + "z": 34, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 48.0 + } + ] + }, + "passed": true, + "note": "Static support, leaf-distance and point-sampled navigation checks; not a complete moving-player collision simulation. Actual mode uses sequential observed snapshots.", + "input_sha256": { + "before": "770a08974e5bf9793c56be780576947dcceb2cd5b8400f479a5356e8509b3146", + "layout": "699060010db084dc05c47400572ac4440784d1a0c3dbd877122decd183048923", + "metadata": "400f672864117b62ed173600932ca04ab15a0c548587bb3d39f34eaa91484e89", + "walk": "453c5ee1e699a59be845db22abc5e481915768ab229a79c0cc6019d8b3e39d0d", + "navigation": "1baf3c4ef2c1ddfca22240395fcde97b5210d97c9afe5cb2b6c3275154cfe044", + "after": "5310988e559868a7e18a98d2e951d87e789798d6a1027df491ede65bb4bcdff8" + } + }, + "actual_surface_checks": { + "version": 1, + "world": "shacraft_lobby_v2", + "scope": { + "project_id": "shacraft-lobby-v2", + "world_id": "6679385a-b67f-4e88-b6be-33836ced6f2d", + "world_epoch": "b7626ccf-9c75-4b00-9cf3-b8a42231c754" + }, + "source": "Full live Paper surface maps plus observed baseline voxels and desired edit overlay", + "verified_surface_columns": 589824, + "affected_columns": 2823, + "outside_edit_columns_verified": 587001, + "changed_surface_columns": 2680, + "surface_mismatches": 0, + "outside_edit_surface_mismatches": 0, + "mismatch_examples": [], + "original_visible_water_columns": 41467, + "visible_water_surface_changes": 0, + "water_surface_change_examples": [], + "expected_states_checked_against_baseline": 4734, + "placed_or_replaced_voxels": 4702, + "removed_voxels": 32, + "lower_snapshot_voxels_consulted_after_cuts": 12, + "observed_water_voxels_replaced": [], + "water_preservation_passed": true, + "passed": true, + "capture_started_at": "2026-09-13T02:35:54.623721211Z", + "capture_finished_at": "2026-09-13T02:37:49.861979626Z", + "atomic_snapshot": false, + "note": "Sequential map captures; edits must be idle during each capture. This checks every visible surface and the observed water blocks in the edit. Hidden untouched blocks outside the voxel survey are not inferred. Live voxel and headroom checks are separate.", + "inputs_sha256": { + "before_map": "25b7eb12af89e0f9931a56f31cc8eca952b817a34218ffdc4fbf3636e1a3e917", + "after_map": "d0dde2583dfbb2f5b37b45a2d1cd5c239b8bfd2e72d4c1434ac280e99f8ed2af", + "baseline_voxels": "770a08974e5bf9793c56be780576947dcceb2cd5b8400f479a5356e8509b3146", + "desired_layout": "699060010db084dc05c47400572ac4440784d1a0c3dbd877122decd183048923" + } + }, + "camera_readiness": "Real Fabric frames; heuristic render readiness, server block state verified separately." +} diff --git a/docs/references/shacraft-balustrade-detail.png b/docs/references/shacraft-balustrade-detail.png new file mode 100644 index 0000000..14ce8d5 Binary files /dev/null and b/docs/references/shacraft-balustrade-detail.png differ diff --git a/docs/references/shacraft-balustrade-exterior.png b/docs/references/shacraft-balustrade-exterior.png new file mode 100644 index 0000000..bddf1b4 Binary files /dev/null and b/docs/references/shacraft-balustrade-exterior.png differ diff --git a/docs/references/shacraft-balustrade-map.png b/docs/references/shacraft-balustrade-map.png new file mode 100644 index 0000000..62ee9b3 Binary files /dev/null and b/docs/references/shacraft-balustrade-map.png differ diff --git a/docs/references/shacraft-balustrade-overview.png b/docs/references/shacraft-balustrade-overview.png new file mode 100644 index 0000000..c53587e Binary files /dev/null and b/docs/references/shacraft-balustrade-overview.png differ diff --git a/docs/references/shacraft-balustrade-verification.json b/docs/references/shacraft-balustrade-verification.json new file mode 100644 index 0000000..5904bc7 --- /dev/null +++ b/docs/references/shacraft-balustrade-verification.json @@ -0,0 +1,695 @@ +{ + "version": 1, + "scope": { + "project_id": "shacraft-lobby-v2", + "world_id": "6679385a-b67f-4e88-b6be-33836ced6f2d", + "world_epoch": "b7626ccf-9c75-4b00-9cf3-b8a42231c754" + }, + "mode": "actual_after_snapshot", + "passed": true, + "world_edits": 0, + "desired_blocks": 710, + "guard": { + "passed": true, + "columns": 302, + "piers": 46, + "cardinal_components": [ + 31, + 46, + 71, + 74, + 80 + ], + "grounded_footings": 22, + "adjacent_road_rails": 6, + "failures": [] + }, + "protected_road_columns": 2927, + "protection_failures": [], + "preserved_fixture_states": 1172, + "lost_fixtures": [], + "fixtures": { + "passed": true, + "checked": { + "leaves": 537, + "lanterns": 16, + "flowers": 258, + "rooted_trees": 12, + "root_connected_logs": 73 + }, + "failures": [] + }, + "walking": { + "checked_points": 4815, + "checked_route_edges": 0, + "failures": [], + "passed": true, + "scope": { + "project_id": "shacraft-lobby-v2", + "world_id": "6679385a-b67f-4e88-b6be-33836ced6f2d", + "world_epoch": "b7626ccf-9c75-4b00-9cf3-b8a42231c754" + }, + "note": "Observed surface samples and vertical headroom; full cubes, slabs and explicit straight bottom-stair samples. Stair samples are a surface profile, not a full player-width collision simulation. Cached snapshot is not a live re-read." + }, + "navigation": { + "version": 1, + "source": { + "x": 0, + "z": 9 + }, + "world_edits": 0, + "method": "Four quarter-center surface samples per clear cell; cardinal half-block BFS, rise/drop <= 0.5 block.", + "limitations": "Planned surface topology only. No headroom, body-width collision, material state or live-world verification. Width checks are cardinal cross-sections using the dominant local tangent; full declared masks are also checked.", + "geometry_sha256": "4a2b951946c91a09dcef9606bfebd8eba51c34f21e827b019dc0a5f768cf4148", + "planned_columns": 16309, + "clear_columns": 14338, + "surface_samples": 57352, + "reachable_samples": 57352, + "unreachable_clear_columns": [], + "unreachable_by_group": {}, + "station": { + "name": "clock-station", + "x": -6, + "z": -105, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 119.0 + }, + "routes": [ + { + "id": "station-axis", + "passed": true, + "declared_clear_width": 9, + "corridor_columns": 593, + "missing_corridor_columns": [], + "declared_clear_columns": 486, + "nonclear_declared_columns": [], + "unreachable_declared_columns": [], + "cross_sections": 47, + "minimum_structural_cross_section": 9, + "minimum_clear_cross_section": 9, + "minimum_reachable_cross_section": 9, + "narrow_cross_sections": [], + "centerline_invalid_steps": [], + "centerline_maximum_boundary_step": 0, + "centerline_jumps": [], + "endpoints": [ + { + "name": "station-axis:start", + "x": 0, + "z": -37, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 45.5 + }, + { + "name": "station-axis:end", + "x": -6, + "z": -77, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 91.0 + } + ] + }, + { + "id": "south-axis-local", + "passed": true, + "declared_clear_width": 9, + "corridor_columns": 685, + "missing_corridor_columns": [], + "declared_clear_columns": 565, + "nonclear_declared_columns": [], + "unreachable_declared_columns": [], + "cross_sections": 59, + "minimum_structural_cross_section": 9, + "minimum_clear_cross_section": 9, + "minimum_reachable_cross_section": 9, + "narrow_cross_sections": [], + "centerline_invalid_steps": [], + "centerline_maximum_boundary_step": 0.5, + "centerline_jumps": [], + "endpoints": [ + { + "name": "south-axis-local:start", + "x": 0, + "z": 57, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 48.0 + }, + { + "name": "south-axis-local:end", + "x": 1, + "z": 110, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 102.0 + } + ] + }, + { + "id": "portal-radial-local", + "passed": true, + "declared_clear_width": 7, + "corridor_columns": 531, + "missing_corridor_columns": [], + "declared_clear_columns": 376, + "nonclear_declared_columns": [], + "unreachable_declared_columns": [], + "cross_sections": 70, + "minimum_structural_cross_section": 7, + "minimum_clear_cross_section": 7, + "minimum_reachable_cross_section": 7, + "narrow_cross_sections": [], + "centerline_invalid_steps": [], + "centerline_maximum_boundary_step": 0.5, + "centerline_jumps": [], + "endpoints": [ + { + "name": "portal-radial-local:start", + "x": -35, + "z": -14, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 57.0 + }, + { + "name": "portal-radial-local:end", + "x": -72, + "z": -46, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 126.0 + } + ] + }, + { + "id": "lake-radial-local", + "passed": true, + "declared_clear_width": 5, + "corridor_columns": 403, + "missing_corridor_columns": [], + "declared_clear_columns": 297, + "nonclear_declared_columns": [], + "unreachable_declared_columns": [], + "cross_sections": 63, + "minimum_structural_cross_section": 5, + "minimum_clear_cross_section": 5, + "minimum_reachable_cross_section": 5, + "narrow_cross_sections": [], + "centerline_invalid_steps": [], + "centerline_maximum_boundary_step": 0.5, + "centerline_jumps": [], + "endpoints": [ + { + "name": "lake-radial-local:start", + "x": -42, + "z": 18, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 50.5 + }, + { + "name": "lake-radial-local:end", + "x": -90, + "z": 32, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 112.5 + } + ] + }, + { + "id": "east-radial-local", + "passed": true, + "declared_clear_width": 7, + "corridor_columns": 451, + "missing_corridor_columns": [], + "declared_clear_columns": 341, + "nonclear_declared_columns": [], + "unreachable_declared_columns": [], + "cross_sections": 49, + "minimum_structural_cross_section": 7, + "minimum_clear_cross_section": 7, + "minimum_reachable_cross_section": 7, + "narrow_cross_sections": [], + "centerline_invalid_steps": [], + "centerline_maximum_boundary_step": 0.5, + "centerline_jumps": [], + "endpoints": [ + { + "name": "east-radial-local:start", + "x": 42, + "z": 16, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 49.0 + }, + { + "name": "east-radial-local:end", + "x": 82, + "z": 8, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 90.5 + } + ] + }, + { + "id": "ring-northwest-local", + "passed": true, + "declared_clear_width": 7, + "corridor_columns": 468, + "missing_corridor_columns": [], + "declared_clear_columns": 350, + "nonclear_declared_columns": [], + "unreachable_declared_columns": [], + "cross_sections": 53, + "minimum_structural_cross_section": 7, + "minimum_clear_cross_section": 7, + "minimum_reachable_cross_section": 7, + "narrow_cross_sections": [], + "centerline_invalid_steps": [], + "centerline_maximum_boundary_step": 0.5, + "centerline_jumps": [], + "endpoints": [ + { + "name": "ring-northwest-local:start", + "x": -40, + "z": -65, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 113.0 + }, + { + "name": "ring-northwest-local:end", + "x": -80, + "z": -77, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 165.0 + } + ] + }, + { + "id": "ring-northeast-local", + "passed": true, + "declared_clear_width": 7, + "corridor_columns": 505, + "missing_corridor_columns": [], + "declared_clear_columns": 374, + "nonclear_declared_columns": [], + "unreachable_declared_columns": [], + "cross_sections": 60, + "minimum_structural_cross_section": 7, + "minimum_clear_cross_section": 7, + "minimum_reachable_cross_section": 7, + "narrow_cross_sections": [], + "centerline_invalid_steps": [], + "centerline_maximum_boundary_step": 0.5, + "centerline_jumps": [], + "endpoints": [ + { + "name": "ring-northeast-local:start", + "x": 40, + "z": -95, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 151.0 + }, + { + "name": "ring-northeast-local:end", + "x": 82, + "z": -78, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 210.0 + } + ] + }, + { + "id": "station-entrance-stair", + "passed": true, + "declared_clear_width": 21, + "corridor_columns": 161, + "missing_corridor_columns": [], + "declared_clear_columns": 147, + "nonclear_declared_columns": [], + "unreachable_declared_columns": [], + "cross_sections": 7, + "minimum_structural_cross_section": 21, + "minimum_clear_cross_section": 21, + "minimum_reachable_cross_section": 21, + "narrow_cross_sections": [], + "centerline_invalid_steps": [], + "centerline_maximum_boundary_step": 0.5, + "centerline_jumps": [], + "endpoints": [ + { + "name": "station-entrance-stair:start", + "x": -6, + "z": -77, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 91.0 + }, + { + "name": "station-entrance-stair:end", + "x": -6, + "z": -83, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 97.0 + } + ] + } + ], + "passed": true, + "bench_access": [ + { + "name": "bench-access--31-1", + "x": -31, + "z": 1, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 38.0 + }, + { + "name": "bench-access--31-2", + "x": -31, + "z": 2, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 37.0 + }, + { + "name": "bench-access--31-3", + "x": -31, + "z": 3, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 36.0 + }, + { + "name": "bench-access--23-32", + "x": -23, + "z": 32, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 45.5 + }, + { + "name": "bench-access--23-33", + "x": -23, + "z": 33, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 46.5 + }, + { + "name": "bench-access--23-34", + "x": -23, + "z": 34, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 47.5 + }, + { + "name": "bench-access--22-33", + "x": -22, + "z": 33, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 45.5 + }, + { + "name": "bench-access--22-34", + "x": -22, + "z": 34, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 46.5 + }, + { + "name": "bench-access--21-34", + "x": -21, + "z": 34, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 45.5 + }, + { + "name": "bench-access--18--16", + "x": -18, + "z": -16, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 42.0 + }, + { + "name": "bench-access--18--15", + "x": -18, + "z": -15, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 41.0 + }, + { + "name": "bench-access--18--14", + "x": -18, + "z": -14, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 40.0 + }, + { + "name": "bench-access--17--16", + "x": -17, + "z": -16, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 41.0 + }, + { + "name": "bench-access--17--15", + "x": -17, + "z": -15, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 40.0 + }, + { + "name": "bench-access--16--16", + "x": -16, + "z": -16, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 40.0 + }, + { + "name": "bench-access--16--15", + "x": -16, + "z": -15, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 39.0 + }, + { + "name": "bench-access-16--16", + "x": 16, + "z": -16, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 40.5 + }, + { + "name": "bench-access-16--15", + "x": 16, + "z": -15, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 39.5 + }, + { + "name": "bench-access-17--16", + "x": 17, + "z": -16, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 41.5 + }, + { + "name": "bench-access-17--15", + "x": 17, + "z": -15, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 40.5 + }, + { + "name": "bench-access-18--16", + "x": 18, + "z": -16, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 42.5 + }, + { + "name": "bench-access-18--15", + "x": 18, + "z": -15, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 41.5 + }, + { + "name": "bench-access-18--14", + "x": 18, + "z": -14, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 40.5 + }, + { + "name": "bench-access-21-34", + "x": 21, + "z": 34, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 46.0 + }, + { + "name": "bench-access-22-33", + "x": 22, + "z": 33, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 46.0 + }, + { + "name": "bench-access-22-34", + "x": 22, + "z": 34, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 47.0 + }, + { + "name": "bench-access-23-32", + "x": 23, + "z": 32, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 46.0 + }, + { + "name": "bench-access-23-33", + "x": 23, + "z": 33, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 47.0 + }, + { + "name": "bench-access-23-34", + "x": 23, + "z": 34, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 48.0 + }, + { + "name": "bench-access-31-1", + "x": 31, + "z": 1, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 38.5 + }, + { + "name": "bench-access-31-2", + "x": 31, + "z": 2, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 37.5 + }, + { + "name": "bench-access-31-3", + "x": 31, + "z": 3, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 36.5 + } + ] + }, + "volume": { + "passed": true, + "outside_desired_voxels": 555598, + "desired_voxels": 710, + "mismatch_examples": [], + "scope_note": "Every voxel inside the supplied before snapshot; no claim for unobserved exterior voxels." + }, + "note": "Point-sampled body headroom and navigation; not a full moving-player collision simulation.", + "input_sha256": { + "layout": "6ec0e65027080207dc8e5f7b19afaa7b33f6ddb723de5c3632cc6097592da581", + "metadata": "242507f39a354f04281bdf4a32a2fe05d9c144af9d1b7c7e7e8ddecc17976114", + "garden_plan": "699060010db084dc05c47400572ac4440784d1a0c3dbd877122decd183048923", + "garden_metadata": "400f672864117b62ed173600932ca04ab15a0c548587bb3d39f34eaa91484e89", + "garden_walk": "453c5ee1e699a59be845db22abc5e481915768ab229a79c0cc6019d8b3e39d0d", + "navigation": "1baf3c4ef2c1ddfca22240395fcde97b5210d97c9afe5cb2b6c3275154cfe044", + "before": "3172d03627e625d759a3b9ce881282e26db2926c35dd68ac0036047ae33c3488", + "after": "c77e8f953f257822f6f6489d397ab39f40459c18183743dbdbea3e168a193c65" + } +} \ No newline at end of file diff --git a/docs/references/shacraft-foundations-map.png b/docs/references/shacraft-foundations-map.png new file mode 100644 index 0000000..4167fb0 Binary files /dev/null and b/docs/references/shacraft-foundations-map.png differ diff --git a/docs/references/shacraft-foundations-overview.png b/docs/references/shacraft-foundations-overview.png new file mode 100644 index 0000000..6a9a0ec Binary files /dev/null and b/docs/references/shacraft-foundations-overview.png differ diff --git a/docs/references/shacraft-foundations-stairs.png b/docs/references/shacraft-foundations-stairs.png new file mode 100644 index 0000000..7f35075 Binary files /dev/null and b/docs/references/shacraft-foundations-stairs.png differ diff --git a/docs/references/shacraft-foundations-verification.json b/docs/references/shacraft-foundations-verification.json new file mode 100644 index 0000000..f29f6bc --- /dev/null +++ b/docs/references/shacraft-foundations-verification.json @@ -0,0 +1,551 @@ +{ + "stage": "02-foundations-zones-01-02", + "total_checked_writes": 79081, + "journalled_batches": 67, + "final_changed_voxels": 78673, + "checks": { + "surface-verification": { + "version": 1, + "world": "shacraft_lobby_v2", + "scope": { + "project_id": "shacraft-lobby-v2", + "world_id": "6679385a-b67f-4e88-b6be-33836ced6f2d", + "world_epoch": "b7626ccf-9c75-4b00-9cf3-b8a42231c754" + }, + "source": "Full live Paper surface maps plus observed baseline voxels and desired edit overlay", + "verified_surface_columns": 589824, + "affected_columns": 16901, + "outside_edit_columns_verified": 572923, + "changed_surface_columns": 16901, + "surface_mismatches": 0, + "outside_edit_surface_mismatches": 0, + "mismatch_examples": [], + "original_visible_water_columns": 41467, + "visible_water_surface_changes": 0, + "water_surface_change_examples": [], + "expected_states_checked_against_baseline": 78673, + "placed_or_replaced_voxels": 77950, + "removed_voxels": 723, + "lower_snapshot_voxels_consulted_after_cuts": 12, + "observed_water_voxels_replaced": [], + "water_preservation_passed": true, + "passed": true, + "capture_started_at": "2026-09-13T01:47:40.367423160Z", + "capture_finished_at": "2026-09-13T01:49:35.666216379Z", + "atomic_snapshot": false, + "note": "Sequential map captures; edits must be idle during each capture. This checks every visible surface and the observed water blocks in the edit. Hidden untouched blocks outside the voxel survey are not inferred. Live voxel and headroom checks are separate.", + "inputs_sha256": { + "before_map": "45612085877b44b0fc58a67f3c140049452b3fc8e8798ff816bd368852a2eaa2", + "after_map": "b4bc17c20864543fab5b03dd1f6ccecba2b79c261fa45f3c8497f1824fb8ccfa", + "baseline_voxels": "d53662eac3c06ed79d86ba5da9ce0e95c32ade146f6eb9f4f30782ec51ae8677", + "desired_layout": "22da24b01fa5d14f278253e48d97721ffb5341543b46827c17617020c1b47374" + } + }, + "walk-verification": { + "checked_points": 15708, + "checked_route_edges": 0, + "failures": [], + "passed": true, + "scope": { + "project_id": "shacraft-lobby-v2", + "world_id": "6679385a-b67f-4e88-b6be-33836ced6f2d", + "world_epoch": "b7626ccf-9c75-4b00-9cf3-b8a42231c754" + }, + "note": "Observed surface samples and vertical headroom; full cubes, slabs and explicit straight bottom-stair samples. Stair samples are a surface profile, not a full player-width collision simulation. Cached snapshot is not a live re-read." + }, + "block-verification": { + "version": 1, + "source": "Complete final observed block-state survey", + "scope": { + "project_id": "shacraft-lobby-v2", + "world_id": "6679385a-b67f-4e88-b6be-33836ced6f2d", + "world_epoch": "b7626ccf-9c75-4b00-9cf3-b8a42231c754" + }, + "verified_desired_blocks": 78673, + "mismatches": 0, + "mismatch_examples": [], + "passed": true, + "verified_by_desired_material": { + "minecraft:deepslate_bricks": 2983, + "minecraft:polished_andesite": 2635, + "minecraft:stone_brick_stairs": 724, + "minecraft:stone": 53085, + "minecraft:stone_bricks": 4399, + "minecraft:smooth_sandstone": 5776, + "minecraft:smooth_stone": 6343, + "minecraft:stone_brick_wall": 670, + "minecraft:grass_block": 561, + "minecraft:air": 723, + "minecraft:chiseled_stone_bricks": 27, + "minecraft:smooth_stone_slab": 27, + "minecraft:lantern": 27, + "minecraft:smooth_quartz": 236, + "minecraft:green_concrete": 457 + }, + "verified_by_group": { + "lake-radial-local-foundation": 963, + "lake-radial-local-paving": 403, + "edge-balustrades": 651, + "remove-obsolete-survey": 572, + "ring-northwest-local-foundation": 1116, + "ring-northwest-local-paving": 468, + "ring-northwest-local-clearance": 30, + "station-west-pilasters": 184, + "clock-station-foundation": 31398, + "station-west-arch-stones": 20, + "clock-station-paving": 5508, + "lamp-piers": 81, + "lamps": 27, + "station-west-blind-arcade": 146, + "portal-radial-local-foundation": 1054, + "portal-radial-local-paving": 531, + "portal-radial-local-clearance": 219, + "station-structural-bands": 256, + "lake-radial-local-clearance": 4, + "arrival-hex-foundation": 14211, + "arrival-hex-paving": 4741, + "station-forecourt-foundation": 3113, + "station-forecourt-paving": 1298, + "clock-station-clearance": 44, + "station-entrance-stair-foundation": 418, + "station-entrance-stair-paving": 161, + "station-forecourt-clearance": 86, + "arrival-medallion": 693, + "arrival-hex-clearance": 203, + "station-axis-foundation": 1166, + "station-axis-paving": 582, + "station-axis-clearance": 83, + "south-axis-local-foundation": 1658, + "south-axis-local-paving": 685, + "south-axis-local-clearance": 27, + "ring-northeast-local-foundation": 2663, + "station-ne-connection-foundation": 143, + "station-ne-connection-paving": 27, + "ring-northeast-local-paving": 505, + "east-radial-local-foundation": 1963, + "east-radial-local-paving": 451, + "ring-northeast-local-clearance": 8, + "east-radial-local-clearance": 8, + "temporary-bridge-threshold": 86, + "temporary-bridge-gates": 19 + }, + "desired_layout_sha256": "22da24b01fa5d14f278253e48d97721ffb5341543b46827c17617020c1b47374", + "snapshot_started_at": "2026-09-13T01:47:42.024943+00:00", + "snapshot_finished_at": "2026-09-13T01:48:27.217492+00:00", + "atomic_snapshot": false, + "note": "Full block states include slab/stair orientation, waterlogging and other properties. Sequential read snapshot; edits were completed before capture." + }, + "navigation-verification": { + "version": 1, + "source": { + "x": 0, + "z": 9 + }, + "world_edits": 0, + "method": "Four quarter-center surface samples per clear cell; cardinal half-block BFS, rise/drop <= 0.5 block.", + "limitations": "Planned surface topology only. No headroom, body-width collision, material state or live-world verification. Width checks are cardinal cross-sections using the dominant local tangent; full declared masks are also checked.", + "geometry_sha256": "e5c78e645100261d1720e50ea23eaa65650eb796341facb9a50bed0ab392e736", + "planned_columns": 16309, + "clear_columns": 15140, + "surface_samples": 60560, + "reachable_samples": 60560, + "unreachable_clear_columns": [], + "unreachable_by_group": {}, + "station": { + "name": "clock-station", + "x": -6, + "z": -105, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 119.0 + }, + "routes": [ + { + "id": "station-axis", + "passed": true, + "declared_clear_width": 9, + "corridor_columns": 593, + "missing_corridor_columns": [], + "declared_clear_columns": 486, + "nonclear_declared_columns": [], + "unreachable_declared_columns": [], + "cross_sections": 47, + "minimum_structural_cross_section": 9, + "minimum_clear_cross_section": 9, + "minimum_reachable_cross_section": 9, + "narrow_cross_sections": [], + "centerline_invalid_steps": [], + "centerline_maximum_boundary_step": 0, + "centerline_jumps": [], + "endpoints": [ + { + "name": "station-axis:start", + "x": 0, + "z": -37, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 45.5 + }, + { + "name": "station-axis:end", + "x": -6, + "z": -77, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 91.0 + } + ] + }, + { + "id": "south-axis-local", + "passed": true, + "declared_clear_width": 9, + "corridor_columns": 685, + "missing_corridor_columns": [], + "declared_clear_columns": 565, + "nonclear_declared_columns": [], + "unreachable_declared_columns": [], + "cross_sections": 59, + "minimum_structural_cross_section": 9, + "minimum_clear_cross_section": 9, + "minimum_reachable_cross_section": 9, + "narrow_cross_sections": [], + "centerline_invalid_steps": [], + "centerline_maximum_boundary_step": 0.5, + "centerline_jumps": [], + "endpoints": [ + { + "name": "south-axis-local:start", + "x": 0, + "z": 57, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 48.0 + }, + { + "name": "south-axis-local:end", + "x": 1, + "z": 110, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 102.0 + } + ] + }, + { + "id": "portal-radial-local", + "passed": true, + "declared_clear_width": 7, + "corridor_columns": 531, + "missing_corridor_columns": [], + "declared_clear_columns": 376, + "nonclear_declared_columns": [], + "unreachable_declared_columns": [], + "cross_sections": 70, + "minimum_structural_cross_section": 7, + "minimum_clear_cross_section": 7, + "minimum_reachable_cross_section": 7, + "narrow_cross_sections": [], + "centerline_invalid_steps": [], + "centerline_maximum_boundary_step": 0.5, + "centerline_jumps": [], + "endpoints": [ + { + "name": "portal-radial-local:start", + "x": -35, + "z": -14, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 57.0 + }, + { + "name": "portal-radial-local:end", + "x": -72, + "z": -46, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 126.0 + } + ] + }, + { + "id": "lake-radial-local", + "passed": true, + "declared_clear_width": 5, + "corridor_columns": 403, + "missing_corridor_columns": [], + "declared_clear_columns": 297, + "nonclear_declared_columns": [], + "unreachable_declared_columns": [], + "cross_sections": 63, + "minimum_structural_cross_section": 5, + "minimum_clear_cross_section": 5, + "minimum_reachable_cross_section": 5, + "narrow_cross_sections": [], + "centerline_invalid_steps": [], + "centerline_maximum_boundary_step": 0.5, + "centerline_jumps": [], + "endpoints": [ + { + "name": "lake-radial-local:start", + "x": -42, + "z": 18, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 50.5 + }, + { + "name": "lake-radial-local:end", + "x": -90, + "z": 32, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 112.5 + } + ] + }, + { + "id": "east-radial-local", + "passed": true, + "declared_clear_width": 7, + "corridor_columns": 451, + "missing_corridor_columns": [], + "declared_clear_columns": 341, + "nonclear_declared_columns": [], + "unreachable_declared_columns": [], + "cross_sections": 49, + "minimum_structural_cross_section": 7, + "minimum_clear_cross_section": 7, + "minimum_reachable_cross_section": 7, + "narrow_cross_sections": [], + "centerline_invalid_steps": [], + "centerline_maximum_boundary_step": 0.5, + "centerline_jumps": [], + "endpoints": [ + { + "name": "east-radial-local:start", + "x": 42, + "z": 16, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 49.0 + }, + { + "name": "east-radial-local:end", + "x": 82, + "z": 8, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 90.5 + } + ] + }, + { + "id": "ring-northwest-local", + "passed": true, + "declared_clear_width": 7, + "corridor_columns": 468, + "missing_corridor_columns": [], + "declared_clear_columns": 350, + "nonclear_declared_columns": [], + "unreachable_declared_columns": [], + "cross_sections": 53, + "minimum_structural_cross_section": 7, + "minimum_clear_cross_section": 7, + "minimum_reachable_cross_section": 7, + "narrow_cross_sections": [], + "centerline_invalid_steps": [], + "centerline_maximum_boundary_step": 0.5, + "centerline_jumps": [], + "endpoints": [ + { + "name": "ring-northwest-local:start", + "x": -40, + "z": -65, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 113.0 + }, + { + "name": "ring-northwest-local:end", + "x": -80, + "z": -77, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 165.0 + } + ] + }, + { + "id": "ring-northeast-local", + "passed": true, + "declared_clear_width": 7, + "corridor_columns": 505, + "missing_corridor_columns": [], + "declared_clear_columns": 374, + "nonclear_declared_columns": [], + "unreachable_declared_columns": [], + "cross_sections": 60, + "minimum_structural_cross_section": 7, + "minimum_clear_cross_section": 7, + "minimum_reachable_cross_section": 7, + "narrow_cross_sections": [], + "centerline_invalid_steps": [], + "centerline_maximum_boundary_step": 0.5, + "centerline_jumps": [], + "endpoints": [ + { + "name": "ring-northeast-local:start", + "x": 40, + "z": -95, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 151.0 + }, + { + "name": "ring-northeast-local:end", + "x": 82, + "z": -78, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 210.0 + } + ] + }, + { + "id": "station-entrance-stair", + "passed": true, + "declared_clear_width": 21, + "corridor_columns": 161, + "missing_corridor_columns": [], + "declared_clear_columns": 147, + "nonclear_declared_columns": [], + "unreachable_declared_columns": [], + "cross_sections": 7, + "minimum_structural_cross_section": 21, + "minimum_clear_cross_section": 21, + "minimum_reachable_cross_section": 21, + "narrow_cross_sections": [], + "centerline_invalid_steps": [], + "centerline_maximum_boundary_step": 0.5, + "centerline_jumps": [], + "endpoints": [ + { + "name": "station-entrance-stair:start", + "x": -6, + "z": -77, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 91.0 + }, + { + "name": "station-entrance-stair:end", + "x": -6, + "z": -83, + "surface_samples": 4, + "reachable_samples": 4, + "passed": true, + "shortest_surface_route_blocks": 97.0 + } + ] + } + ], + "passed": true, + "final_walkable_mask": "Only columns explicitly covered by the passed actual support/headroom verification", + "non_walkable_perimeter_floor_columns_excluded": 454, + "non_walkable_perimeter_floor_examples": [ + [ + -51, + -145 + ], + [ + -50, + -145 + ], + [ + -49, + -145 + ], + [ + -48, + -145 + ], + [ + -47, + -145 + ], + [ + -46, + -145 + ], + [ + -45, + -145 + ], + [ + -24, + -145 + ], + [ + -23, + -145 + ], + [ + -22, + -145 + ] + ] + }, + "lake-native-toe-verification": { + "checked_points": 3, + "checked_route_edges": 0, + "failures": [], + "passed": true, + "scope": { + "project_id": "shacraft-lobby-v2", + "world_id": "6679385a-b67f-4e88-b6be-33836ced6f2d", + "world_epoch": "b7626ccf-9c75-4b00-9cf3-b8a42231c754" + }, + "note": "Observed surface samples and vertical headroom; full cubes, slabs and explicit straight bottom-stair samples. Stair samples are a surface profile, not a full player-width collision simulation. Cached snapshot is not a live re-read.", + "observed_supports": [ + { + "x": -91, + "z": 31, + "y": 83, + "state": "minecraft:white_concrete" + }, + { + "x": -91, + "z": 32, + "y": 83, + "state": "minecraft:grass_block[snowy=false]" + }, + { + "x": -91, + "z": 33, + "y": 83, + "state": "minecraft:grass_block[snowy=false]" + } + ], + "source": "live targeted RPC scan of native shore immediately outside the edit boundary" + } + }, + "archive": "/home/emil/Desktop/Shacraft-Lobby-Archive", + "world_backup": "/home/emil/Desktop/minecraft-builder-mcp/.runtime/projects/shacraft-foundations-01-02-20260913" +} diff --git a/docs/references/shacraft-foundations-west.png b/docs/references/shacraft-foundations-west.png new file mode 100644 index 0000000..b326920 Binary files /dev/null and b/docs/references/shacraft-foundations-west.png differ diff --git a/docs/references/shacraft-layout-arrival.png b/docs/references/shacraft-layout-arrival.png new file mode 100644 index 0000000..1306757 Binary files /dev/null and b/docs/references/shacraft-layout-arrival.png differ diff --git a/docs/references/shacraft-layout-final.png b/docs/references/shacraft-layout-final.png new file mode 100644 index 0000000..04c1ba1 Binary files /dev/null and b/docs/references/shacraft-layout-final.png differ diff --git a/docs/references/shacraft-layout-surface.png b/docs/references/shacraft-layout-surface.png new file mode 100644 index 0000000..06cca13 Binary files /dev/null and b/docs/references/shacraft-layout-surface.png differ diff --git a/docs/references/shacraft-layout-verification.json b/docs/references/shacraft-layout-verification.json new file mode 100644 index 0000000..0781a95 --- /dev/null +++ b/docs/references/shacraft-layout-verification.json @@ -0,0 +1,16 @@ +{ + "world": "shacraft_lobby_v2", + "source": "live Paper surface maps + bounded RPC water reads", + "verified_surface_columns": 589824, + "surface_mismatches": 0, + "unique_marker_blocks": 16967, + "planned_writes": 17040, + "original_water_columns": 42399, + "water_columns_obscured_by_markers": 932, + "water_obscured_by_markers_verified_intact": true, + "height_unchanged_columns": 587098, + "capture_started_at": "2026-09-13T01:03:10.136933937Z", + "capture_finished_at": "2026-09-13T01:05:10.066341053Z", + "atomic_snapshot": false, + "note": "World edits were idle during each capture. Terrain follows its original heights; raised markers represent future structures, not finished traversable paths." +} diff --git a/docs/references/shacraft-lobby-generation-report.json b/docs/references/shacraft-lobby-generation-report.json new file mode 100644 index 0000000..c681d68 --- /dev/null +++ b/docs/references/shacraft-lobby-generation-report.json @@ -0,0 +1,11 @@ +{ + "recipe_id": "b013cd330993b262ed0192659c9276af33e1c6fdb749a02ad332086a6d99e56a", + "chunks": 2304, + "mismatches": 0, + "water_columns": 59609, + "min_surface_y": 48, + "max_surface_y": 203, + "elapsed_ms": 144136, + "world": "shacraft_lobby", + "verified_columns": 589824 +} \ No newline at end of file diff --git a/docs/references/shacraft-lobby-world-heightmap.json b/docs/references/shacraft-lobby-world-heightmap.json new file mode 100644 index 0000000..4423182 --- /dev/null +++ b/docs/references/shacraft-lobby-world-heightmap.json @@ -0,0 +1,35 @@ +{ + "note": "Target surface only; no live world reads. Bounds clip writes, not height calculations. Cache holds 32 recipes until restart; save the recipe to regenerate the same ID.", + "world_verified": false, + "tile_budget": 4096, + "sampled_height_min": 22, + "tile_count": 27648, + "mode": "sculpt", + "status": "completed", + "tile_order": "x_then_z_then_y", + "tile_edge": 16, + "bounds": { + "worldId": "terrain", + "min": { + "x": -384, + "y": 16, + "z": -384 + }, + "max": { + "x": 383, + "y": 207, + "z": 383 + } + }, + "sampled_height_max": 203, + "terrain_id": "b013cd330993b262ed0192659c9276af33e1c6fdb749a02ad332086a6d99e56a", + "sample_grid": { + "height": 256, + "width": 256 + }, + "legend": "Dark green \u003d low; pale stone \u003d high; pink \u003d preserved at sampled surface", + "kind": "terrain_heightmap_preview", + "clipped_samples": 0, + "mimeType": "image/png", + "orientation": "north (-Z) up; east (+X) right" +} \ No newline at end of file diff --git a/docs/references/shacraft-lobby-world-heightmap.png b/docs/references/shacraft-lobby-world-heightmap.png new file mode 100644 index 0000000..a5b899a Binary files /dev/null and b/docs/references/shacraft-lobby-world-heightmap.png differ diff --git a/docs/references/shacraft-natural-v2-generation-report.json b/docs/references/shacraft-natural-v2-generation-report.json new file mode 100644 index 0000000..2eae73c --- /dev/null +++ b/docs/references/shacraft-natural-v2-generation-report.json @@ -0,0 +1,11 @@ +{ + "elapsed_ms": 274728, + "max_surface_y": 204, + "min_surface_y": 48, + "water_columns": 42399, + "mismatches": 0, + "chunks": 2304, + "recipe_id": "fed10bf28fa544e7dba08d10c76a1130c67292856999b06a9fd672feeed39296", + "verified_columns": 589824, + "world": "shacraft_lobby_v2" +} \ No newline at end of file diff --git a/docs/references/shacraft-natural-v2-overview.png b/docs/references/shacraft-natural-v2-overview.png new file mode 100644 index 0000000..9bc909a Binary files /dev/null and b/docs/references/shacraft-natural-v2-overview.png differ diff --git a/docs/references/shacraft-natural-v2-player-view.png b/docs/references/shacraft-natural-v2-player-view.png new file mode 100644 index 0000000..5156bf2 Binary files /dev/null and b/docs/references/shacraft-natural-v2-player-view.png differ diff --git a/docs/references/shacraft-terrain-heightmap.json b/docs/references/shacraft-terrain-heightmap.json new file mode 100644 index 0000000..4e0ccc9 --- /dev/null +++ b/docs/references/shacraft-terrain-heightmap.json @@ -0,0 +1,35 @@ +{ + "note": "Target surface only; no live world reads. Bounds clip writes, not height calculations. Cache holds 32 recipes until restart; save the recipe to regenerate the same ID.", + "world_verified": false, + "tile_budget": 4096, + "sampled_height_min": -43, + "tile_count": 27648, + "mode": "sculpt", + "status": "completed", + "tile_order": "x_then_z_then_y", + "tile_edge": 16, + "bounds": { + "worldId": "terrain", + "min": { + "x": -384, + "y": -48, + "z": -384 + }, + "max": { + "x": 383, + "y": 143, + "z": 383 + } + }, + "sampled_height_max": 139, + "terrain_id": "5f0c33cd7152806995906c6b9ee51024ddbe90c5e7f6585e653552df2f4aabf8", + "sample_grid": { + "height": 256, + "width": 256 + }, + "legend": "Dark green \u003d low; pale stone \u003d high; pink \u003d preserved at sampled surface", + "kind": "terrain_heightmap_preview", + "clipped_samples": 0, + "mimeType": "image/png", + "orientation": "north (-Z) up; east (+X) right" +} \ No newline at end of file diff --git a/docs/references/shacraft-terrain-heightmap.png b/docs/references/shacraft-terrain-heightmap.png new file mode 100644 index 0000000..0156800 Binary files /dev/null and b/docs/references/shacraft-terrain-heightmap.png differ diff --git a/docs/references/shacraft-terrain-study-perspective.png b/docs/references/shacraft-terrain-study-perspective.png new file mode 100644 index 0000000..43391e9 Binary files /dev/null and b/docs/references/shacraft-terrain-study-perspective.png differ diff --git a/docs/references/shacraft-terrain-study-plan.png b/docs/references/shacraft-terrain-study-plan.png new file mode 100644 index 0000000..273e6da Binary files /dev/null and b/docs/references/shacraft-terrain-study-plan.png differ diff --git a/docs/references/shacraft-terrain-study.json b/docs/references/shacraft-terrain-study.json new file mode 100644 index 0000000..f4df661 --- /dev/null +++ b/docs/references/shacraft-terrain-study.json @@ -0,0 +1,20 @@ +{ + "world_applied": false, + "water_level": 48, + "fields": [ + { + "name": "V1 \u2014 rectangular platforms", + "min_ground_y": 22.0, + "max_ground_y": 203.0, + "water_columns": 59609, + "slope_p95": 3.0 + }, + { + "name": "Study \u2014 ridges, soft hills, winding water", + "min_ground_y": 31.35810089111328, + "max_ground_y": 204.61903381347656, + "water_columns": 42399, + "slope_p95": 3.5214755985851562 + } + ] +} diff --git a/docs/references/shacraft-terrain-v1-ingame.png b/docs/references/shacraft-terrain-v1-ingame.png new file mode 100644 index 0000000..b217a16 Binary files /dev/null and b/docs/references/shacraft-terrain-v1-ingame.png differ diff --git a/docs/references/shacraft-zone01-arrival.png b/docs/references/shacraft-zone01-arrival.png new file mode 100644 index 0000000..c4b01d1 Binary files /dev/null and b/docs/references/shacraft-zone01-arrival.png differ diff --git a/docs/references/shacraft-zone01-containment-verification.json b/docs/references/shacraft-zone01-containment-verification.json new file mode 100644 index 0000000..2d2d257 --- /dev/null +++ b/docs/references/shacraft-zone01-containment-verification.json @@ -0,0 +1,119 @@ +{ + "version": 1, + "scope": { + "project_id": "shacraft-lobby-v2", + "world_id": "6679385a-b67f-4e88-b6be-33836ced6f2d", + "world_epoch": "b7626ccf-9c75-4b00-9cf3-b8a42231c754" + }, + "world_edits": 0, + "mode": "actual_after_snapshot", + "desired_blocks": 11502, + "interior_columns": 5888, + "independently_derived_wall_columns": 276, + "interior_voxels": 117673, + "interior_excluded_voxels": 87, + "volume_components": { + "components": [ + { + "voxels": 117673, + "nonfull_voxels": 117473, + "contains_source": true + } + ], + "source_in_volume": true, + "note": "Disconnected sealed components are reported, not treated as escapes. The entire required membrane and exterior flood are checked." + }, + "membrane": { + "passed": true, + "required_voxels": 17276, + "parts": { + "wall": 5457, + "floor": 5870, + "folded_boundary": 61, + "roof": 5888 + }, + "observed_materials": { + "barrier": 11396, + "smooth_sandstone": 4080, + "smooth_stone": 447, + "cut_sandstone": 425, + "polished_andesite": 137, + "stone_bricks": 16, + "waxed_oxidized_cut_copper": 12, + "chiseled_stone_bricks": 16, + "grass_block": 377, + "dirt": 12, + "green_concrete": 356, + "gold_block": 2 + }, + "nonfull_voxels": 0, + "examples": [], + "method": "Every voxel in N6(interior volume) minus interior volume must be an observed full collision cube." + }, + "exterior_flood": { + "passed": true, + "observed_voxels": 220584, + "exterior_reached_voxels": 78339, + "interior_reached_from_exterior": 0, + "source_reached_from_exterior": false, + "unknown_or_partial_blocks_treated_as_empty": { + "stone_brick_wall": 293, + "stone_brick_stairs": 42, + "smooth_sandstone_slab": 505, + "iron_bars": 128, + "waxed_oxidized_cut_copper_stairs": 64, + "iron_chain": 16, + "spruce_leaves": 479, + "lantern": 16, + "oxeye_daisy": 60, + "azure_bluet": 39, + "white_tulip": 62, + "oak_leaves": 58, + "allium": 50, + "spruce_fence": 18, + "spruce_stairs": 18, + "pink_tulip": 49, + "smooth_sandstone_stairs": 8, + "waxed_oxidized_cut_copper_slab": 3 + }, + "leak_examples": [], + "method": "Six-neighbor free-voxel exterior flood; only known full collision cubes obstruct it." + }, + "player_probes": { + "passed": true, + "player_width": 0.6, + "player_height": 1.8, + "source_headroom_observed_air": true, + "swept_crossing_probes": { + "diagonal": 3288, + "cardinal": 2280, + "folded_boundary": 178, + "floor": 1, + "roof": 1 + }, + "unblocked_probes": 0, + "examples": [], + "note": "Supplementary swept-AABB boundary probes; the independently derived full membrane is the complete static containment criterion." + }, + "preserved_decor": { + "passed": true, + "protected_observed_states": 1016, + "changed_states": 0, + "examples": [] + }, + "volume": { + "passed": true, + "outside_desired_voxels": 610596, + "desired_voxels": 11502, + "mismatch_examples": [], + "scope_note": "Every voxel inside the supplied before snapshot; no claim for unobserved exterior voxels." + }, + "passed": true, + "limitations": "Static continuous collision containment for normal nonspectator players only. No protection against spectator, breaking/removing blocks, operator commands, plugin teleports or arbitrary discontinuous teleport/pearl behavior. Actual snapshots are sequential, not atomic.", + "input_sha256": { + "before": "9332c546f50b174ec39b01430525eb903c926b43cbdfaa956a539759fbf0f7b2", + "after": "5fdbffc42df7a799e792bf5b31c1c45f5a7e02e09844860898bcdd2f79e199b3", + "layout": "95b430dd0815c8b0d1b87932e9ac1033413f3e82766dd1d4e67aefe4cbaecfa8", + "metadata": "12caa7f14f15128736a0525a765f67a9d9f6b343fc912b36cd41d2e76ab39904" + } +} \ No newline at end of file diff --git a/docs/references/shacraft-zone01-map.png b/docs/references/shacraft-zone01-map.png new file mode 100644 index 0000000..9aad56f Binary files /dev/null and b/docs/references/shacraft-zone01-map.png differ diff --git a/docs/references/shacraft-zone01-overview.png b/docs/references/shacraft-zone01-overview.png new file mode 100644 index 0000000..e6d3ad6 Binary files /dev/null and b/docs/references/shacraft-zone01-overview.png differ diff --git a/docs/references/shacraft-zone01-welcome.png b/docs/references/shacraft-zone01-welcome.png new file mode 100644 index 0000000..b3e0d17 Binary files /dev/null and b/docs/references/shacraft-zone01-welcome.png differ diff --git a/docs/references/station-stage07/station-front.capture.json b/docs/references/station-stage07/station-front.capture.json new file mode 100644 index 0000000..11d8c65 --- /dev/null +++ b/docs/references/station-stage07/station-front.capture.json @@ -0,0 +1,28 @@ +{ + "capturedAt": "2026-09-13T12:33:57.784380033Z", + "dimension": "minecraft:shacraft_lobby_v2", + "x": -6.0, + "y": 113.0, + "z": -24.0, + "eyeY": 114.61999988555908, + "yaw": 180.0, + "pitch": -8.0, + "fov": 95, + "readiness": "local_chunks_and_render_queue_stable", + "serverRevisionVerified": false, + "loadedChunkRadius": 1, + "stabilizationTicks": 40, + "stabilizationFrames": 3, + "status": "completed", + "mimeType": "image/png", + "width": 1280, + "height": 720, + "sourceWidth": 1280, + "sourceHeight": 720, + "captureId": "4a5e4d45-78f8-4f3b-9c2a-a0a665c29fe4", + "imageSha256": "b3f36b132e62a661e8269b405dcc6e3e48ab3c20eb58894e333d2bbf92cbf262", + "imageBytes": 856573, + "elapsedSeconds": 5.063, + "transport": "authenticated Paper HTTP camera_capture", + "testMode": "one owner/spectator client" +} diff --git a/docs/references/station-stage07/station-front.png b/docs/references/station-stage07/station-front.png new file mode 100644 index 0000000..a2aba61 Binary files /dev/null and b/docs/references/station-stage07/station-front.png differ diff --git a/docs/references/station-stage07/station-overview.capture.json b/docs/references/station-stage07/station-overview.capture.json new file mode 100644 index 0000000..4e1c059 --- /dev/null +++ b/docs/references/station-stage07/station-overview.capture.json @@ -0,0 +1,28 @@ +{ + "capturedAt": "2026-09-13T12:37:51.019066137Z", + "dimension": "minecraft:shacraft_lobby_v2", + "x": 70.0, + "y": 151.0, + "z": -39.0, + "eyeY": 152.61999988555908, + "yaw": 133.0, + "pitch": 22.0, + "fov": 75, + "readiness": "local_chunks_and_render_queue_stable", + "serverRevisionVerified": false, + "loadedChunkRadius": 1, + "stabilizationTicks": 90, + "stabilizationFrames": 3, + "status": "completed", + "mimeType": "image/png", + "width": 1280, + "height": 720, + "sourceWidth": 1280, + "sourceHeight": 720, + "captureId": "c352f761-9dd9-409a-94f1-fc4fd364d7cc", + "imageSha256": "c06c1fbd498b0376f639e788f142c91bdb0e3509fa7ff3ce5846748311dd444e", + "imageBytes": 1302879, + "elapsedSeconds": 9.867, + "transport": "authenticated Paper HTTP camera_capture", + "testMode": "one owner/spectator client" +} diff --git a/docs/references/station-stage07/station-overview.png b/docs/references/station-stage07/station-overview.png new file mode 100644 index 0000000..6108016 Binary files /dev/null and b/docs/references/station-stage07/station-overview.png differ diff --git a/docs/references/station-stage07/station-smash.capture.json b/docs/references/station-stage07/station-smash.capture.json new file mode 100644 index 0000000..6264c40 --- /dev/null +++ b/docs/references/station-stage07/station-smash.capture.json @@ -0,0 +1,28 @@ +{ + "capturedAt": "2026-09-13T12:37:05.830431206Z", + "dimension": "minecraft:shacraft_lobby_v2", + "x": -46.0, + "y": 119.0, + "z": -104.0, + "eyeY": 120.61999988555908, + "yaw": 225.0, + "pitch": 12.0, + "fov": 85, + "readiness": "local_chunks_and_render_queue_stable", + "serverRevisionVerified": false, + "loadedChunkRadius": 1, + "stabilizationTicks": 40, + "stabilizationFrames": 3, + "status": "completed", + "mimeType": "image/png", + "width": 1280, + "height": 720, + "sourceWidth": 1280, + "sourceHeight": 720, + "captureId": "74fc1c80-5328-4dd4-90c7-4ce53661d814", + "imageSha256": "b5d319d2f216b6d6c8f9496e612a2e0066718c660ccad09fc959fedd9ed14354", + "imageBytes": 1259152, + "elapsedSeconds": 5.135, + "transport": "authenticated Paper HTTP camera_capture", + "testMode": "one owner/spectator client" +} diff --git a/docs/references/station-stage07/station-smash.png b/docs/references/station-stage07/station-smash.png new file mode 100644 index 0000000..a737481 Binary files /dev/null and b/docs/references/station-stage07/station-smash.png differ diff --git a/docs/references/station-stage07/station-vestibule.capture.json b/docs/references/station-stage07/station-vestibule.capture.json new file mode 100644 index 0000000..4598cc8 --- /dev/null +++ b/docs/references/station-stage07/station-vestibule.capture.json @@ -0,0 +1,28 @@ +{ + "capturedAt": "2026-09-13T12:29:28.293904723Z", + "dimension": "minecraft:shacraft_lobby_v2", + "x": -5.5, + "y": 100.0, + "z": -91.5, + "eyeY": 101.61999988555908, + "yaw": 180.0, + "pitch": -6.0, + "fov": 85, + "readiness": "local_chunks_and_render_queue_stable", + "serverRevisionVerified": false, + "loadedChunkRadius": 1, + "stabilizationTicks": 150, + "stabilizationFrames": 3, + "status": "completed", + "mimeType": "image/png", + "width": 1280, + "height": 720, + "sourceWidth": 1280, + "sourceHeight": 720, + "captureId": "14496563-40e4-4c8b-a7cc-d111ad178b9d", + "imageSha256": "b420b4b71b22812a4f5bfc9464d1192856dd8d166e66526b579ff6c3e19ea905", + "imageBytes": 1024197, + "elapsedSeconds": 16.526, + "transport": "authenticated Paper HTTP camera_capture", + "testMode": "one owner/spectator client" +} diff --git a/docs/references/station-stage07/station-vestibule.png b/docs/references/station-stage07/station-vestibule.png new file mode 100644 index 0000000..d84a461 Binary files /dev/null and b/docs/references/station-stage07/station-vestibule.png differ diff --git a/docs/references/terrain-brush-live-preview.png b/docs/references/terrain-brush-live-preview.png new file mode 100644 index 0000000..0378dc8 Binary files /dev/null and b/docs/references/terrain-brush-live-preview.png differ diff --git a/docs/references/zone02-interior-v1/00-coordinate-plan.pdf b/docs/references/zone02-interior-v1/00-coordinate-plan.pdf new file mode 100644 index 0000000..1389f22 Binary files /dev/null and b/docs/references/zone02-interior-v1/00-coordinate-plan.pdf differ diff --git a/docs/references/zone02-interior-v1/00-coordinate-plan.png b/docs/references/zone02-interior-v1/00-coordinate-plan.png new file mode 100644 index 0000000..812f80b Binary files /dev/null and b/docs/references/zone02-interior-v1/00-coordinate-plan.png differ diff --git a/docs/references/zone02-interior-v1/01-vestibule.png b/docs/references/zone02-interior-v1/01-vestibule.png new file mode 100644 index 0000000..74c9f5d Binary files /dev/null and b/docs/references/zone02-interior-v1/01-vestibule.png differ diff --git a/docs/references/zone02-interior-v1/02-smash-floor.png b/docs/references/zone02-interior-v1/02-smash-floor.png new file mode 100644 index 0000000..359c976 Binary files /dev/null and b/docs/references/zone02-interior-v1/02-smash-floor.png differ diff --git a/docs/references/zone02-interior-v1/03-cutaway.png b/docs/references/zone02-interior-v1/03-cutaway.png new file mode 100644 index 0000000..0e6fd1a Binary files /dev/null and b/docs/references/zone02-interior-v1/03-cutaway.png differ diff --git a/docs/references/zone02-interior-v1/README.md b/docs/references/zone02-interior-v1/README.md new file mode 100644 index 0000000..2803fa9 --- /dev/null +++ b/docs/references/zone02-interior-v1/README.md @@ -0,0 +1,40 @@ +# Shacraft clock station: vestibule and SMASH + +Design v1, 13 September 2026. **Proposal only; no world edits were made.** + +This design fits the saved, existing zone 02 foundation in `shacraft_lobby_v2`. The current station has a deck and setting-out bands, not a completed above-ground building. Its fixed overall envelope is 115 by 63 blocks; the occupied footprint is irregular. The final stage 02 geometry contains 5,764 station columns, including the east-edge recess. The earlier ideal outline contained 5,823 columns and is not the exact placement authority. + +## Drawings + +- [Coordinate plans and section](00-coordinate-plan.png) · [printable PDF](00-coordinate-plan.pdf) +- [First-floor vestibule](01-vestibule.png) +- [Second-floor SMASH hall](02-smash-floor.png) +- [Clock station cutaway](03-cutaway.png) +- [Exact coordinate specification and checks](layout.json) +- [Full image prompts](prompts.json) + +The three perspective images were generated with the **built-in image_gen tool**. They establish architectural character and atmosphere. The coordinate plan and JSON govern dimensions and placement: generated ornament, block counts, lettering, map thumbnails and roof shapes are illustrative. In particular, the invented slogans in the SMASH image are not approved Shacraft copy. The technical plan was drawn deterministically with Pillow from the saved foundation cells. + +## Spatial arrangement + +The south entrance keeps the existing approach axis at X=-6. The exterior staircase is 21 blocks clear; the entrance and principal approach aisle are seven blocks clear. A green copper and warm brass-colored lift surround is visible from the doorway, with a small directory beside it. Benches and planting occupy the side galleries. Column positions align vertically; pale stone arches and spruce ceiling panels continue the original clock-station reference. + +The first-floor paving remains at block Y98, with feet at Y99. The two-block intermediate deck occupies Y111 and Y112; SMASH is walked at Y113. This gives 12 blocks of structural clear height below the intermediate deck and 11 above it, before the next ceiling begins at Y124. Hanging lamps and decorative beams may reduce local headroom; keep at least four clear blocks over public circulation. The proposed main roof has eaves at Y126 and ridge at Y140; the clock-tower peak is Y158. These are proposed heights, not measurements of an existing shell. + +The lift housing occupies X=-10..-2, Z=-124..-116 on both floors. Two-block walls leave a 5 by 5 clear cabin, with a five-block opening facing south. The suggested eventual behavior is a personal floor selection and transition between enclosed stationary cabins, preventing one player's selection from moving everyone else. Lift controls, animation and world transfers are not implemented by this design. Future floors can reuse the shaft alignment, but their height, roof integration and circulation need a further design pass. + +## SMASH floor + +The user confirmed the Shotbow-style mode: accumulating damage increases knockback, players double-jump and attempt to knock opponents off the arena. See [Shotbow's getting-started guide](https://wiki.shotbow.net/SMASH_Getting_Started). + +Six seven-by-five selection alcoves line the north wall, with three blocks between adjacent alcoves and a seven-block clear aisle in front. Each contains an arena illustration and an ordinary sign at reachable height. Slots 01–06 are **unassigned placeholders**: arena names, destinations and live status are not invented here. Later, each sign can connect to its arena world or queue. + +A 19 by 15 decorative island diorama occupies the western part of the main hall. Its small islands and static figures explain knockback visually. The display sits above a solid floor, behind a stone-and-glass enclosure. It is scenery, not a playable arena or a hole through the building. The west gallery explains damage, knockback and double jumping; the east gallery provides waiting space. Burnt-orange and dark-red accents distinguish SMASH while retaining Shacraft's green copper, cream stone, spruce and warm lighting. + +## Evidence and limits + +`draw-plan.py` checks all specified columns, benches, the lift housing, display and selection bays against a two-block setback inside the saved foundation. It checks for furnishing overlaps and keeps the seven-block entrance aisle and bay-front aisle clear. These are design checks, not a collision test of a constructed building. + +Sources are `.runtime/foundations-stage02/foundations-final.geometry.json` and the saved `zone01-complete.json` surface export; its capture time is recorded in `layout.json`. That export is not an atomic or fresh live scan. It shows only deck, parapet and lamp heights in this footprint. Before building, inspect current voxels again, preserve manual changes, save a backup and implement with conflict-aware batches. Zone 01's existing containment remains in place until a separate, checked opening of the station route. + +For a design rebuild in the main repository, run `python3 docs/references/zone02-interior-v1/draw-plan.py` with Pillow available and the source geometry/export retained. Roof overhangs, arches, window bays and decorative block palettes remain to be detailed during the building pass within the selected region. No additional minigame rooms or playable arenas are included in this stage. diff --git a/docs/references/zone02-interior-v1/SHA256SUMS b/docs/references/zone02-interior-v1/SHA256SUMS new file mode 100644 index 0000000..e02d54c --- /dev/null +++ b/docs/references/zone02-interior-v1/SHA256SUMS @@ -0,0 +1,10 @@ +0aa7b7be08876532e84c3567ab1c4ee1fc6a55f8232e6ad47398f7de43505653 00-coordinate-plan.pdf +9b549bd047b54521e6d2edaa3d09bd7c128cb1d31fd1e170b16d0a9b00d93dc3 00-coordinate-plan.png +5ba4142fa4e0cbcc21770e438ca963762c157e242598908c9326f0479b3c3d86 01-vestibule.png +c23c425ffefa5626e56ab83548e1b2c3f8ed38ab7f686d2d00da93e15d86d41b 02-smash-floor.png +cd8232d0c27ea25b60510128ebdca629b8872288e9ee95db6fa919c34ae0273f 03-cutaway.png +37702b2c8337cba6d301d37c7d88151ad930fb6c474954733cfe0771a820a75a README.md +1c90cf98558c079ba3ef3ebd3cd2d8dee17cecc01535875a9ab1f23a99577d03 draw-plan.py +6341286d9f66171788fc06dfa8368d0ce9cc195de398963465a87337dff5c091 generation-sources.json +135b16fcaaab87030cd78bed3129a0edfeb2aaa8de519245e1746879e45d6b83 layout.json +a361e3016e38e95d8dbc865c70d8d256f48f085ac8f1f37c866ec403b445d605 prompts.json diff --git a/docs/references/zone02-interior-v1/draw-plan.py b/docs/references/zone02-interior-v1/draw-plan.py new file mode 100644 index 0000000..4fb4bab --- /dev/null +++ b/docs/references/zone02-interior-v1/draw-plan.py @@ -0,0 +1,180 @@ +"""Render the proposed station layout against the saved, block-exact foundation. + +Read-only design utility; it never connects to or writes the Minecraft world. +All X/Z boxes are inclusive block coordinates. Pillow is required. +""" +import json +from pathlib import Path +from collections import Counter +from PIL import Image, ImageDraw, ImageFont + +OUT = Path(__file__).resolve().parent +ROOT = OUT.parents[2] +SOURCE = ROOT / '.runtime/foundations-stage02/foundations-final.geometry.json' +geometry = json.loads(SOURCE.read_text()) +foot = {(c['x'], c['z']) for c in geometry['cells'] if c['group'] == 'clock-station'} +inner = {p for p in foot if all((p[0]+dx,p[1]+dz) in foot for dx in range(-2,3) for dz in range(-2,3))} +walls = foot-inner + +def cells(box): + a,b,c,d = box + return {(x,z) for x in range(a,b+1) for z in range(c,d+1)} + +core = [-10,-2,-124,-116] +cabin = [-8,-4,-122,-118] +bays = [[x-3,x+3,-140,-136] for x in [-42,-32,-22,-12,-2,8]] +display = [-42,-24,-124,-110] +pillars = [[x,x+1,z,z+1] for x in [-69,-49,-19,17,31] for z in [-127,-104]] +benches = [[x,x+7,z,z+1] for x in [-67,24] for z in [-132,-108]] +features = {'lift housing':core,'lift cabin':cabin,'display':display} +features.update({f'arena bay {i+1}':b for i,b in enumerate(bays)}) +features.update({f'column {i+1}':b for i,b in enumerate(pillars)}) +features.update({f'bench {i+1}':b for i,b in enumerate(benches)}) +for name,box in features.items(): + assert cells(box) <= inner, (name,'outside two-block perimeter setback') +solid_features = [core,display,*bays,*pillars,*benches] +for i,a in enumerate(solid_features): + for b in solid_features[i+1:]: + assert not cells(a)&cells(b), ('furniture overlap',a,b) +route = cells([-9,-3,-115,-85]) +assert route <= foot +assert not route & set().union(*(cells(b) for b in solid_features)) +bay_aisle = cells([-46,11,-135,-129]) +assert bay_aisle <= inner +assert not bay_aisle & set().union(*(cells(b) for b in solid_features)) +map_path = ROOT / '.runtime/server/plugins/ShacraftTerrain/maps/zone01-complete.json' +surface=json.loads(map_path.read_text()) +hist=Counter(surface['surface_y'][(z-surface['min_z'])*surface['width']+x-surface['min_x']] for x,z in foot) +design = { + 'status':'PROPOSAL ONLY. Foundation is surveyed; walls, interiors and upper levels are not built.', + 'world':'shacraft_lobby_v2','coordinate_convention':'X/Z boxes include both endpoints. Floor Y is block Y; walk Y is top of that floor.', + 'foundation_source':str(SOURCE.relative_to(ROOT)), 'surface_capture_finished_at':surface['capture_finished_at'], + 'foundation_columns':len(foot),'foundation_bounds':[-74,40,-145,-83], 'foundation_surface_height_counts':dict(hist), + 'floors':[{'name':'1 Vestibule','floor_block_y':98,'walk_y':99,'ceiling_underside_y':111,'clear_height':12}, + {'name':'2 Smash','floor_block_y':112,'walk_y':113,'ceiling_underside_y':124,'clear_height':11}], + 'intermediate_deck_block_y':[111,112], 'perimeter_wall_thickness':2, + 'main_roof_eaves_y':126,'main_roof_ridge_y':140,'clocktower_peak_y':158, + 'entrance_axis_x':-6,'entrance_opening_x':[-9,-3],'entrance_threshold_z':[-84,-83], + 'lift':{'housing':core,'clear_cabin':cabin,'door_clear_x':[-8,-4],'door_z':[-117,-116],'opens':'south','same_location_on_both_floors':True}, + 'smash_display':display,'smash_selection_bays':bays,'aligned_columns':pillars,'wing_benches':benches, + 'reserved_clear_aisles':{'entry':[-9,-3,-115,-85],'bay_front':[-46,11,-135,-129]}, + 'checks':{'features_inside_two_block_setback':True,'solid_features_do_not_overlap':True,'entry_route_clear_width':7,'bay_front_clear_depth':7}, + 'limitations':['Saved surface export is not a fresh live scan.', 'Containment and overlap checks cover the proposed plan, not constructed voxels.', + 'Generated perspectives are artistic references; this coordinate specification governs placement.', 'Arena sign slots are unassigned, not working destinations.', + 'Future floors need an explicit roof/height design before construction. Only the lift alignment is reserved.']} +(OUT/'layout.json').write_text(json.dumps(design,indent=2)+'\n') + +W,H=2000,1530 +im=Image.new('RGB',(W,H),'#f5f1e7'); d=ImageDraw.Draw(im) +REG='/usr/share/fonts/truetype/ibm-plex/IBMPlexSans-Regular.ttf' +BOLD='/usr/share/fonts/truetype/ibm-plex/IBMPlexSans-SemiBold.ttf' +SERIF='/usr/share/fonts/truetype/ibm-plex/IBMPlexSerif-Regular.ttf' +def text(x,y,s,size=22,color='#203e36',bold=False,anchor=None): + d.text((x,y),s,font=ImageFont.truetype(BOLD if bold else REG,size),fill=color,anchor=anchor) +text(65,35,'SHACRAFT / CLOCK STATION',44,bold=True) +text(65,93,'01 VESTIBULE + 02 SMASH / COORDINATE PLAN / DESIGN v1',22) +text(65,133,'Fixed foundation: 115 x 63 blocks overall. North is up. Both plans use the same 1-block grid.',21) +green='#376956';orange='#bd633b';wall='#58635c';floor='#e8dfcc';gold='#d0aa54' + +def plan(ox,title,upper=False): + oy=255; sc=7 + def xy(x,z): return ox+(x+74)*sc,oy+(z+145)*sc + def box(b,fill,outline=None): + a,c=xy(b[0],b[2]);bb,dd=xy(b[1]+1,b[3]+1) + d.rectangle((a,c,bb-1,dd-1),fill=fill,outline=outline) + def label(x,z,s,size=17,col='#203e36'): + a,b=xy(x,z);text(a,b,s,size,col,anchor='mm') + text(ox,196,title,28,bold=True) + for x,z in foot: box([x,x,z,z], wall if (x,z) in walls else floor) + # Discrete occupied columns avoid polygon boundary ambiguity. + for x,z in inner: + a,b=xy(x,z) + d.line((a,b,a+sc,b),fill='#ddd5c4') + d.line((a,b,a,b+sc),fill='#ddd5c4') + for b in pillars: box(b,'#7b8177') + for b in benches: box(b,'#957753') + box(core,green);box(cabin,'#cbdcd0') + box([-8,-4,-117,-116],'#cbdcd0') + label(-5.5,-120,'L',21) + if upper: + box(display,orange) + label(-32.5,-118,'DISPLAY',16,'#ffffff') + label(-32.5,-114,'19 x 15',16,'#ffffff') + for i,b in enumerate(bays): + box(b,gold);label((b[0]+b[1]+1)/2,-137.5,f'{i+1:02}',16) + label(-17,-132,'7-BLOCK CLEAR AISLE',17) + label(-60,-118,'RULES',17) + label(28,-118,'WAIT',17) + label(-5,-94,'SOUTH GALLERY',17) + else: + label(-58,-118,'WAITING',17) + label(28,-118,'WAITING',17) + label(-34,-117,'VESTIBULE',19) + box([1,7,-113,-111],gold) + label(4,-107,'DIRECTORY',15) + # South doorway through both perimeter wall blocks. + box([-9,-3,-84,-83],floor) + a,b=xy(-5.5,-88);c,e=xy(-5.5,-111) + d.line((a,b,c,e),fill=green,width=4) + d.polygon([(c,e),(c-8,e+14),(c+8,e+14)],fill=green) + label(-5.5,-97,'7 wide',15) + # Outside dimensions and orientation. + text(ox+402,oy-21,'115 blocks',19,anchor='mm') + d.line((ox,oy-8,ox+805,oy-8),fill=wall,width=2) + for x in [ox,ox+805]: d.line((x,oy-14,x,oy-2),fill=wall,width=2) + text(ox+842,oy+213,'63',21,anchor='mm');text(ox+842,oy+240,'blocks',16,anchor='mm') + d.line((ox+817,oy,ox+817,oy+441),fill=wall,width=2) + for z in [-145,-125,-105,-83]: + a,b=xy(-74,z);text(a-12,b,str(z),15,anchor='rm') + for x in [-74,-50,-25,0,40]: + a,b=xy(x,-82);text(a,b+18,str(x),16,anchor='mm') + text(ox+23,oy+398,'N',23,bold=True) + d.line((ox+31,oy+397,ox+31,oy+358),fill=green,width=3) + d.polygon([(ox+31,oy+352),(ox+23,oy+366),(ox+39,oy+366)],fill=green) + text(ox,oy+491,'X: east-west / Z: north-south / unit: one block',18) + +plan(105,'01 / VESTIBULE • WALK Y99') +plan(1100,'02 / SMASH • WALK Y113',True) +d.line((65,792,1935,792),fill='#bdb8aa',width=2) +text(65,825,'SECTION / ENTRANCE AXIS X = -6',27,bold=True) +text(1060,825,'DESIGN RULES',27,bold=True) +# North at left, south at right; Y shown as world surface elevations. +sx,sy,scale=105,1200,11 +def section_box(z0,z1,y0,y1,fill): + d.rectangle((sx+(z0+145)*scale,sy-(y1-98)*scale,sx+(z1+145)*scale-1,sy-(y0-98)*scale-1),fill=fill) +section_box(-145,-82,98,99,wall) +section_box(-145,-82,111,113,wall) +section_box(-145,-82,124,126,wall) +section_box(-145,-143,99,124,wall) +section_box(-84,-82,99,124,wall) +section_box(-84,-82,99,105,'#f5f1e7') +for y in [99,113]: + top=111 if y==99 else 124 + section_box(-124,-122,y,top,green) + section_box(-118,-116,y+6,top,green) + section_box(-122,-118,y,y+0.25,gold) +for y,lab in [(99,'Y99 / level 1'),(111,'Y111 / ceiling'),(113,'Y113 / level 2'),(124,'Y124 / ceiling')]: + yy=sy-(y-98)*scale + d.line((sx-12,yy,sx+720,yy),fill='#b4afa2',width=1) + text(sx+735,yy,lab,18,anchor='lm') +text(455,1072,'12 clear',23,anchor='mm') +text(455,924,'11 clear',23,anchor='mm') +text(105,1234,'NORTH',18);text(735,1234,'SOUTH / ENTRY',18) +notes=[ + 'Fixed footprint; 2-block perimeter walls.', + 'Lift: 9 x 9 housing; 5 x 5 clear cabin.', + 'Same shaft and south-facing door on both floors.', + 'Six 7 x 5 sign bays; destinations remain unassigned.', + 'SMASH display is scenery on a solid, enclosed floor.', + 'Gameplay takes place in separate arena worlds.', + 'Future stories are not furnished or dimensioned here.', + 'Concept images illustrate style; layout.json governs placement.' +] +for i,s in enumerate(notes): text(1060,878+i*43,s,21) +text(65,1320,'VERIFIED AGAINST THE SAVED FOUNDATION',22,bold=True) +text(65,1358,f'{len(foot):,} foundation columns • all specified furnishings inside the wall setback • no furnishing overlaps',21) +text(65,1394,'This is a design drawing, not a live scan or a completed building. Above-ground shell and floors are proposed.',21) +text(65,1452,'SOURCE: foundations-final.geometry.json + zone01-complete surface export / 13 SEP 2026',18) +im.save(OUT/'00-coordinate-plan.png') +im.save(OUT/'00-coordinate-plan.pdf',resolution=150) +print(json.dumps({'saved':str(OUT),'foundation_columns':len(foot),'height_counts':dict(hist),'checks':'passed'})) diff --git a/docs/references/zone02-interior-v1/generation-sources.json b/docs/references/zone02-interior-v1/generation-sources.json new file mode 100644 index 0000000..9c3cd04 --- /dev/null +++ b/docs/references/zone02-interior-v1/generation-sources.json @@ -0,0 +1,5 @@ +{ + "01-vestibule": "/home/emil/.codex/generated_images/01a096c0-89c4-73b3-b5c7-69c847aebebf/exec-20fd3d79-6c5c-47f8-b312-d0a557f7ca01.png", + "02-smash-floor": "/home/emil/.codex/generated_images/01a096c0-89c4-73b3-b5c7-69c847aebebf/exec-65601ecd-0dd8-46fd-bb8c-7a5a1793cc52.png", + "03-cutaway": "/home/emil/.codex/generated_images/01a096c0-89c4-73b3-b5c7-69c847aebebf/exec-875ff00a-540a-41e2-bf0c-b26fac132054.png" +} diff --git a/docs/references/zone02-interior-v1/layout.json b/docs/references/zone02-interior-v1/layout.json new file mode 100644 index 0000000..b4e6c8d --- /dev/null +++ b/docs/references/zone02-interior-v1/layout.json @@ -0,0 +1,235 @@ +{ + "status": "PROPOSAL ONLY. Foundation is surveyed; walls, interiors and upper levels are not built.", + "world": "shacraft_lobby_v2", + "coordinate_convention": "X/Z boxes include both endpoints. Floor Y is block Y; walk Y is top of that floor.", + "foundation_source": ".runtime/foundations-stage02/foundations-final.geometry.json", + "surface_capture_finished_at": "2026-09-13T11:16:27.647422004Z", + "foundation_columns": 5764, + "foundation_bounds": [ + -74, + 40, + -145, + -83 + ], + "foundation_surface_height_counts": { + "98": 5500, + "99": 252, + "102": 12 + }, + "floors": [ + { + "name": "1 Vestibule", + "floor_block_y": 98, + "walk_y": 99, + "ceiling_underside_y": 111, + "clear_height": 12 + }, + { + "name": "2 Smash", + "floor_block_y": 112, + "walk_y": 113, + "ceiling_underside_y": 124, + "clear_height": 11 + } + ], + "intermediate_deck_block_y": [ + 111, + 112 + ], + "perimeter_wall_thickness": 2, + "main_roof_eaves_y": 126, + "main_roof_ridge_y": 140, + "clocktower_peak_y": 158, + "entrance_axis_x": -6, + "entrance_opening_x": [ + -9, + -3 + ], + "entrance_threshold_z": [ + -84, + -83 + ], + "lift": { + "housing": [ + -10, + -2, + -124, + -116 + ], + "clear_cabin": [ + -8, + -4, + -122, + -118 + ], + "door_clear_x": [ + -8, + -4 + ], + "door_z": [ + -117, + -116 + ], + "opens": "south", + "same_location_on_both_floors": true + }, + "smash_display": [ + -42, + -24, + -124, + -110 + ], + "smash_selection_bays": [ + [ + -45, + -39, + -140, + -136 + ], + [ + -35, + -29, + -140, + -136 + ], + [ + -25, + -19, + -140, + -136 + ], + [ + -15, + -9, + -140, + -136 + ], + [ + -5, + 1, + -140, + -136 + ], + [ + 5, + 11, + -140, + -136 + ] + ], + "aligned_columns": [ + [ + -69, + -68, + -127, + -126 + ], + [ + -69, + -68, + -104, + -103 + ], + [ + -49, + -48, + -127, + -126 + ], + [ + -49, + -48, + -104, + -103 + ], + [ + -19, + -18, + -127, + -126 + ], + [ + -19, + -18, + -104, + -103 + ], + [ + 17, + 18, + -127, + -126 + ], + [ + 17, + 18, + -104, + -103 + ], + [ + 31, + 32, + -127, + -126 + ], + [ + 31, + 32, + -104, + -103 + ] + ], + "wing_benches": [ + [ + -67, + -60, + -132, + -131 + ], + [ + -67, + -60, + -108, + -107 + ], + [ + 24, + 31, + -132, + -131 + ], + [ + 24, + 31, + -108, + -107 + ] + ], + "reserved_clear_aisles": { + "entry": [ + -9, + -3, + -115, + -85 + ], + "bay_front": [ + -46, + 11, + -135, + -129 + ] + }, + "checks": { + "features_inside_two_block_setback": true, + "solid_features_do_not_overlap": true, + "entry_route_clear_width": 7, + "bay_front_clear_depth": 7 + }, + "limitations": [ + "Saved surface export is not a fresh live scan.", + "Containment and overlap checks cover the proposed plan, not constructed voxels.", + "Generated perspectives are artistic references; this coordinate specification governs placement.", + "Arena sign slots are unassigned, not working destinations.", + "Future floors need an explicit roof/height design before construction. Only the lift alignment is reserved." + ] +} diff --git a/docs/references/zone02-interior-v1/prompts.json b/docs/references/zone02-interior-v1/prompts.json new file mode 100644 index 0000000..b584a00 --- /dev/null +++ b/docs/references/zone02-interior-v1/prompts.json @@ -0,0 +1,8 @@ +{ + "mode": "built-in image_gen", + "prompts": { + "01-vestibule": "Use case: stylized-concept.\nAsset type: architectural Minecraft building reference for SHACRAFT, design proposal, not a game screenshot.\nProject: Alpine clock station / minigame selection hub on an already built irregular foundation. All geometry must look buildable using Minecraft blocks, stairs, slabs, fences and panes. Pale sandstone and cream masonry, dark stone plinths, spruce, oxidized green copper, muted brass, warm lanterns, forest-green SHACRAFT banners with a simple gold S. Restrained excellent craftsmanship, architectural clarity, no visual clutter. No sci-fi, no neon holograms, no modern shopping mall. Tiny Minecraft players only as 2-block height scale figures.\nHard spatial brief: foundation east-west 115 blocks overall and north-south 63 blocks maximum. Broad main hall 68 blocks wide by 48 deep, west pavilion 23 wide and east pavilion24 wide, both42 deep. A south entrance projection39 wide by16 deep is offset 11 blocks east of the whole building center. Entrance faces south and has a21-block-wide external staircase rising3 blocks. First floor feetY99, next floor feetY113; a two-block deck atY111..112 gives12 blocks of first-floor clear height. Second floor has11 blocks clear height, next ceiling beginsY124. Lift housing9x9 blocks with usable cabin5x5, on entrance axis x=-6, z=-120, opens south on BOTH floors. Interior rooms have2-block-thick perimeter walls and believable aligned masonry columns. Main ridgeY140; clocktower peakY158. Only two floors are furnished. Future floors remain a design reservation, no extra themed rooms.\n\nPrimary request: Draw ONE large, beautiful interior architectural perspective of the FIRST-FLOOR VESTIBULE, landscape16:9. Camera from south entrance facing north, eye height about3 blocks above floor, moderate wide-angle with straight credible verticals. In the center background, clearly visible forest-green/brass lift doorway9 blocks wide with5-block-wide opening, housed in a square structural core. A long7-block-clear cream stone aisle goes directly from entrance to lift. Immediately beside lift a small readable directory says only \"1 LOBBY\" and \"2 SMASH\". Show a substantial ceiling12 blocks above floor, ribbed masonry arches and deep spruce ceiling panels, no giant open roof or infinite atrium. Side aisles lead left and right into quieter waiting galleries with spruce benches, planters in stone urns and warm lamps. Floor has a restrained forest-green geometric compass mosaic OFF the straight route; do not block path with statue or fountain. Foreground side corners frame pale stone door jambs. Warm daylight through tall side windows. Keep actual usable scale of main hall, the view shows a section of its width rather than a falsely narrow corridor. Small centered title in a slim cream margin: \"SHACRAFT / 01 VESTIBULE\". Beautiful plausible Minecraft block design, not a real-world cathedral.", + "02-smash-floor": "Use case: stylized-concept.\nAsset type: architectural Minecraft building reference for SHACRAFT, design proposal, not a game screenshot.\nProject: Alpine clock station / minigame selection hub on an already built irregular foundation. All geometry must look buildable using Minecraft blocks, stairs, slabs, fences and panes. Pale sandstone and cream masonry, dark stone plinths, spruce, oxidized green copper, muted brass, warm lanterns, forest-green SHACRAFT banners with a simple gold S. Restrained excellent craftsmanship, architectural clarity, no visual clutter. No sci-fi, no neon holograms, no modern shopping mall. Tiny Minecraft players only as 2-block height scale figures.\nHard spatial brief: foundation east-west 115 blocks overall and north-south 63 blocks maximum. Broad main hall 68 blocks wide by 48 deep, west pavilion 23 wide and east pavilion24 wide, both42 deep. A south entrance projection39 wide by16 deep is offset 11 blocks east of the whole building center. Entrance faces south and has a21-block-wide external staircase rising3 blocks. First floor feetY99, next floor feetY113; a two-block deck atY111..112 gives12 blocks of first-floor clear height. Second floor has11 blocks clear height, next ceiling beginsY124. Lift housing9x9 blocks with usable cabin5x5, on entrance axis x=-6, z=-120, opens south on BOTH floors. Interior rooms have2-block-thick perimeter walls and believable aligned masonry columns. Main ridgeY140; clocktower peakY158. Only two floors are furnished. Future floors remain a design reservation, no extra themed rooms.\n\nPrimary request: Draw ONE large, beautiful interior architectural perspective of the SECOND-FLOOR SMASH SELECTION HALL, landscape16:9. Camera near south-east part of main hall looking north-west. Include the green/brass9x9 lift core on right, its opening on its south face visible obliquely, with clear sign \"1 LOBBY\". Left foreground is a19x15-block enclosed decorative display on an intact solid floor: three small voxel floating rock islands mounted over a dark inset plinth, two static small Minecraft figures, one knocked away on a restrained gold block motion arc. This is a MUSEUM DIORAMA with low glass-and-stone protective railing, not an actual combat arena, no lethal pit, no broken public floor. A seven-block-clear aisle passes around display.\nAlong north wall in background EXACTLY SIX recessed selection bays, each7 blocks wide, separated by3 blocks; they have spruce Minecraft signboards at player reach height with a small thumbnail map plaque above and numbered headings \"01\", \"02\", \"03\", \"04\", \"05\", \"06\". These are placeholders for future arenas, no invented live player counts or glowing portals. A large tasteful block-built \"SMASH\" header above back bays. West wall small visual plaques illustrate damage, knockback, double jump using voxel pictograms. SMASH accent colors burnt orange and dark red banners, balanced with Shacraft green. Keep the same cream stone columns, spruce, green copper and brass as vestibule. Ceiling is11 blocks above floor, visible solid ceiling and rafters, not infinitely tall. Daylight, warm readable practical space. Title in slim cream margin: \"SHACRAFT / 02 SMASH\".", + "03-cutaway": "Use case: stylized-concept.\nAsset type: architectural Minecraft building reference for SHACRAFT, design proposal, not a game screenshot.\nProject: Alpine clock station / minigame selection hub on an already built irregular foundation. All geometry must look buildable using Minecraft blocks, stairs, slabs, fences and panes. Pale sandstone and cream masonry, dark stone plinths, spruce, oxidized green copper, muted brass, warm lanterns, forest-green SHACRAFT banners with a simple gold S. Restrained excellent craftsmanship, architectural clarity, no visual clutter. No sci-fi, no neon holograms, no modern shopping mall. Tiny Minecraft players only as 2-block height scale figures.\nHard spatial brief: foundation east-west 115 blocks overall and north-south 63 blocks maximum. Broad main hall 68 blocks wide by 48 deep, west pavilion 23 wide and east pavilion24 wide, both42 deep. A south entrance projection39 wide by16 deep is offset 11 blocks east of the whole building center. Entrance faces south and has a21-block-wide external staircase rising3 blocks. First floor feetY99, next floor feetY113; a two-block deck atY111..112 gives12 blocks of first-floor clear height. Second floor has11 blocks clear height, next ceiling beginsY124. Lift housing9x9 blocks with usable cabin5x5, on entrance axis x=-6, z=-120, opens south on BOTH floors. Interior rooms have2-block-thick perimeter walls and believable aligned masonry columns. Main ridgeY140; clocktower peakY158. Only two floors are furnished. Future floors remain a design reservation, no extra themed rooms.\n\nPrimary request: Create ONE polished landscape architectural cutaway axonometric reference sheet, wide3:2 format, ivory background, thin dark green labels. A dominant three-quarter view looking from south-east shows the WHOLE clock station, broad horizontal wings, original grand green copper roofs, prominent slightly off-center front clock tower. Remove front and right portions of wall and selected roof to reveal EXACTLY TWO furnished stacked floors without concealing their connection. Ground floor is a cream stone vestibule with central arrival aisle, benches in wings, square green/brass lift core at rear of arrival aisle. Upper floor directly above has same aligned core, a small contained floating-island display in left half, six signboard alcoves along rear wall. Floor slab between them clearly shown, consistent14-block floor-to-floor spacing. Remaining roofs are translucent linework only where needed. Upper clock tower contains clockwork silhouette, not extra furnished game rooms. Foundation shape must remain broad main hall plus end pavilions, shallow rear mainhall projection and south projecting tower entrance, not a rectangular skyscraper. No extra buildings or landscape.\nAdd three neat callouts only: \"01 VESTIBULE / WALK Y99\", \"02 SMASH / WALK Y113\", \"LIFT / 5 x 5 CLEAR\". Add small title \"SHACRAFT / CLOCK STATION\" and subtitle \"Two-floor interior concept\". Add note at bottom \"Concept view - coordinate plan governs dimensions\". Strong visually legible separation of floors, genuine Minecraft voxel scale, warm copper and stone, refined presentation, no dense fake dimensions." + } +} diff --git a/examples/layout/shacraft-access.json b/examples/layout/shacraft-access.json new file mode 100644 index 0000000..3e2ea4a --- /dev/null +++ b/examples/layout/shacraft-access.json @@ -0,0 +1,2891 @@ +{ + "schema": "shacraft-layout-access-supplement-v1", + "world": "shacraft_lobby_v2", + "base_plan_unchanged": true, + "routes": [ + { + "id": "boardwalk-north-stairs", + "name": "Portal court / boardwalk stair", + "waypoints": [ + [ + -167, + -82 + ], + [ + -172, + -77 + ], + [ + -177, + -69 + ] + ], + "points": [ + [ + -167, + -82 + ], + [ + -168, + -81 + ], + [ + -169, + -80 + ], + [ + -170, + -80 + ], + [ + -170, + -79 + ], + [ + -171, + -78 + ], + [ + -172, + -77 + ], + [ + -172, + -76 + ], + [ + -173, + -75 + ], + [ + -174, + -75 + ], + [ + -174, + -74 + ], + [ + -174, + -73 + ], + [ + -175, + -72 + ], + [ + -176, + -71 + ], + [ + -176, + -70 + ], + [ + -177, + -69 + ] + ], + "width": 3, + "role": "stairs", + "minimum_marker_y": 51, + "design_intent": "Reserve descending stair flight and water landing; never replace water.", + "labels": [ + { + "point": [ + -167, + -82 + ], + "text": "LAKE BOARDWALK / FUTURE STAIRS" + } + ], + "analysis": { + "ground_min": 43.6, + "ground_max": 56.7, + "water_samples": 5, + "length": 18.3 + }, + "marker_y_by_point": [ + 56, + 55, + 54, + 53, + 53, + 52, + 51, + 51, + 51, + 51, + 51, + 51, + 51, + 51, + 51, + 51 + ] + }, + { + "id": "boardwalk-south-stairs", + "name": "Waterworks / boardwalk stair", + "waypoints": [ + [ + -145, + 70 + ], + [ + -158, + 72 + ], + [ + -173, + 74 + ], + [ + -189, + 75 + ] + ], + "points": [ + [ + -145, + 70 + ], + [ + -146, + 70 + ], + [ + -147, + 70 + ], + [ + -148, + 70 + ], + [ + -149, + 71 + ], + [ + -150, + 71 + ], + [ + -151, + 71 + ], + [ + -152, + 71 + ], + [ + -153, + 71 + ], + [ + -154, + 71 + ], + [ + -155, + 72 + ], + [ + -156, + 72 + ], + [ + -157, + 72 + ], + [ + -158, + 72 + ], + [ + -159, + 72 + ], + [ + -160, + 72 + ], + [ + -161, + 72 + ], + [ + -162, + 72 + ], + [ + -163, + 73 + ], + [ + -164, + 73 + ], + [ + -165, + 73 + ], + [ + -166, + 73 + ], + [ + -167, + 73 + ], + [ + -168, + 73 + ], + [ + -169, + 74 + ], + [ + -170, + 74 + ], + [ + -171, + 74 + ], + [ + -172, + 74 + ], + [ + -173, + 74 + ], + [ + -174, + 74 + ], + [ + -175, + 74 + ], + [ + -176, + 74 + ], + [ + -177, + 74 + ], + [ + -178, + 74 + ], + [ + -179, + 74 + ], + [ + -180, + 74 + ], + [ + -181, + 74 + ], + [ + -181, + 75 + ], + [ + -182, + 75 + ], + [ + -183, + 75 + ], + [ + -184, + 75 + ], + [ + -185, + 75 + ], + [ + -186, + 75 + ], + [ + -187, + 75 + ], + [ + -188, + 75 + ], + [ + -189, + 75 + ] + ], + "width": 3, + "role": "stairs", + "minimum_marker_y": 51, + "design_intent": "Reserve an approximately 45-block descent from the viewing terrace to the Y51 boardwalk.", + "labels": [ + { + "point": [ + -145, + 70 + ], + "text": "BOARDWALK / FUTURE STAIRS" + } + ], + "analysis": { + "ground_min": 45.7, + "ground_max": 69.2, + "water_samples": 4, + "length": 46.7 + }, + "marker_y_by_point": [ + 69, + 68, + 68, + 68, + 67, + 67, + 67, + 66, + 66, + 65, + 65, + 65, + 64, + 63, + 63, + 62, + 61, + 61, + 61, + 60, + 59, + 59, + 59, + 58, + 58, + 58, + 57, + 56, + 56, + 55, + 55, + 54, + 54, + 53, + 52, + 52, + 51, + 51, + 51, + 51, + 51, + 51, + 51, + 51, + 51, + 51 + ] + }, + { + "id": "market-door-1", + "name": "Bakery entrance", + "waypoints": [ + [ + -207, + 205 + ], + [ + -211, + 202 + ], + [ + -218, + 197 + ] + ], + "points": [ + [ + -207, + 205 + ], + [ + -208, + 204 + ], + [ + -209, + 204 + ], + [ + -209, + 203 + ], + [ + -210, + 203 + ], + [ + -211, + 202 + ], + [ + -212, + 201 + ], + [ + -213, + 201 + ], + [ + -213, + 200 + ], + [ + -214, + 200 + ], + [ + -215, + 199 + ], + [ + -216, + 199 + ], + [ + -216, + 198 + ], + [ + -217, + 198 + ], + [ + -218, + 197 + ] + ], + "width": 3, + "role": "secondary", + "design_intent": "Public square boundary to the shop doorway; a reservation, not a complete street.", + "analysis": { + "ground_min": 69.1, + "ground_max": 72.4, + "water_samples": 0, + "length": 16.1 + } + }, + { + "id": "market-door-2", + "name": "Crafts hall entrance", + "waypoints": [ + [ + -182, + 204 + ], + [ + -178, + 204 + ] + ], + "points": [ + [ + -182, + 204 + ], + [ + -181, + 204 + ], + [ + -180, + 204 + ], + [ + -179, + 204 + ], + [ + -178, + 204 + ] + ], + "width": 3, + "role": "secondary", + "design_intent": "Public square boundary to the shop doorway; a reservation, not a complete street.", + "analysis": { + "ground_min": 66.1, + "ground_max": 66.8, + "water_samples": 0, + "length": 4.0 + } + }, + { + "id": "market-door-3", + "name": "Tea house entrance", + "waypoints": [ + [ + -173, + 230 + ], + [ + -170, + 230 + ] + ], + "points": [ + [ + -173, + 230 + ], + [ + -172, + 230 + ], + [ + -171, + 230 + ], + [ + -170, + 230 + ] + ], + "width": 3, + "role": "secondary", + "design_intent": "Public square boundary to the shop doorway; a reservation, not a complete street.", + "analysis": { + "ground_min": 63.8, + "ground_max": 64.0, + "water_samples": 0, + "length": 3.0 + } + }, + { + "id": "market-door-4", + "name": "Guild shop entrance", + "waypoints": [ + [ + -186, + 248 + ], + [ + -181, + 256 + ], + [ + -179, + 263 + ] + ], + "points": [ + [ + -186, + 248 + ], + [ + -186, + 249 + ], + [ + -185, + 250 + ], + [ + -184, + 250 + ], + [ + -184, + 251 + ], + [ + -184, + 252 + ], + [ + -183, + 253 + ], + [ + -182, + 254 + ], + [ + -182, + 255 + ], + [ + -181, + 256 + ], + [ + -181, + 257 + ], + [ + -180, + 258 + ], + [ + -180, + 259 + ], + [ + -180, + 260 + ], + [ + -180, + 261 + ], + [ + -179, + 262 + ], + [ + -179, + 263 + ] + ], + "width": 3, + "role": "secondary", + "design_intent": "Public square boundary to the shop doorway; a reservation, not a complete street.", + "analysis": { + "ground_min": 62.0, + "ground_max": 62.9, + "water_samples": 0, + "length": 18.5 + } + }, + { + "id": "market-door-5", + "name": "Workshop entrance", + "waypoints": [ + [ + -205, + 247 + ], + [ + -209, + 255 + ], + [ + -213, + 264 + ] + ], + "points": [ + [ + -205, + 247 + ], + [ + -205, + 248 + ], + [ + -206, + 249 + ], + [ + -206, + 250 + ], + [ + -207, + 251 + ], + [ + -208, + 252 + ], + [ + -208, + 253 + ], + [ + -209, + 254 + ], + [ + -209, + 255 + ], + [ + -209, + 256 + ], + [ + -210, + 257 + ], + [ + -210, + 258 + ], + [ + -211, + 259 + ], + [ + -211, + 260 + ], + [ + -212, + 261 + ], + [ + -212, + 262 + ], + [ + -213, + 263 + ], + [ + -213, + 264 + ] + ], + "width": 3, + "role": "secondary", + "design_intent": "Public square boundary to the shop doorway; a reservation, not a complete street.", + "analysis": { + "ground_min": 61.2, + "ground_max": 62.3, + "water_samples": 0, + "length": 20.3 + } + }, + { + "id": "market-door-6", + "name": "Guild hall entrance", + "waypoints": [ + [ + -214, + 237 + ], + [ + -218, + 238 + ], + [ + -222, + 238 + ] + ], + "points": [ + [ + -214, + 237 + ], + [ + -215, + 237 + ], + [ + -216, + 237 + ], + [ + -216, + 238 + ], + [ + -217, + 238 + ], + [ + -218, + 238 + ], + [ + -219, + 238 + ], + [ + -220, + 238 + ], + [ + -221, + 238 + ], + [ + -222, + 238 + ] + ], + "width": 3, + "role": "secondary", + "design_intent": "Public square boundary to the shop doorway; a reservation, not a complete street.", + "analysis": { + "ground_min": 63.4, + "ground_max": 64.6, + "water_samples": 0, + "length": 9.0 + } + }, + { + "id": "palm-garden-main-arc", + "name": "Palm courtyard main promenade", + "waypoints": [ + [ + 185, + -9 + ], + [ + 185, + -6 + ], + [ + 187, + -6 + ], + [ + 188, + -6 + ], + [ + 190, + -6 + ], + [ + 191, + -5 + ], + [ + 192, + -5 + ], + [ + 194, + -5 + ], + [ + 195, + -4 + ], + [ + 197, + -4 + ], + [ + 198, + -3 + ], + [ + 199, + -2 + ], + [ + 201, + -1 + ], + [ + 202, + -1 + ], + [ + 203, + 0 + ], + [ + 204, + 1 + ], + [ + 205, + 2 + ], + [ + 206, + 3 + ], + [ + 207, + 5 + ], + [ + 208, + 6 + ], + [ + 209, + 7 + ], + [ + 210, + 8 + ], + [ + 211, + 10 + ], + [ + 211, + 11 + ], + [ + 212, + 12 + ], + [ + 213, + 14 + ], + [ + 213, + 15 + ], + [ + 213, + 17 + ], + [ + 214, + 18 + ], + [ + 214, + 20 + ], + [ + 214, + 21 + ], + [ + 214, + 23 + ], + [ + 214, + 24 + ], + [ + 214, + 26 + ], + [ + 214, + 27 + ], + [ + 213, + 29 + ], + [ + 213, + 30 + ], + [ + 213, + 32 + ], + [ + 212, + 33 + ], + [ + 212, + 34 + ], + [ + 211, + 36 + ], + [ + 210, + 37 + ], + [ + 210, + 38 + ], + [ + 209, + 40 + ], + [ + 208, + 41 + ], + [ + 207, + 42 + ], + [ + 206, + 43 + ], + [ + 205, + 44 + ], + [ + 204, + 45 + ], + [ + 202, + 46 + ], + [ + 201, + 47 + ], + [ + 201, + 46 + ] + ], + "points": [ + [ + 185, + -9 + ], + [ + 185, + -8 + ], + [ + 185, + -7 + ], + [ + 185, + -6 + ], + [ + 186, + -6 + ], + [ + 187, + -6 + ], + [ + 188, + -6 + ], + [ + 189, + -6 + ], + [ + 190, + -6 + ], + [ + 191, + -5 + ], + [ + 192, + -5 + ], + [ + 193, + -5 + ], + [ + 194, + -5 + ], + [ + 194, + -4 + ], + [ + 195, + -4 + ], + [ + 196, + -4 + ], + [ + 197, + -4 + ], + [ + 198, + -4 + ], + [ + 198, + -3 + ], + [ + 198, + -2 + ], + [ + 199, + -2 + ], + [ + 200, + -2 + ], + [ + 200, + -1 + ], + [ + 201, + -1 + ], + [ + 202, + -1 + ], + [ + 202, + 0 + ], + [ + 203, + 0 + ], + [ + 204, + 0 + ], + [ + 204, + 1 + ], + [ + 204, + 2 + ], + [ + 205, + 2 + ], + [ + 206, + 2 + ], + [ + 206, + 3 + ], + [ + 206, + 4 + ], + [ + 207, + 4 + ], + [ + 207, + 5 + ], + [ + 208, + 6 + ], + [ + 209, + 7 + ], + [ + 210, + 8 + ], + [ + 210, + 9 + ], + [ + 211, + 9 + ], + [ + 211, + 10 + ], + [ + 211, + 11 + ], + [ + 212, + 12 + ], + [ + 212, + 13 + ], + [ + 213, + 13 + ], + [ + 213, + 14 + ], + [ + 213, + 15 + ], + [ + 213, + 16 + ], + [ + 213, + 17 + ], + [ + 214, + 18 + ], + [ + 214, + 19 + ], + [ + 214, + 20 + ], + [ + 214, + 21 + ], + [ + 214, + 22 + ], + [ + 214, + 23 + ], + [ + 214, + 24 + ], + [ + 214, + 25 + ], + [ + 214, + 26 + ], + [ + 214, + 27 + ], + [ + 214, + 28 + ], + [ + 213, + 28 + ], + [ + 213, + 29 + ], + [ + 213, + 30 + ], + [ + 213, + 31 + ], + [ + 213, + 32 + ], + [ + 212, + 32 + ], + [ + 212, + 33 + ], + [ + 212, + 34 + ], + [ + 212, + 35 + ], + [ + 211, + 35 + ], + [ + 211, + 36 + ], + [ + 210, + 36 + ], + [ + 210, + 37 + ], + [ + 210, + 38 + ], + [ + 210, + 39 + ], + [ + 209, + 39 + ], + [ + 209, + 40 + ], + [ + 208, + 40 + ], + [ + 208, + 41 + ], + [ + 208, + 42 + ], + [ + 207, + 42 + ], + [ + 206, + 42 + ], + [ + 206, + 43 + ], + [ + 206, + 44 + ], + [ + 205, + 44 + ], + [ + 204, + 44 + ], + [ + 204, + 45 + ], + [ + 203, + 45 + ], + [ + 203, + 46 + ], + [ + 202, + 46 + ], + [ + 201, + 47 + ], + [ + 201, + 46 + ] + ], + "width": 3, + "role": "secondary", + "design_intent": "Connect harbor and southern garden promenade around the outside of the glasshouse.", + "analysis": { + "ground_min": 78.1, + "ground_max": 85.1, + "water_samples": 0, + "length": 94.9 + } + }, + { + "id": "palm-garden-west-arc", + "name": "Palm courtyard west approach", + "waypoints": [ + [ + 160, + 23 + ], + [ + 156, + 23 + ], + [ + 156, + 22 + ], + [ + 156, + 21 + ], + [ + 156, + 20 + ], + [ + 156, + 19 + ], + [ + 156, + 18 + ], + [ + 157, + 17 + ], + [ + 157, + 17 + ], + [ + 157, + 16 + ], + [ + 157, + 15 + ], + [ + 157, + 14 + ], + [ + 158, + 13 + ], + [ + 158, + 12 + ], + [ + 158, + 11 + ], + [ + 159, + 10 + ], + [ + 159, + 10 + ], + [ + 160, + 9 + ], + [ + 160, + 8 + ], + [ + 161, + 7 + ], + [ + 161, + 6 + ], + [ + 162, + 6 + ], + [ + 162, + 5 + ], + [ + 163, + 4 + ], + [ + 164, + 4 + ], + [ + 164, + 3 + ], + [ + 165, + 2 + ], + [ + 166, + 2 + ], + [ + 166, + 1 + ], + [ + 167, + 0 + ], + [ + 168, + 0 + ], + [ + 168, + -1 + ], + [ + 169, + -1 + ], + [ + 170, + -2 + ], + [ + 171, + -2 + ], + [ + 172, + -3 + ], + [ + 172, + -3 + ], + [ + 173, + -4 + ], + [ + 174, + -4 + ], + [ + 175, + -4 + ], + [ + 176, + -5 + ], + [ + 177, + -5 + ], + [ + 178, + -5 + ], + [ + 179, + -5 + ], + [ + 179, + -5 + ], + [ + 180, + -6 + ], + [ + 181, + -6 + ], + [ + 182, + -6 + ], + [ + 183, + -6 + ], + [ + 184, + -6 + ], + [ + 185, + -6 + ], + [ + 185, + -9 + ] + ], + "points": [ + [ + 160, + 23 + ], + [ + 159, + 23 + ], + [ + 158, + 23 + ], + [ + 157, + 23 + ], + [ + 156, + 23 + ], + [ + 156, + 22 + ], + [ + 156, + 21 + ], + [ + 156, + 20 + ], + [ + 156, + 19 + ], + [ + 156, + 18 + ], + [ + 157, + 17 + ], + [ + 157, + 16 + ], + [ + 157, + 15 + ], + [ + 157, + 14 + ], + [ + 158, + 14 + ], + [ + 158, + 13 + ], + [ + 158, + 12 + ], + [ + 158, + 11 + ], + [ + 158, + 10 + ], + [ + 159, + 10 + ], + [ + 160, + 10 + ], + [ + 160, + 9 + ], + [ + 160, + 8 + ], + [ + 161, + 7 + ], + [ + 161, + 6 + ], + [ + 162, + 6 + ], + [ + 162, + 5 + ], + [ + 162, + 4 + ], + [ + 163, + 4 + ], + [ + 164, + 4 + ], + [ + 164, + 3 + ], + [ + 164, + 2 + ], + [ + 165, + 2 + ], + [ + 166, + 2 + ], + [ + 166, + 1 + ], + [ + 166, + 0 + ], + [ + 167, + 0 + ], + [ + 168, + 0 + ], + [ + 168, + -1 + ], + [ + 169, + -1 + ], + [ + 170, + -2 + ], + [ + 171, + -2 + ], + [ + 172, + -2 + ], + [ + 172, + -3 + ], + [ + 172, + -4 + ], + [ + 173, + -4 + ], + [ + 174, + -4 + ], + [ + 175, + -4 + ], + [ + 176, + -4 + ], + [ + 176, + -5 + ], + [ + 177, + -5 + ], + [ + 178, + -5 + ], + [ + 179, + -5 + ], + [ + 180, + -6 + ], + [ + 181, + -6 + ], + [ + 182, + -6 + ], + [ + 183, + -6 + ], + [ + 184, + -6 + ], + [ + 185, + -6 + ], + [ + 185, + -7 + ], + [ + 185, + -8 + ], + [ + 185, + -9 + ] + ], + "width": 3, + "role": "secondary", + "design_intent": "Connect the spawn bridge to the main garden promenade without passing through the glasshouse.", + "analysis": { + "ground_min": 79.9, + "ground_max": 85.7, + "water_samples": 0, + "length": 62.7 + } + }, + { + "id": "winter-garden-north-arc", + "name": "Winter garden north promenade", + "waypoints": [ + [ + 180, + 94 + ], + [ + 185, + 94 + ], + [ + 185, + 93 + ], + [ + 185, + 91 + ], + [ + 185, + 90 + ], + [ + 186, + 88 + ], + [ + 186, + 87 + ], + [ + 187, + 86 + ], + [ + 188, + 84 + ], + [ + 188, + 83 + ], + [ + 189, + 82 + ], + [ + 190, + 81 + ], + [ + 191, + 80 + ], + [ + 192, + 79 + ], + [ + 194, + 78 + ], + [ + 195, + 77 + ], + [ + 196, + 76 + ], + [ + 198, + 75 + ], + [ + 199, + 74 + ], + [ + 200, + 74 + ], + [ + 202, + 73 + ], + [ + 204, + 73 + ], + [ + 205, + 73 + ], + [ + 207, + 72 + ], + [ + 209, + 72 + ], + [ + 210, + 72 + ], + [ + 212, + 72 + ], + [ + 213, + 72 + ], + [ + 215, + 72 + ], + [ + 217, + 73 + ], + [ + 218, + 73 + ], + [ + 220, + 73 + ], + [ + 222, + 74 + ], + [ + 223, + 74 + ], + [ + 224, + 75 + ], + [ + 226, + 76 + ], + [ + 227, + 77 + ], + [ + 228, + 78 + ], + [ + 230, + 79 + ], + [ + 231, + 80 + ], + [ + 232, + 81 + ], + [ + 233, + 82 + ], + [ + 234, + 83 + ], + [ + 234, + 84 + ], + [ + 235, + 86 + ], + [ + 236, + 87 + ], + [ + 236, + 88 + ], + [ + 237, + 90 + ], + [ + 237, + 91 + ], + [ + 237, + 93 + ], + [ + 237, + 94 + ], + [ + 238, + 97 + ] + ], + "points": [ + [ + 180, + 94 + ], + [ + 181, + 94 + ], + [ + 182, + 94 + ], + [ + 183, + 94 + ], + [ + 184, + 94 + ], + [ + 185, + 94 + ], + [ + 185, + 93 + ], + [ + 185, + 92 + ], + [ + 185, + 91 + ], + [ + 185, + 90 + ], + [ + 185, + 89 + ], + [ + 186, + 89 + ], + [ + 186, + 88 + ], + [ + 186, + 87 + ], + [ + 186, + 86 + ], + [ + 187, + 86 + ], + [ + 187, + 85 + ], + [ + 188, + 85 + ], + [ + 188, + 84 + ], + [ + 188, + 83 + ], + [ + 188, + 82 + ], + [ + 189, + 82 + ], + [ + 190, + 82 + ], + [ + 190, + 81 + ], + [ + 190, + 80 + ], + [ + 191, + 80 + ], + [ + 192, + 80 + ], + [ + 192, + 79 + ], + [ + 193, + 79 + ], + [ + 193, + 78 + ], + [ + 194, + 78 + ], + [ + 195, + 77 + ], + [ + 196, + 76 + ], + [ + 197, + 76 + ], + [ + 197, + 75 + ], + [ + 198, + 75 + ], + [ + 198, + 74 + ], + [ + 199, + 74 + ], + [ + 200, + 74 + ], + [ + 201, + 74 + ], + [ + 201, + 73 + ], + [ + 202, + 73 + ], + [ + 203, + 73 + ], + [ + 204, + 73 + ], + [ + 205, + 73 + ], + [ + 206, + 73 + ], + [ + 206, + 72 + ], + [ + 207, + 72 + ], + [ + 208, + 72 + ], + [ + 209, + 72 + ], + [ + 210, + 72 + ], + [ + 211, + 72 + ], + [ + 212, + 72 + ], + [ + 213, + 72 + ], + [ + 214, + 72 + ], + [ + 215, + 72 + ], + [ + 216, + 72 + ], + [ + 216, + 73 + ], + [ + 217, + 73 + ], + [ + 218, + 73 + ], + [ + 219, + 73 + ], + [ + 220, + 73 + ], + [ + 221, + 73 + ], + [ + 221, + 74 + ], + [ + 222, + 74 + ], + [ + 223, + 74 + ], + [ + 224, + 74 + ], + [ + 224, + 75 + ], + [ + 225, + 75 + ], + [ + 225, + 76 + ], + [ + 226, + 76 + ], + [ + 227, + 77 + ], + [ + 228, + 78 + ], + [ + 229, + 78 + ], + [ + 229, + 79 + ], + [ + 230, + 79 + ], + [ + 230, + 80 + ], + [ + 231, + 80 + ], + [ + 232, + 80 + ], + [ + 232, + 81 + ], + [ + 232, + 82 + ], + [ + 233, + 82 + ], + [ + 234, + 82 + ], + [ + 234, + 83 + ], + [ + 234, + 84 + ], + [ + 234, + 85 + ], + [ + 235, + 85 + ], + [ + 235, + 86 + ], + [ + 236, + 86 + ], + [ + 236, + 87 + ], + [ + 236, + 88 + ], + [ + 236, + 89 + ], + [ + 237, + 89 + ], + [ + 237, + 90 + ], + [ + 237, + 91 + ], + [ + 237, + 92 + ], + [ + 237, + 93 + ], + [ + 237, + 94 + ], + [ + 237, + 95 + ], + [ + 238, + 96 + ], + [ + 238, + 97 + ] + ], + "width": 3, + "role": "secondary", + "design_intent": "A court path links the district promenade to the scenic lookout on the east side.", + "analysis": { + "ground_min": 82.3, + "ground_max": 89.0, + "water_samples": 0, + "length": 102.1 + } + }, + { + "id": "observatory-west-arc", + "name": "Observatory courtyard connection", + "waypoints": [ + [ + 218, + 137 + ], + [ + 220, + 139 + ], + [ + 219, + 139 + ], + [ + 219, + 139 + ], + [ + 219, + 139 + ], + [ + 218, + 140 + ], + [ + 218, + 140 + ], + [ + 218, + 140 + ], + [ + 218, + 141 + ], + [ + 217, + 141 + ], + [ + 217, + 142 + ], + [ + 217, + 142 + ], + [ + 217, + 142 + ], + [ + 216, + 143 + ], + [ + 216, + 143 + ], + [ + 216, + 143 + ], + [ + 216, + 144 + ], + [ + 216, + 144 + ], + [ + 216, + 145 + ], + [ + 215, + 145 + ], + [ + 215, + 145 + ], + [ + 215, + 146 + ], + [ + 215, + 146 + ], + [ + 215, + 147 + ], + [ + 215, + 147 + ], + [ + 215, + 147 + ], + [ + 214, + 148 + ], + [ + 214, + 148 + ], + [ + 214, + 149 + ], + [ + 214, + 149 + ], + [ + 214, + 149 + ], + [ + 214, + 150 + ], + [ + 214, + 150 + ], + [ + 214, + 151 + ], + [ + 214, + 151 + ], + [ + 214, + 152 + ], + [ + 214, + 152 + ], + [ + 214, + 152 + ], + [ + 214, + 153 + ], + [ + 214, + 153 + ], + [ + 214, + 154 + ], + [ + 214, + 154 + ], + [ + 214, + 155 + ], + [ + 214, + 155 + ], + [ + 214, + 155 + ], + [ + 214, + 156 + ], + [ + 214, + 156 + ], + [ + 215, + 157 + ], + [ + 215, + 157 + ], + [ + 215, + 157 + ], + [ + 215, + 158 + ], + [ + 218, + 157 + ] + ], + "points": [ + [ + 218, + 137 + ], + [ + 219, + 138 + ], + [ + 220, + 139 + ], + [ + 219, + 139 + ], + [ + 218, + 140 + ], + [ + 218, + 141 + ], + [ + 217, + 141 + ], + [ + 217, + 142 + ], + [ + 216, + 142 + ], + [ + 216, + 143 + ], + [ + 216, + 144 + ], + [ + 216, + 145 + ], + [ + 215, + 145 + ], + [ + 215, + 146 + ], + [ + 215, + 147 + ], + [ + 214, + 148 + ], + [ + 214, + 149 + ], + [ + 214, + 150 + ], + [ + 214, + 151 + ], + [ + 214, + 152 + ], + [ + 214, + 153 + ], + [ + 214, + 154 + ], + [ + 214, + 155 + ], + [ + 214, + 156 + ], + [ + 215, + 157 + ], + [ + 215, + 158 + ], + [ + 216, + 158 + ], + [ + 217, + 157 + ], + [ + 218, + 157 + ] + ], + "width": 3, + "role": "secondary", + "design_intent": "Close the small gap between northern and southern promenade approaches outside the observatory.", + "analysis": { + "ground_min": 82.6, + "ground_max": 86.1, + "water_samples": 0, + "length": 30.5 + } + } + ], + "notes": [ + "Do not apply blindly over the in-flight base marker manifest: reconcile desired blocks against live/scheduled markers.", + "For stairs, minimum_marker_y=51 keeps water cells untouched; land segments follow the actual surface.", + "White path outlines across colored courtyard boundaries indicate entrances, not holes in completed walls.", + "Garden arcs lie in the reserved walk bands outside their glasshouse footprints." + ] +} diff --git a/examples/layout/shacraft-lobby-layout.json b/examples/layout/shacraft-lobby-layout.json new file mode 100644 index 0000000..529e2a6 --- /dev/null +++ b/examples/layout/shacraft-lobby-layout.json @@ -0,0 +1,16064 @@ +{ + "schema": "shacraft-layout-study-v1", + "world": "shacraft_lobby_v2", + "bounds": { + "min_x": -384, + "max_x": 383, + "min_z": -384, + "max_z": 383 + }, + "water_y": 48, + "status": "offline design; not a world mutation or live snapshot", + "districts": [ + { + "id": "01", + "name": "Arrival square", + "label": [ + 0, + 8 + ], + "color": "#84d440", + "intent": "An open hexagonal plaza; preserve the raised grassy crown. Main north axis reveals the clock tower." + }, + { + "id": "02", + "name": "Clock station", + "label": [ + -18, + -119 + ], + "color": "#f4d35e", + "intent": "A long station hall with projecting clock tower and two end pavilions. Its forecourt faces spawn." + }, + { + "id": "03", + "name": "Portal concourse", + "label": [ + -201, + -156 + ], + "color": "#bc80ef", + "intent": "A chamfered hall with six separate portal-bay footprints and a sunken-feeling garden approach." + }, + { + "id": "04", + "name": "Airship harbor", + "label": [ + 179, + -137 + ], + "color": "#40c8e3", + "intent": "East-bank terminal with three west-facing piers above the water gorge. One future flagship uses the middle pier." + }, + { + "id": "05", + "name": "Sky gardens", + "label": [ + 207, + 88 + ], + "color": "#64d9a0", + "intent": "Three distinct rounded landmarks connected by winding garden paths: greenhouse, winter garden, observatory." + }, + { + "id": "06", + "name": "Market quarter", + "label": [ + -195, + 225 + ], + "color": "#ef9863", + "intent": "Six small footprints follow the slope around a compact square. Use stepped streets and individual foundations." + }, + { + "id": "07", + "name": "Arrival viaduct", + "label": [ + 8, + 284 + ], + "color": "#f5f4e9", + "intent": "A long south approach follows the valley ridge; the final viaduct crosses the merging river branches." + }, + { + "id": "08", + "name": "Lake waterworks", + "label": [ + -135, + 43 + ], + "color": "#4ca3ff", + "intent": "A compact pumping house above the east lake shore and a low over-water promenade." + }, + { + "id": "09", + "name": "Scenic overlooks", + "label": [ + 48, + -180 + ], + "color": "#f07eaf", + "intent": "Small optional lookouts frame the valley, lake and arrival route; no mountain flattening." + } + ], + "features": [ + { + "id": "arrival-hex", + "district": "01", + "name": "Arrival square", + "type": "polygon", + "points": [ + [ + 0, + -37 + ], + [ + 39, + -15 + ], + [ + 43, + 31 + ], + [ + 0, + 57 + ], + [ + -43, + 31 + ], + [ + -39, + -15 + ], + [ + 0, + -37 + ] + ], + "color": "#84d440", + "role": "plaza", + "ground": { + "min": 88.39, + "max": 96.81, + "water_samples": 0, + "samples": 5753 + } + }, + { + "id": "spawn-medallion", + "district": "01", + "name": "Shacraft medallion reserve", + "type": "polygon", + "points": [ + [ + 16, + 9 + ], + [ + 16, + 12 + ], + [ + 15, + 14 + ], + [ + 14, + 17 + ], + [ + 12, + 19 + ], + [ + 10, + 21 + ], + [ + 8, + 23 + ], + [ + 5, + 24 + ], + [ + 3, + 25 + ], + [ + 0, + 25 + ], + [ + -3, + 25 + ], + [ + -5, + 24 + ], + [ + -8, + 23 + ], + [ + -10, + 21 + ], + [ + -12, + 19 + ], + [ + -14, + 17 + ], + [ + -15, + 14 + ], + [ + -16, + 12 + ], + [ + -16, + 9 + ], + [ + -16, + 6 + ], + [ + -15, + 4 + ], + [ + -14, + 1 + ], + [ + -12, + -1 + ], + [ + -10, + -3 + ], + [ + -8, + -5 + ], + [ + -5, + -6 + ], + [ + -3, + -7 + ], + [ + 0, + -7 + ], + [ + 3, + -7 + ], + [ + 5, + -6 + ], + [ + 8, + -5 + ], + [ + 10, + -3 + ], + [ + 12, + -1 + ], + [ + 14, + 1 + ], + [ + 15, + 4 + ], + [ + 16, + 6 + ], + [ + 16, + 9 + ] + ], + "color": "#84d440", + "role": "plaza", + "ground": { + "min": 92.04, + "max": 96.25, + "water_samples": 0, + "samples": 829 + } + }, + { + "id": "clock-station", + "district": "02", + "name": "Clock station and tower", + "type": "polygon", + "points": [ + [ + -74, + -139 + ], + [ + -51, + -139 + ], + [ + -51, + -145 + ], + [ + 16, + -145 + ], + [ + 16, + -139 + ], + [ + 40, + -139 + ], + [ + 40, + -98 + ], + [ + 13, + -98 + ], + [ + 13, + -83 + ], + [ + -25, + -83 + ], + [ + -25, + -98 + ], + [ + -74, + -98 + ], + [ + -74, + -139 + ] + ], + "color": "#f4d35e", + "role": "building", + "ground": { + "min": 82.33, + "max": 99.96, + "water_samples": 0, + "samples": 5823 + } + }, + { + "id": "station-clock-base", + "district": "02", + "name": "Clock tower base", + "type": "polyline", + "points": [ + [ + -15, + -114 + ], + [ + 3, + -114 + ], + [ + 3, + -96 + ], + [ + -15, + -96 + ], + [ + -15, + -114 + ] + ], + "color": "#f4d35e", + "role": "detail", + "ground": { + "min": 93.84, + "max": 97.68, + "water_samples": 0, + "samples": 5 + } + }, + { + "id": "station-pavilion--63", + "district": "02", + "name": "End pavilion", + "type": "polyline", + "points": [ + [ + -71, + -134 + ], + [ + -55, + -134 + ], + [ + -55, + -104 + ], + [ + -71, + -104 + ], + [ + -71, + -134 + ] + ], + "color": "#f4d35e", + "role": "detail", + "ground": { + "min": 83.05, + "max": 90.77, + "water_samples": 0, + "samples": 5 + } + }, + { + "id": "station-pavilion-29", + "district": "02", + "name": "End pavilion", + "type": "polyline", + "points": [ + [ + 21, + -134 + ], + [ + 37, + -134 + ], + [ + 37, + -104 + ], + [ + 21, + -104 + ], + [ + 21, + -134 + ] + ], + "color": "#f4d35e", + "role": "detail", + "ground": { + "min": 94.37, + "max": 97.71, + "water_samples": 0, + "samples": 5 + } + }, + { + "id": "station-forecourt", + "district": "02", + "name": "Station forecourt", + "type": "polygon", + "points": [ + [ + 31, + -65 + ], + [ + 31, + -63 + ], + [ + 30, + -61 + ], + [ + 28, + -59 + ], + [ + 26, + -58 + ], + [ + 24, + -56 + ], + [ + 20, + -54 + ], + [ + 17, + -53 + ], + [ + 13, + -52 + ], + [ + 9, + -51 + ], + [ + 4, + -51 + ], + [ + 0, + -50 + ], + [ + -5, + -50 + ], + [ + -10, + -50 + ], + [ + -14, + -51 + ], + [ + -19, + -51 + ], + [ + -23, + -52 + ], + [ + -27, + -53 + ], + [ + -30, + -54 + ], + [ + -34, + -56 + ], + [ + -36, + -57 + ], + [ + -38, + -59 + ], + [ + -40, + -61 + ], + [ + -41, + -63 + ], + [ + -41, + -65 + ], + [ + -41, + -67 + ], + [ + -40, + -69 + ], + [ + -38, + -71 + ], + [ + -36, + -72 + ], + [ + -34, + -74 + ], + [ + -30, + -76 + ], + [ + -27, + -77 + ], + [ + -23, + -78 + ], + [ + -19, + -79 + ], + [ + -14, + -79 + ], + [ + -10, + -80 + ], + [ + -5, + -80 + ], + [ + 0, + -80 + ], + [ + 4, + -79 + ], + [ + 9, + -79 + ], + [ + 13, + -78 + ], + [ + 17, + -77 + ], + [ + 20, + -76 + ], + [ + 24, + -74 + ], + [ + 26, + -72 + ], + [ + 28, + -71 + ], + [ + 30, + -69 + ], + [ + 31, + -67 + ], + [ + 31, + -65 + ] + ], + "color": "#f4d35e", + "role": "plaza", + "ground": { + "min": 91.26, + "max": 96.88, + "water_samples": 0, + "samples": 1745 + } + }, + { + "id": "portal-hall", + "district": "03", + "name": "Portal concourse", + "type": "polygon", + "points": [ + [ + -234, + -176 + ], + [ + -168, + -176 + ], + [ + -159, + -169 + ], + [ + -159, + -143 + ], + [ + -168, + -136 + ], + [ + -234, + -136 + ], + [ + -243, + -143 + ], + [ + -243, + -169 + ], + [ + -234, + -176 + ] + ], + "color": "#bc80ef", + "role": "building", + "ground": { + "min": 61.59, + "max": 75.51, + "water_samples": 0, + "samples": 3329 + } + }, + { + "id": "portal-bay-1", + "district": "03", + "name": "Portal bay 1", + "type": "polyline", + "points": [ + [ + -231, + -170 + ], + [ + -219, + -170 + ], + [ + -219, + -163 + ], + [ + -231, + -163 + ], + [ + -231, + -170 + ] + ], + "color": "#bc80ef", + "role": "detail", + "number": 1, + "ground": { + "min": 66.68, + "max": 70.84, + "water_samples": 0, + "samples": 5 + } + }, + { + "id": "portal-bay-2", + "district": "03", + "name": "Portal bay 2", + "type": "polyline", + "points": [ + [ + -207, + -170 + ], + [ + -195, + -170 + ], + [ + -195, + -163 + ], + [ + -207, + -163 + ], + [ + -207, + -170 + ] + ], + "color": "#bc80ef", + "role": "detail", + "number": 2, + "ground": { + "min": 66.21, + "max": 69.09, + "water_samples": 0, + "samples": 5 + } + }, + { + "id": "portal-bay-3", + "district": "03", + "name": "Portal bay 3", + "type": "polyline", + "points": [ + [ + -183, + -170 + ], + [ + -171, + -170 + ], + [ + -171, + -163 + ], + [ + -183, + -163 + ], + [ + -183, + -170 + ] + ], + "color": "#bc80ef", + "role": "detail", + "number": 3, + "ground": { + "min": 68.63, + "max": 71.21, + "water_samples": 0, + "samples": 5 + } + }, + { + "id": "portal-bay-4", + "district": "03", + "name": "Portal bay 4", + "type": "polyline", + "points": [ + [ + -231, + -149 + ], + [ + -219, + -149 + ], + [ + -219, + -142 + ], + [ + -231, + -142 + ], + [ + -231, + -149 + ] + ], + "color": "#bc80ef", + "role": "detail", + "number": 4, + "ground": { + "min": 64.73, + "max": 66.69, + "water_samples": 0, + "samples": 5 + } + }, + { + "id": "portal-bay-5", + "district": "03", + "name": "Portal bay 5", + "type": "polyline", + "points": [ + [ + -207, + -149 + ], + [ + -195, + -149 + ], + [ + -195, + -142 + ], + [ + -207, + -142 + ], + [ + -207, + -149 + ] + ], + "color": "#bc80ef", + "role": "detail", + "number": 5, + "ground": { + "min": 63.42, + "max": 65.37, + "water_samples": 0, + "samples": 5 + } + }, + { + "id": "portal-bay-6", + "district": "03", + "name": "Portal bay 6", + "type": "polyline", + "points": [ + [ + -183, + -149 + ], + [ + -171, + -149 + ], + [ + -171, + -142 + ], + [ + -183, + -142 + ], + [ + -183, + -149 + ] + ], + "color": "#bc80ef", + "role": "detail", + "number": 6, + "ground": { + "min": 66.17, + "max": 69.12, + "water_samples": 0, + "samples": 5 + } + }, + { + "id": "portal-forecourt", + "district": "03", + "name": "Portal garden court", + "type": "polygon", + "points": [ + [ + -142, + -93 + ], + [ + -142, + -91 + ], + [ + -143, + -90 + ], + [ + -143, + -88 + ], + [ + -144, + -87 + ], + [ + -146, + -86 + ], + [ + -147, + -85 + ], + [ + -149, + -83 + ], + [ + -150, + -83 + ], + [ + -152, + -82 + ], + [ + -155, + -81 + ], + [ + -157, + -81 + ], + [ + -159, + -81 + ], + [ + -161, + -81 + ], + [ + -163, + -81 + ], + [ + -166, + -82 + ], + [ + -168, + -83 + ], + [ + -169, + -83 + ], + [ + -171, + -85 + ], + [ + -172, + -86 + ], + [ + -174, + -87 + ], + [ + -175, + -88 + ], + [ + -175, + -90 + ], + [ + -176, + -91 + ], + [ + -176, + -93 + ], + [ + -176, + -95 + ], + [ + -175, + -96 + ], + [ + -175, + -98 + ], + [ + -174, + -99 + ], + [ + -172, + -100 + ], + [ + -171, + -101 + ], + [ + -169, + -103 + ], + [ + -168, + -103 + ], + [ + -166, + -104 + ], + [ + -163, + -105 + ], + [ + -161, + -105 + ], + [ + -159, + -105 + ], + [ + -157, + -105 + ], + [ + -155, + -105 + ], + [ + -152, + -104 + ], + [ + -150, + -103 + ], + [ + -149, + -103 + ], + [ + -147, + -101 + ], + [ + -146, + -100 + ], + [ + -144, + -99 + ], + [ + -143, + -98 + ], + [ + -143, + -96 + ], + [ + -142, + -95 + ], + [ + -142, + -93 + ] + ], + "color": "#bc80ef", + "role": "plaza", + "ground": { + "min": 51.68, + "max": 69.92, + "water_samples": 0, + "samples": 675 + } + }, + { + "id": "harbor-terminal", + "district": "04", + "name": "Airship terminal", + "type": "polygon", + "points": [ + [ + 169, + -170 + ], + [ + 191, + -170 + ], + [ + 199, + -162 + ], + [ + 199, + -103 + ], + [ + 191, + -95 + ], + [ + 169, + -95 + ], + [ + 161, + -103 + ], + [ + 161, + -162 + ], + [ + 169, + -170 + ] + ], + "color": "#40c8e3", + "role": "building", + "ground": { + "min": 62.31, + "max": 71.58, + "water_samples": 0, + "samples": 2820 + } + }, + { + "id": "airship-pier-1", + "district": "04", + "name": "Airship pier 1", + "type": "polyline", + "points": [ + [ + 161, + -162 + ], + [ + 107, + -162 + ], + [ + 103, + -158 + ], + [ + 107, + -154 + ], + [ + 161, + -154 + ], + [ + 161, + -162 + ] + ], + "color": "#40c8e3", + "role": "pier", + "deck_y": 79, + "number": 1, + "ground": { + "min": 65.51, + "max": 70.2, + "water_samples": 0, + "samples": 6 + } + }, + { + "id": "airship-pier-2", + "district": "04", + "name": "Airship pier 2", + "type": "polyline", + "points": [ + [ + 161, + -136 + ], + [ + 107, + -136 + ], + [ + 103, + -132 + ], + [ + 107, + -128 + ], + [ + 161, + -128 + ], + [ + 161, + -136 + ] + ], + "color": "#40c8e3", + "role": "pier", + "deck_y": 79, + "number": 2, + "ground": { + "min": 61.88, + "max": 71.95, + "water_samples": 0, + "samples": 6 + } + }, + { + "id": "airship-pier-3", + "district": "04", + "name": "Airship pier 3", + "type": "polyline", + "points": [ + [ + 161, + -110 + ], + [ + 107, + -110 + ], + [ + 103, + -106 + ], + [ + 107, + -102 + ], + [ + 161, + -102 + ], + [ + 161, + -110 + ] + ], + "color": "#40c8e3", + "role": "pier", + "deck_y": 79, + "number": 3, + "ground": { + "min": 52.09, + "max": 66.79, + "water_samples": 0, + "samples": 6 + } + }, + { + "id": "flagship-reserve", + "district": "04", + "name": "Flagship mooring reserve", + "type": "polyline", + "points": [ + [ + 107, + -132 + ], + [ + 107, + -131 + ], + [ + 106, + -129 + ], + [ + 105, + -128 + ], + [ + 103, + -127 + ], + [ + 101, + -126 + ], + [ + 99, + -125 + ], + [ + 96, + -124 + ], + [ + 92, + -123 + ], + [ + 89, + -123 + ], + [ + 86, + -122 + ], + [ + 82, + -122 + ], + [ + 78, + -122 + ], + [ + 74, + -122 + ], + [ + 70, + -122 + ], + [ + 67, + -123 + ], + [ + 64, + -123 + ], + [ + 60, + -124 + ], + [ + 57, + -125 + ], + [ + 55, + -126 + ], + [ + 53, + -127 + ], + [ + 51, + -128 + ], + [ + 50, + -129 + ], + [ + 49, + -131 + ], + [ + 49, + -132 + ], + [ + 49, + -133 + ], + [ + 50, + -135 + ], + [ + 51, + -136 + ], + [ + 53, + -137 + ], + [ + 55, + -138 + ], + [ + 57, + -139 + ], + [ + 60, + -140 + ], + [ + 63, + -141 + ], + [ + 67, + -141 + ], + [ + 70, + -142 + ], + [ + 74, + -142 + ], + [ + 78, + -142 + ], + [ + 82, + -142 + ], + [ + 86, + -142 + ], + [ + 89, + -141 + ], + [ + 92, + -141 + ], + [ + 96, + -140 + ], + [ + 99, + -139 + ], + [ + 101, + -138 + ], + [ + 103, + -137 + ], + [ + 105, + -136 + ], + [ + 106, + -135 + ], + [ + 107, + -133 + ], + [ + 107, + -132 + ] + ], + "color": "#40c8e3", + "role": "detail", + "deck_y": 92, + "ground": { + "min": 64.94, + "max": 89.94, + "water_samples": 0, + "samples": 49 + } + }, + { + "id": "garden-1", + "district": "05", + "name": "Palm glasshouse", + "type": "polygon", + "points": [ + [ + 210, + 23 + ], + [ + 210, + 25 + ], + [ + 210, + 27 + ], + [ + 209, + 29 + ], + [ + 208, + 32 + ], + [ + 208, + 34 + ], + [ + 207, + 36 + ], + [ + 205, + 37 + ], + [ + 204, + 39 + ], + [ + 203, + 41 + ], + [ + 201, + 42 + ], + [ + 199, + 43 + ], + [ + 198, + 45 + ], + [ + 196, + 46 + ], + [ + 194, + 46 + ], + [ + 191, + 47 + ], + [ + 189, + 48 + ], + [ + 187, + 48 + ], + [ + 185, + 48 + ], + [ + 183, + 48 + ], + [ + 181, + 48 + ], + [ + 179, + 47 + ], + [ + 176, + 46 + ], + [ + 174, + 46 + ], + [ + 172, + 45 + ], + [ + 171, + 43 + ], + [ + 169, + 42 + ], + [ + 167, + 41 + ], + [ + 166, + 39 + ], + [ + 165, + 37 + ], + [ + 163, + 36 + ], + [ + 162, + 34 + ], + [ + 162, + 32 + ], + [ + 161, + 29 + ], + [ + 160, + 27 + ], + [ + 160, + 25 + ], + [ + 160, + 23 + ], + [ + 160, + 21 + ], + [ + 160, + 19 + ], + [ + 161, + 17 + ], + [ + 162, + 14 + ], + [ + 162, + 12 + ], + [ + 163, + 11 + ], + [ + 165, + 9 + ], + [ + 166, + 7 + ], + [ + 167, + 5 + ], + [ + 169, + 4 + ], + [ + 171, + 3 + ], + [ + 172, + 1 + ], + [ + 174, + 0 + ], + [ + 176, + 0 + ], + [ + 179, + -1 + ], + [ + 181, + -2 + ], + [ + 183, + -2 + ], + [ + 185, + -2 + ], + [ + 187, + -2 + ], + [ + 189, + -2 + ], + [ + 191, + -1 + ], + [ + 194, + 0 + ], + [ + 196, + 0 + ], + [ + 198, + 1 + ], + [ + 199, + 3 + ], + [ + 201, + 4 + ], + [ + 203, + 5 + ], + [ + 204, + 7 + ], + [ + 205, + 9 + ], + [ + 207, + 10 + ], + [ + 208, + 12 + ], + [ + 208, + 14 + ], + [ + 209, + 17 + ], + [ + 210, + 19 + ], + [ + 210, + 21 + ], + [ + 210, + 23 + ] + ], + "color": "#64d9a0", + "role": "building", + "ground": { + "min": 79.61, + "max": 88.0, + "water_samples": 0, + "samples": 2012 + } + }, + { + "id": "garden-court-1", + "district": "05", + "name": "Palm glasshouse surrounding walk", + "type": "polygon", + "points": [ + [ + 217, + 23 + ], + [ + 217, + 26 + ], + [ + 217, + 29 + ], + [ + 216, + 31 + ], + [ + 215, + 34 + ], + [ + 214, + 37 + ], + [ + 213, + 39 + ], + [ + 211, + 41 + ], + [ + 210, + 44 + ], + [ + 208, + 46 + ], + [ + 206, + 48 + ], + [ + 203, + 49 + ], + [ + 201, + 51 + ], + [ + 199, + 52 + ], + [ + 196, + 53 + ], + [ + 193, + 54 + ], + [ + 191, + 55 + ], + [ + 188, + 55 + ], + [ + 185, + 55 + ], + [ + 182, + 55 + ], + [ + 179, + 55 + ], + [ + 177, + 54 + ], + [ + 174, + 53 + ], + [ + 171, + 52 + ], + [ + 169, + 51 + ], + [ + 167, + 49 + ], + [ + 164, + 48 + ], + [ + 162, + 46 + ], + [ + 160, + 44 + ], + [ + 159, + 41 + ], + [ + 157, + 39 + ], + [ + 156, + 37 + ], + [ + 155, + 34 + ], + [ + 154, + 31 + ], + [ + 153, + 29 + ], + [ + 153, + 26 + ], + [ + 153, + 23 + ], + [ + 153, + 20 + ], + [ + 153, + 17 + ], + [ + 154, + 15 + ], + [ + 155, + 12 + ], + [ + 156, + 9 + ], + [ + 157, + 7 + ], + [ + 159, + 5 + ], + [ + 160, + 2 + ], + [ + 162, + 0 + ], + [ + 164, + -2 + ], + [ + 167, + -3 + ], + [ + 169, + -5 + ], + [ + 171, + -6 + ], + [ + 174, + -7 + ], + [ + 177, + -8 + ], + [ + 179, + -9 + ], + [ + 182, + -9 + ], + [ + 185, + -9 + ], + [ + 188, + -9 + ], + [ + 191, + -9 + ], + [ + 193, + -8 + ], + [ + 196, + -7 + ], + [ + 199, + -6 + ], + [ + 201, + -5 + ], + [ + 203, + -3 + ], + [ + 206, + -2 + ], + [ + 208, + 0 + ], + [ + 210, + 2 + ], + [ + 211, + 5 + ], + [ + 213, + 7 + ], + [ + 214, + 9 + ], + [ + 215, + 12 + ], + [ + 216, + 15 + ], + [ + 217, + 17 + ], + [ + 217, + 20 + ], + [ + 217, + 23 + ] + ], + "color": "#64d9a0", + "role": "plaza", + "ground": { + "min": 77.7, + "max": 88.93, + "water_samples": 0, + "samples": 3309 + } + }, + { + "id": "garden-2", + "district": "05", + "name": "Winter garden", + "type": "polygon", + "points": [ + [ + 233, + 94 + ], + [ + 233, + 96 + ], + [ + 233, + 97 + ], + [ + 232, + 99 + ], + [ + 232, + 100 + ], + [ + 231, + 102 + ], + [ + 230, + 103 + ], + [ + 229, + 104 + ], + [ + 228, + 106 + ], + [ + 227, + 107 + ], + [ + 225, + 108 + ], + [ + 224, + 109 + ], + [ + 222, + 110 + ], + [ + 220, + 110 + ], + [ + 219, + 111 + ], + [ + 217, + 111 + ], + [ + 215, + 112 + ], + [ + 213, + 112 + ], + [ + 211, + 112 + ], + [ + 209, + 112 + ], + [ + 207, + 112 + ], + [ + 205, + 111 + ], + [ + 203, + 111 + ], + [ + 202, + 110 + ], + [ + 200, + 110 + ], + [ + 198, + 109 + ], + [ + 197, + 108 + ], + [ + 195, + 107 + ], + [ + 194, + 106 + ], + [ + 193, + 104 + ], + [ + 192, + 103 + ], + [ + 191, + 102 + ], + [ + 190, + 100 + ], + [ + 190, + 99 + ], + [ + 189, + 97 + ], + [ + 189, + 96 + ], + [ + 189, + 94 + ], + [ + 189, + 92 + ], + [ + 189, + 91 + ], + [ + 190, + 89 + ], + [ + 190, + 88 + ], + [ + 191, + 86 + ], + [ + 192, + 85 + ], + [ + 193, + 84 + ], + [ + 194, + 82 + ], + [ + 195, + 81 + ], + [ + 197, + 80 + ], + [ + 198, + 79 + ], + [ + 200, + 78 + ], + [ + 202, + 78 + ], + [ + 203, + 77 + ], + [ + 205, + 77 + ], + [ + 207, + 76 + ], + [ + 209, + 76 + ], + [ + 211, + 76 + ], + [ + 213, + 76 + ], + [ + 215, + 76 + ], + [ + 217, + 77 + ], + [ + 219, + 77 + ], + [ + 220, + 78 + ], + [ + 222, + 78 + ], + [ + 224, + 79 + ], + [ + 225, + 80 + ], + [ + 227, + 81 + ], + [ + 228, + 82 + ], + [ + 229, + 84 + ], + [ + 230, + 85 + ], + [ + 231, + 86 + ], + [ + 232, + 88 + ], + [ + 232, + 89 + ], + [ + 233, + 91 + ], + [ + 233, + 92 + ], + [ + 233, + 94 + ] + ], + "color": "#64d9a0", + "role": "building", + "ground": { + "min": 81.94, + "max": 90.22, + "water_samples": 0, + "samples": 1305 + } + }, + { + "id": "garden-court-2", + "district": "05", + "name": "Winter garden surrounding walk", + "type": "polygon", + "points": [ + [ + 240, + 94 + ], + [ + 240, + 96 + ], + [ + 240, + 98 + ], + [ + 239, + 100 + ], + [ + 238, + 103 + ], + [ + 237, + 105 + ], + [ + 236, + 106 + ], + [ + 235, + 108 + ], + [ + 233, + 110 + ], + [ + 232, + 112 + ], + [ + 230, + 113 + ], + [ + 228, + 114 + ], + [ + 226, + 116 + ], + [ + 223, + 117 + ], + [ + 221, + 117 + ], + [ + 219, + 118 + ], + [ + 216, + 119 + ], + [ + 214, + 119 + ], + [ + 211, + 119 + ], + [ + 208, + 119 + ], + [ + 206, + 119 + ], + [ + 203, + 118 + ], + [ + 201, + 117 + ], + [ + 199, + 117 + ], + [ + 196, + 116 + ], + [ + 194, + 114 + ], + [ + 192, + 113 + ], + [ + 190, + 112 + ], + [ + 189, + 110 + ], + [ + 187, + 108 + ], + [ + 186, + 106 + ], + [ + 185, + 105 + ], + [ + 184, + 103 + ], + [ + 183, + 100 + ], + [ + 182, + 98 + ], + [ + 182, + 96 + ], + [ + 182, + 94 + ], + [ + 182, + 92 + ], + [ + 182, + 90 + ], + [ + 183, + 88 + ], + [ + 184, + 85 + ], + [ + 185, + 83 + ], + [ + 186, + 82 + ], + [ + 187, + 80 + ], + [ + 189, + 78 + ], + [ + 190, + 76 + ], + [ + 192, + 75 + ], + [ + 194, + 74 + ], + [ + 196, + 72 + ], + [ + 199, + 71 + ], + [ + 201, + 71 + ], + [ + 203, + 70 + ], + [ + 206, + 69 + ], + [ + 208, + 69 + ], + [ + 211, + 69 + ], + [ + 214, + 69 + ], + [ + 216, + 69 + ], + [ + 219, + 70 + ], + [ + 221, + 71 + ], + [ + 223, + 71 + ], + [ + 226, + 72 + ], + [ + 228, + 74 + ], + [ + 230, + 75 + ], + [ + 232, + 76 + ], + [ + 233, + 78 + ], + [ + 235, + 80 + ], + [ + 236, + 81 + ], + [ + 237, + 83 + ], + [ + 238, + 85 + ], + [ + 239, + 88 + ], + [ + 240, + 90 + ], + [ + 240, + 92 + ], + [ + 240, + 94 + ] + ], + "color": "#64d9a0", + "role": "plaza", + "ground": { + "min": 81.94, + "max": 90.22, + "water_samples": 0, + "samples": 2338 + } + }, + { + "id": "garden-3", + "district": "05", + "name": "Observatory", + "type": "polygon", + "points": [ + [ + 248, + 152 + ], + [ + 248, + 153 + ], + [ + 248, + 155 + ], + [ + 247, + 156 + ], + [ + 247, + 157 + ], + [ + 247, + 158 + ], + [ + 246, + 160 + ], + [ + 245, + 161 + ], + [ + 244, + 162 + ], + [ + 244, + 163 + ], + [ + 243, + 163 + ], + [ + 242, + 164 + ], + [ + 240, + 165 + ], + [ + 239, + 166 + ], + [ + 238, + 166 + ], + [ + 237, + 166 + ], + [ + 236, + 167 + ], + [ + 234, + 167 + ], + [ + 233, + 167 + ], + [ + 232, + 167 + ], + [ + 230, + 167 + ], + [ + 229, + 166 + ], + [ + 228, + 166 + ], + [ + 227, + 166 + ], + [ + 226, + 165 + ], + [ + 224, + 164 + ], + [ + 223, + 163 + ], + [ + 222, + 163 + ], + [ + 222, + 162 + ], + [ + 221, + 161 + ], + [ + 220, + 160 + ], + [ + 219, + 158 + ], + [ + 219, + 157 + ], + [ + 219, + 156 + ], + [ + 218, + 155 + ], + [ + 218, + 153 + ], + [ + 218, + 152 + ], + [ + 218, + 151 + ], + [ + 218, + 149 + ], + [ + 219, + 148 + ], + [ + 219, + 147 + ], + [ + 219, + 146 + ], + [ + 220, + 144 + ], + [ + 221, + 143 + ], + [ + 222, + 142 + ], + [ + 222, + 141 + ], + [ + 223, + 141 + ], + [ + 224, + 140 + ], + [ + 226, + 139 + ], + [ + 227, + 138 + ], + [ + 228, + 138 + ], + [ + 229, + 138 + ], + [ + 230, + 137 + ], + [ + 232, + 137 + ], + [ + 233, + 137 + ], + [ + 234, + 137 + ], + [ + 236, + 137 + ], + [ + 237, + 138 + ], + [ + 238, + 138 + ], + [ + 239, + 138 + ], + [ + 240, + 139 + ], + [ + 242, + 140 + ], + [ + 243, + 141 + ], + [ + 244, + 141 + ], + [ + 244, + 142 + ], + [ + 245, + 143 + ], + [ + 246, + 144 + ], + [ + 247, + 146 + ], + [ + 247, + 147 + ], + [ + 247, + 148 + ], + [ + 248, + 149 + ], + [ + 248, + 151 + ], + [ + 248, + 152 + ] + ], + "color": "#64d9a0", + "role": "building", + "ground": { + "min": 81.19, + "max": 88.81, + "water_samples": 0, + "samples": 749 + } + }, + { + "id": "garden-court-3", + "district": "05", + "name": "Observatory surrounding walk", + "type": "polygon", + "points": [ + [ + 255, + 152 + ], + [ + 255, + 154 + ], + [ + 255, + 156 + ], + [ + 254, + 158 + ], + [ + 254, + 160 + ], + [ + 253, + 161 + ], + [ + 252, + 163 + ], + [ + 251, + 165 + ], + [ + 250, + 166 + ], + [ + 249, + 168 + ], + [ + 247, + 169 + ], + [ + 246, + 170 + ], + [ + 244, + 171 + ], + [ + 242, + 172 + ], + [ + 241, + 173 + ], + [ + 239, + 173 + ], + [ + 237, + 174 + ], + [ + 235, + 174 + ], + [ + 233, + 174 + ], + [ + 231, + 174 + ], + [ + 229, + 174 + ], + [ + 227, + 173 + ], + [ + 225, + 173 + ], + [ + 224, + 172 + ], + [ + 222, + 171 + ], + [ + 220, + 170 + ], + [ + 219, + 169 + ], + [ + 217, + 168 + ], + [ + 216, + 166 + ], + [ + 215, + 165 + ], + [ + 214, + 163 + ], + [ + 213, + 161 + ], + [ + 212, + 160 + ], + [ + 212, + 158 + ], + [ + 211, + 156 + ], + [ + 211, + 154 + ], + [ + 211, + 152 + ], + [ + 211, + 150 + ], + [ + 211, + 148 + ], + [ + 212, + 146 + ], + [ + 212, + 144 + ], + [ + 213, + 143 + ], + [ + 214, + 141 + ], + [ + 215, + 139 + ], + [ + 216, + 138 + ], + [ + 217, + 136 + ], + [ + 219, + 135 + ], + [ + 220, + 134 + ], + [ + 222, + 133 + ], + [ + 224, + 132 + ], + [ + 225, + 131 + ], + [ + 227, + 131 + ], + [ + 229, + 130 + ], + [ + 231, + 130 + ], + [ + 233, + 130 + ], + [ + 235, + 130 + ], + [ + 237, + 130 + ], + [ + 239, + 131 + ], + [ + 241, + 131 + ], + [ + 242, + 132 + ], + [ + 244, + 133 + ], + [ + 246, + 134 + ], + [ + 247, + 135 + ], + [ + 249, + 136 + ], + [ + 250, + 138 + ], + [ + 251, + 139 + ], + [ + 252, + 141 + ], + [ + 253, + 143 + ], + [ + 254, + 144 + ], + [ + 254, + 146 + ], + [ + 255, + 148 + ], + [ + 255, + 150 + ], + [ + 255, + 152 + ] + ], + "color": "#64d9a0", + "role": "plaza", + "ground": { + "min": 80.35, + "max": 97.79, + "water_samples": 0, + "samples": 1585 + } + }, + { + "id": "market-square", + "district": "06", + "name": "Market square", + "type": "polygon", + "points": [ + [ + -173, + 225 + ], + [ + -173, + 228 + ], + [ + -174, + 231 + ], + [ + -175, + 235 + ], + [ + -176, + 238 + ], + [ + -178, + 240 + ], + [ + -179, + 243 + ], + [ + -182, + 245 + ], + [ + -184, + 247 + ], + [ + -187, + 248 + ], + [ + -189, + 249 + ], + [ + -192, + 250 + ], + [ + -195, + 250 + ], + [ + -198, + 250 + ], + [ + -201, + 249 + ], + [ + -203, + 248 + ], + [ + -206, + 247 + ], + [ + -208, + 245 + ], + [ + -211, + 243 + ], + [ + -212, + 240 + ], + [ + -214, + 238 + ], + [ + -215, + 235 + ], + [ + -216, + 231 + ], + [ + -217, + 228 + ], + [ + -217, + 225 + ], + [ + -217, + 222 + ], + [ + -216, + 219 + ], + [ + -215, + 215 + ], + [ + -214, + 212 + ], + [ + -212, + 210 + ], + [ + -211, + 207 + ], + [ + -208, + 205 + ], + [ + -206, + 203 + ], + [ + -203, + 202 + ], + [ + -201, + 201 + ], + [ + -198, + 200 + ], + [ + -195, + 200 + ], + [ + -192, + 200 + ], + [ + -189, + 201 + ], + [ + -187, + 202 + ], + [ + -184, + 203 + ], + [ + -182, + 205 + ], + [ + -179, + 207 + ], + [ + -178, + 210 + ], + [ + -176, + 212 + ], + [ + -175, + 215 + ], + [ + -174, + 219 + ], + [ + -173, + 222 + ], + [ + -173, + 225 + ] + ], + "color": "#ef9863", + "role": "plaza", + "ground": { + "min": 61.13, + "max": 69.96, + "water_samples": 0, + "samples": 1759 + } + }, + { + "id": "market-1", + "district": "06", + "name": "Bakery", + "type": "polygon", + "points": [ + [ + -232, + 181 + ], + [ + -208, + 177 + ], + [ + -206, + 195 + ], + [ + -230, + 199 + ], + [ + -232, + 181 + ] + ], + "color": "#ef9863", + "role": "building", + "ground": { + "min": 70.86, + "max": 81.22, + "water_samples": 0, + "samples": 447 + } + }, + { + "id": "market-2", + "district": "06", + "name": "Crafts hall", + "type": "polygon", + "points": [ + [ + -188, + 185 + ], + [ + -164, + 188 + ], + [ + -166, + 205 + ], + [ + -190, + 202 + ], + [ + -188, + 185 + ] + ], + "color": "#ef9863", + "role": "building", + "ground": { + "min": 63.71, + "max": 70.4, + "water_samples": 0, + "samples": 419 + } + }, + { + "id": "market-3", + "district": "06", + "name": "Tea house", + "type": "polygon", + "points": [ + [ + -169, + 217 + ], + [ + -152, + 219 + ], + [ + -155, + 245 + ], + [ + -172, + 243 + ], + [ + -169, + 217 + ] + ], + "color": "#ef9863", + "role": "building", + "ground": { + "min": 63.49, + "max": 66.03, + "water_samples": 0, + "samples": 451 + } + }, + { + "id": "market-4", + "district": "06", + "name": "Guild shop", + "type": "polygon", + "points": [ + [ + -190, + 265 + ], + [ + -168, + 262 + ], + [ + -166, + 279 + ], + [ + -188, + 282 + ], + [ + -190, + 265 + ] + ], + "color": "#ef9863", + "role": "building", + "ground": { + "min": 62.21, + "max": 64.75, + "water_samples": 0, + "samples": 383 + } + }, + { + "id": "market-5", + "district": "06", + "name": "Workshop", + "type": "polygon", + "points": [ + [ + -224, + 262 + ], + [ + -201, + 265 + ], + [ + -204, + 282 + ], + [ + -227, + 279 + ], + [ + -224, + 262 + ] + ], + "color": "#ef9863", + "role": "building", + "ground": { + "min": 62.22, + "max": 65.37, + "water_samples": 0, + "samples": 403 + } + }, + { + "id": "market-6", + "district": "06", + "name": "Guild hall", + "type": "polygon", + "points": [ + [ + -245, + 227 + ], + [ + -223, + 225 + ], + [ + -221, + 251 + ], + [ + -243, + 253 + ], + [ + -245, + 227 + ] + ], + "color": "#ef9863", + "role": "building", + "ground": { + "min": 62.16, + "max": 72.66, + "water_samples": 0, + "samples": 581 + } + }, + { + "id": "waterworks-pump", + "district": "08", + "name": "Pumping house", + "type": "polygon", + "points": [ + [ + -144, + 36 + ], + [ + -131, + 32 + ], + [ + -126, + 50 + ], + [ + -139, + 54 + ], + [ + -144, + 36 + ] + ], + "color": "#4ca3ff", + "role": "building", + "ground": { + "min": 66.75, + "max": 71.94, + "water_samples": 0, + "samples": 257 + } + }, + { + "id": "waterworks-lookout", + "district": "08", + "name": "Waterworks lookout", + "type": "polygon", + "points": [ + [ + -125, + 69 + ], + [ + -125, + 71 + ], + [ + -126, + 73 + ], + [ + -127, + 75 + ], + [ + -128, + 76 + ], + [ + -129, + 77 + ], + [ + -131, + 78 + ], + [ + -133, + 79 + ], + [ + -135, + 79 + ], + [ + -137, + 79 + ], + [ + -139, + 78 + ], + [ + -141, + 77 + ], + [ + -142, + 76 + ], + [ + -143, + 75 + ], + [ + -144, + 73 + ], + [ + -145, + 71 + ], + [ + -145, + 69 + ], + [ + -145, + 67 + ], + [ + -144, + 65 + ], + [ + -143, + 63 + ], + [ + -142, + 62 + ], + [ + -141, + 61 + ], + [ + -139, + 60 + ], + [ + -137, + 59 + ], + [ + -135, + 59 + ], + [ + -133, + 59 + ], + [ + -131, + 60 + ], + [ + -129, + 61 + ], + [ + -128, + 62 + ], + [ + -127, + 63 + ], + [ + -126, + 65 + ], + [ + -125, + 67 + ], + [ + -125, + 69 + ] + ], + "color": "#4ca3ff", + "role": "plaza", + "ground": { + "min": 68.63, + "max": 72.92, + "water_samples": 0, + "samples": 333 + } + }, + { + "id": "lake-boardwalk", + "district": "08", + "name": "Lake boardwalk alignment", + "type": "polyline", + "points": [ + [ + -177, + -69 + ], + [ + -177, + -68 + ], + [ + -176, + -67 + ], + [ + -176, + -66 + ], + [ + -176, + -65 + ], + [ + -175, + -64 + ], + [ + -175, + -63 + ], + [ + -175, + -62 + ], + [ + -174, + -61 + ], + [ + -174, + -60 + ], + [ + -174, + -59 + ], + [ + -173, + -58 + ], + [ + -173, + -57 + ], + [ + -172, + -56 + ], + [ + -172, + -55 + ], + [ + -171, + -54 + ], + [ + -170, + -53 + ], + [ + -170, + -52 + ], + [ + -169, + -51 + ], + [ + -168, + -50 + ], + [ + -168, + -49 + ], + [ + -167, + -48 + ], + [ + -167, + -47 + ], + [ + -166, + -46 + ], + [ + -166, + -45 + ], + [ + -166, + -44 + ], + [ + -165, + -43 + ], + [ + -165, + -42 + ], + [ + -165, + -41 + ], + [ + -164, + -40 + ], + [ + -164, + -39 + ], + [ + -164, + -38 + ], + [ + -163, + -37 + ], + [ + -163, + -36 + ], + [ + -163, + -35 + ], + [ + -162, + -34 + ], + [ + -162, + -33 + ], + [ + -162, + -32 + ], + [ + -161, + -31 + ], + [ + -161, + -30 + ], + [ + -161, + -29 + ], + [ + -161, + -28 + ], + [ + -161, + -27 + ], + [ + -161, + -26 + ], + [ + -160, + -25 + ], + [ + -160, + -24 + ], + [ + -160, + -23 + ], + [ + -160, + -22 + ], + [ + -160, + -21 + ], + [ + -160, + -20 + ], + [ + -160, + -19 + ], + [ + -160, + -18 + ], + [ + -160, + -17 + ], + [ + -161, + -16 + ], + [ + -161, + -15 + ], + [ + -161, + -14 + ], + [ + -161, + -13 + ], + [ + -161, + -12 + ], + [ + -161, + -11 + ], + [ + -162, + -10 + ], + [ + -162, + -9 + ], + [ + -162, + -8 + ], + [ + -163, + -7 + ], + [ + -163, + -6 + ], + [ + -163, + -5 + ], + [ + -163, + -4 + ], + [ + -163, + -3 + ], + [ + -163, + -2 + ], + [ + -164, + -1 + ], + [ + -164, + 0 + ], + [ + -164, + 1 + ], + [ + -164, + 2 + ], + [ + -164, + 3 + ], + [ + -164, + 4 + ], + [ + -164, + 5 + ], + [ + -164, + 6 + ], + [ + -164, + 7 + ], + [ + -165, + 8 + ], + [ + -165, + 9 + ], + [ + -165, + 10 + ], + [ + -165, + 11 + ], + [ + -165, + 12 + ], + [ + -165, + 13 + ], + [ + -165, + 14 + ], + [ + -165, + 15 + ], + [ + -165, + 16 + ], + [ + -166, + 17 + ], + [ + -166, + 18 + ], + [ + -166, + 19 + ], + [ + -167, + 20 + ], + [ + -167, + 21 + ], + [ + -167, + 22 + ], + [ + -168, + 23 + ], + [ + -168, + 24 + ], + [ + -168, + 25 + ], + [ + -168, + 26 + ], + [ + -168, + 27 + ], + [ + -168, + 28 + ], + [ + -168, + 29 + ], + [ + -168, + 30 + ], + [ + -168, + 31 + ], + [ + -169, + 32 + ], + [ + -169, + 33 + ], + [ + -169, + 34 + ], + [ + -169, + 35 + ], + [ + -169, + 36 + ], + [ + -169, + 37 + ], + [ + -169, + 38 + ], + [ + -169, + 39 + ], + [ + -170, + 40 + ], + [ + -170, + 41 + ], + [ + -171, + 42 + ], + [ + -171, + 43 + ], + [ + -172, + 44 + ], + [ + -172, + 45 + ], + [ + -173, + 46 + ], + [ + -173, + 47 + ], + [ + -174, + 48 + ], + [ + -175, + 49 + ], + [ + -175, + 50 + ], + [ + -176, + 50 + ], + [ + -177, + 51 + ], + [ + -178, + 52 + ], + [ + -179, + 52 + ], + [ + -180, + 53 + ], + [ + -181, + 53 + ], + [ + -182, + 54 + ], + [ + -183, + 55 + ], + [ + -184, + 56 + ], + [ + -185, + 57 + ], + [ + -186, + 58 + ], + [ + -186, + 59 + ], + [ + -187, + 60 + ], + [ + -187, + 61 + ], + [ + -188, + 62 + ], + [ + -188, + 63 + ], + [ + -188, + 64 + ], + [ + -189, + 65 + ], + [ + -189, + 66 + ], + [ + -189, + 67 + ], + [ + -190, + 68 + ], + [ + -190, + 69 + ], + [ + -190, + 70 + ], + [ + -190, + 71 + ], + [ + -190, + 72 + ], + [ + -190, + 73 + ], + [ + -189, + 74 + ], + [ + -189, + 75 + ] + ], + "color": "#4ca3ff", + "role": "promenade", + "deck_y": 51, + "ground": { + "min": 43.31, + "max": 47.12, + "water_samples": 148, + "samples": 148 + } + }, + { + "id": "overlook-1", + "district": "09", + "name": "North clock-view belvedere", + "type": "polygon", + "points": [ + [ + 51, + -173 + ], + [ + 51, + -171 + ], + [ + 50, + -170 + ], + [ + 50, + -169 + ], + [ + 49, + -167 + ], + [ + 47, + -166 + ], + [ + 46, + -166 + ], + [ + 45, + -165 + ], + [ + 43, + -165 + ], + [ + 41, + -165 + ], + [ + 40, + -166 + ], + [ + 39, + -166 + ], + [ + 37, + -167 + ], + [ + 36, + -169 + ], + [ + 36, + -170 + ], + [ + 35, + -171 + ], + [ + 35, + -173 + ], + [ + 35, + -175 + ], + [ + 36, + -176 + ], + [ + 36, + -177 + ], + [ + 37, + -179 + ], + [ + 39, + -180 + ], + [ + 40, + -180 + ], + [ + 41, + -181 + ], + [ + 43, + -181 + ], + [ + 45, + -181 + ], + [ + 46, + -180 + ], + [ + 47, + -180 + ], + [ + 49, + -179 + ], + [ + 50, + -177 + ], + [ + 50, + -176 + ], + [ + 51, + -175 + ], + [ + 51, + -173 + ] + ], + "color": "#f07eaf", + "role": "plaza", + "ground": { + "min": 87.92, + "max": 96.21, + "water_samples": 0, + "samples": 225 + } + }, + { + "id": "overlook-2", + "district": "09", + "name": "Portal ridge overlook", + "type": "polygon", + "points": [ + [ + -123, + -188 + ], + [ + -123, + -187 + ], + [ + -124, + -185 + ], + [ + -124, + -184 + ], + [ + -125, + -183 + ], + [ + -126, + -182 + ], + [ + -127, + -182 + ], + [ + -129, + -181 + ], + [ + -130, + -181 + ], + [ + -131, + -181 + ], + [ + -133, + -182 + ], + [ + -134, + -182 + ], + [ + -135, + -183 + ], + [ + -136, + -184 + ], + [ + -136, + -185 + ], + [ + -137, + -187 + ], + [ + -137, + -188 + ], + [ + -137, + -189 + ], + [ + -136, + -191 + ], + [ + -136, + -192 + ], + [ + -135, + -193 + ], + [ + -134, + -194 + ], + [ + -133, + -194 + ], + [ + -131, + -195 + ], + [ + -130, + -195 + ], + [ + -129, + -195 + ], + [ + -127, + -194 + ], + [ + -126, + -194 + ], + [ + -125, + -193 + ], + [ + -124, + -192 + ], + [ + -124, + -191 + ], + [ + -123, + -189 + ], + [ + -123, + -188 + ] + ], + "color": "#f07eaf", + "role": "plaza", + "ground": { + "min": 82.7, + "max": 93.53, + "water_samples": 0, + "samples": 169 + } + }, + { + "id": "overlook-3", + "district": "09", + "name": "Harbor overlook", + "type": "polygon", + "points": [ + [ + 237, + -76 + ], + [ + 237, + -74 + ], + [ + 236, + -73 + ], + [ + 236, + -72 + ], + [ + 235, + -70 + ], + [ + 233, + -69 + ], + [ + 232, + -69 + ], + [ + 231, + -68 + ], + [ + 229, + -68 + ], + [ + 227, + -68 + ], + [ + 226, + -69 + ], + [ + 225, + -69 + ], + [ + 223, + -70 + ], + [ + 222, + -72 + ], + [ + 222, + -73 + ], + [ + 221, + -74 + ], + [ + 221, + -76 + ], + [ + 221, + -78 + ], + [ + 222, + -79 + ], + [ + 222, + -80 + ], + [ + 223, + -82 + ], + [ + 225, + -83 + ], + [ + 226, + -83 + ], + [ + 227, + -84 + ], + [ + 229, + -84 + ], + [ + 231, + -84 + ], + [ + 232, + -83 + ], + [ + 233, + -83 + ], + [ + 235, + -82 + ], + [ + 236, + -80 + ], + [ + 236, + -79 + ], + [ + 237, + -78 + ], + [ + 237, + -76 + ] + ], + "color": "#f07eaf", + "role": "plaza", + "ground": { + "min": 72.58, + "max": 83.82, + "water_samples": 0, + "samples": 225 + } + }, + { + "id": "overlook-4", + "district": "09", + "name": "Garden lookout", + "type": "polygon", + "points": [ + [ + 273, + 110 + ], + [ + 273, + 111 + ], + [ + 272, + 113 + ], + [ + 272, + 114 + ], + [ + 271, + 115 + ], + [ + 270, + 116 + ], + [ + 269, + 116 + ], + [ + 267, + 117 + ], + [ + 266, + 117 + ], + [ + 265, + 117 + ], + [ + 263, + 116 + ], + [ + 262, + 116 + ], + [ + 261, + 115 + ], + [ + 260, + 114 + ], + [ + 260, + 113 + ], + [ + 259, + 111 + ], + [ + 259, + 110 + ], + [ + 259, + 109 + ], + [ + 260, + 107 + ], + [ + 260, + 106 + ], + [ + 261, + 105 + ], + [ + 262, + 104 + ], + [ + 263, + 104 + ], + [ + 265, + 103 + ], + [ + 266, + 103 + ], + [ + 267, + 103 + ], + [ + 269, + 104 + ], + [ + 270, + 104 + ], + [ + 271, + 105 + ], + [ + 272, + 106 + ], + [ + 272, + 107 + ], + [ + 273, + 109 + ], + [ + 273, + 110 + ] + ], + "color": "#f07eaf", + "role": "plaza", + "ground": { + "min": 91.4, + "max": 101.3, + "water_samples": 0, + "samples": 169 + } + }, + { + "id": "overlook-5", + "district": "09", + "name": "Valley approach lookout", + "type": "polygon", + "points": [ + [ + -251, + 258 + ], + [ + -251, + 259 + ], + [ + -252, + 261 + ], + [ + -252, + 262 + ], + [ + -253, + 263 + ], + [ + -254, + 264 + ], + [ + -255, + 264 + ], + [ + -257, + 265 + ], + [ + -258, + 265 + ], + [ + -259, + 265 + ], + [ + -261, + 264 + ], + [ + -262, + 264 + ], + [ + -263, + 263 + ], + [ + -264, + 262 + ], + [ + -264, + 261 + ], + [ + -265, + 259 + ], + [ + -265, + 258 + ], + [ + -265, + 257 + ], + [ + -264, + 255 + ], + [ + -264, + 254 + ], + [ + -263, + 253 + ], + [ + -262, + 252 + ], + [ + -261, + 252 + ], + [ + -259, + 251 + ], + [ + -258, + 251 + ], + [ + -257, + 251 + ], + [ + -255, + 252 + ], + [ + -254, + 252 + ], + [ + -253, + 253 + ], + [ + -252, + 254 + ], + [ + -252, + 255 + ], + [ + -251, + 257 + ], + [ + -251, + 258 + ] + ], + "color": "#f07eaf", + "role": "plaza", + "ground": { + "min": 65.75, + "max": 83.85, + "water_samples": 0, + "samples": 169 + } + } + ], + "routes": [ + { + "id": "station-axis", + "name": "Clock avenue", + "points": [ + [ + 0, + -37 + ], + [ + 0, + -38 + ], + [ + -1, + -38 + ], + [ + -1, + -39 + ], + [ + -1, + -40 + ], + [ + -2, + -41 + ], + [ + -2, + -42 + ], + [ + -3, + -43 + ], + [ + -3, + -44 + ], + [ + -3, + -45 + ], + [ + -4, + -47 + ], + [ + -4, + -48 + ], + [ + -4, + -49 + ], + [ + -4, + -50 + ], + [ + -4, + -51 + ], + [ + -4, + -52 + ], + [ + -5, + -53 + ], + [ + -5, + -54 + ], + [ + -5, + -55 + ], + [ + -5, + -56 + ], + [ + -5, + -57 + ], + [ + -5, + -58 + ], + [ + -5, + -59 + ], + [ + -5, + -60 + ], + [ + -5, + -61 + ], + [ + -5, + -62 + ], + [ + -5, + -63 + ], + [ + -5, + -64 + ], + [ + -5, + -65 + ], + [ + -5, + -66 + ], + [ + -5, + -67 + ], + [ + -5, + -68 + ], + [ + -5, + -69 + ], + [ + -5, + -70 + ], + [ + -5, + -72 + ], + [ + -5, + -73 + ], + [ + -5, + -74 + ], + [ + -6, + -75 + ], + [ + -6, + -76 + ], + [ + -6, + -77 + ], + [ + -6, + -78 + ], + [ + -6, + -79 + ], + [ + -6, + -80 + ], + [ + -6, + -81 + ], + [ + -6, + -82 + ], + [ + -6, + -83 + ] + ], + "waypoints": [ + [ + 0, + -37 + ], + [ + -4, + -48 + ], + [ + -5, + -65 + ], + [ + -6, + -83 + ] + ], + "width": 9, + "role": "main", + "analysis": { + "length": 48.9, + "ground_min": 94.5, + "ground_max": 96.1, + "p95_raw_grade": 0.19, + "water_samples": 0 + } + }, + { + "id": "portal-radial", + "name": "Portal approach", + "points": [ + [ + -35, + -14 + ], + [ + -36, + -15 + ], + [ + -37, + -16 + ], + [ + -38, + -17 + ], + [ + -39, + -18 + ], + [ + -40, + -19 + ], + [ + -41, + -20 + ], + [ + -42, + -21 + ], + [ + -43, + -22 + ], + [ + -44, + -23 + ], + [ + -45, + -24 + ], + [ + -46, + -24 + ], + [ + -47, + -25 + ], + [ + -47, + -26 + ], + [ + -48, + -27 + ], + [ + -49, + -28 + ], + [ + -50, + -28 + ], + [ + -51, + -29 + ], + [ + -51, + -30 + ], + [ + -52, + -31 + ], + [ + -53, + -31 + ], + [ + -54, + -32 + ], + [ + -55, + -33 + ], + [ + -56, + -34 + ], + [ + -57, + -35 + ], + [ + -58, + -36 + ], + [ + -59, + -37 + ], + [ + -60, + -38 + ], + [ + -61, + -38 + ], + [ + -62, + -39 + ], + [ + -63, + -40 + ], + [ + -64, + -40 + ], + [ + -64, + -41 + ], + [ + -65, + -42 + ], + [ + -66, + -42 + ], + [ + -67, + -43 + ], + [ + -68, + -44 + ], + [ + -69, + -44 + ], + [ + -69, + -45 + ], + [ + -70, + -45 + ], + [ + -71, + -46 + ], + [ + -72, + -46 + ], + [ + -73, + -47 + ], + [ + -74, + -47 + ], + [ + -75, + -48 + ], + [ + -76, + -49 + ], + [ + -77, + -50 + ], + [ + -78, + -50 + ], + [ + -79, + -51 + ], + [ + -80, + -51 + ], + [ + -81, + -52 + ], + [ + -82, + -52 + ], + [ + -83, + -53 + ], + [ + -84, + -53 + ], + [ + -85, + -54 + ], + [ + -86, + -54 + ], + [ + -87, + -55 + ], + [ + -88, + -55 + ], + [ + -89, + -56 + ], + [ + -90, + -56 + ], + [ + -91, + -57 + ], + [ + -92, + -58 + ], + [ + -93, + -58 + ], + [ + -94, + -59 + ], + [ + -95, + -59 + ], + [ + -96, + -60 + ], + [ + -97, + -60 + ], + [ + -98, + -61 + ], + [ + -99, + -61 + ], + [ + -100, + -61 + ], + [ + -101, + -62 + ], + [ + -102, + -63 + ], + [ + -103, + -63 + ], + [ + -104, + -64 + ], + [ + -105, + -64 + ], + [ + -106, + -65 + ], + [ + -107, + -65 + ], + [ + -108, + -66 + ], + [ + -109, + -66 + ], + [ + -110, + -67 + ], + [ + -112, + -67 + ], + [ + -113, + -67 + ], + [ + -114, + -68 + ], + [ + -115, + -68 + ], + [ + -116, + -69 + ], + [ + -117, + -69 + ], + [ + -118, + -69 + ], + [ + -119, + -70 + ], + [ + -120, + -70 + ], + [ + -121, + -71 + ], + [ + -122, + -71 + ], + [ + -123, + -71 + ], + [ + -124, + -72 + ], + [ + -125, + -72 + ], + [ + -126, + -72 + ], + [ + -127, + -73 + ], + [ + -128, + -73 + ], + [ + -129, + -73 + ], + [ + -130, + -73 + ], + [ + -131, + -74 + ], + [ + -132, + -74 + ], + [ + -133, + -74 + ], + [ + -134, + -75 + ], + [ + -135, + -75 + ], + [ + -136, + -75 + ], + [ + -137, + -76 + ], + [ + -138, + -76 + ], + [ + -140, + -76 + ], + [ + -141, + -77 + ], + [ + -142, + -77 + ], + [ + -143, + -77 + ], + [ + -144, + -78 + ], + [ + -145, + -78 + ], + [ + -146, + -78 + ], + [ + -148, + -78 + ], + [ + -149, + -79 + ], + [ + -150, + -79 + ], + [ + -151, + -79 + ], + [ + -152, + -79 + ], + [ + -153, + -80 + ], + [ + -154, + -80 + ], + [ + -155, + -80 + ], + [ + -156, + -80 + ], + [ + -157, + -81 + ], + [ + -158, + -81 + ], + [ + -159, + -81 + ] + ], + "waypoints": [ + [ + -35, + -14 + ], + [ + -67, + -43 + ], + [ + -107, + -65 + ], + [ + -135, + -75 + ], + [ + -159, + -81 + ] + ], + "width": 7, + "role": "main", + "analysis": { + "length": 154.1, + "ground_min": 60.2, + "ground_max": 95.0, + "p95_raw_grade": 0.53, + "water_samples": 0 + } + }, + { + "id": "portal-forecourt-link", + "name": "Portal court entrance", + "points": [ + [ + -159, + -105 + ], + [ + -159, + -106 + ], + [ + -158, + -106 + ], + [ + -158, + -107 + ], + [ + -158, + -108 + ], + [ + -157, + -109 + ], + [ + -157, + -110 + ], + [ + -156, + -111 + ], + [ + -156, + -112 + ], + [ + -155, + -113 + ], + [ + -155, + -114 + ], + [ + -154, + -115 + ], + [ + -154, + -117 + ], + [ + -154, + -118 + ], + [ + -154, + -119 + ], + [ + -154, + -120 + ], + [ + -154, + -121 + ], + [ + -154, + -122 + ], + [ + -155, + -123 + ], + [ + -155, + -124 + ], + [ + -156, + -125 + ], + [ + -156, + -126 + ], + [ + -157, + -127 + ], + [ + -157, + -128 + ], + [ + -158, + -129 + ], + [ + -158, + -130 + ], + [ + -159, + -131 + ], + [ + -160, + -132 + ], + [ + -160, + -133 + ], + [ + -161, + -134 + ], + [ + -161, + -135 + ], + [ + -162, + -136 + ], + [ + -162, + -137 + ], + [ + -163, + -137 + ], + [ + -163, + -138 + ], + [ + -164, + -139 + ], + [ + -164, + -139 + ] + ], + "waypoints": [ + [ + -159, + -105 + ], + [ + -154, + -120 + ], + [ + -164, + -139 + ] + ], + "width": 5, + "role": "secondary", + "analysis": { + "length": 41.4, + "ground_min": 66.4, + "ground_max": 72.0, + "p95_raw_grade": 0.38, + "water_samples": 0 + } + }, + { + "id": "lake-radial", + "name": "Lake walk", + "points": [ + [ + -42, + 18 + ], + [ + -43, + 18 + ], + [ + -44, + 18 + ], + [ + -44, + 19 + ], + [ + -45, + 19 + ], + [ + -46, + 19 + ], + [ + -47, + 19 + ], + [ + -48, + 19 + ], + [ + -48, + 20 + ], + [ + -49, + 20 + ], + [ + -50, + 20 + ], + [ + -51, + 20 + ], + [ + -52, + 21 + ], + [ + -53, + 21 + ], + [ + -55, + 21 + ], + [ + -56, + 21 + ], + [ + -57, + 22 + ], + [ + -58, + 22 + ], + [ + -59, + 22 + ], + [ + -60, + 22 + ], + [ + -61, + 23 + ], + [ + -62, + 23 + ], + [ + -63, + 23 + ], + [ + -65, + 24 + ], + [ + -66, + 24 + ], + [ + -67, + 24 + ], + [ + -68, + 24 + ], + [ + -69, + 25 + ], + [ + -70, + 25 + ], + [ + -71, + 25 + ], + [ + -72, + 26 + ], + [ + -73, + 26 + ], + [ + -74, + 26 + ], + [ + -75, + 26 + ], + [ + -76, + 27 + ], + [ + -77, + 27 + ], + [ + -78, + 27 + ], + [ + -79, + 28 + ], + [ + -80, + 28 + ], + [ + -81, + 28 + ], + [ + -82, + 29 + ], + [ + -83, + 29 + ], + [ + -84, + 30 + ], + [ + -85, + 30 + ], + [ + -86, + 31 + ], + [ + -87, + 31 + ], + [ + -88, + 31 + ], + [ + -89, + 32 + ], + [ + -90, + 32 + ], + [ + -91, + 33 + ], + [ + -92, + 33 + ], + [ + -93, + 34 + ], + [ + -94, + 34 + ], + [ + -95, + 35 + ], + [ + -96, + 35 + ], + [ + -97, + 36 + ], + [ + -98, + 36 + ], + [ + -99, + 36 + ], + [ + -100, + 37 + ], + [ + -101, + 37 + ], + [ + -102, + 38 + ], + [ + -103, + 38 + ], + [ + -104, + 38 + ], + [ + -105, + 38 + ], + [ + -106, + 39 + ], + [ + -107, + 39 + ], + [ + -109, + 39 + ], + [ + -110, + 39 + ], + [ + -111, + 39 + ], + [ + -112, + 39 + ], + [ + -113, + 39 + ], + [ + -114, + 40 + ], + [ + -116, + 40 + ], + [ + -117, + 40 + ], + [ + -118, + 40 + ], + [ + -119, + 40 + ], + [ + -120, + 40 + ], + [ + -121, + 40 + ], + [ + -122, + 40 + ], + [ + -123, + 40 + ], + [ + -124, + 40 + ], + [ + -125, + 40 + ], + [ + -126, + 40 + ] + ], + "waypoints": [ + [ + -42, + 18 + ], + [ + -77, + 27 + ], + [ + -104, + 38 + ], + [ + -126, + 40 + ] + ], + "width": 5, + "role": "secondary", + "analysis": { + "length": 94.1, + "ground_min": 72.6, + "ground_max": 92.8, + "p95_raw_grade": 0.49, + "water_samples": 0 + } + }, + { + "id": "east-radial", + "name": "Garden approach", + "points": [ + [ + 42, + 16 + ], + [ + 43, + 16 + ], + [ + 44, + 15 + ], + [ + 45, + 15 + ], + [ + 46, + 15 + ], + [ + 47, + 15 + ], + [ + 47, + 14 + ], + [ + 49, + 14 + ], + [ + 50, + 14 + ], + [ + 51, + 14 + ], + [ + 52, + 13 + ], + [ + 53, + 13 + ], + [ + 54, + 13 + ], + [ + 55, + 12 + ], + [ + 57, + 12 + ], + [ + 58, + 12 + ], + [ + 59, + 11 + ], + [ + 60, + 11 + ], + [ + 61, + 11 + ], + [ + 62, + 11 + ], + [ + 63, + 10 + ], + [ + 64, + 10 + ], + [ + 65, + 10 + ], + [ + 66, + 10 + ], + [ + 67, + 10 + ], + [ + 69, + 9 + ], + [ + 70, + 9 + ], + [ + 71, + 9 + ], + [ + 72, + 9 + ], + [ + 73, + 9 + ], + [ + 74, + 9 + ], + [ + 75, + 9 + ], + [ + 77, + 8 + ], + [ + 78, + 8 + ], + [ + 79, + 8 + ], + [ + 80, + 8 + ], + [ + 81, + 8 + ], + [ + 82, + 8 + ] + ], + "waypoints": [ + [ + 42, + 16 + ], + [ + 65, + 10 + ], + [ + 82, + 8 + ] + ], + "width": 7, + "role": "main", + "analysis": { + "length": 43.5, + "ground_min": 84.4, + "ground_max": 90.2, + "p95_raw_grade": 0.24, + "water_samples": 0 + } + }, + { + "id": "south-axis", + "name": "Arrival avenue", + "points": [ + [ + 0, + 57 + ], + [ + 0, + 58 + ], + [ + 0, + 59 + ], + [ + 0, + 60 + ], + [ + 0, + 61 + ], + [ + 0, + 62 + ], + [ + 1, + 62 + ], + [ + 1, + 63 + ], + [ + 1, + 64 + ], + [ + 1, + 65 + ], + [ + 1, + 66 + ], + [ + 1, + 67 + ], + [ + 1, + 68 + ], + [ + 1, + 69 + ], + [ + 2, + 70 + ], + [ + 2, + 71 + ], + [ + 2, + 72 + ], + [ + 2, + 73 + ], + [ + 2, + 74 + ], + [ + 2, + 75 + ], + [ + 2, + 76 + ], + [ + 2, + 78 + ], + [ + 2, + 79 + ], + [ + 3, + 80 + ], + [ + 3, + 81 + ], + [ + 3, + 82 + ], + [ + 3, + 83 + ], + [ + 3, + 84 + ], + [ + 3, + 86 + ], + [ + 3, + 87 + ], + [ + 3, + 88 + ], + [ + 3, + 89 + ], + [ + 3, + 90 + ], + [ + 3, + 91 + ], + [ + 3, + 93 + ], + [ + 3, + 94 + ], + [ + 3, + 95 + ], + [ + 3, + 96 + ], + [ + 3, + 97 + ], + [ + 3, + 98 + ], + [ + 3, + 99 + ], + [ + 3, + 100 + ], + [ + 3, + 101 + ], + [ + 3, + 102 + ], + [ + 2, + 103 + ], + [ + 2, + 104 + ], + [ + 2, + 105 + ], + [ + 2, + 106 + ], + [ + 2, + 107 + ], + [ + 1, + 108 + ], + [ + 1, + 109 + ], + [ + 1, + 110 + ], + [ + 1, + 111 + ], + [ + 1, + 112 + ], + [ + 0, + 113 + ], + [ + 0, + 114 + ], + [ + 0, + 115 + ], + [ + 0, + 116 + ], + [ + -1, + 117 + ], + [ + -1, + 118 + ], + [ + -1, + 119 + ], + [ + -1, + 120 + ], + [ + -2, + 121 + ], + [ + -2, + 122 + ], + [ + -2, + 123 + ], + [ + -2, + 124 + ], + [ + -3, + 125 + ], + [ + -3, + 126 + ], + [ + -3, + 127 + ], + [ + -3, + 128 + ], + [ + -3, + 129 + ], + [ + -4, + 130 + ], + [ + -4, + 131 + ], + [ + -4, + 132 + ], + [ + -4, + 133 + ], + [ + -4, + 134 + ], + [ + -4, + 135 + ], + [ + -5, + 136 + ], + [ + -5, + 137 + ], + [ + -5, + 138 + ], + [ + -5, + 139 + ], + [ + -5, + 140 + ], + [ + -5, + 141 + ], + [ + -5, + 142 + ], + [ + -5, + 143 + ], + [ + -5, + 144 + ], + [ + -5, + 145 + ], + [ + -5, + 146 + ], + [ + -5, + 147 + ], + [ + -5, + 148 + ], + [ + -5, + 149 + ], + [ + -5, + 150 + ], + [ + -4, + 151 + ], + [ + -4, + 152 + ], + [ + -4, + 153 + ], + [ + -4, + 154 + ], + [ + -4, + 155 + ], + [ + -4, + 156 + ], + [ + -3, + 157 + ], + [ + -3, + 158 + ], + [ + -3, + 159 + ], + [ + -3, + 160 + ], + [ + -2, + 161 + ], + [ + -2, + 162 + ], + [ + -2, + 163 + ], + [ + -2, + 164 + ], + [ + -2, + 165 + ], + [ + -1, + 166 + ], + [ + -1, + 167 + ], + [ + -1, + 168 + ], + [ + 0, + 169 + ], + [ + 0, + 170 + ], + [ + 0, + 171 + ], + [ + 0, + 172 + ], + [ + 1, + 173 + ], + [ + 1, + 174 + ], + [ + 1, + 175 + ], + [ + 1, + 176 + ], + [ + 2, + 177 + ], + [ + 2, + 178 + ], + [ + 2, + 179 + ], + [ + 2, + 180 + ], + [ + 3, + 181 + ], + [ + 3, + 182 + ], + [ + 3, + 183 + ], + [ + 3, + 184 + ], + [ + 3, + 185 + ], + [ + 4, + 186 + ], + [ + 4, + 187 + ], + [ + 4, + 188 + ], + [ + 4, + 189 + ], + [ + 4, + 190 + ], + [ + 4, + 191 + ], + [ + 4, + 192 + ], + [ + 5, + 193 + ], + [ + 5, + 194 + ], + [ + 5, + 195 + ], + [ + 5, + 196 + ], + [ + 5, + 197 + ], + [ + 5, + 198 + ], + [ + 5, + 199 + ], + [ + 5, + 200 + ], + [ + 5, + 201 + ], + [ + 6, + 202 + ], + [ + 6, + 203 + ], + [ + 6, + 204 + ], + [ + 6, + 205 + ], + [ + 6, + 206 + ], + [ + 6, + 207 + ], + [ + 6, + 208 + ], + [ + 6, + 209 + ], + [ + 6, + 210 + ], + [ + 6, + 211 + ], + [ + 7, + 212 + ], + [ + 7, + 213 + ], + [ + 7, + 214 + ], + [ + 7, + 215 + ], + [ + 7, + 216 + ], + [ + 7, + 217 + ], + [ + 7, + 218 + ], + [ + 7, + 219 + ], + [ + 7, + 220 + ], + [ + 7, + 221 + ], + [ + 7, + 222 + ], + [ + 7, + 223 + ], + [ + 7, + 224 + ], + [ + 7, + 225 + ], + [ + 8, + 226 + ], + [ + 8, + 227 + ], + [ + 8, + 228 + ], + [ + 8, + 229 + ], + [ + 8, + 230 + ], + [ + 8, + 231 + ], + [ + 8, + 232 + ], + [ + 8, + 233 + ], + [ + 8, + 234 + ], + [ + 8, + 235 + ], + [ + 8, + 236 + ], + [ + 8, + 237 + ], + [ + 8, + 238 + ], + [ + 8, + 239 + ], + [ + 8, + 240 + ], + [ + 8, + 241 + ], + [ + 8, + 242 + ], + [ + 8, + 243 + ], + [ + 8, + 244 + ], + [ + 8, + 245 + ], + [ + 8, + 246 + ], + [ + 8, + 247 + ], + [ + 8, + 248 + ], + [ + 8, + 249 + ], + [ + 8, + 250 + ], + [ + 8, + 251 + ], + [ + 8, + 252 + ], + [ + 8, + 253 + ], + [ + 8, + 254 + ], + [ + 8, + 255 + ], + [ + 8, + 256 + ], + [ + 8, + 257 + ], + [ + 8, + 258 + ], + [ + 8, + 260 + ], + [ + 8, + 261 + ], + [ + 8, + 262 + ], + [ + 8, + 263 + ], + [ + 8, + 264 + ], + [ + 8, + 265 + ], + [ + 8, + 266 + ], + [ + 8, + 267 + ], + [ + 8, + 268 + ], + [ + 7, + 269 + ], + [ + 7, + 270 + ], + [ + 7, + 271 + ], + [ + 7, + 272 + ], + [ + 7, + 273 + ], + [ + 7, + 274 + ], + [ + 7, + 275 + ], + [ + 7, + 276 + ], + [ + 7, + 277 + ], + [ + 7, + 278 + ], + [ + 7, + 279 + ], + [ + 7, + 280 + ], + [ + 7, + 281 + ], + [ + 7, + 282 + ], + [ + 7, + 283 + ], + [ + 7, + 284 + ], + [ + 7, + 285 + ], + [ + 7, + 286 + ], + [ + 7, + 288 + ], + [ + 7, + 289 + ], + [ + 7, + 290 + ], + [ + 7, + 291 + ], + [ + 7, + 292 + ], + [ + 6, + 293 + ], + [ + 6, + 295 + ], + [ + 6, + 296 + ], + [ + 6, + 297 + ], + [ + 6, + 298 + ], + [ + 6, + 299 + ], + [ + 6, + 300 + ], + [ + 6, + 301 + ], + [ + 6, + 303 + ], + [ + 6, + 304 + ], + [ + 6, + 305 + ], + [ + 6, + 306 + ], + [ + 6, + 307 + ], + [ + 6, + 308 + ], + [ + 6, + 309 + ], + [ + 6, + 310 + ], + [ + 6, + 311 + ], + [ + 5, + 312 + ], + [ + 5, + 313 + ], + [ + 5, + 314 + ], + [ + 5, + 315 + ], + [ + 5, + 316 + ], + [ + 5, + 317 + ], + [ + 5, + 318 + ], + [ + 5, + 319 + ], + [ + 5, + 320 + ], + [ + 5, + 321 + ] + ], + "waypoints": [ + [ + 0, + 57 + ], + [ + 3, + 97 + ], + [ + -5, + 143 + ], + [ + 4, + 189 + ], + [ + 8, + 237 + ], + [ + 7, + 281 + ], + [ + 5, + 321 + ] + ], + "width": 9, + "role": "main", + "analysis": { + "length": 275.8, + "ground_min": 59.3, + "ground_max": 91.8, + "p95_raw_grade": 0.38, + "water_samples": 0 + } + }, + { + "id": "ring-northwest", + "name": "Station to portals", + "points": [ + [ + -40, + -65 + ], + [ + -41, + -65 + ], + [ + -42, + -65 + ], + [ + -42, + -66 + ], + [ + -43, + -66 + ], + [ + -44, + -66 + ], + [ + -45, + -66 + ], + [ + -45, + -67 + ], + [ + -46, + -67 + ], + [ + -47, + -67 + ], + [ + -48, + -67 + ], + [ + -49, + -68 + ], + [ + -50, + -68 + ], + [ + -51, + -68 + ], + [ + -52, + -68 + ], + [ + -53, + -69 + ], + [ + -54, + -69 + ], + [ + -55, + -69 + ], + [ + -56, + -70 + ], + [ + -57, + -70 + ], + [ + -58, + -70 + ], + [ + -59, + -71 + ], + [ + -61, + -71 + ], + [ + -62, + -71 + ], + [ + -63, + -72 + ], + [ + -64, + -72 + ], + [ + -65, + -72 + ], + [ + -66, + -73 + ], + [ + -67, + -73 + ], + [ + -68, + -73 + ], + [ + -70, + -74 + ], + [ + -71, + -74 + ], + [ + -72, + -74 + ], + [ + -73, + -75 + ], + [ + -74, + -75 + ], + [ + -75, + -75 + ], + [ + -76, + -76 + ], + [ + -77, + -76 + ], + [ + -78, + -76 + ], + [ + -79, + -77 + ], + [ + -80, + -77 + ], + [ + -81, + -77 + ], + [ + -82, + -77 + ], + [ + -83, + -78 + ], + [ + -84, + -78 + ], + [ + -85, + -78 + ], + [ + -86, + -79 + ], + [ + -87, + -79 + ], + [ + -88, + -79 + ], + [ + -89, + -80 + ], + [ + -90, + -80 + ], + [ + -91, + -80 + ], + [ + -92, + -80 + ], + [ + -93, + -81 + ], + [ + -94, + -81 + ], + [ + -95, + -81 + ], + [ + -96, + -82 + ], + [ + -97, + -82 + ], + [ + -98, + -83 + ], + [ + -99, + -83 + ], + [ + -100, + -83 + ], + [ + -101, + -84 + ], + [ + -102, + -84 + ], + [ + -103, + -84 + ], + [ + -104, + -85 + ], + [ + -105, + -85 + ], + [ + -106, + -85 + ], + [ + -107, + -86 + ], + [ + -108, + -86 + ], + [ + -109, + -86 + ], + [ + -110, + -87 + ], + [ + -111, + -87 + ], + [ + -112, + -87 + ], + [ + -113, + -88 + ], + [ + -114, + -88 + ], + [ + -115, + -89 + ], + [ + -116, + -89 + ], + [ + -117, + -89 + ], + [ + -118, + -90 + ], + [ + -119, + -90 + ], + [ + -120, + -91 + ], + [ + -121, + -91 + ], + [ + -122, + -92 + ], + [ + -123, + -92 + ], + [ + -124, + -93 + ], + [ + -125, + -93 + ], + [ + -126, + -94 + ], + [ + -127, + -94 + ], + [ + -128, + -95 + ], + [ + -129, + -95 + ], + [ + -130, + -96 + ], + [ + -131, + -96 + ], + [ + -132, + -97 + ], + [ + -133, + -97 + ], + [ + -134, + -97 + ], + [ + -135, + -98 + ], + [ + -136, + -98 + ], + [ + -136, + -99 + ], + [ + -137, + -99 + ], + [ + -138, + -100 + ], + [ + -139, + -100 + ], + [ + -140, + -101 + ], + [ + -141, + -102 + ], + [ + -142, + -102 + ], + [ + -143, + -103 + ], + [ + -144, + -104 + ], + [ + -145, + -105 + ], + [ + -146, + -106 + ], + [ + -147, + -107 + ], + [ + -147, + -108 + ], + [ + -148, + -109 + ], + [ + -148, + -110 + ], + [ + -149, + -111 + ], + [ + -149, + -112 + ], + [ + -150, + -113 + ], + [ + -150, + -114 + ], + [ + -150, + -115 + ], + [ + -150, + -116 + ], + [ + -150, + -117 + ], + [ + -151, + -118 + ], + [ + -151, + -119 + ], + [ + -151, + -120 + ], + [ + -152, + -121 + ], + [ + -152, + -122 + ], + [ + -153, + -123 + ], + [ + -153, + -124 + ], + [ + -154, + -125 + ], + [ + -154, + -126 + ], + [ + -155, + -127 + ], + [ + -156, + -128 + ], + [ + -156, + -129 + ], + [ + -157, + -130 + ], + [ + -158, + -131 + ], + [ + -158, + -132 + ], + [ + -159, + -133 + ], + [ + -160, + -133 + ], + [ + -160, + -134 + ], + [ + -161, + -135 + ], + [ + -162, + -136 + ], + [ + -162, + -137 + ], + [ + -163, + -137 + ], + [ + -163, + -138 + ], + [ + -164, + -139 + ], + [ + -164, + -139 + ] + ], + "waypoints": [ + [ + -40, + -65 + ], + [ + -77, + -76 + ], + [ + -116, + -89 + ], + [ + -145, + -105 + ], + [ + -152, + -122 + ], + [ + -164, + -139 + ] + ], + "width": 7, + "role": "main", + "analysis": { + "length": 166.2, + "ground_min": 69.4, + "ground_max": 92.2, + "p95_raw_grade": 0.48, + "water_samples": 0 + } + }, + { + "id": "ring-west", + "name": "West inner promenade", + "points": [ + [ + -147, + -111 + ], + [ + -146, + -110 + ], + [ + -146, + -109 + ], + [ + -145, + -109 + ], + [ + -145, + -108 + ], + [ + -144, + -107 + ], + [ + -143, + -106 + ], + [ + -142, + -105 + ], + [ + -141, + -104 + ], + [ + -141, + -103 + ], + [ + -140, + -102 + ], + [ + -139, + -101 + ], + [ + -138, + -100 + ], + [ + -137, + -99 + ], + [ + -136, + -98 + ], + [ + -136, + -97 + ], + [ + -135, + -96 + ], + [ + -134, + -95 + ], + [ + -134, + -94 + ], + [ + -133, + -93 + ], + [ + -132, + -92 + ], + [ + -132, + -91 + ], + [ + -131, + -90 + ], + [ + -130, + -90 + ], + [ + -130, + -89 + ], + [ + -129, + -88 + ], + [ + -128, + -87 + ], + [ + -128, + -86 + ], + [ + -127, + -85 + ], + [ + -127, + -84 + ], + [ + -126, + -83 + ], + [ + -126, + -82 + ], + [ + -125, + -81 + ], + [ + -125, + -80 + ], + [ + -125, + -79 + ], + [ + -124, + -78 + ], + [ + -124, + -77 + ], + [ + -124, + -76 + ], + [ + -123, + -75 + ], + [ + -123, + -74 + ], + [ + -123, + -73 + ], + [ + -122, + -72 + ], + [ + -122, + -71 + ], + [ + -122, + -70 + ], + [ + -122, + -69 + ], + [ + -122, + -68 + ], + [ + -121, + -67 + ], + [ + -121, + -66 + ], + [ + -121, + -65 + ], + [ + -121, + -64 + ], + [ + -121, + -63 + ], + [ + -121, + -62 + ], + [ + -121, + -61 + ], + [ + -120, + -60 + ], + [ + -120, + -59 + ], + [ + -120, + -58 + ], + [ + -120, + -57 + ], + [ + -120, + -56 + ], + [ + -120, + -55 + ], + [ + -120, + -54 + ], + [ + -120, + -53 + ], + [ + -119, + -52 + ], + [ + -119, + -51 + ], + [ + -119, + -50 + ], + [ + -119, + -48 + ], + [ + -119, + -47 + ], + [ + -119, + -46 + ], + [ + -119, + -45 + ], + [ + -119, + -44 + ], + [ + -118, + -43 + ], + [ + -118, + -42 + ], + [ + -118, + -41 + ], + [ + -118, + -40 + ], + [ + -118, + -39 + ], + [ + -117, + -38 + ], + [ + -117, + -37 + ], + [ + -117, + -36 + ], + [ + -117, + -35 + ], + [ + -117, + -34 + ], + [ + -116, + -33 + ], + [ + -116, + -32 + ], + [ + -116, + -31 + ], + [ + -116, + -30 + ], + [ + -116, + -29 + ], + [ + -115, + -28 + ], + [ + -115, + -27 + ], + [ + -115, + -26 + ], + [ + -115, + -25 + ], + [ + -114, + -24 + ], + [ + -114, + -23 + ], + [ + -114, + -22 + ], + [ + -114, + -21 + ], + [ + -114, + -20 + ], + [ + -113, + -19 + ], + [ + -113, + -18 + ], + [ + -113, + -17 + ], + [ + -113, + -16 + ], + [ + -113, + -15 + ], + [ + -112, + -14 + ], + [ + -112, + -13 + ], + [ + -112, + -12 + ], + [ + -112, + -11 + ], + [ + -112, + -10 + ], + [ + -111, + -9 + ], + [ + -111, + -8 + ], + [ + -111, + -7 + ], + [ + -111, + -6 + ], + [ + -111, + -5 + ], + [ + -111, + -4 + ], + [ + -111, + -3 + ], + [ + -110, + -2 + ], + [ + -110, + -1 + ], + [ + -110, + 0 + ], + [ + -110, + 1 + ], + [ + -110, + 2 + ], + [ + -110, + 3 + ], + [ + -110, + 4 + ], + [ + -110, + 5 + ], + [ + -110, + 6 + ], + [ + -110, + 7 + ], + [ + -110, + 8 + ], + [ + -110, + 9 + ], + [ + -110, + 10 + ], + [ + -110, + 11 + ], + [ + -110, + 12 + ], + [ + -110, + 13 + ], + [ + -110, + 14 + ], + [ + -111, + 15 + ], + [ + -111, + 16 + ], + [ + -111, + 17 + ], + [ + -111, + 18 + ], + [ + -111, + 19 + ], + [ + -111, + 20 + ], + [ + -111, + 21 + ], + [ + -112, + 22 + ], + [ + -112, + 23 + ], + [ + -112, + 24 + ], + [ + -112, + 25 + ], + [ + -112, + 26 + ], + [ + -112, + 27 + ], + [ + -113, + 28 + ], + [ + -113, + 29 + ], + [ + -113, + 30 + ], + [ + -113, + 31 + ], + [ + -113, + 32 + ], + [ + -113, + 33 + ], + [ + -114, + 34 + ], + [ + -114, + 35 + ], + [ + -114, + 36 + ], + [ + -114, + 37 + ], + [ + -114, + 38 + ], + [ + -114, + 39 + ], + [ + -114, + 40 + ], + [ + -115, + 41 + ], + [ + -115, + 42 + ], + [ + -115, + 43 + ], + [ + -115, + 44 + ], + [ + -115, + 45 + ], + [ + -115, + 46 + ], + [ + -115, + 47 + ], + [ + -115, + 48 + ], + [ + -115, + 49 + ], + [ + -115, + 50 + ], + [ + -115, + 51 + ], + [ + -115, + 52 + ], + [ + -115, + 53 + ], + [ + -115, + 54 + ], + [ + -115, + 55 + ], + [ + -115, + 56 + ], + [ + -115, + 57 + ], + [ + -115, + 58 + ], + [ + -114, + 59 + ], + [ + -114, + 60 + ], + [ + -114, + 61 + ], + [ + -114, + 62 + ], + [ + -114, + 63 + ], + [ + -114, + 64 + ], + [ + -114, + 65 + ], + [ + -114, + 66 + ], + [ + -113, + 67 + ], + [ + -113, + 68 + ], + [ + -113, + 69 + ], + [ + -113, + 70 + ], + [ + -113, + 71 + ], + [ + -113, + 72 + ], + [ + -113, + 73 + ], + [ + -113, + 74 + ], + [ + -113, + 75 + ], + [ + -113, + 76 + ], + [ + -113, + 77 + ], + [ + -113, + 78 + ], + [ + -113, + 79 + ], + [ + -112, + 80 + ], + [ + -112, + 81 + ], + [ + -113, + 82 + ], + [ + -113, + 83 + ], + [ + -113, + 84 + ], + [ + -113, + 85 + ], + [ + -113, + 86 + ], + [ + -113, + 87 + ], + [ + -113, + 88 + ], + [ + -113, + 89 + ], + [ + -113, + 90 + ], + [ + -114, + 91 + ], + [ + -114, + 92 + ], + [ + -114, + 93 + ], + [ + -114, + 94 + ], + [ + -115, + 95 + ], + [ + -115, + 96 + ], + [ + -115, + 97 + ], + [ + -116, + 99 + ], + [ + -116, + 100 + ], + [ + -116, + 101 + ], + [ + -117, + 102 + ], + [ + -117, + 103 + ], + [ + -118, + 104 + ], + [ + -118, + 105 + ], + [ + -119, + 106 + ], + [ + -119, + 107 + ], + [ + -119, + 108 + ], + [ + -120, + 110 + ], + [ + -120, + 111 + ], + [ + -121, + 112 + ], + [ + -121, + 113 + ], + [ + -122, + 114 + ], + [ + -122, + 115 + ], + [ + -122, + 116 + ], + [ + -123, + 117 + ], + [ + -124, + 118 + ], + [ + -124, + 119 + ], + [ + -124, + 120 + ], + [ + -125, + 121 + ], + [ + -125, + 122 + ], + [ + -126, + 122 + ], + [ + -126, + 123 + ], + [ + -126, + 124 + ], + [ + -127, + 125 + ], + [ + -127, + 126 + ], + [ + -127, + 126 + ] + ], + "waypoints": [ + [ + -147, + -111 + ], + [ + -125, + -80 + ], + [ + -118, + -41 + ], + [ + -110, + 4 + ], + [ + -115, + 47 + ], + [ + -113, + 88 + ], + [ + -127, + 126 + ] + ], + "width": 7, + "role": "main", + "analysis": { + "length": 263.3, + "ground_min": 67.5, + "ground_max": 80.5, + "p95_raw_grade": 0.37, + "water_samples": 0 + } + }, + { + "id": "ring-market-north", + "name": "Market north street", + "points": [ + [ + -213, + 128 + ], + [ + -213, + 129 + ], + [ + -214, + 130 + ], + [ + -214, + 131 + ], + [ + -214, + 132 + ], + [ + -215, + 132 + ], + [ + -215, + 133 + ], + [ + -216, + 134 + ], + [ + -216, + 135 + ], + [ + -216, + 137 + ], + [ + -217, + 138 + ], + [ + -217, + 139 + ], + [ + -217, + 140 + ], + [ + -218, + 141 + ], + [ + -218, + 142 + ], + [ + -218, + 143 + ], + [ + -218, + 145 + ], + [ + -218, + 146 + ], + [ + -218, + 147 + ], + [ + -218, + 148 + ], + [ + -218, + 149 + ], + [ + -218, + 150 + ], + [ + -217, + 151 + ], + [ + -217, + 152 + ], + [ + -216, + 153 + ], + [ + -216, + 154 + ], + [ + -215, + 155 + ], + [ + -215, + 156 + ], + [ + -214, + 157 + ], + [ + -214, + 158 + ], + [ + -213, + 159 + ], + [ + -213, + 160 + ], + [ + -212, + 161 + ], + [ + -211, + 162 + ], + [ + -211, + 163 + ], + [ + -210, + 164 + ], + [ + -210, + 165 + ], + [ + -209, + 166 + ], + [ + -208, + 166 + ], + [ + -208, + 167 + ], + [ + -207, + 168 + ], + [ + -207, + 169 + ], + [ + -206, + 170 + ], + [ + -205, + 170 + ], + [ + -205, + 171 + ], + [ + -204, + 172 + ], + [ + -203, + 173 + ], + [ + -202, + 174 + ], + [ + -201, + 175 + ], + [ + -200, + 176 + ], + [ + -199, + 176 + ], + [ + -198, + 177 + ], + [ + -197, + 177 + ], + [ + -196, + 178 + ], + [ + -195, + 179 + ], + [ + -194, + 180 + ], + [ + -193, + 181 + ], + [ + -193, + 182 + ], + [ + -193, + 183 + ], + [ + -193, + 184 + ], + [ + -193, + 185 + ], + [ + -193, + 186 + ], + [ + -193, + 187 + ], + [ + -193, + 188 + ], + [ + -193, + 189 + ], + [ + -193, + 191 + ], + [ + -193, + 192 + ], + [ + -193, + 193 + ], + [ + -194, + 194 + ], + [ + -194, + 195 + ], + [ + -194, + 196 + ], + [ + -194, + 197 + ], + [ + -195, + 198 + ], + [ + -195, + 199 + ], + [ + -195, + 200 + ] + ], + "waypoints": [ + [ + -213, + 128 + ], + [ + -218, + 148 + ], + [ + -205, + 171 + ], + [ + -193, + 182 + ], + [ + -195, + 200 + ] + ], + "width": 7, + "role": "main", + "analysis": { + "length": 88.2, + "ground_min": 69.1, + "ground_max": 77.6, + "p95_raw_grade": 0.63, + "water_samples": 0 + } + }, + { + "id": "ring-market-south", + "name": "Market south street", + "points": [ + [ + -195, + 250 + ], + [ + -194, + 250 + ], + [ + -194, + 251 + ], + [ + -193, + 251 + ], + [ + -192, + 252 + ], + [ + -191, + 252 + ], + [ + -190, + 252 + ], + [ + -188, + 253 + ], + [ + -187, + 253 + ], + [ + -186, + 253 + ], + [ + -185, + 253 + ], + [ + -184, + 253 + ], + [ + -183, + 253 + ], + [ + -182, + 253 + ], + [ + -181, + 253 + ], + [ + -180, + 253 + ], + [ + -179, + 253 + ], + [ + -178, + 253 + ], + [ + -177, + 253 + ], + [ + -176, + 253 + ], + [ + -175, + 253 + ], + [ + -174, + 253 + ], + [ + -172, + 253 + ], + [ + -171, + 252 + ], + [ + -170, + 252 + ], + [ + -169, + 252 + ], + [ + -168, + 251 + ], + [ + -167, + 251 + ], + [ + -166, + 251 + ], + [ + -165, + 250 + ], + [ + -164, + 250 + ], + [ + -163, + 250 + ], + [ + -162, + 249 + ], + [ + -161, + 249 + ], + [ + -160, + 248 + ], + [ + -159, + 248 + ], + [ + -158, + 247 + ], + [ + -156, + 247 + ], + [ + -155, + 246 + ], + [ + -154, + 246 + ], + [ + -153, + 246 + ], + [ + -152, + 245 + ], + [ + -151, + 245 + ], + [ + -150, + 244 + ], + [ + -149, + 244 + ], + [ + -148, + 243 + ], + [ + -147, + 243 + ], + [ + -146, + 242 + ], + [ + -145, + 242 + ] + ], + "waypoints": [ + [ + -195, + 250 + ], + [ + -187, + 253 + ], + [ + -170, + 252 + ], + [ + -145, + 242 + ] + ], + "width": 7, + "role": "main", + "analysis": { + "length": 56.2, + "ground_min": 61.8, + "ground_max": 65.9, + "p95_raw_grade": 0.3, + "water_samples": 0 + } + }, + { + "id": "ring-south", + "name": "South inner promenade", + "points": [ + [ + -72, + 242 + ], + [ + -71, + 242 + ], + [ + -70, + 242 + ], + [ + -69, + 242 + ], + [ + -68, + 242 + ], + [ + -67, + 241 + ], + [ + -66, + 241 + ], + [ + -65, + 241 + ], + [ + -64, + 241 + ], + [ + -63, + 241 + ], + [ + -62, + 241 + ], + [ + -61, + 241 + ], + [ + -60, + 240 + ], + [ + -59, + 240 + ], + [ + -58, + 240 + ], + [ + -57, + 240 + ], + [ + -56, + 240 + ], + [ + -55, + 240 + ], + [ + -53, + 240 + ], + [ + -52, + 240 + ], + [ + -51, + 239 + ], + [ + -50, + 239 + ], + [ + -49, + 239 + ], + [ + -48, + 239 + ], + [ + -46, + 239 + ], + [ + -45, + 239 + ], + [ + -44, + 239 + ], + [ + -43, + 239 + ], + [ + -42, + 238 + ], + [ + -41, + 238 + ], + [ + -39, + 238 + ], + [ + -38, + 238 + ], + [ + -37, + 238 + ], + [ + -36, + 238 + ], + [ + -35, + 238 + ], + [ + -34, + 238 + ], + [ + -33, + 238 + ], + [ + -32, + 238 + ], + [ + -31, + 238 + ], + [ + -30, + 238 + ], + [ + -29, + 238 + ], + [ + -28, + 238 + ], + [ + -27, + 238 + ], + [ + -26, + 238 + ], + [ + -25, + 238 + ], + [ + -24, + 238 + ], + [ + -23, + 238 + ], + [ + -22, + 238 + ], + [ + -21, + 238 + ], + [ + -20, + 238 + ], + [ + -19, + 238 + ], + [ + -18, + 238 + ], + [ + -16, + 238 + ], + [ + -15, + 238 + ], + [ + -14, + 238 + ], + [ + -13, + 238 + ], + [ + -12, + 238 + ], + [ + -11, + 239 + ], + [ + -10, + 239 + ], + [ + -9, + 239 + ], + [ + -8, + 239 + ], + [ + -7, + 239 + ], + [ + -6, + 239 + ], + [ + -5, + 239 + ], + [ + -4, + 239 + ], + [ + -3, + 238 + ], + [ + -2, + 238 + ], + [ + -1, + 238 + ], + [ + 0, + 238 + ], + [ + 1, + 238 + ], + [ + 2, + 238 + ], + [ + 3, + 238 + ], + [ + 4, + 238 + ], + [ + 5, + 238 + ], + [ + 6, + 238 + ], + [ + 6, + 237 + ], + [ + 7, + 237 + ], + [ + 8, + 237 + ], + [ + 9, + 237 + ], + [ + 10, + 236 + ], + [ + 11, + 236 + ], + [ + 12, + 235 + ], + [ + 13, + 235 + ], + [ + 14, + 234 + ], + [ + 15, + 234 + ], + [ + 16, + 233 + ], + [ + 17, + 233 + ], + [ + 18, + 232 + ], + [ + 19, + 231 + ], + [ + 20, + 230 + ], + [ + 21, + 229 + ], + [ + 22, + 229 + ], + [ + 22, + 228 + ], + [ + 23, + 227 + ], + [ + 24, + 226 + ], + [ + 25, + 225 + ], + [ + 26, + 224 + ], + [ + 26, + 223 + ], + [ + 27, + 223 + ], + [ + 28, + 222 + ], + [ + 28, + 221 + ], + [ + 29, + 220 + ], + [ + 30, + 219 + ], + [ + 31, + 218 + ], + [ + 32, + 217 + ], + [ + 32, + 216 + ], + [ + 33, + 216 + ], + [ + 34, + 215 + ], + [ + 34, + 214 + ], + [ + 35, + 213 + ], + [ + 35, + 212 + ], + [ + 36, + 212 + ], + [ + 36, + 211 + ], + [ + 37, + 210 + ], + [ + 38, + 209 + ], + [ + 38, + 208 + ], + [ + 39, + 207 + ], + [ + 40, + 206 + ], + [ + 40, + 205 + ], + [ + 41, + 204 + ], + [ + 41, + 203 + ], + [ + 42, + 202 + ], + [ + 42, + 201 + ], + [ + 43, + 200 + ], + [ + 43, + 199 + ], + [ + 44, + 199 + ], + [ + 44, + 198 + ], + [ + 45, + 197 + ], + [ + 45, + 196 + ], + [ + 46, + 195 + ], + [ + 46, + 194 + ], + [ + 46, + 193 + ], + [ + 47, + 192 + ], + [ + 47, + 191 + ], + [ + 48, + 190 + ], + [ + 48, + 189 + ], + [ + 49, + 188 + ], + [ + 49, + 187 + ], + [ + 50, + 186 + ], + [ + 50, + 185 + ], + [ + 51, + 184 + ], + [ + 51, + 183 + ], + [ + 52, + 182 + ], + [ + 52, + 181 + ], + [ + 53, + 180 + ], + [ + 53, + 178 + ], + [ + 54, + 177 + ], + [ + 54, + 176 + ], + [ + 55, + 175 + ], + [ + 55, + 174 + ], + [ + 56, + 173 + ], + [ + 56, + 172 + ], + [ + 57, + 172 + ], + [ + 57, + 171 + ], + [ + 57, + 170 + ], + [ + 58, + 169 + ], + [ + 58, + 168 + ], + [ + 59, + 167 + ], + [ + 59, + 166 + ], + [ + 59, + 166 + ] + ], + "waypoints": [ + [ + -72, + 242 + ], + [ + -36, + 238 + ], + [ + 8, + 237 + ], + [ + 31, + 218 + ], + [ + 46, + 194 + ], + [ + 59, + 166 + ] + ], + "width": 7, + "role": "main", + "analysis": { + "length": 182.1, + "ground_min": 62.8, + "ground_max": 67.8, + "p95_raw_grade": 0.24, + "water_samples": 0 + } + }, + { + "id": "ring-garden-south", + "name": "Garden south promenade", + "points": [ + [ + 143, + 166 + ], + [ + 144, + 167 + ], + [ + 145, + 167 + ], + [ + 146, + 168 + ], + [ + 147, + 168 + ], + [ + 148, + 168 + ], + [ + 148, + 169 + ], + [ + 149, + 169 + ], + [ + 150, + 170 + ], + [ + 151, + 170 + ], + [ + 152, + 171 + ], + [ + 153, + 172 + ], + [ + 154, + 172 + ], + [ + 155, + 173 + ], + [ + 156, + 173 + ], + [ + 157, + 174 + ], + [ + 158, + 174 + ], + [ + 159, + 175 + ], + [ + 160, + 175 + ], + [ + 161, + 176 + ], + [ + 163, + 176 + ], + [ + 164, + 177 + ], + [ + 165, + 177 + ], + [ + 166, + 178 + ], + [ + 167, + 178 + ], + [ + 168, + 178 + ], + [ + 169, + 178 + ], + [ + 170, + 179 + ], + [ + 171, + 179 + ], + [ + 172, + 179 + ], + [ + 173, + 179 + ], + [ + 174, + 179 + ], + [ + 175, + 179 + ], + [ + 176, + 179 + ], + [ + 177, + 179 + ], + [ + 178, + 179 + ], + [ + 179, + 179 + ], + [ + 180, + 179 + ], + [ + 181, + 179 + ], + [ + 182, + 179 + ], + [ + 184, + 178 + ], + [ + 185, + 178 + ], + [ + 186, + 178 + ], + [ + 187, + 178 + ], + [ + 188, + 177 + ], + [ + 189, + 177 + ], + [ + 190, + 177 + ], + [ + 191, + 177 + ], + [ + 192, + 176 + ], + [ + 193, + 176 + ], + [ + 194, + 176 + ], + [ + 195, + 175 + ], + [ + 196, + 175 + ], + [ + 197, + 175 + ], + [ + 198, + 174 + ], + [ + 199, + 174 + ], + [ + 200, + 173 + ], + [ + 201, + 173 + ], + [ + 202, + 172 + ], + [ + 203, + 172 + ], + [ + 204, + 171 + ], + [ + 205, + 171 + ], + [ + 206, + 170 + ], + [ + 207, + 169 + ], + [ + 208, + 169 + ], + [ + 209, + 168 + ], + [ + 210, + 167 + ], + [ + 211, + 166 + ], + [ + 211, + 165 + ], + [ + 212, + 164 + ], + [ + 213, + 164 + ], + [ + 213, + 163 + ], + [ + 214, + 162 + ], + [ + 215, + 161 + ], + [ + 215, + 160 + ], + [ + 216, + 160 + ], + [ + 216, + 159 + ], + [ + 217, + 158 + ], + [ + 218, + 157 + ], + [ + 218, + 157 + ] + ], + "waypoints": [ + [ + 143, + 166 + ], + [ + 172, + 179 + ], + [ + 203, + 172 + ], + [ + 218, + 157 + ] + ], + "width": 7, + "role": "main", + "analysis": { + "length": 92.2, + "ground_min": 71.9, + "ground_max": 83.8, + "p95_raw_grade": 0.36, + "water_samples": 0 + } + }, + { + "id": "ring-gardens", + "name": "Garden promenade", + "points": [ + [ + 218, + 137 + ], + [ + 217, + 136 + ], + [ + 217, + 135 + ], + [ + 216, + 134 + ], + [ + 216, + 133 + ], + [ + 215, + 132 + ], + [ + 215, + 131 + ], + [ + 214, + 130 + ], + [ + 213, + 129 + ], + [ + 213, + 128 + ], + [ + 212, + 128 + ], + [ + 211, + 126 + ], + [ + 211, + 125 + ], + [ + 210, + 124 + ], + [ + 209, + 124 + ], + [ + 209, + 123 + ], + [ + 208, + 122 + ], + [ + 207, + 121 + ], + [ + 207, + 120 + ], + [ + 206, + 119 + ], + [ + 205, + 118 + ], + [ + 204, + 117 + ], + [ + 203, + 116 + ], + [ + 202, + 116 + ], + [ + 201, + 115 + ], + [ + 200, + 115 + ], + [ + 199, + 114 + ], + [ + 198, + 114 + ], + [ + 197, + 114 + ], + [ + 196, + 113 + ], + [ + 195, + 113 + ], + [ + 194, + 113 + ], + [ + 193, + 113 + ], + [ + 192, + 112 + ], + [ + 191, + 112 + ], + [ + 190, + 112 + ], + [ + 189, + 111 + ], + [ + 188, + 111 + ], + [ + 188, + 110 + ], + [ + 187, + 110 + ], + [ + 186, + 109 + ], + [ + 185, + 108 + ], + [ + 184, + 107 + ], + [ + 184, + 106 + ], + [ + 183, + 105 + ], + [ + 182, + 104 + ], + [ + 181, + 103 + ], + [ + 181, + 102 + ], + [ + 180, + 101 + ], + [ + 180, + 100 + ], + [ + 179, + 99 + ], + [ + 179, + 98 + ], + [ + 178, + 97 + ], + [ + 178, + 96 + ], + [ + 177, + 95 + ], + [ + 177, + 94 + ], + [ + 177, + 93 + ], + [ + 177, + 92 + ], + [ + 176, + 91 + ], + [ + 176, + 90 + ], + [ + 176, + 89 + ], + [ + 176, + 88 + ], + [ + 176, + 87 + ], + [ + 176, + 86 + ], + [ + 176, + 85 + ], + [ + 176, + 84 + ], + [ + 176, + 83 + ], + [ + 177, + 82 + ], + [ + 177, + 81 + ], + [ + 177, + 80 + ], + [ + 177, + 79 + ], + [ + 178, + 78 + ], + [ + 178, + 77 + ], + [ + 178, + 76 + ], + [ + 179, + 75 + ], + [ + 179, + 74 + ], + [ + 180, + 73 + ], + [ + 180, + 72 + ], + [ + 180, + 71 + ], + [ + 181, + 71 + ], + [ + 181, + 70 + ], + [ + 182, + 69 + ], + [ + 182, + 68 + ], + [ + 183, + 67 + ], + [ + 183, + 66 + ], + [ + 184, + 65 + ], + [ + 185, + 64 + ], + [ + 185, + 63 + ], + [ + 186, + 62 + ], + [ + 187, + 61 + ], + [ + 188, + 60 + ], + [ + 189, + 59 + ], + [ + 189, + 58 + ], + [ + 190, + 57 + ], + [ + 191, + 56 + ], + [ + 192, + 55 + ], + [ + 193, + 54 + ], + [ + 194, + 53 + ], + [ + 195, + 52 + ], + [ + 196, + 51 + ], + [ + 197, + 51 + ], + [ + 197, + 50 + ], + [ + 198, + 49 + ], + [ + 199, + 49 + ], + [ + 199, + 48 + ], + [ + 200, + 47 + ], + [ + 201, + 46 + ], + [ + 201, + 46 + ] + ], + "waypoints": [ + [ + 218, + 137 + ], + [ + 204, + 117 + ], + [ + 186, + 109 + ], + [ + 176, + 88 + ], + [ + 184, + 65 + ], + [ + 201, + 46 + ] + ], + "width": 7, + "role": "main", + "analysis": { + "length": 127.9, + "ground_min": 82.0, + "ground_max": 87.2, + "p95_raw_grade": 0.47, + "water_samples": 0 + } + }, + { + "id": "ring-harbor", + "name": "Harbor garden promenade", + "points": [ + [ + 185, + -9 + ], + [ + 185, + -10 + ], + [ + 186, + -11 + ], + [ + 186, + -12 + ], + [ + 187, + -13 + ], + [ + 187, + -14 + ], + [ + 188, + -15 + ], + [ + 188, + -16 + ], + [ + 188, + -17 + ], + [ + 189, + -18 + ], + [ + 189, + -19 + ], + [ + 190, + -20 + ], + [ + 190, + -21 + ], + [ + 190, + -22 + ], + [ + 191, + -23 + ], + [ + 191, + -24 + ], + [ + 192, + -25 + ], + [ + 192, + -26 + ], + [ + 193, + -27 + ], + [ + 193, + -29 + ], + [ + 193, + -30 + ], + [ + 194, + -31 + ], + [ + 194, + -32 + ], + [ + 195, + -33 + ], + [ + 195, + -34 + ], + [ + 195, + -35 + ], + [ + 196, + -36 + ], + [ + 196, + -37 + ], + [ + 196, + -38 + ], + [ + 196, + -39 + ], + [ + 197, + -40 + ], + [ + 197, + -41 + ], + [ + 197, + -42 + ], + [ + 197, + -43 + ], + [ + 197, + -44 + ], + [ + 197, + -45 + ], + [ + 197, + -46 + ], + [ + 197, + -47 + ], + [ + 197, + -48 + ], + [ + 197, + -49 + ], + [ + 197, + -50 + ], + [ + 197, + -52 + ], + [ + 197, + -53 + ], + [ + 197, + -54 + ], + [ + 197, + -55 + ], + [ + 196, + -56 + ], + [ + 196, + -57 + ], + [ + 196, + -58 + ], + [ + 196, + -59 + ], + [ + 196, + -60 + ], + [ + 195, + -61 + ], + [ + 195, + -62 + ], + [ + 195, + -63 + ], + [ + 195, + -64 + ], + [ + 194, + -65 + ], + [ + 194, + -66 + ], + [ + 193, + -67 + ], + [ + 193, + -68 + ], + [ + 193, + -69 + ], + [ + 193, + -70 + ], + [ + 192, + -71 + ], + [ + 192, + -72 + ], + [ + 192, + -73 + ], + [ + 191, + -74 + ], + [ + 191, + -75 + ], + [ + 190, + -76 + ], + [ + 190, + -77 + ], + [ + 190, + -78 + ], + [ + 189, + -80 + ], + [ + 189, + -81 + ], + [ + 188, + -82 + ], + [ + 188, + -83 + ], + [ + 187, + -84 + ], + [ + 186, + -85 + ], + [ + 186, + -86 + ], + [ + 185, + -87 + ], + [ + 185, + -88 + ], + [ + 184, + -89 + ], + [ + 184, + -90 + ], + [ + 183, + -90 + ], + [ + 183, + -91 + ], + [ + 183, + -92 + ], + [ + 182, + -93 + ], + [ + 182, + -94 + ], + [ + 181, + -95 + ], + [ + 181, + -95 + ] + ], + "waypoints": [ + [ + 185, + -9 + ], + [ + 197, + -43 + ], + [ + 192, + -72 + ], + [ + 181, + -95 + ] + ], + "width": 7, + "role": "main", + "analysis": { + "length": 98.0, + "ground_min": 66.0, + "ground_max": 79.9, + "p95_raw_grade": 0.43, + "water_samples": 0 + } + }, + { + "id": "ring-northeast", + "name": "Station to east bridge", + "points": [ + [ + 40, + -95 + ], + [ + 41, + -95 + ], + [ + 42, + -95 + ], + [ + 43, + -95 + ], + [ + 44, + -94 + ], + [ + 45, + -94 + ], + [ + 46, + -94 + ], + [ + 48, + -94 + ], + [ + 49, + -94 + ], + [ + 50, + -94 + ], + [ + 51, + -93 + ], + [ + 52, + -93 + ], + [ + 53, + -93 + ], + [ + 54, + -93 + ], + [ + 56, + -92 + ], + [ + 57, + -92 + ], + [ + 58, + -92 + ], + [ + 59, + -91 + ], + [ + 60, + -91 + ], + [ + 61, + -91 + ], + [ + 62, + -90 + ], + [ + 63, + -90 + ], + [ + 64, + -89 + ], + [ + 65, + -89 + ], + [ + 66, + -88 + ], + [ + 67, + -88 + ], + [ + 68, + -87 + ], + [ + 69, + -86 + ], + [ + 70, + -86 + ], + [ + 71, + -85 + ], + [ + 72, + -85 + ], + [ + 73, + -84 + ], + [ + 74, + -83 + ], + [ + 75, + -83 + ], + [ + 76, + -82 + ], + [ + 77, + -82 + ], + [ + 77, + -81 + ], + [ + 78, + -80 + ], + [ + 79, + -80 + ], + [ + 80, + -79 + ], + [ + 81, + -79 + ], + [ + 82, + -78 + ], + [ + 82, + -78 + ] + ], + "waypoints": [ + [ + 40, + -95 + ], + [ + 60, + -91 + ], + [ + 82, + -78 + ] + ], + "width": 7, + "role": "main", + "analysis": { + "length": 49.4, + "ground_min": 83.4, + "ground_max": 92.1, + "p95_raw_grade": 0.38, + "water_samples": 0 + } + }, + { + "id": "harbor-bridge-landing", + "name": "Harbor bridge landing", + "points": [ + [ + 155, + -81 + ], + [ + 156, + -81 + ], + [ + 157, + -81 + ], + [ + 158, + -81 + ], + [ + 159, + -81 + ], + [ + 160, + -81 + ], + [ + 161, + -81 + ], + [ + 162, + -81 + ], + [ + 164, + -81 + ], + [ + 165, + -81 + ], + [ + 166, + -82 + ], + [ + 167, + -82 + ], + [ + 168, + -82 + ], + [ + 169, + -82 + ], + [ + 170, + -83 + ], + [ + 171, + -83 + ], + [ + 172, + -84 + ], + [ + 173, + -84 + ], + [ + 173, + -85 + ], + [ + 174, + -86 + ], + [ + 175, + -87 + ], + [ + 176, + -88 + ], + [ + 176, + -89 + ], + [ + 177, + -90 + ], + [ + 177, + -91 + ], + [ + 178, + -92 + ], + [ + 179, + -93 + ], + [ + 179, + -94 + ], + [ + 180, + -95 + ], + [ + 180, + -95 + ] + ], + "waypoints": [ + [ + 155, + -81 + ], + [ + 171, + -83 + ], + [ + 180, + -95 + ] + ], + "width": 7, + "role": "main", + "analysis": { + "length": 33.1, + "ground_min": 65.7, + "ground_max": 69.5, + "p95_raw_grade": 0.32, + "water_samples": 0 + } + }, + { + "id": "garden-bridge-landing", + "name": "Garden bridge landing", + "points": [ + [ + 151, + 8 + ], + [ + 152, + 9 + ], + [ + 152, + 10 + ], + [ + 153, + 11 + ], + [ + 154, + 12 + ], + [ + 155, + 12 + ], + [ + 155, + 13 + ], + [ + 156, + 14 + ], + [ + 156, + 15 + ], + [ + 157, + 16 + ], + [ + 158, + 17 + ], + [ + 158, + 18 + ], + [ + 159, + 19 + ], + [ + 159, + 21 + ], + [ + 159, + 22 + ], + [ + 160, + 22 + ], + [ + 160, + 23 + ] + ], + "waypoints": [ + [ + 151, + 8 + ], + [ + 157, + 16 + ], + [ + 160, + 23 + ] + ], + "width": 7, + "role": "main", + "analysis": { + "length": 19.9, + "ground_min": 83.7, + "ground_max": 85.1, + "p95_raw_grade": 0.22, + "water_samples": 0 + } + }, + { + "id": "market-shop-street", + "name": "Market north street", + "points": [ + [ + -207, + 192 + ], + [ + -207, + 193 + ], + [ + -206, + 194 + ], + [ + -206, + 195 + ], + [ + -206, + 196 + ], + [ + -205, + 197 + ], + [ + -205, + 198 + ], + [ + -204, + 199 + ], + [ + -204, + 200 + ], + [ + -203, + 201 + ], + [ + -203, + 202 + ] + ], + "waypoints": [ + [ + -207, + 192 + ], + [ + -205, + 198 + ], + [ + -203, + 202 + ] + ], + "width": 5, + "role": "secondary", + "analysis": { + "length": 11.7, + "ground_min": 69.9, + "ground_max": 71.8, + "p95_raw_grade": 0.31, + "water_samples": 0 + } + }, + { + "id": "market-south-shop-street", + "name": "Market south shops", + "points": [ + [ + -187, + 253 + ], + [ + -186, + 253 + ], + [ + -186, + 254 + ], + [ + -185, + 254 + ], + [ + -184, + 255 + ], + [ + -182, + 256 + ], + [ + -181, + 257 + ], + [ + -180, + 257 + ], + [ + -179, + 258 + ], + [ + -178, + 259 + ], + [ + -177, + 260 + ], + [ + -178, + 261 + ], + [ + -178, + 262 + ] + ], + "waypoints": [ + [ + -187, + 253 + ], + [ + -178, + 259 + ], + [ + -178, + 262 + ] + ], + "width": 5, + "role": "secondary", + "analysis": { + "length": 15.7, + "ground_min": 62.0, + "ground_max": 62.7, + "p95_raw_grade": 0.26, + "water_samples": 0 + } + }, + { + "id": "market-workshop-street", + "name": "Workshop approach", + "points": [ + [ + -198, + 250 + ], + [ + -199, + 250 + ], + [ + -199, + 251 + ], + [ + -200, + 251 + ], + [ + -201, + 251 + ], + [ + -203, + 252 + ], + [ + -204, + 252 + ], + [ + -205, + 253 + ], + [ + -206, + 253 + ], + [ + -207, + 254 + ], + [ + -208, + 255 + ], + [ + -209, + 255 + ], + [ + -209, + 256 + ], + [ + -210, + 257 + ], + [ + -211, + 258 + ], + [ + -212, + 259 + ], + [ + -212, + 260 + ], + [ + -213, + 261 + ], + [ + -214, + 262 + ], + [ + -214, + 262 + ] + ], + "waypoints": [ + [ + -198, + 250 + ], + [ + -207, + 254 + ], + [ + -214, + 262 + ] + ], + "width": 5, + "role": "secondary", + "analysis": { + "length": 22.5, + "ground_min": 61.2, + "ground_max": 62.2, + "p95_raw_grade": 0.15, + "water_samples": 0 + } + }, + { + "id": "market-guild-street", + "name": "Guild approach", + "points": [ + [ + -216, + 231 + ], + [ + -217, + 231 + ], + [ + -217, + 232 + ], + [ + -218, + 233 + ], + [ + -219, + 233 + ], + [ + -220, + 234 + ], + [ + -221, + 235 + ], + [ + -222, + 236 + ] + ], + "waypoints": [ + [ + -216, + 231 + ], + [ + -220, + 234 + ], + [ + -222, + 236 + ] + ], + "width": 5, + "role": "secondary", + "analysis": { + "length": 8.7, + "ground_min": 63.1, + "ground_max": 64.1, + "p95_raw_grade": 0.26, + "water_samples": 0 + } + }, + { + "id": "market-teahouse-street", + "name": "Tea house approach", + "points": [ + [ + -173, + 229 + ], + [ + -172, + 229 + ], + [ + -171, + 229 + ] + ], + "waypoints": [ + [ + -173, + 229 + ], + [ + -171, + 229 + ] + ], + "width": 5, + "role": "secondary", + "analysis": { + "length": 2.0, + "ground_min": 63.8, + "ground_max": 64.1, + "p95_raw_grade": 0.14, + "water_samples": 0 + } + }, + { + "id": "market-crafts-street", + "name": "Crafts approach", + "points": [ + [ + -184, + 203 + ], + [ + -183, + 203 + ], + [ + -182, + 204 + ], + [ + -181, + 205 + ], + [ + -180, + 206 + ], + [ + -179, + 206 + ] + ], + "waypoints": [ + [ + -184, + 203 + ], + [ + -179, + 206 + ] + ], + "width": 5, + "role": "secondary", + "analysis": { + "length": 6.2, + "ground_min": 65.8, + "ground_max": 66.7, + "p95_raw_grade": 0.33, + "water_samples": 0 + } + }, + { + "id": "waterworks-connector", + "name": "Waterworks approach", + "points": [ + [ + -114, + 44 + ], + [ + -115, + 44 + ], + [ + -117, + 44 + ], + [ + -118, + 44 + ], + [ + -119, + 44 + ], + [ + -120, + 44 + ], + [ + -121, + 44 + ], + [ + -122, + 44 + ], + [ + -123, + 44 + ], + [ + -124, + 44 + ], + [ + -126, + 44 + ], + [ + -127, + 43 + ], + [ + -128, + 43 + ] + ], + "waypoints": [ + [ + -114, + 44 + ], + [ + -122, + 44 + ], + [ + -128, + 43 + ] + ], + "width": 5, + "role": "secondary", + "analysis": { + "length": 14.4, + "ground_min": 71.9, + "ground_max": 76.0, + "p95_raw_grade": 0.49, + "water_samples": 0 + } + }, + { + "id": "waterworks-lookout-path", + "name": "Waterworks viewing path", + "points": [ + [ + -135, + 52 + ], + [ + -135, + 53 + ], + [ + -135, + 54 + ], + [ + -135, + 55 + ], + [ + -135, + 56 + ], + [ + -135, + 57 + ], + [ + -135, + 58 + ], + [ + -135, + 59 + ] + ], + "waypoints": [ + [ + -135, + 52 + ], + [ + -135, + 59 + ] + ], + "width": 3, + "role": "secondary", + "analysis": { + "length": 7.0, + "ground_min": 71.0, + "ground_max": 71.3, + "p95_raw_grade": 0.12, + "water_samples": 0 + } + }, + { + "id": "bridge-northeast", + "name": "Clock / harbor bridge", + "points": [ + [ + 82, + -78 + ], + [ + 83, + -78 + ], + [ + 84, + -78 + ], + [ + 85, + -78 + ], + [ + 86, + -78 + ], + [ + 87, + -78 + ], + [ + 88, + -78 + ], + [ + 89, + -78 + ], + [ + 90, + -78 + ], + [ + 91, + -78 + ], + [ + 92, + -78 + ], + [ + 93, + -78 + ], + [ + 94, + -79 + ], + [ + 96, + -79 + ], + [ + 97, + -79 + ], + [ + 98, + -79 + ], + [ + 99, + -79 + ], + [ + 100, + -79 + ], + [ + 101, + -79 + ], + [ + 102, + -79 + ], + [ + 103, + -79 + ], + [ + 104, + -79 + ], + [ + 106, + -79 + ], + [ + 107, + -79 + ], + [ + 108, + -79 + ], + [ + 109, + -79 + ], + [ + 110, + -79 + ], + [ + 112, + -79 + ], + [ + 113, + -79 + ], + [ + 114, + -79 + ], + [ + 115, + -79 + ], + [ + 117, + -79 + ], + [ + 118, + -79 + ], + [ + 119, + -80 + ], + [ + 120, + -80 + ], + [ + 122, + -80 + ], + [ + 123, + -80 + ], + [ + 124, + -80 + ], + [ + 125, + -80 + ], + [ + 127, + -80 + ], + [ + 128, + -80 + ], + [ + 129, + -80 + ], + [ + 130, + -80 + ], + [ + 131, + -80 + ], + [ + 133, + -80 + ], + [ + 134, + -80 + ], + [ + 135, + -80 + ], + [ + 136, + -80 + ], + [ + 137, + -80 + ], + [ + 138, + -80 + ], + [ + 139, + -80 + ], + [ + 140, + -80 + ], + [ + 141, + -80 + ], + [ + 143, + -80 + ], + [ + 144, + -81 + ], + [ + 145, + -81 + ], + [ + 146, + -81 + ], + [ + 147, + -81 + ], + [ + 148, + -81 + ], + [ + 149, + -81 + ], + [ + 150, + -81 + ], + [ + 151, + -81 + ], + [ + 152, + -81 + ], + [ + 153, + -81 + ], + [ + 154, + -81 + ], + [ + 155, + -81 + ] + ], + "waypoints": [ + [ + 82, + -78 + ], + [ + 155, + -81 + ] + ], + "width": 7, + "role": "bridge", + "deck_y": 86, + "minimum_ground": 32.99586868286133, + "endpoint_ground": [ + 83.44853973388672, + 69.5047836303711 + ], + "intent": "Mark both parapet alignments and abutments; preserve the river and banks.", + "abutments": [ + { + "point": [ + 82, + -78 + ], + "ground_y": 83.4, + "deck_y": 86, + "future_stairs": false, + "rise": 3 + }, + { + "point": [ + 155, + -81 + ], + "ground_y": 69.5, + "deck_y": 86, + "future_stairs": true, + "rise": 17 + } + ], + "analysis": { + "length": 74.2, + "ground_min": 33.0, + "ground_max": 83.4, + "p95_raw_grade": 2.81, + "water_samples": 23 + } + }, + { + "id": "bridge-east", + "name": "Spawn / garden bridge", + "points": [ + [ + 82, + 8 + ], + [ + 83, + 8 + ], + [ + 84, + 8 + ], + [ + 85, + 8 + ], + [ + 86, + 8 + ], + [ + 87, + 8 + ], + [ + 88, + 8 + ], + [ + 89, + 8 + ], + [ + 90, + 8 + ], + [ + 91, + 8 + ], + [ + 92, + 8 + ], + [ + 93, + 8 + ], + [ + 94, + 8 + ], + [ + 95, + 8 + ], + [ + 96, + 8 + ], + [ + 97, + 8 + ], + [ + 98, + 8 + ], + [ + 99, + 8 + ], + [ + 100, + 8 + ], + [ + 101, + 8 + ], + [ + 102, + 8 + ], + [ + 104, + 8 + ], + [ + 105, + 8 + ], + [ + 106, + 8 + ], + [ + 107, + 8 + ], + [ + 108, + 8 + ], + [ + 110, + 8 + ], + [ + 111, + 8 + ], + [ + 112, + 8 + ], + [ + 113, + 8 + ], + [ + 115, + 8 + ], + [ + 116, + 8 + ], + [ + 117, + 8 + ], + [ + 118, + 8 + ], + [ + 120, + 8 + ], + [ + 121, + 8 + ], + [ + 122, + 8 + ], + [ + 123, + 8 + ], + [ + 125, + 8 + ], + [ + 126, + 8 + ], + [ + 127, + 8 + ], + [ + 128, + 8 + ], + [ + 129, + 8 + ], + [ + 131, + 8 + ], + [ + 132, + 8 + ], + [ + 133, + 8 + ], + [ + 134, + 8 + ], + [ + 135, + 8 + ], + [ + 136, + 8 + ], + [ + 137, + 8 + ], + [ + 138, + 8 + ], + [ + 139, + 8 + ], + [ + 140, + 8 + ], + [ + 141, + 8 + ], + [ + 142, + 8 + ], + [ + 143, + 8 + ], + [ + 144, + 8 + ], + [ + 145, + 8 + ], + [ + 146, + 8 + ], + [ + 147, + 8 + ], + [ + 148, + 8 + ], + [ + 149, + 8 + ], + [ + 150, + 8 + ], + [ + 151, + 8 + ] + ], + "waypoints": [ + [ + 82, + 8 + ], + [ + 151, + 8 + ] + ], + "width": 7, + "role": "bridge", + "deck_y": 87, + "minimum_ground": 33.16427230834961, + "endpoint_ground": [ + 84.41950225830078, + 84.60459899902344 + ], + "intent": "Mark both parapet alignments and abutments; preserve the river and banks.", + "abutments": [ + { + "point": [ + 82, + 8 + ], + "ground_y": 84.4, + "deck_y": 87, + "future_stairs": false, + "rise": 3 + }, + { + "point": [ + 151, + 8 + ], + "ground_y": 84.6, + "deck_y": 87, + "future_stairs": false, + "rise": 3 + } + ], + "analysis": { + "length": 69.0, + "ground_min": 33.2, + "ground_max": 85.8, + "p95_raw_grade": 3.87, + "water_samples": 15 + } + }, + { + "id": "bridge-southeast", + "name": "Garden / south bridge", + "points": [ + [ + 59, + 166 + ], + [ + 60, + 166 + ], + [ + 61, + 166 + ], + [ + 62, + 166 + ], + [ + 63, + 166 + ], + [ + 64, + 166 + ], + [ + 65, + 166 + ], + [ + 66, + 166 + ], + [ + 67, + 166 + ], + [ + 68, + 166 + ], + [ + 69, + 166 + ], + [ + 70, + 166 + ], + [ + 71, + 166 + ], + [ + 72, + 166 + ], + [ + 73, + 166 + ], + [ + 74, + 166 + ], + [ + 75, + 166 + ], + [ + 76, + 166 + ], + [ + 77, + 166 + ], + [ + 78, + 166 + ], + [ + 79, + 166 + ], + [ + 80, + 166 + ], + [ + 82, + 166 + ], + [ + 83, + 166 + ], + [ + 84, + 166 + ], + [ + 85, + 166 + ], + [ + 86, + 166 + ], + [ + 87, + 166 + ], + [ + 89, + 166 + ], + [ + 90, + 166 + ], + [ + 91, + 166 + ], + [ + 92, + 166 + ], + [ + 94, + 166 + ], + [ + 95, + 166 + ], + [ + 96, + 166 + ], + [ + 97, + 166 + ], + [ + 99, + 166 + ], + [ + 100, + 166 + ], + [ + 101, + 166 + ], + [ + 102, + 166 + ], + [ + 103, + 166 + ], + [ + 105, + 166 + ], + [ + 106, + 166 + ], + [ + 107, + 166 + ], + [ + 108, + 166 + ], + [ + 110, + 166 + ], + [ + 111, + 166 + ], + [ + 112, + 166 + ], + [ + 113, + 166 + ], + [ + 115, + 166 + ], + [ + 116, + 166 + ], + [ + 117, + 166 + ], + [ + 118, + 166 + ], + [ + 119, + 166 + ], + [ + 120, + 166 + ], + [ + 122, + 166 + ], + [ + 123, + 166 + ], + [ + 124, + 166 + ], + [ + 125, + 166 + ], + [ + 126, + 166 + ], + [ + 127, + 166 + ], + [ + 128, + 166 + ], + [ + 129, + 166 + ], + [ + 130, + 166 + ], + [ + 131, + 166 + ], + [ + 132, + 166 + ], + [ + 133, + 166 + ], + [ + 134, + 166 + ], + [ + 135, + 166 + ], + [ + 136, + 166 + ], + [ + 137, + 166 + ], + [ + 138, + 166 + ], + [ + 139, + 166 + ], + [ + 140, + 166 + ], + [ + 141, + 166 + ], + [ + 142, + 166 + ], + [ + 143, + 166 + ] + ], + "waypoints": [ + [ + 59, + 166 + ], + [ + 143, + 166 + ] + ], + "width": 7, + "role": "bridge", + "deck_y": 75, + "minimum_ground": 31.958332061767578, + "endpoint_ground": [ + 67.63906860351562, + 72.64015197753906 + ], + "intent": "Mark both parapet alignments and abutments; preserve the river and banks.", + "abutments": [ + { + "point": [ + 59, + 166 + ], + "ground_y": 67.6, + "deck_y": 75, + "future_stairs": true, + "rise": 8 + }, + { + "point": [ + 143, + 166 + ], + "ground_y": 72.6, + "deck_y": 75, + "future_stairs": false, + "rise": 3 + } + ], + "analysis": { + "length": 84.0, + "ground_min": 32.0, + "ground_max": 72.7, + "p95_raw_grade": 2.37, + "water_samples": 18 + } + }, + { + "id": "bridge-market-north", + "name": "Market / lake bridge", + "points": [ + [ + -127, + 126 + ], + [ + -128, + 126 + ], + [ + -129, + 126 + ], + [ + -130, + 126 + ], + [ + -131, + 126 + ], + [ + -132, + 126 + ], + [ + -133, + 126 + ], + [ + -134, + 126 + ], + [ + -135, + 126 + ], + [ + -136, + 126 + ], + [ + -137, + 126 + ], + [ + -138, + 126 + ], + [ + -139, + 126 + ], + [ + -140, + 126 + ], + [ + -141, + 126 + ], + [ + -142, + 126 + ], + [ + -143, + 126 + ], + [ + -144, + 126 + ], + [ + -145, + 126 + ], + [ + -146, + 126 + ], + [ + -147, + 126 + ], + [ + -148, + 126 + ], + [ + -149, + 127 + ], + [ + -151, + 127 + ], + [ + -152, + 127 + ], + [ + -153, + 127 + ], + [ + -154, + 127 + ], + [ + -155, + 127 + ], + [ + -156, + 127 + ], + [ + -158, + 127 + ], + [ + -159, + 127 + ], + [ + -160, + 127 + ], + [ + -161, + 127 + ], + [ + -163, + 127 + ], + [ + -164, + 127 + ], + [ + -165, + 127 + ], + [ + -166, + 127 + ], + [ + -168, + 127 + ], + [ + -169, + 127 + ], + [ + -170, + 127 + ], + [ + -171, + 127 + ], + [ + -172, + 127 + ], + [ + -174, + 127 + ], + [ + -175, + 127 + ], + [ + -176, + 127 + ], + [ + -177, + 127 + ], + [ + -179, + 127 + ], + [ + -180, + 127 + ], + [ + -181, + 127 + ], + [ + -182, + 127 + ], + [ + -184, + 127 + ], + [ + -185, + 127 + ], + [ + -186, + 127 + ], + [ + -187, + 127 + ], + [ + -188, + 127 + ], + [ + -189, + 127 + ], + [ + -191, + 127 + ], + [ + -192, + 128 + ], + [ + -193, + 128 + ], + [ + -194, + 128 + ], + [ + -195, + 128 + ], + [ + -196, + 128 + ], + [ + -197, + 128 + ], + [ + -198, + 128 + ], + [ + -199, + 128 + ], + [ + -200, + 128 + ], + [ + -201, + 128 + ], + [ + -202, + 128 + ], + [ + -203, + 128 + ], + [ + -204, + 128 + ], + [ + -205, + 128 + ], + [ + -206, + 128 + ], + [ + -207, + 128 + ], + [ + -208, + 128 + ], + [ + -209, + 128 + ], + [ + -210, + 128 + ], + [ + -211, + 128 + ], + [ + -212, + 128 + ], + [ + -213, + 128 + ] + ], + "waypoints": [ + [ + -127, + 126 + ], + [ + -213, + 128 + ] + ], + "width": 7, + "role": "bridge", + "deck_y": 72, + "minimum_ground": 33.51194381713867, + "endpoint_ground": [ + 67.53361511230469, + 69.1140365600586 + ], + "intent": "Mark both parapet alignments and abutments; preserve the river and banks.", + "abutments": [ + { + "point": [ + -127, + 126 + ], + "ground_y": 67.5, + "deck_y": 72, + "future_stairs": true, + "rise": 5 + }, + { + "point": [ + -213, + 128 + ], + "ground_y": 69.1, + "deck_y": 72, + "future_stairs": false, + "rise": 3 + } + ], + "analysis": { + "length": 86.8, + "ground_min": 33.5, + "ground_max": 69.1, + "p95_raw_grade": 1.58, + "water_samples": 26 + } + }, + { + "id": "bridge-market-south", + "name": "Market / arrival bridge", + "points": [ + [ + -145, + 242 + ], + [ + -144, + 242 + ], + [ + -143, + 242 + ], + [ + -142, + 242 + ], + [ + -141, + 242 + ], + [ + -140, + 242 + ], + [ + -139, + 242 + ], + [ + -138, + 242 + ], + [ + -137, + 242 + ], + [ + -136, + 242 + ], + [ + -135, + 242 + ], + [ + -134, + 242 + ], + [ + -133, + 242 + ], + [ + -131, + 242 + ], + [ + -130, + 242 + ], + [ + -129, + 242 + ], + [ + -128, + 242 + ], + [ + -127, + 242 + ], + [ + -126, + 242 + ], + [ + -125, + 242 + ], + [ + -124, + 242 + ], + [ + -123, + 242 + ], + [ + -121, + 242 + ], + [ + -120, + 242 + ], + [ + -119, + 242 + ], + [ + -118, + 242 + ], + [ + -117, + 242 + ], + [ + -115, + 242 + ], + [ + -114, + 242 + ], + [ + -113, + 242 + ], + [ + -112, + 242 + ], + [ + -110, + 242 + ], + [ + -109, + 242 + ], + [ + -108, + 242 + ], + [ + -107, + 242 + ], + [ + -105, + 242 + ], + [ + -104, + 242 + ], + [ + -103, + 242 + ], + [ + -102, + 242 + ], + [ + -100, + 242 + ], + [ + -99, + 242 + ], + [ + -98, + 242 + ], + [ + -97, + 242 + ], + [ + -96, + 242 + ], + [ + -94, + 242 + ], + [ + -93, + 242 + ], + [ + -92, + 242 + ], + [ + -91, + 242 + ], + [ + -90, + 242 + ], + [ + -89, + 242 + ], + [ + -88, + 242 + ], + [ + -87, + 242 + ], + [ + -86, + 242 + ], + [ + -84, + 242 + ], + [ + -83, + 242 + ], + [ + -82, + 242 + ], + [ + -81, + 242 + ], + [ + -80, + 242 + ], + [ + -79, + 242 + ], + [ + -78, + 242 + ], + [ + -77, + 242 + ], + [ + -76, + 242 + ], + [ + -75, + 242 + ], + [ + -74, + 242 + ], + [ + -73, + 242 + ], + [ + -72, + 242 + ] + ], + "waypoints": [ + [ + -145, + 242 + ], + [ + -72, + 242 + ] + ], + "width": 7, + "role": "bridge", + "deck_y": 70, + "minimum_ground": 34.22280502319336, + "endpoint_ground": [ + 64.9618148803711, + 67.11980438232422 + ], + "intent": "Mark both parapet alignments and abutments; preserve the river and banks.", + "abutments": [ + { + "point": [ + -145, + 242 + ], + "ground_y": 65.0, + "deck_y": 70, + "future_stairs": true, + "rise": 6 + }, + { + "point": [ + -72, + 242 + ], + "ground_y": 67.1, + "deck_y": 70, + "future_stairs": false, + "rise": 3 + } + ], + "analysis": { + "length": 73.0, + "ground_min": 34.3, + "ground_max": 67.1, + "p95_raw_grade": 1.99, + "water_samples": 21 + } + }, + { + "id": "arrival-viaduct", + "name": "Arrival viaduct", + "points": [ + [ + 5, + 321 + ], + [ + 5, + 322 + ], + [ + 5, + 323 + ], + [ + 5, + 324 + ], + [ + 5, + 325 + ], + [ + 5, + 326 + ], + [ + 5, + 327 + ], + [ + 5, + 328 + ], + [ + 5, + 329 + ], + [ + 5, + 330 + ], + [ + 5, + 331 + ], + [ + 5, + 332 + ], + [ + 5, + 333 + ], + [ + 5, + 334 + ], + [ + 5, + 335 + ], + [ + 5, + 336 + ], + [ + 5, + 337 + ], + [ + 5, + 339 + ], + [ + 5, + 340 + ], + [ + 5, + 341 + ], + [ + 5, + 342 + ], + [ + 5, + 343 + ], + [ + 5, + 345 + ], + [ + 5, + 346 + ], + [ + 5, + 347 + ], + [ + 5, + 348 + ], + [ + 5, + 350 + ], + [ + 5, + 351 + ], + [ + 5, + 352 + ], + [ + 5, + 353 + ], + [ + 5, + 354 + ], + [ + 5, + 356 + ], + [ + 5, + 357 + ], + [ + 5, + 358 + ], + [ + 5, + 359 + ], + [ + 5, + 361 + ], + [ + 5, + 362 + ], + [ + 5, + 363 + ], + [ + 5, + 364 + ], + [ + 5, + 365 + ], + [ + 5, + 367 + ], + [ + 5, + 368 + ], + [ + 5, + 369 + ], + [ + 5, + 370 + ], + [ + 5, + 371 + ], + [ + 5, + 372 + ], + [ + 5, + 373 + ], + [ + 5, + 374 + ], + [ + 5, + 375 + ], + [ + 5, + 376 + ], + [ + 5, + 377 + ], + [ + 5, + 378 + ], + [ + 5, + 379 + ], + [ + 5, + 380 + ], + [ + 5, + 381 + ], + [ + 5, + 382 + ], + [ + 5, + 383 + ] + ], + "waypoints": [ + [ + 5, + 321 + ], + [ + 5, + 383 + ] + ], + "width": 11, + "role": "bridge", + "deck_y": 65, + "minimum_ground": 32.357540130615234, + "endpoint_ground": [ + 59.652252197265625, + 55.05183029174805 + ], + "intent": "Mark both parapet alignments and abutments; preserve the river and banks.", + "abutments": [ + { + "point": [ + 5, + 321 + ], + "ground_y": 59.7, + "deck_y": 65, + "future_stairs": true, + "rise": 6 + }, + { + "point": [ + 5, + 383 + ], + "ground_y": 55.1, + "deck_y": 65, + "future_stairs": true, + "rise": 10 + } + ], + "analysis": { + "length": 62.0, + "ground_min": 32.4, + "ground_max": 61.1, + "p95_raw_grade": 2.03, + "water_samples": 19 + } + }, + { + "id": "north-overlook-path", + "name": "North overlook", + "points": [ + [ + 40, + -128 + ], + [ + 41, + -129 + ], + [ + 42, + -129 + ], + [ + 43, + -129 + ], + [ + 43, + -130 + ], + [ + 44, + -130 + ], + [ + 45, + -131 + ], + [ + 46, + -131 + ], + [ + 47, + -132 + ], + [ + 48, + -132 + ], + [ + 49, + -133 + ], + [ + 50, + -134 + ], + [ + 51, + -134 + ], + [ + 52, + -135 + ], + [ + 53, + -136 + ], + [ + 54, + -136 + ], + [ + 55, + -137 + ], + [ + 56, + -138 + ], + [ + 57, + -138 + ], + [ + 57, + -139 + ], + [ + 58, + -140 + ], + [ + 59, + -141 + ], + [ + 59, + -142 + ], + [ + 59, + -143 + ], + [ + 60, + -144 + ], + [ + 60, + -145 + ], + [ + 61, + -146 + ], + [ + 61, + -147 + ], + [ + 61, + -148 + ], + [ + 62, + -149 + ], + [ + 62, + -150 + ], + [ + 62, + -151 + ], + [ + 62, + -152 + ], + [ + 62, + -153 + ], + [ + 62, + -154 + ], + [ + 62, + -156 + ], + [ + 62, + -157 + ], + [ + 62, + -158 + ], + [ + 62, + -159 + ], + [ + 62, + -160 + ], + [ + 62, + -161 + ], + [ + 62, + -162 + ], + [ + 61, + -163 + ], + [ + 60, + -164 + ], + [ + 60, + -165 + ], + [ + 59, + -166 + ], + [ + 58, + -166 + ], + [ + 57, + -167 + ], + [ + 56, + -168 + ], + [ + 55, + -168 + ], + [ + 54, + -169 + ], + [ + 53, + -169 + ], + [ + 52, + -170 + ], + [ + 51, + -170 + ], + [ + 50, + -171 + ], + [ + 50, + -171 + ] + ], + "waypoints": [ + [ + 40, + -128 + ], + [ + 58, + -140 + ], + [ + 62, + -161 + ], + [ + 50, + -171 + ] + ], + "width": 3, + "role": "secondary", + "analysis": { + "length": 64.1, + "ground_min": 82.0, + "ground_max": 94.1, + "p95_raw_grade": 0.67, + "water_samples": 0 + } + }, + { + "id": "portal-overlook-path", + "name": "Portal ridge path", + "points": [ + [ + -159, + -151 + ], + [ + -158, + -151 + ], + [ + -157, + -151 + ], + [ + -156, + -152 + ], + [ + -155, + -152 + ], + [ + -154, + -152 + ], + [ + -153, + -153 + ], + [ + -152, + -153 + ], + [ + -151, + -153 + ], + [ + -149, + -153 + ], + [ + -148, + -154 + ], + [ + -147, + -154 + ], + [ + -146, + -154 + ], + [ + -145, + -155 + ], + [ + -144, + -155 + ], + [ + -143, + -155 + ], + [ + -142, + -156 + ], + [ + -141, + -156 + ], + [ + -140, + -156 + ], + [ + -139, + -157 + ], + [ + -138, + -157 + ], + [ + -137, + -158 + ], + [ + -136, + -158 + ], + [ + -135, + -159 + ], + [ + -134, + -159 + ], + [ + -133, + -160 + ], + [ + -132, + -161 + ], + [ + -131, + -161 + ], + [ + -131, + -162 + ], + [ + -130, + -163 + ], + [ + -129, + -164 + ], + [ + -128, + -164 + ], + [ + -127, + -165 + ], + [ + -127, + -166 + ], + [ + -126, + -167 + ], + [ + -125, + -168 + ], + [ + -124, + -169 + ], + [ + -124, + -170 + ], + [ + -124, + -171 + ], + [ + -123, + -171 + ], + [ + -123, + -172 + ], + [ + -123, + -173 + ], + [ + -123, + -174 + ], + [ + -124, + -175 + ], + [ + -124, + -176 + ], + [ + -125, + -177 + ], + [ + -126, + -178 + ], + [ + -127, + -179 + ], + [ + -128, + -180 + ], + [ + -129, + -180 + ], + [ + -129, + -181 + ] + ], + "waypoints": [ + [ + -159, + -151 + ], + [ + -136, + -158 + ], + [ + -123, + -172 + ], + [ + -129, + -181 + ] + ], + "width": 3, + "role": "secondary", + "analysis": { + "length": 59.7, + "ground_min": 72.4, + "ground_max": 90.6, + "p95_raw_grade": 1.04, + "water_samples": 0 + } + }, + { + "id": "harbor-overlook-path", + "name": "Harbor lookout trail", + "points": [ + [ + 196, + -80 + ], + [ + 197, + -80 + ], + [ + 198, + -79 + ], + [ + 199, + -79 + ], + [ + 200, + -79 + ], + [ + 201, + -78 + ], + [ + 202, + -78 + ], + [ + 203, + -78 + ], + [ + 205, + -77 + ], + [ + 206, + -77 + ], + [ + 207, + -77 + ], + [ + 208, + -76 + ], + [ + 209, + -76 + ], + [ + 210, + -76 + ], + [ + 211, + -75 + ], + [ + 212, + -75 + ], + [ + 213, + -75 + ], + [ + 214, + -75 + ], + [ + 216, + -75 + ], + [ + 217, + -75 + ], + [ + 218, + -75 + ], + [ + 219, + -75 + ], + [ + 220, + -76 + ], + [ + 221, + -76 + ] + ], + "waypoints": [ + [ + 196, + -80 + ], + [ + 213, + -75 + ], + [ + 221, + -76 + ] + ], + "width": 3, + "role": "secondary", + "analysis": { + "length": 27.3, + "ground_min": 69.3, + "ground_max": 73.7, + "p95_raw_grade": 0.64, + "water_samples": 0 + } + }, + { + "id": "garden-overlook-path", + "name": "Garden lookout trail", + "points": [ + [ + 232, + 96 + ], + [ + 233, + 96 + ], + [ + 234, + 96 + ], + [ + 235, + 96 + ], + [ + 236, + 97 + ], + [ + 237, + 97 + ], + [ + 238, + 97 + ], + [ + 239, + 97 + ], + [ + 240, + 97 + ], + [ + 242, + 97 + ], + [ + 243, + 97 + ], + [ + 244, + 98 + ], + [ + 245, + 98 + ], + [ + 246, + 98 + ], + [ + 247, + 98 + ], + [ + 248, + 99 + ], + [ + 249, + 99 + ], + [ + 250, + 100 + ], + [ + 251, + 100 + ], + [ + 252, + 101 + ], + [ + 253, + 102 + ], + [ + 254, + 102 + ], + [ + 255, + 103 + ], + [ + 256, + 104 + ], + [ + 257, + 105 + ], + [ + 258, + 106 + ], + [ + 259, + 107 + ], + [ + 259, + 107 + ] + ], + "waypoints": [ + [ + 232, + 96 + ], + [ + 249, + 99 + ], + [ + 259, + 107 + ] + ], + "width": 3, + "role": "secondary", + "analysis": { + "length": 31.6, + "ground_min": 87.4, + "ground_max": 92.2, + "p95_raw_grade": 0.81, + "water_samples": 0 + } + }, + { + "id": "south-overlook-path", + "name": "Valley lookout trail", + "points": [ + [ + -218, + 236 + ], + [ + -218, + 235 + ], + [ + -218, + 234 + ], + [ + -219, + 233 + ], + [ + -219, + 232 + ], + [ + -219, + 231 + ], + [ + -219, + 230 + ], + [ + -219, + 228 + ], + [ + -220, + 227 + ], + [ + -220, + 226 + ], + [ + -220, + 225 + ], + [ + -220, + 224 + ], + [ + -221, + 223 + ], + [ + -221, + 222 + ], + [ + -222, + 221 + ], + [ + -223, + 220 + ], + [ + -223, + 219 + ], + [ + -224, + 219 + ], + [ + -225, + 219 + ], + [ + -226, + 218 + ], + [ + -227, + 218 + ], + [ + -228, + 218 + ], + [ + -229, + 218 + ], + [ + -230, + 218 + ], + [ + -231, + 218 + ], + [ + -232, + 218 + ], + [ + -233, + 219 + ], + [ + -234, + 219 + ], + [ + -235, + 219 + ], + [ + -237, + 219 + ], + [ + -238, + 219 + ], + [ + -239, + 220 + ], + [ + -240, + 220 + ], + [ + -241, + 220 + ], + [ + -242, + 221 + ], + [ + -243, + 221 + ], + [ + -244, + 221 + ], + [ + -245, + 222 + ], + [ + -246, + 222 + ], + [ + -247, + 223 + ], + [ + -248, + 223 + ], + [ + -249, + 224 + ], + [ + -250, + 224 + ], + [ + -251, + 225 + ], + [ + -252, + 226 + ], + [ + -253, + 227 + ], + [ + -254, + 228 + ], + [ + -255, + 228 + ], + [ + -255, + 229 + ], + [ + -256, + 230 + ], + [ + -257, + 231 + ], + [ + -258, + 232 + ], + [ + -258, + 233 + ], + [ + -259, + 234 + ], + [ + -260, + 235 + ], + [ + -261, + 236 + ], + [ + -261, + 237 + ], + [ + -262, + 238 + ], + [ + -262, + 239 + ], + [ + -263, + 239 + ], + [ + -263, + 240 + ], + [ + -264, + 241 + ], + [ + -264, + 243 + ], + [ + -264, + 244 + ], + [ + -264, + 245 + ], + [ + -264, + 246 + ], + [ + -264, + 247 + ], + [ + -264, + 248 + ], + [ + -264, + 249 + ], + [ + -264, + 250 + ], + [ + -264, + 251 + ] + ], + "waypoints": [ + [ + -218, + 236 + ], + [ + -224, + 219 + ], + [ + -248, + 223 + ], + [ + -263, + 240 + ], + [ + -264, + 251 + ] + ], + "width": 3, + "role": "secondary", + "analysis": { + "length": 82.9, + "ground_min": 63.1, + "ground_max": 87.1, + "p95_raw_grade": 1.06, + "water_samples": 0 + } + } + ], + "construction_notes": [ + "Colored outlines are building reservations, not instructions to level their entire bounding boxes.", + "Walks remain aligned to terrain; steep local runs need short stairs during the later building phase.", + "Main avenues 9 blocks, district ring 7, side paths 5, scenic trails 3; marker widths can be thinner than final clear widths.", + "Keep protected water intact. Bridges have a separate planned deck Y above their endpoint terrain.", + "The natural western lake bank is too steep for a main ring street. The main route uses the calm inner/east lake shoulder; the low over-water boardwalk is secondary.", + "No minigame arenas. Six portal bays, three harbor piers, one future flagship reservation." + ] +} diff --git a/examples/terrain/relative-brush.json b/examples/terrain/relative-brush.json new file mode 100644 index 0000000..31b05ee --- /dev/null +++ b/examples/terrain/relative-brush.json @@ -0,0 +1,11 @@ +{ + "min": {"x": -4, "y": 80, "z": -4}, + "max": {"x": 4, "y": 92, "z": 4}, + "center": {"x": 0, "z": 0}, + "radius": 3, + "action": "raise", + "amount": 3, + "strength": 1, + "falloff": 0.5, + "preserve": [] +} diff --git a/examples/terrain/shacraft-lobby-world.json b/examples/terrain/shacraft-lobby-world.json new file mode 100644 index 0000000..7302bf2 --- /dev/null +++ b/examples/terrain/shacraft-lobby-world.json @@ -0,0 +1,273 @@ +{ + "version": 1, + "min": { + "x": -384, + "y": 16, + "z": -384 + }, + "max": { + "x": 383, + "y": 207, + "z": 383 + }, + "base_height": 59, + "seed": 28092005, + "mode": "sculpt", + "noise": { + "amplitude": 26, + "scale": 90 + }, + "palette": { + "rock": "minecraft:stone", + "soil": "minecraft:dirt", + "surface": "minecraft:grass_block", + "soil_depth": 3 + }, + "features": [ + { + "type": "ridge", + "points": [ + { + "x": -350, + "z": -285 + }, + { + "x": -190, + "z": -335 + }, + { + "x": 0, + "z": -305 + }, + { + "x": 160, + "z": -330 + }, + { + "x": 330, + "z": -290 + } + ], + "width": 15, + "height": 100, + "falloff": 110 + }, + { + "type": "ridge", + "points": [ + { + "x": -330, + "z": -220 + }, + { + "x": -350, + "z": -40 + }, + { + "x": -330, + "z": 180 + }, + { + "x": -290, + "z": 310 + } + ], + "width": 8, + "height": 60, + "falloff": 70 + }, + { + "type": "ridge", + "points": [ + { + "x": 335, + "z": -210 + }, + { + "x": 340, + "z": 30 + }, + { + "x": 315, + "z": 210 + } + ], + "width": 10, + "height": 66, + "falloff": 80 + }, + { + "type": "hill", + "center": { + "x": 0, + "z": 0 + }, + "radius": 50, + "height": 45, + "falloff": 140 + }, + { + "type": "plateau", + "min": { + "x": -65, + "z": -55 + }, + "max": { + "x": 65, + "z": 60 + }, + "height": 106, + "falloff": 28 + }, + { + "type": "plateau", + "min": { + "x": -82, + "z": -165 + }, + "max": { + "x": 82, + "z": -80 + }, + "height": 118, + "falloff": 24 + }, + { + "type": "plateau", + "min": { + "x": -245, + "z": -185 + }, + "max": { + "x": -125, + "z": -105 + }, + "height": 110, + "falloff": 22 + }, + { + "type": "plateau", + "min": { + "x": 130, + "z": -200 + }, + "max": { + "x": 255, + "z": -100 + }, + "height": 124, + "falloff": 20 + }, + { + "type": "plateau", + "min": { + "x": 135, + "z": -60 + }, + "max": { + "x": 250, + "z": 5 + }, + "height": 110, + "falloff": 15 + }, + { + "type": "plateau", + "min": { + "x": 145, + "z": 40 + }, + "max": { + "x": 250, + "z": 95 + }, + "height": 97, + "falloff": 15 + }, + { + "type": "plateau", + "min": { + "x": 160, + "z": 130 + }, + "max": { + "x": 235, + "z": 180 + }, + "height": 84, + "falloff": 14 + }, + { + "type": "plateau", + "min": { + "x": -240, + "z": 90 + }, + "max": { + "x": -115, + "z": 215 + }, + "height": 92, + "falloff": 25 + }, + { + "type": "basin", + "center": { + "x": -220, + "z": -5 + }, + "radius": 47, + "height": 38, + "falloff": 30 + }, + { + "type": "channel", + "points": [ + { + "x": -40, + "z": 125 + }, + { + "x": -25, + "z": 200 + }, + { + "x": 45, + "z": 260 + }, + { + "x": 15, + "z": 383 + } + ], + "width": 20, + "height": 22, + "falloff": 16 + }, + { + "type": "channel", + "points": [ + { + "x": 100, + "z": -250 + }, + { + "x": 110, + "z": -100 + }, + { + "x": 100, + "z": 70 + }, + { + "x": 80, + "z": 190 + } + ], + "width": 10, + "height": 30, + "falloff": 12 + } + ], + "preserve": [] +} diff --git a/examples/terrain/shacraft-massif.json b/examples/terrain/shacraft-massif.json new file mode 100644 index 0000000..8ec974b --- /dev/null +++ b/examples/terrain/shacraft-massif.json @@ -0,0 +1,273 @@ +{ + "version": 1, + "min": { + "x": -384, + "y": -48, + "z": -384 + }, + "max": { + "x": 383, + "y": 143, + "z": 383 + }, + "base_height": -5, + "seed": 28092005, + "mode": "sculpt", + "noise": { + "amplitude": 26, + "scale": 90 + }, + "palette": { + "rock": "minecraft:stone", + "soil": "minecraft:dirt", + "surface": "minecraft:grass_block", + "soil_depth": 3 + }, + "features": [ + { + "type": "ridge", + "points": [ + { + "x": -350, + "z": -285 + }, + { + "x": -190, + "z": -335 + }, + { + "x": 0, + "z": -305 + }, + { + "x": 160, + "z": -330 + }, + { + "x": 330, + "z": -290 + } + ], + "width": 15, + "height": 100, + "falloff": 110 + }, + { + "type": "ridge", + "points": [ + { + "x": -330, + "z": -220 + }, + { + "x": -350, + "z": -40 + }, + { + "x": -330, + "z": 180 + }, + { + "x": -290, + "z": 310 + } + ], + "width": 8, + "height": 60, + "falloff": 70 + }, + { + "type": "ridge", + "points": [ + { + "x": 335, + "z": -210 + }, + { + "x": 340, + "z": 30 + }, + { + "x": 315, + "z": 210 + } + ], + "width": 10, + "height": 66, + "falloff": 80 + }, + { + "type": "hill", + "center": { + "x": 0, + "z": 0 + }, + "radius": 50, + "height": 45, + "falloff": 140 + }, + { + "type": "plateau", + "min": { + "x": -65, + "z": -55 + }, + "max": { + "x": 65, + "z": 60 + }, + "height": 42, + "falloff": 28 + }, + { + "type": "plateau", + "min": { + "x": -82, + "z": -165 + }, + "max": { + "x": 82, + "z": -80 + }, + "height": 54, + "falloff": 24 + }, + { + "type": "plateau", + "min": { + "x": -245, + "z": -185 + }, + "max": { + "x": -125, + "z": -105 + }, + "height": 46, + "falloff": 22 + }, + { + "type": "plateau", + "min": { + "x": 130, + "z": -200 + }, + "max": { + "x": 255, + "z": -100 + }, + "height": 60, + "falloff": 20 + }, + { + "type": "plateau", + "min": { + "x": 135, + "z": -60 + }, + "max": { + "x": 250, + "z": 5 + }, + "height": 46, + "falloff": 15 + }, + { + "type": "plateau", + "min": { + "x": 145, + "z": 40 + }, + "max": { + "x": 250, + "z": 95 + }, + "height": 33, + "falloff": 15 + }, + { + "type": "plateau", + "min": { + "x": 160, + "z": 130 + }, + "max": { + "x": 235, + "z": 180 + }, + "height": 20, + "falloff": 14 + }, + { + "type": "plateau", + "min": { + "x": -240, + "z": 90 + }, + "max": { + "x": -115, + "z": 215 + }, + "height": 28, + "falloff": 25 + }, + { + "type": "basin", + "center": { + "x": -220, + "z": -5 + }, + "radius": 47, + "height": -26, + "falloff": 30 + }, + { + "type": "channel", + "points": [ + { + "x": -40, + "z": 125 + }, + { + "x": -25, + "z": 200 + }, + { + "x": 45, + "z": 260 + }, + { + "x": 15, + "z": 383 + } + ], + "width": 20, + "height": -42, + "falloff": 16 + }, + { + "type": "channel", + "points": [ + { + "x": 100, + "z": -250 + }, + { + "x": 110, + "z": -100 + }, + { + "x": 100, + "z": 70 + }, + { + "x": 80, + "z": 190 + } + ], + "width": 10, + "height": -34, + "falloff": 12 + } + ], + "preserve": [] +} diff --git a/examples/terrain/shacraft-natural-world.json b/examples/terrain/shacraft-natural-world.json new file mode 100644 index 0000000..0a2ad82 --- /dev/null +++ b/examples/terrain/shacraft-natural-world.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "min": { + "x": -384, + "y": 16, + "z": -384 + }, + "max": { + "x": 383, + "y": 239, + "z": 383 + }, + "base_height": 62, + "seed": 28092005, + "mode": "sculpt", + "noise": { + "amplitude": 0, + "scale": 220 + }, + "palette": { + "rock": "minecraft:stone", + "soil": "minecraft:dirt", + "surface": "minecraft:grass_block", + "soil_depth": 3 + }, + "features": [], + "preserve": [] +} diff --git a/examples/terrain/small-hill.json b/examples/terrain/small-hill.json new file mode 100644 index 0000000..719245d --- /dev/null +++ b/examples/terrain/small-hill.json @@ -0,0 +1,39 @@ +{ + "version": 1, + "min": { + "x": 0, + "y": 80, + "z": 0 + }, + "max": { + "x": 7, + "y": 87, + "z": 7 + }, + "base_height": 82, + "seed": 2809, + "mode": "sculpt", + "noise": { + "amplitude": 1, + "scale": 12 + }, + "palette": { + "rock": "minecraft:stone", + "soil": "minecraft:dirt", + "surface": "minecraft:grass_block", + "soil_depth": 3 + }, + "features": [ + { + "type": "hill", + "center": { + "x": 4, + "z": 4 + }, + "radius": 1, + "height": 3, + "falloff": 3 + } + ], + "preserve": [] +} diff --git a/paper-plugin/src/main/java/io/github/minecraftbuilder/paper/BlockSnapshots.java b/paper-plugin/src/main/java/io/github/minecraftbuilder/paper/BlockSnapshots.java new file mode 100644 index 0000000..ca639ca --- /dev/null +++ b/paper-plugin/src/main/java/io/github/minecraftbuilder/paper/BlockSnapshots.java @@ -0,0 +1,50 @@ +package io.github.minecraftbuilder.paper; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Map; +import java.util.Objects; + +/** Private journal representation. MCP responses expose only block data and an opaque digest. */ +final class BlockSnapshots { + private static final String PREFIX = "\u0000mcb-block-v1:"; + static final int MAX_SNAPSHOT_BYTES = 1024 * 1024; + record Value(String state, String nbt) { } + static boolean captured(String value) { return value != null && value.startsWith(PREFIX); } + static String encode(String state, String nbt) { + Objects.requireNonNull(state); Objects.requireNonNull(nbt); + if (state.isBlank() || state.length() > 1024 || state.indexOf('\n') >= 0 || state.indexOf('\u0000') >= 0) + throw new IllegalArgumentException("Invalid captured block data"); + String result = PREFIX + state + "\n" + nbt; + requireBounded(result); + return result; + } + static Value decode(String value) { + Objects.requireNonNull(value); + if (!captured(value)) return new Value(value, null); + requireBounded(value); + int split = value.indexOf('\n', PREFIX.length()); + if (split <= PREFIX.length() || split - PREFIX.length() > 1024 || split == value.length() - 1) + throw new IllegalArgumentException("Invalid stored block snapshot"); + return new Value(value.substring(PREFIX.length(), split), value.substring(split + 1)); + } + static String state(String value) { return decode(value).state(); } + static String snapshotId(String value) { + if (!captured(value)) return null; + decode(value); + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } + } + static Map publicView(String value) { + String id = snapshotId(value); + return id == null ? Map.of("state", state(value)) : Map.of("state", state(value), "snapshot_id", id); + } + private static void requireBounded(String value) { + if (value.length() > MAX_SNAPSHOT_BYTES || value.getBytes(StandardCharsets.UTF_8).length > MAX_SNAPSHOT_BYTES) + throw new IllegalArgumentException("snapshot_budget_exceeded: block entity exceeds the 1 MiB snapshot limit"); + } + private BlockSnapshots() { } +} diff --git a/paper-plugin/src/main/java/io/github/minecraftbuilder/paper/BrushPreview.java b/paper-plugin/src/main/java/io/github/minecraftbuilder/paper/BrushPreview.java new file mode 100644 index 0000000..dc09a8e --- /dev/null +++ b/paper-plugin/src/main/java/io/github/minecraftbuilder/paper/BrushPreview.java @@ -0,0 +1,44 @@ +package io.github.minecraftbuilder.paper; + +import io.github.minecraftbuilder.core.TerrainBrush; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.*; +import java.util.*; +import javax.imageio.ImageIO; + +/** Exact sampled columns, with a shared elevation scale for before/after panels. */ +public final class BrushPreview { + private BrushPreview() { } + public static Map render(TerrainBrush.Result brush) throws IOException { + int rows=brush.before().length,cols=brush.before()[0].length; + int low=Integer.MAX_VALUE,high=Integer.MIN_VALUE; + for(int z=0;z0?0x89CC70:0xE5A35B;} + else{double t=((panel==0?old:next)-low)/(double)Math.max(1,high-low);rgb=((int)(45+160*t)<<16)|((int)(83+120*t)<<8)|(int)(65+107*t);} + g.setColor(new Color(rgb));int x0=left+(int)(x*cell),z0=36+(int)(z*cell); + g.fillRect(x0,z0,(int)((x+1)*cell)-(int)(x*cell),(int)((z+1)*cell)-(int)(z*cell)); + } + g.setColor(new Color(0xECF0E9)); + } + g.setFont(new Font(Font.SANS_SERIF,Font.PLAIN,12));g.drawString("N (-Z) up | Y "+low+" .. "+high+" | Green: raised Orange: lowered Grey: unchanged",10,289); + }finally{g.dispose();} + ByteArrayOutputStream out=new ByteArrayOutputStream();ImageIO.write(image,"png",out); + return Map.ofEntries(Map.entry("status","completed"),Map.entry("kind","terrain_brush_preview"),Map.entry("source","live_snapshot"), + Map.entry("world_edited",false),Map.entry("scan_bounds",brush.bounds()),Map.entry("changed_columns",brush.changedColumns()), + Map.entry("raised_volume",brush.raisedBlocks()),Map.entry("lowered_volume",brush.loweredBlocks()),Map.entry("dependency_blocks",brush.dependencies().size()), + Map.entry("height_min",low),Map.entry("height_max",high),Map.entry("mimeType","image/png"),Map.entry("imageBase64",Base64.getEncoder().encodeToString(out.toByteArray()))); + } +} diff --git a/paper-plugin/src/main/java/io/github/minecraftbuilder/paper/BuilderPlugin.java b/paper-plugin/src/main/java/io/github/minecraftbuilder/paper/BuilderPlugin.java index fcd52af..0b3a1a9 100644 --- a/paper-plugin/src/main/java/io/github/minecraftbuilder/paper/BuilderPlugin.java +++ b/paper-plugin/src/main/java/io/github/minecraftbuilder/paper/BuilderPlugin.java @@ -30,12 +30,14 @@ public final class BuilderPlugin extends JavaPlugin { private EditEngine engine; private World world; private BuildingWorld access; + private MaterialCatalog materials; private SchematicAssets assets; private Region region; private String projectId, epoch, owner, adminToken, agentToken; private volatile boolean ioBusy, halted; private boolean writesPaused; private int maxBlocks; + private final Map terrains = Collections.synchronizedMap(new LinkedHashMap<>()); private final Map messages = new LinkedHashMap<>(); private final Map parts = new LinkedHashMap<>(); private final Map cameras = new LinkedHashMap<>(); @@ -65,10 +67,11 @@ public final class BuilderPlugin extends JavaPlugin { region=new Region(world.getUID().toString(), configPos("region.min"), configPos("region.max")); maxBlocks=Math.min(4096,Math.max(1,getConfig().getInt("max-plan-blocks",4096))); access=new BuildingWorld(world); - assets=new SchematicAssets(getDataFolder().toPath().resolve("schematics")); + materials=new MaterialCatalog(); + assets=new SchematicAssets(getDataFolder().toPath().resolve("schematics"),BuilderPlugin::rotateSchematicState); loadMetadata(); disk=Executors.newSingleThreadExecutor(r->{Thread t=new Thread(r,"mcb-journal");t.setDaemon(true);return t;}); - Limits limits=new Limits(maxBlocks,512,Math.min(128,Math.max(1,getConfig().getInt("slice-blocks",128))), + Limits limits=new Limits(maxBlocks,4096,Math.min(128,Math.max(1,getConfig().getInt("slice-blocks",128))), Math.min(5,Math.max(1,getConfig().getInt("slice-millis",5)))*1_000_000L,600_000,32); engine=new EditEngine(access,access,new ContextGuard(){ public void check(Plan plan){guard(plan);} @@ -157,7 +160,10 @@ public final class BuilderPlugin extends JavaPlugin { if(world.getNearbyEntities(box).stream().anyMatch(e->!(e instanceof Player)))throw new Fault("unsupported_entity","Export region contains entities; this prototype exports blocks only"); Map data=new LinkedHashMap<>(); for(int y=area.min().y();y<=area.max().y();y++)for(int z=area.min().z();z<=area.max().z();z++)for(int x=area.min().x();x<=area.max().x();x++){ - BlockPos at=new BlockPos(x,y,z);String state=access.getBlock(at);if(!access.supports(state))throw new Fault("unsupported_block","Export contains unsupported block "+state);data.put(at,state); + BlockPos at=new BlockPos(x,y,z);String state=access.getBlock(at); + if(world.getBlockAt(x,y,z).getState() instanceof org.bukkit.block.TileState) + throw new Fault("unsupported_block_entity","Schematic export cannot retain block-entity data at "+at+"; no asset was written"); + if(!access.supports(state))throw new Fault("unsupported_block","Export contains unsupported block "+state);data.put(at,state); }return data; }); return assets.exportSnapshot(required(p,"name"),snapshot,p.has("origin")?pos(p.getAsJsonObject("origin")):pos(p.getAsJsonObject("min")),main(()->Bukkit.getUnsafe().getDataVersion())); @@ -168,8 +174,62 @@ public final class BuilderPlugin extends JavaPlugin { try{engine.persistPlan(plan.id());return planSummary(plan);}finally{main(()->{ioBusy=false;return null;});} } case "project_context": return main(this::context); + case "material_search": return main(()->materials.search(str(p,"query",""),str(p,"kind","block"),p.has("limit")?integer(p,"limit"):null,p.has("cursor")?str(p,"cursor",""):null)); + case "material_describe": return main(()->materials.describe(required(p,"id"))); case "region_inspect": return main(()->inspect(p)); case "region_changes": return Map.of("status","resync_required","reason","Prototype uses fresh bounded reads; complete event delta journal is not implemented"); + case "terrain_brush_prepare": { + TerrainBrush.Spec brush=TerrainBrush.parse(p.getAsJsonObject("brush")); + record PreparedBrush(TerrainBrush.Result result,Plan plan) {} + PreparedBrush prepared=main(()->{ + available();Region scan=brush.bounds(); + if(!region.contains(scan.min())||!region.contains(scan.max()))throw new Fault("out_of_bounds","Brush scan including halo exceeds project area"); + Map snapshot=new LinkedHashMap<>(); + for(int y=scan.min().y();y<=scan.max().y();y++)for(int z=scan.min().z();z<=scan.max().z();z++)for(int x=scan.min().x();x<=scan.max().x();x++){ + BlockPos at=new BlockPos(x,y,z);snapshot.put(at,access.getBlock(at)); + } + TerrainBrush.Result result=TerrainBrush.compile(brush,snapshot,maxBlocks); + if(result.desired().isEmpty())return new PreparedBrush(result,null); + Map desired=new LinkedHashMap<>(result.desired());desired.replaceAll((at,state)->access.canonical(state)); + for(BlockPos at:desired.keySet())checkSurroundings(at,desired); + Plan plan=engine.prepare(projectId,epoch,region,desired,result.dependencies());ioBusy=true; + return new PreparedBrush(result,plan); + }); + if(prepared.plan()==null){Map preview=new LinkedHashMap<>(BrushPreview.render(prepared.result()));preview.put("plan_state","empty");preview.put("changed_blocks",0);return preview;} + try{ + engine.persistPlan(prepared.plan().id());Map preview=new LinkedHashMap<>(BrushPreview.render(prepared.result())); + preview.putAll(planSummary(prepared.plan()));preview.put("plan_state","prepared");return preview; + }finally{main(()->{ioBusy=false;return null;});} + } + case "terrain_preview": { + TerrainRecipe terrain=new TerrainRecipe(p.getAsJsonObject("recipe")); + Object preview=TerrainPreview.render(terrain,p.has("resolution")?integer(p,"resolution"):128,maxBlocks); + synchronized(terrains){terrains.put(terrain.id(),terrain);while(terrains.size()>32)terrains.remove(terrains.keySet().iterator().next());} + return preview; + } + case "terrain_prepare": { + String terrainId=required(p,"terrain_id");TerrainRecipe terrain=terrains.get(terrainId); + if(terrain==null)throw new Fault("not_found","Terrain recipe expired or server restarted; call terrain_preview again with the saved recipe"); + int tileIndex=integer(p,"tile_index");TerrainRecipe.Tile tile=terrain.tile(tileIndex,maxBlocks); + Map desired=new LinkedHashMap<>(tile.blocks()); + Plan plan=main(()->{ + available(); + if(!region.contains(tile.bounds().min())||!region.contains(tile.bounds().max()))throw new Fault("out_of_bounds","Terrain tile exceeds current project area"); + desired.replaceAll((at,state)->access.canonical(state)); + boolean changed=false; + for(BlockPos at:desired.keySet()){ + String before=access.getBlock(at);changed|=!before.equals(desired.get(at)); + if(!TerrainRecipe.replaceable(before))throw new Fault("protected_terrain","Tile contains a building or non-terrain block at "+at+"; preserve it explicitly or choose another tile"); + checkSurroundings(at,desired); + } + if(!changed)return null; + Plan value=engine.prepare(projectId,epoch,region,desired,Set.of());ioBusy=true;return value; + }); + if(plan==null)return Map.of("status","empty","terrain_id",terrainId,"tile_index",tileIndex,"tile_bounds",tile.bounds(),"changed_blocks",0,"reason","No changed target blocks (unchanged or preserved)"); + try{engine.persistPlan(plan.id());Map summary=new LinkedHashMap<>(planSummary(plan)); + summary.put("terrain_id",terrainId);summary.put("tile_index",tileIndex);summary.put("tile_bounds",tile.bounds());return summary; + }finally{main(()->{ioBusy=false;return null;});} + } case "build_prepare": { JsonObject recipe=p.getAsJsonObject("recipe"); Map desired=new LinkedHashMap<>(RecipeCompiler.compile(recipe,maxBlocks)); @@ -184,6 +244,7 @@ public final class BuilderPlugin extends JavaPlugin { if(!part.positions().containsAll(desired.keySet()))throw new Fault("out_of_bounds","Patch exceeds the exact part mask; create a new part for an extension"); } for(BlockPos at:desired.keySet())checkSurroundings(at,desired); + if(p.has("expected_blocks"))ExpectedBlocks.check(p.getAsJsonArray("expected_blocks"),desired.keySet(),access::canonical,access::getBlock,at->BuildingWorld.snapshotId(access.captureBlock(at))); Plan value=engine.prepare(projectId,epoch,region,desired,dependencies);ioBusy=true;return value; }); try { engine.persistPlan(plan.id()); return planSummary(plan); } @@ -228,11 +289,12 @@ public final class BuilderPlugin extends JavaPlugin { } private Object context() { return Map.ofEntries(Map.entry("schema_version",1),Map.entry("project_id",projectId),Map.entry("world_id",world.getUID().toString()),Map.entry("world_epoch",epoch),Map.entry("region",region), - Map.entry("max_plan_blocks",maxBlocks),Map.entry("parts",parts.values().stream().limit(64).map(p->Map.of("part_id",p.id(),"name",p.name(),"protected",p.locked(),"block_count",p.positions().size())).toList()), + Map.entry("max_plan_blocks",maxBlocks),Map.entry("checked_expected_blocks",true),Map.entry("parts",parts.values().stream().limit(64).map(p->Map.of("part_id",p.id(),"name",p.name(),"protected",p.locked(),"block_count",p.positions().size())).toList()), Map.entry("parts_total",parts.size()),Map.entry("operations",engine.recentOperations(20).stream().map(v->Map.of("operation_id",v.id(),"plan_id",v.planId(),"status",v.status().name().toLowerCase(Locale.ROOT),"written",v.written(),"total_changes",v.totalChanges())).toList()), Map.entry("operations_total",engine.operationCount()),Map.entry("truncated",parts.size()>64||engine.operationCount()>20), - Map.entry("supported_materials",BuildingWorld.supportedMaterials()),Map.entry("recipe",Map.of("version",1,"operations",List.of("box","line","cylinder","repeat"))),Map.entry("capabilities",List.of("region_inspect","build_prepare","build_apply","operation_status","operation_cancel","operation_undo_prepare","part_define","part_get","camera_list","camera_capture","asset_list","schematic_export","schematic_import_prepare")), - Map.entry("limitations",List.of("One configured owner and project","Loaded chunks only","No automatic recipe merge","Complete delta journal is not implemented","Camera readiness is heuristic","Sponge schematic v2 only; no entities or block entities"))); + Map.entry("terrain",Map.of("version",1,"features",List.of("hill","ridge","plateau","channel","basin","terrace"),"modes",List.of("sculpt","fill","cut"),"max_cached_recipes",32,"fluid_placement",false,"brush_actions",List.of("raise","lower","flatten","smooth"),"brush_max_scan_blocks",4096)),Map.entry("material_catalog",materials.summary()),Map.entry("recipe",Map.of("version",1,"operations",List.of("box","line","cylinder","repeat"))),Map.entry("capabilities",List.of("material_search","material_describe","region_inspect","build_prepare","build_apply","operation_status","operation_cancel","operation_undo_prepare","part_define","part_get","camera_list","camera_capture","asset_list","schematic_export","schematic_import_prepare","terrain_preview","terrain_prepare","terrain_brush_prepare")), + Map.entry("block_data",Map.of("all_registered_block_states",true,"fluid_placement",true,"block_entity_snapshots",true,"raw_nbt_editing",false,"item_inventory_editing",false)), + Map.entry("limitations",List.of("One configured owner and project","Loaded chunks only","No automatic recipe merge","Complete delta journal is not implemented","Camera readiness is heuristic","Schematic v2: entity and block-entity payloads rejected","Later world simulation is not a journaled direct edit"))); } private Object inspect(JsonObject p) { Region area=new Region(world.getUID().toString(),pos(p.getAsJsonObject("min")),pos(p.getAsJsonObject("max"))); @@ -240,8 +302,14 @@ public final class BuilderPlugin extends JavaPlugin { if(area.volume()>4096)throw new Fault("budget_exceeded","Read at most 4096 blocks per request"); Map palette=new TreeMap<>();List blocks=new ArrayList<>(); boolean exact=str(p,"detail","summary").equals("blocks"); + long snapshotBytes=0; for(int y=area.min().y();y<=area.max().y();y++)for(int z=area.min().z();z<=area.max().z();z++)for(int x=area.min().x();x<=area.max().x();x++){ - BlockPos at=new BlockPos(x,y,z);String state=access.getBlock(at);palette.merge(state,1,Integer::sum);if(exact)blocks.add(Map.of("pos",at,"state",state)); + BlockPos at=new BlockPos(x,y,z);String state=access.getBlock(at);palette.merge(state,1,Integer::sum); + if(exact){ + String captured=access.captureBlock(at);snapshotBytes+=captured.getBytes(StandardCharsets.UTF_8).length; + if(snapshotBytes>8_388_608)throw new Fault("budget_exceeded","Block-entity snapshots exceed 8 MiB; inspect a smaller area"); + Map value=new LinkedHashMap<>(BuildingWorld.publicSnapshot(captured));value.put("pos",at);blocks.add(value); + } } return Map.of("region",area,"palette",palette,"blocks",blocks,"sampled_at",System.currentTimeMillis(),"world_epoch",epoch,"truncated",false); } @@ -249,7 +317,22 @@ public final class BuilderPlugin extends JavaPlugin { return Map.of("plan_id",plan.id(),"plan_hash",hash(plan),"changed_blocks",plan.changes().stream().filter(c->!c.expected().equals(c.desired())).count(),"region",plan.region(),"expires_at",plan.expiresAtMillis()); } private Map status(OperationView v) { - return Map.ofEntries(Map.entry("operation_id",v.id()),Map.entry("plan_id",v.planId()),Map.entry("status",v.status().name().toLowerCase(Locale.ROOT)),Map.entry("written",v.written()),Map.entry("total_changes",v.totalChanges()),Map.entry("processed",v.processed()),Map.entry("conflicts",v.conflicts()),Map.entry("message",Objects.toString(v.message(),""))); + return Map.ofEntries(Map.entry("operation_id",v.id()),Map.entry("plan_id",v.planId()),Map.entry("status",v.status().name().toLowerCase(Locale.ROOT)),Map.entry("written",v.written()),Map.entry("total_changes",v.totalChanges()),Map.entry("processed",v.processed()),Map.entry("conflicts",v.conflicts().stream().map(BuilderPlugin::publicConflict).toList()),Map.entry("message",Objects.toString(v.message(),""))); + } + private static Map publicConflict(Conflict conflict) { + Map result=new LinkedHashMap<>();result.put("pos",conflict.pos());result.put("reason",conflict.reason()); + for(var entry:Map.of("expected",conflict.expected(),"current",conflict.current(),"desired",conflict.desired()).entrySet()){ + var snapshot=BuildingWorld.publicSnapshot(entry.getValue());result.put(entry.getKey(),snapshot.get("state")); + if(snapshot.containsKey("snapshot_id"))result.put(entry.getKey()+"_snapshot_id",snapshot.get("snapshot_id")); + } + return result; + } + private static String rotateSchematicState(String state,int degrees)throws IOException { + try{ + var data=Bukkit.createBlockData(state); + data.rotate(switch(degrees){case 0->org.bukkit.block.structure.StructureRotation.NONE;case 90->org.bukkit.block.structure.StructureRotation.CLOCKWISE_90;case 180->org.bukkit.block.structure.StructureRotation.CLOCKWISE_180;case 270->org.bukkit.block.structure.StructureRotation.COUNTERCLOCKWISE_90;default->throw new IllegalArgumentException("Unsupported rotation");}); + return data.getAsString(); + }catch(IllegalArgumentException failure){throw new IOException("Invalid runtime schematic block state",failure);} } private String hash(Plan p) { try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.toJson(p).getBytes(StandardCharsets.UTF_8)));}catch(NoSuchAlgorithmException e){throw new AssertionError(e);} 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 3008605..f3c6da6 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 @@ -2,38 +2,38 @@ package io.github.minecraftbuilder.paper; import io.github.minecraftbuilder.core.*; import org.bukkit.*; +import org.bukkit.block.BlockState; import org.bukkit.block.data.BlockData; import java.util.*; -/** Small tested policy; unsupported existing contents are protected too. */ +/** Live registry block data with private, lossless block-entity snapshots for guarded edits and undo. */ final class BuildingWorld implements WorldAccess, BlockPolicy { private final World world; - private final Map parsed = new HashMap<>(); - private static final Set MATERIALS = Set.of("air", "stone", "cobblestone", "mossy_cobblestone", - "stone_bricks", "mossy_stone_bricks", "cracked_stone_bricks", "chiseled_stone_bricks", "smooth_stone", - "granite", "polished_granite", "diorite", "polished_diorite", "andesite", "polished_andesite", - "deepslate", "cobbled_deepslate", "polished_deepslate", "deepslate_bricks", "deepslate_tiles", - "bricks", "quartz_block", "quartz_pillar", "smooth_quartz", "sandstone", "cut_sandstone", "smooth_sandstone", - "red_sandstone", "terracotta", "white_terracotta", "black_terracotta", "orange_terracotta", - "white_concrete", "gray_concrete", "black_concrete", "glass", "tinted_glass", "obsidian", - "dirt", "grass_block", "bedrock", "oak_planks", "spruce_planks", "birch_planks", "dark_oak_planks", - "oak_log", "spruce_log", "birch_log", "dark_oak_log", "stripped_oak_log", "stripped_spruce_log", - "stone_brick_stairs", "cobblestone_stairs", "oak_stairs", "spruce_stairs", "deepslate_tile_stairs", - "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"); + private final PaperBlockStateCodec snapshots = new PaperBlockStateCodec(); + private final Map parsed = new LinkedHashMap<>(128, 0.75f, true) { + @Override protected boolean removeEldestEntry(Map.Entry entry) { return size() > 4096; } + }; + BuildingWorld(World world) { this.world = world; } - static List supportedMaterials() { return MATERIALS.stream().sorted().map(s->"minecraft:"+s).toList(); } + static List supportedMaterials() { return Registry.BLOCK.stream().map(type -> type.getKey().toString()).sorted().toList(); } BlockData data(String state) { return parsed.computeIfAbsent(state, Bukkit::createBlockData).clone(); } - String canonical(String state) { if (!supports(state)) throw new RpcServer.Fault("unsupported_block", "Unsupported block: " + state); return data(state).getAsString(); } - public boolean supports(String state) { - 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.type.Leaves leaves && !leaves.isPersistent()); - } catch (IllegalArgumentException e) { return false; } + String canonical(String state) { + // Opaque journal payloads are internal: model-supplied data is always parsed as BlockData. + try { return data(state).getAsString(); } + catch (IllegalArgumentException e) { throw new RpcServer.Fault("unsupported_block", "Invalid registered block or block properties"); } } + public boolean supports(String state) { + try { return supportsData(data(BlockSnapshots.state(state))); } + catch (IllegalArgumentException | NullPointerException e) { return false; } + } + static boolean supportsData(BlockData data) { + // A parsed BlockData already represents a registered block, unlike item-only Materials. + return data != null && data.getMaterial() != null && !data.getMaterial().isLegacy(); + } + static Map publicSnapshot(String value) { return BlockSnapshots.publicView(value); } + static String snapshotId(String value) { return BlockSnapshots.snapshotId(value); } + static String canonicalSnapshotState(String value) { return BlockSnapshots.state(value); } + private void ready(BlockPos p) { if (!Bukkit.isPrimaryThread()) throw new IllegalStateException("World access outside server thread"); if (p.y() < world.getMinHeight() || p.y() >= world.getMaxHeight()) throw new RpcServer.Fault("out_of_bounds", "Position exceeds world height"); @@ -41,15 +41,26 @@ final class BuildingWorld implements WorldAccess, BlockPolicy { if (!world.getWorldBorder().isInside(new Location(world,p.x()+0.5,p.y(),p.z()+0.5))) throw new RpcServer.Fault("out_of_bounds", "Position exceeds world border"); } public String getBlock(BlockPos p) { ready(p); return world.getBlockAt(p.x(),p.y(),p.z()).getBlockData().getAsString(); } - public void setBlock(BlockPos p, String state) { + public String captureBlock(BlockPos p) { ready(p); - // A neighbour may have changed since prepare. Never remove supports next to - // dynamic/unsupported blocks merely because the target itself still matches. - for (BlockPos d : List.of(new BlockPos(1,0,0),new BlockPos(-1,0,0),new BlockPos(0,1,0),new BlockPos(0,-1,0),new BlockPos(0,0,1),new BlockPos(0,0,-1))) { - BlockPos n=p.add(d); - if(n.y()>=world.getMinHeight()&&n.y() desired, Function canonical, + Function read) { + check(entries, desired, canonical, read, ignored -> null); + } + + static void check(JsonArray entries, Set desired, Function canonical, + Function read, Function snapshotId) { + if (entries.size() != desired.size() || entries.size() > 4096) + throw new RpcServer.Fault("invalid_request", "expected_blocks must cover every desired position exactly once"); + Map expected = new LinkedHashMap<>(); + Map snapshots = new HashMap<>(); + for (JsonElement entry : entries) { + JsonObject value = entry.getAsJsonObject(); + JsonObject p = value.getAsJsonObject("pos"); + BlockPos at = new BlockPos(integer(p,"x"), integer(p,"y"), integer(p,"z")); + if (!desired.contains(at) || expected.containsKey(at)) + throw new RpcServer.Fault("invalid_request", "Unexpected or duplicate expected_blocks position"); + expected.put(at, canonical.apply(value.get("state").getAsString())); + if (value.has("snapshot_id")) { + JsonElement id=value.get("snapshot_id"); + if(!id.isJsonPrimitive() || !id.getAsJsonPrimitive().isString() || !id.getAsString().matches("[a-f0-9]{64}")) + throw new RpcServer.Fault("invalid_request", "snapshot_id must be a SHA-256 digest"); + snapshots.put(at,id.getAsString()); + } + } + for (var entry : expected.entrySet()) { + if (!entry.getValue().equals(read.apply(entry.getKey()))) + throw new RpcServer.Fault("stale_snapshot", "Caller snapshot changed at " + entry.getKey()); + String currentId=snapshotId.apply(entry.getKey()), expectedId=snapshots.get(entry.getKey()); + if(currentId!=null && expectedId==null) + throw new RpcServer.Fault("invalid_request", "expected_blocks needs snapshot_id for block-entity data at " + entry.getKey() + "; inspect this block again"); + if(expectedId!=null && !expectedId.equals(currentId)) + throw new RpcServer.Fault("stale_snapshot", "Caller block-entity snapshot changed at " + entry.getKey()); + } + } + + private static int integer(JsonObject value, String name) { + try { return value.get(name).getAsBigDecimal().intValueExact(); } + catch (ArithmeticException | NumberFormatException ex) { + throw new RpcServer.Fault("invalid_request", "Expected position coordinates must be integers"); + } + } +} diff --git a/paper-plugin/src/main/java/io/github/minecraftbuilder/paper/MaterialCatalog.java b/paper-plugin/src/main/java/io/github/minecraftbuilder/paper/MaterialCatalog.java new file mode 100644 index 0000000..01137ad --- /dev/null +++ b/paper-plugin/src/main/java/io/github/minecraftbuilder/paper/MaterialCatalog.java @@ -0,0 +1,202 @@ +package io.github.minecraftbuilder.paper; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.block.TileState; +import org.bukkit.block.data.*; +import org.bukkit.block.data.type.Bed; +import org.bukkit.block.data.type.Door; +import org.bukkit.block.data.type.Leaves; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.*; +import java.util.function.Function; + +/** Runtime registry discovery. Full registries and state permutations never enter an RPC response. */ +final class MaterialCatalog { + static final int DEFAULT_LIMIT = 16, MAX_LIMIT = 32; + private static final int MAX_QUERY = 96, MAX_ID = 128, MAX_CURSOR = 100; + private static final int MAX_PROPERTIES = 64, MAX_PROPERTY_VALUES = 256, MAX_DESCRIPTION_CHARS = 16_384; + private final List entries; + private final Map byId; + private final Function describeBlock; + private final String version; + + record Entry(String id, boolean block, boolean item) { + Map compact() { return Map.of("id", id, "block", block, "item", item); } + } + record StateDescription(String defaultState, Map> properties, List behavior) {} + + /** Construct on the server thread after Bukkit registries have loaded. */ + MaterialCatalog() { + this(Bukkit.getMinecraftVersion() + "/" + Bukkit.getBukkitVersion(), runtimeEntries(), MaterialCatalog::runtimeDescription); + } + + MaterialCatalog(String runtimeVersion, Collection source, Function describeBlock) { + this.entries = source.stream().sorted(Comparator.comparing(Entry::id)).toList(); + Map index = new HashMap<>(); + StringBuilder fingerprint = new StringBuilder("material-catalog-v1\n").append(runtimeVersion).append('\n'); + for (Entry entry : entries) { + if (index.put(entry.id(), entry) != null) throw new IllegalArgumentException("Duplicate material ID: " + entry.id()); + fingerprint.append(entry.id()).append(':').append(entry.block()).append(':').append(entry.item()).append('\n'); + } + this.byId = Map.copyOf(index); + this.describeBlock = Objects.requireNonNull(describeBlock); + this.version = digest(fingerprint.toString()); + } + + Map summary() { + return Map.of("version", version, "materials", entries.size(), + "blocks", entries.stream().filter(Entry::block).count(), + "items", entries.stream().filter(Entry::item).count(), + "search_default_limit", DEFAULT_LIMIT, "search_max_limit", MAX_LIMIT); + } + + Map search(String query, String kind, Integer requestedLimit, String cursor) { + query = normalizeQuery(query); + kind = kind == null ? "block" : kind; + if (!Set.of("block", "item", "all").contains(kind)) + throw fault("invalid_material_query", "kind must be block, item, or all"); + int limit = requestedLimit == null ? DEFAULT_LIMIT : requestedLimit; + if (limit < 1 || limit > MAX_LIMIT) + throw fault("invalid_material_query", "limit must be between 1 and " + MAX_LIMIT); + String scope = digest(kind + "\n" + query); + int offset = decodeCursor(cursor, scope); + String[] tokens = query.isEmpty() ? new String[0] : query.split(" "); + List matching = new ArrayList<>(); + for (Entry entry : entries) { + if (kind.equals("block") && !entry.block() || kind.equals("item") && !entry.item()) continue; + if (Arrays.stream(tokens).allMatch(token -> entry.id().contains(token))) matching.add(entry); + } + if (offset > matching.size()) throw fault("invalid_cursor", "Cursor exceeds the matching catalog"); + int end = Math.min(matching.size(), offset + limit); + Map result = new LinkedHashMap<>(); + result.put("catalog_version", version); + result.put("query", query); + result.put("kind", kind); + result.put("total", matching.size()); + result.put("results", matching.subList(offset, end).stream().map(Entry::compact).toList()); + if (end < matching.size()) result.put("next_cursor", version + ":" + scope + ":" + end); + return result; + } + + Map describe(String id) { + id = normalizeId(id); + Entry entry = byId.get(id); + if (entry == null) throw fault("invalid_material", "Unknown runtime material: " + id); + Map result = new LinkedHashMap<>(entry.compact()); + result.put("catalog_version", version); + result.put("placeable", entry.block()); + if (entry.block()) { + StateDescription state = describeBlock.apply(id); + if (state.defaultState().length() > 1024) + throw fault("material_properties_unavailable", "Runtime default block state exceeds the protocol limit"); + result.put("default_state", state.defaultState()); + result.put("properties", state.properties()); + if (!state.behavior().isEmpty()) result.put("behavior", state.behavior()); + } + return result; + } + + private int decodeCursor(String cursor, String scope) { + if (cursor == null || cursor.isEmpty()) return 0; + if (cursor.length() > MAX_CURSOR || !cursor.matches("[0-9a-f]{24}:[0-9a-f]{24}:[0-9]{1,9}")) + throw fault("invalid_cursor", "Malformed material catalog cursor"); + String[] parts = cursor.split(":"); + if (!parts[0].equals(version)) throw fault("stale_cursor", "Material catalog changed; restart the search"); + if (!parts[1].equals(scope)) throw fault("invalid_cursor", "Cursor belongs to another query or kind"); + return Integer.parseInt(parts[2]); + } + + private static String normalizeQuery(String query) { + if (query == null) return ""; + if (query.length() > MAX_QUERY || query.chars().anyMatch(Character::isISOControl)) + throw fault("invalid_material_query", "query must contain at most 96 printable characters"); + return query.strip().toLowerCase(Locale.ROOT).replaceAll("\\s+", " "); + } + + private static String normalizeId(String id) { + if (id == null || id.length() > MAX_ID || !id.matches("(?:minecraft:)?[a-z0-9_./-]+")) + throw fault("invalid_material", "Expected one exact Minecraft material ID without properties or NBT"); + return id.contains(":") ? id : "minecraft:" + id; + } + + private static List runtimeEntries() { + List entries = new ArrayList<>(); + for (Material material : Material.values()) { + if (material.isLegacy()) continue; + boolean block = material.isBlock(), item = material.isItem(); + if (!block && !item) continue; + if (block) { + // A registry mismatch must fail visibly, never silently shrink the advertised catalog. + try { Bukkit.createBlockData(material); } + catch (RuntimeException error) { + throw new IllegalStateException("Cannot read runtime block registry entry " + material.getKey(), error); + } + } + entries.add(new Entry(material.getKey().toString(), block, item)); + } + return entries; + } + + private static StateDescription runtimeDescription(String id) { + BlockData data = Bukkit.createBlockData(id); + try { + // Paper's public API exposes only createBlockDataStates(), which materializes all + // combinations. Read the existing StateHolder's property domains instead. These + // public mapped methods are verified against the pinned Paper 26.2 runtime. + Object state = data.getClass().getMethod("getState").invoke(data); + List behavior = new ArrayList<>(); + if (data.getMaterial().hasGravity()) behavior.add("gravity"); + if (data.getMaterial() == Material.WATER || data.getMaterial() == Material.LAVA) behavior.add("fluid"); + if (data instanceof Waterlogged) behavior.add("waterloggable"); + if (data instanceof Door || data instanceof Bed) behavior.add("multi_block"); + if (data instanceof Bisected) behavior.add("half_property"); + if (data instanceof FaceAttachable || data instanceof Attachable) behavior.add("attachment_sensitive"); + if (data instanceof Leaves) behavior.add("leaf_decay"); + if (data.createBlockState() instanceof TileState) behavior.add("block_entity"); + return new StateDescription(data.getAsString(), readProperties(state), List.copyOf(behavior)); + } catch (ReflectiveOperationException | LinkageError error) { + throw fault("material_properties_unavailable", "Runtime property introspection is unavailable for " + id); + } + } + + /** Domain-only adapter, kept separate so tests can cover all value types without a running server. */ + static Map> readProperties(Object state) throws ReflectiveOperationException { + Object value = state.getClass().getMethod("getProperties").invoke(state); + if (!(value instanceof Collection properties) || properties.size() > MAX_PROPERTIES) + throw fault("material_properties_unavailable", "Runtime property count exceeds the response limit"); + Map> result = new TreeMap<>(); + int characters = 0; + for (Object property : properties) { + String name = (String) property.getClass().getMethod("getName").invoke(property); + Object possible = property.getClass().getMethod("getPossibleValues").invoke(property); + if (!(possible instanceof Collection values) || values.isEmpty() || values.size() > MAX_PROPERTY_VALUES) + throw fault("material_properties_unavailable", "Runtime property domain exceeds the response limit"); + var serializeValue = property.getClass().getMethod("getName", Comparable.class); + List serialized = new ArrayList<>(); + characters += name.length(); + for (Object candidate : values) { + String text = (String) serializeValue.invoke(property, candidate); + characters += text.length(); + if (characters > MAX_DESCRIPTION_CHARS) + throw fault("material_properties_unavailable", "Runtime property description exceeds the response limit"); + serialized.add(text); + } + if (result.put(name, List.copyOf(serialized)) != null) + throw fault("material_properties_unavailable", "Runtime returned duplicate property names"); + } + return Collections.unmodifiableMap(result); + } + + private static String digest(String input) { + try { + byte[] bytes = MessageDigest.getInstance("SHA-256").digest(input.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(bytes, 0, 12); + } catch (NoSuchAlgorithmException impossible) { throw new IllegalStateException(impossible); } + } + + private static RpcServer.Fault fault(String code, String message) { return new RpcServer.Fault(code, message); } +} diff --git a/paper-plugin/src/main/java/io/github/minecraftbuilder/paper/PaperBlockStateCodec.java b/paper-plugin/src/main/java/io/github/minecraftbuilder/paper/PaperBlockStateCodec.java new file mode 100644 index 0000000..4e366bc --- /dev/null +++ b/paper-plugin/src/main/java/io/github/minecraftbuilder/paper/PaperBlockStateCodec.java @@ -0,0 +1,103 @@ +package io.github.minecraftbuilder.paper; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import org.bukkit.block.BlockState; +import org.bukkit.block.TileState; +import org.bukkit.block.data.BlockData; +import org.bukkit.block.data.type.StructureBlock; + +/** + * Narrow, version-checked Paper 26.2 adapter for data the public BlockData API does not serialize. + * Full snapshot NBT preserves inventories, signs, decorations, entity data and plugin PDC. It never + * accepts NBT from RPC callers. Unavailable internals fail closed before editing instead of silently + * degrading undo to block data. Reflection keeps the plugin API-only build. + */ +final class PaperBlockStateCodec { + private final Class tileClass; + private final Method saveNbt, getBlockEntity, getRegistryAccess, loadData, parseCompound, remove, putString, place; + private final Method getWorldHandle, getPosition, getLiveBlockEntity, setLiveBlockEntity; + private final int placeFlags; + PaperBlockStateCodec() { + try { + tileClass = Class.forName("org.bukkit.craftbukkit.block.CraftBlockEntityState"); + Class compound = Class.forName("net.minecraft.nbt.CompoundTag"); + Class parser = Class.forName("net.minecraft.nbt.TagParser"); + Class block = Class.forName("net.minecraft.world.level.block.Block"); + Class craftState = Class.forName("org.bukkit.craftbukkit.block.CraftBlockState"); + Class entity = Class.forName("net.minecraft.world.level.block.entity.BlockEntity"); + Class position = Class.forName("net.minecraft.core.BlockPos"); + Class level = Class.forName("net.minecraft.world.level.Level"); + saveNbt = entity.getMethod("saveWithFullMetadata", Class.forName("net.minecraft.core.HolderLookup$Provider")); + getBlockEntity = tileClass.getMethod("getBlockEntity"); + getRegistryAccess = tileClass.getMethod("getRegistryAccess"); + loadData = tileClass.getMethod("loadData", compound); + parseCompound = parser.getMethod("parseCompoundFully", String.class); + remove = compound.getMethod("remove", String.class); + putString = compound.getMethod("putString", String.class, String.class); + place = craftState.getMethod("place", int.class); + getWorldHandle = craftState.getMethod("getWorldHandle"); + getPosition = craftState.getMethod("getPosition"); + getLiveBlockEntity = level.getMethod("getBlockEntity", position); + setLiveBlockEntity = level.getMethod("setBlockEntity", entity); + // setBlockData(false) omits SKIP_BLOCK_ENTITY_SIDEEFFECTS and can spill containers. + placeFlags = block.getField("UPDATE_CLIENTS").getInt(null) + | block.getField("UPDATE_SKIP_ALL_SIDEEFFECTS").getInt(null); + if (placeFlags != 818) throw new ReflectiveOperationException("Unexpected Paper placement flags"); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("This Paper version does not provide the required lossless block snapshot API", e); + } + } + String capture(BlockState state) { + String data = state.getBlockData().getAsString(); + if (!(state instanceof TileState)) return data; + if (!tileClass.isInstance(state)) throw new IllegalStateException("Unknown block-entity snapshot implementation"); + // The captured BlockState is freshly read or an unplaced default; its underlying entity + // already contains every value. Craft.getSnapshotNBT calls applyTo during a read and some + // tile kinds (Structure) can mutate the world or dereference an unplaced snapshot's level. + Object tag = call(saveNbt, call(getBlockEntity, state), call(getRegistryAccess, state)); + // Coordinates live in Change; all entity data stays. Unplaced defaults compare with placed data. + call(remove, tag, "x"); call(remove, tag, "y"); call(remove, tag, "z"); + // StringTagVisitor sorts compound keys recursively in pinned Paper 26.2. + return BlockSnapshots.encode(data, tag.toString()); + } + void restoreData(BlockState state, String nbt) { + if (!(state instanceof TileState) || !tileClass.isInstance(state)) + throw new IllegalArgumentException("Stored block entity does not match its block type"); + call(loadData, state, call(parseCompound, null, nbt)); + } + String prepareData(String nbt, BlockData before, BlockData after) { + // Structure mode exists in both block data and entity NBT. Loading old NBT otherwise + // rewrites the new mode back to its previous value. Preserve every unrelated field. + if (before instanceof StructureBlock oldStructure && after instanceof StructureBlock newStructure + && oldStructure.getMode() != newStructure.getMode()) { + Object tag = call(parseCompound, null, nbt); + call(putString, tag, "mode", newStructure.getMode().name()); + return tag.toString(); + } + return nbt; + } + void place(BlockState state) { + if (!state.isPlaced()) throw new IllegalArgumentException("Snapshot restore requires a world location"); + // A same-state call may return false although the TileState override copies changed NBT. + // EditEngine verifies the complete captured value immediately after every write. + call(place, state, placeFlags); + if (state instanceof TileState) { + Object level = call(getWorldHandle, state); + Object position = call(getPosition, state); + if (call(getLiveBlockEntity, level, position) == null) { + // MOVING_PISTON permits a block without an automatically created entity. The + // copied state owns a detached entity at the target position with restored NBT. + call(setLiveBlockEntity, level, call(getBlockEntity, state)); + call(place, state, placeFlags); // Apply/mark changed and send the entity update. + } + } + } + private static Object call(Method method, Object receiver, Object... args) { + try { return method.invoke(receiver, args); } + catch (IllegalAccessException | InvocationTargetException e) { + // Never echo arbitrary sign, book, command or inventory contents from parser exceptions. + throw new IllegalStateException("Paper block snapshot operation failed: " + method.getName()); + } + } +} diff --git a/paper-plugin/src/main/java/io/github/minecraftbuilder/paper/SchematicAssets.java b/paper-plugin/src/main/java/io/github/minecraftbuilder/paper/SchematicAssets.java index ff638eb..fe507b3 100644 --- a/paper-plugin/src/main/java/io/github/minecraftbuilder/paper/SchematicAssets.java +++ b/paper-plugin/src/main/java/io/github/minecraftbuilder/paper/SchematicAssets.java @@ -23,19 +23,11 @@ public final class SchematicAssets { private static final int MAX_COMPRESSED = 1_048_576, MAX_NBT = 4_194_304; private static final Set ROOT_FIELDS = Set.of("Version", "DataVersion", "Width", "Height", "Length", "Offset", "PaletteMax", "Palette", "BlockData", "BlockEntities", "Entities", "Metadata"); - // Deliberately mirrors the prototype's small server policy. The server validates states again on import. - private static final Set MATERIALS = Set.of("air", "stone", "cobblestone", "mossy_cobblestone", - "stone_bricks", "mossy_stone_bricks", "cracked_stone_bricks", "chiseled_stone_bricks", "smooth_stone", - "granite", "polished_granite", "diorite", "polished_diorite", "andesite", "polished_andesite", - "deepslate", "cobbled_deepslate", "polished_deepslate", "deepslate_bricks", "deepslate_tiles", - "bricks", "quartz_block", "quartz_pillar", "smooth_quartz", "sandstone", "cut_sandstone", "smooth_sandstone", - "red_sandstone", "terracotta", "white_terracotta", "black_terracotta", "orange_terracotta", - "white_concrete", "gray_concrete", "black_concrete", "glass", "tinted_glass", "obsidian", "dirt", - "grass_block", "bedrock", "oak_planks", "spruce_planks", "birch_planks", "dark_oak_planks", - "oak_log", "spruce_log", "birch_log", "dark_oak_log", "stripped_oak_log", "stripped_spruce_log", - "stone_brick_stairs", "cobblestone_stairs", "oak_stairs", "spruce_stairs", "deepslate_tile_stairs", - "stone_brick_slab", "cobblestone_slab", "oak_slab", "spruce_slab", "smooth_stone_slab"); private final Path root; + private final StateTransformer states; + + @FunctionalInterface + public interface StateTransformer { String transform(String state, int degrees) throws IOException; } public record Asset(String assetId, String name, int width, int height, int length, int blockCount, int dataVersion, BlockPos offset, String sha256, long bytes) { } @@ -44,7 +36,11 @@ public final class SchematicAssets { private record Tag(int type, Object value) { } private record TagList(int elementType, List values) { } - public SchematicAssets(Path root) throws IOException { + public SchematicAssets(Path root) throws IOException { this(root, SchematicAssets::rotateState); } + + public SchematicAssets(Path root, StateTransformer states) throws IOException { + Objects.requireNonNull(states); + this.states = (state,degrees) -> states.transform(rotateState(state,0),degrees); this.root = root.toAbsolutePath().normalize(); rejectSymlinkParents(); Files.createDirectories(this.root); @@ -75,7 +71,7 @@ public final class SchematicAssets { LinkedHashMap palette = new LinkedHashMap<>(); ByteArrayOutputStream data = new ByteArrayOutputStream(); for (int y = 0; y < height; y++) for (int z = 0; z < length; z++) for (int x = 0; x < width; x++) { - String state = rotateState(blocks.get(new BlockPos(minX + x, minY + y, minZ + z)), 0); + String state = states.transform(blocks.get(new BlockPos(minX + x, minY + y, minZ + z)), 0); int index = palette.computeIfAbsent(state, ignored -> palette.size()); writeVarInt(data, index); } @@ -128,7 +124,7 @@ public final class SchematicAssets { int dx = Math.addExact(x, value.offset.x()), dy = Math.addExact(y, value.offset.y()), dz = Math.addExact(z, value.offset.z()); for (int turn = 0; turn < rotation90 / 90; turn++) { int oldX = dx; dx = Math.negateExact(dz); dz = oldX; } BlockPos at = target.add(new BlockPos(dx, dy, dz)); - result.put(at, rotateState(value.blocks.get(index++), rotation90)); + result.put(at, states.transform(value.blocks.get(index++), rotation90)); } } catch (ArithmeticException e) { throw new IOException("Placement overflows integer coordinates", e); } return Collections.unmodifiableMap(result); @@ -188,42 +184,30 @@ public final class SchematicAssets { throw new IOException("Asset name must contain 1..64 printable characters"); } - /** Only recognised schemas rotate: no guessing about unknown direction-like properties. */ + /** Offline syntax codec; runtime integration injects registry validation and native rotation. */ static String rotateState(String state, int degrees) throws IOException { - if (state == null || state.length() > 512 || !state.matches("minecraft:[a-z0-9_]+(?:\\[[a-z0-9_=,]+\\])?")) + if (state == null || state.length() > 1024 || !state.matches("minecraft:[a-z0-9_]+(?:\\[[a-z0-9_=,]+\\])?")) throw new IOException("Invalid vanilla block state"); int bracket = state.indexOf('['); String id = state.substring(10, bracket < 0 ? state.length() : bracket); - if (!MATERIALS.contains(id)) throw new IOException("Unsupported schematic block: minecraft:" + id); TreeMap properties = new TreeMap<>(); if (bracket >= 0) for (String property : state.substring(bracket + 1, state.length() - 1).split(",")) { String[] pair = property.split("=", -1); if (pair.length != 2 || properties.put(pair[0], pair[1]) != null) throw new IOException("Invalid or duplicate block property"); } - Set allowed = id.endsWith("_stairs") ? Set.of("facing", "half", "shape", "waterlogged") - : id.endsWith("_slab") ? Set.of("type", "waterlogged") - : id.endsWith("_log") || id.equals("quartz_pillar") || id.equals("deepslate") ? Set.of("axis") - : id.equals("grass_block") ? Set.of("snowy") : Set.of(); - if (!allowed.containsAll(properties.keySet())) throw new IOException("Unsupported block properties for minecraft:" + id); - for (var property : properties.entrySet()) { - Set values = switch (property.getKey()) { - case "facing" -> Set.of("north", "east", "south", "west"); - case "axis" -> Set.of("x", "y", "z"); - case "half" -> Set.of("top", "bottom"); - case "shape" -> Set.of("straight", "inner_left", "inner_right", "outer_left", "outer_right"); - case "type" -> Set.of("top", "bottom", "double"); - case "waterlogged" -> Set.of("false"); - case "snowy" -> Set.of("true", "false"); - default -> Set.of(); - }; - if (!values.contains(property.getValue())) throw new IOException("Unsupported block property value"); - } + if (!Set.of(0,90,180,270).contains(degrees)) throw new IOException("Invalid rotation"); + if (degrees == 0) return "minecraft:" + id + (properties.isEmpty() ? "" : "[" + String.join(",", properties.entrySet().stream().map(e -> e.getKey() + "=" + e.getValue()).toList()) + "]"); + Set allowed = Set.of("facing","axis","half","shape","type","waterlogged","snowy"); + if (!allowed.containsAll(properties.keySet())) throw new IOException("Runtime block rotation required for these properties"); + if(properties.containsKey("shape") && !Set.of("straight","inner_left","inner_right","outer_left","outer_right").contains(properties.get("shape"))) + throw new IOException("Runtime block rotation required for this shape"); if (degrees != 0 && id.endsWith("_stairs") && !properties.containsKey("facing")) throw new IOException("Rotation requires explicit stairs facing"); - if (degrees != 0 && allowed.contains("axis") && !properties.containsKey("axis")) + if (degrees != 0 && (id.endsWith("_log") || id.equals("quartz_pillar") || id.equals("deepslate")) && !properties.containsKey("axis")) throw new IOException("Rotation requires explicit block axis"); - if (properties.containsKey("facing")) { + if (properties.containsKey("facing") && !Set.of("up","down").contains(properties.get("facing"))) { List faces = List.of("north", "east", "south", "west"); + if (!faces.contains(properties.get("facing"))) throw new IOException("Invalid facing"); properties.put("facing", faces.get((faces.indexOf(properties.get("facing")) + degrees / 90) % 4)); } if (degrees % 180 != 0 && properties.containsKey("axis") && !properties.get("axis").equals("y")) @@ -260,7 +244,7 @@ public final class SchematicAssets { private static void writeVarInt(OutputStream out, int value) throws IOException { do { int next = value & 127; value >>>= 7; out.write(next | (value != 0 ? 128 : 0)); } while (value != 0); } - private static Decoded decode(byte[] compressed) throws IOException { + private Decoded decode(byte[] compressed) throws IOException { byte[] raw; try (GZIPInputStream gzip = new GZIPInputStream(new ByteArrayInputStream(compressed))) { raw = gzip.readNBytes(MAX_NBT + 1); @@ -299,7 +283,7 @@ public final class SchematicAssets { for (var entry : paletteTags.entrySet()) { if (entry.getValue().type != 3) throw new IOException("Palette indices must be integers"); int id = (Integer) entry.getValue().value; - String state = rotateState(entry.getKey(), 0); + String state = states.transform(entry.getKey(), 0); if (id < 0 || id >= paletteMax || palette.put(id, state) != null) throw new IOException("Invalid or duplicate palette index"); } byte[] blockData = (byte[]) required(tags, "BlockData", 7).value; diff --git a/paper-plugin/src/main/java/io/github/minecraftbuilder/paper/TerrainPreview.java b/paper-plugin/src/main/java/io/github/minecraftbuilder/paper/TerrainPreview.java new file mode 100644 index 0000000..d54c75c --- /dev/null +++ b/paper-plugin/src/main/java/io/github/minecraftbuilder/paper/TerrainPreview.java @@ -0,0 +1,52 @@ +package io.github.minecraftbuilder.paper; + +import io.github.minecraftbuilder.core.*; +import java.awt.image.BufferedImage; +import java.io.*; +import java.util.*; +import javax.imageio.ImageIO; + +/** Diagram of the requested field, never presented as a capture or a live-world comparison. */ +public final class TerrainPreview { + private TerrainPreview() { } + public static Map render(TerrainRecipe recipe,int resolution,int maxBlocks) throws IOException { + if(resolution<32||resolution>256)throw new IllegalArgumentException("Resolution must be 32..256"); + int w=Math.min(resolution,recipe.width()),h=Math.min(resolution,recipe.length()); + int[][] heights=new int[h][w];int low=Integer.MAX_VALUE,high=Integer.MIN_VALUE,clipped=0; + for(int z=0;zrecipe.bounds().max().y())clipped++; + } + BufferedImage image=new BufferedImage(w*2,h*2,BufferedImage.TYPE_INT_RGB); + for(int z=0;z result=new LinkedHashMap<>(render(recipe,256,4096)); + java.nio.file.Files.write(java.nio.file.Path.of(args[1]),Base64.getDecoder().decode((String)result.remove("imageBase64"))); + java.nio.file.Files.writeString(java.nio.file.Path.of(args[2]),new com.google.gson.GsonBuilder().setPrettyPrinting().create().toJson(result)); + } +} diff --git a/paper-plugin/src/test/java/io/github/minecraftbuilder/paper/BlockSnapshotsTest.java b/paper-plugin/src/test/java/io/github/minecraftbuilder/paper/BlockSnapshotsTest.java new file mode 100644 index 0000000..d58a17c --- /dev/null +++ b/paper-plugin/src/test/java/io/github/minecraftbuilder/paper/BlockSnapshotsTest.java @@ -0,0 +1,38 @@ +package io.github.minecraftbuilder.paper; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +final class BlockSnapshotsTest { + @Test void payloadSurvivesExactlyButPublicViewOnlyContainsStateAndDigest() { + String state = "minecraft:chest[facing=north,type=single,waterlogged=false]"; + String nbt = "{Items:[{id:\"minecraft:written_book\",components:{text:\"private secret\"}}],id:\"minecraft:chest\"}"; + String encoded = BlockSnapshots.encode(state, nbt); + assertEquals(state, BlockSnapshots.state(encoded)); + assertEquals(nbt, BlockSnapshots.decode(encoded).nbt()); + var publicValue = BlockSnapshots.publicView(encoded); + assertEquals(2, publicValue.size()); + assertEquals(state, publicValue.get("state")); + assertTrue(publicValue.get("snapshot_id").matches("[0-9a-f]{64}")); + assertFalse(publicValue.toString().contains("private secret")); + assertNotEquals(BlockSnapshots.snapshotId(encoded), BlockSnapshots.snapshotId(BlockSnapshots.encode(state, nbt + " "))); + assertEquals(publicValue, BlockSnapshots.publicView(encoded)); + } + @Test void oldPlainJournalStatesStayUnchanged() { + String plain = "minecraft:stone"; + assertEquals(plain, BlockSnapshots.decode(plain).state()); + assertNull(BlockSnapshots.decode(plain).nbt()); + assertNull(BlockSnapshots.snapshotId(plain)); + assertEquals(java.util.Map.of("state", plain), BlockSnapshots.publicView(plain)); + } + @Test void entityBudgetCountsUtf8BytesAndErrorsNeverIncludePrivateText() { + String huge = "секрет".repeat(BlockSnapshots.MAX_SNAPSHOT_BYTES / 7); + var error = assertThrows(IllegalArgumentException.class, () -> BlockSnapshots.encode("minecraft:chest", huge)); + assertTrue(error.getMessage().contains("snapshot_budget_exceeded")); + assertFalse(error.getMessage().contains("секрет")); + } + @Test void malformedKnownSnapshotAndAmbiguousStateAreRejected() { + assertThrows(IllegalArgumentException.class, () -> BlockSnapshots.decode("\u0000mcb-block-v1:minecraft:chest")); + assertThrows(IllegalArgumentException.class, () -> BlockSnapshots.encode("minecraft:chest\n{}", "{}")); + } +} diff --git a/paper-plugin/src/test/java/io/github/minecraftbuilder/paper/BrushPreviewTest.java b/paper-plugin/src/test/java/io/github/minecraftbuilder/paper/BrushPreviewTest.java new file mode 100644 index 0000000..78407f0 --- /dev/null +++ b/paper-plugin/src/test/java/io/github/minecraftbuilder/paper/BrushPreviewTest.java @@ -0,0 +1,20 @@ +package io.github.minecraftbuilder.paper; + +import io.github.minecraftbuilder.core.*; +import org.junit.jupiter.api.Test; +import java.util.*; +import java.io.ByteArrayInputStream; +import javax.imageio.ImageIO; +import static org.junit.jupiter.api.Assertions.*; + +class BrushPreviewTest { + @Test void nativePreviewShowsActualBeforeAfterWithoutWorldWriteClaim() throws Exception { + var r=new TerrainBrush.Result(Map.of(new BlockPos(0,2,0),"minecraft:stone"),Set.of(new BlockPos(0,1,0)), + new Region("world",new BlockPos(0,0,0),new BlockPos(1,3,1)),new int[][]{{1,1},{1,1}},new int[][]{{2,1},{0,1}},2,1,1); + var result=BrushPreview.render(r);assertEquals("live_snapshot",result.get("source"));assertEquals(false,result.get("world_edited")); + var image=ImageIO.read(new ByteArrayInputStream(Base64.getDecoder().decode((String)result.get("imageBase64")))); + assertEquals(780,image.getWidth());assertEquals(300,image.getHeight()); + assertNotEquals(image.getRGB(10,40),image.getRGB(270,40)); + assertEquals(0x89CC70,image.getRGB(530,40)&0xFFFFFF);assertEquals(0xE5A35B,image.getRGB(530,160)&0xFFFFFF); + } +} diff --git a/paper-plugin/src/test/java/io/github/minecraftbuilder/paper/BuildingWorldPolicyTest.java b/paper-plugin/src/test/java/io/github/minecraftbuilder/paper/BuildingWorldPolicyTest.java new file mode 100644 index 0000000..410ed39 --- /dev/null +++ b/paper-plugin/src/test/java/io/github/minecraftbuilder/paper/BuildingWorldPolicyTest.java @@ -0,0 +1,46 @@ +package io.github.minecraftbuilder.paper; + +import org.bukkit.Material; +import org.bukkit.block.data.BlockData; +import org.bukkit.block.data.Waterlogged; +import org.bukkit.block.data.type.Leaves; +import org.junit.jupiter.api.Test; +import java.lang.reflect.Proxy; +import static org.junit.jupiter.api.Assertions.*; + +/** Parsed BlockData stubs test policy; the live registry integration test validates item rejection. */ +final class BuildingWorldPolicyTest { + private static BlockData state(Material material, Class type, + boolean waterlogged, boolean persistent) { + return (BlockData) Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, + (proxy, method, arguments) -> switch (method.getName()) { + case "getMaterial" -> material; + case "isWaterlogged" -> waterlogged; + case "isPersistent" -> persistent; + default -> throw new UnsupportedOperationException(method.getName()); + }); + } + @Test void leafAndWaterloggedStatesAreNoLongerArtificiallyRestricted() { + for (Material material : new Material[]{Material.SPRUCE_LEAVES, Material.OAK_LEAVES}) + for (boolean wet : new boolean[]{false,true}) + for (boolean persistent : new boolean[]{false,true}) + assertTrue(BuildingWorld.supportsData(state(material, Leaves.class, wet, persistent))); + for (Material material : new Material[]{Material.SPRUCE_FENCE, Material.CUT_SANDSTONE_SLAB, + Material.BARRIER, Material.CHEST}) + assertTrue(BuildingWorld.supportsData(state(material, Waterlogged.class, true, true))); + } + @Test void fluidsGrowthAdministrativeAndBlockEntityTypesAreAccepted() { + for (Material material : new Material[]{Material.WATER, Material.LAVA, Material.OAK_SAPLING, + Material.PEONY, Material.CUT_COPPER, Material.STRUCTURE_VOID, Material.LIGHT, + Material.STRUCTURE_BLOCK, Material.JIGSAW, Material.COMMAND_BLOCK, + Material.CHAIN_COMMAND_BLOCK, Material.REPEATING_COMMAND_BLOCK, Material.TNT, + Material.CHEST, Material.OAK_SIGN, Material.DECORATED_POT, Material.BEEHIVE, + Material.TRIAL_SPAWNER, Material.VAULT}) + assertTrue(BuildingWorld.supportsData(state(material, BlockData.class, false, true)), material.name()); + } + @Test void missingAndLegacyDataDoNotBypassTheParserPolicy() { + assertFalse(BuildingWorld.supportsData(null)); + assertFalse(BuildingWorld.supportsData(state(null, BlockData.class, false, true))); + assertFalse(BuildingWorld.supportsData(state(Material.LEGACY_STONE, BlockData.class, false, true))); + } +} diff --git a/paper-plugin/src/test/java/io/github/minecraftbuilder/paper/ExpectedBlocksTest.java b/paper-plugin/src/test/java/io/github/minecraftbuilder/paper/ExpectedBlocksTest.java new file mode 100644 index 0000000..5de6794 --- /dev/null +++ b/paper-plugin/src/test/java/io/github/minecraftbuilder/paper/ExpectedBlocksTest.java @@ -0,0 +1,54 @@ +package io.github.minecraftbuilder.paper; + +import com.google.gson.*; +import io.github.minecraftbuilder.core.BlockPos; +import org.junit.jupiter.api.Test; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class ExpectedBlocksTest { + private final BlockPos a = new BlockPos(0,64,0), b = new BlockPos(1,64,0); + private JsonArray entries(String... states) { + JsonArray result=new JsonArray(); + for(int x=0;x(Map.of(a,"minecraft:grass_block",b,"minecraft:air")); + ExpectedBlocks.check(snapshot,Set.of(a,b),s->s,live::get); + live.put(b,"minecraft:gold_block"); + var error=assertThrows(RpcServer.Fault.class,()->ExpectedBlocks.check(snapshot,Set.of(a,b),s->s,live::get)); + assertTrue(error.getMessage().contains("Caller snapshot changed")); + assertEquals("minecraft:gold_block",live.get(b)); + } + @Test void rejectsIncompleteDuplicateAndOutsideSnapshots() { + assertThrows(RpcServer.Fault.class,()->ExpectedBlocks.check(entries("air"),Set.of(a,b),s->s,p->"air")); + var duplicate=entries("air","air");duplicate.set(1,duplicate.get(0)); + assertThrows(RpcServer.Fault.class,()->ExpectedBlocks.check(duplicate,Set.of(a,b),s->s,p->"air")); + assertThrows(RpcServer.Fault.class,()->ExpectedBlocks.check(entries("air","air"),Set.of(a,new BlockPos(2,64,0)),s->s,p->"air")); + } + @Test void canonicalizesExpectedStatesAndRejectsFractionalCoordinates() { + ExpectedBlocks.check(entries("grass_block"),Set.of(a),s->"minecraft:"+s,p->"minecraft:grass_block"); + var fractional=entries("air");fractional.get(0).getAsJsonObject().getAsJsonObject("pos").addProperty("x",.5); + assertThrows(RpcServer.Fault.class,()->ExpectedBlocks.check(fractional,Set.of(a),s->s,p->"air")); + } + @Test void detectsContentsChangeEvenWhenBlockStateIsUnchanged() { + var snapshot=entries("minecraft:chest"); + snapshot.get(0).getAsJsonObject().addProperty("snapshot_id","a".repeat(64)); + ExpectedBlocks.check(snapshot,Set.of(a),s->s,p->"minecraft:chest",p->"a".repeat(64)); + var changed=assertThrows(RpcServer.Fault.class,()->ExpectedBlocks.check(snapshot,Set.of(a),s->s,p->"minecraft:chest",p->"b".repeat(64))); + assertTrue(changed.getMessage().contains("block-entity snapshot changed")); + assertThrows(RpcServer.Fault.class,()->ExpectedBlocks.check(snapshot,Set.of(a),s->s,p->"minecraft:chest",p->null)); + } + @Test void requiresEntityDigestAndRejectsMalformedDigest() { + var snapshot=entries("minecraft:chest"); + assertThrows(RpcServer.Fault.class,()->ExpectedBlocks.check(snapshot,Set.of(a),s->s,p->"minecraft:chest",p->"a".repeat(64))); + snapshot.get(0).getAsJsonObject().addProperty("snapshot_id","not-a-hash"); + assertThrows(RpcServer.Fault.class,()->ExpectedBlocks.check(snapshot,Set.of(a),s->s,p->"minecraft:chest",p->"a".repeat(64))); + } +} diff --git a/paper-plugin/src/test/java/io/github/minecraftbuilder/paper/MaterialCatalogTest.java b/paper-plugin/src/test/java/io/github/minecraftbuilder/paper/MaterialCatalogTest.java new file mode 100644 index 0000000..e2eb76d --- /dev/null +++ b/paper-plugin/src/test/java/io/github/minecraftbuilder/paper/MaterialCatalogTest.java @@ -0,0 +1,179 @@ +package io.github.minecraftbuilder.paper; + +import com.google.gson.Gson; +import org.junit.jupiter.api.Test; + +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.IntStream; + +import static org.junit.jupiter.api.Assertions.*; + +class MaterialCatalogTest { + private static List fixtures() { + List entries = new ArrayList<>(); + IntStream.range(0, 70).forEach(i -> entries.add(new MaterialCatalog.Entry("minecraft:block_%02d".formatted(i), true, true))); + entries.add(new MaterialCatalog.Entry("minecraft:water", true, false)); + entries.add(new MaterialCatalog.Entry("minecraft:iron_pickaxe", false, true)); + entries.add(new MaterialCatalog.Entry("minecraft:waxed_oxidized_copper_stairs", true, true)); + entries.add(new MaterialCatalog.Entry("minecraft:oxidized_copper_bulb", true, true)); + entries.add(new MaterialCatalog.Entry("minecraft:cut_copper_stairs", true, true)); + return entries; + } + + private MaterialCatalog catalog() { + return new MaterialCatalog("26.2-test", fixtures(), id -> + new MaterialCatalog.StateDescription(id + "[waterlogged=false]", Map.of("waterlogged", List.of("true", "false")), List.of("waterloggable"))); + } + + @SuppressWarnings("unchecked") + private static List> results(Map page) { + return (List>) page.get("results"); + } + + @Test void summaryIsTinyAndContainsCountsRatherThanIds() { + var summary = catalog().summary(); + assertEquals(75, summary.get("materials")); + assertEquals(74L, summary.get("blocks")); + assertEquals(74L, summary.get("items")); + assertEquals(16, summary.get("search_default_limit")); + assertEquals(32, summary.get("search_max_limit")); + String json = new Gson().toJson(summary); + assertTrue(json.length() < 200); + assertFalse(json.contains("minecraft:")); + } + + @Test void searchDefaultsToBlocksAndSixteenCompactResults() { + var page = catalog().search(null, null, null, null); + assertEquals("block", page.get("kind")); + assertEquals(74, page.get("total")); + assertEquals(16, results(page).size()); + assertTrue(results(page).stream().allMatch(entry -> entry.keySet().equals(Set.of("id", "block", "item")))); + assertTrue(((String) page.get("next_cursor")).length() < 100); + } + + @Test void allPagesAreStableBoundedAndDoNotDuplicateOrLoseEntries() { + var catalog = catalog(); + String cursor = null; + List found = new ArrayList<>(); + int pages = 0; + do { + var page = catalog.search("", "all", 32, cursor); + assertTrue(results(page).size() <= 32); + for (var result : results(page)) found.add((String) result.get("id")); + cursor = (String) page.get("next_cursor"); + pages++; + } while (cursor != null); + assertEquals(3, pages); + assertEquals(75, found.size()); + assertEquals(75, new HashSet<>(found).size()); + assertEquals(found.stream().sorted().toList(), found); + } + + @Test void tokenSearchRequiresEveryTokenAndIgnoresCase() { + var page = catalog().search(" COPPER stair ", "block", 32, null); + assertEquals("copper stair", page.get("query")); + assertEquals(2, results(page).size()); + assertTrue(results(page).stream().allMatch(entry -> ((String) entry.get("id")).contains("copper_stairs"))); + assertEquals(0, catalog().search("copper unknown", "all", 32, null).get("total")); + } + + @Test void itemKindIncludesInventoryBlocksButExcludesFluids() { + var catalog = catalog(); + assertEquals(0, catalog.search("water", "item", 16, null).get("total")); + assertEquals(1, catalog.search("water", "block", 16, null).get("total")); + assertEquals(1, catalog.search("pickaxe", "item", 16, null).get("total")); + assertEquals(0, catalog.search("pickaxe", "block", 16, null).get("total")); + } + + @Test void rejectsCursorWhenCatalogOrSearchChanges() { + var catalog = catalog(); + String cursor = (String) catalog.search("block", "all", 16, null).get("next_cursor"); + assertEquals("invalid_cursor", assertThrows(RpcServer.Fault.class, + () -> catalog.search("water", "all", 16, cursor)).code); + assertEquals("invalid_cursor", assertThrows(RpcServer.Fault.class, + () -> catalog.search("block", "item", 16, cursor)).code); + var changed = new MaterialCatalog("26.3-test", fixtures(), id -> null); + assertEquals("stale_cursor", assertThrows(RpcServer.Fault.class, + () -> changed.search("block", "all", 16, cursor)).code); + var reordered = new ArrayList<>(fixtures()); + Collections.reverse(reordered); + assertEquals(catalog.summary().get("version"), new MaterialCatalog("26.2-test", reordered, id -> null).summary().get("version")); + } + + @Test void rejectsMalformedAndOversizedArguments() { + var catalog = catalog(); + for (int limit : List.of(-1, 0, 33, Integer.MAX_VALUE)) + assertEquals("invalid_material_query", assertThrows(RpcServer.Fault.class, () -> catalog.search("", "all", limit, null)).code); + assertThrows(RpcServer.Fault.class, () -> catalog.search("", "nonsense", null, null)); + assertThrows(RpcServer.Fault.class, () -> catalog.search("x".repeat(97), "all", null, null)); + assertEquals(0, catalog.search("x".repeat(96), "all", null, null).get("total")); + assertThrows(RpcServer.Fault.class, () -> catalog.search("x\ny", "all", null, null)); + for (String cursor : List.of("bad", "x".repeat(101), "::", "0".repeat(24) + ":" + "0".repeat(24) + ":2147483647")) + assertEquals("invalid_cursor", assertThrows(RpcServer.Fault.class, () -> catalog.search("", "all", null, cursor)).code); + String valid = (String) catalog.search("", "all", 16, null).get("next_cursor"); + String beyond = valid.substring(0, valid.lastIndexOf(':') + 1) + "999999999"; + assertEquals("invalid_cursor", assertThrows(RpcServer.Fault.class, () -> catalog.search("", "all", 16, beyond)).code); + } + + @Test void describeLoadsOnlyTheExactBlockRequested() { + AtomicInteger calls = new AtomicInteger(); + var catalog = new MaterialCatalog("test", fixtures(), id -> { + calls.incrementAndGet(); + return new MaterialCatalog.StateDescription(id, Map.of(), List.of()); + }); + catalog.summary(); + catalog.search("", "all", 16, null); + assertEquals(0, calls.get()); + var description = catalog.describe("water"); + assertEquals("minecraft:water", description.get("default_state")); + assertEquals(true, description.get("placeable")); + assertEquals(1, calls.get()); + var item = catalog.describe("minecraft:iron_pickaxe"); + assertEquals(false, item.get("placeable")); + assertEquals(true, item.get("item")); + assertFalse(item.containsKey("default_state")); + assertFalse(item.containsKey("properties")); + assertEquals(1, calls.get()); + } + + @Test void rejectsUnknownLegacyAndNonExactIds() { + for (String id : List.of("minecraft:missing", "minecraft:legacy_stone", "stone[axis=y]", "stone{}", "STONE", "mod:stone", "", "x".repeat(129))) + assertEquals("invalid_material", assertThrows(RpcServer.Fault.class, () -> catalog().describe(id)).code); + assertEquals("invalid_material", assertThrows(RpcServer.Fault.class, () -> catalog().describe(null)).code); + } + + @Test void describesBooleanIntegerAndEnumDomainsWithoutAStateProduct() throws Exception { + var state = new FakeState(List.of( + new FakeProperty("waterlogged", List.of(false, true)), + new FakeProperty("age", List.of(0, 1, 2, 3)), + new FakeProperty("facing", List.of(Facing.N, Facing.S)))); + var properties = MaterialCatalog.readProperties(state); + assertEquals(List.of("false", "true"), properties.get("waterlogged")); + assertEquals(List.of("0", "1", "2", "3"), properties.get("age")); + assertEquals(List.of("north", "south"), properties.get("facing")); + assertEquals(List.of("age", "facing", "waterlogged"), new ArrayList<>(properties.keySet())); + assertEquals(8, properties.values().stream().mapToInt(List::size).sum()); + assertEquals(0, MaterialCatalog.readProperties(new FakeState(List.of())).size()); + } + + @Test void enormousOrDuplicatePropertyDomainsFailRatherThanTruncate() { + var enormous = new FakeState(List.of(new FakeProperty("age", IntStream.range(0, 257).boxed().toList()))); + assertEquals("material_properties_unavailable", assertThrows(RpcServer.Fault.class, + () -> MaterialCatalog.readProperties(enormous)).code); + var duplicate = new FakeState(List.of(new FakeProperty("age", List.of(0)), new FakeProperty("age", List.of(0)))); + assertThrows(RpcServer.Fault.class, () -> MaterialCatalog.readProperties(duplicate)); + } + + enum Facing { N, S } + public record FakeState(Collection properties) { + public Collection getProperties() { return properties; } + } + public record FakeProperty(String name, Collection values) { + public String getName() { return name; } + public Collection getPossibleValues() { return values; } + public String getName(Comparable value) { + return value instanceof Facing facing ? (facing == Facing.N ? "north" : "south") : value.toString(); + } + } +} diff --git a/paper-plugin/src/test/java/io/github/minecraftbuilder/paper/SchematicAssetsTest.java b/paper-plugin/src/test/java/io/github/minecraftbuilder/paper/SchematicAssetsTest.java index d4cbdf0..41df995 100644 --- a/paper-plugin/src/test/java/io/github/minecraftbuilder/paper/SchematicAssetsTest.java +++ b/paper-plugin/src/test/java/io/github/minecraftbuilder/paper/SchematicAssetsTest.java @@ -76,10 +76,10 @@ class SchematicAssetsTest { assertEquals(expected, assets.read(exported.assetId(), ZERO, 0)); } - @Test void rejectsSparseSnapshotUnsupportedBlocksAndUnrepresentableOrigin() throws Exception { + @Test void rejectsSparseSnapshotMalformedBlocksAndUnrepresentableOrigin() throws Exception { SchematicAssets assets = new SchematicAssets(root); assertThrows(IOException.class, () -> assets.exportSnapshot("Gap", Map.of(ZERO, STONE, new BlockPos(2, 0, 0), STONE), ZERO, 5000)); - assertThrows(IOException.class, () -> assets.exportSnapshot("Chest", Map.of(ZERO, "minecraft:chest"), ZERO, 5000)); + assertThrows(IOException.class, () -> assets.exportSnapshot("Chest", Map.of(ZERO, "minecraft:chest{Items:[]}"), ZERO, 5000)); assertThrows(IOException.class, () -> assets.exportSnapshot("Offset", Map.of(new BlockPos(Integer.MIN_VALUE, 0, 0), STONE), new BlockPos(Integer.MAX_VALUE, 0, 0), 5000)); assertEquals(0, assets.list().size()); } @@ -156,10 +156,22 @@ class SchematicAssetsTest { assertThrows(IOException.class, () -> SchematicAssets.rotateState("minecraft:oak_stairs", 90)); assertThrows(IOException.class, () -> SchematicAssets.rotateState("minecraft:oak_log", 90)); assertThrows(IOException.class, () -> SchematicAssets.rotateState("minecraft:oak_log[axis=x,axis=z]", 0)); - assertThrows(IOException.class, () -> SchematicAssets.rotateState("minecraft:oak_stairs[facing=up]", 0)); - assertThrows(IOException.class, () -> SchematicAssets.rotateState("minecraft:oak_slab[type=top,waterlogged=true]", 0)); + assertEquals("minecraft:oak_slab[type=top,waterlogged=true]", SchematicAssets.rotateState("minecraft:oak_slab[type=top,waterlogged=true]", 0)); assertThrows(IOException.class, () -> SchematicAssets.rotateState("minecraft:stone[rotation=4]", 90)); } + @Test void delegatesNewMaterialValidationAndRotationToRuntimeCodec() throws Exception { + List rotations=new ArrayList<>(); + SchematicAssets dynamic=new SchematicAssets(root.resolve("dynamic"),(state,degrees)->{ + rotations.add(degrees); + if(!state.startsWith("minecraft:cherry_trapdoor"))throw new IOException("Unknown runtime block"); + return degrees==90?state.replace("facing=north","facing=east"):state; + }); + String trapdoor="minecraft:cherry_trapdoor[facing=north,half=bottom,open=true,powered=false,waterlogged=true]"; + var asset=dynamic.exportSnapshot("New palette",Map.of(ZERO,trapdoor),ZERO,5000); + assertEquals(trapdoor.replace("facing=north","facing=east"),dynamic.read(asset.assetId(),ZERO,90).get(ZERO)); + assertTrue(rotations.contains(90)); + assertThrows(IOException.class,()->dynamic.exportSnapshot("Invalid",Map.of(ZERO,"minecraft:unknown_block"),ZERO,5000)); + } @Test void metadataAndPlacementRejectFutureDataVersionOnTheActualRead() throws Exception { SchematicAssets assets = new SchematicAssets(root); diff --git a/paper-plugin/src/test/java/io/github/minecraftbuilder/paper/TerrainPreviewTest.java b/paper-plugin/src/test/java/io/github/minecraftbuilder/paper/TerrainPreviewTest.java new file mode 100644 index 0000000..0b70a63 --- /dev/null +++ b/paper-plugin/src/test/java/io/github/minecraftbuilder/paper/TerrainPreviewTest.java @@ -0,0 +1,24 @@ +package io.github.minecraftbuilder.paper; + +import com.google.gson.JsonParser; +import io.github.minecraftbuilder.core.TerrainRecipe; +import org.junit.jupiter.api.Test; +import java.io.ByteArrayInputStream; +import java.util.Base64; +import javax.imageio.ImageIO; +import static org.junit.jupiter.api.Assertions.*; + +class TerrainPreviewTest { + @Test void previewIsBoundedNativePngAndReportsClippingWithoutClaimingWorldVerification() throws Exception { + var recipe=new TerrainRecipe(JsonParser.parseString(""" + {"version":1,"min":{"x":0,"y":0,"z":0},"max":{"x":7,"y":7,"z":7}, + "base_height":20,"seed":1,"mode":"sculpt","noise":{"amplitude":0,"scale":8}, + "palette":{"rock":"minecraft:stone","soil":"minecraft:dirt","surface":"minecraft:grass_block","soil_depth":2},"features":[],"preserve":[]} + """).getAsJsonObject()); + var result=TerrainPreview.render(recipe,32,4096); + assertEquals(false,result.get("world_verified"));assertEquals(64,result.get("clipped_samples"));assertEquals(1,result.get("tile_count")); + var image=ImageIO.read(new ByteArrayInputStream(Base64.getDecoder().decode((String)result.get("imageBase64")))); + assertEquals(16,image.getWidth());assertEquals(16,image.getHeight()); + assertThrows(IllegalArgumentException.class,()->TerrainPreview.render(recipe,1024,4096)); + } +} diff --git a/pom.xml b/pom.xml index d09a5da..2f5a17c 100644 --- a/pom.xml +++ b/pom.xml @@ -1,7 +1,7 @@ 4.0.0 io.github.minecraftbuilderminecraft-builder-mcp0.1.0-SNAPSHOTpom - world-corepaper-plugin + world-corepaper-pluginterrain-world-plugin 25UTF-826.2.build.123-stable org.apache.maven.pluginsmaven-compiler-plugin3.14.1 diff --git a/scripts/build-shacraft-balustrade.py b/scripts/build-shacraft-balustrade.py new file mode 100644 index 0000000..b82b655 --- /dev/null +++ b/scripts/build-shacraft-balustrade.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +"""Compile a continuous, checked arrival-square balustrade; never edit the world. + +The existing polygon is followed exactly. Cardinal elbows close diagonal gaps; +garden-side elbows move outward and receive observed, grounded stone footings. +Every road clear cell, planted block and existing light remains protected. +""" +import argparse +from collections import Counter +import importlib.util +import json +import math +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +STAGE = ROOT / '.runtime/balustrade-stage04' +AIR = 'minecraft:air' +N = {'east': (1, 0), 'north': (0, -1), 'south': (0, 1), 'west': (-1, 0)} + + +def module(name, path): + spec = importlib.util.spec_from_file_location(name, path) + value = importlib.util.module_from_spec(spec) + spec.loader.exec_module(value) + return value + + +survey = module('balustrade_survey', ROOT / 'scripts/foundation-survey.py') +geometry = module('balustrade_geometry', ROOT / 'scripts/foundation-study/geometry.py') + + +def compile_plan(before): + design = json.loads((ROOT / '.runtime/plaza-stage03/design-final.json').read_text()) + geo = json.loads((ROOT / '.runtime/foundations-stage02/foundations-final.geometry.json').read_text()) + cells = {(c['x'], c['z']): c for c in geo['cells']} + roads = {tuple(p) for r in geo['routes'] for p in r['clear_cells']} + outer = geometry.polygon_cells(design['outer_hex']) + edge = {p for p in outer if any((p[0]+dx, p[1]+dz) not in outer for dx, dz in N.values())} + gardens = set() + for bed in design['beds']: + gardens |= geometry.polygon_cells(bed['outline']) + gardens |= {tuple(p) for p in bed.get('additional_planting_columns', [])} + ordered = sorted(edge, key=lambda p: math.atan2(p[1]-9, p[0])) + chain, elbows = [], [] + for a, b in zip(ordered, ordered[1:]+ordered[:1]): + chain.append(a) + dx, dz = b[0]-a[0], b[1]-a[1] + if max(abs(dx), abs(dz)) > 1: + raise ValueError('Boundary order contains a nonadjacent jump') + if dx and dz: + candidates = [(a[0], b[1]), (b[0], a[1])] + def score(p): + state = before.state(p[0], 96, p[1]) + return (p in edge or p in chain, p in gardens or state != AIR, p not in outer, p) + # An opening remains open even when its diagonal connector lies in it. + p = min(candidates, key=score) + chain.append(p) + elbows.append(p) + if len(set(chain)) != len(chain): + raise ValueError('Cardinal perimeter is not a simple cycle') + selected = {p for p in chain if p not in roads} + for p in selected: + state = before.state(p[0], 96, p[1]) + if state != AIR and not ('stone_brick_wall[' in state or + (p in edge and state == 'minecraft:smooth_sandstone_slab[type=bottom,waterlogged=false]')): + raise ValueError(f'Protected decoration intersects fence at {p}: {state}') + if before.state(p[0], 97, p[1]) != AIR: + raise ValueError(f'Protected decoration intersects handrail at {p}') + # Rotate at an opening, then split into uninterrupted fence runs. + cut = next(i for i, p in enumerate(chain) if p not in selected) + linear = chain[cut:]+chain[:cut] + runs, run = [], [] + for p in linear: + if p in selected: + run.append(p) + elif run: + runs.append(run) + run = [] + if run: + runs.append(run) + piers = set() + for run in runs: + segments = max(1, round((len(run)-1)/8)) + piers |= {run[round(i*(len(run)-1)/segments)] for i in range(segments+1)} + piers |= {tuple(p) for p in design['outer_hex'] if tuple(p) in run} + desired, groups, footing_columns, replaced_rims = {}, {}, [], [] + def put(x, y, z, state, group): + if (x, z) in roads: + raise ValueError(f'Protected road cell at {(x, z)}') + before.state(x, y, z) + desired[x, y, z] = 'minecraft:'+state + groups[x, y, z] = group + for x, z in sorted(selected): + if (x, z) not in outer: + # Stop at an observed full support; never assume air or bury plants. + y = 95 + while before.state(x, y, z) == AIR: + y -= 1 + support = before.state(x, y, z).split('[')[0].removeprefix('minecraft:') + if support not in survey.FULL or y > 95: + raise ValueError(f'No simple observed footing at {(x, y, z)}') + if y < 95: + footing_columns.append({'x': x, 'z': z, 'support_y': y}) + if support == 'grass_block': + put(x, y, z, 'dirt', 'stable-buried-footing-soil') + for yy in range(y+1, 96): + put(x, yy, z, 'cut_sandstone' if yy == 95 else 'stone_bricks', 'grounded-elbow-footing') + if before.state(x, 96, z).startswith('minecraft:smooth_sandstone_slab'): + replaced_rims.append([x, z]) + if (x, z) in piers: + state = 'cut_sandstone' + else: + connected = {} + for name, (dx, dz) in N.items(): + neighbor = before.state(x+dx, 96, z+dz).split('[')[0].removeprefix('minecraft:') + connected[name] = (x+dx, z+dz) in selected or neighbor.endswith('_wall') or neighbor in survey.FULL + straight = (connected['east'] and connected['west'] and not connected['north'] and not connected['south']) or (connected['north'] and connected['south'] and not connected['east'] and not connected['west']) + props = {name: 'tall' if value else 'none' for name, value in connected.items()} + props |= {'up': 'false' if straight else 'true', 'waterlogged': 'false'} + state = 'stone_brick_wall['+','.join(f'{k}={v}' for k, v in sorted(props.items()))+']' + put(x, 96, z, state, 'sandstone-piers' if (x, z) in piers else 'connected-stone-balusters') + put(x, 97, z, 'smooth_sandstone_slab[type=bottom,waterlogged=false]', 'continuous-cream-handrail') + # Old uncapped road rails need reciprocal arms where the new fence joins them. + adjacent = {(x+dx, z+dz) for x, z in selected for dx, dz in N.values()} - selected + neighboring_rails = {p for p in adjacent if before.state(p[0], 96, p[1]).startswith('minecraft:stone_brick_wall[')} + for x, z in sorted(neighboring_rails): + if before.state(x, 97, z) != AIR: + raise ValueError('Neighboring rail has an unsupported cap configuration') + links = {} + for name, (dx, dz) in N.items(): + p = x+dx, 96, z+dz + neighbor = desired.get(p, before.state(*p)).split('[')[0].removeprefix('minecraft:') + links[name] = neighbor.endswith('_wall') or neighbor in survey.FULL + straight = (links['east'] and links['west'] and not links['north'] and not links['south']) or (links['north'] and links['south'] and not links['east'] and not links['west']) + props = {name: 'low' if linked else 'none' for name, linked in links.items()} + props |= {'up': 'false' if straight else 'true', 'waterlogged': 'false'} + put(x, 96, z, 'stone_brick_wall['+','.join(f'{k}={v}' for k, v in sorted(props.items()))+']', 'reciprocal-road-rail-joins') + blocks = [dict(zip(('x', 'y', 'z'), p)) | {'block': state, 'expected': before.state(*p), 'group': groups[p]} + for p, state in sorted(desired.items()) if state != before.state(*p)] + meta = {'version': 1, 'scope': before.scope, 'floor_y': 95, 'handrail_top_y': 97.5, + 'fence_columns': sorted(selected), 'pier_columns': sorted(piers), 'path_runs': runs, + 'elbow_columns': sorted(set(elbows) & selected), 'footing_columns': footing_columns, + 'neighboring_rail_columns': sorted(neighboring_rails), + 'replaced_planter_rims': replaced_rims, 'protected_road_columns': sorted(roads), + 'by_group': dict(Counter(b['group'] for b in blocks)), 'changed_blocks': len(blocks)} + # Reuse the independent garden and inherited-route auditor with the new obstacle mask. + garden_meta = json.loads((ROOT / '.runtime/plaza-stage03/plaza-polished.metadata.json').read_text()) + garden_meta['unwalkable_columns'] = sorted({tuple(p) for p in garden_meta['unwalkable_columns']} | selected) + walk = json.loads((ROOT / '.runtime/plaza-stage03/plaza-polished.walk.json').read_text()) + walk['points'] = [p for p in walk['points'] if (p['x'], p['z']) not in selected] + garden_meta['walk_samples'] = len(walk['points']) + return {'version': 1, 'scope': before.scope, 'blocks': blocks}, meta, garden_meta, walk + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--before', type=Path, default=STAGE / 'before-full.json.gz') + parser.add_argument('--output', type=Path, default=STAGE / 'balustrade.json') + args = parser.parse_args() + before = survey.load_snapshot(args.before) + plan, meta, garden_meta, walk = compile_plan(before) + args.output.parent.mkdir(parents=True, exist_ok=True) + for suffix, document in (('.json', plan), ('.metadata.json', meta), ('.garden-metadata.json', garden_meta), ('.walk.json', walk)): + args.output.with_suffix(suffix).write_text(json.dumps(document, separators=(',', ':'))+'\n') + print(json.dumps({k: meta[k] for k in ('changed_blocks', 'by_group', 'replaced_planter_rims')} + | {'fence_columns': len(meta['fence_columns']), 'piers': len(meta['pier_columns']), + 'runs': [len(r) for r in meta['path_runs']], 'footings': len(meta['footing_columns'])})) + + +if __name__ == '__main__': + main() diff --git a/scripts/build-shacraft-foundations.py b/scripts/build-shacraft-foundations.py new file mode 100644 index 0000000..69462f7 --- /dev/null +++ b/scripts/build-shacraft-foundations.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""Compile the reviewed foundations and streets into a checked, reversible block plan. + +No server writes here. A full live voxel survey is required, and differences from +the known natural world + previous marker receipts are protected, not adopted. +Apply the resulting JSON with scripts/layout.py. +""" +import argparse +from collections import Counter, deque +import importlib.util +import json +import math +from pathlib import Path + +ROOT=Path(__file__).resolve().parents[1] + +def module(name,path): + spec=importlib.util.spec_from_file_location(name,path) + value=importlib.util.module_from_spec(spec);spec.loader.exec_module(value);return value + +geometry=module('foundation_geometry',ROOT/'scripts/foundation-study/geometry.py') +survey=module('foundation_survey',ROOT/'scripts/foundation-survey.py') +N=((1,0),(-1,0),(0,1),(0,-1)) + + +def distances(mask): + dist={p:0 for p in mask if any((p[0]+dx,p[1]+dz) not in mask for dx,dz in N)} + q=deque(dist) + while q: + x,z=q.popleft() + for dx,dz in N: + p=(x+dx,z+dz) + if p in mask and p not in dist:dist[p]=dist[(x,z)]+1;q.append(p) + return dist + + +def compile_plan(design,layout,before,original,marker_documents): + scope=before.document['scope'];geo=geometry.build_geometry(design,layout);cells=geo['cells'] + mask=set(cells);edge=distances(mask);desired={};groups={} + known={} + for doc in marker_documents: + for b in doc['blocks']:known[(b['x'],b['y'],b['z'])]=b['block'] + def natural(x,z): + i=(z-original['min_z'])*original['width']+x-original['min_x'] + return original['surface_y'][i],original['palette'][original['material_index'][i]] + def predicted(x,y,z): + if (x,y,z) in known:return known[(x,y,z)] + h,material=natural(x,z) + if y>h:return 'minecraft:air' + if material=='minecraft:water':raise ValueError('Water column outside foundation scope') + if y==h:return material+'[snowy=false]' if material=='minecraft:grass_block' else material + return 'minecraft:dirt' if material=='minecraft:grass_block' and y>=h-3 else 'minecraft:stone' + def put(x,y,z,block,group): + if not block.startswith('minecraft:'):block='minecraft:'+block + desired[(x,y,z)]=block;groups[(x,y,z)]=group + # Remove only old survey blocks within this stage's exact footprint + 1-column + # cleanup margin. Outside district markers remain unchanged. + cleanup=mask|{(x+dx,z+dz) for x,z in mask for dx,dz in N} + for (x,y,z),state in known.items(): + if (x,z) not in cleanup:continue + try:before.state(x,y,z) + except (KeyError,ValueError):continue # Only the optional cleanup margin may extend beyond the survey. + h,m=natural(x,z) + if y>h:new='minecraft:air' + elif y==h:new=m+'[snowy=false]' if m=='minecraft:grass_block' else m + else:continue + put(x,y,z,new,'remove-obsolete-survey') + + road_clear={tuple(p) for r in geo['routes'] for p in r['clear_cells']} + road_buffer=road_clear|{(x+dx,z+dz) for x,z in road_clear for dx,dz in N} + # Exact solid footings and two structural courses beneath every walking deck. + for (x,z),c in cells.items(): + y=c['block_y'];h,_=natural(x,z);d=edge[(x,z)];group=c['group'] + bottom=min(h-1,y-2) if d<=1 else min(h,y-2) + for yy in range(bottom,y): + block='stone' + if d==0: + block='deepslate_bricks' if yy<=h+1 else 'stone_bricks' + if yy==y-1:block='polished_andesite' + if (x+z)%12 in (0,1) and yy>h+1:block='smooth_sandstone' + elif d==1 and yy==y-1:block='stone_bricks' + put(x,yy,z,block,group+'-foundation') + # Clear all natural overburden and former stakes above the finished floor. + for yy in range(y+1,max(h+2,y+4)+1):put(x,yy,z,'air',group+'-clearance') + if c['kind']=='stairs': + paving=f"stone_brick_stairs[facing={c['facing']},half=bottom,shape=straight,waterlogged=false]" + elif not c['clear'] or d==0:paving='polished_andesite' + elif d==1:paving='stone_bricks' + elif d==2:paving='smooth_sandstone' + elif group=='clock-station': + # Quiet structural floor; regular narrow foundation setting-out bands. + paving='stone_bricks' if x%16==0 or z%16==0 else 'smooth_stone' + elif group in ('arrival-hex','station-forecourt'): + paving='smooth_sandstone' + if x%12==0 or z%12==0:paving='smooth_stone' + else: + paving='smooth_stone' + if c['kind']=='full' and ((x if 'radial' in group or 'ring-' in group else z)%8==0):paving='stone_bricks' + put(x,y,z,paving,group+'-paving') + + # Green inset medallion. All tesserae are flush with the arrival paving. + hexagon=[(round(16*math.sin(math.pi/3*i)),round(9-16*math.cos(math.pi/3*i))) for i in range(6)] + medallion=geometry.polygon_cells(hexagon);md=distances(medallion) + for x,z in medallion: + put(x,95,z,'smooth_quartz' if md[(x,z)]<=1 else 'green_concrete','arrival-medallion') + glyph=['01110','11000','11000','01110','00011','00011','01110'] + for row,bits in enumerate(glyph): + for col,on in enumerate(bits): + if on=='1': + for dx in (0,1): + for dz in (0,1):put(-5+col*2+dx,95,2+row*2+dz,'smooth_quartz','arrival-medallion') + + # Keep the future tower and pavilion footing outlines readable in the deck. + for f in layout['features']: + if f['id'] not in ('station-clock-base','station-pavilion--63','station-pavilion-29'):continue + vertices=f['points'];outline=geometry.polygon_cells(vertices) + for x,z in outline: + if any((x+dx,z+dz) not in outline for dx,dz in N):put(x,98,z,'polished_andesite','station-structural-bands') + + # A shallow blind arcade breaks up the tall western station retaining wall. + # Each opening is one block deep, with an intact solid backing and lintel. + for center in (-132,-124,-116,-108): + for offset in range(-2,3): + z=center+offset;h,_=natural(-74,z);top=94-abs(offset) + for y in range(max(h+2,85),top+1): + put(-74,y,z,'air','station-west-blind-arcade') + put(-73,y,z,'deepslate_bricks','station-west-blind-arcade') + if top>=h+2:put(-74,top+1,z,'smooth_sandstone','station-west-arch-stones') + for z0 in (-138,-128,-120,-112,-104,-99): + for z in (z0,z0+1): + h,_=natural(-75,z) + for y in range(h-1,98):put(-75,y,z,'deepslate_bricks' if y=3 or c['group']=='station-entrance-stair':rail[(x,z)]=c['block_y']+1 + for (x,z),y in rail.items(): + props={name:('low' if (x+dx,z+dz) in rail and abs(rail[(x+dx,z+dz)]-y)<=1 else 'none') + for name,(dx,dz) in {'east':(1,0),'north':(0,-1),'south':(0,1),'west':(-1,0)}.items()} + straight=(props['east']==props['west']=='low' and props['north']==props['south']=='none') or (props['north']==props['south']=='low' and props['east']==props['west']=='none') + up='false' if straight and (x+z)%8 else 'true' + state=f"stone_brick_wall[east={props['east']},north={props['north']},south={props['south']},up={up},waterlogged=false,west={props['west']}]" + put(x,y,z,state,'edge-balustrades') + + # Roads meet the reserved bridge decks exactly. A temporary end balustrade + # prevents a finished street from leading straight into an unbuilt span. + bridge_gates=[] + for ident in ('east-radial-local','ring-northeast-local'): + r=next(r for r in geo['routes'] if r['id']==ident) + end_x,end_z=r['centerline'][-1];deck=cells[(end_x,end_z)]['block_y'] + zs=sorted(z for x,z in r['corridor'] if x==end_x) + for z in zs: + x=end_x+1;h,_=natural(x,z) + for y in range(min(h,deck-1),deck+1):put(x,y,z,'stone_bricks','temporary-bridge-threshold') + north='low' if z-1 in zs else 'none';south='low' if z+1 in zs else 'none' + put(x,deck+1,z,f'stone_brick_wall[east=none,north={north},south={south},up=true,waterlogged=false,west=none]','temporary-bridge-gates') + bridge_gates.append({'x':end_x+1.5,'y':deck+4,'z':end_z+.5,'deck_y':deck}) + + # Lit piers are part of the stone edge, never obstacles in the clear road lane. + candidates=[p for p in rail if cells[p]['kind']=='full'] + chosen=[] + landmarks=[(0,-37),(38,-14),(42,30),(0,56),(-42,30),(-38,-14), + (-36,-61),(27,-61),(-25,-83),(13,-83),(-74,-139),(40,-139),(-74,-98),(40,-98)] + for target in landmarks: + options=sorted(candidates,key=lambda p:(p[0]-target[0])**2+(p[1]-target[1])**2) + if options and math.dist(options[0],target)<12 and all(math.dist(options[0],p)>8 for p in chosen):chosen.append(options[0]) + for point in sorted(candidates,key=lambda p:(p[1],p[0])): + if all(math.dist(point,p)>19 for p in chosen):chosen.append(point) + for x,z in chosen: + y=cells[(x,z)]['block_y'] + put(x,y+1,z,'chiseled_stone_bricks','lamp-piers');put(x,y+2,z,'stone_bricks','lamp-piers') + put(x,y+3,z,'smooth_stone_slab[type=double,waterlogged=false]','lamp-piers') + put(x,y+4,z,'lantern[hanging=false,waterlogged=false]','lamps') + + # Preserve any unexpected human edits, including underground blocks. We do + # not silently rebase onto arbitrary newly observed content. + blocks=[];mismatches=[] + for (x,y,z),block in sorted(desired.items()): + actual=before.state(x,y,z);expected=predicted(x,y,z) + if actual!=expected: + mismatches.append({'x':x,'y':y,'z':z,'expected':expected,'actual':actual}) + continue + if block!=actual:blocks.append({'x':x,'y':y,'z':z,'block':block,'expected':actual,'group':groups[(x,y,z)]}) + if mismatches:raise ValueError(f'Unexpected live edits preserved: {len(mismatches)}, first {mismatches[:5]}') + # Sample both treads of every stair and every usable full floor column. Rails + # and lamp piers are excluded; actual after-survey tests their surrounding lanes. + walk=[] + for (x,z),c in cells.items(): + if (x,z) in rail or not c['clear']:continue + if c['kind']=='stairs': + for sub in (.25,.75): + sx,sz=(sub,.5) if c['facing'] in ('east','west') else (.5,sub) + walk.append({'x':x,'z':z,'standing_y':geometry.tread_height(c,sx,sz),'sub_x':sx,'sub_z':sz}) + else:walk.append({'x':x,'z':z,'standing_y':c['block_y']+1}) + metadata={'scope':scope,'geometry_checks':geo['checks'],'changed_blocks':len(blocks),'walk_samples':len(walk), + 'lamps':len(chosen),'rail_columns':len(rail),'by_material':dict(Counter(b['block'] for b in blocks)), + 'temporary_bridge_gates':bridge_gates, + 'source_snapshot_finished_at':before.document['finished_at'],'source_snapshot_atomic':False, + 'note':'Only zones01/02 foundations and local access roads; other districts remain marked reservations.'} + return {'version':1,'scope':scope,'blocks':blocks},metadata,{'scope':scope,'points':walk},geo + + +def main(): + p=argparse.ArgumentParser(description=__doc__) + p.add_argument('--design',type=Path,required=True);p.add_argument('--snapshot',type=Path,required=True) + p.add_argument('--output',type=Path,required=True);a=p.parse_args() + before=survey.load_snapshot(a.snapshot) + design=json.loads(a.design.read_text());layout=json.loads((ROOT/'examples/layout/shacraft-lobby-layout.json').read_text()) + original=json.loads((ROOT/'.runtime/server/plugins/ShacraftTerrain/maps/layout-before.json').read_text()) + markers=[json.loads((ROOT/name).read_text()) for name in ('.runtime/layout-study/markers-final.json','.runtime/layout-study/access-blocks.json')] + plan,meta,walk,geo=compile_plan(design,layout,before,original,markers) + a.output.parent.mkdir(parents=True,exist_ok=True) + a.output.write_text(json.dumps(plan,separators=(',',':'))+'\n') + a.output.with_suffix('.metadata.json').write_text(json.dumps(meta,indent=2)+'\n') + a.output.with_suffix('.walk.json').write_text(json.dumps(walk,separators=(',',':'))+'\n') + a.output.with_suffix('.geometry.json').write_text(json.dumps(geometry.serializable(geo),separators=(',',':'))+'\n') + print(json.dumps(meta)) + + +if __name__=='__main__':main() diff --git a/scripts/build-shacraft-plaza.py b/scripts/build-shacraft-plaza.py new file mode 100644 index 0000000..c086560 --- /dev/null +++ b/scripts/build-shacraft-plaza.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +"""Compile a reference-led arrival garden against observed, protected voxel states. + +No world writes. Apply the resulting checked recipe with scripts/layout.py. +The brand bitmap is sampled into block coordinates, not used as a rendered fake. +""" +import argparse +from collections import Counter, deque +import hashlib +import importlib.util +import json +import math +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +N2 = ((1, 0), (-1, 0), (0, 1), (0, -1)) +N3 = ((1, 0, 0), (-1, 0, 0), (0, 1, 0), (0, -1, 0), (0, 0, 1), (0, 0, -1)) +AIR = 'minecraft:air' + + +def module(name, path): + spec = importlib.util.spec_from_file_location(name, path) + m = importlib.util.module_from_spec(spec) + spec.loader.exec_module(m) + return m + + +geometry = module('plaza_geometry', ROOT / 'scripts/foundation-study/geometry.py') +survey = module('plaza_survey', ROOT / 'scripts/foundation-survey.py') +assets = module('plaza_assets', ROOT / 'scripts/plaza-assets.py') + + +def distances(mask): + result = {p: 0 for p in mask if any((p[0]+dx, p[1]+dz) not in mask for dx, dz in N2)} + pending = deque(result) + while pending: + x, z = pending.popleft() + for dx, dz in N2: + p = x+dx, z+dz + if p in mask and p not in result: + result[p] = result[x, z]+1 + pending.append(p) + return result + + +def brand_cells(path, width=25, height=35): + from PIL import Image + source = Image.open(path).convert('RGBA') + # Select the green artwork, excluding transparency and the pale antialias fringe. + mask = Image.new('L', source.size) + pixels = source.get_flattened_data() if hasattr(source, 'get_flattened_data') else source.getdata() + mask.putdata([255 if a > 100 and g > r*1.15 and g > b*1.15 else 0 for r, g, b, a in pixels]) + box = mask.getbbox() + if not box: + raise ValueError('Brand image contains no green artwork') + cells = mask.crop(box).resize((width, height), Image.Resampling.BOX) + return {(x-width//2, z+9-height//2) for z in range(height) for x in range(width) + if cells.getpixel((x, z)) >= 115} + + +def compile_plan(design, before, previous, logo): + base_geometry = json.loads((ROOT / '.runtime/foundations-stage02/foundations-final.geometry.json').read_text()) + cells = {(c['x'], c['z']): c for c in base_geometry['cells']} + outer = geometry.polygon_cells(design['outer_hex']) + floor = {p for p in outer if cells[p]['kind'] == 'full' and cells[p]['block_y'] == 95} + edge = distances(outer) + protected = {tuple(p) for route in base_geometry['routes'] for p in route['clear_cells']} + desired, groups = {}, {} + + def put(x, y, z, state, group): + if not state.startswith('minecraft:'): + state = 'minecraft:'+state + before.state(x, y, z) # Require observed support and air, including canopy overhangs. + desired[x, y, z], groups[x, y, z] = state, group + + def current(x, y, z): + return desired.get((x, y, z), before.state(x, y, z)) + + # Retire only still-matching zone01 survey marks, including its exterior + # stakes and number. Other districts and changed human blocks are preserved. + marker_groups = {'arrival-hex', 'spawn-medallion', 'spawn-monogram', 'label-01', 'wayfinding-01'} + markers = json.loads((ROOT/'.runtime/layout-study/markers-final.json').read_text()) + for b in markers['blocks']: + if b.get('group') not in marker_groups: + continue + p = b['x'], b['y'], b['z'] + try: + observed = before.state(*p) + except (KeyError, ValueError): + continue + if observed == b['block']: + put(*p, b['expected'], 'retire-zone01-survey') + + # Preserve stair treads, the foundation footprint, and all neighboring districts. + for x, z in sorted(floor): + material = 'smooth_sandstone' + if edge[x, z] == 0: + material = 'cut_sandstone' + elif edge[x, z] == 1: + material = 'smooth_stone' + put(x, 95, z, material, 'cream-paving') + + for radius, material in ((22, 'polished_andesite'), (24, 'smooth_stone'), (28, 'cut_sandstone')): + poly = [(round(radius*math.sin(i*math.pi/3)), 9-round(radius*math.cos(i*math.pi/3))) for i in range(6)] + ring = geometry.polygon_cells(poly) + for x, z in ring: + if any((x+dx, z+dz) not in ring for dx, dz in N2): + put(x, 95, z, material, 'hexagonal-paving-bands') + logo_mask = brand_cells(logo) + for x, z in logo_mask: + if (x, z) not in floor: + raise ValueError('Logo exceeds the paving') + put(x, 95, z, 'green_concrete', 'original-brand-inlay') + + # Replace the former bulky survey-stage light piers. Their perimeter rails remain. + old_plan = json.loads((ROOT / '.runtime/foundations-stage02/foundations-final.json').read_text()) + old_lamps = [(b['x'], b['z']) for b in old_plan['blocks'] if b.get('group') == 'lamps' + and ((b['x'], b['z']) in outer or (b['x'], b['z']) == (-5, 56))] + for x, z in old_lamps: + for y in range(96, 100): + put(x, y, z, 'air', 'replace-old-lamp-piers') + props = {} + for name, (dx, dz) in {'east': (1, 0), 'north': (0, -1), 'south': (0, 1), 'west': (-1, 0)}.items(): + neighbor = before.state(x+dx, 96, z+dz) + props[name] = 'low' if any(n in neighbor for n in ('stone_brick_wall', 'chiseled_stone_bricks')) else 'none' + put(x, 96, z, 'stone_brick_wall['+','.join(f'{k}={v}' for k, v in sorted(props.items() | {'up': 'true', 'waterlogged': 'false'}.items()))+']', 'restored-perimeter-rail') + + beds, beds_union, bench_aprons, fixture_records = [], set(), set(), [] + flower_types = ('pink_tulip', 'white_tulip', 'oxeye_daisy', 'allium', 'azure_bluet') + for index, bed in enumerate(design['beds']): + mask = geometry.polygon_cells(bed['outline']) + mask |= {tuple(p) for p in bed.get('additional_planting_columns', [])} + if not mask <= floor or mask & protected: + raise ValueError('Garden intersects a protected road or non-flat foundation') + beds.append(mask) + beds_union |= mask + inset = distances(mask) + for x, z in sorted(mask): + put(x, 94, z, 'dirt', 'garden-soil') + put(x, 95, z, 'grass_block[snowy=false]', 'garden-soil') + if inset[x, z] == 0: + put(x, 95, z, 'smooth_sandstone', 'planter-rim') + put(x, 96, z, 'smooth_sandstone_slab[type=bottom,waterlogged=false]', 'planter-rim') + else: + # Drifts follow small clusters, rather than an alternating plant checkerboard. + patch = (x//3+2*(z//3)+index) % 7 + if inset[x, z] == 1 and patch in (0, 1, 5): + put(x, 96, z, 'oak_leaves[distance=7,persistent=true,waterlogged=false]', 'low-evergreen-hedge') + elif (x*17+z*31) % 5 != 0: + flower = flower_types[patch % len(flower_types)] + put(x, 96, z, flower, 'flower-drifts') + + tree = bed['tree'] + put(tree['x'], 95, tree['z'], 'dirt', 'stable-tree-soil') + height = max(11, min(15, tree['height_above_floor'])) + for (dx, y, dz), state in assets.conifer(height, seed=1337+index*71).items(): + if 'leaves' in state and y < 4: + continue + put(tree['x']+dx, 96+y, tree['z']+dz, state, 'custom-conifers') + fixture_records.append({'type': 'conifer', 'x': tree['x'], 'z': tree['z'], 'height': height}) + t = bed['small_topiary'] + put(t['x'], 95, t['z'], 'dirt', 'stable-tree-soil') + for y in range(4): + put(t['x'], 96+y, t['z'], 'spruce_log[axis=y]', 'small-topiary') + for y, radius in ((2, 1), (3, 1), (4, 0)): + for dx in range(-radius, radius+1): + for dz in range(-radius, radius+1): + if dx*dx+dz*dz <= 2 and (dx or dz or y == 4): + put(t['x']+dx, 96+y, t['z']+dz, 'spruce_leaves[distance=7,persistent=true,waterlogged=false]', 'small-topiary') + fixture_records.append({'type': 'topiary', 'x': t['x'], 'z': t['z'], 'height': 5}) + + facing_vectors = {'north': (0, -1), 'east': (1, 0), 'south': (0, 1), 'west': (-1, 0)} + opposite = {'north': 'south', 'south': 'north', 'east': 'west', 'west': 'east'} + for bed, mask in zip(design['beds'], beds): + b = bed['bench'] + cx, cz = b['center'] + facing = opposite[b['back_faces']] + dx, dz = facing_vectors[facing] + # Open a level, three-block seat approach through the planter rim. + for offset in (-1, 0, 1): + for step in range(1, 8): + x, z = cx+dx*step-dz*offset, cz+dz*step+dx*offset + if (x, z) not in floor: + raise ValueError('Bench approach exceeds the plaza') + for y in (96, 97): + if '_log[' in current(x, y, z): + raise ValueError('Bench approach intersects a tree') + put(x, y, z, 'air', 'bench-access') + put(x, 95, z, 'smooth_sandstone', 'bench-access') + bench_aprons.add((x, z)) + if (x, z) not in mask: + break + for (ox, y, oz), state in assets.bench(b['length'], facing).items(): + x, z = cx+ox, cz+oz + if (x, z) not in floor or (x, z) in protected: + raise ValueError('Bench exceeds its clear garden bay') + for yy in (96, 97): + if '_log[' in current(x, yy, z): + raise ValueError('Bench intersects a tree root') + put(x, yy, z, 'air', 'bench-access') + put(x, 95, z, 'smooth_sandstone', 'bench-foundation') + put(x, 96+y, z, state, 'garden-benches') + fixture_records.append({'type': 'bench', 'x': cx, 'z': cz, 'facing': facing, 'seats': 3}) + + lamps = [(v['x'], v['z']) for v in design['lighting']['lamps']] + for x, z in lamps: + c = cells.get((x, z), {}) + if (x, z) in protected or c.get('kind') != 'full' or c.get('block_y') != 95: + raise ValueError('Lamp base intrudes into an approach') + put(x, 95, z, 'chiseled_stone_bricks', 'lamp-footing') + for (dx, y, dz), state in assets.lamp(6).items(): + put(x+dx, 96+y, z+dz, state, 'copper-garden-lamps') + fixture_records.append({'type': 'lamp', 'x': x, 'z': z, 'lantern_y': 100}) + + # Leaves use their stable distance to the composed logs, including touching hedges. + leaf_positions = {p for p, state in desired.items() if '_leaves[' in state} + logs = {p for p, state in desired.items() if '_log[' in state} + leaf_dist, pending = {}, deque() + for x, y, z in logs: + for dx, dy, dz in N3: + p = x+dx, y+dy, z+dz + if p in leaf_positions: + leaf_dist[p] = 1 + pending.append(p) + while pending: + x, y, z = pending.popleft() + if leaf_dist[x, y, z] >= 6: + continue + for dx, dy, dz in N3: + p = x+dx, y+dy, z+dz + if p in leaf_positions and p not in leaf_dist: + leaf_dist[p] = leaf_dist[x, y, z]+1 + pending.append(p) + for p in leaf_positions: + material = desired[p].split('[', 1)[0] + desired[p] = f'{material}[distance={leaf_dist.get(p, 7)},persistent=true,waterlogged=false]' + + # Every floor column with a low obstacle is excluded from the walking network. + blocked = set() + for x, z in floor: + if current(x, 96, z) != AIR or current(x, 97, z) != AIR: + blocked.add((x, z)) + blocked |= beds_union - bench_aprons + if blocked & protected: + raise ValueError(f'Decoration obstructs protected road cells: {sorted(blocked & protected)[:8]}') + for x, z in blocked: + if abs(x) <= 6 and (x, z) not in beds_union: + # Existing outer balustrades are handled by the previous navigation mask. + if any(groups.get((x, y, z), '') not in ('restored-perimeter-rail', '') for y in (96, 97)): + raise ValueError('Central sightline/walking axis was obstructed') + + blocks = [] + for p, state in sorted(desired.items()): + observed = before.state(*p) + try: + prior = previous.state(*p) + except (KeyError, ValueError): + if p[1] <= 114: + raise + prior = AIR + if observed != prior: + raise ValueError(f'Unexpected manual edit preserved at {p}') + if state != observed: + blocks.append(dict(zip(('x', 'y', 'z'), p)) | {'block': state, 'expected': observed, 'group': groups[p]}) + walk = [{'x': x, 'z': z, 'standing_y': 96} for x, z in sorted(floor-blocked)] + meta = {'scope': before.scope, 'district': '01', 'changed_blocks': len(blocks), 'planters': len(beds), + 'planter_columns': len(beds_union), 'trees': 6, 'topiary': 6, 'benches': 6, 'new_lamps': len(lamps), + 'old_lamps_replaced': len(old_lamps), 'logo_blocks': len(logo_mask), 'walk_samples': len(walk), + 'unwalkable_columns': sorted(blocked), 'bench_access_columns': sorted(bench_aprons), + 'fixtures': fixture_records, 'by_material': dict(Counter(b['block'] for b in blocks)), + 'by_group': dict(Counter(b['group'] for b in blocks)), 'source': 'Observed after-foundation baseline; approved reference sheets 03 and 11'} + return {'version': 1, 'scope': before.scope, 'blocks': blocks}, meta, {'scope': before.scope, 'points': walk} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--design', type=Path, default=ROOT/'.runtime/plaza-stage03/design-final.json') + parser.add_argument('--before', type=Path, default=ROOT/'.runtime/plaza-stage03/before.json.gz') + parser.add_argument('--output', type=Path, default=ROOT/'.runtime/plaza-stage03/plaza.json') + parser.add_argument('--logo', type=Path, default=Path('/home/emil/Desktop/Shacraft-Lobby-References/brand/logo-180.png')) + args = parser.parse_args() + before = survey.load_snapshot(args.before) + previous = survey.load_snapshot(ROOT/'.runtime/foundations-stage02/after.json.gz') + design = json.loads(args.design.read_text()) + plan, meta, walk = compile_plan(design, before, previous, args.logo) + meta['source_logo_sha256'] = hashlib.sha256(args.logo.read_bytes()).hexdigest() + args.output.write_text(json.dumps(plan, separators=(',', ':'))+'\n') + args.output.with_suffix('.metadata.json').write_text(json.dumps(meta, indent=2)+'\n') + args.output.with_suffix('.walk.json').write_text(json.dumps(walk, separators=(',', ':'))+'\n') + print(json.dumps({k: v for k, v in meta.items() if k not in ('unwalkable_columns', 'bench_access_columns', 'fixtures', 'by_material')})) + + +if __name__ == '__main__': + main() diff --git a/scripts/build-shacraft-station.py b/scripts/build-shacraft-station.py new file mode 100644 index 0000000..9cc197d --- /dev/null +++ b/scripts/build-shacraft-station.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Compile the two-floor clock station against observed, unchanged foundation voxels. + +No live writes: apply the resulting recipe using scripts/layout.py after QA. +""" +import argparse +import importlib.util +import json +from collections import Counter +from pathlib import Path + +ROOT=Path(__file__).resolve().parents[1] +STAGE=ROOT/'.runtime/station-stage07' + +def module(name,path): + spec=importlib.util.spec_from_file_location(name,path) + value=importlib.util.module_from_spec(spec);spec.loader.exec_module(value);return value + +survey=module('station_survey',ROOT/'scripts/foundation-survey.py') + +def canonical(state): + if '[' not in state:return state + name,raw=state[:-1].split('[',1) + return name+'['+','.join(sorted(raw.split(',')))+']' + +def compile_station(before): + layout=json.loads((ROOT/'docs/references/zone02-interior-v1/layout.json').read_text()) + geometry=json.loads((ROOT/'.runtime/foundations-stage02/foundations-final.geometry.json').read_text()) + foundation=survey.load_snapshot(ROOT/'.runtime/foundations-stage02/after.json.gz') + foot={(c['x'],c['z']) for c in geometry['cells'] if c['group']=='clock-station'} + allowed=set(json.loads((STAGE/'context-public.json').read_text())['supported_materials']) + exterior=module('station_exterior',ROOT/'scripts/station-exterior.py') + interior=module('station_interior',ROOT/'scripts/station-interior.py') + states,groups,ext=exterior.compile_exterior(foot,layout) + decorations,labels,intmeta=interior.compile_interior(foot,layout) + changes=Counter() + for p,v in decorations.items(): + if p in states and states[p]!=v:changes[(groups[p],labels[p])]+=1 + states.update(decorations);groups.update(labels) + rows=[];conflicts=[] + for p,value in sorted(states.items()): + state=canonical(value) + if state.split('[')[0] not in allowed:raise ValueError(f'Material unavailable: {state}') + observed=before.state(*p) + if observed==state:continue + if observed not in survey.AIR: + try: prior=foundation.state(*p) + except KeyError:prior=None + if observed!=prior:conflicts.append({'at':p,'current':observed,'foundation_stage':prior}) + rows.append(dict(zip(('x','y','z'),p))|{'block':state,'expected':observed,'group':groups[p]}) + if conflicts:raise ValueError(f'Unexpected existing changes preserved: {conflicts[:12]} ({len(conflicts)} total)') + recipe={'version':1,'scope':before.scope,'blocks':rows} + desired={(b['x'],b['y'],b['z']):b['block'] for b in rows} + def state(x,y,z):return desired.get((x,y,z),before.state(x,y,z)) + inner=exterior._erode(foot,2) + floors=[] + for feet,name in [(99,'vestibule'),(113,'smash')]: + candidates=set(inner) + if feet==99:candidates|={(x,z) for x in range(-9,-2) for z in range(-85,-82)} + clear=sorted(p for p in candidates if all(state(p[0],y,p[1]) in survey.AIR for y in range(feet,feet+4))) + floors.append({'id':name,'standing_y':feet,'source':{'x':-6,'z':-120},'clear_columns':clear,'min_headroom':4}) + def region(name,x0,x1,y0,y1,z0,z1): + return {'id':name,'min':{'x':x0,'y':y0,'z':z0},'max':{'x':x1,'y':y1,'z':z1}} + clear_regions=[region('entrance',-9,-3,99,102,-85,-83)] + for feet in [99,113]: + clear_regions += [region(f'cabin-{feet}',-8,-4,feet,feet+3,-122,-118), + region(f'lift-door-{feet}',-8,-4,feet,feet+3,-117,-116)] + clear_regions += [region('ground-entry-aisle',-9,-3,99,102,-115,-86), + region('smash-bay-front',-46,11,113,116,-135,-129)] + metadata={'scope':before.scope,'exterior':ext,'interior':intmeta,'public_floors':floors, + 'clear_regions':clear_regions,'footprint':sorted(foot), + 'containment':{'bounds':{'min':{'x':-79,'y':94,'z':-150},'max':{'x':45,'y':163,'z':-76}}, + 'authorized_caps':[region('ground-entrance-audit-cap',-9,-3,99,110,-84,-84)], + 'forbidden_y_at_or_above':126, + 'seeds':[{'id':'vestibule-and-cabin','x':-6,'y':99,'z':-120,'min_y':99,'max_y':110}, + {'id':'smash-and-cabin','x':-6,'y':113,'z':-120,'min_y':113,'max_y':123}]}, + 'compile_summary':{'written_blocks':len(rows),'intended_states':len(states),'materials':dict(Counter(b['block'].split('[')[0] for b in rows)), + 'layer_overrides':[{'exterior':a,'interior':b,'count':n} for (a,b),n in changes.items()]}} + return recipe,metadata + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--before',type=Path,default=STAGE/'before.json.gz') + parser.add_argument('--output',type=Path,default=STAGE/'compiled') + args=parser.parse_args();recipe,metadata=compile_station(survey.load_snapshot(args.before)) + args.output.mkdir(parents=True,exist_ok=True) + for name,data in [('station.json',recipe),('station.metadata.json',metadata)]: + (args.output/name).write_text(json.dumps(data,separators=(',',':'))+'\n') + print(json.dumps(metadata['compile_summary'])) + +if __name__=='__main__':main() diff --git a/scripts/camera-wrapper.py b/scripts/camera-wrapper.py index 0846bfb..6ec5ccb 100644 --- a/scripts/camera-wrapper.py +++ b/scripts/camera-wrapper.py @@ -22,6 +22,9 @@ def main(): except (OSError, ValueError): raise SystemExit('Cannot read a valid camera token/port from the private Paper config.') environment = dict(os.environ, MCB_CAMERA_TOKEN=token, MCB_CAMERA_PORT=str(port)) + auto = re.search(r'^camera-auto-connect:[ \t]*[\'\"]?(127\.0\.0\.1:[0-9]{1,5})[\'\"]?[ \t]*$', data, re.M) + if auto: + environment['MCB_CAMERA_AUTO_CONNECT'] = auto.group(1) os.execvpe(sys.argv[1], sys.argv[1:], environment) if __name__ == '__main__': diff --git a/scripts/compile-layout-access.py b/scripts/compile-layout-access.py new file mode 100644 index 0000000..a6fa515 --- /dev/null +++ b/scripts/compile-layout-access.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Compile audited access refinements against the original survey plus placed markers.""" +import argparse +import importlib.util +import json +from pathlib import Path + +ROOT=Path(__file__).resolve().parents[1] +spec=importlib.util.spec_from_file_location('mark',ROOT/'scripts/mark-shacraft-layout.py') +mark=importlib.util.module_from_spec(spec);spec.loader.exec_module(mark) + +def main(): + p=argparse.ArgumentParser();p.add_argument('--before',type=Path,required=True) + p.add_argument('--base',type=Path,required=True);p.add_argument('--study',type=Path,required=True) + p.add_argument('--access',type=Path,required=True);p.add_argument('--output',type=Path,required=True) + a=p.parse_args();before=json.loads(a.before.read_text());base=json.loads(a.base.read_text()) + study=json.loads(a.study.read_text());access=json.loads(a.access.read_text());desired={} + old={(b['x'],b['y'],b['z']):b['block'] for b in base['blocks']} + def ground(x,z): + i=(z-before['min_z'])*before['width']+x-before['min_x'] + return before['surface_y'][i],before['palette'][before['material_index'][i]] + def put(x,z,block,group,y=None): + x,z=round(x),round(z);h,material=ground(x,z);y=max(h,51) if y is None else y + if y= 96 and (old != AIR or (x, z) in roads): + raise ValueError(f'Furniture touches existing decoration or an approach at {at}') + if y == 95 and not full(old): + raise ValueError(f'Inlay would change a stair or unsupported floor at {at}') + details[at] = 'minecraft:'+state + groups[at] = group + + urns = [(-10, 35), (10, 35)] + for cx, cz in urns: + decorate(cx, 96, cz, 'cut_sandstone', 'urn-pedestals') + for dx, dz in N: + decorate(cx+dx, 96, cz+dz, 'smooth_sandstone_slab[type=bottom,waterlogged=false]', 'urn-plinths') + for dx in range(-1, 2): + for dz in range(-1, 2): + if dx == dz == 0: + bowl = 'dirt' + elif dx and dz: + bowl = 'cut_sandstone' + else: + facing = 'east' if dx == 1 else 'west' if dx == -1 else 'south' if dz == 1 else 'north' + bowl = f'smooth_sandstone_stairs[facing={facing},half=bottom,shape=straight,waterlogged=false]' + decorate(cx+dx, 97, cz+dz, bowl, 'urn-bowls') + decorate(cx, 98, cz, 'white_tulip', 'urn-flowers') + # A low freestanding enamel nameboard; its text display is a separate receipt. + decorate(-8, 96, 43, 'cut_sandstone', 'welcome-pedestal') + for x in range(-9, -6): + decorate(x, 97, 43, 'green_concrete', 'welcome-nameboard') + decorate(x, 98, 43, 'waxed_oxidized_cut_copper_slab[type=bottom,waterlogged=false]', 'welcome-coping') + thresholds = { + 'north': [(x, z) for x in range(-4, 5) for z in (-31, -30)], + 'south': [(x, z) for x in range(-4, 5) for z in (50, 51)], + 'east': [(x, z) for x in (38, 39) for z in range(13, 20)], + 'west': [(x, z) for x in (-40, -39) for z in range(16, 21)], + 'northwest': [(x, z) for x in range(-36, -31) for z in range(-16, -9) + if (x, z) in roads and x-z in (-21, -20)], + } + for name, columns in thresholds.items(): + for x, z in columns: + decorate(x, 95, z, 'smooth_stone', 'flush-threshold-'+name) + # Restrained green/brass corner accents stay at the exact paving height. + for x, z in (min(columns), max(columns)): + decorate(x, 95, z, 'waxed_oxidized_cut_copper', 'threshold-copper-insets') + + # Solid joint piers stop a partial-shaped road rail from carrying a folded + # membrane through a whole garden-side balustrade and its connected plants. + for x, z in ((-42, 22), (41, 11), (42, 20)): + at = x, 96, z + if not before.state(*at).startswith('minecraft:stone_brick_wall['): + raise ValueError(f'Joint pier baseline changed at {at}') + details[at], groups[at] = 'minecraft:cut_sandstone', 'finished-road-joint-piers' + + # Allow the complete plaza, its balustrade and short level approach aprons. + near = {(x+dx, z+dz) for x, z in outer for dx in range(-2, 3) for dz in range(-2, 3)} + interior = outer | {tuple(p) for p in rails['fence_columns']} + interior |= {(c['x'], c['z']) for c in geo['cells'] if c['kind'] == 'full' and c['block_y'] == 95 + and (c['x'], c['z']) in near} + # Fold the six-face membrane inward around partial collision shapes instead + # of replacing visible rails, stair treads, plants or fixture overhangs. + n6 = ((1, 0, 0), (-1, 0, 0), (0, 1, 0), (0, -1, 0), (0, 0, 1), (0, 0, -1)) + original_volume = {(x, y, z) for x, z in interior for y in range(96, 116)} + volume = set(original_volume) + cache = {} + def observed(p): + if p not in cache: + cache[p] = details.get(p, before.state(*p)) + return cache[p] + for _ in range(128): + membrane = {(x+dx, y+dy, z+dz) for x, y, z in volume for dx, dy, dz in n6} - volume + partial = {p for p in membrane if observed(p) != AIR and not full(observed(p))} + if not partial: + break + remove = {(x+dx, y+dy, z+dz) for x, y, z in partial for dx, dy, dz in n6} & volume + if not remove: + raise ValueError('Partial-shape boundary cannot be sealed without editing decoration') + volume -= remove + else: + raise ValueError('Boundary folding did not converge') + if (0, 96, 43) not in volume: + raise ValueError('Spawn is outside the finished enclosure') + shell = {(x, z) for x, y, z in membrane if 96 <= y < 116} + floor = {p for p in membrane if p[1] == 95} + roof = {p for p in membrane if p[1] == 116} + walls = membrane - floor - roof + barriers, barrier_groups = {}, {} + for group, positions in [('invisible-side-wall', walls), ('invisible-floor-gap', floor), ('invisible-roof', roof)]: + for at in sorted(positions): + state = details.get(at, before.state(*at)) + if full(state): + continue + if state != AIR: + raise ValueError(f'Enclosure would erase a visible/partial block at {at}: {state}') + barriers[at], barrier_groups[at] = BARRIER, group + if details.keys() & barriers.keys(): + raise ValueError('Decoration and containment recipes overlap') + def recipe(states, labels): + return {'version': 1, 'scope': before.scope, 'blocks': [dict(zip(('x', 'y', 'z'), p)) | + {'block': state, 'expected': before.state(*p), 'group': labels[p]} + for p, state in sorted(states.items()) if state != before.state(*p)]} + finish, envelope = recipe(details, groups), recipe(barriers, barrier_groups) + combined = {'version': 1, 'scope': before.scope, 'blocks': finish['blocks']+envelope['blocks']} + metadata = {'version': 1, 'scope': before.scope, 'interior_columns': sorted(interior), + 'shell_columns': sorted(shell), 'floor_y': 95, 'roof_y': 116, + 'source': {'x': .5, 'y': 96, 'z': 43.5}, + 'interior_excluded_voxels': sorted(original_volume-volume), + 'decoration': {'urn_centers': urns, 'welcome_plinth': [-8, 43], 'thresholds': thresholds}, + 'barrier_groups': dict(Counter(b['group'] for b in envelope['blocks'])), + 'detail_groups': dict(Counter(b['group'] for b in finish['blocks'])), + 'scope_note': 'Static collision enclosure for normal players; spectator and operator teleport/break commands bypass it.'} + return finish, envelope, combined, metadata + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--before', type=Path, default=STAGE / 'before.json.gz') + parser.add_argument('--output-dir', type=Path, default=STAGE) + args = parser.parse_args() + result = compile_plans(survey.load_snapshot(args.before)) + args.output_dir.mkdir(parents=True, exist_ok=True) + for name, value in zip(('finishing.json', 'barriers.json', 'combined.json', 'zone-boundary.metadata.json'), result): + (args.output_dir/name).write_text(json.dumps(value, separators=(',', ':'))+'\n') + print(json.dumps({'details': len(result[0]['blocks']), 'barriers': len(result[1]['blocks']), + 'interior_columns': len(result[3]['interior_columns']), 'shell_columns': len(result[3]['shell_columns']), + 'groups': result[3]['barrier_groups']})) + + +if __name__ == '__main__': + main() diff --git a/scripts/foundation-study/design.py b/scripts/foundation-study/design.py new file mode 100644 index 0000000..9506973 --- /dev/null +++ b/scripts/foundation-study/design.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Read-only elevation study for the first two Shacraft construction districts. + +Writes a reviewable design specification and sections. Does not issue world edits. +Run with .runtime/terrain-study/venv/bin/python scripts/foundation-study/design.py. +Y convention: floor_y is the block coordinate; full-block walking height is Y + 1. +""" +import json +from pathlib import Path +import numpy as np +import matplotlib +matplotlib.use('Agg') +import matplotlib.pyplot as plt +from matplotlib.path import Path as Polygon +from matplotlib.colors import LightSource + +ROOT=Path(__file__).resolve().parents[2] +OUT=ROOT/'.runtime/foundation-study/design' +OUT.mkdir(parents=True,exist_ok=True) +layout=json.loads((ROOT/'examples/layout/shacraft-lobby-layout.json').read_text()) +actual=json.loads((ROOT/'.runtime/server/plugins/ShacraftTerrain/maps/layout-final.json').read_text()) +natural=np.floor(np.fromfile(ROOT/'.runtime/terrain-study/natural.f32',dtype='>f4').reshape(768,768)).astype(int) +live_heights=np.asarray(actual['surface_y']).reshape(768,768) +live_materials=np.asarray(actual['material_index']).reshape(768,768) +zz,xx=np.mgrid[-384:384,-384:384] +points=np.column_stack([xx.ravel(),zz.ravel()]) + +def feature(fid): return next(f for f in layout['features'] if f['id']==fid) +def route(fid): return next(f for f in layout['routes'] if f['id']==fid) +def polygon_mask(vertices):return Polygon(vertices).contains_points(points,radius=.1).reshape(natural.shape) +def metrics(mask,y): + h=natural[mask] + deltas,counts=np.unique(live_heights[mask]-h,return_counts=True) + actual_delta_counts={str(int(k)):int(v) for k,v in zip(deltas,counts)} + return {'columns':int(mask.sum()),'live_surface_delta_from_natural_counts':actual_delta_counts,'natural_min_y':int(h.min()),'natural_max_y':int(h.max()),'cut_above_floor_blocks':int(np.maximum(h-y,0).sum()),'fill_above_natural_blocks':int(np.maximum(y-h,0).sum()),'maximum_cut':int(max(0,(h-y).max())),'maximum_fill':int(max(0,(y-h).max()))} + +surfaces=[] +for fid,y in [('arrival-hex',95),('station-forecourt',95),('clock-station',98)]: + f=feature(fid); mask=polygon_mask(f['points']) + surfaces.append({'id':fid,'district':f['district'],'geometry':'polygon','points':f['points'],'floor_y':y,'walk_y':y+1,'support':'Solid to existing ground inside the exact footprint; two-deep structural deck in cut areas. Exterior dark stone masonry, light stone coping, no broad earth platform.','metrics':metrics(mask,y)}) + +# Each schedule is expressed in the increasing travel coordinate d, irrespective of +# whether the selected world axis increases or decreases along travel. A stair +# replaces the current high-level full block; its back points toward the higher +# previous row. The next row is one full block lower. This guarantees 0.5 steps. +def descent(fid,axis,sign,start,end,y,stairs,final_y,width,waypoints): + rows=[]; level=y + for a in range(start*sign,end*sign+1): + c=a*sign + is_stair=c in stairs + facing=('north' if axis=='z' else ('west' if sign==1 else 'east')) + row={'coordinate':c,'block_y':level,'kind':'stairs' if is_stair else 'full','walk_high_y':level+1,'walk_low_y':level+.5 if is_stair else level+1} + if is_stair:row['facing']=facing + rows.append(row) + if is_stair:level-=1 + assert level==final_y,(fid,level,final_y) + # Walking from higher to lower: flat->stair upper edge is same elevation; + # stair upper->lower edge is 0.5, lower->next flat is another 0.5. + for a,b in zip(rows,rows[1:]):assert abs(a['walk_low_y']-b['walk_high_y'])<=.5 + return {'id':fid,'geometry':'road_profile','axis':axis,'travel_sign':sign,'width':width,'width_note':'Clear paving width, with one additional coping block outside either edge; use the existing marked centerline tangent.','waypoints':waypoints,'start_floor_y':y,'end_floor_y':final_y,'rows':rows,'max_walk_step':.5,'scope':'Only this local part is built in stage 01; preserve all later district markers.'} + +roads=[ + {'id':'station-axis','geometry':'road_flat','width':9,'floor_y':95,'walk_y':96,'waypoints':route('station-axis')['waypoints'],'profile_until_z':-77,'intent':'Continuous flat link into the forecourt; ornamental edge strips outside nine clear blocks.'}, + descent('south-axis-local','z',1,57,110,95,[58,61,64,67,70,73,76,79,82,86,90,94,98,101,104,107],79,9,[[0,57],[3,97],[1,110]]), + descent('portal-radial-local','x',-1,-35,-72,95,[-42,-47,-52,-58,-64,-71],89,7,[[-35,-14],[-67,-43],[-72,-46]]), + descent('lake-radial-local','x',-1,-42,-90,95,[-44,-47,-50,-55,-64,-72,-78,-83,-85,-87,-89,-90],83,5,[[-42,18],[-77,27],[-90,32]]), + descent('east-radial-local','x',1,42,82,95,[43,47,51,55,60,66,72,78],87,7,[[42,16],[65,10],[82,8]]), + descent('ring-northwest-local','x',-1,-40,-80,95,[-42,-45,-49,-53,-57,-61,-65,-69,-73,-77],85,7,[[-40,-65],[-77,-76],[-80,-77]]), + descent('ring-northeast-local','x',1,40,82,98,[41,44,47,50,54,58,62,66,70,74,78,81],86,7,[[40,-95],[60,-91],[82,-78]]) +] +# Endpoint toes use actual captured surface elevations. At the lake the last +# in-bounds column is a stair: a full block here would leave a full-block drop to +# the next native row, and editing that row would exceed the stage's X=-90 bound. +endpoint_toes=[] +for rid,end in [('portal-radial-local',(-72,-46)),('lake-radial-local',(-90,32))]: + r=next(item for item in roads if item['id']==rid) + last=r['rows'][-1] + native=[] + for z in range(end[1]-1,end[1]+2): + x=end[0]-1 + native.append({'x':x,'z':z,'natural_block_y':int(natural[z+384,x+384]),'live_surface_y':int(live_heights[z+384,x+384]),'step_from_road':abs(last['walk_low_y']-(int(live_heights[z+384,x+384])+1))}) + assert all(n['step_from_road']<=.5 for n in native),(rid,native) + r['endpoint_kind']=last['kind'] + r['endpoint_block_y']=last['block_y'] + r['endpoint_walk_low_y']=last['walk_low_y'] + r['native_toe']=native + endpoint_toes.append({'id':rid,'last_road_column':list(end),'last_road_kind':last['kind'],'last_road_block_y':last['block_y'],'native_front_three':native}) +stairs=[ + {'z':-77,'block_y':95,'kind':'full'}, + {'z':-78,'block_y':96,'kind':'stairs','facing':'north'}, + {'z':-79,'block_y':96,'kind':'full'}, + {'z':-80,'block_y':97,'kind':'stairs','facing':'north'}, + {'z':-81,'block_y':97,'kind':'full'}, + {'z':-82,'block_y':98,'kind':'stairs','facing':'north'}, + {'z':-83,'block_y':98,'kind':'full'}] +plan={ + 'schema':'shacraft-foundation-elevation-design-v1', + 'status':'DESIGN ONLY; root builder must compare actual voxels before applying and verify final live world.', + 'world':actual['world'],'source_capture_finished_at':actual['capture_finished_at'], + 'y_convention':'floor_y/block_y is the Minecraft block coordinate. A full block at Y95 is walked on at Y96. A bottom stair at Y96 spans feet heights96.5..97.', + 'selected_districts':['01','02'], + 'surfaces':surfaces,'roads':roads,'verified_native_endpoint_toes':endpoint_toes, + 'station_entrance_stair':{'x_min':-16,'x_max':4,'width':21,'travel_direction':'north','rows':stairs,'max_walk_step':.5}, + 'station_ne_connection':{'geometry':'polygon','points':[[35,-103],[42,-103],[44,-95],[42,-91],[35,-94],[35,-103]],'floor_y':98,'intent':'Small local upper landing joins east wing to the northeast descending road. Keep inside this polygon; no platform around the whole station.'}, + 'plaza_inlay':{'center':[0,9],'outer_radius':16,'floor_y':95,'intent':'Flat green hexagonal ring with cream Shacraft S inlay. Reserve the core for later fountain/arrival feature if desired; never raise the inlay above paving.'}, + 'masonry':{'foundation_core':'minecraft:stone','dark_footing':'minecraft:deepslate_bricks','wall':'minecraft:stone_bricks','secondary_wall':'minecraft:andesite','light_coping':'minecraft:smooth_sandstone','paving':'minecraft:smooth_sandstone','paving_bands':'minecraft:smooth_stone','accent':'minecraft:green_concrete','notes':['Do not fill the surrounding rectangle. Shape all exposed plinth walls to the exact existing footprint.','The west station wing has up to16 blocks of foundation. Use vertical pilasters every8 blocks and recessed blind arch panels; one plain flat wall would look excessive.','Leave district02 building floor usable and flat. Footing lines can indicate tower and pavilions; no tall unfinished walls across doors.','Put parapets only on exposed drops, outside the clear walking width; leave all route openings unblocked.','Finish side road ends with a full-width landing; east87/northeast86 match the existing future bridge levels, so keep the boundary to those markers precise.']}, + 'quality_checks':['Actual block-for-block scan of finished footprint and road surfaces.','Cardinal-direction walking graph from spawn to station floor and every road endpoint, including stair orientation and at least2 air blocks headroom.','Every descent has half-block treads; never create a full block jump as a path transition.','Foundation columns extend down to solid existing ground; no hidden unsupported floating perimeter.','No new block in a water column; no edit outside foundation/road footprints except explicitly approved local rail, light and footing cells.','Check road width across diagonal curves; evaluate the union of row footprints rather than nearest centerline points alone.','Remove obsolete colored markers and text only inside the stage01 mutation envelope; preserve all other district reservations.','Compare orthographic live surface map with plan and inspect actual west station foundation + north axis from player height.'], + 'endpoint_join_notes':['South road ends at z110/block79 near actual ground78..80; a few local grading cells or one final stair row may be required after live voxel inspection.','Portal endpoint(-72,-46) fullY89 joins native89. Lake endpoint(-90,32) uses terminal east-facing stairY84, joining native83 beyond x-90 across the center3 cells with half-block steps; never replace this terminal stair with a full-block landing.','Northeast road must originate at the station upper landing98, not the forecourt95.','The existing camera is spectator; physical walking correctness still requires geometric collision checks.'] +} +(OUT/'foundation-design.json').write_text(json.dumps(plan,indent=2)+'\n') + +# Inspectable plan/sections are computed from immutable natural heights. +# Live surface deltas are measured separately in the JSON metrics above. +fig=plt.figure(figsize=(15,11),layout='constrained') +gs=fig.add_gridspec(2,2,width_ratios=[1.22,1],height_ratios=[1,1]) +ax=fig.add_subplot(gs[:,0]); extent=(-110,105,125,-165) +h=natural[219:510,274:490] +shade=LightSource(315,40).hillshade(h,vert_exag=2,dx=1,dy=1) +ax.imshow(h,cmap='gist_earth',extent=extent,alpha=.8,vmin=48,vmax=150) +ax.imshow(shade,cmap='gray',extent=extent,alpha=.2) +colors=['#f2dfbd','#ddd2b6','#f0c75b'] +for s,c in zip(surfaces,colors): + pp=np.array(s['points']); ax.fill(pp[:,0],pp[:,1],c,alpha=.8);ax.plot(pp[:,0],pp[:,1],color='#263e39',lw=1.2) + cc=pp.mean(axis=0);ax.text(cc[0],cc[1],s['id'].replace('-',' ')+'\nblock Y'+str(s['floor_y']),ha='center',va='center',fontsize=9,bbox=dict(facecolor='white',alpha=.8,edgecolor='none')) +for r in roads: + p=np.array(r['waypoints']);ax.plot(p[:,0],p[:,1],color='#ede3cf',lw=r['width']*.55,solid_capstyle='round');ax.plot(p[:,0],p[:,1],color='#485b53',lw=.6) + if 'end_floor_y' in r:ax.text(p[-1,0],p[-1,1],'Y'+str(r.get('endpoint_block_y',r['end_floor_y']))+(' stair' if r.get('endpoint_kind')=='stairs' else ''),fontsize=8,ha='center',va='bottom',bbox=dict(facecolor='white',alpha=.85,edgecolor='none')) +ax.set(xlim=(-105,100),ylim=(125,-165),xlabel='X — east →',ylabel='Z — south →',title='Shacraft · stage 01 foundations and local streets') +ax.set_aspect('equal');ax.grid(alpha=.15) +ax2=fig.add_subplot(gs[0,1]);xs=np.arange(-80,47); zs=-120*np.ones_like(xs); ground=natural[zs+384,xs+384] +ax2.fill_between(xs,ground,70,color='#718356',alpha=.65,label='Existing natural ground'); ax2.plot(xs,ground,color='#354d2c',lw=1) +inside=(xs>=-74)&(xs<=40);ax2.plot(xs[inside],np.full(sum(inside),99),color='#ae7731',lw=2,label='Station walking plane Y99');ax2.fill_between(xs[inside],ground[inside],99,color='#c4bda9',alpha=.6,label='Supported station plinth') +ax2.set(xlabel='X across station at Z−120',ylabel='Y elevation',ylim=(78,103),title='Station: one continuous floor; articulated west retaining base');ax2.legend(fontsize=8,loc='lower right');ax2.grid(alpha=.2) +ax3=fig.add_subplot(gs[1,1]);zvals=np.arange(-100,111);xvals=np.where(zvals<0,-6,0);ground=natural[zvals+384,xvals+384];ax3.fill_between(zvals,ground,65,color='#718356',alpha=.65) +walk=np.full(len(zvals),96.);walk[zvals<=-83]=99 +for row in stairs: + k=np.where(zvals==row['z'])[0][0];walk[k]=row['block_y']+(0.75 if row['kind']=='stairs' else 1) +for row in roads[1]['rows']: + k=np.where(zvals==row['coordinate'])[0][0];walk[k]=(row['walk_low_y']+row['walk_high_y'])/2 +ax3.plot(zvals,walk,color='#a47230',lw=2,label='Planned walking surface');ax3.plot(zvals,ground,color='#354d2c',lw=1,label='Natural centerline') +ax3.set(xlabel='Z along north/south arrival route',ylabel='Y elevation',ylim=(74,103),title='Continuous arrival sequence: station → forecourt → square → approach');ax3.grid(alpha=.2);ax3.legend(fontsize=8) +fig.savefig(OUT/'foundation-plan-and-sections.png',dpi=170) +print(json.dumps({'plan':str(OUT/'foundation-design.json'),'figure':str(OUT/'foundation-plan-and-sections.png'),'surfaces':[{k:v for k,v in s.items() if k in ['id','floor_y','metrics']} for s in surfaces]},indent=2)) diff --git a/scripts/foundation-study/geometry.py b/scripts/foundation-study/geometry.py new file mode 100644 index 0000000..e96d026 --- /dev/null +++ b/scripts/foundation-study/geometry.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +"""Rasterize the reviewed Shacraft stage-one design without editing the world. + +The public build_geometry(design, layout) function uses only the Python standard +library. Coordinate keys are (x, z). Each cell has block_y, kind, group and clear; +straight bottom stairs additionally have facing. block_y is not player feet Y. + +Precedence: exact foundation polygons, northeast landing, local roads, central +station staircase. Existing dense approved centerlines are used for road curves. +Roads extend a few flat rows back into their origin plaza so an avenue cannot +pinch down to the one-block vertex of the hexagonal square. +""" +from __future__ import annotations + +import argparse +import json +import math +from pathlib import Path +from typing import Any + +Point = tuple[int, int] +Cell = dict[str, Any] + + +def _on_segment(x: int, z: int, a, b) -> bool: + cross = (x-a[0])*(b[1]-a[1]) - (z-a[1])*(b[0]-a[0]) + return abs(cross) < 1e-8 and min(a[0],b[0]) <= x <= max(a[0],b[0]) and min(a[1],b[1]) <= z <= max(a[1],b[1]) + + +def polygon_cells(vertices) -> set[Point]: + """Integer block columns inside OR on the exact polygon, with no box fill.""" + vertices = [tuple(p) for p in vertices] + if len(vertices) < 3: + raise ValueError('A foundation polygon needs three vertices') + segments = list(zip(vertices, vertices[1:]+vertices[:1])) + result: set[Point] = set() + for z in range(math.floor(min(p[1] for p in vertices)), math.ceil(max(p[1] for p in vertices))+1): + for x in range(math.floor(min(p[0] for p in vertices)), math.ceil(max(p[0] for p in vertices))+1): + inside = False + boundary = False + for a,b in segments: + if _on_segment(x,z,a,b): + boundary = True + break + if (a[1] > z) != (b[1] > z): + cross_x = a[0] + (z-a[1])*(b[0]-a[0])/(b[1]-a[1]) + if x < cross_x: + inside = not inside + if boundary or inside: + result.add((x,z)) + return result + + +def _distance_squared(x: int, z: int, a, b) -> float: + dx,dz=b[0]-a[0],b[1]-a[1] + if not dx and not dz: + return (x-a[0])**2+(z-a[1])**2 + t=max(0.,min(1.,((x-a[0])*dx+(z-a[1])*dz)/(dx*dx+dz*dz))) + return (x-a[0]-t*dx)**2+(z-a[1]-t*dz)**2 + + +def _densify(points) -> list[Point]: + result=[] + for a,b in zip(points,points[1:]): + n=max(1,math.ceil(max(abs(a[0]-b[0]),abs(a[1]-b[1])))) + for i in range(n+1): + p=(round(a[0]+(b[0]-a[0])*i/n),round(a[1]+(b[1]-a[1])*i/n)) + if not result or result[-1] != p: + result.append(p) + if not result and points: + result=[tuple(map(round,points[0]))] + return result + + +def _cardinal_path(points: list[Point]) -> list[Point]: + """Insert cardinal intermediate samples for collision checks on diagonals.""" + if not points: + return [] + result=[points[0]] + for x,z in points[1:]: + ax,az=result[-1] + while (ax,az)!=(x,z): + if ax!=x: + ax += 1 if x>ax else -1 + else: + az += 1 if z>az else -1 + result.append((ax,az)) + return result + + +def _approved_centerline(spec, layout) -> list[Point]: + base_id=spec['id'].removesuffix('-local') + approved=next((r for r in layout.get('routes',[]) if r['id']==base_id),None) + points=_densify(approved['points'] if approved else spec['waypoints']) + if spec['geometry']=='road_profile': + axis=0 if spec['axis']=='x' else 1 + lo=min(row['coordinate'] for row in spec['rows']) + hi=max(row['coordinate'] for row in spec['rows']) + points=[p for p in points if lo <= p[axis] <= hi] + # If the approved path terminates just before a reviewed local endpoint, + # add the small explicit tail without replacing its established curve. + expected=tuple(spec['waypoints'][-1]) + if points and points[-1][axis] != expected[axis]: + points += _densify([points[-1],expected])[1:] + elif 'profile_until_z' in spec: + stop=spec['profile_until_z'] + points=[p for p in points if p[1]>=stop] + if len(points)<2: + raise ValueError(f"No usable centerline for {spec['id']}") + return points + + +def _origin_extension(points: list[Point], spec) -> list[Point]: + """Overlap the origin plateau without changing its level or approved curve.""" + first=points[0] + distance=max(4,spec['width']//2+2) + target=points[min(len(points)-1,distance)] + dx,dz=target[0]-first[0],target[1]-first[1] + length=math.hypot(dx,dz) + if not length: + return points + back=(round(first[0]-dx*distance/length),round(first[1]-dz*distance/length)) + return _densify([back,first])[:-1]+points + + +def _corridor(points: list[Point], width: int, axis=None, lo=None, hi=None): + """Clear corridor plus one block of side coping, evaluated by cell centers.""" + outer=width/2+1 + clear_radius2=(width/2)**2 + outer_radius2=outer**2 + distances: dict[Point,float] = {} + for a,b in zip(points,points[1:]): + for z in range(math.floor(min(a[1],b[1])-outer),math.ceil(max(a[1],b[1])+outer)+1): + for x in range(math.floor(min(a[0],b[0])-outer),math.ceil(max(a[0],b[0])+outer)+1): + if axis is not None and not lo <= (x,z)[axis] <= hi: + continue + d=_distance_squared(x,z,a,b) + if d <= outer_radius2+1e-8 and d < distances.get((x,z),math.inf): + distances[(x,z)]=d + return {p:d<=clear_radius2+1e-8 for p,d in distances.items()} + + +def tread_height(cell: Cell, local_x: float, local_z: float) -> float: + """Collision surface of a full block or a straight bottom stair at an offset. + + Use .25/.75 offsets to inspect both tread halves, avoiding the central edge. + """ + if cell['kind']=='full': + return cell['block_y']+1 + high={'north':local_z<.5,'south':local_z>.5, + 'west':local_x<.5,'east':local_x>.5}[cell['facing']] + return cell['block_y']+(1. if high else .5) + + +def build_geometry(design, layout): + """Return cells, route metadata and compact invariant checks. + + cells[(x,z)] -> {block_y:int,kind:'full'|'stairs',facing?:str, + group:str,clear:bool} + routes[] -> {id,centerline,centerline_4,corridor,clear_cells,clear_width} + + `clear` distinguishes usable road paving from exterior coping; for a main + polygon every floor cell is clear. It does not mean the world has been cleared. + """ + cells: dict[Point,Cell] = {} + routes=[] + polygon_areas={} + for surface in design['surfaces']: + footprint=polygon_cells(surface['points']) + polygon_areas[surface['id']]=len(footprint) + for p in footprint: + cells[p]={'block_y':surface['floor_y'],'kind':'full','group':surface['id'],'clear':True} + landing=design.get('station_ne_connection') + if landing: + for p in polygon_cells(landing['points']): + cells[p]={'block_y':landing['floor_y'],'kind':'full','group':'station-ne-connection','clear':True} + for spec in design['roads']: + centerline=_approved_centerline(spec,layout) + extended=_origin_extension(centerline,spec) + profiled=spec['geometry']=='road_profile' + if profiled: + axis=0 if spec['axis']=='x' else 1 + profiles={row['coordinate']:row for row in spec['rows']} + # Extend only the origin; stop exactly at the designed last road row. + endpoint=centerline[-1][axis] + origin=extended[0][axis] + lo,hi=sorted([endpoint,origin]) + corridor=_corridor(extended,spec['width'],axis,lo,hi) + first=spec['rows'][0] + else: + corridor=_corridor(extended,spec['width']) + # The last flat avenue rows must not cover the station staircase. + if 'profile_until_z' in spec: + corridor={p:clear for p,clear in corridor.items() if p[1]>=spec['profile_until_z']} + for p,clear in corridor.items(): + if profiled: + row=profiles.get(p[axis]) + if row is None: + row={'block_y':first['block_y'],'kind':'full'} + cell={'block_y':row['block_y'],'kind':row['kind'],'group':spec['id'],'clear':clear} + if row['kind']=='stairs': + cell['facing']=row['facing'] + else: + cell={'block_y':spec['floor_y'],'kind':'full','group':spec['id'],'clear':clear} + # A coping line within an already level clear plaza is a floor band, + # not an obstacle or a place for a parapet. Preserve that distinction. + previous=cells.get(p) + if previous and previous['clear'] and previous['block_y']==cell['block_y'] and previous['kind']==cell['kind']=='full': + cell['clear']=True + cells[p]=cell + routes.append({'id':spec['id'],'centerline':centerline,'centerline_4':_cardinal_path(centerline),'corridor':sorted(corridor),'clear_cells':sorted(p for p,c in corridor.items() if c),'clear_width':spec['width']}) + stair=design['station_entrance_stair'] + stair_cells=[] + stair_clear=[] + for row in stair['rows']: + for x in range(stair['x_min']-1,stair['x_max']+2): + p=(x,row['z']) + clear=stair['x_min']<=x<=stair['x_max'] + cells[p]={'block_y':row['block_y'],'kind':row['kind'],'group':'station-entrance-stair','clear':clear} + if row['kind']=='stairs': + cells[p]['facing']=row['facing'] + stair_cells.append(p) + if clear: + stair_clear.append(p) + mid=(stair['x_min']+stair['x_max'])//2 + line=[(mid,row['z']) for row in stair['rows']] + routes.append({'id':'station-entrance-stair','centerline':line,'centerline_4':_cardinal_path(line),'corridor':stair_cells,'clear_cells':stair_clear,'clear_width':stair['width']}) + missing=[] + blocked=[] + for route in routes: + for p in route['centerline_4']: + if p not in cells: + missing.append((route['id'],p)) + elif not cells[p]['clear']: + blocked.append((route['id'],p)) + if missing or blocked: + raise ValueError(f'Road centerline incomplete: missing={missing[:8]}, non-clear={blocked[:8]}') + max_step=0. + for route in routes: + path=route['centerline_4'] + route_step=0. + for p,q in zip(path,path[1:]): + dx,dz=q[0]-p[0],q[1]-p[1] + departure=tread_height(cells[p],.5+dx*.25,.5+dz*.25) + arrival=tread_height(cells[q],.5-dx*.25,.5-dz*.25) + step=abs(departure-arrival) + if step>.5: + raise ValueError(f"Unwalkable centerline in {route['id']}: {p}->{q}, {step} blocks") + route_step=max(route_step,step) + route['maximum_centerline_step']=route_step + max_step=max(max_step,route_step) + counts={} + for cell in cells.values(): + counts[cell['group']]=counts.get(cell['group'],0)+1 + return {'cells':cells,'routes':routes,'checks':{'columns':len(cells),'polygon_areas':polygon_areas,'columns_by_final_group':counts,'centerline_missing':len(missing),'centerline_nonclear':len(blocked),'maximum_centerline_step':max_step,'stairs':sum(c['kind']=='stairs' for c in cells.values()),'minimum_block_y':min(c['block_y'] for c in cells.values()),'maximum_block_y':max(c['block_y'] for c in cells.values()),'world_edits':0}} + + +def serializable(geometry): + return {**geometry,'cells':[{'x':x,'z':z,**cell} for (x,z),cell in sorted(geometry['cells'].items(),key=lambda p:(p[0][1],p[0][0]))]} + + +def main(): + root=Path(__file__).resolve().parents[2] + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--design',type=Path,default=root/'.runtime/foundation-study/design/foundation-design.json') + parser.add_argument('--layout',type=Path,default=root/'examples/layout/shacraft-lobby-layout.json') + parser.add_argument('--output',type=Path,default=root/'.runtime/foundation-study/design/geometry.json') + args=parser.parse_args() + geometry=build_geometry(json.loads(args.design.read_text()),json.loads(args.layout.read_text())) + args.output.parent.mkdir(parents=True,exist_ok=True) + args.output.write_text(json.dumps(serializable(geometry),separators=(',',':'))+'\n') + print(json.dumps({'output':str(args.output),'checks':geometry['checks']},indent=2)) + + +if __name__=='__main__': + main() diff --git a/scripts/foundation-study/verify_geometry.py b/scripts/foundation-study/verify_geometry.py new file mode 100644 index 0000000..af43256 --- /dev/null +++ b/scripts/foundation-study/verify_geometry.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +"""Independently audit planned foundation navigation on a half-block surface grid. + +This is geometry QA, not a Minecraft collision simulator or a live block survey. +Only clear cells are traversable. Four quarter-center samples describe each full +block or straight bottom stair; adjacent samples require <= 0.5-block height change. +""" +import argparse +from collections import Counter, deque +import hashlib +import json +from pathlib import Path +import unittest + + +def normalize_cells(document): + result = {} + for cell in document['cells']: + if any(type(cell.get(key)) is not int for key in ('x', 'z', 'block_y')): + raise ValueError('Geometry coordinates and block_y must be integers') + if type(cell.get('clear')) is not bool or cell.get('kind') not in ('full', 'stairs'): + raise ValueError('Geometry needs explicit clear and full/stairs kind') + if cell['kind'] == 'stairs' and cell.get('facing') not in ('north', 'east', 'south', 'west'): + raise ValueError('Stairs need an explicit cardinal facing') + at = (cell['x'], cell['z']) + if at in result: + raise ValueError(f'Duplicate geometry cell {at}') + result[at] = cell + return result + + +def cell_nodes(x, z): + return [(2 * x + i, 2 * z + j) for i in (0, 1) for j in (0, 1)] + + +def height2(cell, sample): + if cell['kind'] == 'full': + return 2 * cell['block_y'] + 2 + ix, iz = sample[0] % 2, sample[1] % 2 + facing = cell['facing'] + high = ((facing == 'north' and iz == 0) or (facing == 'south' and iz == 1) + or (facing == 'west' and ix == 0) or (facing == 'east' and ix == 1)) + return 2 * cell['block_y'] + (2 if high else 1) + + +def surface_graph(cells): + return {node: height2(cell, node) for (x, z), cell in cells.items() if cell['clear'] + for node in cell_nodes(x, z)} + + +def neighbors(node): + x, z = node + return ((x - 1, z), (x + 1, z), (x, z - 1), (x, z + 1)) + + +def reachable(graph, source): + starts = [p for p in cell_nodes(*source) if p in graph] + if len(starts) != 4: + raise ValueError(f'Navigation source {source} is missing or non-clear') + # One source prevents accidentally joining two disconnected halves of a cell. + queue, reached = deque(starts[:1]), {starts[0]: 0} + while queue: + node = queue.popleft() + for other in neighbors(node): + if other in graph and other not in reached and abs(graph[node] - graph[other]) <= 1: + reached[other] = reached[node] + 1 + queue.append(other) + return reached + + +def at_node(node, height=None): + value = {'x': node[0] / 2 + .25, 'z': node[1] / 2 + .25} + if height is not None: + value['standing_y'] = height / 2 + return value + + +def endpoint_report(name, at, graph, reached): + nodes = cell_nodes(*at) + existing = [p for p in nodes if p in graph] + connected = [p for p in nodes if p in reached] + return {'name': name, 'x': at[0], 'z': at[1], 'surface_samples': len(existing), + 'reachable_samples': len(connected), 'passed': len(connected) == 4, + 'shortest_surface_route_blocks': min((reached[p] / 2 for p in connected), default=None)} + + +def route_report(route, cells, graph, reached): + path = [tuple(p) for p in route.get('centerline_4', route['centerline'])] + corridor = {tuple(p) for p in route['corridor']} + clear = {tuple(p) for p in route['clear_cells']} + width = route['clear_width'] + if type(width) is not int or width < 1 or width % 2 != 1: + raise ValueError('This cross-section audit requires positive odd clear_width') + missing_corridor = sorted(corridor - cells.keys()) + nonclear = sorted(p for p in clear if p not in cells or not cells[p]['clear']) + unreachable = sorted(p for p in clear if any(n not in reached for n in cell_nodes(*p))) + jumps, invalid_steps = [], [] + max_boundary_step = 0 + for p, q in zip(path, path[1:]): + dx, dz = q[0] - p[0], q[1] - p[1] + if abs(dx) + abs(dz) != 1: + invalid_steps.append({'from': list(p), 'to': list(q)}) + continue + # Check both parallel quarter-center lanes across the shared block face. + for a in cell_nodes(*p): + b = (a[0] + dx, a[1] + dz) + if (b[0] // 2, b[1] // 2) != q: + continue + if a not in graph or b not in graph: + jumps.append({'from': at_node(a), 'to': at_node(b), 'reason': 'missing_clear_surface'}) + continue + step = abs(graph[a] - graph[b]) / 2 + max_boundary_step = max(max_boundary_step, step) + if step > .5: + jumps.append({'from': at_node(a, graph[a]), 'to': at_node(b, graph[b]), + 'height_change': step, 'reason': 'height_step_exceeds_half_block'}) + cross_sections, narrow = [], [] + for i, p in enumerate(path): + # Use at least one corridor-width of tangent support. Inserted cardinal + # elbows near an axis-clipped endpoint must not rotate the cross-section + # to measure longitudinally past that deliberate terminal boundary. + window = max(6, width) + before, after = path[max(0, i - window)], path[min(len(path) - 1, i + window)] + tx, tz = after[0] - before[0], after[1] - before[1] + normal = (0, 1) if abs(tx) > abs(tz) else (1, 0) + radius = width // 2 + samples = [(p[0] + normal[0] * offset, p[1] + normal[1] * offset) + for offset in range(-radius, radius + 1)] + present = sum(s in cells for s in samples) + usable = sum(s in cells and cells[s]['clear'] for s in samples) + connected = sum(all(n in reached for n in cell_nodes(*s)) for s in samples) + cross_sections.append((present, usable, connected)) + if min(present, usable, connected) < width: + narrow.append({'x': p[0], 'z': p[1], 'normal': list(normal), 'required_width': width, + 'structural_columns': present, 'clear_columns': usable, + 'reachable_columns': connected, + 'problem_columns': [list(s) for s in samples if s not in cells or not cells[s]['clear'] + or any(n not in reached for n in cell_nodes(*s))]}) + ends = [endpoint_report(route['id'] + ':' + name, at, graph, reached) + for name, at in [('start', path[0]), ('end', path[-1])]] + passed = not (missing_corridor or nonclear or unreachable or jumps or invalid_steps or narrow) + return {'id': route['id'], 'passed': passed, 'declared_clear_width': width, + 'corridor_columns': len(corridor), 'missing_corridor_columns': missing_corridor, + 'declared_clear_columns': len(clear), 'nonclear_declared_columns': nonclear, + 'unreachable_declared_columns': unreachable, + 'cross_sections': len(cross_sections), + 'minimum_structural_cross_section': min(row[0] for row in cross_sections), + 'minimum_clear_cross_section': min(row[1] for row in cross_sections), + 'minimum_reachable_cross_section': min(row[2] for row in cross_sections), + 'narrow_cross_sections': narrow, 'centerline_invalid_steps': invalid_steps, + 'centerline_maximum_boundary_step': max_boundary_step, + 'centerline_jumps': jumps, 'endpoints': ends} + + +def audit(document, source=(0, 9), station=(-6, -105)): + cells = normalize_cells(document) + graph = surface_graph(cells) + reached = reachable(graph, source) + unreachable = sorted(p for p, cell in cells.items() if cell['clear'] + and any(n not in reached for n in cell_nodes(*p))) + routes = [route_report(route, cells, graph, reached) for route in document['routes']] + goal = endpoint_report('clock-station', station, graph, reached) + return {'version': 1, 'source': {'x': source[0], 'z': source[1]}, 'world_edits': 0, + 'method': 'Four quarter-center surface samples per clear cell; cardinal half-block BFS, rise/drop <= 0.5 block.', + 'limitations': 'Planned surface topology only. No headroom, body-width collision, material state or live-world verification. Width checks are cardinal cross-sections using the dominant local tangent; full declared masks are also checked.', + 'geometry_sha256': hashlib.sha256(json.dumps(document, sort_keys=True, separators=(',', ':')).encode()).hexdigest(), + 'planned_columns': len(cells), 'clear_columns': sum(c['clear'] for c in cells.values()), + 'surface_samples': len(graph), 'reachable_samples': len(reached), + 'unreachable_clear_columns': [list(p) for p in unreachable], + 'unreachable_by_group': dict(Counter(cells[p].get('group', 'unknown') for p in unreachable)), + 'station': goal, 'routes': routes, + 'passed': not unreachable and goal['passed'] and all(r['passed'] for r in routes)} + + +class GeometryAuditTests(unittest.TestCase): + def test_full_block_jump_separates_components(self): + cells = {(x, 0): {'kind': 'full', 'block_y': 10 if x == 0 else 11, 'clear': True} + for x in range(2)} + graph = surface_graph(cells) + self.assertEqual(len(reachable(graph, (0, 0))), 4) + + def test_stairs_connect_two_levels_for_all_directions(self): + for facing, direction in [('north', (0, -1)), ('south', (0, 1)), ('west', (-1, 0)), ('east', (1, 0))]: + with self.subTest(facing=facing): + dx, dz = direction + cells = {(-dx, -dz): {'kind': 'full', 'block_y': 9, 'clear': True}, + (0, 0): {'kind': 'stairs', 'block_y': 10, 'facing': facing, 'clear': True}, + (dx, dz): {'kind': 'full', 'block_y': 10, 'clear': True}} + graph = surface_graph(cells) + self.assertEqual(len(reachable(graph, (-dx, -dz))), 12) + + def test_nonclear_surface_is_never_traversable(self): + graph = surface_graph({(0, 0): {'kind': 'full', 'block_y': 10, 'clear': False}}) + self.assertFalse(graph) + + def test_missing_width_column_is_reported(self): + cells = {(x, z): {'x': x, 'z': z, 'kind': 'full', 'block_y': 10, 'clear': True} + for x in range(-1, 2) for z in range(3)} + del cells[(-1, 1)] + graph = surface_graph(cells) + route = {'id': 'test', 'centerline': [(0, 0), (0, 1), (0, 2)], + 'corridor': list(cells), 'clear_cells': list(cells), 'clear_width': 3} + report = route_report(route, cells, graph, reachable(graph, (0, 0))) + self.assertFalse(report['passed']) + self.assertEqual(report['minimum_structural_cross_section'], 2) + self.assertEqual(report['narrow_cross_sections'][0]['problem_columns'], [[-1, 1]]) + + +def main(): + root = Path(__file__).resolve().parents[2] + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--geometry', type=Path, default=root / '.runtime/foundation-study/design/geometry.json') + parser.add_argument('--report', type=Path, default=root / '.runtime/foundation-study/design/navigation-qa.json') + parser.add_argument('--source', type=int, nargs=2, default=(0, 9), metavar=('X', 'Z')) + parser.add_argument('--station', type=int, nargs=2, default=(-6, -105), metavar=('X', 'Z')) + parser.add_argument('--self-test', action='store_true') + args = parser.parse_args() + if args.self_test: + result = unittest.TextTestRunner().run(unittest.defaultTestLoader.loadTestsFromTestCase(GeometryAuditTests)) + raise SystemExit(0 if result.wasSuccessful() else 1) + report = audit(json.loads(args.geometry.read_text()), tuple(args.source), tuple(args.station)) + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps({'passed': report['passed'], 'clear_columns': report['clear_columns'], + 'reachable_samples': report['reachable_samples'], 'surface_samples': report['surface_samples'], + 'unreachable_clear_columns': len(report['unreachable_clear_columns']), + 'unreachable_by_group': report['unreachable_by_group'], 'station': report['station'], + 'routes': [{'id': r['id'], 'passed': r['passed'], + 'minimum_clear_width': r['minimum_clear_cross_section'], + 'narrow_sections': len(r['narrow_cross_sections']), + 'centerline_jumps': len(r['centerline_jumps']), + 'unreachable_columns': len(r['unreachable_declared_columns'])} for r in report['routes']], + 'report': str(args.report.resolve())}, indent=2)) + raise SystemExit(0 if report['passed'] else 1) + + +if __name__ == '__main__': + main() diff --git a/scripts/foundation-survey.py b/scripts/foundation-survey.py new file mode 100644 index 0000000..bf1f711 --- /dev/null +++ b/scripts/foundation-survey.py @@ -0,0 +1,405 @@ +#!/usr/bin/env python3 +"""Read-only, scoped block surveys for checked foundations and half-block path clearance. + +Snapshots contain actual complete block states, never inferred terrain or implicit air. +Reads are sequential and non-atomic: keep edits idle during a survey. Cached reads may +only be reused explicitly with --resume; the original observation times are retained. +""" +import argparse +from collections import defaultdict +from datetime import datetime, timezone +import fcntl +import gzip +import hashlib +import importlib.util +import json +import math +import os +from pathlib import Path +import re +import tempfile + +_spec = importlib.util.spec_from_file_location('foundation_terrain', Path(__file__).with_name('terrain.py')) +terrain = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(terrain) +SCOPE_KEYS = ('project_id', 'world_id', 'world_epoch') +AXES = ('x', 'y', 'z') +STATE = re.compile(r'minecraft:[a-z0-9_]+(?:\[[a-z0-9_=,]+\])?\Z') +MAX_VOXELS = 4_000_000 +AIR = {'minecraft:air', 'minecraft:cave_air', 'minecraft:void_air'} +# Conservative shape set. Unknown blocks never count as air or safe support. +FULL = set(('stone cobblestone mossy_cobblestone stone_bricks mossy_stone_bricks ' + 'cracked_stone_bricks chiseled_stone_bricks smooth_stone granite polished_granite ' + 'diorite polished_diorite andesite polished_andesite deepslate cobbled_deepslate ' + 'polished_deepslate deepslate_bricks deepslate_tiles bricks quartz_block quartz_pillar ' + 'smooth_quartz sandstone cut_sandstone smooth_sandstone red_sandstone terracotta ' + 'glass tinted_glass obsidian dirt grass_block bedrock oak_planks spruce_planks ' + 'birch_planks dark_oak_planks oak_log spruce_log birch_log dark_oak_log ' + 'stripped_oak_log stripped_spruce_log moss_block glowstone gold_block ' + 'waxed_oxidized_cut_copper barrier').split()) +SLABS = {'stone_brick_slab', 'cobblestone_slab', 'oak_slab', 'spruce_slab', 'smooth_stone_slab'} +COLORS = ('white orange magenta light_blue yellow lime pink gray light_gray cyan purple blue brown green red black').split() +FULL.update(color + suffix for color in COLORS for suffix in ('_concrete', '_terracotta', '_stained_glass')) + + +def utc_now(): + return datetime.now(timezone.utc).isoformat() + + +def point(value): + if not isinstance(value, dict) or any(type(value.get(a)) is not int for a in AXES): + raise ValueError('Coordinates must contain integer x, y, z') + if any(not -(2 ** 31) <= value[a] < 2 ** 31 for a in AXES): + raise ValueError('Coordinates must fit signed 32-bit integers') + return {a: value[a] for a in AXES} + + +def scope_of(context): + scope = {k: context.get(k) for k in SCOPE_KEYS} + if any(not isinstance(v, str) or not v for v in scope.values()): + raise ValueError('Project context must identify project, world and epoch') + return scope + + +def volume(box): + return math.prod(box['max'][a] - box['min'][a] + 1 for a in AXES) + + +def box_of(lo, hi): + box = {'min': point(lo), 'max': point(hi)} + if any(box['min'][a] > box['max'][a] for a in AXES): + raise ValueError('Minimum exceeds maximum') + return box + + +def cell_key(box): + return tuple(box['min'][a] // 16 for a in AXES) + + +def box_cells(lo, hi): + box = box_of(lo, hi) + if volume(box) > MAX_VOXELS: + raise ValueError(f'Survey exceeds {MAX_VOXELS:,} voxels; split it explicitly') + cells = [] + for cx in range(lo['x'] // 16, hi['x'] // 16 + 1): + for cy in range(lo['y'] // 16, hi['y'] // 16 + 1): + for cz in range(lo['z'] // 16, hi['z'] // 16 + 1): + origin = dict(zip(AXES, (cx * 16, cy * 16, cz * 16))) + cells.append({'min': {a: max(lo[a], origin[a]) for a in AXES}, + 'max': {a: min(hi[a], origin[a] + 15) for a in AXES}}) + return cells + + +def column_cells(document): + if not isinstance(document, dict) or document.get('version') != 1 or not isinstance(document.get('columns'), list): + raise ValueError('Column plan requires version: 1 and columns: [{x,z,min_y,max_y}]') + if not 1 <= len(document['columns']) <= MAX_VOXELS: + raise ValueError('Column plan is empty or too large') + merged = {} + for column in document['columns']: + if not isinstance(column, dict) or any(type(column.get(k)) is not int for k in ('x', 'z', 'min_y', 'max_y')): + raise ValueError('Every column requires integer x, z, min_y and max_y') + lo = point({'x': column['x'], 'z': column['z'], 'y': column['min_y']}) + hi = point({**lo, 'y': column['max_y']}) + for box in box_cells(lo, hi): + key = cell_key(box) + old = merged.get(key) + merged[key] = box if old is None else { + 'min': {a: min(old['min'][a], box['min'][a]) for a in AXES}, + 'max': {a: max(old['max'][a], box['max'][a]) for a in AXES}} + cells = [merged[key] for key in sorted(merged)] + if sum(volume(b) for b in cells) > MAX_VOXELS: + raise ValueError('Expanded column survey exceeds voxel limit') + return cells + + +def assert_in_scope(cells, context): + region = context.get('region', {}) + area = box_of(region.get('min'), region.get('max')) + for cell in cells: + if any(cell['min'][a] < area['min'][a] or cell['max'][a] > area['max'][a] for a in AXES): + raise ValueError('Requested survey exceeds the selected project area') + + +def index_at(box, x, y, z): + lo, hi = box['min'], box['max'] + if not (lo['x'] <= x <= hi['x'] and lo['y'] <= y <= hi['y'] and lo['z'] <= z <= hi['z']): + raise KeyError(f'No observed block at {(x, y, z)}; air is never inferred') + return ((y - lo['y']) * (hi['z'] - lo['z'] + 1) + z - lo['z']) * (hi['x'] - lo['x'] + 1) + x - lo['x'] + + +def capture_cell(backend, box, epoch): + started = utc_now() + result = backend.call('region_inspect', **box, detail='blocks') + if result.get('world_epoch') != epoch or result.get('truncated') is not False: + raise RuntimeError('Inspection was truncated or returned another world epoch') + palette, lookup = [], {} + indices = [None] * volume(box) + for item in result.get('blocks', []): + pos = point(item.get('pos')) + index = index_at(box, **pos) + if indices[index] is not None: + raise RuntimeError('Inspection returned a duplicate position') + state = item.get('state') + if not isinstance(state, str) or not STATE.fullmatch(state): + raise RuntimeError('Inspection returned an invalid block state') + if state not in lookup: + lookup[state] = len(palette) + palette.append(state) + indices[index] = lookup[state] + if any(i is None for i in indices): + raise RuntimeError('Inspection omitted blocks; missing blocks are not air') + return {**box, 'palette': palette, 'indices': indices, + 'observed_from': started, 'observed_until': utc_now()} + + +def digest(value): + return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest() + + +def save_gzip(path, document): + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + temporary = None + try: + with tempfile.NamedTemporaryFile(prefix=path.name + '.', suffix='.tmp', dir=path.parent, delete=False) as raw: + temporary = Path(raw.name) + with gzip.GzipFile(fileobj=raw, mode='wb', mtime=0) as zipped: + zipped.write(json.dumps(document, separators=(',', ':')).encode()) + raw.flush() + os.fsync(raw.fileno()) + temporary.replace(path) + directory = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(directory) + finally: + os.close(directory) + finally: + if temporary is not None and temporary.exists(): + temporary.unlink() + + +class Snapshot: + def __init__(self, document, expected_scope=None): + if not isinstance(document, dict) or document.get('version') != 1 or document.get('complete') is not True: + raise ValueError('Only complete version-1 snapshots may be used') + self.scope = scope_of(document.get('scope', {})) + if expected_scope is not None and self.scope != expected_scope: + raise ValueError('Snapshot belongs to another project/world/epoch') + self.document, self.cells = document, {} + requested = document.get('requested_cells') + captured = document.get('cells') + if not isinstance(requested, list) or not requested or not isinstance(captured, list) or len(requested) != len(captured): + raise ValueError('Snapshot does not cover every requested cell') + if sum(volume(box_of(b.get('min'), b.get('max'))) for b in requested) > MAX_VOXELS: + raise ValueError('Snapshot exceeds maximum voxel count') + for expected, cell in zip(requested, captured): + box = box_of(cell.get('min'), cell.get('max')) + if box != expected or cell_key(box) != tuple(box['max'][a] // 16 for a in AXES): + raise ValueError('Snapshot cell differs from request or crosses a 16³ cell') + key = cell_key(box) + if key in self.cells: + raise ValueError('Snapshot contains duplicate cells') + palette, indices = cell.get('palette'), cell.get('indices') + if not isinstance(palette, list) or not palette or any(not isinstance(s, str) or not STATE.fullmatch(s) for s in palette): + raise ValueError('Snapshot palette contains invalid states') + if not isinstance(indices, list) or len(indices) != volume(box) or any(type(i) is not int or not 0 <= i < len(palette) for i in indices): + raise ValueError('Snapshot has missing or invalid block indices') + self.cells[key] = cell + + def state(self, x, y, z): + point({'x': x, 'y': y, 'z': z}) + cell = self.cells.get((x // 16, y // 16, z // 16)) + if cell is None: + raise KeyError(f'No observed block at {(x, y, z)}; air is never inferred') + return cell['palette'][cell['indices'][index_at(cell, x, y, z)]] + + def states_for(self, blocks): + return {tuple(b[a] for a in AXES): self.state(**point(b)) for b in blocks} + + +def load_snapshot(path, expected_scope=None): + with gzip.open(path, 'rt') as handle: + return Snapshot(json.load(handle), expected_scope) + + +def scan(backend, cells, path, resume=False, progress=lambda value: None): + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + context = backend.call('project_context') + scope = scope_of(context) + assert_in_scope(cells, context) + cache = path.with_name(path.name + '.parts') + with path.with_name(path.name + '.lock').open('a') as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + if path.exists(): + raise ValueError('Complete snapshot already exists; choose a fresh path for a fresh survey') + if cache.exists() and not resume: + raise ValueError('Survey cache already exists; --resume explicitly reuses older observations') + cache.mkdir(exist_ok=True) + identity = {'version': 1, 'scope': scope, 'requested_cells': cells} + header = cache / 'manifest.json' + if header.exists(): + saved = json.loads(header.read_text()) + if saved.get('identity') != identity or saved.get('digest') != digest(identity): + raise ValueError('Survey cache scope or requested cells differ') + else: + saved = {'identity': identity, 'digest': digest(identity), 'started_at': utc_now()} + terrain.save(header, saved) + captured = [] + for number, box in enumerate(cells): + part = cache / f'{number:06d}.json.gz' + if part.exists(): + with gzip.open(part, 'rt') as handle: + cell = json.load(handle) + # Reject incomplete, reordered, or damaged cached observations before reuse. + Snapshot({'version': 1, 'complete': True, 'scope': scope, + 'requested_cells': [box], 'cells': [cell]}, scope) + else: + cell = capture_cell(backend, box, scope['world_epoch']) + save_gzip(part, cell) + captured.append(cell) + progress({'cells': number + 1, 'total_cells': len(cells)}) + final_context = backend.call('project_context') + if scope_of(final_context) != scope or final_context.get('region') != context.get('region'): + raise RuntimeError('Project scope changed during capture; no complete snapshot written') + document = {**identity, 'complete': True, 'atomic_snapshot': False, + 'started_at': saved['started_at'], 'finished_at': utc_now(), + 'resumed_cache': resume, 'cells': captured, + 'note': 'Sequential observations. Exact states must be checked again atomically when editing.'} + result = Snapshot(document, scope) + save_gzip(path, document) + return result + + +def vertical_shape(state, sub_x=.5, sub_z=.5): + """Occupied Y intervals at a surface sample, or None for unknown geometry. + + A straight stair has two levels. Samples on the riser boundary are ambiguous + and rejected, including the default center, rather than choosing one level. + """ + if state in AIR: + return [] + base, _, properties = state.removeprefix('minecraft:').partition('[') + props = dict(piece.split('=', 1) for piece in properties.rstrip(']').split(',') if '=' in piece) + if base in FULL: + return [(0.0, 1.0)] + if base in SLABS and props.get('waterlogged', 'false') == 'false': + return {'bottom': [(0.0, .5)], 'top': [(.5, 1.0)], 'double': [(0.0, 1.0)]}.get(props.get('type')) + if (base.endswith('_stairs') and props.get('shape') == 'straight' + and props.get('half') == 'bottom' and props.get('waterlogged', 'false') == 'false'): + facing = props.get('facing') + if facing not in ('north', 'south', 'east', 'west'): + return None + offset = sub_z if facing in ('north', 'south') else sub_x + if abs(offset - .5) <= 1e-7: + return None + high = offset < .5 if facing in ('north', 'west') else offset > .5 + return [(0.0, 1.0 if high else .5)] + return None + + +def verify_walkable(snapshot, points): + """Check observed surface samples and their 1.8-block vertical clearance. + + `standing_y` is the feet height, not the supporting block Y. Full blocks and + horizontal slabs support a centered player's full footprint. Explicit sub_x + and sub_z fractions additionally sample straight bottom stairs on each side + of their riser. These are point samples, not a full moving-player collision + simulation. Optional ordered `route` points check physical cardinal steps of + at most one block and rises/drops of at most half a block. + """ + failures, routes = [], defaultdict(list) + for number, p in enumerate(points): + if not isinstance(p, dict) or type(p.get('x')) is not int or type(p.get('z')) is not int: + raise ValueError('Walk points require integer x and z') + feet = p.get('standing_y') + if type(feet) not in (int, float) or not math.isfinite(feet) or feet * 2 != round(feet * 2): + raise ValueError('standing_y must be a finite half-block height') + x, z = p['x'], p['z'] + sub_x, sub_z = p.get('sub_x', .5), p.get('sub_z', .5) + if any(type(value) not in (int, float) or not math.isfinite(value) or not 0 < value < 1 + for value in (sub_x, sub_z)): + raise ValueError('sub_x and sub_z must be finite fractions strictly between 0 and 1') + support_y = math.ceil(feet) - 1 + reason = None + try: + support = vertical_shape(snapshot.state(x, support_y, z), sub_x, sub_z) + if support is None: + reason = 'unknown_support_shape' + elif not any(abs(support_y + top - feet) < 1e-8 for _, top in support): + reason = 'missing_support_at_feet' + if reason is None: + for y in range(math.floor(feet), math.ceil(feet + 1.8)): + occupied = vertical_shape(snapshot.state(x, y, z), sub_x, sub_z) + if occupied is None: + reason = 'unknown_clearance_shape' + break + if any(y + bottom < feet + 1.8 and y + top > feet for bottom, top in occupied): + reason = 'blocked_headroom' + break + except KeyError: + reason = 'unobserved_block' + if reason: + failures.append({'index': number, 'x': x, 'z': z, 'sub_x': sub_x, 'sub_z': sub_z, + 'standing_y': feet, 'reason': reason}) + if 'route' in p: + route = p['route'] + if not isinstance(route, str) or not route: + raise ValueError('route must be a nonempty string') + routes[route].append((number, x + sub_x, z + sub_z, feet)) + edges = 0 + for route, row in routes.items(): + for previous, current in zip(row, row[1:]): + edges += 1 + dx, dz = abs(previous[1] - current[1]), abs(previous[2] - current[2]) + if (dx > 1e-7 and dz > 1e-7) or not 1e-7 < dx + dz <= 1 + 1e-7: + failures.append({'index': current[0], 'route': route, 'reason': 'non_cardinal_route_step'}) + elif abs(previous[3] - current[3]) > .5: + failures.append({'index': current[0], 'route': route, 'reason': 'route_step_exceeds_half_block'}) + return {'checked_points': len(points), 'checked_route_edges': edges, 'failures': failures, + 'passed': not failures, 'scope': snapshot.scope, + 'note': 'Observed surface samples and vertical headroom; full cubes, slabs and explicit straight bottom-stair samples. Stair samples are a surface profile, not a full player-width collision simulation. Cached snapshot is not a live re-read.'} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest='command', required=True) + capture = commands.add_parser('scan', help='Read actual blocks; never edits or loads chunks') + capture.add_argument('--min', type=int, nargs=3, metavar=('X', 'Y', 'Z')) + capture.add_argument('--max', type=int, nargs=3, metavar=('X', 'Y', 'Z')) + capture.add_argument('--columns', type=Path) + capture.add_argument('--out', type=Path, required=True) + capture.add_argument('--resume', action='store_true', help='Explicitly reuse older partial observations') + capture.add_argument('--config', type=Path, default=terrain.ROOT / '.runtime/server/plugins/MinecraftBuilderMCP/config.yml') + verify = commands.add_parser('verify-walk', help='Check surfaces against an observed snapshot') + verify.add_argument('--snapshot', type=Path, required=True) + verify.add_argument('--points', type=Path, required=True, help='JSON {scope:{...},points:[{x,z,standing_y,sub_x?,sub_z?,route?}]}') + verify.add_argument('--report', type=Path, required=True) + args = parser.parse_args() + if args.command == 'scan': + if args.columns: + if args.min or args.max: + parser.error('Use --columns or --min/--max, not both') + cells = column_cells(json.loads(args.columns.read_text())) + else: + if not args.min or not args.max: + parser.error('--min and --max are required without --columns') + cells = box_cells(dict(zip(AXES, args.min)), dict(zip(AXES, args.max))) + result = scan(terrain.Backend(args.config), cells, args.out, args.resume, + progress=lambda p: print(json.dumps(p), flush=True) if p['cells'] % 16 == 0 or p['cells'] == p['total_cells'] else None) + print(json.dumps({'status': 'captured', 'scope': result.scope, 'cells': len(cells), + 'voxels': sum(volume(c) for c in cells), 'path': str(args.out.resolve())})) + else: + document = json.loads(args.points.read_text()) + result = verify_walkable(load_snapshot(args.snapshot, scope_of(document.get('scope', {}))), document['points']) + args.report.parent.mkdir(parents=True, exist_ok=True) + terrain.save(args.report, result) + print(json.dumps({'passed': result['passed'], 'checked_points': result['checked_points'], + 'failures': len(result['failures']), 'report': str(args.report.resolve())})) + if not result['passed']: + raise SystemExit(1) + + +if __name__ == '__main__': + main() diff --git a/scripts/layout-study/README.md b/scripts/layout-study/README.md new file mode 100644 index 0000000..cc8d383 --- /dev/null +++ b/scripts/layout-study/README.md @@ -0,0 +1,19 @@ +# Shacraft layout study + +`plan.py` produces a terrain-aware block marking plan and a review image. It reads the approved, big-endian 768 × 768 float height field at `.runtime/terrain-study/natural.f32`; it does not connect to Minecraft or write world blocks. + +Run with the terrain study's NumPy / Matplotlib environment: + +```sh +.runtime/terrain-study/venv/bin/python scripts/layout-study/plan.py +``` + +Outputs are `.runtime/layout-study/layout.json` and `layout-preview.png`. The image is a computed planning diagram, not an in-game photograph or a live map. Live world state and safe replacement materials must be checked separately before marking. + +The plan retains the Shacraft reference's district order while adapting footprints to the natural valley. A central clock avenue gives spawn a clear destination. The promenade links the station, portals, waterworks, market, gardens and harbor without forcing every trip through spawn. Scenic branches stay subordinate to that main circulation. + +The market uses six separate foundations on the calmer southern shoulder. The portal hall is moved north of the lake edge. All building and plaza footprints in the plan lie on dry ground. No route centerline cuts through a building interior by more than a doorway allowance. This is a geometric check, not a human navigation playtest. + +JSON coordinates are `[x,z]`, with north in negative Z. Feature polylines and route points are already sampled and rounded. Routes also retain their sparse `waypoints`. `role: bridge` routes have a planned `deck_y` and `abutments` with measured ground height and a future stairs flag. The northeastern bridge needs a substantial east landing stair: its deck is Y86 while that bank is approximately Y69. The three harbor piers and flagship reservation also carry explicit design heights. + +Ground contours reserve future buildings; they do not specify large flat platforms. The station and portal hall still span sloped ground, so later architecture should use separate foundations, lower wings and short stairs. The actual terrain, rivers and lake remain the governing geometry. Side trails on mountain shoulders may need stair segments when built. diff --git a/scripts/layout-study/plan.py b/scripts/layout-study/plan.py new file mode 100644 index 0000000..dfc3f93 --- /dev/null +++ b/scripts/layout-study/plan.py @@ -0,0 +1,188 @@ +"""Design-stage contours and circulation derived from Shacraft's approved height field. +No world access or mutation. Coordinates use north=-Z; fields are planning data only. +""" +from pathlib import Path +import json, math +import numpy as np +import matplotlib +matplotlib.use('Agg') +import matplotlib.pyplot as plt +from matplotlib.colors import LightSource +from matplotlib.path import Path as MplPath +ROOT=Path(__file__).resolve().parents[2] +OUT=ROOT/'.runtime/layout-study'; OUT.mkdir(parents=True,exist_ok=True) +h=np.fromfile(ROOT/'.runtime/terrain-study/natural.f32',dtype='>f4').reshape(768,768).astype(float) +features=[];routes=[] +colors={'01':'#84d440','02':'#f4d35e','03':'#bc80ef','04':'#40c8e3','05':'#64d9a0','06':'#ef9863','07':'#f5f4e9','08':'#4ca3ff','09':'#f07eaf'} +districts=[ + ('01','Arrival square',[0,8],'An open hexagonal plaza; preserve the raised grassy crown. Main north axis reveals the clock tower.'), + ('02','Clock station',[-18,-119],'A long station hall with projecting clock tower and two end pavilions. Its forecourt faces spawn.'), + ('03','Portal concourse',[-201,-156],'A chamfered hall with six separate portal-bay footprints and a sunken-feeling garden approach.'), + ('04','Airship harbor',[179,-137],'East-bank terminal with three west-facing piers above the water gorge. One future flagship uses the middle pier.'), + ('05','Sky gardens',[207,88],'Three distinct rounded landmarks connected by winding garden paths: greenhouse, winter garden, observatory.'), + ('06','Market quarter',[-195,225],'Six small footprints follow the slope around a compact square. Use stepped streets and individual foundations.'), + ('07','Arrival viaduct',[8,284],'A long south approach follows the valley ridge; the final viaduct crosses the merging river branches.'), + ('08','Lake waterworks',[-135,43],'A compact pumping house above the east lake shore and a low over-water promenade.'), + ('09','Scenic overlooks',[48,-180],'Small optional lookouts frame the valley, lake and arrival route; no mountain flattening.')] +districts=[dict(id=i,name=n,label=p,color=colors[i],intent=t) for i,n,p,t in districts] + +def closed(points): return points+[points[0]] if points[0]!=points[-1] else points + +def add(i,d,n,p,role='building',**kw): + f=dict(id=i,district=d,name=n,type='polyline' if role in ('detail','promenade','pier','bridge','axis') else 'polygon',points=p,color=colors[d],role=role,**kw) + features.append(f);return f + +def ellipse(cx,cz,rx,rz=None,n=72,angle=0): + rz=rx if rz is None else rz;a=math.radians(angle) + return closed([[round(cx+math.cos(t)*rx*math.cos(a)-math.sin(t)*rz*math.sin(a)),round(cz+math.cos(t)*rx*math.sin(a)+math.sin(t)*rz*math.cos(a))]for t in np.linspace(0,2*math.pi,n,endpoint=False)]) + +def rect(cx,cz,w,d,angle=0): + a=math.radians(angle) + return closed([[round(cx+x*math.cos(a)-z*math.sin(a)),round(cz+x*math.sin(a)+z*math.cos(a))]for x,z in[(-w/2,-d/2),(w/2,-d/2),(w/2,d/2),(-w/2,d/2)]]) + +def smooth(points): + p=np.array([points[0]]+points+[points[-1]],float);out=[] + for k in range(1,len(p)-2): + a,b,c,d=p[k-1:k+3];n=max(2,int(np.linalg.norm(c-b))) + for t in np.linspace(0,1,n,endpoint=False): + q=.5*((2*b)+(-a+c)*t+(2*a-5*b+4*c-d)*t*t+(-a+3*b-3*c+d)*t*t*t) + q=[round(float(q[0])),round(float(q[1]))] + if not out or q!=out[-1]:out.append(q) + out.append(points[-1]);return out + +def route(i,name,points,width=7,role='main',**kw): + r=dict(id=i,name=name,points=smooth(points),waypoints=points,width=width,role=role,**kw);routes.append(r);return r + +add('arrival-hex','01','Arrival square',closed([[0,-37],[39,-15],[43,31],[0,57],[-43,31],[-39,-15]]),'plaza') +add('spawn-medallion','01','Shacraft medallion reserve',ellipse(0,9,16,n=36),'plaza') +add('clock-station','02','Clock station and tower',closed([[-74,-139],[-51,-139],[-51,-145],[16,-145],[16,-139],[40,-139],[40,-98],[13,-98],[13,-83],[-25,-83],[-25,-98],[-74,-98]])) +add('station-clock-base','02','Clock tower base',rect(-6,-105,18,18),'detail') +for cx in[-63,29]:add('station-pavilion-'+str(cx),'02','End pavilion',rect(cx,-119,16,30),'detail') +add('station-forecourt','02','Station forecourt',ellipse(-5,-65,36,15,n=48),'plaza') +add('portal-hall','03','Portal concourse',closed([[-227,-163],[-161,-163],[-152,-154],[-152,-122],[-161,-113],[-227,-113],[-236,-122],[-236,-154]])) +for k,(cx,cz) in enumerate([(x,z)for z in[-151,-125]for x in[-218,-194,-170]],1):add('portal-bay-'+str(k),'03','Portal bay '+str(k),rect(cx,cz,13,8),'detail',number=k) +add('portal-forecourt','03','Portal garden court',ellipse(-159,-93,17,12,n=48),'plaza') +add('harbor-terminal','04','Airship terminal',closed([[169,-170],[191,-170],[199,-162],[199,-103],[191,-95],[169,-95],[161,-103],[161,-162]])) +for k,z in enumerate([-158,-132,-106],1): + add('airship-pier-'+str(k),'04','Airship pier '+str(k),closed([[161,z-4],[107,z-4],[103,z],[107,z+4],[161,z+4]]),'pier',deck_y=79,number=k) +add('flagship-reserve','04','Flagship mooring reserve',ellipse(78,-132,29,10,n=48),'detail',deck_y=92) +for i,(x,z,rx,rz,n) in enumerate([(185,23,25,25,'Palm glasshouse'),(211,94,22,18,'Winter garden'),(233,152,15,15,'Observatory')],1): + add('garden-'+str(i),'05',n,ellipse(x,z,rx,rz), 'building') + add('garden-court-'+str(i),'05',n+' surrounding walk',ellipse(x,z,rx+7,rz+7),'plaza') +add('market-square','06','Market square',ellipse(-195,225,22,25,n=48),'plaza') +for i,(x,z,w,d,a,n) in enumerate([(-219,188,24,18,-8,'Bakery'),(-177,195,24,18,7,'Crafts hall'),(-162,231,17,26,8,'Tea house'),(-178,272,22,18,-8,'Guild shop'),(-214,272,23,17,8,'Workshop'),(-233,239,22,26,-5,'Guild hall')],1): + add('market-'+str(i),'06',n,rect(x,z,w,d,a)) +add('waterworks-pump','08','Pumping house',rect(-135,43,14,18,-15)) +add('waterworks-lookout','08','Waterworks lookout',ellipse(-135,69,10,n=32),'plaza') +shore=[] +for z in range(-69,76,3): + wet=np.where(h[z+384,:384]<48)[0]-384 + if len(wet):shore.append([int(wet[-1]-3),z]) +add('lake-boardwalk','08','Lake boardwalk alignment',smooth(shore),'promenade',deck_y=51) +for i,(x,z,r,n) in enumerate([(43,-173,8,'North clock-view belvedere'),(-130,-188,7,'Portal ridge overlook'),(229,-76,8,'Harbor overlook'),(266,110,7,'Garden lookout'),(-258,258,7,'Valley approach lookout')],1): + add('overlook-'+str(i),'09',n,ellipse(x,z,r,n=32),'plaza') +# Pull the portal footprint north onto its drier shoulder; retain all six bays. +for f in features: + if f['id']=='portal-hall' or f['id'].startswith('portal-bay-'): + f['points']=[[x-7,round(-156+(z+138)*.8)] for x,z in f['points']] +# Direct spawn routes make the principal destinations easy to find. +route('station-axis','Clock avenue',[[0,-37],[-4,-48],[-5,-65],[-6,-83]],9) +route('portal-radial','Portal approach',[[-35,-14],[-67,-43],[-107,-65],[-135,-75],[-159,-81]],7) +route('portal-forecourt-link','Portal court entrance',[[-159,-105],[-154,-120],[-164,-139]],5,'secondary') +route('lake-radial','Lake walk',[[-42,18],[-77,27],[-104,38],[-126,40]],5,'secondary') +route('east-radial','Garden approach',[[42,16],[65,10],[82,8]],7) +route('south-axis','Arrival avenue',[[0,57],[3,97],[-5,143],[4,189],[8,237],[7,281],[5,321]],9) +# A district promenade closes two large loops and bypasses spawn. +route('ring-northwest','Station to portals',[[-40,-65],[-77,-76],[-116,-89],[-145,-105],[-152,-122],[-164,-139]],7) +route('ring-west','West inner promenade',[[-147,-111],[-125,-80],[-118,-41],[-110,4],[-115,47],[-113,88],[-127,126]],7) +route('ring-market-north','Market north street',[[-213,128],[-218,148],[-205,171],[-193,182],[-195,200]],7) +route('ring-market-south','Market south street',[[-195,250],[-187,253],[-170,252],[-145,242]],7) +route('ring-south','South inner promenade',[[-72,242],[-36,238],[8,237],[31,218],[46,194],[59,166]],7) +route('ring-garden-south','Garden south promenade',[[143,166],[172,179],[203,172],[218,157]],7) +route('ring-gardens','Garden promenade',[[218,137],[204,117],[186,109],[176,88],[184,65],[201,46]],7) +route('ring-harbor','Harbor garden promenade',[[185,-9],[197,-43],[192,-72],[181,-95]],7) +route('ring-northeast','Station to east bridge',[[40,-95],[60,-91],[82,-78]],7) +route('harbor-bridge-landing','Harbor bridge landing',[[155,-81],[171,-83],[180,-95]],7) +route('garden-bridge-landing','Garden bridge landing',[[151,8],[157,16],[160,23]],7) +route('market-shop-street','Market north street',[[-207,192],[-205,198],[-203,202]],5,'secondary') +route('market-south-shop-street','Market south shops',[[-187,253],[-178,259],[-178,262]],5,'secondary') +route('market-workshop-street','Workshop approach',[[-198,250],[-207,254],[-214,262]],5,'secondary') +route('market-guild-street','Guild approach',[[-216,231],[-220,234],[-222,236]],5,'secondary') +route('market-teahouse-street','Tea house approach',[[-173,229],[-171,229]],5,'secondary') +route('market-crafts-street','Crafts approach',[[-184,203],[-179,206]],5,'secondary') +route('waterworks-connector','Waterworks approach',[[-114,44],[-122,44],[-128,43]],5,'secondary') +route('waterworks-lookout-path','Waterworks viewing path',[[-135,52],[-135,59]],3,'secondary') +# Bridges are measured separately. Grade is a later construction task, not terrain flattening. +for i,n,a,b,w in [ + ('bridge-northeast','Clock / harbor bridge',[82,-78],[155,-81],7), + ('bridge-east','Spawn / garden bridge',[82,8],[151,8],7), + ('bridge-southeast','Garden / south bridge',[59,166],[143,166],7), + ('bridge-market-north','Market / lake bridge',[-127,126],[-213,128],7), + ('bridge-market-south','Market / arrival bridge',[-145,242],[-72,242],7), + ('arrival-viaduct','Arrival viaduct',[5,321],[5,383],11)]: + pts=np.linspace(a,b,int(np.linalg.norm(np.array(b)-a))+1).round().astype(int) + heights=h[pts[:,1]+384,pts[:,0]+384] + deck=int(math.ceil(max(heights[0],heights[-1])))+2 + if i=='arrival-viaduct':deck=max(deck,65) + r=route(i,n,[a,b],w,'bridge',deck_y=deck,minimum_ground=float(heights.min()),endpoint_ground=[float(heights[0]),float(heights[-1])]); + # Edge lines are appropriate for construction marking; they are not a complete bridge. + r['intent']='Mark both parapet alignments and abutments; preserve the river and banks.' + r['abutments']=[dict(point=q,ground_y=round(float(y),1),deck_y=deck,future_stairs=(deck-math.floor(y)>4),rise=deck-math.floor(y)) for q,y in zip([a,b],[heights[0],heights[-1]])] +# Scenic branches never substitute for main circulation. +for i,n,p in[ + ('north-overlook-path','North overlook',[[40,-128],[58,-140],[62,-161],[50,-171]]), + ('portal-overlook-path','Portal ridge path',[[-159,-151],[-136,-158],[-123,-172],[-129,-181]]), + ('harbor-overlook-path','Harbor lookout trail',[[196,-80],[213,-75],[221,-76]]), + ('garden-overlook-path','Garden lookout trail',[[232,96],[249,99],[259,107]]), + ('south-overlook-path','Valley lookout trail',[[-218,236],[-224,219],[-248,223],[-263,240],[-264,251]])]:route(i,n,p,3,'secondary') +# Geometric checks on the immutable approved field; live blocks must still be checked before writes. +zz,xx=np.mgrid[-384:384,-384:384] +for f in features: + pts=np.array(f['points']);sample=h[np.clip(pts[:,1]+384,0,767),np.clip(pts[:,0]+384,0,767)] + if f['type']=='polygon': + x0,z0=pts.min(axis=0);x1,z1=pts.max(axis=0);px,pz=np.meshgrid(np.arange(x0,x1+1),np.arange(z0,z1+1)) + mask=MplPath(pts).contains_points(np.column_stack((px.flat,pz.flat)),radius=.01).reshape(px.shape) + sample=h[z0+384:z1+385,x0+384:x1+385][mask] + f['ground']={'min':round(float(sample.min()),2),'max':round(float(sample.max()),2),'water_samples':int((sample<48).sum()),'samples':len(sample)} +for r in routes: + pts=np.array(r['points']);v=h[pts[:,1]+384,pts[:,0]+384] + ds=np.linalg.norm(np.diff(pts,axis=0),axis=1);grade=np.abs(np.diff(v))/np.maximum(ds,.001) + r['analysis']={'length':round(float(ds.sum()),1),'ground_min':round(float(v.min()),1),'ground_max':round(float(v.max()),1),'p95_raw_grade':round(float(np.percentile(grade,95)),2),'water_samples':int((v<48).sum())} +plan={'schema':'shacraft-layout-study-v1','world':'shacraft_lobby_v2','bounds':{'min_x':-384,'max_x':383,'min_z':-384,'max_z':383},'water_y':48,'status':'offline design; not a world mutation or live snapshot','districts':districts,'features':features,'routes':routes,'construction_notes':[ + 'Colored outlines are building reservations, not instructions to level their entire bounding boxes.', + 'Walks remain aligned to terrain; steep local runs need short stairs during the later building phase.', + 'Main avenues 9 blocks, district ring 7, side paths 5, scenic trails 3; marker widths can be thinner than final clear widths.', + 'Keep protected water intact. Bridges have a separate planned deck Y above their endpoint terrain.', + 'The natural western lake bank is too steep for a main ring street. The main route uses the calm inner/east lake shoulder; the low over-water boardwalk is secondary.', + 'No minigame arenas. Six portal bays, three harbor piers, one future flagship reservation.' +]} +(OUT/'layout.json').write_text(json.dumps(plan,indent=2)+'\n') +# Render a design review from data, not a Minecraft screenshot. +dz,dx=np.gradient(h);rock=np.clip((np.hypot(dx,dz)-.55)/1.5,0,1)[...,None] +rgb=np.array([.32,.40,.27])*(1-rock)+np.array([.49,.49,.46])*rock +rgb=LightSource(315,42).shade_rgb(rgb,h,vert_exag=1,blend_mode='soft');rgb=np.where((h<48)[...,None],np.array([.13,.28,.35]),rgb) +fig,ax=plt.subplots(figsize=(14,14),facecolor='#151b18');ax.set_facecolor('#151b18') +ax.imshow(rgb,extent=(-384,384,384,-384)) +for r in routes: + p=np.array(r['points']);lw=2.2 if r['role']!='secondary' else 1 + ax.plot(p[:,0],p[:,1],color='#f7f2de' if r['role']!='bridge' else '#ffffff',lw=lw,alpha=.92,linestyle='-' if r['role']!='bridge' else '--') +for f in features: + p=np.array(f['points']);ax.plot(p[:,0],p[:,1],color=f['color'],lw=2 if f['role'] not in ('detail','promenade') else 1.25) +for d in districts: + if d['id']=='09':continue + x,z=d['label'];ax.text(x,z,d['id'],color='#111914',ha='center',va='center',fontsize=12,fontweight='bold',bbox=dict(boxstyle='circle,pad=.35',fc=d['color'],ec='#111914',lw=1)) +ax.set(xlim=(-325,310),ylim=(384,-255),xlabel='X / blocks',ylabel='Z / blocks; north up') +ax.set_title('SHACRAFT / terrain-adapted lobby layout',loc='left',color='#f5f3ea',fontsize=19,pad=22) +ax.tick_params(colors='#b5c0b8');ax.xaxis.label.set_color('#b5c0b8');ax.yaxis.label.set_color('#b5c0b8') +for i,d in enumerate(districts): + fig.text(.085+(i%3)*.302,.065-(i//3)*.021,d['id']+' '+d['name'],color=d['color'],fontsize=11) +fig.text(.085,.009,'DESIGN STUDY • Exact approved height field; outlines not yet placed. White: routes / dashed: future bridge spans.',color='#b5c0b8',fontsize=9) +fig.subplots_adjust(left=.06,right=.985,top=.945,bottom=.11) +fig.savefig(OUT/'layout-preview.png',dpi=140);plt.close(fig) +print(json.dumps({'features':len(features),'routes':len(routes),'path_length':round(sum(r['analysis']['length']for r in routes)), 'output':str(OUT)})) +print('BUILDING HEIGHT SPANS') +for f in features: + if f['role']=='building':print(f['id'],f['ground']) +print('BRIDGES') +for r in routes: + if r['role']=='bridge':print(r['id'],r['deck_y'],r['endpoint_ground'],r['analysis']) diff --git a/scripts/layout.py b/scripts/layout.py new file mode 100644 index 0000000..a378c21 --- /dev/null +++ b/scripts/layout.py @@ -0,0 +1,326 @@ +#!/usr/bin/env python3 +"""Place an explicit block layout with checked snapshots, resumable receipts, and guarded undo.""" +import argparse +from collections import Counter, defaultdict +import fcntl +import hashlib +import importlib.util +import json +from pathlib import Path +import re +import sys +import time + +spec = importlib.util.spec_from_file_location('layout_terrain', Path(__file__).with_name('terrain.py')) +terrain = importlib.util.module_from_spec(spec) +spec.loader.exec_module(terrain) +ROOT = terrain.ROOT +MAX_BLOCKS = 4096 +MAX_OPERATIONS = 256 +MAX_READ_CELLS = 32 +SCOPE_KEYS = ('project_id', 'world_id', 'world_epoch') +FAILED = ('conflict', 'cancelled', 'failed', 'recovery_required') +STATE = re.compile(r'minecraft:[a-z0-9_]+(?:\[[a-z0-9_=,]+\])?\Z') + + +def position(block): + return tuple(block[axis] for axis in ('x', 'y', 'z')) + + +def coordinates(at): + return dict(zip(('x', 'y', 'z'), at)) + + +def normalize(value): + if not isinstance(value, dict) or type(value.get('version')) is not int or value['version'] != 1: + raise ValueError('Layout version must be 1') + scope = value.get('scope', {}) + if not isinstance(scope, dict) or any(not isinstance(scope.get(k), str) or not scope[k] for k in SCOPE_KEYS): + raise ValueError('Layout requires project_id, world_id, and world_epoch') + source = value.get('blocks') + if not isinstance(source, list) or not 1 <= len(source) <= 1_000_000: + raise ValueError('Layout must contain 1..1,000,000 explicit blocks') + blocks, seen = [], set() + for raw in source: + if not isinstance(raw, dict): + raise ValueError('Every block must be an object') + if any(type(raw.get(a)) is not int or not -2**31 <= raw[a] < 2**31 for a in ('x', 'y', 'z')): + raise ValueError('Block coordinates must be signed 32-bit integers') + at = position(raw) + if at in seen: + raise ValueError(f'Duplicate block position: {at}') + seen.add(at) + block, expected = raw.get('block'), raw.get('expected', 'minecraft:air') + if any(not isinstance(s, str) or len(s) > 512 or not STATE.fullmatch(s) for s in (block, expected)): + raise ValueError(f'Invalid block state at {at}; use full minecraft: names') + blocks.append({**coordinates(at), 'block': block, 'expected': expected}) + blocks.sort(key=position) + return {'version': 1, 'scope': {k: scope[k] for k in SCOPE_KEYS}, 'blocks': blocks} + + +def digest(value): + return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest() + + +def read_cell(block): + return (block['x'] // 16, block['y'] // 16, block['z'] // 16) + + +def bounds(blocks): + return {'min': {a: min(b[a] for b in blocks) for a in ('x', 'y', 'z')}, + 'max': {a: max(b[a] for b in blocks) for a in ('x', 'y', 'z')}} + + +def volume(box): + return (box['max']['x'] - box['min']['x'] + 1) * (box['max']['y'] - box['min']['y'] + 1) * (box['max']['z'] - box['min']['z'] + 1) + + +def read_groups(blocks): + cells = defaultdict(list) + for block in blocks: + cells[read_cell(block)].append(block) + return [cells[key] for key in sorted(cells)] + + +def compressed_runs(blocks): + """Runs never cross a 16³ read cell, so every inspection is bounded to 4096 voxels.""" + rows = defaultdict(list) + for block in blocks: + rows[(*read_cell(block), block['y'], block['z'], block['block'])].append(block) + for key in sorted(rows): + row = sorted(rows[key], key=lambda b: b['x']) + run = [] + for block in row: + if run and block['x'] != run[-1]['x'] + 1: + yield run + run = [] + run.append(block) + if run: + yield run + + +def make_batches(blocks): + batches, batch, cells = [], [], set() + for run in compressed_runs(blocks): + cell = read_cell(run[0]) + if batch and (len(batch) >= MAX_OPERATIONS or sum(len(r) for r in batch) + len(run) > MAX_BLOCKS + or len(cells | {cell}) > MAX_READ_CELLS): + batches.append(batch) + batch, cells = [], set() + batch.append(run) + cells.add(cell) + if batch: + batches.append(batch) + result = [] + for index, runs in enumerate(batches): + selected = [b for run in runs for b in run] + operations = [{'type': 'box', 'min': coordinates(position(run[0])), + 'max': coordinates(position(run[-1])), 'block': run[0]['block']} for run in runs] + result.append({'index': index, 'blocks': selected, 'recipe': {'version': 1, 'operations': operations}}) + return result + + +def check_context(context, layout, require_checked=True): + if {key: context.get(key) for key in SCOPE_KEYS} != layout['scope']: + raise RuntimeError('Layout belongs to another project/world/epoch') + if require_checked and context.get('checked_expected_blocks') is not True: + raise RuntimeError('Server lacks atomic checked_expected_blocks; update the plugin before placing this layout') + area = context.get('region', {}) + for block in layout['blocks']: + if any(not area.get('min', {}).get(a, 1) <= block[a] <= area.get('max', {}).get(a, 0) for a in ('x', 'y', 'z')): + raise RuntimeError(f'Layout exceeds selected project area at {position(block)}') + + +def inspect_states(backend, blocks, epoch): + states = {} + for group in read_groups(blocks): + box = bounds(group) + result = backend.call('region_inspect', **box, detail='blocks') + if result.get('world_epoch') != epoch or result.get('truncated') is not False: + raise RuntimeError('Inspection was truncated or returned a different world epoch') + found = {} + for item in result.get('blocks', []): + at = position(item['pos']) + if at in found: + raise RuntimeError('Inspection contained duplicate positions') + found[at] = item['state'] + if len(found) != volume(box): + raise RuntimeError('Inspection did not return the complete bounded region') + for block in group: + at = position(block) + if at not in found: + raise RuntimeError(f'Inspection omitted {at}') + states[at] = found[at] + return states + + +def verify(backend, blocks, epoch, field): + states = inspect_states(backend, blocks, epoch) + mismatches = [(position(b), b[field], states[position(b)]) for b in blocks if states[position(b)] != b[field]] + if mismatches: + at, expected, actual = mismatches[0] + raise RuntimeError(f'Block mismatch at {at}: expected {expected}, found {actual} ({len(mismatches)} mismatches); no blind overwrite') + return len(blocks) + + +def load_or_create(path, layout, batches): + expected_digest = digest(layout) + snapshot = path.with_suffix(path.suffix + '.input.json') + if path.exists(): + manifest = json.loads(path.read_text()) + if (manifest.get('version') != 1 or manifest.get('planning_version') != 1 + or manifest.get('scope') != layout['scope'] or manifest.get('input_digest') != expected_digest): + raise RuntimeError('Manifest layout digest, planning version, or project/world/epoch differs; do not reuse it') + if not snapshot.exists() or digest(normalize(json.loads(snapshot.read_text()))) != expected_digest: + raise RuntimeError('Saved layout input is missing or changed') + if len(manifest.get('batches', [])) != len(batches): + raise RuntimeError('Saved batch count differs from deterministic planner') + for saved, planned in zip(manifest['batches'], batches): + if saved.get('index') != planned['index'] or saved.get('digest') != digest(planned): + raise RuntimeError('Saved batch differs from deterministic planner') + return manifest + if snapshot.exists() and digest(normalize(json.loads(snapshot.read_text()))) != expected_digest: + raise RuntimeError('An orphaned layout input differs; choose a new manifest path') + terrain.save(snapshot, layout) + manifest = {'version': 1, 'planning_version': 1, 'scope': layout['scope'], + 'input_digest': expected_digest, 'blocks': len(layout['blocks']), + 'batches': [{'index': b['index'], 'digest': digest(b), 'blocks': len(b['blocks'])} for b in batches]} + terrain.save(path, manifest) + return manifest + + +def apply_layout(backend, layout, path, progress=print): + check_context(backend.call('project_context'), layout) + batches = make_batches(layout['blocks']) + manifest = load_or_create(path, layout, batches) + if manifest.get('undo_started') or manifest.get('undone') or any('undo' in e for e in manifest['batches']): + raise RuntimeError('Layout has begun undo; finish undo and use a new manifest for new work') + epoch = layout['scope']['world_epoch'] + for batch, entry in zip(batches, manifest['batches']): + if entry.get('status') in FAILED: + raise RuntimeError(f"Batch {entry['index']} stopped on {entry['status']}; inspect or undo it instead of creating a new plan") + if entry.get('status') == 'applied': + verify(backend, batch['blocks'], epoch, 'block') + continue + # An unknown apply may already have changed the world: resolve its same stable key first. + if 'idempotency_key' not in entry and 'operation_id' not in entry: + verify(backend, batch['blocks'], epoch, 'expected') + if 'plan_id' not in entry: + prepared = backend.call('build_prepare', recipe=batch['recipe'], expected_blocks=[ + {'pos': coordinates(position(b)), 'state': b['expected']} for b in batch['blocks']]) + entry.update(prepared) + terrain.save(path, manifest) + terrain.apply_plan(backend, entry, manifest, path) + verify(backend, batch['blocks'], epoch, 'block') + entry['verified_at'] = int(time.time()) + terrain.save(path, manifest) + progress(json.dumps({'batch': entry['index'] + 1, 'batches': len(batches), 'status': 'verified', + 'written': entry['written'], 'operation_id': entry['operation_id']})) + manifest['completed'] = True + terrain.save(path, manifest) + return {'status': 'completed', 'blocks': len(layout['blocks']), 'batches': len(batches), 'manifest': str(path)} + + +def undo_layout(backend, path, progress=print): + snapshot = path.with_suffix(path.suffix + '.input.json') + if not path.exists() or not snapshot.exists(): + raise RuntimeError('Manifest or saved layout input not found') + layout = normalize(json.loads(snapshot.read_text())) + check_context(backend.call('project_context'), layout, require_checked=False) + batches = make_batches(layout['blocks']) + manifest = load_or_create(path, layout, batches) + for entry in manifest['batches']: + if 'idempotency_key' in entry and 'operation_id' not in entry: + raise RuntimeError('Uncertain apply response. Resume apply with the same manifest first; undo will not start an unconfirmed write') + manifest['undo_started'] = True + terrain.save(path, manifest) + for batch, entry in reversed(list(zip(batches, manifest['batches']))): + if 'operation_id' not in entry: + continue + source = terrain.finish(backend, entry['operation_id']) + if source['status'] == 'recovery_required': + raise RuntimeError('Interrupted server operation requires recovery review before undo') + if not source['written']: + continue + if 'undo' not in entry: + entry['undo'] = backend.call('operation_undo_prepare', operation_id=entry['operation_id']) + terrain.save(path, manifest) + if entry['undo'].get('status') in FAILED: + raise RuntimeError(f"Undo stopped on {entry['undo']['status']}; inspect conflicts before taking further action") + terrain.apply_plan(backend, entry['undo'], manifest, path) + # Only a fully applied source has receipts for every changed position. A guarded partial + # undo intentionally leaves conflicting manual edits alone; the server verifies its receipts. + if source['status'] == 'applied': + verify(backend, batch['blocks'], layout['scope']['world_epoch'], 'expected') + entry['undo']['verified_at'] = int(time.time()) + else: + entry['undo']['verification'] = 'server_receipts_only_for_partial_source' + terrain.save(path, manifest) + progress(json.dumps({'batch': entry['index'] + 1, 'status': 'undone', 'written': entry['undo']['written']})) + manifest['undone'] = True + terrain.save(path, manifest) + return {'status': 'undone', 'manifest': str(path)} + + +def report(layout): + batches = make_batches(layout['blocks']) + boxes = [bounds(group) for b in batches for group in read_groups(b['blocks'])] + return {'status': 'offline_report', 'scope': layout['scope'], 'input_digest': digest(layout), + 'blocks': len(layout['blocks']), 'batches': len(batches), 'bounds': bounds(layout['blocks']), + 'materials': dict(sorted(Counter(b['block'] for b in layout['blocks']).items())), + 'inspection_requests_per_pass': len(boxes), 'inspection_voxels_per_pass': sum(map(volume, boxes)), + 'max_inspection_volume': max(map(volume, boxes)), + 'max_batch_blocks': max(len(b['blocks']) for b in batches), + 'max_batch_operations': max(len(b['recipe']['operations']) for b in batches)} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest='command', required=True) + p = sub.add_parser('report', help='Report deterministic batching offline; no server required') + p.add_argument('layout', type=Path) + descriptions = {'prepare': 'Save immutable input and check current blocks without editing the world', + 'apply': 'Place or resume the layout through checked, journalled RPC', + 'undo': 'Undo recorded layout operations in reverse order, preserving later edits'} + for name, description in descriptions.items(): + p = sub.add_parser(name, help=description) + if name != 'undo': + p.add_argument('layout', type=Path) + p.add_argument('--manifest', type=Path, required=True) + p.add_argument('--config', type=Path, default=ROOT / '.runtime/server/plugins/MinecraftBuilderMCP/config.yml') + p.add_argument('--console', action='store_true', help='Only for an explicitly configured isolated fixture') + if name != 'prepare': + p.add_argument('--execute', action='store_true', required=True, help='Explicitly perform this world edit') + args = parser.parse_args() + if args.command == 'report': + print(json.dumps(report(normalize(json.loads(args.layout.read_text()))), indent=2)) + return + path = args.manifest.resolve() + path.parent.mkdir(parents=True, exist_ok=True) + with path.with_suffix(path.suffix + '.lock').open('a') as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + backend = terrain.Backend(args.config, args.console) + if args.command == 'undo': + result = undo_layout(backend, path, progress=lambda s: print(s, flush=True)) + else: + layout = normalize(json.loads(args.layout.read_text())) + if args.command == 'prepare': + check_context(backend.call('project_context'), layout) + batches = make_batches(layout['blocks']) + manifest = load_or_create(path, layout, batches) + if any('plan_id' in e for e in manifest['batches']): + raise RuntimeError('This manifest already has plans; use apply to resume, or report for an offline summary') + for batch in batches: + verify(backend, batch['blocks'], layout['scope']['world_epoch'], 'expected') + result = {**report(layout), 'status': 'prepared_input', 'manifest': str(path)} + else: + result = apply_layout(backend, layout, path, progress=lambda s: print(s, flush=True)) + print(json.dumps(result, indent=2)) + + +if __name__ == '__main__': + try: + main() + except (RuntimeError, ValueError, OSError) as error: + print(f'Layout stopped: {error}', file=sys.stderr) + sys.exit(1) diff --git a/scripts/light-shacraft-station.py b/scripts/light-shacraft-station.py new file mode 100644 index 0000000..98b905b --- /dev/null +++ b/scripts/light-shacraft-station.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Compile concealed station lighting against an explicit observed snapshot. + +No world I/O. Each fitting replaces full cubes only: brown glass forms a flush +floor tile or a recessed ceiling lens, with glowstone hidden directly behind it. +The caller applies and independently verifies the resulting checked recipe. +""" + +import argparse +from collections import Counter +from datetime import datetime, timezone +import hashlib +import importlib.util +import json +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +STAGE = ROOT / ".runtime/station-stage07" +GLOW = "minecraft:glowstone" +LENS = "minecraft:brown_stained_glass" +WOOD = "minecraft:spruce_planks" +CREAM = "minecraft:smooth_sandstone" + + +def _module(name, path): + spec = importlib.util.spec_from_file_location(name, path) + value = importlib.util.module_from_spec(spec) + spec.loader.exec_module(value) + return value + + +survey = _module("station_lighting_survey", ROOT / "scripts/foundation-survey.py") + + +def compile_lighting(before, station): + if station["scope"] != before.scope: + raise ValueError("Station metadata and observed snapshot have different scopes") + footprint = {tuple(p) for p in station["footprint"]} + inside = {(x, z) for x, z in footprint + if all((x + dx, z + dz) in footprint + for dx in range(-2, 3) for dz in range(-2, 3))} + targets = {feet: {(t["x"] + dx, t["z"] + dz) + for t in station["interior"]["navigation"][str(feet)]["named_targets"] + for dx in range(-1, 2) for dz in range(-1, 2)} + for feet in (99, 113)} + # Keep both cabin landings, controls, doorway and immediate approach unchanged. + lift_exclusion = {(x, z) for x in range(-11, 0) for z in range(-125, -112)} + changes = {} + fixtures = [] + + def full(state): + return state.split("[", 1)[0].removeprefix("minecraft:") in survey.FULL + + def put(x, y, z, value, group): + if (x, z) not in inside or not 97 <= y <= 124: + raise ValueError(f"Lighting write outside authorized volume: {(x, y, z)}") + old = before.state(x, y, z) + if not full(old) or not full(value): + raise ValueError(f"Lighting may replace full cubes only: {(x, y, z)} {old}") + if old == value: + return + p = (x, y, z) + if p in changes and changes[p]["block"] != value: + raise ValueError(f"Lighting layers disagree at {p}") + changes[p] = {"x": x, "y": y, "z": z, "expected": old, + "block": value, "group": group} + + for feet, ceiling in ((99, 110), (113, 123)): + public = {(p["x"], p["z"]) for p in station["interior"]["walk_points_by_floor"][str(feet)]} + public &= inside + candidates = {"floor": set(), "ceiling": set()} + for x, z in sorted(public - lift_exclusion): + # Existing flooring mosaics and green/brass bands are not recoloured. + if (x, z) not in targets[feet] and before.state(x, feet - 1, z) == CREAM: + neighborhood = {(x + dx, z + dz) for dx in range(-1, 2) for dz in range(-1, 2)} + if neighborhood <= public and all( + before.state(xx, y, zz) in survey.AIR + for xx, zz in neighborhood for y in range(feet, feet + 4) + ) and full(before.state(x, feet - 2, z)): + candidates["floor"].add((x, z)) + # Plain spruce cells only: beams, chains, chandeliers, panels and lift + # geometry are excluded by the observed material and air-column tests. + if (before.state(x, ceiling, z) == WOOD + and full(before.state(x, ceiling + 1, z)) + and all(before.state(x, y, z) in survey.AIR for y in range(feet, ceiling))): + candidates["ceiling"].add((x, z)) + + for kind in ("floor", "ceiling"): + chosen = set() + # A common architectural grid; a maximum two-block adjustment avoids + # a column, mosaic or ceiling beam without creating dense bright rows. + for gx in range(-69, 40, 9): + for gz in range(-139, -83, 9): + nearby = [(x, z) for x in range(gx - 2, gx + 3) + for z in range(gz - 2, gz + 3) + if (x, z) in candidates[kind]] + nearby.sort(key=lambda p: ((p[0] - gx) ** 2 + (p[1] - gz) ** 2, + abs(p[0] - gx) + abs(p[1] - gz), p)) + selected = next((p for p in nearby if all( + (p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2 >= 36 for q in chosen)), None) + if selected is None: + continue + x, z = selected + chosen.add(selected) + lens_y, emit_y = (feet - 1, feet - 2) if kind == "floor" else (ceiling, ceiling + 1) + group = f"station-lighting:{feet}:{kind}" + put(x, lens_y, z, LENS, group) + put(x, emit_y, z, GLOW, group) + fixtures.append({"floor_walk_y": feet, "kind": kind, + "lens": [x, lens_y, z], "emitter": [x, emit_y, z], + "grid_anchor_xz": [gx, gz]}) + + recipe = {"version": 1, "scope": before.scope, + "blocks": [changes[p] for p in sorted(changes)]} + metadata = { + "version": 1, "scope": before.scope, "world_writes": 0, + "status": "checked lighting candidate; live application and visual review pending", + "style": "Sparse warm floor tiles and small concealed ceiling lenses on a nine-block grid", + "grid_spacing": 9, "maximum_anchor_adjustment": 2, + "minimum_same_layer_fixture_distance": 6, + "owned_y": [97, 124], "perimeter_setback": 2, + "changed_states": len(changes), "fixtures": fixtures, + "fixture_counts": dict(sorted(Counter(f"{f['floor_walk_y']}:{f['kind']}" for f in fixtures).items())), + "changed_materials": dict(Counter(row["block"] for row in changes.values())), + "checks": {"only_full_cube_replacements": True, "no_new_collision_obstructions": True, + "existing_mosaic_bands_preserved": True, "lift_landings_and_doorway_excluded": True, + "floor_fixtures_outside_named_target_neighborhoods": True, + "floor_fixtures_have_clear_three_by_three_surrounds": True, + "ceiling_lenses_replace_only_plain_spruce": True, + "decorative_furniture_and_diorama_preserved": True}, + "limits": "Fixture geometry does not predict the client's final rendered brightness. Review actual Minecraft images after applying and waiting for lighting updates.", + } + return recipe, metadata + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--before", type=Path, default=STAGE / "after.json.gz") + parser.add_argument("--station-metadata", type=Path, default=STAGE / "compiled/station.metadata.json") + parser.add_argument("--output", type=Path, default=STAGE / "lighting") + args = parser.parse_args() + recipe, metadata = compile_lighting(survey.load_snapshot(args.before), json.loads(args.station_metadata.read_text())) + metadata["compiled_at_utc"] = datetime.now(timezone.utc).isoformat() + metadata["inputs"] = {str(path): hashlib.sha256(path.read_bytes()).hexdigest() + for path in (args.before, args.station_metadata, Path(__file__))} + args.output.mkdir(parents=True, exist_ok=True) + for name, doc in (("lighting.json", recipe), ("lighting.metadata.json", metadata)): + destination = args.output / name + if destination.exists(): + raise FileExistsError(f"Preserve the previous candidate: {destination}") + destination.write_text(json.dumps(doc, indent=2) + "\n") + print(json.dumps({"recipe": str(args.output / "lighting.json"), "changed_states": len(recipe["blocks"]), + "fixture_counts": metadata["fixture_counts"]})) + + +if __name__ == "__main__": + main() diff --git a/scripts/mark-shacraft-layout.py b/scripts/mark-shacraft-layout.py new file mode 100644 index 0000000..38581d8 --- /dev/null +++ b/scripts/mark-shacraft-layout.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Compile the Shacraft spatial study into checked, reversible survey blocks. + +This only writes a desired-block document. scripts/layout.py performs live edits. +Ground lines replace one observed surface block; they never level terrain. +""" +import argparse +from collections import Counter +import json +import math +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +COLORS = {'01': 'lime', '02': 'yellow', '03': 'purple', '04': 'cyan', + '05': 'red', '06': 'orange', '07': 'white', '08': 'blue', '09': 'pink'} +RGB = {'lime':'#98d84d','yellow':'#f6d34a','purple':'#9460ce','cyan':'#23b6b6', + 'green':'#527c31','orange':'#f78d27','white':'#f0f0e6','blue':'#4a69d8', + 'pink':'#f394b5','black':'#26282d','light_gray':'#a4aaa6','gray':'#545c61', + 'red':'#e3544b','light_blue':'#68c8ec'} +FONT = { + '0':['111','101','101','101','111'], '1':['010','110','010','010','111'], + '2':['111','001','111','100','111'], '3':['111','001','111','001','111'], + '4':['101','101','111','001','001'], '5':['111','100','111','001','111'], + '6':['111','100','111','101','111'], '7':['111','001','010','010','010'], + '8':['111','101','111','101','111'], '9':['111','101','111','001','111'], + 'S':['111','100','111','001','111'], +} + + +def raster_line(points, radius=.6): + """Integer columns whose centres meet a piecewise line, with no diagonal holes.""" + result=set() + for (ax,az),(bx,bz) in zip(points,points[1:]): + dx,dz=bx-ax,bz-az; length=dx*dx+dz*dz + for z in range(math.floor(min(az,bz)-radius),math.ceil(max(az,bz)+radius)+1): + for x in range(math.floor(min(ax,bx)-radius),math.ceil(max(ax,bx)+radius)+1): + t=max(0,min(1,((x-ax)*dx+(z-az)*dz)/length)) if length else 0 + if (x-ax-t*dx)**2+(z-az-t*dz)**2 <= radius**2: + result.add((x,z)) + return result + + +def offset(points, distance): + result=[] + for i,(x,z) in enumerate(points): + a=points[max(0,i-3)];b=points[min(len(points)-1,i+3)] + dx,dz=b[0]-a[0],b[1]-a[1];norm=math.hypot(dx,dz) or 1 + result.append((x-dz/norm*distance,z+dx/norm*distance)) + return result + + +def main(): + p=argparse.ArgumentParser(description=__doc__) + p.add_argument('--study',type=Path,required=True);p.add_argument('--surface',type=Path,required=True) + p.add_argument('--scope',type=Path,required=True);p.add_argument('--output',type=Path,required=True) + args=p.parse_args();study=json.loads(args.study.read_text());surface=json.loads(args.surface.read_text()) + scope=json.loads(args.scope.read_text()) + if surface['world_uuid']!=scope['world_id'] or surface['world']!=study['world']: + raise ValueError('Study, world surface and authorization scope disagree') + W=surface['width'];MINX=surface['min_x'];MINZ=surface['min_z'] + heights=surface['surface_y'];palette=surface['palette'];materials=surface['material_index'] + desired={}; priorities={};skipped=Counter();raised=[] + + def at(x,z): + if not MINX<=x<=surface['max_x'] or not MINZ<=z<=surface['max_z']: + return None,None + i=(z-MINZ)*W+x-MINX + return heights[i],palette[materials[i]] + + def put(x,z,color,group,priority=10,y=None): + x,z=round(x),round(z);top,material=at(x,z) + if top is None:skipped['outside']+=1;return + if y is None: + if material=='minecraft:water':skipped['water']+=1;return + y=max(50,top) + if y=priorities.get(key,-1): + desired[key]={'x':x,'y':y,'z':z,'block':block,'expected':expected,'group':group} + priorities[key]=priority + + def stroke(points,color,group,radius=.6,priority=10,y=None): + for x,z in sorted(raster_line(points,radius)): + put(x,z,color,group,priority,y) + + def text(value,cx,cz,color,group,scale=2): + width=(len(value)*4-1)*scale + for j,char in enumerate(value): + for row,bits in enumerate(FONT[char]): + for col,bit in enumerate(bits): + if bit=='1': + for dz in range(scale): + for dx in range(scale): + put(cx-width//2+j*4*scale+col*scale+dx,cz-5*scale//2+row*scale+dz, + color,group,35) + + # The main route is a corridor reservation, with a clear green interior. + # Thin edge lines and spaced centre ticks keep the terrain visually dominant. + for route in study['routes']: + pts=route['points'];group=route['id'];width=route['width'];deck=route.get('deck_y') + is_bridge=route['role']=='bridge' + edgecolor='white' if width>=5 else 'light_gray' + if is_bridge:edgecolor='white' + for side in (-1,1): + stroke(offset(pts,side*(width-1)/2),edgecolor,group,.65,10,deck) + if width>=7: + for i in range(0,len(pts),18): + stroke(pts[i:i+3],'light_gray',group,.55,11,deck) + if is_bridge: + # Cross ties define the future deck without filling it or the river. + for i in range(0,len(pts),12): + a=offset(pts,-(width-1)/2)[i];b=offset(pts,(width-1)/2)[i] + stroke([a,b],'red' if group=='arrival-viaduct' else 'light_gray',group,.55,12,deck) + for end in (0,-1): + for side in (-1,1): + x,z=offset(pts,side*(width-1)/2)[end] + for y in range(deck+1,deck+5):put(x,z,'white',group,28,y) + put(x,z,'glowstone',group,29,deck+5) + + for feature in study['features']: + color=COLORS[feature['district']];role=feature['role'];group=feature['id'] + pts=feature['points'];deck=feature.get('deck_y') + if feature['type']=='polygon' and pts[0]!=pts[-1]:pts=pts+[pts[0]] + radius=1.05 if role in ('building','pier') or group=='arrival-hex' else .65 + stroke(pts,color,group,radius,20,deck) + if role=='building': + # Four sparse survey stakes make footprints recognizable at eye level. + for point in [pts[i] for i in sorted(set([0,(len(pts)-1)//4,(len(pts)-1)//2,3*(len(pts)-1)//4]))]: + x,z=point;top,_=at(x,z) + if top is None:continue + for y in range(top+1,top+4):put(x,z,color,group,27,y) + put(x,z,'glowstone',group,28,top+4) + + # Wayfinding IDs appear as blocks as well as external map labels. + # Small districts use one-block pixels to keep their reservations readable. + for district in study['districts']: + ident=district['id'];x,z=district['label'];color=COLORS[ident] + if ident=='09':continue + x,z={'01':(-22,27),'07':(26,271),'08':(-136,63)}.get(ident,(x,z)) + text(ident,x,z,'white','label-'+ident,1 if ident=='08' else 2) + # The brand medallion remains a reserved ring; its S is a simple block glyph. + text('S',0,8,'lime','spawn-monogram',3) + + # Monumental colored posts stand outside the main entry points, never in a path. + posts=[('01',33,48),('02',11,-73),('03',-151,-125),('04',197,-97), + ('05',174,75),('06',-171,226),('07',18,287),('08',-132,43)] + labels=[] + for ident,x,z in posts: + top,material=at(x,z) + if material=='minecraft:water':raise ValueError('Wayfinding post in water') + color=COLORS[ident];group='wayfinding-'+ident + for xx in range(x-1,x+2): + for zz in range(z-1,z+2):put(xx,zz,color,group,40) + for y in range(top+1,top+9):put(x,z,color,group,40,y) + put(x,z,'glowstone',group,41,top+9) + for xx in (x-1,x+1):put(xx,z,color,group,40,top+7) + district=next(d for d in study['districts'] if d['id']==ident) + labels.append({'id':ident,'name':district['name'],'x':x+.5,'y':top+11,'z':z+.5,'color':RGB[color]}) + + blocks=sorted(desired.values(),key=lambda b:(b['z']//16,b['x']//16,b['y']//16,b['y'],b['z'],b['x'])) + out={'version':1,'scope':scope,'blocks':blocks} + args.output.parent.mkdir(parents=True,exist_ok=True) + args.output.write_text(json.dumps(out,separators=(',',':'))+'\n') + metadata={'source_surface':str(args.surface),'world':surface['world'],'blocks':len(blocks), + 'by_material':dict(Counter(b['block'] for b in blocks)), + 'by_group':dict(Counter(b['group'] for b in blocks)), + 'skipped_strokes':dict(skipped),'raised_fixed_height_strokes':raised, + 'wayfinding_labels':labels,'districts':study['districts'], + 'note':'Surface contours preserve height; bridge outlines reserve a future deck. Walkability requires later paths and stairs.'} + args.output.with_suffix('.metadata.json').write_text(json.dumps(metadata,indent=2)+'\n') + print(json.dumps({'blocks':len(blocks),'groups':len(metadata['by_group']), + 'skipped':dict(skipped),'raised_fixed_height_strokes':len(raised)})) + + +if __name__=='__main__':main() diff --git a/scripts/material-registry-probe/MaterialRegistryProbe.java b/scripts/material-registry-probe/MaterialRegistryProbe.java new file mode 100644 index 0000000..efdf2fe --- /dev/null +++ b/scripts/material-registry-probe/MaterialRegistryProbe.java @@ -0,0 +1,114 @@ +package probe; +import org.bukkit.*; +import org.bukkit.block.data.BlockData; +import org.bukkit.command.*; +import org.bukkit.plugin.java.JavaPlugin; +import java.lang.reflect.*; +import java.nio.file.*; +import java.time.Instant; +import java.util.*; + +/** Test-only, console-only helper; never installed in the user's lobby server. */ +public final class MaterialRegistryProbe extends JavaPlugin { + private final List> failures = new ArrayList<>(); + private int failureCount; + @Override public void onEnable() { getLogger().info("Use console registryprobe to run the isolated registry test"); } + @Override public boolean onCommand(CommandSender sender, Command cmd, String label, String[] args) { + if (sender instanceof ConsoleCommandSender) runProbe(); return true; + } + private static Method method(Class c, String name, Class... args) throws Exception { + Method result=c.getDeclaredMethod(name,args); result.setAccessible(true); return result; + } + private static Object call(Method m, Object receiver, Object... args) throws Throwable { + try { return m.invoke(receiver,args); } catch(InvocationTargetException e) { throw e.getCause(); } + } + private static void equal(Object expected,Object actual) { if (!Objects.equals(expected,actual)) throw new AssertionError("snapshot_mismatch"); } + private void failure(String id,String state,String phase,Throwable error) { + failureCount++; + if(failures.size()<128) failures.add(Map.of("block",id,"state",state,"phase",phase,"reason",error.getClass().getSimpleName())); + } + private void runProbe() { + long start=System.nanoTime(); failures.clear(); failureCount=0; + int total=0,passed=0,entityDefaults=0,catalogDescribed=0,registryCount=0,maxDescriptionBytes=0,maxProperties=0,maxPropertyValues=0; + int propertyCases=0,propertyPassed=0,freshPassed=0,sameMaterialPassed=0,privateUndoPassed=0; + World anchorWorld=null; BlockData anchorBefore=null; boolean anchorRestored=false; + String fatal=null; Map catalogSummary=Map.of(); + try { + var plugin=Bukkit.getPluginManager().getPlugin("MinecraftBuilderMCP"); var loader=plugin.getClass().getClassLoader(); + Class accessClass=Class.forName("io.github.minecraftbuilder.paper.BuildingWorld",true,loader); + Class catalogClass=Class.forName("io.github.minecraftbuilder.paper.MaterialCatalog",true,loader); + Constructor catalogConstructor=catalogClass.getDeclaredConstructor(); catalogConstructor.setAccessible(true); + Object catalog=catalogConstructor.newInstance(); Method describe=method(catalogClass,"describe",String.class); + catalogSummary=(Map)call(method(catalogClass,"summary"),catalog); registryCount=(int)Registry.BLOCK.stream().count(); + Class posClass=Class.forName("io.github.minecraftbuilder.core.BlockPos",true,loader); + Constructor constructor=accessClass.getDeclaredConstructor(World.class); constructor.setAccessible(true); + World world=Bukkit.getWorld("world"); + if(world==null||Bukkit.getPort()!=25576) throw new IllegalStateException("IsolatedServerRequired"); + world.loadChunk(0,0); Object pos=posClass.getConstructor(int.class,int.class,int.class).newInstance(4,100,4); + if(!world.getBlockAt(4,100,4).getType().isAir()||!world.getBlockAt(5,100,4).getType().isAir()) throw new IllegalStateException("AirFixtureRequired"); + anchorWorld=world; anchorBefore=world.getBlockAt(5,100,4).getBlockData(); + world.getBlockAt(5,100,4).setBlockData(Material.STONE.createBlockData(),false); + Object access=constructor.newInstance(world); + Method capture=method(accessClass,"captureBlock",posClass),prepare=method(accessClass,"prepareBlock",posClass,String.class,String.class),place=method(accessClass,"setCapturedBlock",posClass,String.class); + String before=(String)call(capture,access,pos); + for(Material material:Material.values()) { + if(material.isLegacy()||!material.isBlock()) continue; + total++; String id=material.getKey().toString(); Map properties=Map.of(); + try { + Map description=(Map)call(describe,catalog,id); + maxDescriptionBytes=Math.max(maxDescriptionBytes,new com.google.gson.Gson().toJson(description).getBytes(java.nio.charset.StandardCharsets.UTF_8).length); + properties=(Map)description.get("properties"); maxProperties=Math.max(maxProperties,properties.size()); + for(Object values:properties.values()) maxPropertyValues=Math.max(maxPropertyValues,((Collection)values).size()); + catalogDescribed++; + } catch(Throwable e) { failure(id,id,"catalog_description",e); } + String defaultState=material.createBlockData().getAsString(),phase="prepare_default",base=null; + try { + base=(String)call(prepare,access,pos,defaultState,before); if(base.startsWith("\u0000")) entityDefaults++; + equal(before,call(capture,access,pos)); + phase="place_default"; call(place,access,pos,base); + phase="verify_default"; equal(base,call(capture,access,pos)); passed++; + } catch(Throwable e) { failure(id,defaultState,phase,e); } + finally { call(place,access,pos,before); equal(before,call(capture,access,pos)); } + if(base==null) continue; + Set variants=new LinkedHashSet<>(); + for(var property:properties.entrySet()) for(Object value:(Collection)property.getValue()) + variants.add(Bukkit.createBlockData(id+"["+property.getKey()+"="+value+"]").getAsString()); + for(String variant:variants) { + if(++propertyCases>30_000||System.nanoTime()-start>35_000_000_000L) throw new IllegalStateException("ProbeBudgetExceeded"); + phase="prepare_fresh_property"; + try { + String desired=(String)call(prepare,access,pos,variant,before); equal(before,call(capture,access,pos)); + phase="place_fresh_property"; call(place,access,pos,desired); + phase="verify_fresh_property"; equal(desired,call(capture,access,pos)); freshPassed++; + call(place,access,pos,before); equal(before,call(capture,access,pos)); + phase="place_existing_default"; call(place,access,pos,base); equal(base,call(capture,access,pos)); + phase="prepare_same_material"; String changed=(String)call(prepare,access,pos,variant,base); equal(base,call(capture,access,pos)); + phase="place_same_material"; call(place,access,pos,changed); + phase="verify_same_material"; equal(changed,call(capture,access,pos)); sameMaterialPassed++; + phase="private_undo"; call(place,access,pos,base); equal(base,call(capture,access,pos)); privateUndoPassed++; propertyPassed++; + } catch(Throwable e) { failure(id,variant,phase,e); } + finally { call(place,access,pos,before); equal(before,call(capture,access,pos)); } + } + } + } catch(Throwable e) { fatal=e.getClass().getSimpleName(); } + finally { + if(anchorWorld!=null&&anchorBefore!=null) { + anchorWorld.getBlockAt(5,100,4).setBlockData(anchorBefore,false); + anchorRestored=anchorBefore.equals(anchorWorld.getBlockAt(5,100,4).getBlockData()); + } + } + double millis=(System.nanoTime()-start)/1_000_000.0; + Map result=new LinkedHashMap<>(); + result.put("timestamp",Instant.now().toString());result.put("server_version",Bukkit.getVersion()); + result.put("fixture","Air target (4,100,4); temporary stone section anchor (5,100,4)");result.put("anchor_restored",anchorRestored); + result.put("catalog_summary",catalogSummary);result.put("catalog_described",catalogDescribed);result.put("registry_blocks",registryCount); + result.put("max_description_bytes",maxDescriptionBytes);result.put("max_properties",maxProperties);result.put("max_property_values",maxPropertyValues); + result.put("total",total);result.put("passed",passed);result.put("entity_defaults",entityDefaults); + result.put("property_cases",propertyCases);result.put("property_passed",propertyPassed);result.put("fresh_property_passed",freshPassed); + result.put("same_material_property_passed",sameMaterialPassed);result.put("private_undo_passed",privateUndoPassed); + result.put("duration_ms",millis);result.put("fatal",fatal);result.put("failure_count",failureCount);result.put("failures",failures); + try { getDataFolder().mkdirs(); Files.writeString(getDataFolder().toPath().resolve("report.json"),new com.google.gson.GsonBuilder().serializeNulls().create().toJson(result)); } + catch(Exception e) { getLogger().severe("Could not save registry probe report"); } + getLogger().info("Registry probe: "+passed+"/"+total+", property cases "+propertyPassed+"/"+propertyCases+", failures "+failureCount+", fatal "+fatal+", "+Math.round(millis)+" ms"); + } +} diff --git a/scripts/material-registry-probe/README.md b/scripts/material-registry-probe/README.md new file mode 100644 index 0000000..0a5a8ef --- /dev/null +++ b/scripts/material-registry-probe/README.md @@ -0,0 +1,25 @@ +# Isolated material registry regression probe + +This optional test plugin exercises the **installed production plugin** through reflection. It is excluded from Maven modules and never installed by the development server launcher or this builder. + +It verifies every registered block default, every catalog description, and each distinct state obtained by varying one property at a time. Property cases cover fresh placement, changing an existing block of the same material, and exact private snapshot restoration. It also verifies that preparation does not modify the world. This is not a Cartesian enumeration of all property combinations or a test of later game ticks. + +Build from the repository root after the development Paper runtime and JDK have been prepared: + +```bash +python3 scripts/material-registry-probe/build.py +``` + +The only build outputs are under `.runtime/material-registry-probe/`. Use `--paper-home PATH` to select a prepared Paper installation for its dependency libraries and `--java-home PATH` to select another JDK 25 or newer. + +Install **only into a disposable isolated test server**, using the pinned Paper version and the production plugin JAR being tested: + +```bash +cp .runtime/material-registry-probe/material-registry-probe.jar .runtime/material-test-server/plugins/ +``` + +Start or restart that isolated server, then enter `registryprobe` in its server console. The plugin does nothing on startup and ignores player invocations. It requires port **25576**, world **`world`**, and air at **(4, 100, 4)** and **(5, 100, 4)**. It temporarily uses the second position as a stone anchor, then restores both positions; the anchor makes `cave_air` and `void_air` observable because Minecraft treats entirely empty sections as ordinary air. Do not use a valuable world just because it happens to match these guards. + +Read `plugins/MaterialRegistryProbe/report.json` in the isolated server directory. A successful report has matching default/property/catalog counts, `anchor_restored: true`, `fatal: null`, and zero failures. Reports contain only material/state identifiers, counts, timings and exception types—no block-entity payloads. The probe caps property cases at 30,000, execution at 35 seconds, and detailed failures at 128. The console command runs on the server thread; the test server should have no players. Remove the test plugin after use. + +The initial Paper 26.2 verification covered 1,196 block defaults, 186 block-entity defaults, and 5,392 distinct property cases in approximately three seconds. These counts are observations, not hardcoded expectations; the probe follows the runtime registry. diff --git a/scripts/material-registry-probe/build.py b/scripts/material-registry-probe/build.py new file mode 100644 index 0000000..02bd169 --- /dev/null +++ b/scripts/material-registry-probe/build.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Build the opt-in isolated Paper regression probe; never install or run it.""" +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import shutil +import subprocess + +ROOT = Path(__file__).resolve().parents[2] +OUTPUT = ROOT / ".runtime" / "material-registry-probe" +PLUGIN = """name: MaterialRegistryProbe +version: '1.0' +main: probe.MaterialRegistryProbe +api-version: '26.2' +depend: [MinecraftBuilderMCP] +commands: + registryprobe: + description: Run isolated all-registry block snapshot verification +""" + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--paper-home", type=Path, default=ROOT / ".runtime" / "server", + help="Prepared Paper installation whose libraries supply the compile classpath") + parser.add_argument("--java-home", type=Path, + default=Path(os.environ.get("MCB_JAVA_HOME", str(Path.home() / ".cache" / "minecraft-builder-mcp" / "jdk-25.0.2"))), + help="JDK 25 or newer; defaults to the project's downloaded JDK") + args = parser.parse_args() + libraries = sorted((args.paper_home / "libraries").rglob("*.jar")) + if not libraries: + parser.error("No Paper dependency jars found; prepare the local development server first") + javac, jar = args.java_home / "bin" / "javac", args.java_home / "bin" / "jar" + if not javac.is_file() or not jar.is_file(): + parser.error("JDK javac/jar unavailable; supply --java-home or MCB_JAVA_HOME") + classes = OUTPUT / "classes" + if classes.exists(): + shutil.rmtree(classes) + classes.mkdir(parents=True) + subprocess.run([str(javac), "--release", "25", "-cp", os.pathsep.join(str(p.resolve()) for p in libraries), + "-d", str(classes), str(Path(__file__).with_name("MaterialRegistryProbe.java"))], check=True) + (classes / "plugin.yml").write_text(PLUGIN, encoding="utf-8") + output = OUTPUT / "material-registry-probe.jar" + subprocess.run([str(jar), "--create", "--file", str(output), "-C", str(classes), "."], check=True) + print(output) + + +if __name__ == "__main__": + main() diff --git a/scripts/plaza-assets.py b/scripts/plaza-assets.py new file mode 100644 index 0000000..41b6bc5 --- /dev/null +++ b/scripts/plaza-assets.py @@ -0,0 +1,177 @@ +"""Small, stateless voxel assets for the Shacraft arrival gardens. + +Every public builder returns ``{(x, y, z): full_block_state}`` in local block +coordinates. Origin Y is the first air block above the paving/soil: translate +by Y96 for a paving block at Y95. No builder reads or changes a Minecraft world. +Trees need soil under their trunk; the composer owns foundations, collision +checks, leaf-distance propagation, block connection updates, and placement. +""" + +from __future__ import annotations + +import json +import math +import random + +Position = tuple[int, int, int] +Asset = dict[Position, str] +_DIRECTIONS = ("north", "east", "south", "west") + + +def _state(material: str, **properties: object) -> str: + suffix = ",".join( + f"{key}={str(value).lower()}" for key, value in sorted(properties.items()) + ) + return f"minecraft:{material}" + (f"[{suffix}]" if suffix else "") + + +def _stair(material: str, facing: str) -> str: + return _state(material, facing=facing, half="bottom", shape="straight", waterlogged=False) + + +def _connections(material: str, *directions: str) -> str: + return _state(material, **{d: d in directions for d in _DIRECTIONS}, waterlogged=False) + + +def _rotate(asset: Asset, quarter_turns: int) -> Asset: + """Rotate north to east, including stair backs and fence/bar connections.""" + result: Asset = {} + direction = {d: _DIRECTIONS[(i + quarter_turns) % 4] for i, d in enumerate(_DIRECTIONS)} + for (x, y, z), state in asset.items(): + for _ in range(quarter_turns): + x, z = -z, x + if "[" in state: + material, raw = state[:-1].split("[", 1) + properties = dict(pair.split("=", 1) for pair in raw.split(",")) + properties = {direction.get(k, k): direction.get(v, v) for k, v in properties.items()} + if quarter_turns % 2 and properties.get("axis") in ("x", "z"): + properties["axis"] = "z" if properties["axis"] == "x" else "x" + state = _state(material.removeprefix("minecraft:"), **properties) + result[x, y, z] = state + return result + + +def conifer(height: int = 13, seed: int = 0) -> Asset: + """A narrow spruce with irregular connected whorls and three clear trunk rows. + + Height is the occupied block count, 11..15. The maximum canopy radius is + three blocks, and foliage begins at local Y3. Leaves deliberately start at + distance=7; recompute distances against the composed world before applying. + """ + if type(height) is not int or not 11 <= height <= 15: + raise ValueError("Conifer height must be an integer from 11 through 15") + if type(seed) is not int: + raise ValueError("Conifer seed must be an integer") + rng = random.Random(seed) + phase = rng.uniform(0, math.tau) + phase2 = rng.uniform(0, math.tau) + leaves = _state("spruce_leaves", distance=7, persistent=True, waterlogged=False) + asset: Asset = {} + for y in range(3, height): + progress = (y - 3) / (height - 4) + tier = (0.30, -0.30, -0.65)[(y - 3) % 3] + radius = max(0.0, 2.95 * (1.0 - progress) ** 0.85 + tier) + if y == height - 1: + radius = 0.0 + for x in range(-3, 4): + for z in range(-3, 4): + angle = math.atan2(z, x) + edge = radius + 0.23 * math.cos(4 * angle + phase) + 0.16 * math.sin(3 * angle + phase2) + edge += rng.uniform(-0.10, 0.10) + if (x == 0 and z == 0) or math.hypot(x, z) <= edge: + asset[x, y, z] = leaves + # Leave a connected green leader above the last woody branch; overwriting + # these narrow upper layers with logs creates visible brown pegs at the tip. + for y in range(height - 5): + asset[0, y, 0] = _state("spruce_log", axis="y") + # Angular variation can leave a diagonal-only leaf at a narrow upper tier. + # Keep the face-connected canopy so no isolated foliage floats beside it. + connected = {(0, 0, 0)} + pending = [(0, 0, 0)] + while pending: + x, y, z = pending.pop() + for dx, dy, dz in ((1, 0, 0), (-1, 0, 0), (0, 1, 0), (0, -1, 0), (0, 0, 1), (0, 0, -1)): + neighbor = (x + dx, y + dy, z + dz) + if neighbor in asset and neighbor not in connected: + connected.add(neighbor) + pending.append(neighbor) + return {position: state for position, state in asset.items() if position in connected} + + +def bench(length: int = 5, facing: str = "north") -> Asset: + """A 3..5 block overall bench, including its two stone armrests. + + ``facing`` is the seated viewer's direction, not Minecraft's stair-facing + property: a north-looking seat has a south-facing stair/high back. The + default occupies X=-2..2, Z=0..1, Y=0; its front opens toward negative Z. + The fence back has Minecraft's 1.5-block collision height. This is decorative + seating and does not add a sit interaction. + """ + if type(length) is not int or not 3 <= length <= 5: + raise ValueError("Bench overall length must be an integer from 3 through 5") + if facing not in _DIRECTIONS: + raise ValueError("Bench facing must be north, east, south, or west") + first = -(length // 2) + last = first + length - 1 + asset: Asset = {} + for x in range(first + 1, last): + asset[x, 0, 0] = _stair("spruce_stairs", "south") + asset[x, 0, 1] = _connections("spruce_fence", "north", "east", "west") + for x in (first, last): + asset[x, 0, 0] = _state("stone_bricks") + asset[x, 0, 1] = _state("stone_bricks") + return _rotate(asset, _DIRECTIONS.index(facing)) + + +def lamp(height: int = 6) -> Asset: + """A slender warm street lamp with a copper hood and a small brass collar. + + Height is 6..8 occupied blocks. The stone/chain stem occupies one column; + the 3x3 cross-shaped housing begins at height-3, above pedestrian headroom. + Its hanging lantern has a full copper block immediately above it. The four + inward-facing copper stairs form the hood eaves; no trapdoors are used. + """ + if type(height) is not int or not 6 <= height <= 8: + raise ValueError("Lamp height must be an integer from 6 through 8") + collar_y = height - 3 + light_y = height - 2 + roof_y = height - 1 + asset: Asset = { + (0, 0, 0): _state("chiseled_stone_bricks"), + (0, 1, 0): _state("stone_brick_wall", east="none", north="none", south="none", up=True, waterlogged=False, west="none"), + (0, collar_y, 0): _state("gold_block"), + (0, light_y, 0): _state("lantern", hanging=True, waterlogged=False), + (0, roof_y, 0): _state("waxed_oxidized_cut_copper"), + } + for y in range(2, collar_y): + asset[0, y, 0] = _state("iron_chain", axis="y", waterlogged=False) + for x, z, inward in ((-1, 0, "east"), (1, 0, "west"), (0, -1, "south"), (0, 1, "north")): + asset[x, collar_y, z] = _connections("iron_bars", inward) + asset[x, light_y, z] = _connections("iron_bars") + asset[x, roof_y, z] = _stair("waxed_oxidized_cut_copper_stairs", inward) + return asset + + +def describe(asset: Asset) -> dict: + """Compact occupied bounds and ground contact cells for the layout composer.""" + if not asset: + raise ValueError("Cannot describe an empty asset") + low = [min(p[i] for p in asset) for i in range(3)] + high = [max(p[i] for p in asset) for i in range(3)] + return { + "blocks": len(asset), + "min": dict(zip(("x", "y", "z"), low)), + "max": dict(zip(("x", "y", "z"), high)), + "size": dict(zip(("x", "y", "z"), (high[i] - low[i] + 1 for i in range(3)))), + "ground_contacts": sorted([x, z] for x, y, z in asset if y == 0), + "materials": sorted({state.split("[", 1)[0] for state in asset.values()}), + } + + +if __name__ == "__main__": + print(json.dumps({ + "coordinate_convention": "Local Y0 is first air above paving; add Y96 over paving Y95.", + "conifer": describe(conifer()), + "bench_north": describe(bench()), + "lamp": describe(lamp()), + }, indent=2)) diff --git a/scripts/render-layout-map.py b/scripts/render-layout-map.py new file mode 100644 index 0000000..9c7f044 --- /dev/null +++ b/scripts/render-layout-map.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Render a survey atlas from live surface data; optional blocks are a labelled preview.""" +import argparse +import json +import math +from pathlib import Path +import numpy as np +import matplotlib +matplotlib.use('Agg') +import matplotlib.pyplot as plt +from matplotlib.colors import to_rgb, LightSource + +ROOT=Path(__file__).resolve().parents[1] +COLOR={'01':'#98d84d','02':'#f6d34a','03':'#9460ce','04':'#23b6b6','05':'#e3544b', + '06':'#f78d27','07':'#f0f0e6','08':'#4a69d8','09':'#f394b5'} +BLOCK={'lime':'#98d84d','yellow':'#f6d34a','purple':'#9460ce','cyan':'#23b6b6', + 'green':'#527c31','orange':'#f78d27','white':'#f0f0e6','blue':'#4a69d8', + 'pink':'#f394b5','black':'#26282d','light_gray':'#a4aaa6','red':'#e3544b'} + +def main(): + p=argparse.ArgumentParser();p.add_argument('surface',type=Path);p.add_argument('--study',type=Path,required=True) + p.add_argument('--blocks',type=Path);p.add_argument('--output',type=Path,required=True) + args=p.parse_args();s=json.loads(args.surface.read_text());study=json.loads(args.study.read_text()) + h=np.array(s['surface_y']).reshape(s['length'],s['width']).astype(float) + indexes=np.array(s['material_index']).reshape(h.shape);palette=list(s['palette']);colors=list(s['palette_rgb']) + if args.blocks: + for b in json.loads(args.blocks.read_text())['blocks']: + x,z=b['x']-s['min_x'],b['z']-s['min_z'] + if b['y']',color=fg,lw=1.5)) + ax.plot([-288,-224],[355,355],color=fg,lw=3);ax.text(-256,370,'64 blocks',color=fg,ha='center',fontsize=9) + fig.text(.06,.953,'SHACRAFT',color=fg,fontsize=28,fontweight='bold') + fig.text(.242,.958,'LOBBY / SITE MARKING',color=muted,fontsize=16) + fig.text(.746,.881,'DISTRICTS',color=muted,fontsize=12,fontweight='bold') + descriptions={ + '01':'Hexagonal arrival plaza\nCentral Shacraft medallion', + '02':'Clock tower + station hall\nPavilions and forecourt', + '03':'Portal concourse\nSix individual portal bays', + '04':'Airship terminal\nThree piers + flagship reserve', + '05':'Palm house · winter garden\nObservatory and garden walks', + '06':'Market square\nSix separate building plots', + '07':'Arrival avenue and viaduct\nSouthern entrance to the valley', + '08':'Lake pumping house\nBoardwalk and viewing terrace', + '09':'Scenic overlooks\nSmall optional ridge trails'} + for i,d in enumerate(study['districts']): + y=.842-i*.067;ident=d['id'] + fig.text(.749,y,ident,color=COLOR[ident],fontsize=16,fontweight='bold') + fig.text(.779,y,descriptions[ident],color=fg,fontsize=10,linespacing=1.55) + fig.text(.747,.20,'READING THE MARKS',color=muted,fontsize=11,fontweight='bold') + fig.text(.747,.169,'Color = building / courtyard boundary\nWhite = future path edges\nRaised ribs = bridge deck reservation\nLit stakes = corners and wayfinding',color=fg,fontsize=9,linespacing=1.7,va='top') + note='DESIGN PREVIEW · proposed blocks over a live survey' if args.blocks else 'ACTUAL WORLD SURFACE · captured after placement · no player camera required' + fig.text(.06,.055,note,color=fg,fontsize=10) + fig.text(.06,.035,'768 × 768 world · central development shown · terrain heights and water preserved · paths and stairs are still reservations',color=muted,fontsize=9) + args.output.parent.mkdir(parents=True,exist_ok=True);fig.savefig(args.output,dpi=150,facecolor=bg);plt.close(fig) + print(args.output) + +if __name__=='__main__':main() diff --git a/scripts/station-exterior.py b/scripts/station-exterior.py new file mode 100644 index 0000000..5cf50c0 --- /dev/null +++ b/scripts/station-exterior.py @@ -0,0 +1,423 @@ +#!/usr/bin/env python3 +"""Deterministic, detached exterior for the approved two-floor Shacraft station. + +The caller supplies the exact station foundation cells and combines this shell with +the interior before taking expected-state snapshots. This module never reads or +writes a Minecraft world. Coordinates and block states are explicit and stable. +""" + +from collections import Counter, defaultdict +from math import ceil, hypot + + +AIR = "minecraft:air" +STONE = "minecraft:stone_bricks" +DARK = "minecraft:polished_deepslate" +CREAM = "minecraft:smooth_sandstone" +ASHLAR = "minecraft:cut_sandstone" +PALE = "minecraft:smooth_quartz" +PILLAR = "minecraft:quartz_pillar[axis=y]" +GREEN = "minecraft:waxed_oxidized_cut_copper" +DEEP_GREEN = "minecraft:green_concrete" +GOLD = "minecraft:gold_block" +WOOD = "minecraft:spruce_planks" +GLASS = "minecraft:brown_stained_glass" +CLEAR_GLASS = "minecraft:glass" +LAMP = "minecraft:lantern[hanging=true,waterlogged=false]" +CHAIN = "minecraft:iron_chain[axis=y,waterlogged=false]" +GLOW = "minecraft:glowstone" +CARDINALS = ((0, -1, "north"), (1, 0, "east"), (0, 1, "south"), (-1, 0, "west")) + + +def _dilate(cells, radius): + return {(x + dx, z + dz) for x, z in cells + for dx in range(-radius, radius + 1) for dz in range(-radius, radius + 1)} + + +def _erode(cells, radius): + return {(x, z) for x, z in cells if all((x + dx, z + dz) in cells + for dx in range(-radius, radius + 1) for dz in range(-radius, radius + 1))} + + +def _runs(values): + result = [] + for value in sorted(values): + if result and value == result[-1][-1] + 1: + result[-1].append(value) + else: + result.append([value]) + return result + + +def _line(x0, y0, x1, y1): + """Inclusive integer line for legible clock hands and roof trim.""" + dx, dy = abs(x1 - x0), -abs(y1 - y0) + sx, sy = 1 if x0 < x1 else -1, 1 if y0 < y1 else -1 + error = dx + dy + while True: + yield x0, y0 + if (x0, y0) == (x1, y1): + return + twice = 2 * error + if twice >= dy: + error += dy + x0 += sx + if twice <= dx: + error += dx + y0 += sy + + +def compile_exterior(footprint: set[tuple[int, int]], layout: dict): + """Return (block states, ownership groups, metadata), with no external effects. + + Structural floors are exactly the supplied footprint. Interior ornament may + replace their finish, but must retain the two complete separating decks. + All exterior windows are full glass blocks, including their inner wall layer. + Only the south entrance removes wall blocks below the sealed roof space. + """ + footprint = {(int(x), int(z)) for x, z in footprint} + if not footprint: + raise ValueError("Station foundation must not be empty") + if any(not (-74 <= x <= 40 and -145 <= z <= -83) for x, z in footprint): + raise ValueError("Foundation exceeds the approved station envelope") + if layout.get("perimeter_wall_thickness", 2) != 2: + raise ValueError("The approved shell requires two-block perimeter walls") + inner = _erode(footprint, 2) + walls = footprint - inner + boundary = footprint - _erode(footprint, 1) + shadow = _dilate(footprint, 2) + maximum_shadow = _dilate(footprint, 3) + states, groups = {}, {} + + def put(x, y, z, state, group): + x, y, z = int(x), int(y), int(z) + if not (-78 <= x <= 44 and -149 <= z <= -80 and 98 <= y <= 160): + raise ValueError(f"Exterior escaped approved coordinate bounds: {(x, y, z)}") + if (x, z) not in maximum_shadow: + raise ValueError(f"Exterior overhang exceeds three blocks: {(x, y, z)}") + if y == 98 and (x, z) not in footprint: + raise ValueError(f"Ground floor escaped foundation: {(x, y, z)}") + states[x, y, z] = state + groups[x, y, z] = group + + def box(x0, x1, y0, y1, z0, z1, state, group, mask=None): + for z in range(z0, z1 + 1): + for x in range(x0, x1 + 1): + if mask is not None and (x, z) not in mask: + continue + for y in range(y0, y1 + 1): + put(x, y, z, state, group) + + def stair(material, facing, half="bottom"): + return f"minecraft:{material}[facing={facing},half={half},shape=straight,waterlogged=false]" + + # The upper cabin will be stationary; neither deck contains a lift-shaft hole. + for x, z in sorted(footprint): + for y, state, group in ((98, CREAM, "floor.ground"), + (111, WOOD, "floor.intermediate.structure"), + (112, CREAM, "floor.upper"), + (124, WOOD, "ceiling.upper.structure"), + (125, CREAM, "ceiling.upper.seal")): + put(x, y, z, state, group) + for x, z in sorted(walls): + for y in range(99, 125): + state = ASHLAR + if y == 99: + state = DARK + elif y == 100: + state = STONE + elif y in (101, 109, 113, 122, 123): + state = CREAM + elif y in (110, 111): + state = STONE + elif y in (112, 124): + state = PALE + put(x, y, z, state, "wall.masonry") + + # Identify straight stretches from the exact contour, including its eastern recess. + faces = [] + for nx, nz, facing in CARDINALS: + planes = defaultdict(list) + for x, z in boundary: + if (x + nx, z + nz) not in footprint: + planes[z if nz else x].append(x if nz else z) + for plane, positions in sorted(planes.items()): + for run in _runs(positions): + if len(run) >= 7: + faces.append((nx, nz, facing, plane, run[0], run[-1])) + + def face_position(nx, nz, plane, tangent, depth=0): + # Positive depth is toward the inside; negative depth is a relief projection. + return (tangent - nx * depth, plane - nz * depth) if nz else (plane - nx * depth, tangent - nz * depth) + + windows, pilasters, facade_lamps = [], [], [] + for nx, nz, facing, plane, lo, hi in faces: + phase = -6 if nz else -119 + centers = [c for c in range(lo + 3, hi - 2) if (c - phase) % 10 == 0] + if not centers and hi - lo >= 8: + centers = [(lo + hi) // 2] + for center in centers: + for base, top in ((102, 108), (115, 122)): + # A five-wide pointed arch, enclosed by cream voussoirs and piers. + for tangent in range(center - 3, center + 4): + delta = abs(tangent - center) + for y in range(base - 1, top + 2): + cap = top - max(0, delta - 1) + opening = delta <= 2 and base <= y <= cap + state = GLASS if opening else CREAM + if opening and tangent == center and y < top - 1: + state = WOOD + if opening and y == base + 3: + state = WOOD + if delta == 3 and y <= top - 1: + state = PILLAR + for depth in (0, 1): + x, z = face_position(nx, nz, plane, tangent, depth) + if (x, z) in walls: + put(x, y, z, state, "window.frame" if state != GLASS else "window.glass") + # Bottom sill projects by one block but never opens the shell. + for tangent in range(center - 3, center + 4): + x, z = face_position(nx, nz, plane, tangent, -1) + if (x, z) in maximum_shadow: + put(x, base - 1, z, CREAM, "window.sill") + windows.append({"normal": facing, "plane": plane, "center": center, + "base_y": base, "apex_y": top, "glass_layers": 2}) + # Vertical bays use quiet, regular dressed-stone pilasters, not material noise. + pier_centers = sorted({lo, hi, *[c + 5 for c in centers if c + 5 <= hi]}) + for center in pier_centers: + x, z = face_position(nx, nz, plane, center) + for depth in (0, 1): + px, pz = face_position(nx, nz, plane, center, depth) + if (px, pz) not in walls: + continue + for y in range(101, 124): + put(px, y, pz, PILLAR if y not in (110, 111, 112) else PALE, "wall.pilaster") + pilasters.append([x, z]) + if center not in (lo, hi) and hi - lo >= 15: + px, pz = face_position(nx, nz, plane, center, -1) + # Eave-mounted lighting: clear below, and supported immediately above. + put(px, 124, pz, CREAM, "lamp.bracket") + put(px, 123, pz, CHAIN, "lamp.chain") + put(px, 122, pz, LAMP, "lamp.lantern") + facade_lamps.append([px, 122, pz]) + + # Continuous layered cornices connect every projection of the exact footprint. + first_relief = _dilate(footprint, 1) - inner + second_relief = shadow - _erode(footprint, 1) + for x, z in sorted(first_relief): + put(x, 110, z, CREAM, "cornice.floor.lower") + put(x, 112, z, PALE, "cornice.floor.upper") + put(x, 124, z, CREAM, "cornice.eave.lower") + for x, z in sorted(second_relief): + put(x, 125, z, CREAM, "cornice.eave.upper") + + # Union of three pitched masses: a broad central hall and two hipped end wings. + # Every roof column is closed, without making the inaccessible attic solid fill. + def roof_height(x, z): + candidates = [] + if -53 <= x <= 18 and -147 <= z <= -95: + candidates.append(140 - ceil(abs(z + 121) * 14 / 26)) + if -76 <= x <= -50 and -141 <= z <= -96: + candidates.append(126 + max(0, min(13 - abs(x + 63), z + 141, -96 - z))) + if 14 <= x <= 42 and -141 <= z <= -96: + candidates.append(126 + max(0, min(14 - abs(x - 28), z + 141, -96 - z))) + if -27 <= x <= 15 and -100 <= z <= -81: + candidates.append(126 + max(0, min(10 - abs(z + 91), x + 27, 15 - x))) + return max([126, *candidates]) + + roof_heights = {p: min(140, roof_height(*p)) for p in shadow} + shadow_boundary = shadow - _erode(shadow, 1) + for x, z in sorted(shadow): + top = roof_heights[x, z] + # Close all verge/gable faces down to the continuous eave line. + if (x, z) in shadow_boundary: + for y in range(126, top): + put(x, y, z, ASHLAR if y < top - 1 else GREEN, "roof.gable") + put(x, top - 1, z, GREEN, "roof.underlay") + uphill = [(roof_heights.get((x + dx, z + dz), top - 1), facing) + for dx, dz, facing in CARDINALS] + high, facing = max(uphill, key=lambda item: item[0]) + state = stair("waxed_oxidized_cut_copper_stairs", facing) if high > top else GREEN + if (x + 6) % 12 == 0 and top < 139: + state = GREEN + put(x, top, z, state, "roof.copper") + if top == 140: + put(x, 140, z, GREEN, "roof.ridge") + + # Four pavilion lantern roofs lend a clear rhythm to the ends of the facade. + pavilions = [(-66, -131, 7), (-66, -106, 7), (32, -131, 7), (28, -106, 6)] + for cx, cz, radius in pavilions: + pavilion = {(x, z) for x in range(cx - radius, cx + radius + 1) + for z in range(cz - radius, cz + radius + 1) if (x, z) in shadow} + for x, z in sorted(pavilion): + ring = max(abs(x - cx), abs(z - cz)) + top = 137 - ring + # The cap only raises the parent roof; it never cuts an accidental opening. + if top <= roof_heights[x, z]: + continue + for y in range(roof_heights[x, z], top): + put(x, y, z, GREEN if y >= top - 1 else CREAM, "pavilion.roof.support") + facing = "east" if x < cx else "west" if x > cx else "south" if z < cz else "north" + put(x, top, z, stair("waxed_oxidized_cut_copper_stairs", facing) if ring else GREEN, "pavilion.roof.copper") + for y, state in ((138, GREEN), (139, GOLD), (140, CHAIN)): + put(cx, y, cz, state, "pavilion.finial") + + # Shacraft-green hanging stone panels, with a small gold S motif on the end bays. + # These are block reliefs, not entities or inventory-backed banner blocks. + shield_centers = [(-65, -97, 1), (28, -97, 1), (-65, -140, -1), (31, -140, -1)] + glyph = ("111", "100", "111", "001", "111") + for cx, plane, normal in shield_centers: + for y in range(110, 123): + half = 2 if y >= 112 else y - 109 + for dx in range(-half, half + 1): + put(cx + dx, y, plane, GOLD if abs(dx) == half else DEEP_GREEN, "ornament.green.shield") + for row, bits in enumerate(glyph): + for col, bit in enumerate(bits): + if bit == "1": + put(cx + col - 1, 120 - row, plane + normal, GOLD, "ornament.gold.s") + for dx in range(-3, 4): + put(cx + dx, 123, plane, CREAM, "ornament.shield.cap") + + # The projecting entrance arch remains seven blocks clear at useful head height. + axis = int(layout.get("entrance_axis_x", -6)) + entrance = layout.get("entrance_opening_x", [-9, -3]) + for x in range(axis - 6, axis + 7): + dx = abs(x - axis) + for z in (-85, -84): + if (x, z) not in footprint: + continue + for y in range(99, 111): + if dx in (4, 5): + put(x, y, z, PILLAR if y > 100 else DARK, "entrance.pier") + elif y >= 109 - min(dx, 4): + put(x, y, z, CREAM, "entrance.arch") + entrance_air = [] + for x in range(int(entrance[0]), int(entrance[1]) + 1): + top = 109 - abs(x - axis) + for z in (-85, -84, -83): + for y in range(99, top + 1): + put(x, y, z, AIR, "entrance.clear") + entrance_air.append([x, y, z]) + for px in (axis - 6, axis + 6): + put(px, 108, -83, CREAM, "entrance.lamp.bracket") + put(px, 108, -82, CREAM, "entrance.lamp.bracket") + put(px, 107, -82, CHAIN, "entrance.lamp.chain") + put(px, 106, -82, LAMP, "entrance.lamp") + # A high, glazed lancet over the portal echoes the long window below the reference clock. + for x in range(axis - 3, axis + 4): + for y in range(115, 124): + cap = 123 - abs(x - axis) + state = GLASS if y <= cap else CREAM + if x == axis or y == 118: + state = WOOD if y <= cap else CREAM + for z in (-85, -84): + if (x, z) in walls: + put(x, y, z, state, "entrance.upper.lancet") + + # Sealed clock tower: solid underside already exists at Y124/125, no future-floor doorway. + tx0, tx1, tz0, tz1 = axis - 9, axis + 9, -102, -84 + # Carry the tower's front corner piers down through both public-storey facades. + for x in (tx0, tx0 + 1, tx1 - 1, tx1): + for z in (tz1, tz1 - 1): + for y in range(101, 125): + put(x, y, z, PALE if y in (110, 111, 112, 124) else PILLAR, "tower.facade.pier") + tower = {(x, z) for x in range(tx0, tx1 + 1) for z in range(tz0, tz1 + 1)} + tower_inner = _erode(tower, 2) + tower_wall = tower - tower_inner + for x, z in sorted(tower): + put(x, 126, z, CREAM, "tower.base.seal") + for x, z in sorted(tower_wall): + corner = (x <= tx0 + 1 or x >= tx1 - 1) and (z <= tz0 + 1 or z >= tz1 - 1) + for y in range(127, 148): + state = PILLAR if corner else ASHLAR + if y in (128, 130, 147): + state = PALE + put(x, y, z, state, "tower.masonry") + # Clock disks have a stone backing, recessed cream face and four gold bezels. + clock_faces = [("south", axis, -82), ("north", axis, -104), + ("west", -16, -93), ("east", 4, -93)] + hour_marks = {(0, 6), (0, -6), (6, 0), (-6, 0), (4, 4), (-4, 4), (4, -4), (-4, -4)} + hands = set(_line(0, 0, -3, 4)) | set(_line(0, 0, 3, 3)) + for facing, center, plane in clock_faces: + nx, nz = {"south": (0, 1), "north": (0, -1), "west": (-1, 0), "east": (1, 0)}[facing] + for u in range(-8, 9): + for v in range(-8, 9): + radius = hypot(u, v) + if radius > 8.3: + continue + x, z = (center + u, plane) if nz else (center, plane + u) + # All ornament is supported by one continuous backing layer. + put(x - nx, 139 + v, z - nz, CREAM, "clock.backing") + state = CREAM + if radius > 7.4: + state = PALE + elif radius > 6.5: + state = GOLD + elif (u, v) in hour_marks: + state = DEEP_GREEN + elif (u, v) in hands: + state = DARK + if (u, v) == (0, 0): + state = GOLD + put(x, 139 + v, z, state, "clock.face." + facing) + # Broad cream cap, steep patinated copper crown and a restrained gold finial. + tower_cap = _dilate(tower, 1) + for x, z in sorted(tower_cap): + put(x, 148, z, PALE, "tower.cornice") + for x, z in sorted(tower): + radius = max(abs(x - axis), abs(z + 93)) + top = 149 + max(0, 7 - radius) + for y in range(149, top): + put(x, y, z, GREEN, "tower.roof.underlay") + facing = "east" if x < axis else "west" if x > axis else "south" if z < -93 else "north" + put(x, top, z, stair("waxed_oxidized_cut_copper_stairs", facing) if radius else GREEN, "tower.roof.copper") + for x, z in ((tx0, tz0), (tx1, tz0), (tx0, tz1), (tx1, tz1)): + put(x, 150, z, GREEN, "tower.corner.finial") + put(x, 151, z, GOLD, "tower.corner.finial") + put(x, 152, z, CHAIN, "tower.corner.finial") + for y, state in ((157, GOLD), (158, CHAIN)): + put(axis, y, -93, state, "tower.central.finial") + for x, z in ((tx0 - 1, tz1), (tx1 + 1, tz1), (tx0 - 1, tz0), (tx1 + 1, tz0)): + put(x, 148, z, PALE, "tower.lamp.bracket") + put(x, 147, z, CHAIN, "tower.lamp.chain") + put(x, 146, z, LAMP, "tower.lamp") + + # Deterministic checks describe the shell; the root performs full merged navigation QA. + entrance_columns = {(x, z) for x in range(entrance[0], entrance[1] + 1) for z in (-85, -84, -83)} + assert all(states.get((x, y, z)) == AIR for x, z in entrance_columns for y in range(99, 103)) + assert all((x, z) in footprint for (x, y, z) in states if y == 98) + assert all(states[x, 112, z] != AIR and states[x, 125, z] != AIR for x, z in footprint) + assert {p for p, state in states.items() if state == AIR} == {tuple(p) for p in entrance_air} + solid = {p for p, state in states.items() if state != AIR} + remaining = set(solid) + queue = [remaining.pop()] + while queue: + x, y, z = queue.pop() + for p in ((x + 1, y, z), (x - 1, y, z), (x, y + 1, z), + (x, y - 1, z), (x, y, z + 1), (x, y, z - 1)): + if p in remaining: + remaining.remove(p) + queue.append(p) + assert not remaining, "Exterior contains detached floating ornament" + metadata = { + "generator": "station-exterior-v1", "world_writes": 0, + "foundation_columns": len(footprint), "two_block_wall_columns": len(walls), + "bounds": {"min": [min(p[i] for p in states) for i in range(3)], + "max": [max(p[i] for p in states) for i in range(3)]}, + "floor_block_y": [98, 112], "walk_y": [99, 113], + "solid_intermediate_deck": [111, 112], "sealed_roof_ceiling": [124, 125], + "main_roof_eaves_y": 126, "main_roof_ridge_y": 140, "clocktower_peak_y": 158, + "entrance_clear_x": list(entrance), "entrance_clear_z": [-85, -84, -83], + "entrance_minimum_clear_height": 8, "windows": windows, "pilasters": pilasters, + "facade_lamps": facade_lamps, "pavilions": [list(p) for p in pavilions], + "clock_faces": [{"facing": f, "center_or_x": c, "plane_or_z": p, + "center_y": 139, "radius": 8} for f, c, p in clock_faces], + "materials": sorted({state.split("[")[0] for state in states.values()}), + "states_by_group": dict(sorted(Counter(groups.values()).items())), + "blocks": len(states), "entrance_air_blocks": len(entrance_air), + "checks": {"ground_floor_on_exact_footprint": True, "two_solid_separating_decks": True, + "only_main_entrance_is_open": True, "roof_and_tower_have_no_doorway": True, + "maximum_overhang_blocks": 3, "all_windows_full_block_glass": True, + "all_solid_states_share_one_cardinally_connected_component": True}, + } + return states, groups, metadata diff --git a/scripts/station-interior.py b/scripts/station-interior.py new file mode 100644 index 0000000..250138c --- /dev/null +++ b/scripts/station-interior.py @@ -0,0 +1,514 @@ +#!/usr/bin/env python3 +"""Compile the two furnished Shacraft station floors without world I/O. + +The caller owns the exterior, authoritative survey and expected-state writes. +This module owns only the two-block-eroded footprint at block Y 98..123. +All six SMASH panels are decorative, unassigned slots. Functional signs, text +displays and the individual lift transition are deliberately metadata only. +""" + +from collections import Counter, deque + + +AIR = "minecraft:air" +CREAM = "minecraft:smooth_sandstone" +PALE = "minecraft:smooth_quartz" +CUT = "minecraft:cut_sandstone" +GREEN = "minecraft:waxed_oxidized_cut_copper" +GOLD = "minecraft:gold_block" +WOOD = "minecraft:spruce_planks" +DARK = "minecraft:dark_oak_planks" +CHAIN = "minecraft:iron_chain[axis=y,waterlogged=false]" +LANTERN = "minecraft:lantern[hanging=true,waterlogged=false]" +LEAF = "minecraft:spruce_leaves[distance=7,persistent=true,waterlogged=false]" +LOG = "minecraft:spruce_log[axis=y]" + + +def _slab(material, top=False): + return f"minecraft:{material}[type={'top' if top else 'bottom'},waterlogged=false]" + + +def _stair(material, facing, top=False): + return (f"minecraft:{material}[facing={facing},half={'top' if top else 'bottom'}," + "shape=straight,waterlogged=false]") + + +def _rect(box): + a, b, c, d = map(int, box) + return {(x, z) for x in range(a, b + 1) for z in range(c, d + 1)} + + +def _pose(x, y, z, text, facing="south", scale=0.7, **extra): + return {"position": {"x": x, "y": y, "z": z}, "text": text, + "facing": facing, "scale": scale, **extra} + + +class _Interior: + def __init__(self, footprint, layout): + self.footprint = {(int(x), int(z)) for x, z in footprint} + self.inside = {(x, z) for x, z in self.footprint + if all((x + dx, z + dz) in self.footprint + for dx in range(-2, 3) for dz in range(-2, 3))} + if not self.inside: + raise ValueError("The station needs an interior after its two-block setback") + self.layout = layout + self.states = {} + self.groups = {} + self.features = [] + self.texts = [] + self.targets = {99: [], 113: []} + self.selections = [] + self.lift = {} + self.floor_walk = {} + + def put(self, x, y, z, state, group): + p = (int(x), int(y), int(z)) + if (p[0], p[2]) not in self.inside or not 98 <= p[1] <= 123: + raise ValueError(f"Interior write outside the owned volume: {p} ({group})") + self.states[p] = state + self.groups[p] = group + + def box(self, bounds, lo, hi, state, group, clipped=False): + for x, z in sorted(_rect(bounds)): + if clipped and (x, z) not in self.inside: + continue + for y in range(lo, hi + 1): + self.put(x, y, z, state, group) + + def target(self, feet, x, z, name): + self.targets[feet].append({"name": name, "x": x, "y": feet, "z": z}) + + def record(self, kind, bounds, feet, **extra): + self.features.append({"kind": kind, "bounds_xz": list(bounds), + "walk_y": feet, **extra}) + + def shell(self): + for x, z in sorted(self.inside): + for y in range(98, 124): + if y in (98, 112): + state, group = CREAM, "interior:continuous-floor" + elif y == 111: + state, group = "minecraft:stone_bricks", "interior:solid-intermediate-deck" + else: + state, group = AIR, "interior:owned-room-air" + self.put(x, y, z, state, group) + + def floors(self, feet): + floor = feet - 1 + group = f"interior:{feet}:floor-inlay" + # Large calm limestone panels, outlined by the architectural column grid. + for x, z in sorted(self.inside): + if x in (-51, -46, -21, -16, 15, 20) or z in (-129, -124, -106, -101): + self.put(x, floor, z, PALE, group) + if x in (-50, -17, 19) and -135 <= z <= -93: + self.put(x, floor, z, CUT, group) + for x in (-11, -10, -2, -1): + for z in range(-115, -84): + if (x, z) in self.inside: + self.put(x, floor, z, GREEN if x in (-10, -2) else PALE, group) + for z in (-111, -103, -95, -87): + for x in (-10, -2): + if (x, z) in self.inside: + self.put(x, floor, z, GOLD, group) + centres = [(-59, -117), (25, -117), (-34, -103), (5, -99)] + if feet == 99: + centres += [(-33, -117), (-31, -135), (5, -135)] + for cx, cz in centres: + for dx in range(-5, 6): + for dz in range(-5, 6): + if (cx + dx, cz + dz) not in self.inside: + continue + r = abs(dx) + abs(dz) + if r == 5: + state = GREEN + elif r == 4: + state = PALE + elif r <= 2 and (dx == 0 or dz == 0): + state = GOLD if r == 0 else CUT + else: + continue + self.put(cx + dx, floor, cz + dz, state, group) + if feet == 113: + # A terracotta runner belongs to SMASH, while the centre axis stays pale. + for x, z in sorted(_rect([-46, 11, -134, -130])): + self.put(x, floor, z, "minecraft:orange_terracotta", group) + for x in range(-46, 12): + for z in (-135, -129): + self.put(x, floor, z, PALE if x % 10 else GOLD, group) + for x, z in sorted(_rect([22, 29, -124, -112])): + edge = x in (22, 29) or z in (-124, -112) + self.put(x, floor, z, GREEN if edge else "minecraft:orange_terracotta", group) + + def arcade(self, feet, ceiling): + group = f"interior:{feet}:arcade" + panel_y = ceiling - 1 + # A solid coffer ceiling closes the upper floor below the inaccessible attic. + for x, z in sorted(self.inside): + self.put(x, panel_y, z, WOOD, group) + if z in (-139, -128, -125, -105, -102, -91) or x in (-50, -47, -20, -17, 16, 19): + self.put(x, panel_y, z, DARK, group) + if z in (-128, -125, -105, -102): + self.put(x, panel_y - 1, z, CREAM, group) + for bounds in self.layout["aligned_columns"]: + a, b, c, d = bounds + self.box(bounds, feet, feet, CUT, group) + self.box(bounds, feet + 1, feet + 1, PALE, group) + self.box(bounds, feet + 2, ceiling - 4, + "minecraft:quartz_pillar[axis=y]", group) + self.box(bounds, ceiling - 3, ceiling - 1, CREAM, group) + self.box([a - 1, b + 1, c - 1, d + 1], ceiling - 3, ceiling - 3, + _slab("smooth_sandstone_slab", top=True), group, clipped=True) + self.box([a - 1, b + 1, c - 1, d + 1], ceiling - 2, ceiling - 2, + CREAM, group, clipped=True) + self.record("aligned-column", bounds, feet) + # High corbels make the two column rows read as a shallow cream arcade. + for bounds in self.layout["aligned_columns"]: + a, b, c, d = bounds + for x, facing in ((a - 2, "east"), (b + 2, "west")): + for z in (c, d): + if (x, z) in self.inside: + self.put(x, ceiling - 3, z, + _stair("smooth_sandstone_stairs", facing, top=True), group) + + def chandelier(self, x, z, feet, ceiling): + group = f"interior:{feet}:chandelier" + collar = ceiling - 4 + for y in range(collar + 1, ceiling): + self.put(x, y, z, CHAIN, group) + self.put(x, collar, z, GOLD, group) + for dx, dz in ((-1, 0), (1, 0), (0, -1), (0, 1)): + self.put(x + dx, collar, z + dz, GREEN, group) + self.put(x + dx, collar - 1, z + dz, LANTERN, group) + self.record("four-lantern-chandelier", [x - 1, x + 1, z - 1, z + 1], + feet, lowest_block_y=collar - 1) + + def bench(self, bounds, feet, facing="south", name="bench"): + a, b, c, d = bounds + if b - a < 3 or d - c != 1: + raise ValueError("Station benches use a long, two-block-deep rectangle") + group = f"interior:{feet}:bench" + back_z, seat_z = (c, d) if facing == "south" else (d, c) + stair_facing = "north" if facing == "south" else "south" + for x in range(a, b + 1): + if x in (a, b): + for z in (c, d): + self.put(x, feet, z, CUT, group) + self.put(x, feet + 1, z, _slab("smooth_sandstone_slab"), group) + else: + self.put(x, feet, seat_z, _stair("spruce_stairs", stair_facing), group) + self.put(x, feet, back_z, WOOD, group) + self.put(x, feet + 1, back_z, _slab("spruce_slab"), group) + front = d + 1 if facing == "south" else c - 1 + for x in range(a + 1, b): + self.target(feet, x, front, f"{name}-access-{x}") + self.record("spruce-bench", bounds, feet, facing=facing) + + def planter(self, x, z, feet, name="topiary"): + group = f"interior:{feet}:planter" + bounds = [x - 1, x + 1, z - 1, z + 1] + self.box(bounds, feet, feet, CUT, group) + self.put(x, feet, z, "minecraft:dirt", group) + self.put(x, feet + 1, z, LOG, group) + self.put(x, feet + 2, z, LOG, group) + for dx, dz in ((-1, 0), (0, -1), (0, 0), (0, 1), (1, 0)): + self.put(x + dx, feet + 3, z + dz, LEAF, group) + self.put(x, feet + 4, z, LEAF, group) + self.record(name, bounds, feet) + + def lift_cabin(self, feet, ceiling): + group = f"interior:{feet}:lift" + housing = self.layout["lift"]["housing"] + cabin = self.layout["lift"]["clear_cabin"] + inner = _rect(cabin) + a, b, c, d = housing + for x, z in sorted(_rect(housing)): + if (x, z) in inner: + self.put(x, feet - 1, z, "minecraft:polished_andesite", group) + continue + for y in range(feet, ceiling): + state = GREEN + if x in (a, b) and z in (c, d): + state = CUT if y in (feet, feet + 5) else CREAM + if y == feet + 5 and z == d: + state = GOLD + self.put(x, y, z, state, group) + # The unused volume above each cabin is solid, never an open shaft. + self.box(cabin, feet + 7, ceiling - 1, WOOD, group) + self.box(cabin, feet, feet + 6, AIR, group) + self.box([-8, -4, -117, -116], feet, feet + 4, AIR, group) + self.box([-8, -4, -117, -116], feet + 5, feet + 5, GOLD, group) + for x in (-8, -4): + for z in range(-122, -117): + self.put(x, feet - 1, z, GREEN, group) + self.put(-6, feet + 6, -120, CHAIN, group) + self.put(-6, feet + 5, -120, LANTERN, group) + selector = (-6, feet + 2, -123) + self.put(*selector, GOLD, group) + number = 1 if feet == 99 else 2 + self.lift[str(number)] = { + "housing_bounds_xz": housing, "clear_cabin_bounds_xz": cabin, + "walk_y": feet, "door_bounds_xz": [-8, -4, -117, -116], + "door_clear_height": 5, "selector_block": list(selector), + "destination": {"x": -5.5, "y": 113 if feet == 99 else 99, + "z": -119.5, "yaw": 0, "pitch": 0}, + "landing_block": [-6, feet, -120], "floor_is_solid": True, + } + self.texts.append(_pose(-5.5, feet + 6.2, -114.93, + "1 ВЕСТИБЮЛЬ" if number == 1 else "2 SMASH", + scale=0.9, id=f"lift-{number}-front")) + self.texts.append(_pose(-5.5, feet + 3.3, -121.92, + "Вверх · SMASH" if number == 1 else "Вниз · Вестибюль", + scale=0.45, id=f"lift-{number}-selector")) + self.target(feet, -6, -120, f"lift-{number}-landing") + self.target(feet, -6, -114, f"lift-{number}-approach") + self.record("enclosed-lift-cabin", housing, feet, floor_number=number) + + def gallery_panel(self, x, z, feet, title, subtitle, facing="east"): + group = f"interior:{feet}:gallery-panel" + # East-facing panels stand along the western wall, clear of the arcade. + for dz in range(-2, 3): + for dy in range(0, 6): + state = CUT if abs(dz) == 2 or dy in (0, 5) else DARK + self.put(x, feet + dy, z + dz, state, group) + for dz in (-2, 2): + self.put(x, feet + 2, z + dz, GOLD, group) + self.texts.append(_pose(x + 1.04, feet + 3.9, z + 0.5, + title + "\n" + subtitle, facing=facing, scale=0.52, + id=f"gallery-{feet}-{x}-{z}")) + self.target(feet, x + 3, z, title) + self.record("rules-panel" if feet == 113 else "station-gallery-panel", + [x, x, z - 2, z + 2], feet) + + def diorama(self): + feet = 113 + a, b, c, d = self.layout["smash_display"] + group = "interior:113:contained-island-diorama" + self.box([a, b, c, d], feet, feet, "minecraft:black_concrete", group) + for x, z in sorted(_rect([a, b, c, d])): + if x in (a, b) or z in (c, d): + self.put(x, feet, z, CUT, group) + for y in (feet + 1, feet + 2): + self.put(x, y, z, "minecraft:glass", group) + self.put(x, feet + 3, z, _slab("smooth_sandstone_slab"), group) + # Miniatures hover above an opaque, intact display base, inside a glass case. + for ix, (cx, cz, radius) in enumerate(((-38, -115, 2), (-29, -115, 2), (-34, -120, 2))): + self.put(cx, 114, cz, "minecraft:deepslate[axis=y]", group) + for dx in range(-radius, radius + 1): + for dz in range(-radius, radius + 1): + dist = abs(dx) + abs(dz) + if dist <= 2: + self.put(cx + dx, 115, cz + dz, "minecraft:stone", group) + if dist <= 3: + self.put(cx + dx, 116, cz + dz, "minecraft:moss_block", group) + self.put(cx, 117, cz, LOG, group) + self.put(cx, 118, cz, LOG, group) + for dx, dz in ((-1, 0), (0, -1), (0, 0), (0, 1), (1, 0)): + self.put(cx + dx, 119, cz + dz, LEAF, group) + self.put(cx, 120, cz, LEAF, group) + self.put(cx + (1 if ix != 1 else -1), 117, cz + 1, + "minecraft:red_concrete" if ix == 1 else "minecraft:light_blue_concrete", group) + self.texts.append(_pose((a + b) / 2 + 0.5, 115, d + 1.05, + "SMASH · УДЕРЖИСЬ НА ОСТРОВЕ", scale=0.6, + id="smash-diorama-caption")) + self.target(feet, -33, d + 2, "diorama-south-view") + self.target(feet, a - 2, -117, "diorama-west-view") + self.target(feet, b + 2, -117, "diorama-east-view") + self.record("closed-display-only-diorama", [a, b, c, d], feet, + enclosed=True, arena=False, floor_intact=True, top_y=120) + + def selection_bays(self): + feet = 113 + group = "interior:113:smash-selection-bays" + # Solid infill behind the recessed panels prevents unfinished rear rooms. + for x, z in sorted(self.inside): + if -47 <= x <= 13 and z <= -140: + for y in range(feet, 124): + self.put(x, y, z, CREAM, group) + for index, bounds in enumerate(self.layout["smash_selection_bays"], 1): + a, b, c, d = bounds + mid = (a + b) // 2 + for x in range(a, b + 1): + for y in range(feet, feet + 8): + self.put(x, y, c, DARK, group) + for x in (a, b): + for z in range(c + 1, d + 1): + for y in range(feet, feet + 7): + self.put(x, y, z, CUT if y == feet else CREAM, group) + self.box([a, b, c + 1, d], feet + 7, feet + 7, CREAM, group) + self.box([a + 1, b - 1, c + 1, c + 1], feet + 1, feet + 6, GOLD, group) + # Five-wide miniature reliefs are explicitly generic, unassigned previews. + for dx in range(-2, 3): + for dy in range(4): + material = "light_blue_concrete" + summit = 1 + ((dx + index) % 3 == 0) + if dy < summit: + material = "stone" if dy == 0 else "moss_block" + if dy == 3 and dx == (index % 3) - 1: + material = "white_concrete" + self.put(mid + dx, feet + 2 + dy, c + 2, + "minecraft:" + material, group) + for x in range(mid - 1, mid + 2): + self.put(x, feet, d, CUT, group) + self.put(x, feet + 1, d, WOOD, group) + self.put(mid, feet + 6, c + 2, LANTERN, group) + self.put(mid, feet + 7, c + 2, GREEN, group) + sign = [mid, feet + 1, d + 1] + support = [mid, feet + 1, d] + self.selections.append({ + "slot": f"{index:02d}", "bounds_xz": bounds, + "assigned": False, "destination": None, + "relief_is_decorative": True, + "sign_block": sign, "sign_support_block": support, + "sign_facing": "south", "sign_lines": [f"АРЕНА {index:02d}", "Не назначено", "", ""], + "standing_block": [mid, feet, d + 2], + "label_pose": _pose(mid + 0.5, feet + 6.6, d + 1.05, + f"{index:02d}", scale=0.55), + }) + self.target(feet, mid, d + 2, f"arena-{index:02d}-sign") + self.record("unassigned-arena-selection-bay", bounds, feet, slot=index) + self.box([-35, 1, -139, -138], 121, 123, DARK, group) + self.texts.append(_pose(-16.5, 122.2, -136.94, "S M A S H", scale=2.2, + id="smash-header")) + + def furniture(self, feet): + for i, bounds in enumerate(self.layout["wing_benches"]): + self.bench(bounds, feet, "south", f"wing-bench-{i + 1}") + for x, z in ((-56, -131), (-56, -106), (21, -131), (21, -106)): + self.planter(x, z, feet) + # The north and south galleries are furnished public rooms, without doors + # suggesting additional unfinished wings or upper-floor circulation. + for i, (bounds, facing) in enumerate((([-22, -15, -94, -93], "north"), + ([0, 7, -94, -93], "north"))): + self.bench(bounds, feet, facing, f"south-gallery-bench-{i + 1}") + for x, z in ((-30, -94), (12, -94)): + if _rect([x - 1, x + 1, z - 1, z + 1]) <= self.inside: + self.planter(x, z, feet) + if feet == 99: + for i, bounds in enumerate(([-44, -37, -137, -136], [-26, -19, -137, -136], + [1, 8, -137, -136])): + self.bench(bounds, feet, "south", f"north-gallery-bench-{i + 1}") + for x in (-31, 11): + self.planter(x, -137, feet) + self.gallery_panel(-72, -116, feet, "SHACRAFT", "Площадь прибытия") + self.texts.append(_pose(-5.5, 108, -114.93, + "SHACRAFT", scale=0.9, + id="vestibule-heading")) + else: + for z, title, subtitle in ((-122, "УРОН", "Больше урона — сильнее отбрасывание"), + (-115, "ОТБРАСЫВАНИЕ", "Удержись на острове"), + (-108, "ДВОЙНОЙ ПРЫЖОК", "Вернись на площадку")): + self.gallery_panel(-72, z, feet, title, subtitle) + self.texts.append(_pose(26, 118.5, -105.9, "ЗОНА ОЖИДАНИЯ", scale=0.7, + id="smash-waiting-heading")) + + def leaf_distances(self): + leaves = {p for p, s in self.states.items() if s.startswith("minecraft:spruce_leaves[")} + distance = {} + queue = deque() + for p, s in self.states.items(): + if s.startswith("minecraft:spruce_log["): + queue.append((p, 0)) + while queue: + (x, y, z), d = queue.popleft() + if d >= 6: + continue + for p in ((x - 1, y, z), (x + 1, y, z), (x, y - 1, z), + (x, y + 1, z), (x, y, z - 1), (x, y, z + 1)): + if p in leaves and d + 1 < distance.get(p, 7): + distance[p] = d + 1 + queue.append((p, d + 1)) + for p in leaves: + self.states[p] = (f"minecraft:spruce_leaves[distance={distance.get(p, 7)}," + "persistent=true,waterlogged=false]") + + def navigation(self): + details = {} + for feet, seed in ((99, (-6, -86)), (113, (-6, -120))): + clear = {(x, z) for x, z in self.inside + if all(self.states[(x, y, z)] == AIR for y in range(feet, feet + 4))} + if seed not in clear: + raise ValueError(f"Interior navigation start is obstructed at {feet}: {seed}") + reached = {seed} + queue = deque([seed]) + while queue: + x, z = queue.popleft() + for p in ((x - 1, z), (x + 1, z), (x, z - 1), (x, z + 1)): + if p in clear and p not in reached: + reached.add(p) + queue.append(p) + for target in self.targets[feet]: + if (target["x"], target["z"]) not in reached: + raise ValueError(f"Unreachable interior target: {target}") + # Close genuine inaccessible air pockets; never leave a half-built room. + sealed = clear - reached + for x, z in sorted(sealed): + for y in range(feet, 111 if feet == 99 else 124): + self.put(x, y, z, CREAM, f"interior:{feet}:sealed-service-infill") + reserved = [self.layout["reserved_clear_aisles"]["entry"]] + if feet == 113: + reserved.append(self.layout["reserved_clear_aisles"]["bay_front"]) + for bounds in reserved: + required = _rect(bounds) & self.inside + blocked = required - reached + if blocked: + raise ValueError(f"Reserved circulation is blocked at Y{feet}: {sorted(blocked)[:5]}") + self.floor_walk[feet] = [{"x": x, "y": feet, "z": z} + for x, z in sorted(reached)] + details[str(feet)] = {"walk_columns": len(reached), + "minimum_verified_headroom": 4, + "named_targets": self.targets[feet], + "sealed_inaccessible_columns": len(sealed), + "all_named_targets_reachable": True, + "reserved_aisles_clear": True} + return details + + +def compile_interior(footprint, layout): + """Return canonical voxel states, per-voxel groups, and JSON-safe metadata. + + ``footprint`` contains the actual surveyed foundation's (x, z) columns. + Geometry is deterministic and absolute, as specified by the approved layout. + The returned air states are intentional room clearance; callers must apply + the normal expected-state and protected-volume checks before any live write. + """ + b = _Interior(footprint, layout) + floors = [(int(f["walk_y"]), int(f["ceiling_underside_y"])) for f in layout["floors"]] + if floors != [(99, 111), (113, 124)]: + raise ValueError("This approved interior is defined only for walk Y99 and Y113") + b.shell() + for feet, ceiling in floors: + b.floors(feet) + b.arcade(feet, ceiling) + b.furniture(feet) + lights = [(-58, -116), (25, -116), (5, -107), (-32, -101)] + if feet == 99: + lights += [(-33, -116), (-30, -132)] + for x, z in lights: + b.chandelier(x, z, feet, ceiling) + b.lift_cabin(feet, ceiling) + b.diorama() + b.selection_bays() + b.leaf_distances() + navigation = b.navigation() + palette = sorted({s.partition("[")[0] for s in b.states.values()}) + metadata = { + "schema_version": 1, + "name": "Shacraft station furnished vestibule and SMASH gallery", + "status": "compiled candidate; requires caller survey, collision and live verification", + "owned_y": [98, 123], "perimeter_setback_blocks": 2, + "foundation_columns": len(b.footprint), "interior_columns": len(b.inside), + "state_count": len(b.states), "non_air_count": sum(s != AIR for s in b.states.values()), + "group_counts": dict(sorted(Counter(b.groups.values()).items())), + "palette": palette, "features": b.features, "lift": b.lift, + "selection_bays": b.selections, "text_displays": b.texts, + "walk_points": b.floor_walk[99] + b.floor_walk[113], + "walk_points_by_floor": {str(k): v for k, v in b.floor_walk.items()}, + "navigation": navigation, + "design_invariants": { + "only_two_furnished_floors": True, "upper_floor_has_no_holes": True, + "lift_has_no_open_shaft": True, "no_attic_or_clocktower_access": True, + "diorama_is_closed_and_display_only": True, "arena_slots_are_unassigned": True, + "functional_text_signs_and_lift_are_caller_owned": True, + }, + } + return b.states, b.groups, metadata diff --git a/scripts/terrain-study/NaturalTerrainStudy.java b/scripts/terrain-study/NaturalTerrainStudy.java new file mode 100644 index 0000000..b483344 --- /dev/null +++ b/scripts/terrain-study/NaturalTerrainStudy.java @@ -0,0 +1,19 @@ +import com.google.gson.JsonParser; +import io.github.minecraftbuilder.core.TerrainRecipe; +import java.io.*; +import java.nio.file.*; + +/** Offline composition experiment, not a world writer or a production recipe. */ +public final class NaturalTerrainStudy { + private static final io.github.minecraftbuilder.terrainworld.NaturalTerrain terrain = + new io.github.minecraftbuilder.terrainworld.NaturalTerrain(28092005); + static float height(int x,int z) { return terrain.height(x,z); } + public static void main(String[] args) throws Exception { + var old=new TerrainRecipe(JsonParser.parseString(Files.readString(Path.of(args[0]))).getAsJsonObject()); + Path output=Path.of(args[1]);Files.createDirectories(output); + try(var a=new DataOutputStream(new BufferedOutputStream(Files.newOutputStream(output.resolve("before.f32")))); + var b=new DataOutputStream(new BufferedOutputStream(Files.newOutputStream(output.resolve("natural.f32"))))) { + for(int z=-384;z<384;z++)for(int x=-384;x<384;x++) {a.writeFloat(old.surfaceHeight(x,z));b.writeFloat(height(x,z));} + } + } +} diff --git a/scripts/terrain-study/README.md b/scripts/terrain-study/README.md new file mode 100644 index 0000000..1589294 --- /dev/null +++ b/scripts/terrain-study/README.md @@ -0,0 +1,46 @@ +# Shacraft terrain composition study + +This is an **offline prototype** responding to the first lobby terrain's rectangular platforms and repetitive slopes. The original study did not alter a live world. Its exact height field is now available through the production `shacraft-natural-v1` initial-world profile; it still does not extend the production MCP recipe schema. + +## Findings and design decisions + +The original recipe combines three correlated octaves of grid-based value noise, then flattens large rectangular areas. It also paints grass on every exposed top step regardless of slope. The screenshots show both geometric platform edges and repetitive green/brown contour bands. Adding more octaves alone cannot remove those platform boundaries. + +[Red Blob Games](https://www.redblobgames.com/maps/terrain-from-noise/) explains independent octave sampling, Simplex noise as a way to reduce directional artifacts, ridged transformations, and elevation redistribution. The study uses seeded OpenSimplex2S with distinct fields for broad relief, ridges, coordinate warping and fine detail. + +[libnoise tutorial 5](https://libnoise.sourceforge.net/tutorials/tutorial5.html) separates the terrain-type control map from mountain and lowland height fields. Here explicit curved mountain corridors provide artistic control: north is the main skyline, side ranges are lower, and the central valley stays comparatively calm. Mountain detail is weighted by those corridors instead of covering the entire map uniformly. + +[FastNoiseLite's documentation](https://github.com/Auburn/FastNoiseLite/wiki/Documentation) provides fBm, ridged fractals, octave weighting and domain warping. The prototype uses two independent low-frequency fields to displace X/Z coordinates before evaluating terrain, producing irregular ridge paths and water margins. This is a modest 27-block warp, not unlimited distortion. + +There are no rectangular plateau features in the study. Broad smooth hills leave space for architecture without committing to enormous flat pads. Later construction should flatten only the actual footprint and blend the foundation into the slope. Natural terrain alone does not guarantee walkable routes; those need a separate route/grade pass after the silhouette is selected. + +The lakes and connected river paths share Y=48. Shorelines arise from the intersection between the computed ground and that water plane. River cuts are curved and variable in width. The shape is authored and noise-modulated; it is **not** a drainage or hydraulic erosion simulation. Small remaining angular changes around path vertices should be replaced by spline sampling in a production implementation. + +## Prototype parameters + +- Broad lowland field: scale 220 blocks, amplitude 11, 4 octaves. +- Mountain ridge field: scale 85, 5 octaves, gain 0.48, lacunarity 2.07, weighted strength 0.7. +- Warp fields: scale 165, 3 octaves, displacement 27 blocks. +- Fine detail: scale 17, amplitude about 1.2–4.4 depending on mountain influence. +- Comparison palette: slope-dependent rock/grass and depth-dependent water, **identical for both versions**. This isolates geometry; it is not a Minecraft material or shader preview. + +## Reproduce + +From the repository root, with the project Java 25 runtime on PATH: + +```bash +mkdir -p .runtime/terrain-study +javac -cp terrain-world-plugin/target/terrain-world-plugin-0.1.0-SNAPSHOT.jar -d .runtime/terrain-study \ + scripts/terrain-study/NaturalTerrainStudy.java +java -cp .runtime/terrain-study:terrain-world-plugin/target/terrain-world-plugin-0.1.0-SNAPSHOT.jar \ + NaturalTerrainStudy examples/terrain/shacraft-lobby-world.json .runtime/terrain-study +python3 scripts/terrain-study/render.py +``` + +Rendering requires numpy and matplotlib. The binary samples are 768×768 big-endian float32, row-major Z then X. No upsampling, erosion or post-sculpting is hidden in the renderer. Perspective views sample every fourth column and use true vertical scale. + +Outputs: `docs/references/shacraft-terrain-study-plan.png`, `shacraft-terrain-study-perspective.png`, and `shacraft-terrain-study.json`. These are computed previews, **not screenshots**. The original comparison images predate the version 2 world. The production height field is tested against the SHA-256 of all 589,824 original prototype float samples. + +## Third-party source + +`../../terrain-world-plugin/src/main/java/io/github/minecraftbuilder/terrainworld/noise/FastNoiseLite.java` is upstream Java source with only a package declaration added from [Auburn/FastNoiseLite](https://github.com/Auburn/FastNoiseLite), pinned to commit `785f37a9ad76e283586a379675085f2063ae03f7`. Its MIT license and copyright notice are preserved at the top of the file. The MIT notice is also bundled in the production jar under `META-INF/LICENSE-FastNoiseLite.txt`. diff --git a/scripts/terrain-study/render.py b/scripts/terrain-study/render.py new file mode 100644 index 0000000..76b1d9c --- /dev/null +++ b/scripts/terrain-study/render.py @@ -0,0 +1,63 @@ +"""Render comparable scientific previews from sampled height fields; never edits screenshots.""" +from pathlib import Path +import json +import numpy as np +import matplotlib +matplotlib.use('Agg') +import matplotlib.pyplot as plt +from matplotlib.colors import LightSource + +ROOT = Path(__file__).resolve().parents[2] +DATA = ROOT / '.runtime/terrain-study' +OUT = ROOT / 'docs/references' +WATER = 48 +fields = [np.fromfile(DATA / name, dtype='>f4').reshape(768, 768).astype(float) + for name in ['before.f32', 'natural.f32']] +names = ['V1 — rectangular platforms', 'Study — ridges, soft hills, winding water'] + +def colors(ground): + dz, dx = np.gradient(ground) + slope = np.hypot(dx, dz) + rock = np.clip((slope - .55) / 1.5, 0, 1)[..., None] + green = np.array([.35, .46, .25]) + stone = np.array([.54, .54, .49]) + rgb = green * (1-rock) + stone * rock + # Identical materials and lighting for both fields: isolate the shape comparison. + shaded = LightSource(315, 42).shade_rgb(rgb, ground, vert_exag=1, blend_mode='soft') + depth = np.clip((WATER-ground)/25, 0, 1)[..., None] + water = np.array([.19, .46, .50])*(1-depth) + np.array([.12, .30, .37])*depth + return np.where((ground < WATER)[..., None], water, shaded) + +fig, axes = plt.subplots(1, 2, figsize=(16, 8), facecolor='#f4f3ee') +for ax, ground, name in zip(axes, fields, names): + ax.imshow(colors(ground), extent=(-384,384,384,-384)) + ax.set_title(name, fontsize=16, loc='left', pad=16) + ax.set_xlabel('X / blocks'); ax.set_ylabel('Z / blocks — north up') +fig.suptitle('Shacraft / terrain composition study · 768 × 768 blocks', fontsize=20, x=.06, ha='left', y=.99) +fig.text(.06,.025,'Computed height fields. Same scale, water level and shading. Right: offline prototype, not yet in Minecraft.',fontsize=11) +fig.subplots_adjust(left=.06,right=.98,top=.90,bottom=.10,wspace=.18) +fig.savefig(OUT/'shacraft-terrain-study-plan.png',dpi=130) +plt.close(fig) + +fig=plt.figure(figsize=(16,8),facecolor='#f4f3ee') +for i,(ground,name) in enumerate(zip(fields,names)): + ax=fig.add_subplot(1,2,i+1,projection='3d',facecolor='#f4f3ee') + s=4; x=np.arange(-384,384,s); z=np.arange(-384,384,s); xx,zz=np.meshgrid(x,z) + ax.plot_surface(xx,zz,np.maximum(ground[::s,::s],WATER),facecolors=colors(ground)[::s,::s], + rstride=1,cstride=1,linewidth=0,antialiased=False,shade=False) + ax.set(xlim=(-384,384),ylim=(-384,384),zlim=(25,220)) + ax.set_box_aspect((768,768,195));ax.view_init(elev=37,azim=-58);ax.set_axis_off() + ax.set_title(name,loc='left',fontsize=16,pad=0) +fig.suptitle('Shacraft / compare silhouettes before rebuilding',fontsize=20,x=.05,ha='left',y=.95) +fig.text(.05,.07,'Geometric preview at true vertical scale. No rectangular plateaus in the new study. No erosion simulation.',fontsize=11) +fig.subplots_adjust(left=.01,right=.99,top=.86,bottom=.10,wspace=-.06) +fig.savefig(OUT/'shacraft-terrain-study-perspective.png',dpi=130) +plt.close(fig) + +stats=[] +for ground,name in zip(fields,names): + dz,dx=np.gradient(ground);s=np.hypot(dx,dz) + stats.append({'name':name,'min_ground_y':float(ground.min()),'max_ground_y':float(ground.max()), + 'water_columns':int((ground preview['tile_count']: + raise RuntimeError('Choose 1..64 existing tiles per batch') + manifest = {'version': 1, 'scope': scope, 'terrain_id': terrain_id, 'recipe': recipe, + 'start': args.start, 'count': args.count, 'tile_budget': preview['tile_budget'], 'tiles': []} + save(path, manifest) + # The entire requested envelope must fit; never silently clip to an unrelated current project. + for axis in ('x', 'y', 'z'): + if recipe['min'][axis] < context['region']['min'][axis] or recipe['max'][axis] > context['region']['max'][axis]: + raise RuntimeError('Recipe exceeds selected project area; choose a dedicated terrain site first') + for index in range(args.start, args.start + args.count): + entry = next((e for e in manifest['tiles'] if e['tile_index'] == index), None) + if entry is None: + entry = backend.call('terrain_prepare', terrain_id=terrain_id, tile_index=index) + manifest['tiles'].append(entry) + save(path, manifest) + if entry.get('status') == 'empty' or entry.get('changed_blocks') == 0: + continue + if entry.get('status') in ('conflict', 'cancelled', 'failed', 'recovery_required'): + raise RuntimeError('Batch stopped previously; inspect or undo it instead of forcing through') + apply_plan(backend, entry, manifest, path) + print(json.dumps({'tile': index, 'status': entry['status'], 'written': entry['written']}), flush=True) + print(json.dumps({'status': 'completed', 'manifest': str(path), 'tiles': len(manifest['tiles'])})) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest='command', required=True) + preview = sub.add_parser('preview', help='Render the exact height field OFFLINE; no server or world mutation') + preview.add_argument('recipe', type=Path) + preview.add_argument('--output', type=Path, required=True) + for name in ('apply', 'undo'): + p = sub.add_parser(name) + p.add_argument('--manifest', type=Path, required=True) + p.add_argument('--config', type=Path, default=ROOT / '.runtime/server/plugins/MinecraftBuilderMCP/config.yml') + p.add_argument('--console', action='store_true', help='Only for an explicitly configured isolated fixture') + p.add_argument('--execute', action='store_true', required=True, help='Explicitly perform this world edit') + if name == 'apply': + p.add_argument('recipe', type=Path) + p.add_argument('--start', type=int, required=True) + p.add_argument('--count', type=int, required=True) + args = parser.parse_args() + if args.command == 'preview': + java = Path(os.environ.get('MCB_JAVA_HOME', str(Path.home() / '.cache/minecraft-builder-mcp/jdk-25.0.2'))) / 'bin/java' + jar = ROOT / 'paper-plugin/target/paper-plugin-0.1.0-SNAPSHOT.jar' + args.output.parent.mkdir(parents=True, exist_ok=True) + subprocess.run([str(java), '-Djava.awt.headless=true', '-cp', str(jar), 'io.github.minecraftbuilder.paper.TerrainPreview', str(args.recipe.resolve()), str(args.output.resolve()), str(args.output.with_suffix('.json').resolve())], check=True) + print(args.output) + else: + run_batch(args) + + +if __name__ == '__main__': + main() diff --git a/scripts/test-materials-live.py b/scripts/test-materials-live.py new file mode 100644 index 0000000..b8fbd3f --- /dev/null +++ b/scripts/test-materials-live.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +"""Opt-in live integration checks against the isolated material-integration Paper world. + +Requires .runtime/material-test-server/test-access.json provisioned by the operator. +Never targets the lobby: both scope and project identity are checked before mutation. +Run phase 'before-restart', restart the isolated server, then run 'after-restart'. +""" +import argparse +import json +import socket +import struct +import time +import urllib.error +import urllib.request +import uuid +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +RUNTIME = ROOT / '.runtime/material-test-server' + + +class TestServer: + def __init__(self): + self.auth = json.loads((RUNTIME / 'test-access.json').read_text()) + context = self.rpc('project_context') + assert context['project_id'] == 'material-integration', 'Refusing a non-test project' + assert context['region']['max']['x'] == 511, 'Unexpected test region' + + def rpc(self, method, **params): + body = json.dumps({'method': method, 'params': dict(params, player_id='console', project_id='material-integration')}).encode() + request = urllib.request.Request('http://127.0.0.1:18765/v1/rpc', data=body, + headers={'Authorization': 'Bearer ' + self.auth['agent'], 'Content-Type': 'application/json'}) + try: + with urllib.request.urlopen(request, timeout=25) as response: + value = json.load(response) + except urllib.error.HTTPError as error: + value = json.load(error) + if not value['ok']: + raise RuntimeError(json.dumps(value['error'])) + return value['result'] + + def rcon(self, command): + def receive(stream): + def exact(count): + result = b'' + while len(result) < count: + piece = stream.recv(count-len(result)) + if not piece: + raise EOFError('RCON disconnected') + result += piece + return result + length, = struct.unpack(' 1000 + assert len(json.dumps(context['material_catalog'])) < 256 + first = server.rpc('material_search', query='trapdoor') + second = server.rpc('material_search', query='trapdoor', cursor=first['next_cursor']) + ids = [e['id'] for e in first['results']+second['results']] + assert len(first['results']) == 16 and len(ids) == len(set(ids)) == first['total'] + expect_error(lambda: server.rpc('material_search', query='stairs', cursor=first['next_cursor']), 'invalid_cursor') + expect_error(lambda: server.rpc('material_search', limit=33), 'invalid_material_query') + descriptions = [server.rpc('material_describe', id=material) for material in ( + 'minecraft:cherry_trapdoor', 'minecraft:water', 'minecraft:oak_wall_sign', + 'minecraft:redstone_wire', 'minecraft:wheat', 'minecraft:decorated_pot')] + assert set(descriptions[0]['properties']['open']) == {'true', 'false'} + assert len(descriptions[1]['properties']['level']) == 16 + assert server.rpc('material_describe', id='minecraft:diamond_sword')['placeable'] is False + expect_error(lambda: server.rpc('material_describe', id='minecraft:not_a_real_material'), 'invalid_material') + server.rcon('forceload add 0 0 79 79') + server.rcon('gamerule minecraft:random_tick_speed 0') + server.rcon('fill 28 99 28 60 99 44 minecraft:stone') + + # Newly available ordinary states, paired door halves, water and waterlogging. + samples = [ + ((32,100,32),'minecraft:cherry_trapdoor[facing=north,half=bottom,open=true,powered=false,waterlogged=true]'), + ((34,100,32),'minecraft:green_carpet'), + ((36,100,32),'minecraft:potted_oxeye_daisy'), + ((38,100,32),'minecraft:white_candle[candles=3,lit=true,waterlogged=false]'), + ((40,100,32),'minecraft:water[level=0]'), + ((42,100,32),'minecraft:oak_door[facing=north,half=lower,hinge=left,open=false,powered=false]'), + ((42,101,32),'minecraft:oak_door[facing=north,half=upper,hinge=left,open=false,powered=false]'), + ] + targets = [(dict(zip(('x','y','z'),pos)),state) for pos,state in samples] + baseline = [server.block(*pos) for pos,_ in samples] + result = server.apply(server.prepare(targets, baseline)) + for (pos, state) in targets: + assert server.block(**pos)['state'] == state + server.undo(result['operation_id']) + assert [server.block(*pos) for pos,_ in samples] == baseline + + # Fixture data is known test content; private snapshots must stay out of model responses. + server.rcon('setblock 32 100 36 minecraft:chest[facing=north]') + server.rcon('data merge block 32 100 36 {Items:[{Slot:0b,id:"minecraft:diamond",count:7}]}') + server.rcon('setblock 36 100 36 minecraft:oak_sign') + server.rcon('data merge block 36 100 36 {front_text:{messages:["MCP snapshot test","","",""]}}') + server.rcon('setblock 40 100 36 minecraft:decorated_pot') + chest = server.block(32,100,36) + sign = server.block(36,100,36) + pot = server.block(40,100,36) + assert all(len(v['snapshot_id']) == 64 for v in (chest,sign,pot)) + assert 'minecraft:diamond' in server.rcon('data get block 32 100 36 Items') + assert 'MCP snapshot test' in server.rcon('data get block 36 100 36 front_text') + assert 'MCP snapshot test' not in json.dumps(sign) + expect_error(lambda:server.prepare([(chest['pos'],'minecraft:stone')], [dict(pos=chest['pos'],state=chest['state'])]), 'snapshot_id') + + rotate = server.apply(server.prepare([(chest['pos'],chest['state'].replace('facing=north','facing=east'))],[chest])) + assert 'minecraft:diamond' in server.rcon('data get block 32 100 36 Items') + server.undo(rotate['operation_id']) + assert server.block(32,100,36) == chest + + stale = server.prepare([(chest['pos'],'minecraft:stone')],[chest]) + server.rcon('data modify block 32 100 36 Items[0].count set value 9') + changed = server.block(32,100,36) + assert changed['snapshot_id'] != chest['snapshot_id'] + expect_error(lambda:server.prepare([(chest['pos'],'minecraft:stone')],[chest]), 'stale_snapshot') + conflict = server.apply(stale, 'conflict') + assert 'minecraft:diamond' not in json.dumps(conflict) + assert '\u0000mcb-block' not in json.dumps(conflict) + assert conflict['conflicts'][0]['current_snapshot_id'] == changed['snapshot_id'] + server.rcon('data modify block 32 100 36 Items[0].count set value 7') + assert server.block(32,100,36) == chest + + # Block-entity export must fail rather than silently strip contents. + expect_error(lambda:server.rpc('schematic_export',name='reject-tile',min=chest['pos'],max=chest['pos']), 'unsupported_block_entity') + trap_pos=dict(x=44,y=100,z=36) + trap='minecraft:cherry_trapdoor[facing=north,half=bottom,open=true,powered=false,waterlogged=true]' + placed=server.apply(server.prepare([(trap_pos,trap)])) + asset=server.rpc('schematic_export',name='new-material-rotation',min=trap_pos,max=trap_pos) + target=dict(x=46,y=100,z=36) + imported=server.apply(server.rpc('schematic_import_prepare',asset_id=asset['assetId'],target=target,rotation=90)) + assert 'facing=east' in server.block(**target)['state'] + server.undo(imported['operation_id']);server.undo(placed['operation_id']) + + before = [chest, sign, pot] + replaced = server.apply(server.prepare([(v['pos'],'minecraft:stone') for v in before],before)) + item_check=server.rcon('execute if entity @e[type=minecraft:item,x=28,y=98,z=32,dx=16,dy=6,dz=8] run say UNEXPECTED_ITEM_DROP') + assert 'UNEXPECTED_ITEM_DROP' not in item_check + receipt={'catalog':context['material_catalog'],'catalog_summary_bytes':len(json.dumps(context['material_catalog'],separators=(',',':')).encode()),'search_page_bytes':len(json.dumps(first).encode()),'description_bytes':[len(json.dumps(v).encode()) for v in descriptions], + 'checks':['bounded search/pagination','property domains','item-only distinction','new blocks and fluids apply/undo','paired doors','private block-entity digests','same-material inventory preservation','manual-content stale snapshot and apply conflict','no inventory drops','schematic new-material native rotation','schematic block-entity export rejection'], + 'restart_operation_id':replaced['operation_id'],'before_restart_blocks':before,'phase':'awaiting_restart'} + (RUNTIME/'live-receipt.json').write_text(json.dumps(receipt,indent=2)+'\n') + server.rcon('save-all flush') + print(json.dumps({k:v for k,v in receipt.items() if k not in ('before_restart_blocks','restart_operation_id')},indent=2),flush=True) + + +def after_restart(server): + receipt=json.loads((RUNTIME/'live-receipt.json').read_text()) + server.rcon('forceload add 0 0 79 79') + server.undo(receipt['restart_operation_id']) + for expected in receipt['before_restart_blocks']: + assert server.block(**expected['pos']) == expected + assert 'minecraft:diamond' in server.rcon('data get block 32 100 36 Items') + assert 'MCP snapshot test' in server.rcon('data get block 36 100 36 front_text') + receipt['checks'].append('complete chest/sign/pot undo after Paper restart') + receipt['phase']='complete' + (RUNTIME/'live-receipt.json').write_text(json.dumps(receipt,indent=2)+'\n') + print('Complete: chest contents, sign text and decorated-pot snapshot restored exactly after restart.',flush=True) + + +if __name__ == '__main__': + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('phase',choices=('before-restart','after-restart')) + args=parser.parse_args() + server=TestServer() + (before_restart if args.phase=='before-restart' else after_restart)(server) diff --git a/scripts/test_foundation_survey.py b/scripts/test_foundation_survey.py new file mode 100644 index 0000000..9f158b2 --- /dev/null +++ b/scripts/test_foundation_survey.py @@ -0,0 +1,199 @@ +"""Read-only survey completeness, cache isolation and path-height regression tests.""" +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest + +_spec = importlib.util.spec_from_file_location('foundation_survey', Path(__file__).with_name('foundation-survey.py')) +survey = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(survey) +SCOPE = {'project_id': 'project', 'world_id': 'world', 'world_epoch': 'epoch'} + + +class Backend: + def __init__(self, states=None): + self.states = states or {} + self.calls = [] + self.mutate = None + self.fail_call = None + self.context = {**SCOPE, 'region': {'min': {'x': -64, 'y': -64, 'z': -64}, + 'max': {'x': 63, 'y': 127, 'z': 63}}} + + def call(self, method, **params): + self.calls.append(method) + if method == 'project_context': + return self.context + if method != 'region_inspect': + raise AssertionError('Only read-only RPC is allowed') + if self.fail_call == self.calls.count('region_inspect'): + raise RuntimeError('chunk_not_loaded') + if survey.volume(params) > 4096: + raise AssertionError('Unbounded inspection') + lo, hi = params['min'], params['max'] + result = {'world_epoch': self.context['world_epoch'], 'truncated': False, + 'blocks': [{'pos': {'x': x, 'y': y, 'z': z}, 'state': self.states.get((x, y, z), 'minecraft:air')} + for x in range(lo['x'], hi['x'] + 1) for y in range(lo['y'], hi['y'] + 1) + for z in range(lo['z'], hi['z'] + 1)]} + if self.mutate: + self.mutate(result) + return result + + +def box(lo=(-2, 64, -2), hi=(2, 68, 2)): + return survey.box_cells(dict(zip(survey.AXES, lo)), dict(zip(survey.AXES, hi))) + + +class SurveyTests(unittest.TestCase): + def capture(self, backend=None, cells=None): + backend = backend or Backend() + cells = cells or box() + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'survey.json.gz' + result = survey.scan(backend, cells, path) + loaded = survey.load_snapshot(path, SCOPE) + self.assertEqual(loaded.document, result.document) + return loaded + + def test_negative_cell_partition_and_exact_states(self): + states = {(-2, 64, -2): 'minecraft:smooth_stone_slab[type=bottom,waterlogged=false]'} + result = self.capture(Backend(states)) + self.assertEqual(result.state(-2, 64, -2), states[(-2, 64, -2)]) + self.assertEqual(result.state(2, 68, 2), 'minecraft:air') + self.assertEqual(len(result.cells), 4) + with self.assertRaises(KeyError): + result.state(-3, 64, -2) + with self.assertRaises(KeyError): + result.state(17, 64, 0) + + def test_columns_do_not_assume_air_in_unread_cells(self): + cells = survey.column_cells({'version': 1, 'columns': [ + {'x': -20, 'z': 1, 'min_y': 64, 'max_y': 68}, + {'x': 20, 'z': 1, 'min_y': 66, 'max_y': 72}]}) + result = self.capture(cells=cells) + self.assertEqual(result.state(20, 69, 1), 'minecraft:air') + with self.assertRaises(KeyError): + result.state(0, 69, 1) + with self.assertRaises(KeyError): + result.state(20, 65, 1) + + def test_rejects_missing_duplicate_truncated_and_wrong_epoch(self): + changes = [lambda r: r['blocks'].pop(), + lambda r: r['blocks'].append(r['blocks'][0]), + lambda r: r.update(truncated=True), + lambda r: r.update(world_epoch='different')] + for mutate in changes: + with self.subTest(mutate=mutate), tempfile.TemporaryDirectory() as directory: + backend = Backend() + backend.mutate = mutate + path = Path(directory) / 'survey.json.gz' + with self.assertRaises(RuntimeError): + survey.scan(backend, box(), path) + self.assertFalse(path.exists()) + + def test_out_of_area_fails_before_any_inspection(self): + backend = Backend() + with tempfile.TemporaryDirectory() as directory: + with self.assertRaises(ValueError): + survey.scan(backend, box((-65, 64, 0), (-60, 68, 0)), Path(directory) / 'survey.json.gz') + self.assertEqual(backend.calls, ['project_context']) + + def test_explicit_resume_keeps_readonly_cache_and_checks_scope(self): + backend = Backend() + backend.fail_call = 2 + cells = box((-16, 64, 0), (16, 68, 0)) + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'survey.json.gz' + with self.assertRaisesRegex(RuntimeError, 'chunk_not_loaded'): + survey.scan(backend, cells, path) + self.assertFalse(path.exists()) + with self.assertRaisesRegex(ValueError, '--resume'): + survey.scan(backend, cells, path) + backend.context['world_epoch'] = 'another-epoch' + with self.assertRaisesRegex(ValueError, 'scope'): + survey.scan(backend, cells, path, resume=True) + backend.context['world_epoch'] = 'epoch' + backend.fail_call = None + result = survey.scan(backend, cells, path, resume=True) + self.assertTrue(result.document['resumed_cache']) + self.assertEqual(backend.calls.count('region_inspect'), 4) + with self.assertRaisesRegex(ValueError, 'fresh path'): + survey.scan(backend, cells, path, resume=True) + + def test_corrupt_cache_and_missing_snapshot_indices_are_rejected(self): + snapshot = self.capture() + snapshot.document['cells'][0]['indices'].pop() + with self.assertRaisesRegex(ValueError, 'indices'): + survey.Snapshot(snapshot.document, SCOPE) + + def test_foreign_scope_rejected(self): + snapshot = self.capture() + with self.assertRaisesRegex(ValueError, 'another project'): + survey.Snapshot(snapshot.document, {**SCOPE, 'project_id': 'other'}) + + def test_walkability_half_slab_fullblock_clearance_and_gradient(self): + states = {(0, 64, 0): 'minecraft:stone', + (1, 65, 0): 'minecraft:smooth_stone_slab[type=bottom,waterlogged=false]', + (2, 65, 0): 'minecraft:stone'} + snapshot = self.capture(Backend(states), box((0, 64, 0), (3, 69, 0))) + points = [{'x': x, 'z': 0, 'standing_y': feet, 'route': 'main'} + for x, feet in [(0, 65), (1, 65.5), (2, 66)]] + result = survey.verify_walkable(snapshot, points) + self.assertTrue(result['passed']) + self.assertEqual(result['checked_route_edges'], 2) + points[1]['standing_y'] = 66 + result = survey.verify_walkable(snapshot, points) + self.assertFalse(result['passed']) + self.assertIn('missing_support_at_feet', [f['reason'] for f in result['failures']]) + self.assertIn('route_step_exceeds_half_block', [f['reason'] for f in result['failures']]) + + def test_headroom_unknown_stairs_and_absent_observations_fail(self): + states = {(0, 64, 0): 'minecraft:stone', (0, 66, 0): 'minecraft:stone', + (1, 64, 0): 'minecraft:stone_brick_stairs[facing=north,half=bottom,shape=straight,waterlogged=false]'} + snapshot = self.capture(Backend(states), box((0, 64, 0), (1, 68, 0))) + result = survey.verify_walkable(snapshot, [{'x': x, 'z': 0, 'standing_y': 65} for x in (0, 1, 2)]) + self.assertEqual([f['reason'] for f in result['failures']], + ['blocked_headroom', 'unknown_support_shape', 'unobserved_block']) + + def test_straight_bottom_stair_profile_all_directions(self): + sides = {'north': ((.5, .75), (.5, .25)), 'south': ((.5, .25), (.5, .75)), + 'east': ((.25, .5), (.75, .5)), 'west': ((.75, .5), (.25, .5))} + for facing, (low, high) in sides.items(): + with self.subTest(facing=facing): + state = f'minecraft:stone_brick_stairs[facing={facing},half=bottom,shape=straight,waterlogged=false]' + snapshot = self.capture(Backend({(0, 64, 0): state}), box((0, 64, 0), (0, 68, 0))) + points = [{'x': 0, 'z': 0, 'sub_x': sub[0], 'sub_z': sub[1], 'standing_y': feet, 'route': facing} + for sub, feet in [(low, 64.5), (high, 65)]] + result = survey.verify_walkable(snapshot, points) + self.assertTrue(result['passed'], result) + self.assertEqual(result['checked_route_edges'], 1) + points[0]['standing_y'] = 65 + self.assertEqual(survey.verify_walkable(snapshot, points)['failures'][0]['reason'], 'missing_support_at_feet') + + def test_stair_headroom_riser_boundary_and_unsupported_shapes(self): + base = 'minecraft:stone_brick_stairs[facing=north,half=bottom,shape=straight,waterlogged=false]' + snapshot = self.capture(Backend({(0, 64, 0): base, (0, 66, 0): 'minecraft:stone'}), + box((0, 64, 0), (0, 68, 0))) + result = survey.verify_walkable(snapshot, [{'x': 0, 'z': 0, 'sub_z': .25, 'standing_y': 65}]) + self.assertEqual(result['failures'][0]['reason'], 'blocked_headroom') + self.assertIsNone(survey.vertical_shape(base, .5, .5)) + self.assertIsNone(survey.vertical_shape(base.replace('straight', 'inner_left'), .5, .25)) + self.assertIsNone(survey.vertical_shape(base.replace('half=bottom', 'half=top'), .5, .25)) + self.assertIsNone(survey.vertical_shape(base.replace('waterlogged=false', 'waterlogged=true'), .5, .25)) + + def test_route_subsamples_use_physical_positions_across_blocks(self): + state = 'minecraft:stone_brick_stairs[facing=north,half=bottom,shape=straight,waterlogged=false]' + snapshot = self.capture(Backend({(0, 64, 0): state, (0, 65, -1): state}), + box((0, 64, -1), (0, 70, 0))) + points = [{'x': 0, 'z': z, 'sub_z': sub_z, 'standing_y': feet, 'route': 'stair'} + for z, sub_z, feet in [(0, .75, 64.5), (0, .25, 65), (-1, .75, 65.5), (-1, .25, 66)]] + result = survey.verify_walkable(snapshot, points) + self.assertTrue(result['passed'], result) + self.assertEqual(result['checked_route_edges'], 3) + points[1]['sub_x'] = .6 + result = survey.verify_walkable(snapshot, points) + self.assertIn('non_cardinal_route_step', [f['reason'] for f in result['failures']]) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/test_layout.py b/scripts/test_layout.py new file mode 100644 index 0000000..72425a4 --- /dev/null +++ b/scripts/test_layout.py @@ -0,0 +1,248 @@ +"""Layout placement safety and resume checks; uses a simulated RPC world, never Minecraft.""" +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest + +spec = importlib.util.spec_from_file_location('layout', Path(__file__).with_name('layout.py')) +layout = importlib.util.module_from_spec(spec) +spec.loader.exec_module(layout) + +SCOPE = {'project_id': 'project', 'world_id': 'world', 'world_epoch': 'epoch'} + + +def document(blocks): + return layout.normalize({'version': 1, 'scope': SCOPE, 'blocks': blocks}) + + +def block(x, y=60, z=0, material='minecraft:lime_concrete', expected='minecraft:air'): + return {'x': x, 'y': y, 'z': z, 'block': material, 'expected': expected} + + +class FakeBackend: + def __init__(self): + self.world, self.plans, self.operations, self.keys, self.calls = {}, {}, {}, {}, [] + self.lose_apply = False + self.corrupt_after_apply = False + self.context = {**SCOPE, 'checked_expected_blocks': True, + 'region': {'min': {'x': -512, 'y': -64, 'z': -512}, 'max': {'x': 511, 'y': 319, 'z': 511}}} + + def state(self, at): + return self.world.get(at, 'minecraft:air') + + def new_plan(self, changes, undo_of=None): + plan_id = 'p' + str(len(self.plans)) + self.plans[plan_id] = {'changes': changes, 'undo_of': undo_of} + return {'plan_id': plan_id, 'plan_hash': 'hash-' + plan_id, + 'changed_blocks': sum(e != d for _, e, d in changes)} + + def call(self, method, **params): + self.calls.append((method, params)) + if method == 'project_context': + return self.context + if method == 'region_inspect': + lo, hi = params['min'], params['max'] + self.assert_bounded(lo, hi) + return {'world_epoch': SCOPE['world_epoch'], 'truncated': False, + 'blocks': [{'pos': {'x': x, 'y': y, 'z': z}, 'state': self.state((x, y, z))} + for y in range(lo['y'], hi['y'] + 1) for z in range(lo['z'], hi['z'] + 1) + for x in range(lo['x'], hi['x'] + 1)]} + if method == 'build_prepare': + desired = {} + for op in params['recipe']['operations']: + for y in range(op['min']['y'], op['max']['y'] + 1): + for z in range(op['min']['z'], op['max']['z'] + 1): + for x in range(op['min']['x'], op['max']['x'] + 1): + desired[(x, y, z)] = op['block'] + expected = {layout.position(e['pos']): e['state'] for e in params['expected_blocks']} + if desired.keys() != expected.keys(): + raise AssertionError('Expected states must cover exactly the desired mask') + if any(self.state(at) != state for at, state in expected.items()): + raise RuntimeError('stale_snapshot') + return self.new_plan([(at, expected[at], state) for at, state in desired.items()]) + if method == 'build_apply': + key = params['idempotency_key'] + if key in self.keys: + return self.operations[self.keys[key]] + operation = 'o' + str(len(self.operations)) + plan = self.plans[params['plan_id']] + written, status = 0, 'applied' + receipts = [] + for at, expected, desired in plan['changes']: + if self.state(at) != expected: + status = 'conflict' + break + if expected != desired: + self.world[at] = desired + receipts.append((at, expected, desired)) + written += 1 + self.operations[operation] = {'operation_id': operation, 'status': status, 'written': written, + 'plan_id': params['plan_id'], 'receipts': receipts} + self.keys[key] = operation + if self.corrupt_after_apply: + self.world[plan['changes'][0][0]] = 'minecraft:gold_block' + if self.lose_apply: + self.lose_apply = False + raise TimeoutError('Simulated response lost after the world changed') + return self.operations[operation] + if method == 'operation_status': + return self.operations[params['operation_id']] + if method == 'operation_undo_prepare': + source = self.operations[params['operation_id']] + changes = [(at, desired, expected) for at, expected, desired in source['receipts']] + if any(self.state(at) != expected for at, expected, _ in changes): + raise RuntimeError('Manual edit conflicts with guarded undo') + return self.new_plan(changes, params['operation_id']) + raise AssertionError(f'Unexpected RPC {method}') + + @staticmethod + def assert_bounded(lo, hi): + if layout.volume({'min': lo, 'max': hi}) > 4096: + raise AssertionError('Unbounded inspection') + + +class LayoutTests(unittest.TestCase): + def test_dense_layout_preserves_every_position_and_respects_all_budgets(self): + value = document([block(x, y, z) for x in range(-20, 20) for y in range(63, 67) for z in range(-20, 20)]) + batches = layout.make_batches(value['blocks']) + actual = [b for batch in batches for b in batch['blocks']] + self.assertEqual(sorted(actual, key=layout.position), value['blocks']) + self.assertGreater(len(batches), 1) + for batch in batches: + self.assertLessEqual(len(batch['blocks']), 4096) + self.assertLessEqual(len(batch['recipe']['operations']), 256) + self.assertLessEqual(len(layout.read_groups(batch['blocks'])), 32) + for group in layout.read_groups(batch['blocks']): + self.assertLessEqual(layout.volume(layout.bounds(group)), 4096) + self.assertEqual(layout.make_batches(list(reversed(value['blocks']))), batches) + + def test_compression_does_not_fill_holes_or_merge_materials(self): + value = document([block(0), block(1), block(3), block(4, material='minecraft:red_concrete')]) + operations = layout.make_batches(value['blocks'])[0]['recipe']['operations'] + self.assertEqual(len(operations), 3) + lime = [o for o in operations if o['block'] == 'minecraft:lime_concrete'] + self.assertEqual([(o['min']['x'], o['max']['x']) for o in lime], [(0, 1), (3, 3)]) + + def test_duplicate_and_invalid_coordinates_are_rejected(self): + with self.assertRaisesRegex(ValueError, 'Duplicate'): + document([block(0), block(0)]) + with self.assertRaisesRegex(ValueError, '32-bit'): + document([block(True)]) + + def test_manual_edit_stops_before_prepare(self): + with tempfile.TemporaryDirectory() as directory: + backend = FakeBackend() + backend.world[(0, 60, 0)] = 'minecraft:gold_block' + with self.assertRaisesRegex(RuntimeError, 'Block mismatch'): + layout.apply_layout(backend, document([block(0)]), Path(directory) / 'ledger.json') + self.assertNotIn('build_prepare', [method for method, _ in backend.calls]) + self.assertEqual(backend.state((0, 60, 0)), 'minecraft:gold_block') + + def test_expected_surface_replacement_is_sent_atomically_and_verified(self): + with tempfile.TemporaryDirectory() as directory: + backend = FakeBackend() + backend.world[(0, 60, 0)] = 'minecraft:grass_block' + path = Path(directory) / 'ledger.json' + result = layout.apply_layout(backend, document([block(0, expected='minecraft:grass_block')]), path, progress=lambda _: None) + self.assertEqual(result['status'], 'completed') + prepared = next(params for method, params in backend.calls if method == 'build_prepare') + self.assertEqual(prepared['expected_blocks'], [{'pos': {'x': 0, 'y': 60, 'z': 0}, 'state': 'minecraft:grass_block'}]) + self.assertIn('verified_at', json.loads(path.read_text())['batches'][0]) + + def test_lost_apply_response_resumes_same_key_without_failing_old_expected_read(self): + with tempfile.TemporaryDirectory() as directory: + backend, path = FakeBackend(), Path(directory) / 'ledger.json' + backend.lose_apply = True + value = document([block(0)]) + with self.assertRaises(TimeoutError): + layout.apply_layout(backend, value, path, progress=lambda _: None) + self.assertEqual(backend.state((0, 60, 0)), 'minecraft:lime_concrete') + before = json.loads(path.read_text())['batches'][0] + self.assertIn('idempotency_key', before) + self.assertNotIn('operation_id', before) + layout.apply_layout(backend, value, path, progress=lambda _: None) + applies = [params for method, params in backend.calls if method == 'build_apply'] + self.assertEqual(len(applies), 2) + self.assertEqual(applies[0], applies[1]) + self.assertEqual(len(backend.plans), 1) + self.assertTrue(json.loads(path.read_text())['completed']) + + def test_existing_conflict_is_not_reprepared(self): + with tempfile.TemporaryDirectory() as directory: + backend, path = FakeBackend(), Path(directory) / 'ledger.json' + value = document([block(0)]) + manifest = layout.load_or_create(path, value, layout.make_batches(value['blocks'])) + manifest['batches'][0]['status'] = 'conflict' + layout.terrain.save(path, manifest) + with self.assertRaisesRegex(RuntimeError, 'stopped on conflict'): + layout.apply_layout(backend, value, path) + self.assertEqual([method for method, _ in backend.calls], ['project_context']) + + def test_old_server_scope_and_changed_input_are_rejected_without_write(self): + with tempfile.TemporaryDirectory() as directory: + backend, path = FakeBackend(), Path(directory) / 'ledger.json' + value = document([block(0)]) + backend.context['checked_expected_blocks'] = False + with self.assertRaisesRegex(RuntimeError, 'atomic checked_expected_blocks'): + layout.apply_layout(backend, value, path) + backend.context['checked_expected_blocks'] = True + backend.context['world_epoch'] = 'other' + with self.assertRaisesRegex(RuntimeError, 'another project'): + layout.apply_layout(backend, value, path) + backend.context['world_epoch'] = 'epoch' + layout.load_or_create(path, value, layout.make_batches(value['blocks'])) + with self.assertRaisesRegex(RuntimeError, 'digest'): + layout.apply_layout(backend, document([block(1)]), path) + self.assertFalse(backend.plans) + + def test_post_apply_verification_stops_before_next_batch(self): + with tempfile.TemporaryDirectory() as directory: + backend, path = FakeBackend(), Path(directory) / 'ledger.json' + # More than 32 separate read cells creates several small, cheap batches. + value = document([block(x * 16, z=z * 16) for x in range(-4, 5) for z in range(-4, 5)]) + backend.corrupt_after_apply = True + with self.assertRaisesRegex(RuntimeError, 'Block mismatch'): + layout.apply_layout(backend, value, path) + self.assertEqual(len(backend.plans), 1) + saved = json.loads(path.read_text()) + self.assertNotIn('verified_at', saved['batches'][0]) + self.assertNotIn('completed', saved) + + def test_undo_is_reverse_order_and_refuses_resume_apply_after_undo(self): + with tempfile.TemporaryDirectory() as directory: + backend, path = FakeBackend(), Path(directory) / 'ledger.json' + value = document([block(x * 16, z=z * 16) for x in range(-4, 5) for z in range(-4, 5)]) + layout.apply_layout(backend, value, path, progress=lambda _: None) + source_ids = [e['operation_id'] for e in json.loads(path.read_text())['batches']] + result = layout.undo_layout(backend, path, progress=lambda _: None) + self.assertEqual(result['status'], 'undone') + undo_ids = [params['operation_id'] for method, params in backend.calls if method == 'operation_undo_prepare'] + self.assertEqual(undo_ids, list(reversed(source_ids))) + self.assertTrue(all(backend.state(layout.position(b)) == b['expected'] for b in value['blocks'])) + with self.assertRaisesRegex(RuntimeError, 'begun undo'): + layout.apply_layout(backend, value, path) + + def test_undo_does_not_start_an_unknown_apply(self): + with tempfile.TemporaryDirectory() as directory: + backend, path = FakeBackend(), Path(directory) / 'ledger.json' + backend.lose_apply = True + with self.assertRaises(TimeoutError): + layout.apply_layout(backend, document([block(0)]), path) + calls_before = len(backend.calls) + with self.assertRaisesRegex(RuntimeError, 'Uncertain apply'): + layout.undo_layout(backend, path) + self.assertEqual([m for m, _ in backend.calls[calls_before:]], ['project_context']) + + def test_undo_preserves_later_manual_edits(self): + with tempfile.TemporaryDirectory() as directory: + backend, path = FakeBackend(), Path(directory) / 'ledger.json' + layout.apply_layout(backend, document([block(0)]), path, progress=lambda _: None) + backend.world[(0, 60, 0)] = 'minecraft:gold_block' + with self.assertRaisesRegex(RuntimeError, 'Manual edit'): + layout.undo_layout(backend, path) + self.assertEqual(backend.state((0, 60, 0)), 'minecraft:gold_block') + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/test_terrain.py b/scripts/test_terrain.py new file mode 100644 index 0000000..e92dd4c --- /dev/null +++ b/scripts/test_terrain.py @@ -0,0 +1,67 @@ +"""Regression checks for resumable local batches; no live world needed.""" +import importlib.util +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location('terrain', Path(__file__).with_name('terrain.py')) +terrain = importlib.util.module_from_spec(spec) +spec.loader.exec_module(terrain) + + +class BatchTests(unittest.TestCase): + def test_lost_apply_response_reuses_persisted_key(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'batch.json' + entry = {'plan_id': 'plan', 'plan_hash': 'hash'} + manifest = {'tiles': [entry]} + calls = [] + class Backend: + def call(self, method, **params): + calls.append((method, params)) + if len(calls) == 1: + raise TimeoutError('lost response') + return {'operation_id': 'operation'} + backend = Backend() + with self.assertRaises(TimeoutError): + terrain.apply_plan(backend, entry, manifest, path) + saved = terrain.json.loads(path.read_text()) + self.assertIn('idempotency_key', saved['tiles'][0]) + with patch.object(terrain, 'finish', return_value={'status': 'applied', 'written': 7}): + terrain.apply_plan(backend, saved['tiles'][0], saved, path) + self.assertEqual(calls[0], calls[1]) + self.assertEqual(terrain.json.loads(path.read_text())['tiles'][0]['operation_id'], 'operation') + + def test_conflict_is_persisted_and_reported_not_reprepared(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'batch.json' + entry = {'plan_id': 'p', 'plan_hash': 'h', 'operation_id': 'o'} + class Backend: + def call(self, *args, **kwargs): + raise AssertionError('Must not start another apply') + with patch.object(terrain, 'finish', return_value={'status': 'conflict', 'written': 3}): + with self.assertRaisesRegex(RuntimeError, 'conflict'): + terrain.apply_plan(Backend(), entry, {'tiles': [entry]}, path) + self.assertEqual(terrain.json.loads(path.read_text())['tiles'][0]['status'], 'conflict') + + def test_undo_never_starts_an_unknown_apply(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'batch.json' + scope = {'project_id': 'p', 'world_id': 'w', 'world_epoch': 'e'} + terrain.save(path, {'scope': scope, 'tiles': [{'idempotency_key': 'k', 'plan_id': 'p', 'plan_hash': 'h'}]}) + class Backend: + def __init__(self, *args): + pass + def call(self, method, **params): + if method != 'project_context': + raise AssertionError('Undo must not initiate a write to discover unknown apply outcome') + return scope + args = terrain.argparse.Namespace(config=Path('unused'), console=True, manifest=path, command='undo') + with patch.object(terrain, 'Backend', Backend): + with self.assertRaisesRegex(RuntimeError, 'Uncertain apply'): + terrain.run_batch(args) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/verify-balustrade.py b/scripts/verify-balustrade.py new file mode 100644 index 0000000..0174aa0 --- /dev/null +++ b/scripts/verify-balustrade.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +"""Read-only candidate and observed-volume QA for the arrival-square balustrade. + +Checks stable capped-wall states, cardinal continuity, grounded footings, original +road masks, preserved garden fixtures and remaining walkable floor. Actual mode +also compares every captured voxel against baseline plus the desired overlay. +""" +import argparse +from collections import Counter, deque +import hashlib +import importlib.util +import json +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location('balustrade_plaza_qa', ROOT / 'scripts/verify-plaza.py') +plaza = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(plaza) +survey = plaza.survey +N = {'east': (1, 0), 'north': (0, -1), 'south': (0, 1), 'west': (-1, 0)} +CAP = 'minecraft:smooth_sandstone_slab[type=bottom,waterlogged=false]' + + +def coordinates(document): + result = {} + for row in document['blocks']: + at = tuple(survey.point(row)[axis] for axis in ('x', 'y', 'z')) + if at in result: + raise ValueError(f'Duplicate desired position {at}') + result[at] = row['block'] + return result + + +def stable_wall_properties(connected, capped=True): + """Vanilla 26.2 WallBlock under a full bottom face (the bottom slab cap). + + Cap coverage makes arms tall. shouldRaisePost suppresses the post for either + opposite tall pair only after its endpoint/corner/T asymmetry condition. + """ + ns = connected['north'] != connected['south'] + ew = connected['east'] != connected['west'] + opposite = ((connected['north'] and connected['south']) or + (connected['east'] and connected['west'])) + up = ns or ew or not opposite + return {**{name: ('tall' if capped else 'low') if connected[name] else 'none' for name in N}, + 'up': str(up).lower(), 'waterlogged': 'false'} + + +def wall_neighbor(state): + name, _ = plaza.parse(state) + return name.endswith('_wall') or name in plaza.STATIC_CUBES + + +def check_guard(reader, metadata): + columns = set(map(tuple, metadata['fence_columns'])) + piers = set(map(tuple, metadata['pier_columns'])) + failures, listed = [], [] + if not columns or not piers <= columns: + raise ValueError('Fence columns and piers must form a nonempty valid set') + for index, raw in enumerate(metadata['path_runs']): + run = list(map(tuple, raw)) + if len(run) < 2 or run[0] not in piers or run[-1] not in piers: + failures.append({'reason': 'run_needs_pier_endpoints', 'run': index}) + for a, b in zip(run, run[1:]): + if abs(a[0] - b[0]) + abs(a[1] - b[1]) != 1: + failures.append({'reason': 'noncardinal_guard_gap', 'from': a, 'to': b}) + listed.extend(run) + if len(listed) != len(set(listed)) or set(listed) != columns: + failures.append({'reason': 'path_runs_do_not_cover_fence_once'}) + for x, z in sorted(columns): + state = reader.state(x, 96, z) + material, props = plaza.parse(state) + if (x, z) in piers: + if material != 'cut_sandstone': + failures.append({'at': [x, 96, z], 'reason': 'pier_material', 'actual': state}) + else: + connections = {name: wall_neighbor(reader.state(x + dx, 96, z + dz)) + for name, (dx, dz) in N.items()} + expected = stable_wall_properties(connections) + if material != 'stone_brick_wall' or props != expected: + failures.append({'at': [x, 96, z], 'reason': 'unstable_wall_state', + 'actual': state, 'expected_properties': expected}) + if reader.state(x, 97, z) != CAP: + failures.append({'at': [x, 97, z], 'reason': 'missing_continuous_bottom_cap'}) + if plaza.parse(reader.state(x, 95, z))[0] not in plaza.STATIC_CUBES: + failures.append({'at': [x, 95, z], 'reason': 'guard_without_full_base'}) + for footing in metadata['footing_columns']: + x, z, bottom = footing['x'], footing['z'], footing['support_y'] + if (x, z) not in columns or not 70 <= bottom < 95: + raise ValueError('Unexpected footing coordinate or support height') + if plaza.parse(reader.state(x, bottom, z))[0] == 'grass_block': + failures.append({'at': [x, bottom, z], 'reason': 'grass_under_opaque_footing_will_decay_to_dirt'}) + for y in range(bottom, 96): + if plaza.parse(reader.state(x, y, z))[0] not in plaza.STATIC_CUBES: + failures.append({'at': [x, y, z], 'reason': 'footing_gap_or_unknown_support'}) + # A newly connected pier also changes the reciprocal side of an old road + # rail. Inspect real neighbors, including walls absent from compiler masks. + adjacent = {(x + dx, z + dz) for x, z in columns for dx, dz in N.values() + if (x + dx, z + dz) not in columns and + plaza.parse(reader.state(x + dx, 96, z + dz))[0] == 'stone_brick_wall'} + for x, z in sorted(adjacent): + above = reader.state(x, 97, z) + if above not in survey.AIR and above != CAP: + failures.append({'at': [x, 97, z], 'reason': 'unknown_neighbor_rail_top_shape', 'actual': above}) + continue + connected = {name: wall_neighbor(reader.state(x + dx, 96, z + dz)) + for name, (dx, dz) in N.items()} + actual = reader.state(x, 96, z) + expected = stable_wall_properties(connected, above == CAP) + if plaza.parse(actual)[1] != expected: + failures.append({'at': [x, 96, z], 'reason': 'unstable_reciprocal_road_rail', + 'actual': actual, 'expected_properties': expected}) + pending, components = set(columns), [] + while pending: + seen, queue = set(), deque([next(iter(pending))]) + while queue: + p = queue.popleft() + if p not in pending: + continue + pending.remove(p) + seen.add(p) + queue.extend((p[0] + dx, p[1] + dz) for dx, dz in N.values()) + components.append(len(seen)) + if sorted(components) != sorted(len(run) for run in metadata['path_runs']): + failures.append({'reason': 'actual_cardinal_components_differ_from_runs', 'components': components}) + return {'passed': not failures, 'columns': len(columns), 'piers': len(piers), + 'cardinal_components': sorted(components), 'grounded_footings': len(metadata['footing_columns']), + 'adjacent_road_rails': len(adjacent), + 'failures': failures} + + +def captured_voxels(snapshot): + for cell in snapshot.cells.values(): + lo, hi, index = cell['min'], cell['max'], 0 + for y in range(lo['y'], hi['y'] + 1): + for z in range(lo['z'], hi['z'] + 1): + for x in range(lo['x'], hi['x'] + 1): + yield (x, y, z), cell['palette'][cell['indices'][index]] + index += 1 + + +def compare_volume(before, after, desired): + mismatches, counts = [], Counter() + for at, baseline in captured_voxels(before): + changed = at in desired + counts['desired_voxels' if changed else 'outside_desired_voxels'] += 1 + actual, expected = after.state(*at), desired.get(at, baseline) + if actual != expected: + counts['desired_mismatches' if changed else 'outside_desired_mismatches'] += 1 + if len(mismatches) < 30: + mismatches.append({'at': list(at), 'expected': expected, 'actual': actual, + 'inside_desired': changed}) + if counts['desired_voxels'] != len(desired): + raise ValueError('Desired blocks extend outside the captured baseline volume') + return {'passed': not mismatches, **dict(counts), 'mismatch_examples': mismatches, + 'scope_note': 'Every voxel inside the supplied before snapshot; no claim for unobserved exterior voxels.'} + + +def audit(before, layout, metadata, garden_plan, garden_metadata, garden_walk, nav, after=None): + scope = survey.scope_of(layout['scope']) + if any(value != scope for value in (before.scope, metadata['scope'], garden_plan['scope'], + garden_metadata['scope'], garden_walk['scope'])) or (after and after.scope != scope): + raise ValueError('Project/world/epoch differs between inputs') + desired, failures = coordinates(layout), [] + for row in layout['blocks']: + at = tuple(row[a] for a in ('x', 'y', 'z')) + if before.state(*at) != row['expected']: + raise ValueError(f'Stale expected block at {at}') + fence = set(map(tuple, metadata['fence_columns'])) + road = {tuple(p) for route in nav['routes'] for p in route['clear_cells']} + bench = set(map(tuple, garden_metadata['bench_access_columns'])) + for x, y, z in desired: + if (x, z) in road or (x, z) in bench: + failures.append({'at': [x, y, z], 'reason': 'protected_road_or_bench_column_edited'}) + if fence & (road | bench): + failures.append({'reason': 'fence_declared_on_protected_road_or_bench'}) + reader = plaza.Reader(after or before, None if after else desired) + guard = check_guard(reader, metadata) + old_desired = coordinates(garden_plan) + protected = {at: state for at, state in old_desired.items() + if plaza.parse(state)[0] in plaza.FLOWERS | {'spruce_stairs', 'spruce_fence', 'lantern', + 'iron_chain', 'iron_bars', 'gold_block'} or any(part in state for part in ('_leaves', '_log', 'waxed_oxidized_cut_copper'))} + for fixture in garden_metadata['fixtures']: + if fixture['type'] in ('conifer', 'topiary'): + at = fixture['x'], 95, fixture['z'] + protected[at] = before.state(*at) + lost = [{'at': list(at), 'expected': state, 'actual': reader.state(*at)} + for at, state in protected.items() if reader.state(*at) != state] + fixture_report = plaza.check_fixtures(reader, old_desired, garden_metadata) + blocked = set(map(tuple, garden_metadata['unwalkable_columns'])) | fence + points = [p for p in garden_walk['points'] if (p['x'], p['z']) not in fence] + walking = survey.verify_walkable(reader, points) + navigation = json.loads(json.dumps(nav)) + for cell in navigation['cells']: + if (cell['x'], cell['z']) in blocked: + cell['clear'] = False + nav_report = plaza.navigation.audit(navigation) + graph = plaza.navigation.surface_graph(plaza.navigation.normalize_cells(navigation)) + reached = plaza.navigation.reachable(graph, (0, 9)) + bench_report = [plaza.navigation.endpoint_report(f'bench-access-{x}-{z}', (x, z), graph, reached) + for x, z in sorted(bench)] + nav_report['bench_access'] = bench_report + nav_report['passed'] &= all(row['passed'] for row in bench_report) + volume = compare_volume(before, after, desired) if after else None + passed = (not failures and not lost and guard['passed'] and fixture_report['passed'] and + walking['passed'] and nav_report['passed'] and (volume is None or volume['passed'])) + return {'version': 1, 'scope': scope, 'mode': 'actual_after_snapshot' if after else 'candidate_overlay', + 'passed': passed, 'world_edits': 0, 'desired_blocks': len(desired), 'guard': guard, + 'protected_road_columns': len(road), 'protection_failures': failures, + 'preserved_fixture_states': len(protected), 'lost_fixtures': lost[:30], + 'fixtures': fixture_report, 'walking': walking, 'navigation': nav_report, + 'volume': volume, 'note': 'Point-sampled body headroom and navigation; not a full moving-player collision simulation.'} + + +class BalustradeTests(unittest.TestCase): + def test_stable_capped_wall_corner_and_straight_and_cross(self): + for sides, up in [({'north', 'south'}, 'false'), ({'east', 'west'}, 'false'), + (set(N), 'false'), ({'north', 'east'}, 'true'), + ({'north', 'east', 'south'}, 'true'), ({'north'}, 'true')]: + self.assertEqual(stable_wall_properties({n: n in sides for n in N})['up'], up) + uncapped = stable_wall_properties({n: n in {'east', 'west'} for n in N}, False) + self.assertEqual((uncapped['east'], uncapped['west'], uncapped['up']), ('low', 'low', 'false')) + + def test_volume_detects_unrelated_change_and_exact_properties(self): + before = type('Snapshot', (), {'cells': {0: {'min': dict(x=0, y=0, z=0), + 'max': dict(x=1, y=0, z=0), 'palette': ['minecraft:air'], 'indices': [0, 0]}}})() + states = {(0, 0, 0): CAP, (1, 0, 0): 'minecraft:stone'} + after = type('After', (), {'state': lambda self, x, y, z: states[x, y, z]})() + result = compare_volume(before, after, {(0, 0, 0): CAP}) + self.assertFalse(result['passed']) + self.assertEqual(result['outside_desired_mismatches'], 1) + states[(1, 0, 0)] = 'minecraft:air' + self.assertTrue(compare_volume(before, after, {(0, 0, 0): CAP})['passed']) + states[(0, 0, 0)] = CAP.replace('bottom', 'top') + self.assertEqual(compare_volume(before, after, {(0, 0, 0): CAP})['desired_mismatches'], 1) + + def test_diagonal_run_is_rejected(self): + states = {(x, y, z): 'minecraft:air' for x in range(-1, 3) for z in range(-1, 3) for y in range(95, 98)} + for x, z in [(0, 0), (1, 1)]: + states[x, 95, z] = 'minecraft:stone' + states[x, 96, z] = 'minecraft:cut_sandstone' + states[x, 97, z] = CAP + reader = type('Reader', (), {'state': lambda self, x, y, z: states[x, y, z]})() + meta = {'fence_columns': [[0, 0], [1, 1]], 'pier_columns': [[0, 0], [1, 1]], + 'path_runs': [[[0, 0], [1, 1]]], 'footing_columns': []} + result = check_guard(reader, meta) + self.assertIn('noncardinal_guard_gap', {f['reason'] for f in result['failures']}) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + stage, garden = ROOT / '.runtime/balustrade-stage04', ROOT / '.runtime/plaza-stage03' + parser.add_argument('--before', type=Path, default=stage / 'before-full.json.gz') + parser.add_argument('--after', type=Path) + parser.add_argument('--layout', type=Path, default=stage / 'balustrade.json') + parser.add_argument('--metadata', type=Path, default=stage / 'balustrade.metadata.json') + parser.add_argument('--garden-plan', type=Path, default=garden / 'plaza-polished.json') + parser.add_argument('--garden-metadata', type=Path, default=garden / 'plaza-polished.metadata.json') + parser.add_argument('--garden-walk', type=Path, default=garden / 'plaza-polished.walk.json') + parser.add_argument('--navigation', type=Path, default=ROOT / '.runtime/foundations-stage02/navigation-verified-walkable-input.json') + parser.add_argument('--report', type=Path, default=stage / 'candidate-qa.json') + parser.add_argument('--self-test', action='store_true') + args = parser.parse_args() + if args.self_test: + result = unittest.TextTestRunner().run(unittest.defaultTestLoader.loadTestsFromTestCase(BalustradeTests)) + raise SystemExit(0 if result.wasSuccessful() else 1) + paths = {key: getattr(args, key) for key in ('layout', 'metadata', 'garden_plan', 'garden_metadata', 'garden_walk', 'navigation')} + documents = [json.loads(path.read_text()) for path in paths.values()] + before = survey.load_snapshot(args.before) + after = survey.load_snapshot(args.after, before.scope) if args.after else None + report = audit(before, *documents, after=after) + paths['before'] = args.before + if args.after: + paths['after'] = args.after + report['input_sha256'] = {key: hashlib.sha256(path.read_bytes()).hexdigest() for key, path in paths.items()} + args.report.parent.mkdir(parents=True, exist_ok=True) + survey.terrain.save(args.report, report) + print(json.dumps({key: report[key] for key in ('passed', 'mode', 'desired_blocks', 'guard', 'protection_failures', + 'lost_fixtures', 'fixtures', 'volume')} + | {'walk_points': report['walking']['checked_points'], 'walk_failures': report['walking']['failures'][:10], + 'navigation_passed': report['navigation']['passed'], + 'unreachable_columns': report['navigation']['unreachable_clear_columns'], + 'report': str(args.report.resolve())}, indent=2)) + raise SystemExit(0 if report['passed'] else 1) + + +if __name__ == '__main__': + main() diff --git a/scripts/verify-foundation-survey.py b/scripts/verify-foundation-survey.py new file mode 100644 index 0000000..08a3e52 --- /dev/null +++ b/scripts/verify-foundation-survey.py @@ -0,0 +1,423 @@ +#!/usr/bin/env python3 +"""Verify a foundation edit against actual before/after Paper surface maps. + +Air cuts expose saved lower voxel states; an omitted voxel never becomes assumed +air. The whole map is compared, including columns outside the edit. Optional PNG +output is a fresh material/height render of the observed after map, not a mockup. +""" +import argparse +from collections import Counter, defaultdict +import hashlib +import importlib.util +import json +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[1] +_spec = importlib.util.spec_from_file_location('foundation_snapshot', ROOT / 'scripts/foundation-survey.py') +survey = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(survey) +AIR = {'minecraft:air', 'minecraft:cave_air', 'minecraft:void_air'} +WATER = {'minecraft:water', 'minecraft:bubble_column'} +MAP_SCOPE = ('world_uuid', 'world_key', 'min_x', 'max_x', 'min_z', 'max_z', 'width', 'length') + + +def material(state): + return state.split('[', 1)[0] + + +def ignored_materials(document): + """Air stays implicit; absent metadata preserves legacy map semantics.""" + raw = document.get('ignored_materials', []) + if (not isinstance(raw, list) or any(not isinstance(value, str) for value in raw) + or len(raw) != len(set(raw)) + or not set(raw) <= AIR | {'minecraft:barrier'}): + raise ValueError('Unsupported explicit ignored_materials map policy') + return AIR | set(raw) + + +def validate_map(document): + if document.get('format') != 'minecraft-builder-surface-map-v1' or document.get('source') != 'paper_world_surface': + raise ValueError('Expected a captured Paper world surface map') + for key in ('min_x', 'max_x', 'min_z', 'max_z', 'width', 'length'): + if type(document.get(key)) is not int: + raise ValueError('Map bounds must be integers') + count = document['width'] * document['length'] + if (not 0 < count <= 4_194_304 or document['width'] != document['max_x'] - document['min_x'] + 1 + or document['length'] != document['max_z'] - document['min_z'] + 1): + raise ValueError('Map bounds and dimensions disagree') + palette = document.get('palette') + if not isinstance(palette, list) or not palette or any(not isinstance(m, str) or not survey.STATE.fullmatch(m) or '[' in m for m in palette): + raise ValueError('Map palette must contain material names') + heights, indices = document.get('surface_y'), document.get('material_index') + if not isinstance(heights, list) or len(heights) != count or any(type(y) is not int for y in heights): + raise ValueError('Map has missing or invalid surface heights') + if not isinstance(indices, list) or len(indices) != count or any(type(i) is not int or not 0 <= i < len(palette) for i in indices): + raise ValueError('Map has missing or invalid material indices') + ignored = ignored_materials(document) + if any(palette[index] in ignored - AIR for index in indices): + raise ValueError('Map surface contains a material its explicit policy ignores') + return [palette[index] for index in indices] + + +def column_index(document, x, z): + if not (document['min_x'] <= x <= document['max_x'] and document['min_z'] <= z <= document['max_z']): + raise ValueError(f'Edited column {(x, z)} is outside the surface map') + return (z - document['min_z']) * document['width'] + x - document['min_x'] + + +def expected_surface(before, snapshot, layout): + original_materials = validate_map(before) + ignored = ignored_materials(before) + scope = survey.scope_of(layout.get('scope', {})) + if snapshot.scope != scope or scope['world_id'] != before['world_uuid']: + raise ValueError('Voxel snapshot, layout and map scopes differ') + overlays = defaultdict(dict) + actual_changes = Counter() + water_replacements = [] + expected_states_checked = 0 + blocks = layout.get('blocks') + if layout.get('version') != 1 or not isinstance(blocks, list) or not blocks: + raise ValueError('Layout requires version 1 and explicit blocks') + for block in blocks: + pos = survey.point(block) + x, y, z = pos['x'], pos['y'], pos['z'] + column_index(before, x, z) + if y in overlays[(x, z)]: + raise ValueError(f'Duplicate desired block {(x, y, z)}') + desired, expected = block.get('block'), block.get('expected') + if any(not isinstance(s, str) or not survey.STATE.fullmatch(s) for s in (desired, expected)): + raise ValueError('Layout requires valid desired and expected block states') + observed = snapshot.state(x, y, z) + if observed != expected: + raise ValueError(f'Layout expected state disagrees with baseline voxel snapshot at {(x, y, z)}') + expected_states_checked += 1 + overlays[(x, z)][y] = desired + if desired != observed: + actual_changes['removed' if material(desired) in AIR else 'placed_or_replaced'] += 1 + if material(observed) in WATER and desired != observed: + water_replacements.append({'x': x, 'y': y, 'z': z, 'before': observed, 'desired': desired}) + heights = before['surface_y'].copy() + materials = original_materials.copy() + exposed_snapshot_blocks = 0 + map_only_covered_tops = 0 + for (x, z), overlay in overlays.items(): + i = column_index(before, x, z) + original_top = before['surface_y'][i] + # A map and a block capture taken at different times must still agree + # where reconstruction relies on their shared original surface. + visible_overlay_top = max((y for y, state in overlay.items() if material(state) not in ignored), + default=original_top) + try: + observed_original_top = material(snapshot.state(x, original_top, z)) + except KeyError: + # An overhead addition can be surveyed independently of the ground + # it covers. Its checked desired voxel proves the new surface lies + # above the earlier captured top; no lower voxel is reconstructed. + # Cuts and invisible-only additions still require that observation. + if visible_overlay_top <= original_top: + raise + observed_original_top = original_materials[i] + map_only_covered_tops += 1 + empty_fallback = original_materials[i] in AIR + if (observed_original_top != original_materials[i] + and not (empty_fallback and observed_original_top in ignored)): + raise ValueError(f'Before map and voxel snapshot disagree at original surface {(x, original_top, z)}') + top = max(original_top, visible_overlay_top) + while True: + if top in overlay: + state = overlay[top] + elif top == original_top: + state = original_materials[i] + elif top > original_top: + # The captured surface proves higher untouched cells are + # ignored by this explicit visibility policy. They may be + # invisible barriers; this is no claim of collision-free air. + state = 'minecraft:air' + else: + state = snapshot.state(x, top, z) + exposed_snapshot_blocks += 1 + if material(state) not in ignored: + heights[i], materials[i] = top, material(state) + break + if empty_fallback and top == original_top: + # A captured all-transparent column reports air at world min Y. + # Do not read below that explicit fallback or invent terrain. + heights[i], materials[i] = top, original_materials[i] + break + top -= 1 + return heights, materials, overlays, { + 'expected_states_checked_against_baseline': expected_states_checked, + 'placed_or_replaced_voxels': actual_changes['placed_or_replaced'], 'removed_voxels': actual_changes['removed'], + 'lower_snapshot_voxels_consulted_after_cuts': exposed_snapshot_blocks, + 'covered_original_tops_known_only_from_before_map': map_only_covered_tops, + 'observed_water_voxels_replaced': water_replacements} + + +def verify(before, after, snapshot, layout): + after_materials = validate_map(after) + if ignored_materials(before) != ignored_materials(after): + raise ValueError('Before and after map ignored_materials policies differ; capture a matching baseline') + for key in MAP_SCOPE: + if before.get(key) != after.get(key): + raise ValueError(f'Before and after map scopes differ: {key}') + expected_y, expected_m, overlays, stats = expected_surface(before, snapshot, layout) + original_materials = [before['palette'][i] for i in before['material_index']] + affected = {column_index(before, x, z) for x, z in overlays} + mismatches, outside_mismatches = [], 0 + changed_surface = 0 + water_columns = 0 + water_surface_changes = [] + for i, observed in enumerate(after_materials): + x, z = i % before['width'] + before['min_x'], i // before['width'] + before['min_z'] + if before['surface_y'][i] != after['surface_y'][i] or original_materials[i] != observed: + changed_surface += 1 + if expected_y[i] != after['surface_y'][i] or expected_m[i] != observed: + mismatch = {'x': x, 'z': z, 'expected_y': expected_y[i], 'actual_y': after['surface_y'][i], + 'expected_material': expected_m[i], 'actual_material': observed, 'inside_edit_columns': i in affected} + mismatches.append(mismatch) + if i not in affected: + outside_mismatches += 1 + if original_materials[i] in WATER: + water_columns += 1 + if observed != original_materials[i] or before['surface_y'][i] != after['surface_y'][i]: + water_surface_changes.append({'x': x, 'z': z, 'before_y': before['surface_y'][i], + 'after_y': after['surface_y'][i], 'after_material': observed}) + water_ok = not water_surface_changes and not stats['observed_water_voxels_replaced'] + return {'version': 1, 'world': before['world'], 'scope': snapshot.scope, + 'source': 'Full live Paper surface maps plus observed baseline voxels and desired edit overlay', + 'surface_ignored_materials': sorted(ignored_materials(after)), + 'verified_surface_columns': len(expected_y), 'affected_columns': len(affected), + 'outside_edit_columns_verified': len(expected_y) - len(affected), + 'changed_surface_columns': changed_surface, 'surface_mismatches': len(mismatches), + 'outside_edit_surface_mismatches': outside_mismatches, + 'mismatch_examples': mismatches[:40], + 'original_visible_water_columns': water_columns, + 'visible_water_surface_changes': len(water_surface_changes), + 'water_surface_change_examples': water_surface_changes[:20], + **stats, 'water_preservation_passed': water_ok, + 'passed': not mismatches and water_ok, + 'capture_started_at': after.get('capture_started_at'), 'capture_finished_at': after.get('capture_finished_at'), + 'atomic_snapshot': False, + 'note': 'Sequential map captures; edits must be idle during each capture. This checks every visible surface and the observed water blocks in the edit. Hidden untouched blocks outside the voxel survey are not inferred. Live voxel and headroom checks are separate.'} + + +def render(after, layout, report, path): + import numpy as np + import matplotlib + matplotlib.use('Agg') + import matplotlib.pyplot as plt + from matplotlib.colors import to_rgb + + heights = np.asarray(after['surface_y'], dtype=float).reshape(after['length'], after['width']) + ids = np.asarray(after['material_index']).reshape(heights.shape) + colors = after.get('palette_rgb') + if not isinstance(colors, list) or len(colors) != len(after['palette']): + raise ValueError('PNG rendering requires the actual map palette_rgb array') + rgb = np.asarray([to_rgb(c) for c in colors])[ids] + dz, dx = np.gradient(heights) + light = (.55 * dx + .55 * dz + .63) / np.sqrt(dx * dx + dz * dz + 1) + shade = np.clip(.76 + .36 * light, .44, 1.13) + for index, name in enumerate(after['palette']): + if name.endswith(('_concrete', '_wool', '_terracotta')): + shade[ids == index] = 1 + if name in WATER: + shade[ids == index] = .96 + rgb = np.clip(rgb * shade[:, :, None], 0, 1) + blocks = layout['blocks'] + x0, x1 = min(b['x'] for b in blocks) - 20, max(b['x'] for b in blocks) + 20 + z0, z1 = min(b['z'] for b in blocks) - 20, max(b['z'] for b in blocks) + 20 + fig, ax = plt.subplots(figsize=(11, 14), facecolor='#f1eee4') + ax.set_facecolor('#f1eee4') + ax.imshow(rgb, origin='upper', interpolation='nearest', + extent=(after['min_x'], after['max_x'] + 1, after['max_z'] + 1, after['min_z'])) + ax.set(xlim=(x0, x1), ylim=(z1, z0), xlabel='X · east →', ylabel='Z (positive south)') + ax.set_aspect('equal') + ax.tick_params(colors='#4f594d', labelsize=9) + for spine in ax.spines.values(): + spine.set_color('#9a9e8c') + labels = [ + ((0, 9), (64, -10), '01 ARRIVAL SQUARE'), + ((-14, -117), (33, -156), '02 CLOCK STATION'), + ((-6, -79), (54, -75), 'Station steps'), + ((-7, -61), (37, -44), 'Forecourt'), + ((1, 91), (-57, 115), 'South approach'), + ((-75, 30), (-74, 62), 'Lake approach'), + ((-60, -36), (-98, -23), 'Portal approach'), + ((-63, -72), (-93, -103), 'Northwest promenade'), + ((67, -84), (77, -117), 'Northeast promenade'), + ((68, 10), (72, 42), 'East approach')] + for (x, z), (tx, tz), label in labels: + ax.annotate(label, xy=(x + .5, z + .5), xytext=(tx, tz), + ha='center', va='center', fontsize=8.7 if label[:2] in ('01', '02') else 8, + color='#263b31', weight='bold' if label[:2] in ('01', '02') else 'normal', + bbox=dict(boxstyle='round,pad=.3', fc='#f8f5ea', ec='#849080', lw=.6, alpha=.96), + arrowprops=dict(arrowstyle='-', color='#354b3c', lw=.8, shrinkA=3, shrinkB=2)) + ax.annotate('N', xy=(x1 - 8, z0 + 4), xytext=(x1 - 8, z0 + 18), ha='center', va='center', + color='#20392d', fontsize=11, weight='bold', arrowprops=dict(arrowstyle='-|>', color='#20392d')) + bar_x, bar_z = x0 + 8, z1 - 8 + ax.plot([bar_x, bar_x + 25], [bar_z, bar_z], color='#20392d', lw=2) + ax.text(bar_x + 12.5, bar_z - 2, '25 blocks', ha='center', va='bottom', fontsize=8, color='#20392d', + bbox=dict(fc='#f8f5ea', ec='none', alpha=.8, pad=1)) + fig.suptitle('SHACRAFT / FOUNDATIONS 01 + 02', x=.5, y=.975, fontsize=17, weight='bold', color='#20392d') + fig.text(.5, .945, 'Actual server surface · north up · local roads and supported foundations', + ha='center', fontsize=10, color='#52614e') + status = 'surface check passed' if report['passed'] else f"{report['surface_mismatches']} surface mismatches" + fig.text(.5, .025, f"{report['verified_surface_columns']:,} map columns checked · {status}\n" + 'Material colors and relief come from the captured world. Labels are annotations.', + ha='center', fontsize=9, color='#52614e', linespacing=1.6) + fig.subplots_adjust(left=.09, right=.96, top=.923, bottom=.077) + path.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(path, dpi=180, facecolor=fig.get_facecolor()) + plt.close(fig) + + +class FakeSnapshot: + scope = {'project_id': 'project', 'world_id': 'world', 'world_epoch': 'epoch'} + + def __init__(self, states): + self.states = states + + def state(self, x, y, z): + return self.states[(x, y, z)] + + +def fixture_map(heights, materials): + palette = list(dict.fromkeys(materials)) + return {'format': 'minecraft-builder-surface-map-v1', 'source': 'paper_world_surface', + 'world': 'test', 'world_uuid': 'world', 'world_key': 'minecraft:test', + 'min_x': 0, 'max_x': len(heights) - 1, 'min_z': 0, 'max_z': 0, + 'width': len(heights), 'length': 1, 'surface_y': heights, + 'palette': palette, 'material_index': [palette.index(m) for m in materials]} + + +class FoundationMapTests(unittest.TestCase): + def baseline(self): + before = fixture_map([10, 12, 8], ['minecraft:grass_block', 'minecraft:gold_block', 'minecraft:water']) + snapshot = FakeSnapshot({(0, 10, 0): 'minecraft:grass_block', (0, 9, 0): 'minecraft:air', + (0, 8, 0): 'minecraft:stone'}) + layout = {'version': 1, 'scope': snapshot.scope, + 'blocks': [{'x': 0, 'y': 10, 'z': 0, 'block': 'minecraft:air', 'expected': 'minecraft:grass_block'}]} + return before, snapshot, layout + + def test_cut_exposes_lower_observed_block_and_preserves_other_columns(self): + before, snapshot, layout = self.baseline() + after = fixture_map([8, 12, 8], ['minecraft:stone', 'minecraft:gold_block', 'minecraft:water']) + report = verify(before, after, snapshot, layout) + self.assertTrue(report['passed']) + self.assertEqual(report['lower_snapshot_voxels_consulted_after_cuts'], 2) + self.assertEqual(report['outside_edit_columns_verified'], 2) + self.assertEqual(report['original_visible_water_columns'], 1) + + def test_unrelated_column_change_is_detected(self): + before, snapshot, layout = self.baseline() + after = fixture_map([8, 11, 8], ['minecraft:stone', 'minecraft:gold_block', 'minecraft:water']) + report = verify(before, after, snapshot, layout) + self.assertFalse(report['passed']) + self.assertEqual(report['outside_edit_surface_mismatches'], 1) + self.assertEqual(report['mismatch_examples'][0]['x'], 1) + + def test_missing_lower_voxel_is_not_treated_as_air(self): + before, snapshot, layout = self.baseline() + del snapshot.states[(0, 9, 0)] + with self.assertRaises(KeyError): + expected_surface(before, snapshot, layout) + + def test_new_surface_block_properties_reduce_to_material(self): + before, snapshot, layout = self.baseline() + snapshot.states[(0, 11, 0)] = 'minecraft:air' + layout['blocks'].append({'x': 0, 'y': 11, 'z': 0, 'expected': 'minecraft:air', + 'block': 'minecraft:smooth_stone_slab[type=bottom,waterlogged=false]'}) + after = fixture_map([11, 12, 8], ['minecraft:smooth_stone_slab', 'minecraft:gold_block', 'minecraft:water']) + self.assertTrue(verify(before, after, snapshot, layout)['passed']) + + def test_stale_expected_state_and_surface_source_mismatch_rejected(self): + before, snapshot, layout = self.baseline() + layout['blocks'][0]['expected'] = 'minecraft:dirt' + with self.assertRaisesRegex(ValueError, 'baseline'): + expected_surface(before, snapshot, layout) + layout['blocks'][0]['expected'] = 'minecraft:grass_block' + before['palette'][0] = 'minecraft:dirt' + with self.assertRaisesRegex(ValueError, 'original surface'): + expected_surface(before, snapshot, layout) + + def test_explicit_barrier_roof_is_invisible_but_legacy_roof_is_visible(self): + before, snapshot, _ = self.baseline() + snapshot.states[(0, 20, 0)] = 'minecraft:air' + layout = {'version': 1, 'scope': snapshot.scope, 'blocks': [ + {'x': 0, 'y': 20, 'z': 0, 'block': 'minecraft:barrier', 'expected': 'minecraft:air'}]} + legacy_after = fixture_map([20, 12, 8], ['minecraft:barrier', 'minecraft:gold_block', 'minecraft:water']) + self.assertTrue(verify(before, legacy_after, snapshot, layout)['passed']) + visible_after = json.loads(json.dumps(before)) + before['ignored_materials'] = visible_after['ignored_materials'] = ['minecraft:barrier'] + self.assertTrue(verify(before, visible_after, snapshot, layout)['passed']) + + def test_replacing_visible_top_with_ignored_barrier_exposes_lower_observed_stone(self): + before, snapshot, layout = self.baseline() + layout['blocks'][0]['block'] = 'minecraft:barrier' + after = fixture_map([8, 12, 8], ['minecraft:stone', 'minecraft:gold_block', 'minecraft:water']) + before['ignored_materials'] = after['ignored_materials'] = sorted(AIR | {'minecraft:barrier'}) + self.assertTrue(verify(before, after, snapshot, layout)['passed']) + + def test_policy_change_is_not_silently_compared_to_legacy_map(self): + before, snapshot, layout = self.baseline() + after = fixture_map([8, 12, 8], ['minecraft:stone', 'minecraft:gold_block', 'minecraft:water']) + after['ignored_materials'] = sorted(AIR | {'minecraft:barrier'}) + with self.assertRaisesRegex(ValueError, 'policies differ'): + verify(before, after, snapshot, layout) + + def test_all_transparent_column_keeps_world_min_air_fallback(self): + before = fixture_map([-64], ['minecraft:air']) + before['ignored_materials'] = ['minecraft:barrier'] + after = json.loads(json.dumps(before)) + snapshot = FakeSnapshot({(0, -64, 0): 'minecraft:barrier', (0, 20, 0): 'minecraft:air'}) + layout = {'version': 1, 'scope': snapshot.scope, 'blocks': [ + {'x': 0, 'y': 20, 'z': 0, 'block': 'minecraft:barrier', 'expected': 'minecraft:air'}]} + self.assertTrue(verify(before, after, snapshot, layout)['passed']) + + def test_overhead_addition_uses_captured_map_below_tight_voxel_survey(self): + before = fixture_map([10], ['minecraft:grass_block']) + snapshot = FakeSnapshot({(0, 20, 0): 'minecraft:air'}) + layout = {'version': 1, 'scope': snapshot.scope, 'blocks': [ + {'x': 0, 'y': 20, 'z': 0, 'block': 'minecraft:stone', 'expected': 'minecraft:air'}]} + after = fixture_map([20], ['minecraft:stone']) + report = verify(before, after, snapshot, layout) + self.assertTrue(report['passed']) + self.assertEqual(report['covered_original_tops_known_only_from_before_map'], 1) + before['ignored_materials'] = ['minecraft:barrier'] + layout['blocks'][0]['block'] = 'minecraft:barrier' + with self.assertRaises(KeyError): + expected_surface(before, snapshot, layout) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + maps = ROOT / '.runtime/server/plugins/ShacraftTerrain/maps' + stage = ROOT / '.runtime/foundations-stage02' + parser.add_argument('--before-map', type=Path, default=maps / 'foundations-before.json') + parser.add_argument('--after-map', type=Path, default=maps / 'foundations-after.json') + parser.add_argument('--snapshot', type=Path, default=stage / 'before.json.gz') + parser.add_argument('--layout', type=Path, default=stage / 'foundations.json') + parser.add_argument('--report', type=Path, default=stage / 'surface-verification.json') + parser.add_argument('--png', type=Path) + parser.add_argument('--self-test', action='store_true') + args = parser.parse_args() + if args.self_test: + result = unittest.TextTestRunner().run(unittest.defaultTestLoader.loadTestsFromTestCase(FoundationMapTests)) + raise SystemExit(0 if result.wasSuccessful() else 1) + before, after, layout = [json.loads(p.read_text()) for p in (args.before_map, args.after_map, args.layout)] + snapshot = survey.load_snapshot(args.snapshot, survey.scope_of(layout['scope'])) + report = verify(before, after, snapshot, layout) + report['inputs_sha256'] = {name: hashlib.sha256(path.read_bytes()).hexdigest() + for name, path in [('before_map', args.before_map), ('after_map', args.after_map), + ('baseline_voxels', args.snapshot), ('desired_layout', args.layout)]} + args.report.parent.mkdir(parents=True, exist_ok=True) + survey.terrain.save(args.report, report) + if args.png: + render(after, layout, report, args.png) + print(json.dumps({k: v for k, v in report.items() if k not in ('inputs_sha256', 'mismatch_examples', 'note', 'observed_water_voxels_replaced')}, indent=2)) + raise SystemExit(0 if report['passed'] else 1) + + +if __name__ == '__main__': + main() diff --git a/scripts/verify-layout-survey.py b/scripts/verify-layout-survey.py new file mode 100644 index 0000000..de1d0af --- /dev/null +++ b/scripts/verify-layout-survey.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Compare every live map column to the planned visible result and inspect hidden water.""" +import argparse +from collections import defaultdict +import importlib.util +import json +from pathlib import Path + +ROOT=Path(__file__).resolve().parents[1] + +def main(): + p=argparse.ArgumentParser(description=__doc__) + p.add_argument('--before',type=Path,required=True);p.add_argument('--after',type=Path,required=True) + p.add_argument('--layout',type=Path,action='append',required=True);p.add_argument('--report',type=Path,required=True) + args=p.parse_args();before=json.loads(args.before.read_text());after=json.loads(args.after.read_text()) + for key in ('world_uuid','min_x','max_x','min_z','max_z','width','length'): + if before[key]!=after[key]:raise ValueError('Map scopes differ: '+key) + w=before['width'];expected_y=before['surface_y'].copy() + expected_m=[before['palette'][i] for i in before['material_index']] + original_m=expected_m.copy();targets=set();planned_blocks=0 + for path in args.layout: + layout=json.loads(path.read_text()) + if layout['scope']['world_id']!=before['world_uuid']:raise ValueError('Layout world differs') + for b in sorted(layout['blocks'],key=lambda b:b['y']): + i=(b['z']-before['min_z'])*w+b['x']-before['min_x'];targets.add((b['x'],b['y'],b['z']));planned_blocks+=1 + if b['y']>=expected_y[i]:expected_y[i]=b['y'];expected_m[i]=b['block'] + observed=[after['palette'][i] for i in after['material_index']] + mismatches=[i for i in range(len(expected_y)) if expected_y[i]!=after['surface_y'][i] or expected_m[i]!=observed[i]] + if mismatches:raise ValueError(f'{len(mismatches)} unexpected surface columns; first indices {mismatches[:8]}') + hidden_water=defaultdict(list) + for i,material in enumerate(original_m): + if material=='minecraft:water' and observed[i]!='minecraft:water': + x=i%w+before['min_x'];z=i//w+before['min_z'];y=before['surface_y'][i] + hidden_water[(x//16,z//16,y)].append((x,y,z)) + spec=importlib.util.spec_from_file_location('terrain',ROOT/'scripts/terrain.py') + terrain=importlib.util.module_from_spec(spec);spec.loader.exec_module(terrain) + backend=terrain.Backend(ROOT/'.runtime/server/plugins/MinecraftBuilderMCP/config.yml') + context=backend.call('project_context') + if context['world_id']!=before['world_uuid']:raise ValueError('Live verification world differs') + verified_water=0 + for points in hidden_water.values(): + lo={axis:min(point[j] for point in points) for j,axis in enumerate(('x','y','z'))} + hi={axis:max(point[j] for point in points) for j,axis in enumerate(('x','y','z'))} + read=backend.call('region_inspect',min=lo,max=hi,detail='blocks') + states={tuple(b['pos'][axis] for axis in ('x','y','z')):b['state'] for b in read['blocks']} + for point in points: + if not states[point].startswith('minecraft:water['):raise ValueError('Water changed beneath a marker: '+str(point)) + verified_water+=1 + report={'world':before['world'],'source':'live Paper surface maps + bounded RPC water reads', + 'verified_surface_columns':len(expected_y),'surface_mismatches':0, + 'unique_marker_blocks':len(targets),'planned_writes':planned_blocks, + 'original_water_columns':original_m.count('minecraft:water'),'water_columns_obscured_by_markers':verified_water, + 'water_obscured_by_markers_verified_intact':True, + 'height_unchanged_columns':sum(a==b for a,b in zip(before['surface_y'],after['surface_y'])), + 'capture_started_at':after['capture_started_at'],'capture_finished_at':after['capture_finished_at'], + 'atomic_snapshot':False,'note':'World edits were idle during each capture. Terrain follows its original heights; raised markers represent future structures, not finished traversable paths.'} + args.report.parent.mkdir(parents=True,exist_ok=True);args.report.write_text(json.dumps(report,indent=2)+'\n') + print(json.dumps(report)) + +if __name__=='__main__':main() diff --git a/scripts/verify-plaza.py b/scripts/verify-plaza.py new file mode 100644 index 0000000..c0cd765 --- /dev/null +++ b/scripts/verify-plaza.py @@ -0,0 +1,375 @@ +#!/usr/bin/env python3 +"""Read-only candidate/live QA for the composed Shacraft arrival gardens. + +Candidate mode overlays desired states on the observed baseline. Live mode uses +an observed after snapshot and compares every planned state exactly. The checks +cover this toolkit's static single flowers, persistent leaves, rooted log trees, +supported lanterns and supplied walking surfaces, not arbitrary Minecraft physics. +""" +import argparse +from collections import Counter, deque +import hashlib +import importlib.util +import json +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +def module(name, path): + spec = importlib.util.spec_from_file_location(name, path) + value = importlib.util.module_from_spec(spec) + spec.loader.exec_module(value) + return value + + +survey = module('plaza_qa_survey', ROOT / 'scripts/foundation-survey.py') +navigation = module('plaza_qa_navigation', ROOT / 'scripts/foundation-study/verify_geometry.py') +FLOWERS = {'allium', 'oxeye_daisy', 'azure_bluet', 'pink_tulip', 'white_tulip'} +SOIL = {'dirt', 'grass_block'} +STATIC_CUBES = survey.FULL | {'waxed_oxidized_cut_copper'} +N3 = ((1, 0, 0), (-1, 0, 0), (0, 1, 0), (0, -1, 0), (0, 0, 1), (0, 0, -1)) + + +def parse(state): + name, _, raw = state.removeprefix('minecraft:').partition('[') + props = dict(part.split('=', 1) for part in raw.rstrip(']').split(',') if '=' in part) + return name, props + + +class Reader: + def __init__(self, snapshot, overlay=None): + self.snapshot, self.scope = snapshot, snapshot.scope + self.overlay, self.cache = overlay or {}, {} + + def state(self, x, y, z): + key = (x, y, z) + if key not in self.cache: + self.cache[key] = self.overlay[key] if key in self.overlay else self.snapshot.state(x, y, z) + return self.cache[key] + + +def supported_center(state, face): + """Conservative face support for full cubes and simple dry shapes used here.""" + name, props = parse(state) + if props.get('waterlogged') == 'true': + return False + if name in STATIC_CUBES: + return True + if name.endswith('_slab'): + return props.get('type') == 'double' or props.get('type') == ('bottom' if face == 'down' else 'top') + if name.endswith('_stairs'): + return props.get('half') == ('bottom' if face == 'down' else 'top') + if name == 'iron_chain': + return props.get('axis') == 'y' + if name in ('spruce_fence', 'iron_bars'): + return True + if name == 'stone_brick_wall': + return props.get('up') == 'true' + return False + + +def leaf_distance(reader, start): + """Shortest face-connected path from a leaf to a log, capped at seven. + + Read actual adjacent states, including unchanged leaves/logs, rather than + trusting the compiler's desired-only propagation or saved distance values. + """ + pending, seen = deque([(start, 0)]), {start} + while pending: + (x, y, z), distance = pending.popleft() + if distance >= 6: + continue + for dx, dy, dz in N3: + neighbor = x + dx, y + dy, z + dz + name, _ = parse(reader.state(*neighbor)) + if name.endswith('_log'): + return distance + 1 + if name.endswith('_leaves') and neighbor not in seen: + seen.add(neighbor) + pending.append((neighbor, distance + 1)) + return 7 + + +def check_fixtures(reader, desired, metadata): + failures, counts = [], Counter() + for at, planned in desired.items(): + name, _ = parse(planned) + if name not in FLOWERS and name != 'lantern' and not name.endswith('_leaves'): + continue + x, y, z = at + state = reader.state(*at) + actual_name, props = parse(state) + if actual_name != name: + failures.append({'at': list(at), 'reason': 'fixture_material_differs', 'planned': planned, 'actual': state}) + continue + if name in FLOWERS: + counts['flowers'] += 1 + below = reader.state(x, y - 1, z) + if parse(below)[0] not in SOIL: + failures.append({'at': list(at), 'reason': 'flower_without_valid_soil', 'below': below}) + elif name == 'lantern': + counts['lanterns'] += 1 + hanging = props.get('hanging') == 'true' + support = reader.state(x, y + (1 if hanging else -1), z) + if props.get('waterlogged') != 'false' or not supported_center(support, 'down' if hanging else 'up'): + failures.append({'at': list(at), 'reason': 'lantern_without_dry_center_support', 'support': support}) + else: + counts['leaves'] += 1 + expected_distance = leaf_distance(reader, at) + if (props.get('persistent') != 'true' or props.get('waterlogged') != 'false' + or props.get('distance') != str(expected_distance)): + failures.append({'at': list(at), 'reason': 'unstable_or_wrong_leaf_state', + 'actual': state, 'expected_distance': expected_distance}) + roots = set() + for fixture in metadata['fixtures']: + if fixture['type'] not in ('conifer', 'topiary'): + continue + counts['rooted_trees'] += 1 + x, z = fixture['x'], fixture['z'] + root = (x, 96, z) + roots.add(root) + root_name, root_props = parse(reader.state(*root)) + below = reader.state(x, 95, z) + if not root_name.endswith('_log') or root_props.get('axis') != 'y' or parse(below)[0] not in SOIL: + failures.append({'at': list(root), 'reason': 'tree_root_without_vertical_log_and_soil', 'below': below}) + elif parse(below)[0] == 'grass_block': + failures.append({'at': [x, 95, z], 'reason': 'grass_under_opaque_trunk_will_decay_to_dirt'}) + trunk = sorted(p[1] for p, state in desired.items() if p[0] == x and p[2] == z and parse(state)[0].endswith('_log')) + if not trunk or trunk != list(range(96, max(trunk) + 1)): + failures.append({'at': list(root), 'reason': 'discontinuous_planned_trunk', 'log_y': trunk}) + else: + for y in trunk: + material, props = parse(reader.state(x, y, z)) + if not material.endswith('_log') or props.get('axis') != 'y': + failures.append({'at': [x, y, z], 'reason': 'trunk_gap_or_wrong_axis'}) + log_positions = {p for p, s in desired.items() if parse(s)[0].endswith('_log')} + pending, connected = deque(roots & log_positions), roots & log_positions + while pending: + x, y, z = pending.popleft() + for dx, dy, dz in N3: + p = x + dx, y + dy, z + dz + if p in log_positions and p not in connected: + pending.append(p) + connected.add(p) + if log_positions - connected: + failures.append({'reason': 'logs_disconnected_from_declared_tree_roots', 'positions': [list(p) for p in sorted(log_positions - connected)[:20]]}) + counts['root_connected_logs'] = len(connected) + return {'passed': not failures, 'checked': dict(counts), 'failures': failures} + + +def audit(before, layout, metadata, walk, base_navigation, after=None): + scope = survey.scope_of(layout['scope']) + if any(value != scope for value in (before.scope, metadata['scope'], walk['scope'])) or (after and after.scope != scope): + raise ValueError('Project/world/epoch differs between QA inputs') + desired, mismatches = {}, [] + for block in layout['blocks']: + pos = survey.point(block) + at = tuple(pos[a] for a in ('x', 'y', 'z')) + if at in desired: + raise ValueError('Duplicate desired coordinate') + if before.state(*at) != block['expected']: + raise ValueError(f'Plan expected state differs from baseline at {at}') + desired[at] = block['block'] + if after and after.state(*at) != block['block']: + mismatches.append({'at': list(at), 'expected': block['block'], 'actual': after.state(*at)}) + reader = Reader(after or before, None if after else desired) + fixtures = check_fixtures(reader, desired, metadata) + walking = survey.verify_walkable(reader, walk['points']) + nav = json.loads(json.dumps(base_navigation)) + blocked = {tuple(p) for p in metadata['unwalkable_columns']} + if any((p['x'], p['z']) in blocked for p in walk['points']): + raise ValueError('Walking samples include declared unwalkable ground') + excluded = 0 + for cell in nav['cells']: + if cell['clear'] and (cell['x'], cell['z']) in blocked: + cell['clear'] = False + excluded += 1 + navigation_result = navigation.audit(nav) + navigation_result['decorated_ground_columns_excluded'] = excluded + nav_cells = navigation.normalize_cells(nav) + graph = navigation.surface_graph(nav_cells) + reached = navigation.reachable(graph, (0, 9)) + bench_access = [] + vectors = {'north': (0, -1), 'south': (0, 1), 'west': (-1, 0), 'east': (1, 0)} + for fixture in metadata['fixtures']: + if fixture['type'] != 'bench': + continue + dx, dz = vectors[fixture['facing']] + for offset in (-1, 0, 1): + point = fixture['x'] + dx - dz * offset, fixture['z'] + dz + dx * offset + bench_access.append(navigation.endpoint_report( + f"bench-{fixture['x']}-{fixture['z']}-front-{offset}", point, graph, reached)) + navigation_result['bench_front_access'] = bench_access + navigation_result['passed'] &= all(point['passed'] for point in bench_access) + # A canopy stays traversable when it does not occupy the 1.8-block body space. + # Only explicit low obstacles are removed; overhead leaves do not mask paths. + return {'version': 1, 'mode': 'actual_after_snapshot' if after else 'candidate_overlay', 'scope': scope, + 'world_edits': 0, 'desired_blocks': len(desired), 'baseline_expected_states_verified': len(desired), + 'actual_exact_state_mismatches': len(mismatches) if after else None, + 'actual_mismatch_examples': mismatches[:30], 'fixtures': fixtures, 'walking': walking, + 'navigation': navigation_result, + 'passed': not mismatches and fixtures['passed'] and walking['passed'] and navigation_result['passed'], + 'note': 'Static support, leaf-distance and point-sampled navigation checks; not a complete moving-player collision simulation. Actual mode uses sequential observed snapshots.'} + + +def render_map(document, metadata, report, path, boundary_columns=None, boundary_note=None): + """Render actual captured surface cells with semantic material colors.""" + import numpy as np + import matplotlib + matplotlib.use('Agg') + import matplotlib.pyplot as plt + from matplotlib.colors import to_rgb + from matplotlib.collections import LineCollection + + if document.get('source') != 'paper_world_surface' or document.get('world_uuid') != metadata['scope']['world_id']: + raise ValueError('Rendering requires an actual Paper map of this project world') + semantic = {'minecraft:smooth_sandstone': '#e8ddbd', 'minecraft:cut_sandstone': '#d5c7a5', + 'minecraft:smooth_sandstone_slab': '#e8ddbd', 'minecraft:grass_block': '#87a75f', + 'minecraft:oak_leaves': '#78924d', 'minecraft:spruce_leaves': '#3e6851', + 'minecraft:spruce_log': '#765637', 'minecraft:green_concrete': '#548338', + 'minecraft:spruce_fence': '#7b5839', 'minecraft:spruce_stairs': '#967147', + 'minecraft:allium': '#b388ce', 'minecraft:oxeye_daisy': '#faf3d6', + 'minecraft:azure_bluet': '#f2f0db', 'minecraft:pink_tulip': '#efa8b7', + 'minecraft:white_tulip': '#f7f3e6', 'minecraft:lantern': '#ecc565'} + palette = [] + for name, fallback in zip(document['palette'], document['palette_rgb']): + color = '#68a494' if 'waxed_oxidized_cut_copper' in name else semantic.get(name, fallback) + palette.append(to_rgb(color)) + heights = np.asarray(document['surface_y'], dtype=float).reshape(document['length'], document['width']) + indices = np.asarray(document['material_index']).reshape(heights.shape) + rgb = np.asarray(palette)[indices] + dz, dx = np.gradient(heights) + shade = np.clip(.79 + .26 * (.55 * dx + .55 * dz + .63) / np.sqrt(1 + dx * dx + dz * dz), .64, 1.06) + for index, name in enumerate(document['palette']): + if name.removeprefix('minecraft:') in FLOWERS or name.endswith('_concrete'): + shade[indices == index] = 1 + rgb = np.clip(rgb * shade[:, :, None], 0, 1) + fig, ax = plt.subplots(figsize=(11, 12), facecolor='#f3f0e6') + ax.imshow(rgb, interpolation='nearest', origin='upper', + extent=(document['min_x'], document['max_x'] + 1, document['max_z'] + 1, document['min_z'])) + if boundary_columns: + boundary = set(map(tuple, boundary_columns)) + segments = [[(x + .5, z + .5), (x + dx + .5, z + dz + .5)] + for x, z in boundary for dx, dz in ((1, 0), (0, 1)) if (x + dx, z + dz) in boundary] + ax.add_collection(LineCollection(segments, colors='#a74640', linewidths=1, alpha=.85, zorder=5)) + ax.scatter([x + .5 for x, z in boundary], [z + .5 for x, z in boundary], + s=2, color='#a74640', alpha=.85, zorder=5, label='Invisible wall · observed barrier blocks') + ax.legend(loc='upper left', fontsize=8, facecolor='#faf7ed', framealpha=.94, edgecolor='#bcbfac') + ax.set(xlim=(-52, 53), ylim=(67, -47), xlabel='X · east →', ylabel='Z (positive south)') + ax.set_aspect('equal') + ax.tick_params(colors='#596451', labelsize=9) + for spine in ax.spines.values(): + spine.set_color('#9ca28d') + ax.annotate('N', xy=(47, -44), xytext=(47, -35), ha='center', va='center', + fontsize=11, weight='bold', color='#254534', arrowprops=dict(arrowstyle='-|>', color='#254534')) + ax.plot([-47, -37], [62, 62], color='#254534', lw=2) + ax.text(-42, 60, '10 blocks', ha='center', fontsize=8, color='#254534', + bbox=dict(fc='#faf7ed', ec='none', pad=2, alpha=.9)) + fig.suptitle('SHACRAFT / ARRIVAL GARDEN 01', x=.5, y=.975, fontsize=17, weight='bold', color='#244732') + fig.text(.5, .945, 'Captured server surface · original brand inlay · planted gardens', + ha='center', fontsize=10, color='#586751') + fig.text(.5, .035, f"{metadata['planters']} garden beds · {metadata['trees']} conifers · {metadata['benches']} benches · {metadata['new_lamps']} copper-capped lamps\n" + 'Observed block positions and materials · semantic colors and height shading.' + + ('\n' + boundary_note if boundary_note else ''), + ha='center', fontsize=9, color='#586751', linespacing=1.7) + fig.subplots_adjust(left=.09, right=.97, top=.917, bottom=.135 if boundary_note else .105) + path.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(path, dpi=180, facecolor=fig.get_facecolor()) + plt.close(fig) + + +class FakeSnapshot: + scope = {'project_id': 'test', 'world_id': 'world', 'world_epoch': 'epoch'} + + def __init__(self, states): + self.states = states + + def state(self, x, y, z): + return self.states.get((x, y, z), 'minecraft:air') + + +class PlazaQATests(unittest.TestCase): + def test_flower_soil_and_hanging_lantern_support(self): + desired = {(0, 96, 0): 'minecraft:white_tulip', + (2, 100, 0): 'minecraft:lantern[hanging=true,waterlogged=false]'} + states = {**desired, (0, 95, 0): 'minecraft:grass_block[snowy=false]', (2, 101, 0): 'minecraft:waxed_oxidized_cut_copper'} + result = check_fixtures(Reader(FakeSnapshot(states)), desired, {'fixtures': []}) + self.assertTrue(result['passed']) + del states[(2, 101, 0)] + states[(0, 95, 0)] = 'minecraft:smooth_sandstone' + result = check_fixtures(Reader(FakeSnapshot(states)), desired, {'fixtures': []}) + self.assertEqual({f['reason'] for f in result['failures']}, {'flower_without_valid_soil', 'lantern_without_dry_center_support'}) + + def test_leaf_distance_uses_unchanged_neighbors_and_requires_persistence(self): + leaf = 'minecraft:spruce_leaves[distance=2,persistent=true,waterlogged=false]' + desired = {(0, 100, 0): leaf} + states = {**desired, (1, 100, 0): 'minecraft:oak_leaves[distance=1,persistent=true,waterlogged=false]', + (2, 100, 0): 'minecraft:spruce_log[axis=y]'} + result = check_fixtures(Reader(FakeSnapshot(states)), desired, {'fixtures': []}) + self.assertTrue(result['passed']) + states[(0, 100, 0)] = leaf.replace('persistent=true', 'persistent=false') + self.assertFalse(check_fixtures(Reader(FakeSnapshot(states)), desired, {'fixtures': []})['passed']) + + def test_disconnected_tree_trunk_is_rejected(self): + desired = {(0, 96, 0): 'minecraft:spruce_log[axis=y]', (0, 98, 0): 'minecraft:spruce_log[axis=y]'} + states = {**desired, (0, 95, 0): 'minecraft:dirt'} + result = check_fixtures(Reader(FakeSnapshot(states)), desired, {'fixtures': [{'type': 'conifer', 'x': 0, 'z': 0}]}) + self.assertFalse(result['passed']) + self.assertIn('discontinuous_planned_trunk', {f['reason'] for f in result['failures']}) + + def test_grass_under_opaque_root_is_unstable_but_dirt_is_valid(self): + desired = {(0, 96, 0): 'minecraft:spruce_log[axis=y]'} + states = {**desired, (0, 95, 0): 'minecraft:grass_block[snowy=false]'} + metadata = {'fixtures': [{'type': 'conifer', 'x': 0, 'z': 0}]} + result = check_fixtures(Reader(FakeSnapshot(states)), desired, metadata) + self.assertEqual(result['failures'][0]['reason'], 'grass_under_opaque_trunk_will_decay_to_dirt') + states[(0, 95, 0)] = 'minecraft:dirt' + self.assertTrue(check_fixtures(Reader(FakeSnapshot(states)), desired, metadata)['passed']) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + stage = ROOT / '.runtime/plaza-stage03' + parser.add_argument('--before', type=Path, default=stage / 'before.json.gz') + parser.add_argument('--after', type=Path) + parser.add_argument('--layout', type=Path, default=stage / 'plaza.json') + parser.add_argument('--metadata', type=Path, default=stage / 'plaza.metadata.json') + parser.add_argument('--walk', type=Path, default=stage / 'plaza.walk.json') + parser.add_argument('--navigation', type=Path, default=ROOT / '.runtime/foundations-stage02/navigation-verified-walkable-input.json') + parser.add_argument('--report', type=Path, default=stage / 'candidate-qa.json') + parser.add_argument('--map', type=Path, help='Actual after surface map, used only with --png and --after') + parser.add_argument('--png', type=Path, help='Focused map of the captured arrival garden') + parser.add_argument('--self-test', action='store_true') + args = parser.parse_args() + if args.self_test: + result = unittest.TextTestRunner().run(unittest.defaultTestLoader.loadTestsFromTestCase(PlazaQATests)) + raise SystemExit(0 if result.wasSuccessful() else 1) + layout, metadata, walk, nav = [json.loads(p.read_text()) for p in (args.layout, args.metadata, args.walk, args.navigation)] + before = survey.load_snapshot(args.before, layout['scope']) + after = survey.load_snapshot(args.after, layout['scope']) if args.after else None + report = audit(before, layout, metadata, walk, nav, after) + paths = {'before': args.before, 'layout': args.layout, 'metadata': args.metadata, 'walk': args.walk, 'navigation': args.navigation} + if args.after: + paths['after'] = args.after + report['input_sha256'] = {key: hashlib.sha256(path.read_bytes()).hexdigest() for key, path in paths.items()} + args.report.parent.mkdir(parents=True, exist_ok=True) + survey.terrain.save(args.report, report) + if args.png: + if not args.map or not args.after: + parser.error('--png requires an actual --map and --after snapshot') + render_map(json.loads(args.map.read_text()), metadata, report, args.png) + print(json.dumps({'passed': report['passed'], 'mode': report['mode'], 'desired_blocks': report['desired_blocks'], + 'exact_mismatches': report['actual_exact_state_mismatches'], + 'fixtures': report['fixtures'], 'walk_points': report['walking']['checked_points'], + 'walk_failures': report['walking']['failures'][:20], + 'navigation_passed': report['navigation']['passed'], + 'unreachable_clear_columns': len(report['navigation']['unreachable_clear_columns']), + 'route_failures': [r['id'] for r in report['navigation']['routes'] if not r['passed']], + 'report': str(args.report.resolve())}, indent=2)) + raise SystemExit(0 if report['passed'] else 1) + + +if __name__ == '__main__': + main() diff --git a/scripts/verify-station.py b/scripts/verify-station.py new file mode 100644 index 0000000..5071b68 --- /dev/null +++ b/scripts/verify-station.py @@ -0,0 +1,389 @@ +#!/usr/bin/env python3 +"""Read-only candidate/live station block, public-floor and containment QA. + +Recipes use the checked layout contract {version:1,scope,blocks:[x,y,z,block, +expected]}. Metadata declares public_floors, air-only clear_regions and optional +containment bounds/seeds/authorized_caps. Flooding treats partial/unknown shapes +as passable, so a successful result does not rely on decorative collision shapes. +""" +import argparse +from collections import Counter, deque +import hashlib +import importlib.util +import json +import math +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location('station_base', ROOT / 'scripts/verify-balustrade.py') +base = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(base) +survey, plaza = base.survey, base.plaza +FULL = plaza.STATIC_CUBES | {'barrier'} +N2 = ((1, 0), (-1, 0), (0, 1), (0, -1)) +N3 = ((1, 0, 0), (-1, 0, 0), (0, 1, 0), (0, -1, 0), (0, 0, 1), (0, 0, -1)) + + +def full_cube(state): + return plaza.parse(state)[0] in FULL + + +def bounded_box(document): + box = survey.box_of(document['min'], document['max']) + if survey.volume(box) > survey.MAX_VOXELS: + raise ValueError('Verification box exceeds the snapshot voxel limit') + return box + + +def positions(box): + return ((x, y, z) for x in range(box['min']['x'], box['max']['x'] + 1) + for y in range(box['min']['y'], box['max']['y'] + 1) + for z in range(box['min']['z'], box['max']['z'] + 1)) + + +def inside(at, box): + return all(box['min'][axis] <= value <= box['max'][axis] + for axis, value in zip(('x', 'y', 'z'), at)) + + +def surface_shape(state): + if full_cube(state): + return [(0., 1.)] + name, props = plaza.parse(state) + if name in {'smooth_sandstone_slab', 'cut_sandstone_slab', 'waxed_oxidized_cut_copper_slab'}: + if props.get('waterlogged') != 'false': + return None + return {'bottom': [(0., .5)], 'top': [(.5, 1.)], 'double': [(0., 1.)]}.get(props.get('type')) + return survey.vertical_shape(state) + + +def public_floors(reader, floors): + reports = [] + for floor in floors: + name, feet, raw = floor['id'], floor['standing_y'], floor['clear_columns'] + if feet not in (99, 113): + raise ValueError('This station stage declares public feet at Y99 or Y113') + if (not isinstance(raw, list) or not raw or any(not isinstance(p, list) or len(p) != 2 + or any(type(v) is not int for v in p) for p in raw)): + raise ValueError('Each public floor requires integer clear_columns [x,z]') + clear = set(map(tuple, raw)) + if len(clear) != len(raw): + raise ValueError('Duplicate public floor column') + source = floor['source']['x'], floor['source']['z'] + if source not in clear: + raise ValueError('Floor source is not a declared clear column') + height = floor.get('min_headroom', 4) + if type(height) not in (int, float) or not math.isfinite(height) or height < 1.8: + raise ValueError('Headroom must be finite and at least 1.8 blocks') + failures, valid = [], set() + for x, z in sorted(clear): + support = surface_shape(reader.state(x, feet - 1, z)) + reason = None + if support is None or not any(abs(high - 1.) < 1e-8 for low, high in support): + reason = 'missing_or_unknown_support_at_public_feet' + else: + for y in range(feet, math.ceil(feet + height)): + shape = surface_shape(reader.state(x, y, z)) + if shape is None: + reason = 'unknown_shape_in_required_clearance' + break + if any(y + lo < feet + height and y + hi > feet for lo, hi in shape): + reason = 'occupied_required_clearance' + break + if reason: + failures.append({'x': x, 'z': z, 'standing_y': feet, 'reason': reason}) + else: + valid.add((x, z)) + reached, queue = set(), deque([source]) + while queue: + p = queue.popleft() + if p in reached or p not in valid: + continue + reached.add(p) + queue.extend((p[0] + dx, p[1] + dz) for dx, dz in N2) + unreachable = sorted(clear - reached) + reports.append({'id': name, 'standing_y': feet, 'required_headroom': height, + 'declared_columns': len(clear), 'physically_clear_columns': len(valid), + 'reachable_clear_columns': len(reached), 'failed_samples': len(failures), + 'sample_failure_examples': failures[:30], 'unreachable_columns': len(unreachable), + 'unreachable_examples': unreachable[:30], 'passed': not failures and not unreachable}) + return {'status': 'checked' if reports else 'not_provided', 'passed': bool(reports) and all(r['passed'] for r in reports), + 'floors': reports, 'method': 'Observed center supports and declared vertical headroom, then cardinal traversal across valid level-floor columns. No lift transitions are inferred.'} + + +def clear_regions(reader, regions): + reports = [] + for region in regions: + box, failures, checked = bounded_box(region), [], 0 + for at in positions(box): + checked += 1 + state = reader.state(*at) + if state not in survey.AIR: + failures.append({'at': list(at), 'actual': state}) + reports.append({'id': region['id'], 'bounds': box, 'checked_voxels': checked, + 'nonair_voxels': len(failures), 'examples': failures[:30], 'passed': not failures}) + return {'status': 'checked' if reports else 'not_provided', 'passed': bool(reports) and all(r['passed'] for r in reports), + 'regions': reports, 'method': 'Every declared doorway/cabin/aisle clearance voxel must be observed air.'} + + +def named_access(reader, metadata): + """Ensure furnishing targets are not silently filtered out of public topology.""" + interior = metadata.get('interior') + if interior is None: + return {'status': 'not_provided', 'passed': True} + failures, targets_by_floor, lift_reports = [], {}, [] + floors = {f['standing_y']: set(map(tuple, f['clear_columns'])) for f in metadata['public_floors']} + for raw_feet, navigation in interior['navigation'].items(): + feet = int(raw_feet) + targets = navigation['named_targets'] + targets_by_floor[raw_feet] = len(targets) + if feet not in floors or not targets: + failures.append({'reason': 'missing_floor_or_named_targets', 'standing_y': feet}) + for target in targets: + x, z = target['x'], target['z'] + if target['y'] != feet or (x, z) not in floors.get(feet, set()): + failures.append({'reason': 'named_target_missing_from_public_topology', 'target': target}) + if not full_cube(reader.state(x, feet - 1, z)) or any( + reader.state(x, y, z) not in survey.AIR for y in range(feet, feet + 4)): + failures.append({'reason': 'named_target_missing_full_support_or_four_air', 'target': target}) + for level, lift in interior['lift'].items(): + selector = tuple(lift['selector_block']) + landing = tuple(lift['landing_block']) + selector_state = reader.state(*selector) + x, feet, z = landing + passed = selector_state == 'minecraft:gold_block' and full_cube(reader.state(x, feet - 1, z)) and all( + reader.state(x, y, z) in survey.AIR for y in range(feet, feet + 4)) + lift_reports.append({'floor': level, 'selector': selector, 'observed_selector': selector_state, + 'landing': landing, 'passed': passed}) + if not passed: + failures.append({'reason': 'lift_selector_or_landing_obstructed', 'floor': level}) + return {'status': 'checked', 'passed': not failures, 'named_targets_per_floor': targets_by_floor, + 'lift_landings': lift_reports, 'failures': failures, + 'method': 'Named furnishing targets must remain in the independently verified public-floor topology with full support and four air blocks. Gold selector and landing voxels are checked; runtime lift operation is not inferred.'} + + +def fixture_stability(reader, desired): + fixtures = {p: s for p, s in desired.items() if plaza.parse(s)[0] in {'lantern', 'spruce_leaves'}} + report = plaza.check_fixtures(reader, fixtures, {'fixtures': []}) + report['checked'].pop('root_connected_logs', None) + logs = {p: s for p, s in desired.items() if plaza.parse(s)[0] == 'spruce_log'} + roots = [p for p in logs if (p[0], p[1] - 1, p[2]) not in logs] + for x, y, z in roots: + below = reader.state(x, y - 1, z) + if plaza.parse(below)[0] not in {'dirt', 'moss_block'}: + report['failures'].append({'reason': 'station_topiary_or_diorama_root_without_soil', + 'at': [x, y, z], 'below': below}) + report['checked'].update({'spruce_logs': len(logs), 'topiary_or_diorama_roots': len(roots)}) + report['passed'] = not report['failures'] + report['status'] = 'checked' + report['method'] = 'Planned lantern support, persistent leaf distances against the full observed neighborhood, and station topiary/diorama roots on stable dirt or moss.' + return report + + +def conservative_containment(reader, metadata): + if metadata is None: + return {'status': 'not_proven', 'passed': False, 'reason': 'No containment bounds, seeds and authorized entrance caps were supplied.'} + box = bounded_box(metadata['bounds']) + caps, cap_reports = set(), [] + for region in metadata.get('authorized_caps', []): + cap_box = bounded_box(region) + points = set(positions(cap_box)) + if not points or not all(inside(p, box) for p in points): + raise ValueError('Authorized virtual cap lies outside surveyed containment bounds') + caps |= points + cap_reports.append({'id': region['id'], 'bounds': cap_box, 'voxels': len(points)}) + blocked, passable, unknown = set(), set(), Counter() + for at in positions(box): + state = reader.state(*at) + if full_cube(state) or at in caps: + blocked.add(at) + else: + passable.add(at) + if state not in survey.AIR: + unknown[plaza.parse(state)[0]] += 1 + regions = [(r['id'], bounded_box(r)) for r in metadata.get('forbidden_regions', [])] + roof_y = metadata.get('forbidden_y_at_or_above', 126) + if type(roof_y) is not int: + raise ValueError('Forbidden roof threshold must be an integer Y') + seeds = metadata.get('seeds') + if not isinstance(seeds, list) or not seeds: + raise ValueError('Containment requires explicit public aisle/cabin seeds') + reports = [] + for seed in seeds: + coordinates = [seed[axis] for axis in ('x', 'y', 'z')] + if any(type(v) not in (int, float) or not math.isfinite(v) for v in coordinates): + raise ValueError('Seed coordinates must be finite') + source = tuple(math.floor(v) for v in coordinates) + if not inside(source, box) or source in caps: + raise ValueError('Containment source is outside its observed box or inside a virtual cap') + low, high = seed.get('min_y'), seed.get('max_y') + if any(v is not None and type(v) is not int for v in (low, high)): + raise ValueError('Per-floor minimum/maximum reachable Y must be integers') + reached, queue, violations, examples = set(), deque([source]), Counter(), [] + while queue: + p = queue.popleft() + if p in reached or p not in passable: + continue + reached.add(p) + reasons = [] + if any(value in (box['min'][axis], box['max'][axis]) for axis, value in zip(('x', 'y', 'z'), p)): + reasons.append('survey_boundary_reachable') + if p[1] >= roof_y: + reasons.append('roof_space_reachable') + if low is not None and p[1] < low: + reasons.append('below_public_floor_reachable') + if high is not None and p[1] > high: + reasons.append('above_public_room_reachable') + reasons.extend('forbidden_region:' + name for name, bounds in regions if inside(p, bounds)) + for reason in reasons: + violations[reason] += 1 + if len(examples) < 30: + examples.append({'at': list(p), 'reason': reason}) + queue.extend((p[0] + dx, p[1] + dy, p[2] + dz) for dx, dy, dz in N3) + source_air = reader.state(*source) in survey.AIR + reports.append({'id': seed['id'], 'source': list(source), 'source_observed_air': source_air, + 'reachable_voxels': len(reached), 'minimum_reached_y': min((p[1] for p in reached), default=None), + 'maximum_reached_y': max((p[1] for p in reached), default=None), + 'violation_counts': dict(violations), 'examples': examples, + 'passed': source_air and bool(reached) and not violations}) + return {'status': 'checked', 'passed': all(r['passed'] for r in reports), 'bounds': box, + 'observed_voxels': len(blocked) + len(passable), 'authorized_virtual_caps': cap_reports, + 'partial_or_unknown_materials_treated_as_passable': dict(unknown), 'seeds': reports, + 'method': 'Independent 6-neighbor floods from each public room/cabin seed. Known full cubes including glass block movement; partial/unknown shapes are treated as empty. Authorized entrance caps are virtual audit boundaries only.', + 'limits': 'This overestimates continuous player movement and does not simulate teleportation, spectator, block removal or lift operation. Failed floods through partial shapes require review; they are not automatically proven playable escape paths.'} + + +def audit(before, recipe, metadata, after=None): + scope = survey.scope_of(recipe['scope']) + if scope != before.scope or scope != metadata['scope'] or (after and after.scope != scope): + raise ValueError('Recipe, metadata and snapshots differ in project/world/epoch') + desired = base.coordinates(recipe) + for row in recipe['blocks']: + at = tuple(row[axis] for axis in ('x', 'y', 'z')) + if before.state(*at) != row['expected']: + raise ValueError(f'Recipe expected state differs from observed baseline at {at}') + reader = plaza.Reader(after or before, None if after else desired) + floor_report = public_floors(reader, metadata.get('public_floors', [])) + clearance = clear_regions(reader, metadata.get('clear_regions', [])) + access = named_access(reader, metadata) + fixtures = fixture_stability(reader, desired) + containment = conservative_containment(reader, metadata.get('containment')) + volume = base.compare_volume(before, after, desired) if after else None + unproven = [] + if floor_report['status'] == 'not_provided': + unproven.append('Public floor support/headroom/reachability: no clear-column topology supplied.') + if clearance['status'] == 'not_provided': + unproven.append('Doorway and lift cabin clearances: no explicit clearance regions supplied.') + if containment['status'] == 'not_proven': + unproven.append('Windows, floor separation and roof/private-space containment: no flood contract supplied.') + provided = [r for r in (floor_report, clearance, access, fixtures, containment) if r['status'] == 'checked'] + supported_passed = all(r['passed'] for r in provided) and (volume is None or volume['passed']) + return {'version': 1, 'scope': scope, 'world_edits': 0, + 'mode': 'actual_after_snapshot' if after else 'candidate_overlay', 'desired_blocks': len(desired), + 'expected_states_verified_against_baseline': len(desired), 'public_floors': floor_report, + 'clear_regions': clearance, 'named_access': access, 'fixtures': fixtures, + 'containment': containment, 'volume': volume, + 'supported_checks_passed': supported_passed, 'unproven_checks': unproven, + 'passed': supported_passed and not unproven, + 'limits': 'Only observed block states are verified. Block entity text, display entities, gameplay/sign destinations and operational lift transitions require separate runtime checks. Actual snapshots are sequential, not atomic.'} + + +class FakeReader: + def __init__(self, states): + self.states = states + + def state(self, x, y, z): + return self.states.get((x, y, z), 'minecraft:air') + + +class StationTests(unittest.TestCase): + def room(self): + box = {'min': {'x': -1, 'y': 98, 'z': -1}, 'max': {'x': 5, 'y': 127, 'z': 5}} + states = {(x, y, z): 'minecraft:glass' for x in range(5) for y in range(99, 104) for z in range(5) + if x in (0, 4) or z in (0, 4) or y in (99, 103)} + meta = {'bounds': box, 'seeds': [{'id': 'room', 'x': 2, 'y': 100, 'z': 2, 'min_y': 100, 'max_y': 102}]} + return FakeReader(states), meta + + def test_closed_glass_room_is_contained_and_window_hole_is_detected(self): + reader, metadata = self.room() + self.assertTrue(conservative_containment(reader, metadata)['passed']) + del reader.states[0, 101, 2] + result = conservative_containment(reader, metadata) + self.assertFalse(result['passed']) + self.assertIn('survey_boundary_reachable', result['seeds'][0]['violation_counts']) + + def test_authorized_entrance_can_be_virtually_capped_without_changing_world(self): + reader, metadata = self.room() + del reader.states[0, 101, 2] + metadata['authorized_caps'] = [{'id': 'entry', 'min': {'x': 0, 'y': 101, 'z': 2}, 'max': {'x': 0, 'y': 101, 'z': 2}}] + self.assertTrue(conservative_containment(reader, metadata)['passed']) + self.assertEqual(reader.state(0, 101, 2), 'minecraft:air') + + def test_partial_window_is_treated_as_open_and_upper_floor_hole_is_detected(self): + reader, metadata = self.room() + reader.states[0, 101, 2] = 'minecraft:iron_bars[east=false,north=true,south=true,waterlogged=false,west=false]' + self.assertFalse(conservative_containment(reader, metadata)['passed']) + reader, metadata = self.room() + del reader.states[2, 99, 2] + result = conservative_containment(reader, metadata) + self.assertIn('below_public_floor_reachable', result['seeds'][0]['violation_counts']) + + def test_public_headroom_is_four_blocks_and_obstacle_disconnects_floor(self): + reader = FakeReader({(x, 98, 0): 'minecraft:stone' for x in range(3)}) + floor = {'id': 'vestibule', 'standing_y': 99, 'source': {'x': 0, 'z': 0}, 'clear_columns': [[0, 0], [1, 0], [2, 0]]} + self.assertTrue(public_floors(reader, [floor])['passed']) + reader.states[1, 102, 0] = 'minecraft:lantern[hanging=true,waterlogged=false]' + result = public_floors(reader, [floor]) + self.assertFalse(result['passed']) + self.assertEqual(result['floors'][0]['unreachable_columns'], 2) + + def test_clearance_region_reports_real_door_obstruction(self): + reader = FakeReader({(0, 99, 0): 'minecraft:stone'}) + region = {'id': 'door', 'min': {'x': 0, 'y': 99, 'z': 0}, 'max': {'x': 0, 'y': 102, 'z': 0}} + self.assertEqual(clear_regions(reader, [region])['regions'][0]['nonair_voxels'], 1) + + def test_named_target_cannot_be_hidden_by_filtering_the_public_mask(self): + reader = FakeReader({(x, 98, 0): 'minecraft:stone' for x in range(2)}) + metadata = {'public_floors': [{'standing_y': 99, 'clear_columns': [[0, 0]]}], + 'interior': {'navigation': {'99': {'named_targets': [ + {'name': 'bench-front', 'x': 1, 'y': 99, 'z': 0}]}}, 'lift': {}}} + self.assertFalse(named_access(reader, metadata)['passed']) + metadata['public_floors'][0]['clear_columns'].append([1, 0]) + self.assertTrue(named_access(reader, metadata)['passed']) + + def test_fixture_leaf_distance_and_lantern_support_use_actual_states(self): + desired = {(0, 100, 0): 'minecraft:lantern[hanging=true,waterlogged=false]', + (2, 100, 0): 'minecraft:spruce_leaves[distance=1,persistent=true,waterlogged=false]'} + reader = FakeReader(desired | {(0, 101, 0): 'minecraft:stone', + (2, 99, 0): 'minecraft:spruce_log[axis=y]'}) + self.assertTrue(fixture_stability(reader, desired)['passed']) + del reader.states[0, 101, 0] + del reader.states[2, 99, 0] + self.assertEqual(len(fixture_stability(reader, desired)['failures']), 2) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for name in ('before', 'after', 'recipe', 'metadata', 'report'): + parser.add_argument('--' + name, type=Path) + parser.add_argument('--self-test', action='store_true') + args = parser.parse_args() + if args.self_test: + result = unittest.TextTestRunner().run(unittest.defaultTestLoader.loadTestsFromTestCase(StationTests)) + raise SystemExit(0 if result.wasSuccessful() else 1) + if any(getattr(args, name) is None for name in ('before', 'recipe', 'metadata', 'report')): + parser.error('--before, --recipe, --metadata and --report are required') + before = survey.load_snapshot(args.before) + after = survey.load_snapshot(args.after, before.scope) if args.after else None + report = audit(before, json.loads(args.recipe.read_text()), json.loads(args.metadata.read_text()), after) + paths = {name: getattr(args, name) for name in ('before', 'after', 'recipe', 'metadata') if getattr(args, name)} + report['input_sha256'] = {key: hashlib.sha256(path.read_bytes()).hexdigest() for key, path in paths.items()} + args.report.parent.mkdir(parents=True, exist_ok=True) + survey.terrain.save(args.report, report) + print(json.dumps(report, indent=2)) + raise SystemExit(0 if report['passed'] else 2 if report['supported_checks_passed'] and report['unproven_checks'] else 1) + + +if __name__ == '__main__': + main() diff --git a/scripts/verify-zone-containment.py b/scripts/verify-zone-containment.py new file mode 100644 index 0000000..25515ef --- /dev/null +++ b/scripts/verify-zone-containment.py @@ -0,0 +1,384 @@ +#!/usr/bin/env python3 +"""Read-only proof of a closed, observed full-cube Minecraft zone enclosure. + +The membrane is derived independently from an extruded XZ mask minus explicit +excluded voxels: every six-neighbor exterior shell voxel must be a full cube. An +exterior flood treats every unknown/partial block as empty, overestimating escape +routes. Swept 0.6 x 1.8 player AABB probes additionally test cardinal/diagonal +crossings, half-block stair rises, floor drops and roof ascent. These sample +probes supplement the complete membrane proof; they are not the proof itself. + +This verifies static continuous movement, not spectator, commands, breaking, +plugin teleportation, or arbitrary discontinuous teleport/pearl behavior. +""" +import argparse +from collections import Counter, deque +import hashlib +import importlib.util +import itertools +import json +import math +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location('containment_base', ROOT / 'scripts/verify-balustrade.py') +base = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(base) +survey, plaza = base.survey, base.plaza +FULL = plaza.STATIC_CUBES | {'barrier'} +N2 = ((1, 0), (-1, 0), (0, 1), (0, -1)) +N3 = ((1, 0, 0), (-1, 0, 0), (0, 1, 0), (0, -1, 0), (0, 0, 1), (0, 0, -1)) +WIDTH, HEIGHT = .6, 1.8 + + +def full_cube(state): + return plaza.parse(state)[0] in FULL + + +def mask_geometry(metadata): + raw = metadata['interior_columns'] + if (not isinstance(raw, list) or not raw or any(not isinstance(p, list) or len(p) != 2 + or any(type(v) is not int for v in p) for p in raw)): + raise ValueError('interior_columns must be nonempty integer [x,z] pairs') + interior = set(map(tuple, raw)) + if len(interior) != len(raw): + raise ValueError('Duplicate interior column') + floor, roof = metadata['floor_y'], metadata['roof_y'] + if type(floor) is not int or type(roof) is not int or roof - floor < 4: + raise ValueError('Integer floor_y and roof_y need at least three free interior rows') + shell = {(x + dx, z + dz) for x, z in interior for dx, dz in N2} - interior + footprint = interior | shell + reached, queue = set(), deque([next(iter(interior))]) + while queue: + p = queue.popleft() + if p in reached or p not in interior: + continue + reached.add(p) + queue.extend((p[0] + dx, p[1] + dz) for dx, dz in N2) + if reached != interior: + raise ValueError('Interior mask must be one cardinally connected component') + for key in ('wall_columns', 'shell_columns'): + if ('interior_excluded_voxels' not in metadata and key in metadata + and set(map(tuple, metadata[key])) != shell): + raise ValueError(f'Claimed {key} differ from the independently derived complete shell') + bounds = ((min(x for x, z in footprint) - 1, floor - 1, min(z for x, z in footprint) - 1), + (max(x for x, z in footprint) + 1, roof + 1, max(z for x, z in footprint) + 1)) + if math.prod(hi - lo + 1 for lo, hi in zip(*bounds)) > survey.MAX_VOXELS: + raise ValueError('Enclosure verification exceeds the bounded snapshot voxel limit') + raw_excluded = metadata.get('interior_excluded_voxels', []) + if (not isinstance(raw_excluded, list) or any(not isinstance(p, list) or len(p) != 3 + or any(type(v) is not int for v in p) for p in raw_excluded)): + raise ValueError('interior_excluded_voxels must contain integer [x,y,z] triples') + excluded = set(map(tuple, raw_excluded)) + if len(excluded) != len(raw_excluded): + raise ValueError('Duplicate excluded interior voxel') + volume = {(x, y, z) for x, z in interior for y in range(floor + 1, roof)} + if not excluded <= volume: + raise ValueError('Excluded voxel is outside the base extruded interior') + volume -= excluded + if not volume: + raise ValueError('Excluded voxels remove the whole enclosure interior') + membrane_voxels = {(x + dx, y + dy, z + dz) for x, y, z in volume for dx, dy, dz in N3} - volume + return interior, shell, footprint, floor, roof, bounds, volume, membrane_voxels, excluded + + +def membrane(reader, geometry): + interior, _, _, floor, roof, _ = geometry[:6] + failures, materials, counts = [], Counter(), Counter() + for x, y, z in sorted(geometry[7]): + kind = ('floor' if y == floor else 'roof' if y == roof else + 'wall' if (x, z) not in interior else 'folded_boundary') + counts[kind] += 1 + state = reader.state(x, y, z) + materials[plaza.parse(state)[0]] += 1 + if not full_cube(state): + failures.append({'at': [x, y, z], 'part': kind, 'actual': state}) + return {'passed': not failures, 'required_voxels': sum(counts.values()), 'parts': dict(counts), + 'observed_materials': dict(materials), 'nonfull_voxels': len(failures), 'examples': failures[:30], + 'method': 'Every voxel in N6(interior volume) minus interior volume must be an observed full collision cube.'} + + +def conservative_exterior_flood(reader, geometry, source): + lo, hi = geometry[5] + blocked, passable, unknown = set(), set(), Counter() + boundary = [] + for x in range(lo[0], hi[0] + 1): + for y in range(lo[1], hi[1] + 1): + for z in range(lo[2], hi[2] + 1): + p = x, y, z + state = reader.state(*p) + if full_cube(state): + blocked.add(p) + continue + passable.add(p) + if state not in survey.AIR: + unknown[plaza.parse(state)[0]] += 1 + if any(v in (low, high) for v, low, high in zip(p, lo, hi)): + boundary.append(p) + reached, queue = set(boundary), deque(boundary) + while queue: + x, y, z = queue.popleft() + for dx, dy, dz in N3: + p = x + dx, y + dy, z + dz + if p in passable and p not in reached: + reached.add(p) + queue.append(p) + leaks = sorted(reached & geometry[6]) + source_cell = tuple(math.floor(v) for v in source) + return {'passed': not leaks, 'observed_voxels': len(blocked) + len(passable), + 'exterior_reached_voxels': len(reached), 'interior_reached_from_exterior': len(leaks), + 'source_reached_from_exterior': source_cell in reached, + 'unknown_or_partial_blocks_treated_as_empty': dict(unknown), 'leak_examples': leaks[:30], + 'method': 'Six-neighbor free-voxel exterior flood; only known full collision cubes obstruct it.'} + + +def segment_box(start, finish, low, high): + """Slab intersection with the interior of an expanded block AABB. + + Face contact is legal: a player's feet resting exactly on paving must not + make every horizontal probe appear blocked before reaching the guard. + """ + enter, leave = 0., 1. + for a, b, lo, hi in zip(start, finish, low, high): + lo, hi = lo + 1e-9, hi - 1e-9 + delta = b - a + if abs(delta) < 1e-12: + if a < lo or a > hi: + return False + continue + first, last = sorted(((lo - a) / delta, (hi - a) / delta)) + enter, leave = max(enter, first), min(leave, last) + if enter > leave: + return False + return True + + +def swept_player_hits_full_cube(reader, start, finish): + radius = WIDTH / 2 + low = [math.floor(min(start[i], finish[i]) - (radius if i != 1 else 0)) for i in range(3)] + high = [math.floor(max(start[i], finish[i]) + (radius if i != 1 else HEIGHT)) for i in range(3)] + for x in range(low[0], high[0] + 1): + for y in range(low[1], high[1] + 1): + for z in range(low[2], high[2] + 1): + if full_cube(reader.state(x, y, z)) and segment_box( + start, finish, (x - radius, y - HEIGHT, z - radius), + (x + 1 + radius, y + 1, z + 1 + radius)): + return True + return False + + +def player_probes(reader, geometry, source): + interior, _, _, floor, roof, _ = geometry[:6] + counts, missed = Counter(), [] + source_clear = all(reader.state(x, y, z) in survey.AIR + for x in range(math.floor(source[0] - WIDTH / 2), math.floor(source[0] + WIDTH / 2) + 1) + for y in range(math.floor(source[1]), math.ceil(source[1] + HEIGHT)) + for z in range(math.floor(source[2] - WIDTH / 2), math.floor(source[2] + WIDTH / 2) + 1)) + # Cross each boundary in cardinal and diagonal directions, including half- + # block stair rises/drops. Fixed-height samples also cover free flight rows. + heights = sorted({float(floor + 1), float(source[1]), roof - HEIGHT - .05}) + for x, z in sorted(interior): + for dx, dz in itertools.product((-1, 0, 1), repeat=2): + if (dx == dz == 0) or (x + dx, z + dz) in interior: + continue + for y in heights: + for dy in (-.5, 0., .5): + start = (x + .5, y, z + .5) + finish = (x + dx + .5, y + dy, z + dz + .5) + kind = 'diagonal' if dx and dz else 'cardinal' + counts[kind] += 1 + if not swept_player_hits_full_cube(reader, start, finish): + missed.append({'kind': kind, 'from': start, 'to': finish}) + # Test the locally folded boundary as well as the original outer perimeter. + # The complete membrane check covers every height even when a sample starts + # in an already obstructed partial-height pocket. + for x, y, z in sorted(geometry[8] & geometry[7]): + for dx, dy, dz in N3: + p = x + dx, y + dy, z + dz + if p not in geometry[6]: + continue + start, finish = (p[0] + .5, float(p[1]), p[2] + .5), (x + .5, float(y), z + .5) + counts['folded_boundary'] += 1 + if not swept_player_hits_full_cube(reader, start, finish): + missed.append({'kind': 'folded_boundary', 'from': start, 'to': finish}) + for kind, start, finish in [('floor', (source[0], floor + 1.1, source[2]), + (source[0], floor - .5, source[2])), + ('roof', (source[0], roof - HEIGHT - .1, source[2]), + (source[0], roof - .5, source[2]))]: + counts[kind] += 1 + if not swept_player_hits_full_cube(reader, start, finish): + missed.append({'kind': kind, 'from': start, 'to': finish}) + return {'passed': source_clear and not missed, 'player_width': WIDTH, 'player_height': HEIGHT, + 'source_headroom_observed_air': source_clear, 'swept_crossing_probes': dict(counts), + 'unblocked_probes': len(missed), 'examples': missed[:30], + 'note': 'Supplementary swept-AABB boundary probes; the independently derived full membrane is the complete static containment criterion.'} + + +def volume_components(reader, geometry, source): + """Report geometric components; every one is still checked for containment.""" + pending, components = set(geometry[6]), [] + source_cell = tuple(math.floor(v) for v in source) + while pending: + queue, count, nonfull, contains_source = deque([next(iter(pending))]), 0, 0, False + while queue: + p = queue.popleft() + if p not in pending: + continue + pending.remove(p) + count += 1 + nonfull += not full_cube(reader.state(*p)) + contains_source |= p == source_cell + queue.extend((p[0] + dx, p[1] + dy, p[2] + dz) for dx, dy, dz in N3) + components.append({'voxels': count, 'nonfull_voxels': nonfull, 'contains_source': contains_source}) + return {'components': sorted(components, key=lambda c: c['voxels'], reverse=True), + 'source_in_volume': any(c['contains_source'] for c in components), + 'note': 'Disconnected sealed components are reported, not treated as escapes. The entire required membrane and exterior flood are checked.'} + + +def preserved_decor(before, reader, desired): + protected, failures = 0, [] + for at, state in base.captured_voxels(before): + name, _ = plaza.parse(state) + if (name not in plaza.FLOWERS | {'lantern', 'spruce_fence', 'spruce_stairs', 'iron_chain'} + and not any(suffix in name for suffix in ('_leaves', '_log', 'waxed_oxidized_cut_copper'))): + continue + protected += 1 + if reader.state(*at) != state: + failures.append({'at': list(at), 'before': state, 'after': reader.state(*at), 'in_plan': at in desired}) + return {'passed': not failures, 'protected_observed_states': protected, 'changed_states': len(failures), + 'examples': failures[:30]} + + +def audit(before, layout, metadata, after=None): + scope = survey.scope_of(layout['scope']) + if scope != before.scope or scope != metadata['scope'] or (after and after.scope != scope): + raise ValueError('Project/world/epoch differs between containment inputs') + desired = base.coordinates(layout) + for row in layout['blocks']: + p = tuple(row[a] for a in ('x', 'y', 'z')) + if before.state(*p) != row['expected']: + raise ValueError(f'Expected plan state differs from observed baseline at {p}') + source = tuple(metadata['source'][axis] for axis in ('x', 'y', 'z')) + if any(type(v) not in (int, float) or not math.isfinite(v) for v in source): + raise ValueError('Source must contain finite x/y/z player feet coordinates') + geometry = mask_geometry(metadata) + if tuple(math.floor(v) for v in source) not in geometry[6] or not geometry[3] < source[1] < geometry[4] - HEIGHT: + raise ValueError('Source is outside the enclosure interior') + reader = plaza.Reader(after or before, None if after else desired) + closed = membrane(reader, geometry) + flood = conservative_exterior_flood(reader, geometry, source) + probes = player_probes(reader, geometry, source) + components = volume_components(reader, geometry, source) + decor = preserved_decor(before, reader, desired) + volume = base.compare_volume(before, after, desired) if after else None + return {'version': 1, 'scope': scope, 'world_edits': 0, + 'mode': 'actual_after_snapshot' if after else 'candidate_overlay', + 'desired_blocks': len(desired), 'interior_columns': len(geometry[0]), + 'independently_derived_wall_columns': len(geometry[1]), + 'interior_voxels': len(geometry[6]), 'interior_excluded_voxels': len(geometry[8]), + 'volume_components': components, + 'membrane': closed, 'exterior_flood': flood, 'player_probes': probes, + 'preserved_decor': decor, 'volume': volume, + 'passed': all(r['passed'] for r in (closed, flood, probes, decor)) and (volume is None or volume['passed']), + 'limitations': 'Static continuous collision containment for normal nonspectator players only. No protection against spectator, breaking/removing blocks, operator commands, plugin teleports or arbitrary discontinuous teleport/pearl behavior. Actual snapshots are sequential, not atomic.'} + + +class ContainmentTests(unittest.TestCase): + def scene(self): + meta = {'interior_columns': [[0, 0], [1, 0], [0, 1], [1, 1]], 'floor_y': 0, 'roof_y': 5} + geo = mask_geometry(meta) + states = {} + for x, z in geo[2]: + for y in ([0, 5] if (x, z) in geo[0] else range(6)): + states[x, y, z] = 'minecraft:barrier' + reader = type('Reader', (), {'state': lambda self, x, y, z: states.get((x, y, z), 'minecraft:air')})() + return meta, geo, states, reader + + def test_complete_shell_blocks_flight_diagonals_stairs_and_drops(self): + _, geo, _, reader = self.scene() + self.assertTrue(membrane(reader, geo)['passed']) + self.assertTrue(conservative_exterior_flood(reader, geo, (.5, 1., .5))['passed']) + self.assertTrue(player_probes(reader, geo, (.5, 1., .5))['passed']) + + def test_one_missing_wall_or_roof_voxel_leaks(self): + for at in [(-1, 2, 0), (0, 5, 0), (0, 0, 0)]: + _, geo, states, reader = self.scene() + del states[at] + self.assertFalse(membrane(reader, geo)['passed']) + self.assertFalse(conservative_exterior_flood(reader, geo, (.5, 1., .5))['passed']) + + def test_floor_need_not_extend_under_exterior_wall(self): + _, geo, states, reader = self.scene() + for x, z in geo[1]: + states.pop((x, 0, z), None) + self.assertTrue(membrane(reader, geo)['passed']) + self.assertTrue(conservative_exterior_flood(reader, geo, (.5, 1., .5))['passed']) + + def test_inward_fold_preserves_partial_stair_outside_and_missing_fold_leaks(self): + meta, _, states, reader = self.scene() + meta['interior_excluded_voxels'] = [[0, 2, 0]] + geo = mask_geometry(meta) + states[-1, 2, 0] = 'minecraft:stone_brick_stairs[facing=east,half=bottom,shape=straight,waterlogged=false]' + states[0, 2, 0] = 'minecraft:barrier' + self.assertNotIn((-1, 2, 0), geo[7]) + self.assertTrue(membrane(reader, geo)['passed']) + self.assertTrue(conservative_exterior_flood(reader, geo, (1.5, 1., 1.5))['passed']) + self.assertTrue(player_probes(reader, geo, (1.5, 1., 1.5))['passed']) + del states[0, 2, 0] + self.assertFalse(membrane(reader, geo)['passed']) + self.assertFalse(conservative_exterior_flood(reader, geo, (1.5, 1., 1.5))['passed']) + + def test_exclusions_must_be_unique_voxels_inside_original_volume(self): + meta, _, _, _ = self.scene() + for exclusions in [[[0, 2, 0], [0, 2, 0]], [[100, 2, 100]], [[0, 0, 0]]]: + meta['interior_excluded_voxels'] = exclusions + with self.assertRaises(ValueError): + mask_geometry(meta) + + def test_stair_or_slab_cannot_substitute_for_solid_membrane(self): + _, geo, states, reader = self.scene() + states[-1, 2, 0] = 'minecraft:stone_brick_slab[type=bottom,waterlogged=false]' + self.assertFalse(membrane(reader, geo)['passed']) + self.assertFalse(conservative_exterior_flood(reader, geo, (.5, 1., .5))['passed']) + states[-1, 2, 0] = 'minecraft:stone' + self.assertTrue(membrane(reader, geo)['passed']) + + def test_swept_aabb_detects_diagonal_corner_and_vertical_collision(self): + reader = type('Reader', (), {'state': lambda self, x, y, z: 'minecraft:barrier' if (x, y, z) == (1, 1, 1) else 'minecraft:air'})() + self.assertTrue(swept_player_hits_full_cube(reader, (.5, 1., .5), (2.5, 1., 2.5))) + self.assertFalse(swept_player_hits_full_cube(reader, (.2, 1., .2), (.2, 2., .2))) + + def test_standing_contact_with_floor_does_not_mask_an_open_horizontal_route(self): + reader = type('Reader', (), {'state': lambda self, x, y, z: 'minecraft:stone' if y == 0 else 'minecraft:air'})() + self.assertFalse(swept_player_hits_full_cube(reader, (.5, 1., .5), (2.5, 1., 2.5))) + self.assertTrue(swept_player_hits_full_cube(reader, (.5, 1., .5), (.5, .5, .5))) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--before', type=Path) + parser.add_argument('--after', type=Path) + parser.add_argument('--layout', type=Path) + parser.add_argument('--metadata', type=Path) + parser.add_argument('--report', type=Path) + parser.add_argument('--self-test', action='store_true') + args = parser.parse_args() + if args.self_test: + result = unittest.TextTestRunner().run(unittest.defaultTestLoader.loadTestsFromTestCase(ContainmentTests)) + raise SystemExit(0 if result.wasSuccessful() else 1) + if any(getattr(args, key) is None for key in ('before', 'layout', 'metadata', 'report')): + parser.error('--before, --layout, --metadata and --report are required') + before = survey.load_snapshot(args.before) + after = survey.load_snapshot(args.after, before.scope) if args.after else None + report = audit(before, json.loads(args.layout.read_text()), json.loads(args.metadata.read_text()), after) + paths = {key: getattr(args, key) for key in ('before', 'after', 'layout', 'metadata') if getattr(args, key)} + report['input_sha256'] = {key: hashlib.sha256(path.read_bytes()).hexdigest() for key, path in paths.items()} + args.report.parent.mkdir(parents=True, exist_ok=True) + survey.terrain.save(args.report, report) + print(json.dumps(report, indent=2)) + raise SystemExit(0 if report['passed'] else 1) + + +if __name__ == '__main__': + main() diff --git a/terrain-world-plugin/README.md b/terrain-world-plugin/README.md new file mode 100644 index 0000000..b8f00cd --- /dev/null +++ b/terrain-world-plugin/README.md @@ -0,0 +1,55 @@ +# Initial terrain worlds + +## Natural Shacraft profile + +Configure `terrain-profile: shacraft-natural-v1`, copy `examples/terrain/shacraft-natural-world.json` as the recipe, and select a new world name such as `shacraft_lobby_v2`. The recipe supplies the seed, footprint and palette; its feature list must be empty because the versioned profile defines its own landforms. This is not a new MCP terrain recipe type. + +Independent OpenSimplex2S fBm, weighted ridged noise, moderate coordinate warping and authored mountain corridors shape the skyline around soft central hills and connected water courses. There are no rectangular platform features. Before integer flooring, all heights exactly match the approved study's full-field SHA-256. No hydraulic erosion, biome pass or automatic route grading is implied. + +Steep columns expose stone/andesite without a dirt layer. Gentle dry columns retain grass and soil; submerged columns have rock beds and continuous source water. Geometry, profile and material version are bound into the immutable generation identity. Live pre-generation checks every column's height, top material and water fill. + +FastNoiseLite is vendored under `noise/` from upstream commit `785f37a9ad76e283586a379675085f2063ae03f7`, with a package declaration added. Its MIT notice is present in the source and under `META-INF/LICENSE-FastNoiseLite.txt` in the jar. + +## Recipe-based initial worlds + +This optional Paper 26.2 plugin creates and reloads a separate world using the existing `TerrainRecipe` height field. It loads before MinecraftBuilderMCP. Installation is inert until `enabled: true` is explicitly set in `plugins/ShacraftTerrain/config.yml`. + +Build with `./mvnw package`, install `target/terrain-world-plugin-0.1.0-SNAPSHOT.jar`, and copy a sculpt recipe without preserve masks into the plugin data directory as `terrain.json`: + +```yaml +enabled: true +world: shacraft_lobby +recipe: terrain.json +water-level: 48 +border-size: 2048 +``` + +The Shacraft example is `../examples/terrain/shacraft-lobby-world.json`: a 768×768 layout translated 64 blocks upward from the original design recipe. Spawn plaza Y=106; station Y=118; airship harbor Y=124. The west lake has a rock bed at Y=38 and water through Y=48. The ravines share that water level. Omit `water-level` for dry terrain. + +Generation builds solid foundations down to world bedrock, follows the recipe's soil layers, replaces submerged surface blocks with rock and fills submerged columns with source water. There are no vanilla caves, structures, decorations or mobs. The recipe continues outside the footprint so neighboring chunks do not end at artificial vertical cuts. The optional exploration border can sit beyond the design footprint to keep its visible wall out of construction views; it does not expand the editor's authorized bounds. Day and weather are fixed for construction. + +This is **initial world generation**, not an editor apply operation. It has no block-by-block undo journal. Existing chunks are never regenerated. The persisted generation manifest binds the generator version, recipe and water level; changing them under the same world name fails closed. Existing world folders without that manifest are refused. Keep the generator plugin, its configuration and manifest with world backups so future chunks use the same terrain. + +Commands: + +- `/lobby`: teleport yourself to the central spawn. Requires operator permission. +- `/lobby status`: inspect the current pre-generation pass. +- Server console `lobby `: teleport an online player to the lobby. +- Server console `lobby generate`: asynchronously load/generate the recipe footprint one chunk at a time. Verify every column's visible height, water fill and non-air bed; save `generation-report.json` on completion. Do this before building: later edits legitimately produce mismatches. Repeating the command loads existing chunks, without overwriting them. +- Server console `lobby map `: export an actual top-down surface map of the configured footprint, with no player or camera client. For example, `lobby map layout-before` writes `plugins/ShacraftTerrain/maps/layout-before.json` and `layout-before.png`. Use `lobby status` to see progress. Names use 1–48 lowercase letters, digits, underscores or hyphens; an existing map is never overwritten. + +The map exporter reads one existing chunk per tick on the server thread and refuses to generate missing chunks. It changes no blocks. A detached worker renders the PNG and writes the JSON after all columns have been captured. Maps are bounded to 4,194,304 columns. The height and material describe the highest non-air block, including water and survey markers, so bridges and roofs hide the surfaces below. PNGs show actual surface materials with slope shading; colored concrete keeps its unshaded survey color. This is a server-derived orthographic map, not a screenshot or a perspective camera. + +The compact JSON format `minecraft-builder-surface-map-v1` includes the world UUID/key, inclusive X/Z bounds, capture timestamps, `palette`, `palette_rgb`, and flat `surface_y` / `material_index` arrays. Index a column with `(z - min_z) * width + (x - min_x)`. North is at the top (negative Z), east at the right (positive X), and each PNG pixel represents one block. These are live, sequential chunk observations rather than an atomic snapshot: pause edits while capturing a before/after verification map. The exporter does not certify a particular build revision. + +MinecraftBuilderMCP still supports one active project. Before selecting this world in its config, stop the server and archive the previous project's configuration, metadata and journal together. Use a new project ID and world epoch, a fresh journal directory, and explicit project bounds within the new world. Keep normal per-plan limits and owner authorization. The player-facing `/ai area` command retains its 2-million-block selection limit; an operator can configure the full lobby envelope directly for staged work. + +For the local Shacraft session, connect to `127.0.0.1:25575`, then `/lobby`. The old `world` remains loaded; console `execute in minecraft:overworld run tp 0 5 0` can return a player there. The active AI editor remains bound to Shacraft until the old project is restored offline. + +Unit checks cover solid foundations, lake source-water continuity, negative chunk coordinates, the complete design's height bounds and invalid initial-world modes. Live generation reports cover real Paper chunks; they are not screenshots or a visual approval of the composition. + +The first live Shacraft pass generated/loaded 2304 chunks in 144 seconds and verified all 589,824 columns, including 59,609 water columns, with zero mismatches. See [the saved report](../docs/references/shacraft-lobby-generation-report.json). Terrain reaches Y=203. The local server uses `--heap 4G` and `view-distance=32`; the client also needs an adequate render distance. + +Paper 26.2 stores this dimension under `world/dimensions/minecraft/shacraft_lobby`. Back up the complete primary `world` save (including `level.dat`) together with both plugins' data and the generator jar. Copying only a legacy top-level `shacraft_lobby` folder is not a valid backup for this version. + +The live natural v2 pass verified all 589,824 columns across 2304 chunks in 275 seconds: zero height/material/water mismatches, 42,399 water columns, highest surface Y=204. See [the report](../docs/references/shacraft-natural-v2-generation-report.json). The separately retained v1 report describes the earlier rectangular layout. diff --git a/terrain-world-plugin/pom.xml b/terrain-world-plugin/pom.xml new file mode 100644 index 0000000..0981df0 --- /dev/null +++ b/terrain-world-plugin/pom.xml @@ -0,0 +1,15 @@ + + 4.0.0 + io.github.minecraftbuilderminecraft-builder-mcp0.1.0-SNAPSHOT + terrain-world-plugin + papermchttps://repo.papermc.io/repository/maven-public/ + + io.github.minecraftbuilderworld-core${project.version} + io.papermc.paperpaper-api${paper.version}provided + com.google.code.gsongson2.14.0 + org.junit.jupiterjunit-jupiter5.13.4test + + + org.apache.maven.pluginsmaven-shade-plugin3.6.1packageshadefalse*:*META-INF/*.SFMETA-INF/*.RSAMETA-INF/*.DSA + + diff --git a/terrain-world-plugin/src/main/java/io/github/minecraftbuilder/terrainworld/LobbyRespawn.java b/terrain-world-plugin/src/main/java/io/github/minecraftbuilder/terrainworld/LobbyRespawn.java new file mode 100644 index 0000000..73ba47f --- /dev/null +++ b/terrain-world-plugin/src/main/java/io/github/minecraftbuilder/terrainworld/LobbyRespawn.java @@ -0,0 +1,25 @@ +package io.github.minecraftbuilder.terrainworld; + +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerRespawnEvent; + +/** A configured indoor spawn must survive vanilla's highest-surface respawn search. */ +final class LobbyRespawn implements Listener { + private final World world; + private final Location configuredSpawn; + + LobbyRespawn(World world, Location configuredSpawn) { + this.world = world; + this.configuredSpawn = configuredSpawn == null ? null : configuredSpawn.clone(); + } + + @EventHandler + public void onRespawn(PlayerRespawnEvent event) { + if (configuredSpawn != null && event.getRespawnReason() == PlayerRespawnEvent.RespawnReason.DEATH + && event.getPlayer().getWorld() == world) + event.setRespawnLocation(configuredSpawn); + } +} diff --git a/terrain-world-plugin/src/main/java/io/github/minecraftbuilder/terrainworld/LobbySpawn.java b/terrain-world-plugin/src/main/java/io/github/minecraftbuilder/terrainworld/LobbySpawn.java new file mode 100644 index 0000000..858f8e3 --- /dev/null +++ b/terrain-world-plugin/src/main/java/io/github/minecraftbuilder/terrainworld/LobbySpawn.java @@ -0,0 +1,42 @@ +package io.github.minecraftbuilder.terrainworld; + +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.configuration.ConfigurationSection; +import java.util.function.IntSupplier; + +/** Explicit player feet position; never derived from a roof or containment heightmap. */ +record LobbySpawn(double x, double y, double z, float yaw, float pitch) { + LobbySpawn { + if (!Double.isFinite(x) || !Double.isFinite(y) || !Double.isFinite(z) + || !Float.isFinite(yaw) || !Float.isFinite(pitch)) + throw new IllegalArgumentException("Spawn coordinates and angles must be finite"); + if (Math.abs(x) >= 30_000_000 || Math.abs(z) >= 30_000_000) + throw new IllegalArgumentException("Spawn coordinates must be inside Minecraft's world limits"); + if (pitch < -90 || pitch > 90) throw new IllegalArgumentException("Spawn pitch must be between -90 and 90"); + } + + static LobbySpawn fromConfig(ConfigurationSection config, IntSupplier fallbackY) { + if (!config.contains("spawn")) return new LobbySpawn(.5, fallbackY.getAsInt(), .5, 180, 0); + ConfigurationSection spawn = config.getConfigurationSection("spawn"); + if (spawn == null) throw new IllegalArgumentException("Spawn must be a section with numeric x, y and z"); + return new LobbySpawn(number(spawn, "x"), number(spawn, "y"), number(spawn, "z"), + (float) (spawn.contains("yaw") ? number(spawn, "yaw") : 180), + (float) (spawn.contains("pitch") ? number(spawn, "pitch") : 0)); + } + + private static double number(ConfigurationSection section, String key) { + if (!(section.get(key) instanceof Number value)) + throw new IllegalArgumentException("Spawn " + key + " must be numeric"); + return value.doubleValue(); + } + + void requireHeight(int minY, int maxY) { + if (y < minY || y >= maxY) throw new IllegalArgumentException("Spawn Y must be inside world height bounds"); + } + + Location location(World world) { + requireHeight(world.getMinHeight(), world.getMaxHeight()); + return new Location(world, x, y, z, yaw, pitch); + } +} diff --git a/terrain-world-plugin/src/main/java/io/github/minecraftbuilder/terrainworld/NaturalTerrain.java b/terrain-world-plugin/src/main/java/io/github/minecraftbuilder/terrainworld/NaturalTerrain.java new file mode 100644 index 0000000..e10ab29 --- /dev/null +++ b/terrain-world-plugin/src/main/java/io/github/minecraftbuilder/terrainworld/NaturalTerrain.java @@ -0,0 +1,63 @@ +package io.github.minecraftbuilder.terrainworld; + +import io.github.minecraftbuilder.terrainworld.noise.FastNoiseLite; + +/** Frozen Shacraft natural-v1 profile, promoted from the approved offline study. + * Noise objects are private and never mutated after construction; sampling is thread safe. + */ +public final class NaturalTerrain { + private static FastNoiseLite noise(int seed, float scale, int octaves, boolean ridged) { + var n = new FastNoiseLite(seed); + n.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2S); + n.SetFrequency(1f / scale); + n.SetFractalType(ridged ? FastNoiseLite.FractalType.Ridged : FastNoiseLite.FractalType.FBm); + n.SetFractalOctaves(octaves); n.SetFractalGain(.48f); n.SetFractalLacunarity(2.07f); + n.SetFractalWeightedStrength(ridged ? .7f : .25f); + return n; + } + private final FastNoiseLite broad, ridges, warpX, warpZ, detail, bank; + public NaturalTerrain(int seed) { + broad=noise(seed,220,4,false); ridges=noise(seed+12,85,5,true); + warpX=noise(seed+8995,165,3,false); warpZ=noise(seed+108995,165,3,false); + detail=noise(seed+208995,17,3,false); bank=noise(seed+308995,52,3,false); + } + public double slope(int x,int z) { + return Math.hypot((height(x+2,z)-height(x-2,z))/4.0,(height(x,z+2)-height(x,z-2))/4.0); + } + public float rockVariation(int x,int z) { return bank.GetNoise(x*2f,z*2f); } + private static final double[][] north={{-370,-225},{-280,-295},{-180,-320},{-70,-270},{55,-295},{170,-330},{275,-270},{370,-235}}; + private static final double[][] west={{-355,-235},{-315,-135},{-360,-30},{-315,95},{-325,245},{-285,370}}; + private static final double[][] east={{345,-235},{320,-100},{355,45},{315,160},{340,300},{285,390}}; + private static final double[][] riverEast={{160,-384},{145,-280},{112,-190},{127,-100},{107,-25},{100,70},{85,145},{110,215},{55,280},{30,384}}; + private static final double[][] riverWest={{-220,65},{-190,125},{-140,175},{-110,235},{-50,285},{-20,345},{30,384}}; + private static double distance(double x,double z,double[][] path) { + double best=Double.POSITIVE_INFINITY; + for(int i=1;i 1.05 + .22 * variation; + if (wet || cliff) return variation > .1 ? Material.ANDESITE : Material.STONE; + return surface; + } + + @Override public void generateNoise(WorldInfo info, Random random, int cx, int cz, ChunkData data) { + for (int x = 0; x < 16; x++) for (int z = 0; z < 16; z++) { + int top = height(cx * 16 + x, cz * 16 + z); + if (waterLevel != null && (waterLevel <= data.getMinHeight() || waterLevel >= data.getMaxHeight() - 1)) + throw new IllegalArgumentException("Water level outside world height"); + if (top <= data.getMinHeight() || top >= data.getMaxHeight() - 1) + throw new IllegalArgumentException("Recipe surface outside world height"); + data.setBlock(x, data.getMinHeight(), z, Material.BEDROCK); + Material topMaterial = surfaceMaterial(cx*16+x,cz*16+z); + int soilDepth = natural != null && topMaterial != surface ? 0 : depth; + int dirtStart = Math.max(data.getMinHeight() + 1, top - soilDepth); + data.setRegion(x, data.getMinHeight() + 1, z, x + 1, dirtStart, z + 1, rock); + data.setRegion(x, dirtStart, z, x + 1, top, z + 1, soil); + data.setBlock(x, top, z, topMaterial); + if (waterLevel != null && top < waterLevel) + data.setRegion(x, top + 1, z, x + 1, waterLevel + 1, z + 1, Material.WATER); + } + } + + @Override public int getBaseHeight(WorldInfo world, Random random, int x, int z, HeightMap map) { + return (map == HeightMap.OCEAN_FLOOR || map == HeightMap.OCEAN_FLOOR_WG ? height(x, z) : visibleHeight(x, z)) + 1; + } + @Override public Location getFixedSpawnLocation(World world, Random random) { + return new Location(world, .5, height(0, 0) + 1, .5, 180, 0); + } + @Override public BiomeProvider getDefaultBiomeProvider(WorldInfo world) { + return new BiomeProvider() { + @Override public Biome getBiome(WorldInfo info, int x, int y, int z) { return Biome.PLAINS; } + @Override public List getBiomes(WorldInfo info) { return List.of(Biome.PLAINS); } + }; + } + @Override public boolean shouldGenerateNoise() { return false; } + @Override public boolean shouldGenerateSurface() { return false; } + @Override public boolean shouldGenerateCaves() { return false; } + @Override public boolean shouldGenerateDecorations() { return false; } + @Override public boolean shouldGenerateMobs() { return false; } + @Override public boolean shouldGenerateStructures() { return false; } +} diff --git a/terrain-world-plugin/src/main/java/io/github/minecraftbuilder/terrainworld/StationLabels.java b/terrain-world-plugin/src/main/java/io/github/minecraftbuilder/terrainworld/StationLabels.java new file mode 100644 index 0000000..c6555e6 --- /dev/null +++ b/terrain-world-plugin/src/main/java/io/github/minecraftbuilder/terrainworld/StationLabels.java @@ -0,0 +1,188 @@ +package io.github.minecraftbuilder.terrainworld; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.TextColor; +import net.kyori.adventure.text.format.TextDecoration; +import org.bukkit.Bukkit; +import org.bukkit.Chunk; +import org.bukkit.Color; +import org.bukkit.Location; +import org.bukkit.NamespacedKey; +import org.bukkit.World; +import org.bukkit.entity.Display; +import org.bukkit.entity.TextDisplay; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.util.Transformation; +import org.joml.Quaternionf; +import org.joml.Vector3f; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +/** Optional, persistent signs for the completed station; no arena destinations are implied. */ +public final class StationLabels { + public static final String TAG = "shacraft_station_v1"; + private static final TextColor INK = TextColor.color(0xf3dfab); + private static final Map ACTIVE = new HashMap<>(); + + /** Position is the visual centre; yaw 0 faces south and yaw -90 faces east. */ + public record LabelSpec(String id, String text, double x, double y, double z, + float yaw, float scale, int lineWidth, boolean bold) {} + + private static final List LABELS = labels(); + + private StationLabels() {} + + /** Immutable positions/text for a build manifest or a review before installation. */ + public static List manifest() { return LABELS; } + + /** + * Schedule installation on the server thread after existing label chunks have + * loaded their saved entities. This prevents duplicate labels after a restart. + * No terrain is generated. Only this plugin's marked TextDisplays are replaced. + */ + public static void install(JavaPlugin plugin, World world) { + Objects.requireNonNull(plugin); Objects.requireNonNull(world); + if (!Bukkit.isPrimaryThread()) throw new IllegalStateException("Station labels must be scheduled on the server thread"); + Installation installation = new Installation(plugin, world); + Installation previous = ACTIVE.put(world.getUID(), installation); + if (previous != null) previous.close(); + installation.load(); + } + + private static List labels() { + List labels = new ArrayList<>(); + labels.add(new LabelSpec("exterior-shacraft", "SHACRAFT", -5.5, 111.2, -81.94, 0, 6.4f, 160, true)); + labels.add(new LabelSpec("smash-header", "S M A S H", -16.5, 122.2, -136.94, 0, 7f, 160, true)); + int[] bayCenters = {-42, -32, -22, -12, -2, 8}; + for (int i = 0; i < bayCenters.length; i++) { + String number = String.format(java.util.Locale.ROOT, "%02d", i + 1); + labels.add(new LabelSpec("arena-" + number, number, + bayCenters[i] + .5, 119.6, -134.95, 0, 1.2f, 120, false)); + labels.add(new LabelSpec("arena-" + number + "-sign", "АРЕНА " + number + "\nСКОРО", + bayCenters[i] + .5, 114.5, -134.95, 0, 1f, 120, false)); + } + for (int floor = 1; floor <= 2; floor++) { + int feet = floor == 1 ? 99 : 113; + labels.add(new LabelSpec("lift-" + floor + "-front", floor == 1 ? "1 · ВЕСТИБЮЛЬ" : "2 · SMASH", + -5.5, feet + 6.2, -114.93, 0, 2.4f, 160, true)); + labels.add(new LabelSpec("lift-" + floor + "-selector", floor == 1 ? "2 SMASH ↑" : "1 ВЕСТИБЮЛЬ ↓", + -5.5, feet + 3.3, -121.92, 0, 1.65f, 160, true)); + labels.add(new LabelSpec("lift-" + floor + "-instruction", "ПКМ по золотой\nпанели", + -5.5, feet + 1.3, -121.92, 0, 1.2f, 120, false)); + } + labels.add(new LabelSpec("vestibule-lift-heading", "SHACRAFT", -5.5, 108, -114.93, 0, 3f, 160, true)); + labels.add(new LabelSpec("smash-damage", "УРОН\nЧем выше урон,\nтем сильнее\nотбрасывание", + -70.96, 116.9, -121.5, -90, 1.1f, 120, false)); + labels.add(new LabelSpec("smash-knockback", "ОТБРАСЫВАНИЕ\nСтолкни соперников\nс острова", + -70.96, 116.9, -114.5, -90, 1.1f, 120, false)); + labels.add(new LabelSpec("smash-double-jump", "ДВОЙНОЙ ПРЫЖОК\nПрыгни ещё раз,\nчтобы вернуться\nна остров", + -70.96, 116.9, -107.5, -90, 1.1f, 120, false)); + return List.copyOf(labels); + } + + private static void configure(TextDisplay display, LabelSpec label, NamespacedKey owner, NamespacedKey id) { + display.text(Component.text(label.text(), INK).decoration(TextDecoration.BOLD, label.bold())); + display.setBillboard(Display.Billboard.FIXED); + display.setRotation(label.yaw(), 0); + display.setAlignment(TextDisplay.TextAlignment.CENTER); + display.setLineWidth(label.lineWidth()); + display.setDefaultBackground(false); + display.setBackgroundColor(Color.fromARGB(0, 0, 0, 0)); + display.setSeeThrough(false); + display.setShadowed(true); + display.setTextOpacity((byte) 255); + display.setBrightness(new Display.Brightness(15, 15)); + display.setViewRange(label.id().equals("exterior-shacraft") ? 2f : 1f); + display.setShadowRadius(0); display.setShadowStrength(0); + display.setInterpolationDuration(0); display.setTeleportDuration(0); + float lines = label.text().split("\n", -1).length; + // Minecraft text is bottom-centred and uses 0.025 blocks per font pixel. + display.setTransformation(new Transformation(new Vector3f(0, -.125f * label.scale() * lines, 0), + new Quaternionf(), new Vector3f(label.scale()), new Quaternionf())); + // Disable the optional display bounding-box cull; normal view distance still applies. + display.setDisplayWidth(0); display.setDisplayHeight(0); + display.setPersistent(true); + display.addScoreboardTag(TAG); + display.getPersistentDataContainer().set(owner, PersistentDataType.STRING, TAG); + display.getPersistentDataContainer().set(id, PersistentDataType.STRING, label.id()); + } + + private static final class Installation { + private final JavaPlugin plugin; + private final World world; + private final List ownedTickets = new ArrayList<>(); + private List chunks = List.of(); + private boolean closed; + private int attempts; + + Installation(JavaPlugin plugin, World world) { this.plugin = plugin; this.world = world; } + + void load() { + record Column(int x, int z) {} + var columns = new LinkedHashSet(); + for (LabelSpec label : LABELS) + columns.add(new Column(Math.floorDiv((int) Math.floor(label.x()), 16), + Math.floorDiv((int) Math.floor(label.z()), 16))); + for (Column column : columns) { + if (!world.isChunkGenerated(column.x(), column.z())) { + fail("Station label chunk is not generated: " + column.x() + "," + column.z()); return; + } + } + List> loads = columns.stream() + .map(c -> world.getChunkAtAsync(c.x(), c.z(), false)).toList(); + CompletableFuture.allOf(loads.toArray(CompletableFuture[]::new)).whenComplete((unused, error) -> { + if (!plugin.isEnabled() || closed) return; + Bukkit.getScheduler().runTask(plugin, () -> { + if (closed) return; + if (error != null) { fail("Cannot load station label chunks: " + error.getMessage()); return; } + chunks = loads.stream().map(CompletableFuture::join).toList(); + if (chunks.stream().anyMatch(Objects::isNull)) { fail("An existing station chunk is unavailable"); return; } + for (Chunk chunk : chunks) if (chunk.addPluginChunkTicket(plugin)) ownedTickets.add(chunk); + waitForEntities(); + }); + }); + } + + void waitForEntities() { + if (closed || !plugin.isEnabled()) { close(); return; } + if (chunks.stream().anyMatch(c -> !c.isEntitiesLoaded())) { + if (++attempts >= 100) { fail("Station entity data did not load; existing labels retained"); return; } + Bukkit.getScheduler().runTaskLater(plugin, this::waitForEntities, 1L); return; + } + NamespacedKey owner = new NamespacedKey(plugin, "station_label_set"); + NamespacedKey id = new NamespacedKey(plugin, "station_label_id"); + List previous = world.getEntitiesByClass(TextDisplay.class).stream() + .filter(e -> TAG.equals(e.getPersistentDataContainer().get(owner, PersistentDataType.STRING))).toList(); + List created = new ArrayList<>(); + try { + for (LabelSpec label : LABELS) { + Location location = new Location(world, label.x(), label.y(), label.z(), label.yaw(), 0); + created.add(world.spawn(location, TextDisplay.class, display -> configure(display, label, owner, id))); + } + previous.forEach(TextDisplay::remove); + plugin.getLogger().info("Station labels installed: " + created.size() + "; replaced=" + previous.size()); + } catch (Exception error) { + created.forEach(TextDisplay::remove); + plugin.getLogger().warning("Station labels not installed; previous labels retained: " + error.getMessage()); + } finally { close(); } + } + + void fail(String message) { plugin.getLogger().warning(message); close(); } + + void close() { + if (closed) return; + closed = true; + for (Chunk chunk : ownedTickets) chunk.removePluginChunkTicket(plugin); + ownedTickets.clear(); + ACTIVE.remove(world.getUID(), this); + } + } +} diff --git a/terrain-world-plugin/src/main/java/io/github/minecraftbuilder/terrainworld/StationLift.java b/terrain-world-plugin/src/main/java/io/github/minecraftbuilder/terrainworld/StationLift.java new file mode 100644 index 0000000..f2a081f --- /dev/null +++ b/terrain-world-plugin/src/main/java/io/github/minecraftbuilder/terrainworld/StationLift.java @@ -0,0 +1,108 @@ +package io.github.minecraftbuilder.terrainworld; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.*; +import org.bukkit.block.Block; +import org.bukkit.command.*; +import org.bukkit.entity.Player; +import org.bukkit.event.*; +import org.bukkit.event.block.Action; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.plugin.java.JavaPlugin; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** Explicitly enabled, two-stop personal lift for the completed station layout. */ +final class StationLift implements Listener, CommandExecutor { + private final JavaPlugin plugin; + private final World world; + private final Set pending = ConcurrentHashMap.newKeySet(); + private static final Set LANDING_FLOORS = EnumSet.of(Material.SMOOTH_SANDSTONE, + Material.SMOOTH_QUARTZ, Material.QUARTZ_BLOCK, Material.CUT_SANDSTONE, + Material.STONE_BRICKS, Material.SMOOTH_STONE, Material.POLISHED_ANDESITE, + Material.GOLD_BLOCK, Material.WAXED_OXIDIZED_CUT_COPPER, Material.GREEN_CONCRETE, + Material.SPRUCE_PLANKS, Material.POLISHED_DEEPSLATE, Material.WHITE_CONCRETE); + + StationLift(JavaPlugin plugin, World world) { this.plugin = plugin; this.world = world; } + + static int selectorFloor(int x, int y, int z) { + if (x != -6 || z != -123) return 0; + return y == 101 ? 1 : y == 115 ? 2 : 0; + } + + static int feetY(int floor) { + if (floor != 1 && floor != 2) throw new IllegalArgumentException("Only completed floors 1 and 2 are available"); + return floor == 1 ? 99 : 113; + } + + static boolean supportsLanding(Material material) { return LANDING_FLOORS.contains(material); } + + static boolean inCabin(double x, double y, double z, int floor) { + return x >= -8 && x < -3 && z >= -122 && z < -117 && y >= feetY(floor) && y < feetY(floor) + 2; + } + + @EventHandler(ignoreCancelled = true) + public void onInteract(PlayerInteractEvent event) { + if (event.getHand() != EquipmentSlot.HAND || event.getAction() != Action.RIGHT_CLICK_BLOCK) return; + Player player = event.getPlayer(); + Block block = event.getClickedBlock(); + if (player.getWorld() != world || block == null || block.getType() != Material.GOLD_BLOCK) return; + int floor = selectorFloor(block.getX(), block.getY(), block.getZ()); + if (floor == 0) return; + event.setCancelled(true); + Location at = player.getLocation(); + if (!inCabin(at.getX(), at.getY(), at.getZ(), floor)) { + player.sendMessage(Component.text("Войди в кабину лифта.", NamedTextColor.GOLD)); return; + } + travel(player, floor == 1 ? 2 : 1, false); + } + + private void travel(Player player, int floor, boolean entrance) { + if (!pending.add(player.getUniqueId())) return; + Location target = new Location(world, -5.5, feetY(floor), entrance ? -92.5 : -119.5, entrance ? 180 : 0, 0); + world.getChunkAtAsync(target).whenComplete((chunk, error) -> { + if (!plugin.isEnabled()) { pending.remove(player.getUniqueId()); return; } + Bukkit.getScheduler().runTask(plugin, () -> { + if (error != null || !player.isOnline() || player.getWorld() != world || !safeLanding(target)) { + pending.remove(player.getUniqueId()); + if (player.isOnline()) player.sendMessage(Component.text("Лифт временно недоступен: проверь площадку назначения.", NamedTextColor.RED)); + return; + } + // Loaded, checked destination. No shared cabin moves other passengers. + boolean moved = player.teleport(target); + pending.remove(player.getUniqueId()); + if (moved) { + player.playSound(target, Sound.BLOCK_NOTE_BLOCK_CHIME, .6f, floor == 2 ? 1.2f : .8f); + player.sendActionBar(Component.text(floor == 1 ? "1 · ВЕСТИБЮЛЬ" : "2 · SMASH", NamedTextColor.GOLD)); + } + }); + }); + } + + private boolean safeLanding(Location target) { + int x=target.getBlockX(),y=target.getBlockY(),z=target.getBlockZ(); + return supportsLanding(world.getBlockAt(x,y-1,z).getType()) + && world.getBlockAt(x,y,z).getType().isAir() && world.getBlockAt(x,y+1,z).getType().isAir(); + } + + @Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + Player player; + String selected; + boolean entrance; + if (sender instanceof Player p) { + if (args.length > 1) return usage(sender); + player=p; selected=args.length==0 ? "1" : args[0]; entrance=args.length==0; + } else if (sender instanceof ConsoleCommandSender && args.length>=1 && args.length<=2) { + player=Bukkit.getPlayerExact(args[0]); selected=args.length==2 ? args[1] : "1"; entrance=args.length==1; + } else return usage(sender); + if (!selected.equals("1") && !selected.equals("2")) return usage(sender); + if (player==null || player.getWorld()!=world) { + sender.sendMessage("Station is available in the configured lobby world."); return true; + } + travel(player,Integer.parseInt(selected),entrance); return true; + } + + private boolean usage(CommandSender sender) { sender.sendMessage("/station [1|2] — vestibule or SMASH"); return true; } +} diff --git a/terrain-world-plugin/src/main/java/io/github/minecraftbuilder/terrainworld/SurfaceMap.java b/terrain-world-plugin/src/main/java/io/github/minecraftbuilder/terrainworld/SurfaceMap.java new file mode 100644 index 0000000..16146ae --- /dev/null +++ b/terrain-world-plugin/src/main/java/io/github/minecraftbuilder/terrainworld/SurfaceMap.java @@ -0,0 +1,173 @@ +package io.github.minecraftbuilder.terrainworld; + +import com.google.gson.stream.JsonWriter; +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.nio.file.*; +import java.time.Instant; +import java.util.*; + +/** Detached world surface data. Rendering and file I/O never call the Bukkit API. */ +final class SurfaceMap { + static final int MAX_COLUMNS = 4_194_304; + final int minX, minZ, maxX, maxZ, width, length; + private final int[] heights, materials; + private final BitSet captured; + private final Map materialIds = new LinkedHashMap<>(); + private int columns, minY = Integer.MAX_VALUE, maxY = Integer.MIN_VALUE; + + SurfaceMap(int minX, int minZ, int maxX, int maxZ) { + long w = (long) maxX - minX + 1, l = (long) maxZ - minZ + 1; + if (w <= 0 || l <= 0 || w > MAX_COLUMNS || l > MAX_COLUMNS || w * l > MAX_COLUMNS) + throw new IllegalArgumentException("Map bounds must contain 1 to " + MAX_COLUMNS + " columns"); + this.minX = minX; this.minZ = minZ; this.maxX = maxX; this.maxZ = maxZ; + width = (int) w; length = (int) l; + heights = new int[width * length]; materials = new int[heights.length]; + captured = new BitSet(heights.length); + } + + void setColumn(int x, int z, int y, String material) { + if (x < minX || x > maxX || z < minZ || z > maxZ) throw new IllegalArgumentException("Column outside map"); + if (material == null || !material.matches("minecraft:[a-z0-9_]+")) throw new IllegalArgumentException("Invalid material key"); + int index = (z - minZ) * width + x - minX; + if (captured.get(index)) throw new IllegalStateException("Column captured twice"); + heights[index] = y; + materials[index] = materialIds.computeIfAbsent(material, ignored -> materialIds.size()); + captured.set(index); columns++; minY = Math.min(minY, y); maxY = Math.max(maxY, y); + } + + int columns() { return columns; } + + static void validateName(String name) { + if (name == null || !name.matches("[a-z0-9][a-z0-9_-]{0,47}")) + throw new IllegalArgumentException("Map name must use 1–48 lowercase letters, digits, underscores or hyphens"); + } + + static void requireUnusedName(Path directory, String name) throws IOException { + validateName(name); + for (String extension : List.of(".json", ".png")) + if (Files.exists(directory.resolve(name + extension), LinkOption.NOFOLLOW_LINKS)) + throw new FileAlreadyExistsException("Map already exists; choose a new name: " + name); + } + + void write(Path directory, String name, String world, String worldKey, String worldUuid, + Instant startedAt, Instant finishedAt) throws IOException { + if (columns != heights.length) throw new IllegalStateException("Cannot export an incomplete map"); + requireUnusedName(directory, name); + Files.createDirectories(directory); + Path jsonTemp = Files.createTempFile(directory, ".surface-", ".json.tmp"); + Path pngTemp = Files.createTempFile(directory, ".surface-", ".png.tmp"); + try { + try (JsonWriter out = new JsonWriter(Files.newBufferedWriter(jsonTemp))) { + out.beginObject(); + out.name("format").value("minecraft-builder-surface-map-v1"); + out.name("source").value("paper_world_surface"); + out.name("ignored_materials").beginArray().value("minecraft:barrier").endArray(); + out.name("surface_policy").value("Highest block excluding all air variants and ignored_materials; wholly transparent columns use minecraft:air at world minimum Y."); + out.name("world").value(world); out.name("world_key").value(worldKey); out.name("world_uuid").value(worldUuid); + out.name("capture_started_at").value(startedAt.toString()); + out.name("capture_finished_at").value(finishedAt.toString()); + out.name("atomic_snapshot").value(false); + out.name("capture_note").value("Existing chunks read sequentially on the server thread; edits during capture can appear in different chunks at different times."); + out.name("orientation").value("north_up; x increases right; z increases down"); + out.name("index").value("(z - min_z) * width + (x - min_x)"); + out.name("min_x").value(minX); out.name("max_x").value(maxX); + out.name("min_z").value(minZ); out.name("max_z").value(maxZ); + out.name("width").value(width); out.name("length").value(length); + out.name("columns").value(columns); out.name("min_surface_y").value(minY); out.name("max_surface_y").value(maxY); + out.name("palette").beginArray(); + for (String material : materialIds.keySet()) out.value(material); + out.endArray(); out.name("palette_rgb").beginArray(); + for (String material : materialIds.keySet()) out.value(String.format(Locale.ROOT, "#%06x", color(material))); + out.endArray(); out.name("surface_y").beginArray(); + for (int height : heights) out.value(height); + out.endArray(); out.name("material_index").beginArray(); + for (int material : materials) out.value(material); + out.endArray(); out.endObject(); + } + if (!ImageIO.write(render(), "png", pngTemp.toFile())) throw new IOException("PNG writer unavailable"); + // Neither final filename is replaced, even if another process creates it during capture. + Files.move(jsonTemp, directory.resolve(name + ".json")); + Files.move(pngTemp, directory.resolve(name + ".png")); + } finally { + Files.deleteIfExists(jsonTemp); Files.deleteIfExists(pngTemp); + } + } + + BufferedImage render() { + if (columns != heights.length) throw new IllegalStateException("Cannot render an incomplete map"); + List palette = new ArrayList<>(materialIds.keySet()); + BufferedImage image = new BufferedImage(width, length, BufferedImage.TYPE_INT_RGB); + for (int z = 0; z < length; z++) for (int x = 0; x < width; x++) { + int index = z * width + x; + String material = palette.get(materials[index]); + int base = color(material); + double dx = (height(x + 1, z) - height(x - 1, z)) / 2.0; + double dz = (height(x, z + 1) - height(x, z - 1)) / 2.0; + double norm = Math.sqrt(dx * dx + dz * dz + 1); + double light = (-dx * -.55 - dz * -.55 + .63) / norm; + double shade = Math.clamp(.76 + .36 * light, .44, 1.13); + // Survey blocks retain their categorical color; water has no false land relief. + if (material.endsWith("_concrete") || material.endsWith("_wool") || material.endsWith("_terracotta")) shade = 1; + if (material.equals("minecraft:water")) shade = .96; + image.setRGB(x, z, shaded(base, shade)); + } + return image; + } + + private double height(int x, int z) { + return heights[Math.clamp(z, 0, length - 1) * width + Math.clamp(x, 0, width - 1)]; + } + + private static int shaded(int color, double shade) { + int r = (int) Math.clamp((color >> 16 & 255) * shade, 0, 255); + int g = (int) Math.clamp((color >> 8 & 255) * shade, 0, 255); + int b = (int) Math.clamp((color & 255) * shade, 0, 255); + return r << 16 | g << 8 | b; + } + + static int color(String key) { + String material = key.replaceFirst("^minecraft:", ""); + String pigment = material.replaceFirst("_(concrete|wool|terracotta)$", ""); + if (!pigment.equals(material)) { + Integer color = switch (pigment) { + case "white" -> 0xf0f0e6; case "orange" -> 0xf78d27; case "magenta" -> 0xdc52c7; + case "light_blue" -> 0x68c8ec; case "yellow" -> 0xf6d34a; case "lime" -> 0x98d84d; + case "pink" -> 0xf394b5; case "gray" -> 0x545c61; case "light_gray" -> 0xa4aaa6; + case "cyan" -> 0x23b6b6; case "purple" -> 0x9460ce; case "blue" -> 0x4a69d8; + case "brown" -> 0x916044; case "green" -> 0x527c31; case "red" -> 0xe3544b; + case "black" -> 0x26282d; default -> null; + }; + if (color != null) return color; + } + if (material.contains("leaves")) return 0x397548; + if (material.contains("spruce") || material.contains("dark_oak")) return 0x75583c; + if (material.endsWith("_planks") || material.endsWith("_log")) return 0xa48458; + if (material.contains("copper")) return 0x639f8d; + if (material.contains("quartz")) return 0xe9e4d5; + if (material.contains("sandstone")) return 0xc8bb83; + if (material.contains("deepslate") || material.equals("obsidian")) return 0x555a64; + if (material.contains("stone_brick")) return 0x9ca49e; + return switch (material) { + case "water", "bubble_column" -> 0x367dba; + case "grass_block", "short_grass", "tall_grass", "moss_block" -> 0x79a957; + case "dirt", "coarse_dirt", "rooted_dirt", "dirt_path", "farmland" -> 0x937454; + case "podzol", "mud" -> 0x675746; + case "stone", "cobblestone", "smooth_stone" -> 0xa2a8a3; + case "andesite", "polished_andesite" -> 0x929e98; + case "diorite", "polished_diorite" -> 0xb8bebb; + case "granite", "polished_granite" -> 0xae8980; + case "sand" -> 0xd7cc9a; + case "gravel" -> 0xa49f97; + case "snow", "snow_block", "powder_snow" -> 0xe8eff1; + case "ice", "packed_ice", "blue_ice" -> 0x93c7dc; + case "gold_block", "glowstone", "lantern" -> 0xf4cf58; + case "sea_lantern" -> 0xb4ece2; + case "terracotta", "bricks" -> 0xb6775d; + case "glass", "tinted_glass" -> 0xacced3; + case "air", "cave_air", "void_air" -> 0x18232e; + default -> 0x8a8988; + }; + } +} diff --git a/terrain-world-plugin/src/main/java/io/github/minecraftbuilder/terrainworld/SurfaceMapCapture.java b/terrain-world-plugin/src/main/java/io/github/minecraftbuilder/terrainworld/SurfaceMapCapture.java new file mode 100644 index 0000000..edd0704 --- /dev/null +++ b/terrain-world-plugin/src/main/java/io/github/minecraftbuilder/terrainworld/SurfaceMapCapture.java @@ -0,0 +1,115 @@ +package io.github.minecraftbuilder.terrainworld; + +import org.bukkit.*; +import org.bukkit.plugin.java.JavaPlugin; +import java.io.IOException; +import java.nio.file.Path; +import java.time.Instant; +import java.util.function.IntFunction; + +/** One existing chunk per server tick, followed by detached asynchronous rendering. */ +final class SurfaceMapCapture { + private final JavaPlugin plugin; + private final World world; + private boolean active; + private int completed, total, chunksWide; + private String name = "none", state = "idle"; + private SurfaceMap map; + private Instant startedAt; + + SurfaceMapCapture(JavaPlugin plugin, World world) { this.plugin = plugin; this.world = world; } + boolean active() { return active; } + String status() { return "map=" + name + "; map_state=" + state + "; map_chunks=" + completed + "/" + total; } + private Path directory() { return plugin.getDataFolder().toPath().resolve("maps"); } + + record Column(int y, Material material) {} + + /** Barriers contain players, but must not hide the visible world in a surface map. */ + static Column visibleColumn(int minY, int highestY, IntFunction materialAt) { + for (int y = Math.max(minY, highestY); y >= minY; y--) { + Material material = materialAt.apply(y); + if (material != Material.AIR && material != Material.CAVE_AIR + && material != Material.VOID_AIR && material != Material.BARRIER) return new Column(y, material); + if (y == minY) break; + } + // A void column has no visible surface; retain a bounded, explicit air sentinel. + return new Column(minY, Material.AIR); + } + + private Column visibleColumn(int x, int z) { + return visibleColumn(world.getMinHeight(), world.getHighestBlockYAt(x, z, HeightMap.WORLD_SURFACE), + y -> world.getBlockAt(x, y, z).getType()); + } + + void start(String name, int minX, int minZ, int maxX, int maxZ) throws IOException { + if (active) throw new IllegalStateException("Map capture already active"); + SurfaceMap.requireUnusedName(directory(), name); + map = new SurfaceMap(minX, minZ, maxX, maxZ); + this.name = name; completed = 0; + chunksWide = Math.floorDiv(maxX, 16) - Math.floorDiv(minX, 16) + 1; + total = chunksWide * (Math.floorDiv(maxZ, 16) - Math.floorDiv(minZ, 16) + 1); + startedAt = Instant.now(); active = true; state = "reading"; + plugin.getLogger().info("Surface map " + name + " started: " + total + " existing chunks; no terrain generation or block changes"); + next(); + } + + private void next() { + if (!active || !plugin.isEnabled()) return; + if (completed == total) { write(); return; } + int cx = Math.floorDiv(map.minX, 16) + completed % chunksWide; + int cz = Math.floorDiv(map.minZ, 16) + completed / chunksWide; + if (!world.isChunkGenerated(cx, cz)) { fail("Chunk " + cx + "," + cz + " has not been generated"); return; } + // generate=false: exporting a map must not extend the world. + world.getChunkAtAsync(cx, cz, false).whenComplete((chunk, error) -> { + if (!plugin.isEnabled()) return; + Bukkit.getScheduler().runTask(plugin, () -> { + if (error != null) { fail(error.getMessage()); return; } + if (chunk == null) { fail("Existing chunk unavailable: " + cx + "," + cz); return; } + boolean ticket = false; + try { + ticket = chunk.addPluginChunkTicket(plugin); + for (int z = Math.max(map.minZ, cz * 16); z <= Math.min(map.maxZ, cz * 16 + 15); z++) + for (int x = Math.max(map.minX, cx * 16); x <= Math.min(map.maxX, cx * 16 + 15); x++) { + Column column = visibleColumn(x, z); + map.setColumn(x, z, column.y(), column.material().getKey().toString()); + } + completed++; + if (completed % 512 == 0) plugin.getLogger().info("Surface map " + name + ": " + completed + "/" + total + " chunks"); + } catch (Exception e) { fail(e.getMessage()); return; } + finally { if (ticket) chunk.removePluginChunkTicket(plugin); } + // Zero-delay tasks can run again in the same scheduler heartbeat + // when the chunk future is already complete. Require a later tick. + Bukkit.getScheduler().runTaskLater(plugin, this::next, 1L); + }); + }); + } + + private void write() { + state = "writing"; + Instant finishedAt = Instant.now(); + String worldName = world.getName(), worldKey = world.getKey().toString(), worldUuid = world.getUID().toString(); + // This worker only sees the completed primitive grid, strings and output directory. + SurfaceMap capturedMap = map; + Path destination = directory(); + String capturedName = name; + Instant capturedStart = startedAt; + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + try { + capturedMap.write(destination, capturedName, worldName, worldKey, worldUuid, capturedStart, finishedAt); + if (!plugin.isEnabled()) return; + Bukkit.getScheduler().runTask(plugin, () -> { + active = false; state = "complete"; map = null; + plugin.getLogger().info("Surface map complete: maps/" + capturedName + ".json and .png; " + + capturedMap.columns() + " observed columns; north up; chunk-sequential capture"); + }); + } catch (Exception e) { + if (plugin.isEnabled()) Bukkit.getScheduler().runTask(plugin, () -> fail(e.getMessage())); + } + }); + } + + private void fail(String reason) { + active = false; state = "failed"; map = null; + plugin.getLogger().severe("Surface map " + name + " stopped: " + reason); + } +} diff --git a/terrain-world-plugin/src/main/java/io/github/minecraftbuilder/terrainworld/TerrainWorldPlugin.java b/terrain-world-plugin/src/main/java/io/github/minecraftbuilder/terrainworld/TerrainWorldPlugin.java new file mode 100644 index 0000000..2844573 --- /dev/null +++ b/terrain-world-plugin/src/main/java/io/github/minecraftbuilder/terrainworld/TerrainWorldPlugin.java @@ -0,0 +1,154 @@ +package io.github.minecraftbuilder.terrainworld; + +import com.google.gson.*; +import org.bukkit.*; +import org.bukkit.command.*; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; +import java.nio.file.*; +import java.util.*; + +/** Optional bootstrap for a separate, operator-selected world. */ +public final class TerrainWorldPlugin extends JavaPlugin { + private RecipeGenerator generator; + private World world; + private Location lobbySpawn; + private SurfaceMapCapture surfaceMaps; + private boolean generating; + private int completed, total, mismatches, minHeight, maxHeight; + private long columns, wetColumns, started; + + @Override public void onEnable() { + try { + saveDefaultConfig(); + if (!getConfig().getBoolean("enabled", false)) return; + String name = getConfig().getString("world", "shacraft_lobby"); + if (!name.matches("[a-z0-9_]{1,48}")) throw new IllegalArgumentException("Invalid world name"); + // Paper 26.2 stores dimensions beneath the primary save, not server/name. + World overworld = Objects.requireNonNull(Bukkit.getWorld(NamespacedKey.minecraft("overworld"))); + Path folder = overworld.getWorldPath().resolveSibling(name); + Path recipeFile = getDataFolder().toPath().resolve(getConfig().getString("recipe", "terrain.json")).normalize(); + if (!recipeFile.startsWith(getDataFolder().toPath())) throw new IllegalArgumentException("Recipe must be inside plugin data folder"); + JsonObject source = JsonParser.parseString(Files.readString(recipeFile)).getAsJsonObject(); + Integer waterLevel = getConfig().isInt("water-level") ? getConfig().getInt("water-level") : null; + String profile = getConfig().getString("terrain-profile", "recipe-v1"); + generator = new RecipeGenerator(source, waterLevel, profile); + LobbySpawn spawn = LobbySpawn.fromConfig(getConfig(), () -> generator.height(0, 0) + 1); + Path manifest = getDataFolder().toPath().resolve(name + ".generation.json"); + String identity = generator.identity(); + if (Files.exists(manifest)) { + var saved = JsonParser.parseString(Files.readString(manifest)).getAsJsonObject(); + if (!saved.get("identity").getAsString().equals(identity)) + throw new IllegalStateException("Recipe changed: use a new world name to avoid seams"); + } else { + if (Files.exists(folder)) throw new IllegalStateException("Refusing to adopt an existing world without its generation manifest"); + JsonObject saved = new JsonObject(); saved.addProperty("identity", identity); saved.add("recipe", source); + if (waterLevel != null) saved.addProperty("water_level", waterLevel); + saved.addProperty("terrain_profile", profile); + Files.writeString(manifest, new GsonBuilder().setPrettyPrinting().create().toJson(saved), StandardOpenOption.CREATE_NEW); + } + if (Bukkit.getWorld(name) != null) throw new IllegalStateException("World already loaded by another provider"); + world = new WorldCreator(NamespacedKey.minecraft(name)).seed(source.get("seed").getAsLong()) + .generator(generator).generateStructures(false).createWorld(); + if (world == null) throw new IllegalStateException("World creation failed"); + surfaceMaps = new SurfaceMapCapture(this, world); + var bounds = generator.recipe.bounds(); + world.getWorldBorder().setCenter((bounds.min().x() + bounds.max().x() + 1) / 2.0, + (bounds.min().z() + bounds.max().z() + 1) / 2.0); + int footprint = Math.max(generator.recipe.width(), generator.recipe.length()); + world.getWorldBorder().setSize(Math.max(footprint, getConfig().getInt("border-size", footprint))); + lobbySpawn = spawn.location(world); + if (getConfig().contains("spawn")) world.setSpawnLocation(lobbySpawn); + else world.setSpawnLocation(0, lobbySpawn.getBlockY(), 0); + getServer().getPluginManager().registerEvents(new LobbyRespawn(world, + getConfig().contains("spawn") ? lobbySpawn : null), this); + world.setTime(6000); world.setStorm(false); world.setThundering(false); + world.setGameRule(GameRule.DO_DAYLIGHT_CYCLE, false); + world.setGameRule(GameRule.DO_WEATHER_CYCLE, false); + world.setGameRule(GameRule.DO_MOB_SPAWNING, false); + Objects.requireNonNull(getCommand("lobby")).setExecutor(this::command); + if (getConfig().getBoolean("station.enabled", false)) { + StationLift lift = new StationLift(this, world); + getServer().getPluginManager().registerEvents(lift, this); + Objects.requireNonNull(getCommand("station")).setExecutor(lift); + StationLabels.install(this, world); + } + getLogger().info("Terrain world ready: " + name + "; recipe=" + generator.recipe.id()); + } catch (Exception e) { + getLogger().severe("Terrain world disabled: " + e.getMessage()); + getServer().getPluginManager().disablePlugin(this); + } + } + + private boolean command(CommandSender sender, Command command, String label, String[] args) { + if (world == null) { sender.sendMessage("Terrain world unavailable"); return true; } + if (args.length == 1 && args[0].equals("status")) { + sender.sendMessage("Shacraft: " + completed + "/" + total + " chunks; active=" + generating + "; mismatched columns=" + mismatches + "; " + surfaceMaps.status()); + } else if (args.length == 2 && args[0].equals("map")) { + if (!(sender instanceof ConsoleCommandSender)) { sender.sendMessage("Surface map export is a server-console command"); return true; } + if (generating) { sender.sendMessage("Wait for terrain generation before capturing a map"); return true; } + try { + var b = generator.recipe.bounds(); + surfaceMaps.start(args[1], b.min().x(), b.min().z(), b.max().x(), b.max().z()); + sender.sendMessage("Surface map started; inspect lobby status. Output: plugins/ShacraftTerrain/maps/" + args[1] + ".{json,png}"); + } catch (Exception e) { sender.sendMessage("Cannot capture map: " + e.getMessage()); } + } else if (args.length == 1 && args[0].equals("generate") && sender instanceof ConsoleCommandSender) { + if (generating) { sender.sendMessage("Generation already active"); return true; } + if (surfaceMaps.active()) { sender.sendMessage("Wait for the active surface map capture"); return true; } + completed = 0; columns = 0; wetColumns = 0; mismatches = 0; + minHeight = Integer.MAX_VALUE; maxHeight = Integer.MIN_VALUE; + var b = generator.recipe.bounds(); + total = (Math.floorDiv(b.max().x(), 16) - Math.floorDiv(b.min().x(), 16) + 1) + * (Math.floorDiv(b.max().z(), 16) - Math.floorDiv(b.min().z(), 16) + 1); + started = System.currentTimeMillis(); generating = true; next(); + } else { + Player player = sender instanceof Player p ? p : args.length == 1 ? Bukkit.getPlayerExact(args[0]) : null; + if (player == null) { sender.sendMessage("/lobby [status] or console: lobby | generate | map "); return true; } + player.teleportAsync(lobbySpawn.clone()); + } + return true; + } + + private void next() { + if (!isEnabled() || !generating) return; + if (completed == total) { + generating = false; world.save(); + var report = Map.of("world", world.getName(), "recipe_id", generator.recipe.id(), "chunks", total, + "verified_columns", columns, "water_columns", wetColumns, "mismatches", mismatches, "min_surface_y", minHeight, + "max_surface_y", maxHeight, "elapsed_ms", System.currentTimeMillis() - started); + try { Files.writeString(getDataFolder().toPath().resolve("generation-report.json"), new GsonBuilder().setPrettyPrinting().create().toJson(report)); } + catch (Exception e) { getLogger().severe("Cannot save generation report: " + e.getMessage()); } + getLogger().info("Terrain generation complete: " + report); return; + } + var b = generator.recipe.bounds(); + int nx = Math.floorDiv(b.max().x(), 16) - Math.floorDiv(b.min().x(), 16) + 1; + int cx = Math.floorDiv(b.min().x(), 16) + completed % nx; + int cz = Math.floorDiv(b.min().z(), 16) + completed / nx; + world.getChunkAtAsync(cx, cz, true).whenComplete((chunk, error) -> { + if (!isEnabled()) return; + Bukkit.getScheduler().runTask(this, () -> { + if (error != null) { generating = false; getLogger().severe("Generation stopped: " + error.getMessage()); return; } + for (int x = 0; x < 16; x++) for (int z = 0; z < 16; z++) { + int wx = cx * 16 + x, wz = cz * 16 + z; + if (wx < b.min().x() || wx > b.max().x() || wz < b.min().z() || wz > b.max().z()) continue; + int expected = generator.visibleHeight(wx, wz); + int actual = world.getHighestBlockYAt(wx, wz, HeightMap.WORLD_SURFACE); + int ground = generator.height(wx, wz); + boolean wet = expected > ground; + boolean mismatch = actual != expected || world.getBlockAt(wx, ground, wz).getType() != generator.surfaceMaterial(wx,wz); + if (wet) { + wetColumns++; + for (int y = ground + 1; y <= expected; y++) + if (world.getBlockAt(wx, y, wz).getType() != Material.WATER) { mismatch = true; break; } + if (world.getBlockAt(wx, ground, wz).getType().isAir()) mismatch = true; + } + if (mismatch) mismatches++; + minHeight = Math.min(minHeight, actual); maxHeight = Math.max(maxHeight, actual); columns++; + } + completed++; + if (completed % 128 == 0) getLogger().info("Terrain progress " + completed + "/" + total); + Bukkit.getScheduler().runTaskLater(this, this::next, 1L); + }); + }); + } +} diff --git a/terrain-world-plugin/src/main/java/io/github/minecraftbuilder/terrainworld/noise/FastNoiseLite.java b/terrain-world-plugin/src/main/java/io/github/minecraftbuilder/terrainworld/noise/FastNoiseLite.java new file mode 100644 index 0000000..7a993de --- /dev/null +++ b/terrain-world-plugin/src/main/java/io/github/minecraftbuilder/terrainworld/noise/FastNoiseLite.java @@ -0,0 +1,2611 @@ +package io.github.minecraftbuilder.terrainworld.noise; + +// MIT License +// +// Copyright(c) 2023 Jordan Peck (jordan.me2@gmail.com) +// Copyright(c) 2023 Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +// .'',;:cldxkO00KKXXNNWWWNNXKOkxdollcc::::::;:::ccllloooolllllllllooollc:,'... ...........',;cldxkO000Okxdlc::;;;,,;;;::cclllllll +// ..',;:ldxO0KXXNNNNNNNNXXK0kxdolcc::::::;;;,,,,,,;;;;;;;;;;:::cclllllc:;'.... ...........',;:ldxO0KXXXK0Okxdolc::;;;;::cllodddddo +// ...',:loxO0KXNNNNNXXKK0Okxdolc::;::::::::;;;,,'''''.....''',;:clllllc:;,'............''''''''',;:loxO0KXNNNNNXK0Okxdollccccllodxxxxxxd +// ....';:ldkO0KXXXKK00Okxdolcc:;;;;;::cclllcc:;;,''..... ....',;clooddolcc:;;;;,,;;;;;::::;;;;;;:cloxk0KXNWWWWWWNXKK0Okxddoooddxxkkkkkxx +// .....';:ldxkOOOOOkxxdolcc:;;;,,,;;:cllooooolcc:;'... ..,:codxkkkxddooollloooooooollcc:::::clodkO0KXNWWWWWWNNXK00Okxxxxxxxxkkkkxxx +// . ....';:cloddddo___________,,,,;;:clooddddoolc:,... ..,:ldx__00OOOkkk___kkkkkkxxdollc::::cclodkO0KXXNNNNNNXXK0OOkxxxxxxxxxxxxddd +// .......',;:cccc:| |,,,;;:cclooddddoll:;'.. ..';cox| \KKK000| |KK00OOkxdocc___;::clldxxkO0KKKKK00Okkxdddddddddddddddoo +// .......'',,,,,''| ________|',,;;::cclloooooolc:;'......___:ldk| \KK000| |XKKK0Okxolc| |;;::cclodxxkkkkxxdoolllcclllooodddooooo +// ''......''''....| | ....'',,,,;;;::cclloooollc:;,''.'| |oxk| \OOO0| |KKK00Oxdoll|___|;;;;;::ccllllllcc::;;,,;;;:cclloooooooo +// ;;,''.......... | |_____',,;;;____:___cllo________.___| |___| \xkk| |KK_______ool___:::;________;;;_______...'',;;:ccclllloo +// c:;,''......... | |:::/ ' |lo/ | | \dx| |0/ \d| |cc/ |'/ \......',,;;:ccllo +// ol:;,'..........| _____|ll/ __ |o/ ______|____ ___| | \o| |/ ___ \| |o/ ______|/ ___ \ .......'',;:clo +// dlc;,...........| |::clooo| / | |x\___ \KXKKK0| |dol| |\ \| | | | | |d\___ \..| | / / ....',:cl +// xoc;'... .....'| |llodddd| \__| |_____\ \KKK0O| |lc:| |'\ | |___| | |_____\ \.| |_/___/... ...',;:c +// dlc;'... ....',;| |oddddddo\ | |Okkx| |::;| |..\ |\ /| | | \ |... ....',;:c +// ol:,'.......',:c|___|xxxddollc\_____,___|_________/ddoll|___|,,,|___|...\_____|:\ ______/l|___|_________/...\________|'........',;::cc +// c:;'.......';:codxxkkkkxxolc::;::clodxkOO0OOkkxdollc::;;,,''''',,,,''''''''''',,'''''',;:loxkkOOkxol:;,'''',,;:ccllcc:;,'''''',;::ccll +// ;,'.......',:codxkOO0OOkxdlc:;,,;;:cldxxkkxxdolc:;;,,''.....'',;;:::;;,,,'''''........,;cldkO0KK0Okdoc::;;::cloodddoolc:;;;;;::ccllooo +// .........',;:lodxOO0000Okdoc:,,',,;:clloddoolc:;,''.......'',;:clooollc:;;,,''.......',:ldkOKXNNXX0Oxdolllloddxxxxxxdolccccccllooodddd +// . .....';:cldxkO0000Okxol:;,''',,;::cccc:;,,'.......'',;:cldxxkkxxdolc:;;,'.......';coxOKXNWWWNXKOkxddddxxkkkkkkxdoollllooddxxxxkkk +// ....',;:codxkO000OOxdoc:;,''',,,;;;;,''.......',,;:clodkO00000Okxolc::;,,''..',;:ldxOKXNWWWNNK0OkkkkkkkkkkkxxddooooodxxkOOOOO000 +// ....',;;clodxkkOOOkkdolc:;,,,,,,,,'..........,;:clodxkO0KKXKK0Okxdolcc::;;,,,;;:codkO0XXNNNNXKK0OOOOOkkkkxxdoollloodxkO0KKKXXXXX +// +// VERSION: 1.1.1 +// https://github.com/Auburn/FastNoiseLite + +// To switch between using floats or doubles for input position, +// perform a file-wide replace on the following strings (including /*FNLfloat*/) +// /*FNLfloat*/ float +// /*FNLfloat*/ double + +public class FastNoiseLite +{ + public enum NoiseType + { + OpenSimplex2, + OpenSimplex2S, + Cellular, + Perlin, + ValueCubic, + Value + }; + + public enum RotationType3D + { + None, + ImproveXYPlanes, + ImproveXZPlanes + }; + + public enum FractalType + { + None, + FBm, + Ridged, + PingPong, + DomainWarpProgressive, + DomainWarpIndependent + }; + + public enum CellularDistanceFunction + { + Euclidean, + EuclideanSq, + Manhattan, + Hybrid + }; + + public enum CellularReturnType + { + CellValue, + Distance, + Distance2, + Distance2Add, + Distance2Sub, + Distance2Mul, + Distance2Div + }; + + public enum DomainWarpType + { + OpenSimplex2, + OpenSimplex2Reduced, + BasicGrid + }; + + private enum TransformType3D + { + None, + ImproveXYPlanes, + ImproveXZPlanes, + DefaultOpenSimplex2 + }; + + private int mSeed = 1337; + private float mFrequency = 0.01f; + private NoiseType mNoiseType = NoiseType.OpenSimplex2; + private RotationType3D mRotationType3D = RotationType3D.None; + private TransformType3D mTransformType3D = TransformType3D.DefaultOpenSimplex2; + + private FractalType mFractalType = FractalType.None; + private int mOctaves = 3; + private float mLacunarity = 2.0f; + private float mGain = 0.5f; + private float mWeightedStrength = 0.0f; + private float mPingPongStrength = 2.0f; + + private float mFractalBounding = 1 / 1.75f; + + private CellularDistanceFunction mCellularDistanceFunction = CellularDistanceFunction.EuclideanSq; + private CellularReturnType mCellularReturnType = CellularReturnType.Distance; + private float mCellularJitterModifier = 1.0f; + + private DomainWarpType mDomainWarpType = DomainWarpType.OpenSimplex2; + private TransformType3D mWarpTransformType3D = TransformType3D.DefaultOpenSimplex2; + private float mDomainWarpAmp = 1.0f; + + /// + /// Create new FastNoise object with default seed + /// + public FastNoiseLite() { } + + /// + /// Create new FastNoise object with specified seed + /// + public FastNoiseLite(int seed) + { + SetSeed(seed); + } + + /// + /// Sets seed used for all noise types + /// + /// + /// Default: 1337 + /// + public void SetSeed(int seed) { mSeed = seed; } + + /// + /// Sets frequency for all noise types + /// + /// + /// Default: 0.01 + /// + public void SetFrequency(float frequency) { mFrequency = frequency; } + + /// + /// Sets noise algorithm used for GetNoise(...) + /// + /// + /// Default: OpenSimplex2 + /// + public void SetNoiseType(NoiseType noiseType) + { + mNoiseType = noiseType; + UpdateTransformType3D(); + } + + /// + /// Sets domain rotation type for 3D Noise and 3D DomainWarp. + /// Can aid in reducing directional artifacts when sampling a 2D plane in 3D + /// + /// + /// Default: None + /// + public void SetRotationType3D(RotationType3D rotationType3D) + { + mRotationType3D = rotationType3D; + UpdateTransformType3D(); + UpdateWarpTransformType3D(); + } + + /// + /// Sets method for combining octaves in all fractal noise types + /// + /// + /// Default: None + /// Note: FractalType.DomainWarp... only affects DomainWarp(...) + /// + public void SetFractalType(FractalType fractalType) { mFractalType = fractalType; } + + /// + /// Sets octave count for all fractal noise types + /// + /// + /// Default: 3 + /// + public void SetFractalOctaves(int octaves) + { + mOctaves = octaves; + CalculateFractalBounding(); + } + + /// + /// Sets octave lacunarity for all fractal noise types + /// + /// + /// Default: 2.0 + /// + public void SetFractalLacunarity(float lacunarity) { mLacunarity = lacunarity; } + + /// + /// Sets octave gain for all fractal noise types + /// + /// + /// Default: 0.5 + /// + public void SetFractalGain(float gain) + { + mGain = gain; + CalculateFractalBounding(); + } + + /// + /// Sets octave weighting for all none DomainWarp fratal types + /// + /// + /// Default: 0.0 + /// Note: Keep between 0...1 to maintain -1...1 output bounding + /// + public void SetFractalWeightedStrength(float weightedStrength) { mWeightedStrength = weightedStrength; } + + /// + /// Sets strength of the fractal ping pong effect + /// + /// + /// Default: 2.0 + /// + public void SetFractalPingPongStrength(float pingPongStrength) { mPingPongStrength = pingPongStrength; } + + + /// + /// Sets distance function used in cellular noise calculations + /// + /// + /// Default: Distance + /// + public void SetCellularDistanceFunction(CellularDistanceFunction cellularDistanceFunction) { mCellularDistanceFunction = cellularDistanceFunction; } + + /// + /// Sets return type from cellular noise calculations + /// + /// + /// Default: EuclideanSq + /// + public void SetCellularReturnType(CellularReturnType cellularReturnType) { mCellularReturnType = cellularReturnType; } + + /// + /// Sets the maximum distance a cellular point can move from it's grid position + /// + /// + /// Default: 1.0 + /// Note: Setting this higher than 1 will cause artifacts + /// + public void SetCellularJitter(float cellularJitter) { mCellularJitterModifier = cellularJitter; } + + + /// + /// Sets the warp algorithm when using DomainWarp(...) + /// + /// + /// Default: OpenSimplex2 + /// + public void SetDomainWarpType(DomainWarpType domainWarpType) + { + mDomainWarpType = domainWarpType; + UpdateWarpTransformType3D(); + } + + + /// + /// Sets the maximum warp distance from original position when using DomainWarp(...) + /// + /// + /// Default: 1.0 + /// + public void SetDomainWarpAmp(float domainWarpAmp) { mDomainWarpAmp = domainWarpAmp; } + + + /// + /// 2D noise at given position using current settings + /// + /// + /// Noise output bounded between -1...1 + /// + public float GetNoise(/*FNLfloat*/ float x, /*FNLfloat*/ float y) + { + x *= mFrequency; + y *= mFrequency; + + switch (mNoiseType) + { + case OpenSimplex2: + case OpenSimplex2S: + { + final /*FNLfloat*/ float SQRT3 = (/*FNLfloat*/ float)1.7320508075688772935274463415059; + final /*FNLfloat*/ float F2 = 0.5f * (SQRT3 - 1); + /*FNLfloat*/ float t = (x + y) * F2; + x += t; + y += t; + } + break; + default: + break; + } + + switch (mFractalType) + { + default: + return GenNoiseSingle(mSeed, x, y); + case FBm: + return GenFractalFBm(x, y); + case Ridged: + return GenFractalRidged(x, y); + case PingPong: + return GenFractalPingPong(x, y); + } + } + + /// + /// 3D noise at given position using current settings + /// + /// + /// Noise output bounded between -1...1 + /// + public float GetNoise(/*FNLfloat*/ float x, /*FNLfloat*/ float y, /*FNLfloat*/ float z) + { + x *= mFrequency; + y *= mFrequency; + z *= mFrequency; + + switch (mTransformType3D) + { + case ImproveXYPlanes: + { + /*FNLfloat*/ float xy = x + y; + /*FNLfloat*/ float s2 = xy * -(/*FNLfloat*/ float)0.211324865405187; + z *= (/*FNLfloat*/ float)0.577350269189626; + x += s2 - z; + y = y + s2 - z; + z += xy * (/*FNLfloat*/ float)0.577350269189626; + } + break; + case ImproveXZPlanes: + { + /*FNLfloat*/ float xz = x + z; + /*FNLfloat*/ float s2 = xz * -(/*FNLfloat*/ float)0.211324865405187; + y *= (/*FNLfloat*/ float)0.577350269189626; + x += s2 - y; + z += s2 - y; + y += xz * (/*FNLfloat*/ float)0.577350269189626; + } + break; + case DefaultOpenSimplex2: + { + final /*FNLfloat*/ float R3 = (/*FNLfloat*/ float)(2.0 / 3.0); + /*FNLfloat*/ float r = (x + y + z) * R3; // Rotation, not skew + x = r - x; + y = r - y; + z = r - z; + } + break; + default: + break; + } + + switch (mFractalType) + { + default: + return GenNoiseSingle(mSeed, x, y, z); + case FBm: + return GenFractalFBm(x, y, z); + case Ridged: + return GenFractalRidged(x, y, z); + case PingPong: + return GenFractalPingPong(x, y, z); + } + } + + + /// + /// 2D warps the input position using current domain warp settings + /// + /// + /// Example usage with GetNoise + /// DomainWarp(coord) + /// noise = GetNoise(x, y) + /// + public void DomainWarp(Vector2 coord) + { + switch (mFractalType) + { + default: + DomainWarpSingle(coord); + break; + case DomainWarpProgressive: + DomainWarpFractalProgressive(coord); + break; + case DomainWarpIndependent: + DomainWarpFractalIndependent(coord); + break; + } + } + + /// + /// 3D warps the input position using current domain warp settings + /// + /// + /// Example usage with GetNoise + /// DomainWarp(coord) + /// noise = GetNoise(x, y, z) + /// + public void DomainWarp(Vector3 coord) + { + switch (mFractalType) + { + default: + DomainWarpSingle(coord); + break; + case DomainWarpProgressive: + DomainWarpFractalProgressive(coord); + break; + case DomainWarpIndependent: + DomainWarpFractalIndependent(coord); + break; + } + } + + + private static final float[] Gradients2D = { + 0.130526192220052f, 0.99144486137381f, 0.38268343236509f, 0.923879532511287f, 0.608761429008721f, 0.793353340291235f, 0.793353340291235f, 0.608761429008721f, + 0.923879532511287f, 0.38268343236509f, 0.99144486137381f, 0.130526192220051f, 0.99144486137381f, -0.130526192220051f, 0.923879532511287f, -0.38268343236509f, + 0.793353340291235f, -0.60876142900872f, 0.608761429008721f, -0.793353340291235f, 0.38268343236509f, -0.923879532511287f, 0.130526192220052f, -0.99144486137381f, + -0.130526192220052f, -0.99144486137381f, -0.38268343236509f, -0.923879532511287f, -0.608761429008721f, -0.793353340291235f, -0.793353340291235f, -0.608761429008721f, + -0.923879532511287f, -0.38268343236509f, -0.99144486137381f, -0.130526192220052f, -0.99144486137381f, 0.130526192220051f, -0.923879532511287f, 0.38268343236509f, + -0.793353340291235f, 0.608761429008721f, -0.608761429008721f, 0.793353340291235f, -0.38268343236509f, 0.923879532511287f, -0.130526192220052f, 0.99144486137381f, + 0.130526192220052f, 0.99144486137381f, 0.38268343236509f, 0.923879532511287f, 0.608761429008721f, 0.793353340291235f, 0.793353340291235f, 0.608761429008721f, + 0.923879532511287f, 0.38268343236509f, 0.99144486137381f, 0.130526192220051f, 0.99144486137381f, -0.130526192220051f, 0.923879532511287f, -0.38268343236509f, + 0.793353340291235f, -0.60876142900872f, 0.608761429008721f, -0.793353340291235f, 0.38268343236509f, -0.923879532511287f, 0.130526192220052f, -0.99144486137381f, + -0.130526192220052f, -0.99144486137381f, -0.38268343236509f, -0.923879532511287f, -0.608761429008721f, -0.793353340291235f, -0.793353340291235f, -0.608761429008721f, + -0.923879532511287f, -0.38268343236509f, -0.99144486137381f, -0.130526192220052f, -0.99144486137381f, 0.130526192220051f, -0.923879532511287f, 0.38268343236509f, + -0.793353340291235f, 0.608761429008721f, -0.608761429008721f, 0.793353340291235f, -0.38268343236509f, 0.923879532511287f, -0.130526192220052f, 0.99144486137381f, + 0.130526192220052f, 0.99144486137381f, 0.38268343236509f, 0.923879532511287f, 0.608761429008721f, 0.793353340291235f, 0.793353340291235f, 0.608761429008721f, + 0.923879532511287f, 0.38268343236509f, 0.99144486137381f, 0.130526192220051f, 0.99144486137381f, -0.130526192220051f, 0.923879532511287f, -0.38268343236509f, + 0.793353340291235f, -0.60876142900872f, 0.608761429008721f, -0.793353340291235f, 0.38268343236509f, -0.923879532511287f, 0.130526192220052f, -0.99144486137381f, + -0.130526192220052f, -0.99144486137381f, -0.38268343236509f, -0.923879532511287f, -0.608761429008721f, -0.793353340291235f, -0.793353340291235f, -0.608761429008721f, + -0.923879532511287f, -0.38268343236509f, -0.99144486137381f, -0.130526192220052f, -0.99144486137381f, 0.130526192220051f, -0.923879532511287f, 0.38268343236509f, + -0.793353340291235f, 0.608761429008721f, -0.608761429008721f, 0.793353340291235f, -0.38268343236509f, 0.923879532511287f, -0.130526192220052f, 0.99144486137381f, + 0.130526192220052f, 0.99144486137381f, 0.38268343236509f, 0.923879532511287f, 0.608761429008721f, 0.793353340291235f, 0.793353340291235f, 0.608761429008721f, + 0.923879532511287f, 0.38268343236509f, 0.99144486137381f, 0.130526192220051f, 0.99144486137381f, -0.130526192220051f, 0.923879532511287f, -0.38268343236509f, + 0.793353340291235f, -0.60876142900872f, 0.608761429008721f, -0.793353340291235f, 0.38268343236509f, -0.923879532511287f, 0.130526192220052f, -0.99144486137381f, + -0.130526192220052f, -0.99144486137381f, -0.38268343236509f, -0.923879532511287f, -0.608761429008721f, -0.793353340291235f, -0.793353340291235f, -0.608761429008721f, + -0.923879532511287f, -0.38268343236509f, -0.99144486137381f, -0.130526192220052f, -0.99144486137381f, 0.130526192220051f, -0.923879532511287f, 0.38268343236509f, + -0.793353340291235f, 0.608761429008721f, -0.608761429008721f, 0.793353340291235f, -0.38268343236509f, 0.923879532511287f, -0.130526192220052f, 0.99144486137381f, + 0.130526192220052f, 0.99144486137381f, 0.38268343236509f, 0.923879532511287f, 0.608761429008721f, 0.793353340291235f, 0.793353340291235f, 0.608761429008721f, + 0.923879532511287f, 0.38268343236509f, 0.99144486137381f, 0.130526192220051f, 0.99144486137381f, -0.130526192220051f, 0.923879532511287f, -0.38268343236509f, + 0.793353340291235f, -0.60876142900872f, 0.608761429008721f, -0.793353340291235f, 0.38268343236509f, -0.923879532511287f, 0.130526192220052f, -0.99144486137381f, + -0.130526192220052f, -0.99144486137381f, -0.38268343236509f, -0.923879532511287f, -0.608761429008721f, -0.793353340291235f, -0.793353340291235f, -0.608761429008721f, + -0.923879532511287f, -0.38268343236509f, -0.99144486137381f, -0.130526192220052f, -0.99144486137381f, 0.130526192220051f, -0.923879532511287f, 0.38268343236509f, + -0.793353340291235f, 0.608761429008721f, -0.608761429008721f, 0.793353340291235f, -0.38268343236509f, 0.923879532511287f, -0.130526192220052f, 0.99144486137381f, + 0.38268343236509f, 0.923879532511287f, 0.923879532511287f, 0.38268343236509f, 0.923879532511287f, -0.38268343236509f, 0.38268343236509f, -0.923879532511287f, + -0.38268343236509f, -0.923879532511287f, -0.923879532511287f, -0.38268343236509f, -0.923879532511287f, 0.38268343236509f, -0.38268343236509f, 0.923879532511287f, + }; + + private static final float[] RandVecs2D = { + -0.2700222198f, -0.9628540911f, 0.3863092627f, -0.9223693152f, 0.04444859006f, -0.999011673f, -0.5992523158f, -0.8005602176f, -0.7819280288f, 0.6233687174f, 0.9464672271f, 0.3227999196f, -0.6514146797f, -0.7587218957f, 0.9378472289f, 0.347048376f, + -0.8497875957f, -0.5271252623f, -0.879042592f, 0.4767432447f, -0.892300288f, -0.4514423508f, -0.379844434f, -0.9250503802f, -0.9951650832f, 0.0982163789f, 0.7724397808f, -0.6350880136f, 0.7573283322f, -0.6530343002f, -0.9928004525f, -0.119780055f, + -0.0532665713f, 0.9985803285f, 0.9754253726f, -0.2203300762f, -0.7665018163f, 0.6422421394f, 0.991636706f, 0.1290606184f, -0.994696838f, 0.1028503788f, -0.5379205513f, -0.84299554f, 0.5022815471f, -0.8647041387f, 0.4559821461f, -0.8899889226f, + -0.8659131224f, -0.5001944266f, 0.0879458407f, -0.9961252577f, -0.5051684983f, 0.8630207346f, 0.7753185226f, -0.6315704146f, -0.6921944612f, 0.7217110418f, -0.5191659449f, -0.8546734591f, 0.8978622882f, -0.4402764035f, -0.1706774107f, 0.9853269617f, + -0.9353430106f, -0.3537420705f, -0.9992404798f, 0.03896746794f, -0.2882064021f, -0.9575683108f, -0.9663811329f, 0.2571137995f, -0.8759714238f, -0.4823630009f, -0.8303123018f, -0.5572983775f, 0.05110133755f, -0.9986934731f, -0.8558373281f, -0.5172450752f, + 0.09887025282f, 0.9951003332f, 0.9189016087f, 0.3944867976f, -0.2439375892f, -0.9697909324f, -0.8121409387f, -0.5834613061f, -0.9910431363f, 0.1335421355f, 0.8492423985f, -0.5280031709f, -0.9717838994f, -0.2358729591f, 0.9949457207f, 0.1004142068f, + 0.6241065508f, -0.7813392434f, 0.662910307f, 0.7486988212f, -0.7197418176f, 0.6942418282f, -0.8143370775f, -0.5803922158f, 0.104521054f, -0.9945226741f, -0.1065926113f, -0.9943027784f, 0.445799684f, -0.8951327509f, 0.105547406f, 0.9944142724f, + -0.992790267f, 0.1198644477f, -0.8334366408f, 0.552615025f, 0.9115561563f, -0.4111755999f, 0.8285544909f, -0.5599084351f, 0.7217097654f, -0.6921957921f, 0.4940492677f, -0.8694339084f, -0.3652321272f, -0.9309164803f, -0.9696606758f, 0.2444548501f, + 0.08925509731f, -0.996008799f, 0.5354071276f, -0.8445941083f, -0.1053576186f, 0.9944343981f, -0.9890284586f, 0.1477251101f, 0.004856104961f, 0.9999882091f, 0.9885598478f, 0.1508291331f, 0.9286129562f, -0.3710498316f, -0.5832393863f, -0.8123003252f, + 0.3015207509f, 0.9534596146f, -0.9575110528f, 0.2883965738f, 0.9715802154f, -0.2367105511f, 0.229981792f, 0.9731949318f, 0.955763816f, -0.2941352207f, 0.740956116f, 0.6715534485f, -0.9971513787f, -0.07542630764f, 0.6905710663f, -0.7232645452f, + -0.290713703f, -0.9568100872f, 0.5912777791f, -0.8064679708f, -0.9454592212f, -0.325740481f, 0.6664455681f, 0.74555369f, 0.6236134912f, 0.7817328275f, 0.9126993851f, -0.4086316587f, -0.8191762011f, 0.5735419353f, -0.8812745759f, -0.4726046147f, + 0.9953313627f, 0.09651672651f, 0.9855650846f, -0.1692969699f, -0.8495980887f, 0.5274306472f, 0.6174853946f, -0.7865823463f, 0.8508156371f, 0.52546432f, 0.9985032451f, -0.05469249926f, 0.1971371563f, -0.9803759185f, 0.6607855748f, -0.7505747292f, + -0.03097494063f, 0.9995201614f, -0.6731660801f, 0.739491331f, -0.7195018362f, -0.6944905383f, 0.9727511689f, 0.2318515979f, 0.9997059088f, -0.0242506907f, 0.4421787429f, -0.8969269532f, 0.9981350961f, -0.061043673f, -0.9173660799f, -0.3980445648f, + -0.8150056635f, -0.5794529907f, -0.8789331304f, 0.4769450202f, 0.0158605829f, 0.999874213f, -0.8095464474f, 0.5870558317f, -0.9165898907f, -0.3998286786f, -0.8023542565f, 0.5968480938f, -0.5176737917f, 0.8555780767f, -0.8154407307f, -0.5788405779f, + 0.4022010347f, -0.9155513791f, -0.9052556868f, -0.4248672045f, 0.7317445619f, 0.6815789728f, -0.5647632201f, -0.8252529947f, -0.8403276335f, -0.5420788397f, -0.9314281527f, 0.363925262f, 0.5238198472f, 0.8518290719f, 0.7432803869f, -0.6689800195f, + -0.985371561f, -0.1704197369f, 0.4601468731f, 0.88784281f, 0.825855404f, 0.5638819483f, 0.6182366099f, 0.7859920446f, 0.8331502863f, -0.553046653f, 0.1500307506f, 0.9886813308f, -0.662330369f, -0.7492119075f, -0.668598664f, 0.743623444f, + 0.7025606278f, 0.7116238924f, -0.5419389763f, -0.8404178401f, -0.3388616456f, 0.9408362159f, 0.8331530315f, 0.5530425174f, -0.2989720662f, -0.9542618632f, 0.2638522993f, 0.9645630949f, 0.124108739f, -0.9922686234f, -0.7282649308f, -0.6852956957f, + 0.6962500149f, 0.7177993569f, -0.9183535368f, 0.3957610156f, -0.6326102274f, -0.7744703352f, -0.9331891859f, -0.359385508f, -0.1153779357f, -0.9933216659f, 0.9514974788f, -0.3076565421f, -0.08987977445f, -0.9959526224f, 0.6678496916f, 0.7442961705f, + 0.7952400393f, -0.6062947138f, -0.6462007402f, -0.7631674805f, -0.2733598753f, 0.9619118351f, 0.9669590226f, -0.254931851f, -0.9792894595f, 0.2024651934f, -0.5369502995f, -0.8436138784f, -0.270036471f, -0.9628500944f, -0.6400277131f, 0.7683518247f, + -0.7854537493f, -0.6189203566f, 0.06005905383f, -0.9981948257f, -0.02455770378f, 0.9996984141f, -0.65983623f, 0.751409442f, -0.6253894466f, -0.7803127835f, -0.6210408851f, -0.7837781695f, 0.8348888491f, 0.5504185768f, -0.1592275245f, 0.9872419133f, + 0.8367622488f, 0.5475663786f, -0.8675753916f, -0.4973056806f, -0.2022662628f, -0.9793305667f, 0.9399189937f, 0.3413975472f, 0.9877404807f, -0.1561049093f, -0.9034455656f, 0.4287028224f, 0.1269804218f, -0.9919052235f, -0.3819600854f, 0.924178821f, + 0.9754625894f, 0.2201652486f, -0.3204015856f, -0.9472818081f, -0.9874760884f, 0.1577687387f, 0.02535348474f, -0.9996785487f, 0.4835130794f, -0.8753371362f, -0.2850799925f, -0.9585037287f, -0.06805516006f, -0.99768156f, -0.7885244045f, -0.6150034663f, + 0.3185392127f, -0.9479096845f, 0.8880043089f, 0.4598351306f, 0.6476921488f, -0.7619021462f, 0.9820241299f, 0.1887554194f, 0.9357275128f, -0.3527237187f, -0.8894895414f, 0.4569555293f, 0.7922791302f, 0.6101588153f, 0.7483818261f, 0.6632681526f, + -0.7288929755f, -0.6846276581f, 0.8729032783f, -0.4878932944f, 0.8288345784f, 0.5594937369f, 0.08074567077f, 0.9967347374f, 0.9799148216f, -0.1994165048f, -0.580730673f, -0.8140957471f, -0.4700049791f, -0.8826637636f, 0.2409492979f, 0.9705377045f, + 0.9437816757f, -0.3305694308f, -0.8927998638f, -0.4504535528f, -0.8069622304f, 0.5906030467f, 0.06258973166f, 0.9980393407f, -0.9312597469f, 0.3643559849f, 0.5777449785f, 0.8162173362f, -0.3360095855f, -0.941858566f, 0.697932075f, -0.7161639607f, + -0.002008157227f, -0.9999979837f, -0.1827294312f, -0.9831632392f, -0.6523911722f, 0.7578824173f, -0.4302626911f, -0.9027037258f, -0.9985126289f, -0.05452091251f, -0.01028102172f, -0.9999471489f, -0.4946071129f, 0.8691166802f, -0.2999350194f, 0.9539596344f, + 0.8165471961f, 0.5772786819f, 0.2697460475f, 0.962931498f, -0.7306287391f, -0.6827749597f, -0.7590952064f, -0.6509796216f, -0.907053853f, 0.4210146171f, -0.5104861064f, -0.8598860013f, 0.8613350597f, 0.5080373165f, 0.5007881595f, -0.8655698812f, + -0.654158152f, 0.7563577938f, -0.8382755311f, -0.545246856f, 0.6940070834f, 0.7199681717f, 0.06950936031f, 0.9975812994f, 0.1702942185f, -0.9853932612f, 0.2695973274f, 0.9629731466f, 0.5519612192f, -0.8338697815f, 0.225657487f, -0.9742067022f, + 0.4215262855f, -0.9068161835f, 0.4881873305f, -0.8727388672f, -0.3683854996f, -0.9296731273f, -0.9825390578f, 0.1860564427f, 0.81256471f, 0.5828709909f, 0.3196460933f, -0.9475370046f, 0.9570913859f, 0.2897862643f, -0.6876655497f, -0.7260276109f, + -0.9988770922f, -0.047376731f, -0.1250179027f, 0.992154486f, -0.8280133617f, 0.560708367f, 0.9324863769f, -0.3612051451f, 0.6394653183f, 0.7688199442f, -0.01623847064f, -0.9998681473f, -0.9955014666f, -0.09474613458f, -0.81453315f, 0.580117012f, + 0.4037327978f, -0.9148769469f, 0.9944263371f, 0.1054336766f, -0.1624711654f, 0.9867132919f, -0.9949487814f, -0.100383875f, -0.6995302564f, 0.7146029809f, 0.5263414922f, -0.85027327f, -0.5395221479f, 0.841971408f, 0.6579370318f, 0.7530729462f, + 0.01426758847f, -0.9998982128f, -0.6734383991f, 0.7392433447f, 0.639412098f, -0.7688642071f, 0.9211571421f, 0.3891908523f, -0.146637214f, -0.9891903394f, -0.782318098f, 0.6228791163f, -0.5039610839f, -0.8637263605f, -0.7743120191f, -0.6328039957f, + }; + + private static final float[] Gradients3D = { + 0, 1, 1, 0, 0,-1, 1, 0, 0, 1,-1, 0, 0,-1,-1, 0, + 1, 0, 1, 0, -1, 0, 1, 0, 1, 0,-1, 0, -1, 0,-1, 0, + 1, 1, 0, 0, -1, 1, 0, 0, 1,-1, 0, 0, -1,-1, 0, 0, + 0, 1, 1, 0, 0,-1, 1, 0, 0, 1,-1, 0, 0,-1,-1, 0, + 1, 0, 1, 0, -1, 0, 1, 0, 1, 0,-1, 0, -1, 0,-1, 0, + 1, 1, 0, 0, -1, 1, 0, 0, 1,-1, 0, 0, -1,-1, 0, 0, + 0, 1, 1, 0, 0,-1, 1, 0, 0, 1,-1, 0, 0,-1,-1, 0, + 1, 0, 1, 0, -1, 0, 1, 0, 1, 0,-1, 0, -1, 0,-1, 0, + 1, 1, 0, 0, -1, 1, 0, 0, 1,-1, 0, 0, -1,-1, 0, 0, + 0, 1, 1, 0, 0,-1, 1, 0, 0, 1,-1, 0, 0,-1,-1, 0, + 1, 0, 1, 0, -1, 0, 1, 0, 1, 0,-1, 0, -1, 0,-1, 0, + 1, 1, 0, 0, -1, 1, 0, 0, 1,-1, 0, 0, -1,-1, 0, 0, + 0, 1, 1, 0, 0,-1, 1, 0, 0, 1,-1, 0, 0,-1,-1, 0, + 1, 0, 1, 0, -1, 0, 1, 0, 1, 0,-1, 0, -1, 0,-1, 0, + 1, 1, 0, 0, -1, 1, 0, 0, 1,-1, 0, 0, -1,-1, 0, 0, + 1, 1, 0, 0, 0,-1, 1, 0, -1, 1, 0, 0, 0,-1,-1, 0 + }; + + private static final float[] RandVecs3D = { + -0.7292736885f, -0.6618439697f, 0.1735581948f, 0, 0.790292081f, -0.5480887466f, -0.2739291014f, 0, 0.7217578935f, 0.6226212466f, -0.3023380997f, 0, 0.565683137f, -0.8208298145f, -0.0790000257f, 0, 0.760049034f, -0.5555979497f, -0.3370999617f, 0, 0.3713945616f, 0.5011264475f, 0.7816254623f, 0, -0.1277062463f, -0.4254438999f, -0.8959289049f, 0, -0.2881560924f, -0.5815838982f, 0.7607405838f, 0, + 0.5849561111f, -0.662820239f, -0.4674352136f, 0, 0.3307171178f, 0.0391653737f, 0.94291689f, 0, 0.8712121778f, -0.4113374369f, -0.2679381538f, 0, 0.580981015f, 0.7021915846f, 0.4115677815f, 0, 0.503756873f, 0.6330056931f, -0.5878203852f, 0, 0.4493712205f, 0.601390195f, 0.6606022552f, 0, -0.6878403724f, 0.09018890807f, -0.7202371714f, 0, -0.5958956522f, -0.6469350577f, 0.475797649f, 0, + -0.5127052122f, 0.1946921978f, -0.8361987284f, 0, -0.9911507142f, -0.05410276466f, -0.1212153153f, 0, -0.2149721042f, 0.9720882117f, -0.09397607749f, 0, -0.7518650936f, -0.5428057603f, 0.3742469607f, 0, 0.5237068895f, 0.8516377189f, -0.02107817834f, 0, 0.6333504779f, 0.1926167129f, -0.7495104896f, 0, -0.06788241606f, 0.3998305789f, 0.9140719259f, 0, -0.5538628599f, -0.4729896695f, -0.6852128902f, 0, + -0.7261455366f, -0.5911990757f, 0.3509933228f, 0, -0.9229274737f, -0.1782808786f, 0.3412049336f, 0, -0.6968815002f, 0.6511274338f, 0.3006480328f, 0, 0.9608044783f, -0.2098363234f, -0.1811724921f, 0, 0.06817146062f, -0.9743405129f, 0.2145069156f, 0, -0.3577285196f, -0.6697087264f, -0.6507845481f, 0, -0.1868621131f, 0.7648617052f, -0.6164974636f, 0, -0.6541697588f, 0.3967914832f, 0.6439087246f, 0, + 0.6993340405f, -0.6164538506f, 0.3618239211f, 0, -0.1546665739f, 0.6291283928f, 0.7617583057f, 0, -0.6841612949f, -0.2580482182f, -0.6821542638f, 0, 0.5383980957f, 0.4258654885f, 0.7271630328f, 0, -0.5026987823f, -0.7939832935f, -0.3418836993f, 0, 0.3202971715f, 0.2834415347f, 0.9039195862f, 0, 0.8683227101f, -0.0003762656404f, -0.4959995258f, 0, 0.791120031f, -0.08511045745f, 0.6057105799f, 0, + -0.04011016052f, -0.4397248749f, 0.8972364289f, 0, 0.9145119872f, 0.3579346169f, -0.1885487608f, 0, -0.9612039066f, -0.2756484276f, 0.01024666929f, 0, 0.6510361721f, -0.2877799159f, -0.7023778346f, 0, -0.2041786351f, 0.7365237271f, 0.644859585f, 0, -0.7718263711f, 0.3790626912f, 0.5104855816f, 0, -0.3060082741f, -0.7692987727f, 0.5608371729f, 0, 0.454007341f, -0.5024843065f, 0.7357899537f, 0, + 0.4816795475f, 0.6021208291f, -0.6367380315f, 0, 0.6961980369f, -0.3222197429f, 0.641469197f, 0, -0.6532160499f, -0.6781148932f, 0.3368515753f, 0, 0.5089301236f, -0.6154662304f, -0.6018234363f, 0, -0.1635919754f, -0.9133604627f, -0.372840892f, 0, 0.52408019f, -0.8437664109f, 0.1157505864f, 0, 0.5902587356f, 0.4983817807f, -0.6349883666f, 0, 0.5863227872f, 0.494764745f, 0.6414307729f, 0, + 0.6779335087f, 0.2341345225f, 0.6968408593f, 0, 0.7177054546f, -0.6858979348f, 0.120178631f, 0, -0.5328819713f, -0.5205125012f, 0.6671608058f, 0, -0.8654874251f, -0.0700727088f, -0.4960053754f, 0, -0.2861810166f, 0.7952089234f, 0.5345495242f, 0, -0.04849529634f, 0.9810836427f, -0.1874115585f, 0, -0.6358521667f, 0.6058348682f, 0.4781800233f, 0, 0.6254794696f, -0.2861619734f, 0.7258696564f, 0, + -0.2585259868f, 0.5061949264f, -0.8227581726f, 0, 0.02136306781f, 0.5064016808f, -0.8620330371f, 0, 0.200111773f, 0.8599263484f, 0.4695550591f, 0, 0.4743561372f, 0.6014985084f, -0.6427953014f, 0, 0.6622993731f, -0.5202474575f, -0.5391679918f, 0, 0.08084972818f, -0.6532720452f, 0.7527940996f, 0, -0.6893687501f, 0.0592860349f, 0.7219805347f, 0, -0.1121887082f, -0.9673185067f, 0.2273952515f, 0, + 0.7344116094f, 0.5979668656f, -0.3210532909f, 0, 0.5789393465f, -0.2488849713f, 0.7764570201f, 0, 0.6988182827f, 0.3557169806f, -0.6205791146f, 0, -0.8636845529f, -0.2748771249f, -0.4224826141f, 0, -0.4247027957f, -0.4640880967f, 0.777335046f, 0, 0.5257722489f, -0.8427017621f, 0.1158329937f, 0, 0.9343830603f, 0.316302472f, -0.1639543925f, 0, -0.1016836419f, -0.8057303073f, -0.5834887393f, 0, + -0.6529238969f, 0.50602126f, -0.5635892736f, 0, -0.2465286165f, -0.9668205684f, -0.06694497494f, 0, -0.9776897119f, -0.2099250524f, -0.007368825344f, 0, 0.7736893337f, 0.5734244712f, 0.2694238123f, 0, -0.6095087895f, 0.4995678998f, 0.6155736747f, 0, 0.5794535482f, 0.7434546771f, 0.3339292269f, 0, -0.8226211154f, 0.08142581855f, 0.5627293636f, 0, -0.510385483f, 0.4703667658f, 0.7199039967f, 0, + -0.5764971849f, -0.07231656274f, -0.8138926898f, 0, 0.7250628871f, 0.3949971505f, -0.5641463116f, 0, -0.1525424005f, 0.4860840828f, -0.8604958341f, 0, -0.5550976208f, -0.4957820792f, 0.667882296f, 0, -0.1883614327f, 0.9145869398f, 0.357841725f, 0, 0.7625556724f, -0.5414408243f, -0.3540489801f, 0, -0.5870231946f, -0.3226498013f, -0.7424963803f, 0, 0.3051124198f, 0.2262544068f, -0.9250488391f, 0, + 0.6379576059f, 0.577242424f, -0.5097070502f, 0, -0.5966775796f, 0.1454852398f, -0.7891830656f, 0, -0.658330573f, 0.6555487542f, -0.3699414651f, 0, 0.7434892426f, 0.2351084581f, 0.6260573129f, 0, 0.5562114096f, 0.8264360377f, -0.0873632843f, 0, -0.3028940016f, -0.8251527185f, 0.4768419182f, 0, 0.1129343818f, -0.985888439f, -0.1235710781f, 0, 0.5937652891f, -0.5896813806f, 0.5474656618f, 0, + 0.6757964092f, -0.5835758614f, -0.4502648413f, 0, 0.7242302609f, -0.1152719764f, 0.6798550586f, 0, -0.9511914166f, 0.0753623979f, -0.2992580792f, 0, 0.2539470961f, -0.1886339355f, 0.9486454084f, 0, 0.571433621f, -0.1679450851f, -0.8032795685f, 0, -0.06778234979f, 0.3978269256f, 0.9149531629f, 0, 0.6074972649f, 0.733060024f, -0.3058922593f, 0, -0.5435478392f, 0.1675822484f, 0.8224791405f, 0, + -0.5876678086f, -0.3380045064f, -0.7351186982f, 0, -0.7967562402f, 0.04097822706f, -0.6029098428f, 0, -0.1996350917f, 0.8706294745f, 0.4496111079f, 0, -0.02787660336f, -0.9106232682f, -0.4122962022f, 0, -0.7797625996f, -0.6257634692f, 0.01975775581f, 0, -0.5211232846f, 0.7401644346f, -0.4249554471f, 0, 0.8575424857f, 0.4053272873f, -0.3167501783f, 0, 0.1045223322f, 0.8390195772f, -0.5339674439f, 0, + 0.3501822831f, 0.9242524096f, -0.1520850155f, 0, 0.1987849858f, 0.07647613266f, 0.9770547224f, 0, 0.7845996363f, 0.6066256811f, -0.1280964233f, 0, 0.09006737436f, -0.9750989929f, -0.2026569073f, 0, -0.8274343547f, -0.542299559f, 0.1458203587f, 0, -0.3485797732f, -0.415802277f, 0.840000362f, 0, -0.2471778936f, -0.7304819962f, -0.6366310879f, 0, -0.3700154943f, 0.8577948156f, 0.3567584454f, 0, + 0.5913394901f, -0.548311967f, -0.5913303597f, 0, 0.1204873514f, -0.7626472379f, -0.6354935001f, 0, 0.616959265f, 0.03079647928f, 0.7863922953f, 0, 0.1258156836f, -0.6640829889f, -0.7369967419f, 0, -0.6477565124f, -0.1740147258f, -0.7417077429f, 0, 0.6217889313f, -0.7804430448f, -0.06547655076f, 0, 0.6589943422f, -0.6096987708f, 0.4404473475f, 0, -0.2689837504f, -0.6732403169f, -0.6887635427f, 0, + -0.3849775103f, 0.5676542638f, 0.7277093879f, 0, 0.5754444408f, 0.8110471154f, -0.1051963504f, 0, 0.9141593684f, 0.3832947817f, 0.131900567f, 0, -0.107925319f, 0.9245493968f, 0.3654593525f, 0, 0.377977089f, 0.3043148782f, 0.8743716458f, 0, -0.2142885215f, -0.8259286236f, 0.5214617324f, 0, 0.5802544474f, 0.4148098596f, -0.7008834116f, 0, -0.1982660881f, 0.8567161266f, -0.4761596756f, 0, + -0.03381553704f, 0.3773180787f, -0.9254661404f, 0, -0.6867922841f, -0.6656597827f, 0.2919133642f, 0, 0.7731742607f, -0.2875793547f, -0.5652430251f, 0, -0.09655941928f, 0.9193708367f, -0.3813575004f, 0, 0.2715702457f, -0.9577909544f, -0.09426605581f, 0, 0.2451015704f, -0.6917998565f, -0.6792188003f, 0, 0.977700782f, -0.1753855374f, 0.1155036542f, 0, -0.5224739938f, 0.8521606816f, 0.02903615945f, 0, + -0.7734880599f, -0.5261292347f, 0.3534179531f, 0, -0.7134492443f, -0.269547243f, 0.6467878011f, 0, 0.1644037271f, 0.5105846203f, -0.8439637196f, 0, 0.6494635788f, 0.05585611296f, 0.7583384168f, 0, -0.4711970882f, 0.5017280509f, -0.7254255765f, 0, -0.6335764307f, -0.2381686273f, -0.7361091029f, 0, -0.9021533097f, -0.270947803f, -0.3357181763f, 0, -0.3793711033f, 0.872258117f, 0.3086152025f, 0, + -0.6855598966f, -0.3250143309f, 0.6514394162f, 0, 0.2900942212f, -0.7799057743f, -0.5546100667f, 0, -0.2098319339f, 0.85037073f, 0.4825351604f, 0, -0.4592603758f, 0.6598504336f, -0.5947077538f, 0, 0.8715945488f, 0.09616365406f, -0.4807031248f, 0, -0.6776666319f, 0.7118504878f, -0.1844907016f, 0, 0.7044377633f, 0.312427597f, 0.637304036f, 0, -0.7052318886f, -0.2401093292f, -0.6670798253f, 0, + 0.081921007f, -0.7207336136f, -0.6883545647f, 0, -0.6993680906f, -0.5875763221f, -0.4069869034f, 0, -0.1281454481f, 0.6419895885f, 0.7559286424f, 0, -0.6337388239f, -0.6785471501f, -0.3714146849f, 0, 0.5565051903f, -0.2168887573f, -0.8020356851f, 0, -0.5791554484f, 0.7244372011f, -0.3738578718f, 0, 0.1175779076f, -0.7096451073f, 0.6946792478f, 0, -0.6134619607f, 0.1323631078f, 0.7785527795f, 0, + 0.6984635305f, -0.02980516237f, -0.715024719f, 0, 0.8318082963f, -0.3930171956f, 0.3919597455f, 0, 0.1469576422f, 0.05541651717f, -0.9875892167f, 0, 0.708868575f, -0.2690503865f, 0.6520101478f, 0, 0.2726053183f, 0.67369766f, -0.68688995f, 0, -0.6591295371f, 0.3035458599f, -0.6880466294f, 0, 0.4815131379f, -0.7528270071f, 0.4487723203f, 0, 0.9430009463f, 0.1675647412f, -0.2875261255f, 0, + 0.434802957f, 0.7695304522f, -0.4677277752f, 0, 0.3931996188f, 0.594473625f, 0.7014236729f, 0, 0.7254336655f, -0.603925654f, 0.3301814672f, 0, 0.7590235227f, -0.6506083235f, 0.02433313207f, 0, -0.8552768592f, -0.3430042733f, 0.3883935666f, 0, -0.6139746835f, 0.6981725247f, 0.3682257648f, 0, -0.7465905486f, -0.5752009504f, 0.3342849376f, 0, 0.5730065677f, 0.810555537f, -0.1210916791f, 0, + -0.9225877367f, -0.3475211012f, -0.167514036f, 0, -0.7105816789f, -0.4719692027f, -0.5218416899f, 0, -0.08564609717f, 0.3583001386f, 0.929669703f, 0, -0.8279697606f, -0.2043157126f, 0.5222271202f, 0, 0.427944023f, 0.278165994f, 0.8599346446f, 0, 0.5399079671f, -0.7857120652f, -0.3019204161f, 0, 0.5678404253f, -0.5495413974f, -0.6128307303f, 0, -0.9896071041f, 0.1365639107f, -0.04503418428f, 0, + -0.6154342638f, -0.6440875597f, 0.4543037336f, 0, 0.1074204368f, -0.7946340692f, 0.5975094525f, 0, -0.3595449969f, -0.8885529948f, 0.28495784f, 0, -0.2180405296f, 0.1529888965f, 0.9638738118f, 0, -0.7277432317f, -0.6164050508f, -0.3007234646f, 0, 0.7249729114f, -0.00669719484f, 0.6887448187f, 0, -0.5553659455f, -0.5336586252f, 0.6377908264f, 0, 0.5137558015f, 0.7976208196f, -0.3160000073f, 0, + -0.3794024848f, 0.9245608561f, -0.03522751494f, 0, 0.8229248658f, 0.2745365933f, -0.4974176556f, 0, -0.5404114394f, 0.6091141441f, 0.5804613989f, 0, 0.8036581901f, -0.2703029469f, 0.5301601931f, 0, 0.6044318879f, 0.6832968393f, 0.4095943388f, 0, 0.06389988817f, 0.9658208605f, -0.2512108074f, 0, 0.1087113286f, 0.7402471173f, -0.6634877936f, 0, -0.713427712f, -0.6926784018f, 0.1059128479f, 0, + 0.6458897819f, -0.5724548511f, -0.5050958653f, 0, -0.6553931414f, 0.7381471625f, 0.159995615f, 0, 0.3910961323f, 0.9188871375f, -0.05186755998f, 0, -0.4879022471f, -0.5904376907f, 0.6429111375f, 0, 0.6014790094f, 0.7707441366f, -0.2101820095f, 0, -0.5677173047f, 0.7511360995f, 0.3368851762f, 0, 0.7858573506f, 0.226674665f, 0.5753666838f, 0, -0.4520345543f, -0.604222686f, -0.6561857263f, 0, + 0.002272116345f, 0.4132844051f, -0.9105991643f, 0, -0.5815751419f, -0.5162925989f, 0.6286591339f, 0, -0.03703704785f, 0.8273785755f, 0.5604221175f, 0, -0.5119692504f, 0.7953543429f, -0.3244980058f, 0, -0.2682417366f, -0.9572290247f, -0.1084387619f, 0, -0.2322482736f, -0.9679131102f, -0.09594243324f, 0, 0.3554328906f, -0.8881505545f, 0.2913006227f, 0, 0.7346520519f, -0.4371373164f, 0.5188422971f, 0, + 0.9985120116f, 0.04659011161f, -0.02833944577f, 0, -0.3727687496f, -0.9082481361f, 0.1900757285f, 0, 0.91737377f, -0.3483642108f, 0.1925298489f, 0, 0.2714911074f, 0.4147529736f, -0.8684886582f, 0, 0.5131763485f, -0.7116334161f, 0.4798207128f, 0, -0.8737353606f, 0.18886992f, -0.4482350644f, 0, 0.8460043821f, -0.3725217914f, 0.3814499973f, 0, 0.8978727456f, -0.1780209141f, -0.4026575304f, 0, + 0.2178065647f, -0.9698322841f, -0.1094789531f, 0, -0.1518031304f, -0.7788918132f, -0.6085091231f, 0, -0.2600384876f, -0.4755398075f, -0.8403819825f, 0, 0.572313509f, -0.7474340931f, -0.3373418503f, 0, -0.7174141009f, 0.1699017182f, -0.6756111411f, 0, -0.684180784f, 0.02145707593f, -0.7289967412f, 0, -0.2007447902f, 0.06555605789f, -0.9774476623f, 0, -0.1148803697f, -0.8044887315f, 0.5827524187f, 0, + -0.7870349638f, 0.03447489231f, 0.6159443543f, 0, -0.2015596421f, 0.6859872284f, 0.6991389226f, 0, -0.08581082512f, -0.10920836f, -0.9903080513f, 0, 0.5532693395f, 0.7325250401f, -0.396610771f, 0, -0.1842489331f, -0.9777375055f, -0.1004076743f, 0, 0.0775473789f, -0.9111505856f, 0.4047110257f, 0, 0.1399838409f, 0.7601631212f, -0.6344734459f, 0, 0.4484419361f, -0.845289248f, 0.2904925424f, 0 + }; + + + private static float FastMin(float a, float b) { return a < b ? a : b; } + + private static float FastMax(float a, float b) { return a > b ? a : b; } + + private static float FastAbs(float f) { return f < 0 ? -f : f; } + + private static float FastSqrt(float f) { return (float)Math.sqrt(f); } + + private static int FastFloor(/*FNLfloat*/ float f) { return f >= 0 ? (int)f : (int)f - 1; } + + private static int FastRound(/*FNLfloat*/ float f) { return f >= 0 ? (int)(f + 0.5f) : (int)(f - 0.5f); } + + private static float Lerp(float a, float b, float t) { return a + t * (b - a); } + + private static float InterpHermite(float t) { return t * t * (3 - 2 * t); } + + private static float InterpQuintic(float t) { return t * t * t * (t * (t * 6 - 15) + 10); } + + private static float CubicLerp(float a, float b, float c, float d, float t) + { + float p = (d - c) - (a - b); + return t * t * t * p + t * t * ((a - b) - p) + t * (c - a) + b; + } + + private static float PingPong(float t) + { + t -= (int)(t * 0.5f) * 2; + return t < 1 ? t : 2 - t; + } + + private void CalculateFractalBounding() + { + float gain = FastAbs(mGain); + float amp = gain; + float ampFractal = 1.0f; + for (int i = 1; i < mOctaves; i++) + { + ampFractal += amp; + amp *= gain; + } + mFractalBounding = 1 / ampFractal; + } + + // Hashing + private static final int PrimeX = 501125321; + private static final int PrimeY = 1136930381; + private static final int PrimeZ = 1720413743; + + private static int Hash(int seed, int xPrimed, int yPrimed) + { + int hash = seed ^ xPrimed ^ yPrimed; + + hash *= 0x27d4eb2d; + return hash; + } + + private static int Hash(int seed, int xPrimed, int yPrimed, int zPrimed) + { + int hash = seed ^ xPrimed ^ yPrimed ^ zPrimed; + + hash *= 0x27d4eb2d; + return hash; + } + + private static float ValCoord(int seed, int xPrimed, int yPrimed) + { + int hash = Hash(seed, xPrimed, yPrimed); + + hash *= hash; + hash ^= hash << 19; + return hash * (1 / 2147483648.0f); + } + + private static float ValCoord(int seed, int xPrimed, int yPrimed, int zPrimed) + { + int hash = Hash(seed, xPrimed, yPrimed, zPrimed); + + hash *= hash; + hash ^= hash << 19; + return hash * (1 / 2147483648.0f); + } + + private static float GradCoord(int seed, int xPrimed, int yPrimed, float xd, float yd) + { + int hash = Hash(seed, xPrimed, yPrimed); + hash ^= hash >> 15; + hash &= 127 << 1; + + float xg = Gradients2D[hash]; + float yg = Gradients2D[hash | 1]; + + return xd * xg + yd * yg; + } + + private static float GradCoord(int seed, int xPrimed, int yPrimed, int zPrimed, float xd, float yd, float zd) + { + int hash = Hash(seed, xPrimed, yPrimed, zPrimed); + hash ^= hash >> 15; + hash &= 63 << 2; + + float xg = Gradients3D[hash]; + float yg = Gradients3D[hash | 1]; + float zg = Gradients3D[hash | 2]; + + return xd * xg + yd * yg + zd * zg; + } + + + // Generic noise gen + + private float GenNoiseSingle(int seed, /*FNLfloat*/ float x, /*FNLfloat*/ float y) + { + switch (mNoiseType) + { + case OpenSimplex2: + return SingleSimplex(seed, x, y); + case OpenSimplex2S: + return SingleOpenSimplex2S(seed, x, y); + case Cellular: + return SingleCellular(seed, x, y); + case Perlin: + return SinglePerlin(seed, x, y); + case ValueCubic: + return SingleValueCubic(seed, x, y); + case Value: + return SingleValue(seed, x, y); + default: + return 0; + } + } + + private float GenNoiseSingle(int seed, /*FNLfloat*/ float x, /*FNLfloat*/ float y, /*FNLfloat*/ float z) + { + switch (mNoiseType) + { + case OpenSimplex2: + return SingleOpenSimplex2(seed, x, y, z); + case OpenSimplex2S: + return SingleOpenSimplex2S(seed, x, y, z); + case Cellular: + return SingleCellular(seed, x, y, z); + case Perlin: + return SinglePerlin(seed, x, y, z); + case ValueCubic: + return SingleValueCubic(seed, x, y, z); + case Value: + return SingleValue(seed, x, y, z); + default: + return 0; + } + } + + + // Noise Coordinate Transforms (frequency, and possible skew or rotation) + + private void UpdateTransformType3D() + { + switch (mRotationType3D) + { + case ImproveXYPlanes: + mTransformType3D = TransformType3D.ImproveXYPlanes; + break; + case ImproveXZPlanes: + mTransformType3D = TransformType3D.ImproveXZPlanes; + break; + default: + switch (mNoiseType) + { + case OpenSimplex2: + case OpenSimplex2S: + mTransformType3D = TransformType3D.DefaultOpenSimplex2; + break; + default: + mTransformType3D = TransformType3D.None; + break; + } + break; + } + } + + private void UpdateWarpTransformType3D() + { + switch (mRotationType3D) + { + case ImproveXYPlanes: + mWarpTransformType3D = TransformType3D.ImproveXYPlanes; + break; + case ImproveXZPlanes: + mWarpTransformType3D = TransformType3D.ImproveXZPlanes; + break; + default: + switch (mDomainWarpType) + { + case OpenSimplex2: + case OpenSimplex2Reduced: + mWarpTransformType3D = TransformType3D.DefaultOpenSimplex2; + break; + default: + mWarpTransformType3D = TransformType3D.None; + break; + } + break; + } + } + + + // Fractal FBm + + private float GenFractalFBm(/*FNLfloat*/ float x, /*FNLfloat*/ float y) + { + int seed = mSeed; + float sum = 0; + float amp = mFractalBounding; + + for (int i = 0; i < mOctaves; i++) + { + float noise = GenNoiseSingle(seed++, x, y); + sum += noise * amp; + amp *= Lerp(1.0f, FastMin(noise + 1, 2) * 0.5f, mWeightedStrength); + + x *= mLacunarity; + y *= mLacunarity; + amp *= mGain; + } + + return sum; + } + + private float GenFractalFBm(/*FNLfloat*/ float x, /*FNLfloat*/ float y, /*FNLfloat*/ float z) + { + int seed = mSeed; + float sum = 0; + float amp = mFractalBounding; + + for (int i = 0; i < mOctaves; i++) + { + float noise = GenNoiseSingle(seed++, x, y, z); + sum += noise * amp; + amp *= Lerp(1.0f, (noise + 1) * 0.5f, mWeightedStrength); + + x *= mLacunarity; + y *= mLacunarity; + z *= mLacunarity; + amp *= mGain; + } + + return sum; + } + + + // Fractal Ridged + + private float GenFractalRidged(/*FNLfloat*/ float x, /*FNLfloat*/ float y) + { + int seed = mSeed; + float sum = 0; + float amp = mFractalBounding; + + for (int i = 0; i < mOctaves; i++) + { + float noise = FastAbs(GenNoiseSingle(seed++, x, y)); + sum += (noise * -2 + 1) * amp; + amp *= Lerp(1.0f, 1 - noise, mWeightedStrength); + + x *= mLacunarity; + y *= mLacunarity; + amp *= mGain; + } + + return sum; + } + + private float GenFractalRidged(/*FNLfloat*/ float x, /*FNLfloat*/ float y, /*FNLfloat*/ float z) + { + int seed = mSeed; + float sum = 0; + float amp = mFractalBounding; + + for (int i = 0; i < mOctaves; i++) + { + float noise = FastAbs(GenNoiseSingle(seed++, x, y, z)); + sum += (noise * -2 + 1) * amp; + amp *= Lerp(1.0f, 1 - noise, mWeightedStrength); + + x *= mLacunarity; + y *= mLacunarity; + z *= mLacunarity; + amp *= mGain; + } + + return sum; + } + + + // Fractal PingPong + + private float GenFractalPingPong(/*FNLfloat*/ float x, /*FNLfloat*/ float y) + { + int seed = mSeed; + float sum = 0; + float amp = mFractalBounding; + + for (int i = 0; i < mOctaves; i++) + { + float noise = PingPong((GenNoiseSingle(seed++, x, y) + 1) * mPingPongStrength); + sum += (noise - 0.5f) * 2 * amp; + amp *= Lerp(1.0f, noise, mWeightedStrength); + + x *= mLacunarity; + y *= mLacunarity; + amp *= mGain; + } + + return sum; + } + + private float GenFractalPingPong(/*FNLfloat*/ float x, /*FNLfloat*/ float y, /*FNLfloat*/ float z) + { + int seed = mSeed; + float sum = 0; + float amp = mFractalBounding; + + for (int i = 0; i < mOctaves; i++) + { + float noise = PingPong((GenNoiseSingle(seed++, x, y, z) + 1) * mPingPongStrength); + sum += (noise - 0.5f) * 2 * amp; + amp *= Lerp(1.0f, noise, mWeightedStrength); + + x *= mLacunarity; + y *= mLacunarity; + z *= mLacunarity; + amp *= mGain; + } + + return sum; + } + + + // Simplex/OpenSimplex2 Noise + + private float SingleSimplex(int seed, /*FNLfloat*/ float x, /*FNLfloat*/ float y) + { + // 2D OpenSimplex2 case uses the same algorithm as ordinary Simplex. + + final float SQRT3 = 1.7320508075688772935274463415059f; + final float G2 = (3 - SQRT3) / 6; + + /* + * --- Skew moved to switch statements before fractal evaluation --- + * final FNLfloat F2 = 0.5f * (SQRT3 - 1); + * FNLfloat s = (x + y) * F2; + * x += s; y += s; + */ + + int i = FastFloor(x); + int j = FastFloor(y); + float xi = (float)(x - i); + float yi = (float)(y - j); + + float t = (xi + yi) * G2; + float x0 = (float)(xi - t); + float y0 = (float)(yi - t); + + i *= PrimeX; + j *= PrimeY; + + float n0, n1, n2; + + float a = 0.5f - x0 * x0 - y0 * y0; + if (a <= 0) n0 = 0; + else + { + n0 = (a * a) * (a * a) * GradCoord(seed, i, j, x0, y0); + } + + float c = (float)(2 * (1 - 2 * G2) * (1 / G2 - 2)) * t + ((float)(-2 * (1 - 2 * G2) * (1 - 2 * G2)) + a); + if (c <= 0) n2 = 0; + else + { + float x2 = x0 + (2 * (float)G2 - 1); + float y2 = y0 + (2 * (float)G2 - 1); + n2 = (c * c) * (c * c) * GradCoord(seed, i + PrimeX, j + PrimeY, x2, y2); + } + + if (y0 > x0) + { + float x1 = x0 + (float)G2; + float y1 = y0 + ((float)G2 - 1); + float b = 0.5f - x1 * x1 - y1 * y1; + if (b <= 0) n1 = 0; + else + { + n1 = (b * b) * (b * b) * GradCoord(seed, i, j + PrimeY, x1, y1); + } + } + else + { + float x1 = x0 + ((float)G2 - 1); + float y1 = y0 + (float)G2; + float b = 0.5f - x1 * x1 - y1 * y1; + if (b <= 0) n1 = 0; + else + { + n1 = (b * b) * (b * b) * GradCoord(seed, i + PrimeX, j, x1, y1); + } + } + + return (n0 + n1 + n2) * 99.83685446303647f; + } + + private float SingleOpenSimplex2(int seed, /*FNLfloat*/ float x, /*FNLfloat*/ float y, /*FNLfloat*/ float z) + { + // 3D OpenSimplex2 case uses two offset rotated cube grids. + + /* + * --- Rotation moved to switch statements before fractal evaluation --- + * final FNLfloat R3 = (FNLfloat)(2.0 / 3.0); + * FNLfloat r = (x + y + z) * R3; // Rotation, not skew + * x = r - x; y = r - y; z = r - z; + */ + + int i = FastRound(x); + int j = FastRound(y); + int k = FastRound(z); + float x0 = (float)(x - i); + float y0 = (float)(y - j); + float z0 = (float)(z - k); + + int xNSign = (int)(-1.0f - x0) | 1; + int yNSign = (int)(-1.0f - y0) | 1; + int zNSign = (int)(-1.0f - z0) | 1; + + float ax0 = xNSign * -x0; + float ay0 = yNSign * -y0; + float az0 = zNSign * -z0; + + i *= PrimeX; + j *= PrimeY; + k *= PrimeZ; + + float value = 0; + float a = (0.6f - x0 * x0) - (y0 * y0 + z0 * z0); + + for (int l = 0; ; l++) + { + if (a > 0) + { + value += (a * a) * (a * a) * GradCoord(seed, i, j, k, x0, y0, z0); + } + + if (ax0 >= ay0 && ax0 >= az0) + { + float b = a + ax0 + ax0; + if (b > 1) + { + b -= 1; + value += (b * b) * (b * b) * GradCoord(seed, i - xNSign * PrimeX, j, k, x0 + xNSign, y0, z0); + } + } + else if (ay0 > ax0 && ay0 >= az0) + { + float b = a + ay0 + ay0; + if (b > 1) + { + b -= 1; + value += (b * b) * (b * b) * GradCoord(seed, i, j - yNSign * PrimeY, k, x0, y0 + yNSign, z0); + } + } + else + { + float b = a + az0 + az0; + if (b > 1) + { + b -= 1; + value += (b * b) * (b * b) * GradCoord(seed, i, j, k - zNSign * PrimeZ, x0, y0, z0 + zNSign); + } + } + + if (l == 1) break; + + ax0 = 0.5f - ax0; + ay0 = 0.5f - ay0; + az0 = 0.5f - az0; + + x0 = xNSign * ax0; + y0 = yNSign * ay0; + z0 = zNSign * az0; + + a += (0.75f - ax0) - (ay0 + az0); + + i += (xNSign >> 1) & PrimeX; + j += (yNSign >> 1) & PrimeY; + k += (zNSign >> 1) & PrimeZ; + + xNSign = -xNSign; + yNSign = -yNSign; + zNSign = -zNSign; + + seed = ~seed; + } + + return value * 32.69428253173828125f; + } + + + // OpenSimplex2S Noise + + private float SingleOpenSimplex2S(int seed, /*FNLfloat*/ float x, /*FNLfloat*/ float y) + { + // 2D OpenSimplex2S case is a modified 2D simplex noise. + + final /*FNLfloat*/ float SQRT3 = (/*FNLfloat*/ float)1.7320508075688772935274463415059; + final /*FNLfloat*/ float G2 = (3 - SQRT3) / 6; + + /* + * --- Skew moved to TransformNoiseCoordinate method --- + * final FNLfloat F2 = 0.5f * (SQRT3 - 1); + * FNLfloat s = (x + y) * F2; + * x += s; y += s; + */ + + int i = FastFloor(x); + int j = FastFloor(y); + float xi = (float)(x - i); + float yi = (float)(y - j); + + i *= PrimeX; + j *= PrimeY; + int i1 = i + PrimeX; + int j1 = j + PrimeY; + + float t = (xi + yi) * (float)G2; + float x0 = xi - t; + float y0 = yi - t; + + float a0 = (2.0f / 3.0f) - x0 * x0 - y0 * y0; + float value = (a0 * a0) * (a0 * a0) * GradCoord(seed, i, j, x0, y0); + + float a1 = (float)(2 * (1 - 2 * G2) * (1 / G2 - 2)) * t + ((float)(-2 * (1 - 2 * G2) * (1 - 2 * G2)) + a0); + float x1 = x0 - (float)(1 - 2 * G2); + float y1 = y0 - (float)(1 - 2 * G2); + value += (a1 * a1) * (a1 * a1) * GradCoord(seed, i1, j1, x1, y1); + + // Nested conditionals were faster than compact bit logic/arithmetic. + float xmyi = xi - yi; + if (t > G2) + { + if (xi + xmyi > 1) + { + float x2 = x0 + (float)(3 * G2 - 2); + float y2 = y0 + (float)(3 * G2 - 1); + float a2 = (2.0f / 3.0f) - x2 * x2 - y2 * y2; + if (a2 > 0) + { + value += (a2 * a2) * (a2 * a2) * GradCoord(seed, i + (PrimeX << 1), j + PrimeY, x2, y2); + } + } + else + { + float x2 = x0 + (float)G2; + float y2 = y0 + (float)(G2 - 1); + float a2 = (2.0f / 3.0f) - x2 * x2 - y2 * y2; + if (a2 > 0) + { + value += (a2 * a2) * (a2 * a2) * GradCoord(seed, i, j + PrimeY, x2, y2); + } + } + + if (yi - xmyi > 1) + { + float x3 = x0 + (float)(3 * G2 - 1); + float y3 = y0 + (float)(3 * G2 - 2); + float a3 = (2.0f / 3.0f) - x3 * x3 - y3 * y3; + if (a3 > 0) + { + value += (a3 * a3) * (a3 * a3) * GradCoord(seed, i + PrimeX, j + (PrimeY << 1), x3, y3); + } + } + else + { + float x3 = x0 + (float)(G2 - 1); + float y3 = y0 + (float)G2; + float a3 = (2.0f / 3.0f) - x3 * x3 - y3 * y3; + if (a3 > 0) + { + value += (a3 * a3) * (a3 * a3) * GradCoord(seed, i + PrimeX, j, x3, y3); + } + } + } + else + { + if (xi + xmyi < 0) + { + float x2 = x0 + (float)(1 - G2); + float y2 = y0 - (float)G2; + float a2 = (2.0f / 3.0f) - x2 * x2 - y2 * y2; + if (a2 > 0) + { + value += (a2 * a2) * (a2 * a2) * GradCoord(seed, i - PrimeX, j, x2, y2); + } + } + else + { + float x2 = x0 + (float)(G2 - 1); + float y2 = y0 + (float)G2; + float a2 = (2.0f / 3.0f) - x2 * x2 - y2 * y2; + if (a2 > 0) + { + value += (a2 * a2) * (a2 * a2) * GradCoord(seed, i + PrimeX, j, x2, y2); + } + } + + if (yi < xmyi) + { + float x2 = x0 - (float)G2; + float y2 = y0 - (float)(G2 - 1); + float a2 = (2.0f / 3.0f) - x2 * x2 - y2 * y2; + if (a2 > 0) + { + value += (a2 * a2) * (a2 * a2) * GradCoord(seed, i, j - PrimeY, x2, y2); + } + } + else + { + float x2 = x0 + (float)G2; + float y2 = y0 + (float)(G2 - 1); + float a2 = (2.0f / 3.0f) - x2 * x2 - y2 * y2; + if (a2 > 0) + { + value += (a2 * a2) * (a2 * a2) * GradCoord(seed, i, j + PrimeY, x2, y2); + } + } + } + + return value * 18.24196194486065f; + } + + private float SingleOpenSimplex2S(int seed, /*FNLfloat*/ float x, /*FNLfloat*/ float y, /*FNLfloat*/ float z) + { + // 3D OpenSimplex2S case uses two offset rotated cube grids. + + /* + * --- Rotation moved to TransformNoiseCoordinate method --- + * final FNLfloat R3 = (FNLfloat)(2.0 / 3.0); + * FNLfloat r = (x + y + z) * R3; // Rotation, not skew + * x = r - x; y = r - y; z = r - z; + */ + + int i = FastFloor(x); + int j = FastFloor(y); + int k = FastFloor(z); + float xi = (float)(x - i); + float yi = (float)(y - j); + float zi = (float)(z - k); + + i *= PrimeX; + j *= PrimeY; + k *= PrimeZ; + int seed2 = seed + 1293373; + + int xNMask = (int)(-0.5f - xi); + int yNMask = (int)(-0.5f - yi); + int zNMask = (int)(-0.5f - zi); + + float x0 = xi + xNMask; + float y0 = yi + yNMask; + float z0 = zi + zNMask; + float a0 = 0.75f - x0 * x0 - y0 * y0 - z0 * z0; + float value = (a0 * a0) * (a0 * a0) * GradCoord(seed, + i + (xNMask & PrimeX), j + (yNMask & PrimeY), k + (zNMask & PrimeZ), x0, y0, z0); + + float x1 = xi - 0.5f; + float y1 = yi - 0.5f; + float z1 = zi - 0.5f; + float a1 = 0.75f - x1 * x1 - y1 * y1 - z1 * z1; + value += (a1 * a1) * (a1 * a1) * GradCoord(seed2, + i + PrimeX, j + PrimeY, k + PrimeZ, x1, y1, z1); + + float xAFlipMask0 = ((xNMask | 1) << 1) * x1; + float yAFlipMask0 = ((yNMask | 1) << 1) * y1; + float zAFlipMask0 = ((zNMask | 1) << 1) * z1; + float xAFlipMask1 = (-2 - (xNMask << 2)) * x1 - 1.0f; + float yAFlipMask1 = (-2 - (yNMask << 2)) * y1 - 1.0f; + float zAFlipMask1 = (-2 - (zNMask << 2)) * z1 - 1.0f; + + boolean skip5 = false; + float a2 = xAFlipMask0 + a0; + if (a2 > 0) + { + float x2 = x0 - (xNMask | 1); + float y2 = y0; + float z2 = z0; + value += (a2 * a2) * (a2 * a2) * GradCoord(seed, + i + (~xNMask & PrimeX), j + (yNMask & PrimeY), k + (zNMask & PrimeZ), x2, y2, z2); + } + else + { + float a3 = yAFlipMask0 + zAFlipMask0 + a0; + if (a3 > 0) + { + float x3 = x0; + float y3 = y0 - (yNMask | 1); + float z3 = z0 - (zNMask | 1); + value += (a3 * a3) * (a3 * a3) * GradCoord(seed, + i + (xNMask & PrimeX), j + (~yNMask & PrimeY), k + (~zNMask & PrimeZ), x3, y3, z3); + } + + float a4 = xAFlipMask1 + a1; + if (a4 > 0) + { + float x4 = (xNMask | 1) + x1; + float y4 = y1; + float z4 = z1; + value += (a4 * a4) * (a4 * a4) * GradCoord(seed2, + i + (xNMask & (PrimeX * 2)), j + PrimeY, k + PrimeZ, x4, y4, z4); + skip5 = true; + } + } + + boolean skip9 = false; + float a6 = yAFlipMask0 + a0; + if (a6 > 0) + { + float x6 = x0; + float y6 = y0 - (yNMask | 1); + float z6 = z0; + value += (a6 * a6) * (a6 * a6) * GradCoord(seed, + i + (xNMask & PrimeX), j + (~yNMask & PrimeY), k + (zNMask & PrimeZ), x6, y6, z6); + } + else + { + float a7 = xAFlipMask0 + zAFlipMask0 + a0; + if (a7 > 0) + { + float x7 = x0 - (xNMask | 1); + float y7 = y0; + float z7 = z0 - (zNMask | 1); + value += (a7 * a7) * (a7 * a7) * GradCoord(seed, + i + (~xNMask & PrimeX), j + (yNMask & PrimeY), k + (~zNMask & PrimeZ), x7, y7, z7); + } + + float a8 = yAFlipMask1 + a1; + if (a8 > 0) + { + float x8 = x1; + float y8 = (yNMask | 1) + y1; + float z8 = z1; + value += (a8 * a8) * (a8 * a8) * GradCoord(seed2, + i + PrimeX, j + (yNMask & (PrimeY << 1)), k + PrimeZ, x8, y8, z8); + skip9 = true; + } + } + + boolean skipD = false; + float aA = zAFlipMask0 + a0; + if (aA > 0) + { + float xA = x0; + float yA = y0; + float zA = z0 - (zNMask | 1); + value += (aA * aA) * (aA * aA) * GradCoord(seed, + i + (xNMask & PrimeX), j + (yNMask & PrimeY), k + (~zNMask & PrimeZ), xA, yA, zA); + } + else + { + float aB = xAFlipMask0 + yAFlipMask0 + a0; + if (aB > 0) + { + float xB = x0 - (xNMask | 1); + float yB = y0 - (yNMask | 1); + float zB = z0; + value += (aB * aB) * (aB * aB) * GradCoord(seed, + i + (~xNMask & PrimeX), j + (~yNMask & PrimeY), k + (zNMask & PrimeZ), xB, yB, zB); + } + + float aC = zAFlipMask1 + a1; + if (aC > 0) + { + float xC = x1; + float yC = y1; + float zC = (zNMask | 1) + z1; + value += (aC * aC) * (aC * aC) * GradCoord(seed2, + i + PrimeX, j + PrimeY, k + (zNMask & (PrimeZ << 1)), xC, yC, zC); + skipD = true; + } + } + + if (!skip5) + { + float a5 = yAFlipMask1 + zAFlipMask1 + a1; + if (a5 > 0) + { + float x5 = x1; + float y5 = (yNMask | 1) + y1; + float z5 = (zNMask | 1) + z1; + value += (a5 * a5) * (a5 * a5) * GradCoord(seed2, + i + PrimeX, j + (yNMask & (PrimeY << 1)), k + (zNMask & (PrimeZ << 1)), x5, y5, z5); + } + } + + if (!skip9) + { + float a9 = xAFlipMask1 + zAFlipMask1 + a1; + if (a9 > 0) + { + float x9 = (xNMask | 1) + x1; + float y9 = y1; + float z9 = (zNMask | 1) + z1; + value += (a9 * a9) * (a9 * a9) * GradCoord(seed2, + i + (xNMask & (PrimeX * 2)), j + PrimeY, k + (zNMask & (PrimeZ << 1)), x9, y9, z9); + } + } + + if (!skipD) + { + float aD = xAFlipMask1 + yAFlipMask1 + a1; + if (aD > 0) + { + float xD = (xNMask | 1) + x1; + float yD = (yNMask | 1) + y1; + float zD = z1; + value += (aD * aD) * (aD * aD) * GradCoord(seed2, + i + (xNMask & (PrimeX << 1)), j + (yNMask & (PrimeY << 1)), k + PrimeZ, xD, yD, zD); + } + } + + return value * 9.046026385208288f; + } + + + // Cellular Noise + + private float SingleCellular(int seed, /*FNLfloat*/ float x, /*FNLfloat*/ float y) + { + int xr = FastRound(x); + int yr = FastRound(y); + + float distance0 = Float.MAX_VALUE; + float distance1 = Float.MAX_VALUE; + int closestHash = 0; + + float cellularJitter = 0.43701595f * mCellularJitterModifier; + + int xPrimed = (xr - 1) * PrimeX; + int yPrimedBase = (yr - 1) * PrimeY; + + switch (mCellularDistanceFunction) + { + default: + case Euclidean: + case EuclideanSq: + for (int xi = xr - 1; xi <= xr + 1; xi++) + { + int yPrimed = yPrimedBase; + + for (int yi = yr - 1; yi <= yr + 1; yi++) + { + int hash = Hash(seed, xPrimed, yPrimed); + int idx = hash & (255 << 1); + + float vecX = (float)(xi - x) + RandVecs2D[idx] * cellularJitter; + float vecY = (float)(yi - y) + RandVecs2D[idx | 1] * cellularJitter; + + float newDistance = vecX * vecX + vecY * vecY; + + distance1 = FastMax(FastMin(distance1, newDistance), distance0); + if (newDistance < distance0) + { + distance0 = newDistance; + closestHash = hash; + } + yPrimed += PrimeY; + } + xPrimed += PrimeX; + } + break; + case Manhattan: + for (int xi = xr - 1; xi <= xr + 1; xi++) + { + int yPrimed = yPrimedBase; + + for (int yi = yr - 1; yi <= yr + 1; yi++) + { + int hash = Hash(seed, xPrimed, yPrimed); + int idx = hash & (255 << 1); + + float vecX = (float)(xi - x) + RandVecs2D[idx] * cellularJitter; + float vecY = (float)(yi - y) + RandVecs2D[idx | 1] * cellularJitter; + + float newDistance = FastAbs(vecX) + FastAbs(vecY); + + distance1 = FastMax(FastMin(distance1, newDistance), distance0); + if (newDistance < distance0) + { + distance0 = newDistance; + closestHash = hash; + } + yPrimed += PrimeY; + } + xPrimed += PrimeX; + } + break; + case Hybrid: + for (int xi = xr - 1; xi <= xr + 1; xi++) + { + int yPrimed = yPrimedBase; + + for (int yi = yr - 1; yi <= yr + 1; yi++) + { + int hash = Hash(seed, xPrimed, yPrimed); + int idx = hash & (255 << 1); + + float vecX = (float)(xi - x) + RandVecs2D[idx] * cellularJitter; + float vecY = (float)(yi - y) + RandVecs2D[idx | 1] * cellularJitter; + + float newDistance = (FastAbs(vecX) + FastAbs(vecY)) + (vecX * vecX + vecY * vecY); + + distance1 = FastMax(FastMin(distance1, newDistance), distance0); + if (newDistance < distance0) + { + distance0 = newDistance; + closestHash = hash; + } + yPrimed += PrimeY; + } + xPrimed += PrimeX; + } + break; + } + + if (mCellularDistanceFunction == CellularDistanceFunction.Euclidean && mCellularReturnType != CellularReturnType.CellValue) + { + distance0 = FastSqrt(distance0); + + if (mCellularReturnType != CellularReturnType.Distance) + { + distance1 = FastSqrt(distance1); + } + } + + switch (mCellularReturnType) + { + case CellValue: + return closestHash * (1 / 2147483648.0f); + case Distance: + return distance0 - 1; + case Distance2: + return distance1 - 1; + case Distance2Add: + return (distance1 + distance0) * 0.5f - 1; + case Distance2Sub: + return distance1 - distance0 - 1; + case Distance2Mul: + return distance1 * distance0 * 0.5f - 1; + case Distance2Div: + return distance0 / distance1 - 1; + default: + return 0; + } + } + + private float SingleCellular(int seed, /*FNLfloat*/ float x, /*FNLfloat*/ float y, /*FNLfloat*/ float z) + { + int xr = FastRound(x); + int yr = FastRound(y); + int zr = FastRound(z); + + float distance0 = Float.MAX_VALUE; + float distance1 = Float.MAX_VALUE; + int closestHash = 0; + + float cellularJitter = 0.39614353f * mCellularJitterModifier; + + int xPrimed = (xr - 1) * PrimeX; + int yPrimedBase = (yr - 1) * PrimeY; + int zPrimedBase = (zr - 1) * PrimeZ; + + switch (mCellularDistanceFunction) + { + case Euclidean: + case EuclideanSq: + for (int xi = xr - 1; xi <= xr + 1; xi++) + { + int yPrimed = yPrimedBase; + + for (int yi = yr - 1; yi <= yr + 1; yi++) + { + int zPrimed = zPrimedBase; + + for (int zi = zr - 1; zi <= zr + 1; zi++) + { + int hash = Hash(seed, xPrimed, yPrimed, zPrimed); + int idx = hash & (255 << 2); + + float vecX = (float)(xi - x) + RandVecs3D[idx] * cellularJitter; + float vecY = (float)(yi - y) + RandVecs3D[idx | 1] * cellularJitter; + float vecZ = (float)(zi - z) + RandVecs3D[idx | 2] * cellularJitter; + + float newDistance = vecX * vecX + vecY * vecY + vecZ * vecZ; + + distance1 = FastMax(FastMin(distance1, newDistance), distance0); + if (newDistance < distance0) + { + distance0 = newDistance; + closestHash = hash; + } + zPrimed += PrimeZ; + } + yPrimed += PrimeY; + } + xPrimed += PrimeX; + } + break; + case Manhattan: + for (int xi = xr - 1; xi <= xr + 1; xi++) + { + int yPrimed = yPrimedBase; + + for (int yi = yr - 1; yi <= yr + 1; yi++) + { + int zPrimed = zPrimedBase; + + for (int zi = zr - 1; zi <= zr + 1; zi++) + { + int hash = Hash(seed, xPrimed, yPrimed, zPrimed); + int idx = hash & (255 << 2); + + float vecX = (float)(xi - x) + RandVecs3D[idx] * cellularJitter; + float vecY = (float)(yi - y) + RandVecs3D[idx | 1] * cellularJitter; + float vecZ = (float)(zi - z) + RandVecs3D[idx | 2] * cellularJitter; + + float newDistance = FastAbs(vecX) + FastAbs(vecY) + FastAbs(vecZ); + + distance1 = FastMax(FastMin(distance1, newDistance), distance0); + if (newDistance < distance0) + { + distance0 = newDistance; + closestHash = hash; + } + zPrimed += PrimeZ; + } + yPrimed += PrimeY; + } + xPrimed += PrimeX; + } + break; + case Hybrid: + for (int xi = xr - 1; xi <= xr + 1; xi++) + { + int yPrimed = yPrimedBase; + + for (int yi = yr - 1; yi <= yr + 1; yi++) + { + int zPrimed = zPrimedBase; + + for (int zi = zr - 1; zi <= zr + 1; zi++) + { + int hash = Hash(seed, xPrimed, yPrimed, zPrimed); + int idx = hash & (255 << 2); + + float vecX = (float)(xi - x) + RandVecs3D[idx] * cellularJitter; + float vecY = (float)(yi - y) + RandVecs3D[idx | 1] * cellularJitter; + float vecZ = (float)(zi - z) + RandVecs3D[idx | 2] * cellularJitter; + + float newDistance = (FastAbs(vecX) + FastAbs(vecY) + FastAbs(vecZ)) + (vecX * vecX + vecY * vecY + vecZ * vecZ); + + distance1 = FastMax(FastMin(distance1, newDistance), distance0); + if (newDistance < distance0) + { + distance0 = newDistance; + closestHash = hash; + } + zPrimed += PrimeZ; + } + yPrimed += PrimeY; + } + xPrimed += PrimeX; + } + break; + default: + break; + } + + if (mCellularDistanceFunction == CellularDistanceFunction.Euclidean && mCellularReturnType != CellularReturnType.CellValue) + { + distance0 = FastSqrt(distance0); + + if (mCellularReturnType != CellularReturnType.Distance) + { + distance1 = FastSqrt(distance1); + } + } + + switch (mCellularReturnType) + { + case CellValue: + return closestHash * (1 / 2147483648.0f); + case Distance: + return distance0 - 1; + case Distance2: + return distance1 - 1; + case Distance2Add: + return (distance1 + distance0) * 0.5f - 1; + case Distance2Sub: + return distance1 - distance0 - 1; + case Distance2Mul: + return distance1 * distance0 * 0.5f - 1; + case Distance2Div: + return distance0 / distance1 - 1; + default: + return 0; + } + } + + + // Perlin Noise + + private float SinglePerlin(int seed, /*FNLfloat*/ float x, /*FNLfloat*/ float y) + { + int x0 = FastFloor(x); + int y0 = FastFloor(y); + + float xd0 = (float)(x - x0); + float yd0 = (float)(y - y0); + float xd1 = xd0 - 1; + float yd1 = yd0 - 1; + + float xs = InterpQuintic(xd0); + float ys = InterpQuintic(yd0); + + x0 *= PrimeX; + y0 *= PrimeY; + int x1 = x0 + PrimeX; + int y1 = y0 + PrimeY; + + float xf0 = Lerp(GradCoord(seed, x0, y0, xd0, yd0), GradCoord(seed, x1, y0, xd1, yd0), xs); + float xf1 = Lerp(GradCoord(seed, x0, y1, xd0, yd1), GradCoord(seed, x1, y1, xd1, yd1), xs); + + return Lerp(xf0, xf1, ys) * 1.4247691104677813f; + } + + private float SinglePerlin(int seed, /*FNLfloat*/ float x, /*FNLfloat*/ float y, /*FNLfloat*/ float z) + { + int x0 = FastFloor(x); + int y0 = FastFloor(y); + int z0 = FastFloor(z); + + float xd0 = (float)(x - x0); + float yd0 = (float)(y - y0); + float zd0 = (float)(z - z0); + float xd1 = xd0 - 1; + float yd1 = yd0 - 1; + float zd1 = zd0 - 1; + + float xs = InterpQuintic(xd0); + float ys = InterpQuintic(yd0); + float zs = InterpQuintic(zd0); + + x0 *= PrimeX; + y0 *= PrimeY; + z0 *= PrimeZ; + int x1 = x0 + PrimeX; + int y1 = y0 + PrimeY; + int z1 = z0 + PrimeZ; + + float xf00 = Lerp(GradCoord(seed, x0, y0, z0, xd0, yd0, zd0), GradCoord(seed, x1, y0, z0, xd1, yd0, zd0), xs); + float xf10 = Lerp(GradCoord(seed, x0, y1, z0, xd0, yd1, zd0), GradCoord(seed, x1, y1, z0, xd1, yd1, zd0), xs); + float xf01 = Lerp(GradCoord(seed, x0, y0, z1, xd0, yd0, zd1), GradCoord(seed, x1, y0, z1, xd1, yd0, zd1), xs); + float xf11 = Lerp(GradCoord(seed, x0, y1, z1, xd0, yd1, zd1), GradCoord(seed, x1, y1, z1, xd1, yd1, zd1), xs); + + float yf0 = Lerp(xf00, xf10, ys); + float yf1 = Lerp(xf01, xf11, ys); + + return Lerp(yf0, yf1, zs) * 0.964921414852142333984375f; + } + + + // Value Cubic Noise + + private float SingleValueCubic(int seed, /*FNLfloat*/ float x, /*FNLfloat*/ float y) + { + int x1 = FastFloor(x); + int y1 = FastFloor(y); + + float xs = (float)(x - x1); + float ys = (float)(y - y1); + + x1 *= PrimeX; + y1 *= PrimeY; + int x0 = x1 - PrimeX; + int y0 = y1 - PrimeY; + int x2 = x1 + PrimeX; + int y2 = y1 + PrimeY; + int x3 = x1 + (PrimeX << 1); + int y3 = y1 + (PrimeY << 1); + + return CubicLerp( + CubicLerp(ValCoord(seed, x0, y0), ValCoord(seed, x1, y0), ValCoord(seed, x2, y0), ValCoord(seed, x3, y0), + xs), + CubicLerp(ValCoord(seed, x0, y1), ValCoord(seed, x1, y1), ValCoord(seed, x2, y1), ValCoord(seed, x3, y1), + xs), + CubicLerp(ValCoord(seed, x0, y2), ValCoord(seed, x1, y2), ValCoord(seed, x2, y2), ValCoord(seed, x3, y2), + xs), + CubicLerp(ValCoord(seed, x0, y3), ValCoord(seed, x1, y3), ValCoord(seed, x2, y3), ValCoord(seed, x3, y3), + xs), + ys) * (1 / (1.5f * 1.5f)); + } + + private float SingleValueCubic(int seed, /*FNLfloat*/ float x, /*FNLfloat*/ float y, /*FNLfloat*/ float z) + { + int x1 = FastFloor(x); + int y1 = FastFloor(y); + int z1 = FastFloor(z); + + float xs = (float)(x - x1); + float ys = (float)(y - y1); + float zs = (float)(z - z1); + + x1 *= PrimeX; + y1 *= PrimeY; + z1 *= PrimeZ; + + int x0 = x1 - PrimeX; + int y0 = y1 - PrimeY; + int z0 = z1 - PrimeZ; + int x2 = x1 + PrimeX; + int y2 = y1 + PrimeY; + int z2 = z1 + PrimeZ; + int x3 = x1 + (PrimeX << 1); + int y3 = y1 + (PrimeY << 1); + int z3 = z1 + (PrimeZ << 1); + + + return CubicLerp( + CubicLerp( + CubicLerp(ValCoord(seed, x0, y0, z0), ValCoord(seed, x1, y0, z0), ValCoord(seed, x2, y0, z0), ValCoord(seed, x3, y0, z0), xs), + CubicLerp(ValCoord(seed, x0, y1, z0), ValCoord(seed, x1, y1, z0), ValCoord(seed, x2, y1, z0), ValCoord(seed, x3, y1, z0), xs), + CubicLerp(ValCoord(seed, x0, y2, z0), ValCoord(seed, x1, y2, z0), ValCoord(seed, x2, y2, z0), ValCoord(seed, x3, y2, z0), xs), + CubicLerp(ValCoord(seed, x0, y3, z0), ValCoord(seed, x1, y3, z0), ValCoord(seed, x2, y3, z0), ValCoord(seed, x3, y3, z0), xs), + ys), + CubicLerp( + CubicLerp(ValCoord(seed, x0, y0, z1), ValCoord(seed, x1, y0, z1), ValCoord(seed, x2, y0, z1), ValCoord(seed, x3, y0, z1), xs), + CubicLerp(ValCoord(seed, x0, y1, z1), ValCoord(seed, x1, y1, z1), ValCoord(seed, x2, y1, z1), ValCoord(seed, x3, y1, z1), xs), + CubicLerp(ValCoord(seed, x0, y2, z1), ValCoord(seed, x1, y2, z1), ValCoord(seed, x2, y2, z1), ValCoord(seed, x3, y2, z1), xs), + CubicLerp(ValCoord(seed, x0, y3, z1), ValCoord(seed, x1, y3, z1), ValCoord(seed, x2, y3, z1), ValCoord(seed, x3, y3, z1), xs), + ys), + CubicLerp( + CubicLerp(ValCoord(seed, x0, y0, z2), ValCoord(seed, x1, y0, z2), ValCoord(seed, x2, y0, z2), ValCoord(seed, x3, y0, z2), xs), + CubicLerp(ValCoord(seed, x0, y1, z2), ValCoord(seed, x1, y1, z2), ValCoord(seed, x2, y1, z2), ValCoord(seed, x3, y1, z2), xs), + CubicLerp(ValCoord(seed, x0, y2, z2), ValCoord(seed, x1, y2, z2), ValCoord(seed, x2, y2, z2), ValCoord(seed, x3, y2, z2), xs), + CubicLerp(ValCoord(seed, x0, y3, z2), ValCoord(seed, x1, y3, z2), ValCoord(seed, x2, y3, z2), ValCoord(seed, x3, y3, z2), xs), + ys), + CubicLerp( + CubicLerp(ValCoord(seed, x0, y0, z3), ValCoord(seed, x1, y0, z3), ValCoord(seed, x2, y0, z3), ValCoord(seed, x3, y0, z3), xs), + CubicLerp(ValCoord(seed, x0, y1, z3), ValCoord(seed, x1, y1, z3), ValCoord(seed, x2, y1, z3), ValCoord(seed, x3, y1, z3), xs), + CubicLerp(ValCoord(seed, x0, y2, z3), ValCoord(seed, x1, y2, z3), ValCoord(seed, x2, y2, z3), ValCoord(seed, x3, y2, z3), xs), + CubicLerp(ValCoord(seed, x0, y3, z3), ValCoord(seed, x1, y3, z3), ValCoord(seed, x2, y3, z3), ValCoord(seed, x3, y3, z3), xs), + ys), + zs) * (1 / (1.5f * 1.5f * 1.5f)); + } + + + // Value Noise + + private float SingleValue(int seed, /*FNLfloat*/ float x, /*FNLfloat*/ float y) + { + int x0 = FastFloor(x); + int y0 = FastFloor(y); + + float xs = InterpHermite((float)(x - x0)); + float ys = InterpHermite((float)(y - y0)); + + x0 *= PrimeX; + y0 *= PrimeY; + int x1 = x0 + PrimeX; + int y1 = y0 + PrimeY; + + float xf0 = Lerp(ValCoord(seed, x0, y0), ValCoord(seed, x1, y0), xs); + float xf1 = Lerp(ValCoord(seed, x0, y1), ValCoord(seed, x1, y1), xs); + + return Lerp(xf0, xf1, ys); + } + + private float SingleValue(int seed, /*FNLfloat*/ float x, /*FNLfloat*/ float y, /*FNLfloat*/ float z) + { + int x0 = FastFloor(x); + int y0 = FastFloor(y); + int z0 = FastFloor(z); + + float xs = InterpHermite((float)(x - x0)); + float ys = InterpHermite((float)(y - y0)); + float zs = InterpHermite((float)(z - z0)); + + x0 *= PrimeX; + y0 *= PrimeY; + z0 *= PrimeZ; + int x1 = x0 + PrimeX; + int y1 = y0 + PrimeY; + int z1 = z0 + PrimeZ; + + float xf00 = Lerp(ValCoord(seed, x0, y0, z0), ValCoord(seed, x1, y0, z0), xs); + float xf10 = Lerp(ValCoord(seed, x0, y1, z0), ValCoord(seed, x1, y1, z0), xs); + float xf01 = Lerp(ValCoord(seed, x0, y0, z1), ValCoord(seed, x1, y0, z1), xs); + float xf11 = Lerp(ValCoord(seed, x0, y1, z1), ValCoord(seed, x1, y1, z1), xs); + + float yf0 = Lerp(xf00, xf10, ys); + float yf1 = Lerp(xf01, xf11, ys); + + return Lerp(yf0, yf1, zs); + } + + + // Domain Warp + + private void DoSingleDomainWarp(int seed, float amp, float freq, /*FNLfloat*/ float x, /*FNLfloat*/ float y, Vector2 coord) + { + switch (mDomainWarpType) + { + case OpenSimplex2: + SingleDomainWarpSimplexGradient(seed, amp * 38.283687591552734375f, freq, x, y, coord, false); + break; + case OpenSimplex2Reduced: + SingleDomainWarpSimplexGradient(seed, amp * 16.0f, freq, x, y, coord, true); + break; + case BasicGrid: + SingleDomainWarpBasicGrid(seed, amp, freq, x, y, coord); + break; + } + } + + private void DoSingleDomainWarp(int seed, float amp, float freq, /*FNLfloat*/ float x, /*FNLfloat*/ float y, /*FNLfloat*/ float z, Vector3 coord) + { + switch (mDomainWarpType) + { + case OpenSimplex2: + SingleDomainWarpOpenSimplex2Gradient(seed, amp * 32.69428253173828125f, freq, x, y, z, coord, false); + break; + case OpenSimplex2Reduced: + SingleDomainWarpOpenSimplex2Gradient(seed, amp * 7.71604938271605f, freq, x, y, z, coord, true); + break; + case BasicGrid: + SingleDomainWarpBasicGrid(seed, amp, freq, x, y, z, coord); + break; + } + } + + + // Domain Warp Single Wrapper + + private void DomainWarpSingle(Vector2 coord) + { + int seed = mSeed; + float amp = mDomainWarpAmp * mFractalBounding; + float freq = mFrequency; + + /*FNLfloat*/ float xs = coord.x; + /*FNLfloat*/ float ys = coord.y; + switch (mDomainWarpType) + { + case OpenSimplex2: + case OpenSimplex2Reduced: + { + final /*FNLfloat*/ float SQRT3 = (/*FNLfloat*/ float)1.7320508075688772935274463415059; + final /*FNLfloat*/ float F2 = 0.5f * (SQRT3 - 1); + /*FNLfloat*/ float t = (xs + ys) * F2; + xs += t; ys += t; + } + break; + default: + break; + } + + DoSingleDomainWarp(seed, amp, freq, xs, ys, coord); + } + + private void DomainWarpSingle(Vector3 coord) + { + int seed = mSeed; + float amp = mDomainWarpAmp * mFractalBounding; + float freq = mFrequency; + + /*FNLfloat*/ float xs = coord.x; + /*FNLfloat*/ float ys = coord.y; + /*FNLfloat*/ float zs = coord.z; + switch (mWarpTransformType3D) + { + case ImproveXYPlanes: + { + /*FNLfloat*/ float xy = xs + ys; + /*FNLfloat*/ float s2 = xy * -(/*FNLfloat*/ float)0.211324865405187; + zs *= (/*FNLfloat*/ float)0.577350269189626; + xs += s2 - zs; + ys = ys + s2 - zs; + zs += xy * (/*FNLfloat*/ float)0.577350269189626; + } + break; + case ImproveXZPlanes: + { + /*FNLfloat*/ float xz = xs + zs; + /*FNLfloat*/ float s2 = xz * -(/*FNLfloat*/ float)0.211324865405187; + ys *= (/*FNLfloat*/ float)0.577350269189626; + xs += s2 - ys; zs += s2 - ys; + ys += xz * (/*FNLfloat*/ float)0.577350269189626; + } + break; + case DefaultOpenSimplex2: + { + final /*FNLfloat*/ float R3 = (/*FNLfloat*/ float)(2.0 / 3.0); + /*FNLfloat*/ float r = (xs + ys + zs) * R3; // Rotation, not skew + xs = r - xs; + ys = r - ys; + zs = r - zs; + } + break; + default: + break; + } + + DoSingleDomainWarp(seed, amp, freq, xs, ys, zs, coord); + } + + + // Domain Warp Fractal Progressive + + private void DomainWarpFractalProgressive(Vector2 coord) + { + int seed = mSeed; + float amp = mDomainWarpAmp * mFractalBounding; + float freq = mFrequency; + + for (int i = 0; i < mOctaves; i++) + { + /*FNLfloat*/ float xs = coord.x; + /*FNLfloat*/ float ys = coord.y; + switch (mDomainWarpType) + { + case OpenSimplex2: + case OpenSimplex2Reduced: + { + final /*FNLfloat*/ float SQRT3 = (/*FNLfloat*/ float)1.7320508075688772935274463415059; + final /*FNLfloat*/ float F2 = 0.5f * (SQRT3 - 1); + /*FNLfloat*/ float t = (xs + ys) * F2; + xs += t; ys += t; + } + break; + default: + break; + } + + DoSingleDomainWarp(seed, amp, freq, xs, ys, coord); + + seed++; + amp *= mGain; + freq *= mLacunarity; + } + } + + private void DomainWarpFractalProgressive(Vector3 coord) + { + int seed = mSeed; + float amp = mDomainWarpAmp * mFractalBounding; + float freq = mFrequency; + + for (int i = 0; i < mOctaves; i++) + { + /*FNLfloat*/ float xs = coord.x; + /*FNLfloat*/ float ys = coord.y; + /*FNLfloat*/ float zs = coord.z; + switch (mWarpTransformType3D) + { + case ImproveXYPlanes: + { + /*FNLfloat*/ float xy = xs + ys; + /*FNLfloat*/ float s2 = xy * -(/*FNLfloat*/ float)0.211324865405187; + zs *= (/*FNLfloat*/ float)0.577350269189626; + xs += s2 - zs; + ys = ys + s2 - zs; + zs += xy * (/*FNLfloat*/ float)0.577350269189626; + } + break; + case ImproveXZPlanes: + { + /*FNLfloat*/ float xz = xs + zs; + /*FNLfloat*/ float s2 = xz * -(/*FNLfloat*/ float)0.211324865405187; + ys *= (/*FNLfloat*/ float)0.577350269189626; + xs += s2 - ys; zs += s2 - ys; + ys += xz * (/*FNLfloat*/ float)0.577350269189626; + } + break; + case DefaultOpenSimplex2: + { + final /*FNLfloat*/ float R3 = (/*FNLfloat*/ float)(2.0 / 3.0); + /*FNLfloat*/ float r = (xs + ys + zs) * R3; // Rotation, not skew + xs = r - xs; + ys = r - ys; + zs = r - zs; + } + break; + default: + break; + } + + DoSingleDomainWarp(seed, amp, freq, xs, ys, zs, coord); + + seed++; + amp *= mGain; + freq *= mLacunarity; + } + } + + + // Domain Warp Fractal Independant + private void DomainWarpFractalIndependent(Vector2 coord) + { + /*FNLfloat*/ float xs = coord.x; + /*FNLfloat*/ float ys = coord.y; + switch (mDomainWarpType) + { + case OpenSimplex2: + case OpenSimplex2Reduced: + { + final /*FNLfloat*/ float SQRT3 = (/*FNLfloat*/ float)1.7320508075688772935274463415059; + final /*FNLfloat*/ float F2 = 0.5f * (SQRT3 - 1); + /*FNLfloat*/ float t = (xs + ys) * F2; + xs += t; ys += t; + } + break; + default: + break; + } + + int seed = mSeed; + float amp = mDomainWarpAmp * mFractalBounding; + float freq = mFrequency; + + for (int i = 0; i < mOctaves; i++) + { + DoSingleDomainWarp(seed, amp, freq, xs, ys, coord); + + seed++; + amp *= mGain; + freq *= mLacunarity; + } + } + + private void DomainWarpFractalIndependent(Vector3 coord) + { + /*FNLfloat*/ float xs = coord.x; + /*FNLfloat*/ float ys = coord.y; + /*FNLfloat*/ float zs = coord.z; + switch (mWarpTransformType3D) + { + case ImproveXYPlanes: + { + /*FNLfloat*/ float xy = xs + ys; + /*FNLfloat*/ float s2 = xy * -(/*FNLfloat*/ float)0.211324865405187; + zs *= (/*FNLfloat*/ float)0.577350269189626; + xs += s2 - zs; + ys = ys + s2 - zs; + zs += xy * (/*FNLfloat*/ float)0.577350269189626; + } + break; + case ImproveXZPlanes: + { + /*FNLfloat*/ float xz = xs + zs; + /*FNLfloat*/ float s2 = xz * -(/*FNLfloat*/ float)0.211324865405187; + ys *= (/*FNLfloat*/ float)0.577350269189626; + xs += s2 - ys; zs += s2 - ys; + ys += xz * (/*FNLfloat*/ float)0.577350269189626; + } + break; + case DefaultOpenSimplex2: + { + final /*FNLfloat*/ float R3 = (/*FNLfloat*/ float)(2.0 / 3.0); + /*FNLfloat*/ float r = (xs + ys + zs) * R3; // Rotation, not skew + xs = r - xs; + ys = r - ys; + zs = r - zs; + } + break; + default: + break; + } + + int seed = mSeed; + float amp = mDomainWarpAmp * mFractalBounding; + float freq = mFrequency; + + for (int i = 0; i < mOctaves; i++) + { + DoSingleDomainWarp(seed, amp, freq, xs, ys, zs, coord); + + seed++; + amp *= mGain; + freq *= mLacunarity; + } + } + + + // Domain Warp Basic Grid + + private void SingleDomainWarpBasicGrid(int seed, float warpAmp, float frequency, /*FNLfloat*/ float x, /*FNLfloat*/ float y, Vector2 coord) + { + /*FNLfloat*/ float xf = x * frequency; + /*FNLfloat*/ float yf = y * frequency; + + int x0 = FastFloor(xf); + int y0 = FastFloor(yf); + + float xs = InterpHermite((float)(xf - x0)); + float ys = InterpHermite((float)(yf - y0)); + + x0 *= PrimeX; + y0 *= PrimeY; + int x1 = x0 + PrimeX; + int y1 = y0 + PrimeY; + + int hash0 = Hash(seed, x0, y0) & (255 << 1); + int hash1 = Hash(seed, x1, y0) & (255 << 1); + + float lx0x = Lerp(RandVecs2D[hash0], RandVecs2D[hash1], xs); + float ly0x = Lerp(RandVecs2D[hash0 | 1], RandVecs2D[hash1 | 1], xs); + + hash0 = Hash(seed, x0, y1) & (255 << 1); + hash1 = Hash(seed, x1, y1) & (255 << 1); + + float lx1x = Lerp(RandVecs2D[hash0], RandVecs2D[hash1], xs); + float ly1x = Lerp(RandVecs2D[hash0 | 1], RandVecs2D[hash1 | 1], xs); + + coord.x += Lerp(lx0x, lx1x, ys) * warpAmp; + coord.y += Lerp(ly0x, ly1x, ys) * warpAmp; + } + + private void SingleDomainWarpBasicGrid(int seed, float warpAmp, float frequency, /*FNLfloat*/ float x, /*FNLfloat*/ float y, /*FNLfloat*/ float z, Vector3 coord) + { + /*FNLfloat*/ float xf = x * frequency; + /*FNLfloat*/ float yf = y * frequency; + /*FNLfloat*/ float zf = z * frequency; + + int x0 = FastFloor(xf); + int y0 = FastFloor(yf); + int z0 = FastFloor(zf); + + float xs = InterpHermite((float)(xf - x0)); + float ys = InterpHermite((float)(yf - y0)); + float zs = InterpHermite((float)(zf - z0)); + + x0 *= PrimeX; + y0 *= PrimeY; + z0 *= PrimeZ; + int x1 = x0 + PrimeX; + int y1 = y0 + PrimeY; + int z1 = z0 + PrimeZ; + + int hash0 = Hash(seed, x0, y0, z0) & (255 << 2); + int hash1 = Hash(seed, x1, y0, z0) & (255 << 2); + + float lx0x = Lerp(RandVecs3D[hash0], RandVecs3D[hash1], xs); + float ly0x = Lerp(RandVecs3D[hash0 | 1], RandVecs3D[hash1 | 1], xs); + float lz0x = Lerp(RandVecs3D[hash0 | 2], RandVecs3D[hash1 | 2], xs); + + hash0 = Hash(seed, x0, y1, z0) & (255 << 2); + hash1 = Hash(seed, x1, y1, z0) & (255 << 2); + + float lx1x = Lerp(RandVecs3D[hash0], RandVecs3D[hash1], xs); + float ly1x = Lerp(RandVecs3D[hash0 | 1], RandVecs3D[hash1 | 1], xs); + float lz1x = Lerp(RandVecs3D[hash0 | 2], RandVecs3D[hash1 | 2], xs); + + float lx0y = Lerp(lx0x, lx1x, ys); + float ly0y = Lerp(ly0x, ly1x, ys); + float lz0y = Lerp(lz0x, lz1x, ys); + + hash0 = Hash(seed, x0, y0, z1) & (255 << 2); + hash1 = Hash(seed, x1, y0, z1) & (255 << 2); + + lx0x = Lerp(RandVecs3D[hash0], RandVecs3D[hash1], xs); + ly0x = Lerp(RandVecs3D[hash0 | 1], RandVecs3D[hash1 | 1], xs); + lz0x = Lerp(RandVecs3D[hash0 | 2], RandVecs3D[hash1 | 2], xs); + + hash0 = Hash(seed, x0, y1, z1) & (255 << 2); + hash1 = Hash(seed, x1, y1, z1) & (255 << 2); + + lx1x = Lerp(RandVecs3D[hash0], RandVecs3D[hash1], xs); + ly1x = Lerp(RandVecs3D[hash0 | 1], RandVecs3D[hash1 | 1], xs); + lz1x = Lerp(RandVecs3D[hash0 | 2], RandVecs3D[hash1 | 2], xs); + + coord.x += Lerp(lx0y, Lerp(lx0x, lx1x, ys), zs) * warpAmp; + coord.y += Lerp(ly0y, Lerp(ly0x, ly1x, ys), zs) * warpAmp; + coord.z += Lerp(lz0y, Lerp(lz0x, lz1x, ys), zs) * warpAmp; + } + + + // Domain Warp Simplex/OpenSimplex2 + private void SingleDomainWarpSimplexGradient(int seed, float warpAmp, float frequency, /*FNLfloat*/ float x, /*FNLfloat*/ float y, Vector2 coord, boolean outGradOnly) + { + final float SQRT3 = 1.7320508075688772935274463415059f; + final float G2 = (3 - SQRT3) / 6; + + x *= frequency; + y *= frequency; + + /* + * --- Skew moved to switch statements before fractal evaluation --- + * final FNLfloat F2 = 0.5f * (SQRT3 - 1); + * FNLfloat s = (x + y) * F2; + * x += s; y += s; + */ + + int i = FastFloor(x); + int j = FastFloor(y); + float xi = (float)(x - i); + float yi = (float)(y - j); + + float t = (xi + yi) * G2; + float x0 = (float)(xi - t); + float y0 = (float)(yi - t); + + i *= PrimeX; + j *= PrimeY; + + float vx, vy; + vx = vy = 0; + + float a = 0.5f - x0 * x0 - y0 * y0; + if (a > 0) + { + float aaaa = (a * a) * (a * a); + float xo, yo; + if (outGradOnly) + { + int hash = Hash(seed, i, j) & (255 << 1); + xo = RandVecs2D[hash]; + yo = RandVecs2D[hash | 1]; + } + else + { + int hash = Hash(seed, i, j); + int index1 = hash & (127 << 1); + int index2 = (hash >> 7) & (255 << 1); + float xg = Gradients2D[index1]; + float yg = Gradients2D[index1 | 1]; + float value = x0 * xg + y0 * yg; + float xgo = RandVecs2D[index2]; + float ygo = RandVecs2D[index2 | 1]; + xo = value * xgo; + yo = value * ygo; + } + vx += aaaa * xo; + vy += aaaa * yo; + } + + float c = (float)(2 * (1 - 2 * G2) * (1 / G2 - 2)) * t + ((float)(-2 * (1 - 2 * G2) * (1 - 2 * G2)) + a); + if (c > 0) + { + float x2 = x0 + (2 * (float)G2 - 1); + float y2 = y0 + (2 * (float)G2 - 1); + float cccc = (c * c) * (c * c); + float xo, yo; + if (outGradOnly) + { + int hash = Hash(seed, i + PrimeX, j + PrimeY) & (255 << 1); + xo = RandVecs2D[hash]; + yo = RandVecs2D[hash | 1]; + } + else + { + int hash = Hash(seed, i + PrimeX, j + PrimeY); + int index1 = hash & (127 << 1); + int index2 = (hash >> 7) & (255 << 1); + float xg = Gradients2D[index1]; + float yg = Gradients2D[index1 | 1]; + float value = x2 * xg + y2 * yg; + float xgo = RandVecs2D[index2]; + float ygo = RandVecs2D[index2 | 1]; + xo = value * xgo; + yo = value * ygo; + } + vx += cccc * xo; + vy += cccc * yo; + } + + if (y0 > x0) + { + float x1 = x0 + (float)G2; + float y1 = y0 + ((float)G2 - 1); + float b = 0.5f - x1 * x1 - y1 * y1; + if (b > 0) + { + float bbbb = (b * b) * (b * b); + float xo, yo; + if (outGradOnly) + { + int hash = Hash(seed, i, j + PrimeY) & (255 << 1); + xo = RandVecs2D[hash]; + yo = RandVecs2D[hash | 1]; + } + else + { + int hash = Hash(seed, i, j + PrimeY); + int index1 = hash & (127 << 1); + int index2 = (hash >> 7) & (255 << 1); + float xg = Gradients2D[index1]; + float yg = Gradients2D[index1 | 1]; + float value = x1 * xg + y1 * yg; + float xgo = RandVecs2D[index2]; + float ygo = RandVecs2D[index2 | 1]; + xo = value * xgo; + yo = value * ygo; + } + vx += bbbb * xo; + vy += bbbb * yo; + } + } + else + { + float x1 = x0 + ((float)G2 - 1); + float y1 = y0 + (float)G2; + float b = 0.5f - x1 * x1 - y1 * y1; + if (b > 0) + { + float bbbb = (b * b) * (b * b); + float xo, yo; + if (outGradOnly) + { + int hash = Hash(seed, i + PrimeX, j) & (255 << 1); + xo = RandVecs2D[hash]; + yo = RandVecs2D[hash | 1]; + } + else + { + int hash = Hash(seed, i + PrimeX, j); + int index1 = hash & (127 << 1); + int index2 = (hash >> 7) & (255 << 1); + float xg = Gradients2D[index1]; + float yg = Gradients2D[index1 | 1]; + float value = x1 * xg + y1 * yg; + float xgo = RandVecs2D[index2]; + float ygo = RandVecs2D[index2 | 1]; + xo = value * xgo; + yo = value * ygo; + } + vx += bbbb * xo; + vy += bbbb * yo; + } + } + + coord.x += vx * warpAmp; + coord.y += vy * warpAmp; + } + + private void SingleDomainWarpOpenSimplex2Gradient(int seed, float warpAmp, float frequency, /*FNLfloat*/ float x, /*FNLfloat*/ float y, /*FNLfloat*/ float z, Vector3 coord, boolean outGradOnly) + { + x *= frequency; + y *= frequency; + z *= frequency; + + /* + * --- Rotation moved to switch statements before fractal evaluation --- + * final FNLfloat R3 = (FNLfloat)(2.0 / 3.0); + * FNLfloat r = (x + y + z) * R3; // Rotation, not skew + * x = r - x; y = r - y; z = r - z; + */ + + int i = FastRound(x); + int j = FastRound(y); + int k = FastRound(z); + float x0 = (float)x - i; + float y0 = (float)y - j; + float z0 = (float)z - k; + + int xNSign = (int)(-x0 - 1.0f) | 1; + int yNSign = (int)(-y0 - 1.0f) | 1; + int zNSign = (int)(-z0 - 1.0f) | 1; + + float ax0 = xNSign * -x0; + float ay0 = yNSign * -y0; + float az0 = zNSign * -z0; + + i *= PrimeX; + j *= PrimeY; + k *= PrimeZ; + + float vx, vy, vz; + vx = vy = vz = 0; + + float a = (0.6f - x0 * x0) - (y0 * y0 + z0 * z0); + for (int l = 0; ; l++) + { + if (a > 0) + { + float aaaa = (a * a) * (a * a); + float xo, yo, zo; + if (outGradOnly) + { + int hash = Hash(seed, i, j, k) & (255 << 2); + xo = RandVecs3D[hash]; + yo = RandVecs3D[hash | 1]; + zo = RandVecs3D[hash | 2]; + } + else + { + int hash = Hash(seed, i, j, k); + int index1 = hash & (63 << 2); + int index2 = (hash >> 6) & (255 << 2); + float xg = Gradients3D[index1]; + float yg = Gradients3D[index1 | 1]; + float zg = Gradients3D[index1 | 2]; + float value = x0 * xg + y0 * yg + z0 * zg; + float xgo = RandVecs3D[index2]; + float ygo = RandVecs3D[index2 | 1]; + float zgo = RandVecs3D[index2 | 2]; + xo = value * xgo; + yo = value * ygo; + zo = value * zgo; + } + vx += aaaa * xo; + vy += aaaa * yo; + vz += aaaa * zo; + } + + float b = a; + int i1 = i; + int j1 = j; + int k1 = k; + float x1 = x0; + float y1 = y0; + float z1 = z0; + + if (ax0 >= ay0 && ax0 >= az0) + { + x1 += xNSign; + b = b + ax0 + ax0; + i1 -= xNSign * PrimeX; + } + else if (ay0 > ax0 && ay0 >= az0) + { + y1 += yNSign; + b = b + ay0 + ay0; + j1 -= yNSign * PrimeY; + } + else + { + z1 += zNSign; + b = b + az0 + az0; + k1 -= zNSign * PrimeZ; + } + + if (b > 1) + { + b -= 1; + float bbbb = (b * b) * (b * b); + float xo, yo, zo; + if (outGradOnly) + { + int hash = Hash(seed, i1, j1, k1) & (255 << 2); + xo = RandVecs3D[hash]; + yo = RandVecs3D[hash | 1]; + zo = RandVecs3D[hash | 2]; + } + else + { + int hash = Hash(seed, i1, j1, k1); + int index1 = hash & (63 << 2); + int index2 = (hash >> 6) & (255 << 2); + float xg = Gradients3D[index1]; + float yg = Gradients3D[index1 | 1]; + float zg = Gradients3D[index1 | 2]; + float value = x1 * xg + y1 * yg + z1 * zg; + float xgo = RandVecs3D[index2]; + float ygo = RandVecs3D[index2 | 1]; + float zgo = RandVecs3D[index2 | 2]; + xo = value * xgo; + yo = value * ygo; + zo = value * zgo; + } + vx += bbbb * xo; + vy += bbbb * yo; + vz += bbbb * zo; + } + + if (l == 1) break; + + ax0 = 0.5f - ax0; + ay0 = 0.5f - ay0; + az0 = 0.5f - az0; + + x0 = xNSign * ax0; + y0 = yNSign * ay0; + z0 = zNSign * az0; + + a += (0.75f - ax0) - (ay0 + az0); + + i += (xNSign >> 1) & PrimeX; + j += (yNSign >> 1) & PrimeY; + k += (zNSign >> 1) & PrimeZ; + + xNSign = -xNSign; + yNSign = -yNSign; + zNSign = -zNSign; + + seed += 1293373; + } + + coord.x += vx * warpAmp; + coord.y += vy * warpAmp; + coord.z += vz * warpAmp; + } + + public static class Vector2 + { + public /*FNLfloat*/ float x; + public /*FNLfloat*/ float y; + public Vector2(/*FNLfloat*/ float x, /*FNLfloat*/ float y) + { + this.x = x; + this.y = y; + } + } + + public static class Vector3 + { + public /*FNLfloat*/ float x; + public /*FNLfloat*/ float y; + public /*FNLfloat*/ float z; + public Vector3(/*FNLfloat*/ float x, /*FNLfloat*/ float y, /*FNLfloat*/ float z) + { + this.x = x; + this.y = y; + this.z = z; + } + } +} \ No newline at end of file diff --git a/terrain-world-plugin/src/main/resources/META-INF/LICENSE-FastNoiseLite.txt b/terrain-world-plugin/src/main/resources/META-INF/LICENSE-FastNoiseLite.txt new file mode 100644 index 0000000..0e55394 --- /dev/null +++ b/terrain-world-plugin/src/main/resources/META-INF/LICENSE-FastNoiseLite.txt @@ -0,0 +1,26 @@ +MIT License + +Copyright(c) 2023 Jordan Peck (jordan.me2@gmail.com) +Copyright(c) 2023 Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files(the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions : + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +Upstream: https://github.com/Auburn/FastNoiseLite +Commit: 785f37a9ad76e283586a379675085f2063ae03f7 +Local change: Java package declaration only. diff --git a/terrain-world-plugin/src/main/resources/config.yml b/terrain-world-plugin/src/main/resources/config.yml new file mode 100644 index 0000000..0307d42 --- /dev/null +++ b/terrain-world-plugin/src/main/resources/config.yml @@ -0,0 +1,22 @@ +# Explicit opt-in; never creates or adopts a world by default. +enabled: false +world: shacraft_lobby +recipe: terrain.json +# recipe-v1, or shacraft-natural-v1 with an empty-feature envelope recipe. +terrain-profile: recipe-v1 +# Optional absolute surface Y for lakes/rivers. Omit for dry terrain. +# water-level: 48 +# Optional exploration border, independent of the editor's authorized bounds. +# border-size: 2048 +# Optional fixed player feet position for /lobby, world spawn and deaths in this lobby. +# Omit this section to keep the recipe's original spawn at x=0, z=0. +# Set this after building a raised plaza; invisible roofs are not spawn surfaces. +# spawn: +# x: 0.5 +# y: 96 +# z: 43.5 +# yaw: 180 +# pitch: 0 +# Enable only after the two-floor clock station at the documented coordinates is built. +# station: +# enabled: true diff --git a/terrain-world-plugin/src/main/resources/plugin.yml b/terrain-world-plugin/src/main/resources/plugin.yml new file mode 100644 index 0000000..bb43d1d --- /dev/null +++ b/terrain-world-plugin/src/main/resources/plugin.yml @@ -0,0 +1,17 @@ +name: ShacraftTerrain +version: '0.1.0' +main: io.github.minecraftbuilder.terrainworld.TerrainWorldPlugin +api-version: '26.2' +loadbefore: [MinecraftBuilderMCP] +commands: + lobby: + description: Visit the configured terrain world, inspect generation or export a surface map + permission: minecraftbuilder.lobby + station: + description: Visit the completed clock station vestibule or SMASH floor + permission: minecraftbuilder.station +permissions: + minecraftbuilder.lobby: + default: op + minecraftbuilder.station: + default: true diff --git a/terrain-world-plugin/src/test/java/io/github/minecraftbuilder/terrainworld/LobbyRespawnTest.java b/terrain-world-plugin/src/test/java/io/github/minecraftbuilder/terrainworld/LobbyRespawnTest.java new file mode 100644 index 0000000..6f4f267 --- /dev/null +++ b/terrain-world-plugin/src/test/java/io/github/minecraftbuilder/terrainworld/LobbyRespawnTest.java @@ -0,0 +1,68 @@ +package io.github.minecraftbuilder.terrainworld; + +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerRespawnEvent; +import org.junit.jupiter.api.Test; +import java.lang.reflect.Proxy; +import static org.junit.jupiter.api.Assertions.*; + +class LobbyRespawnTest { + @Test void configuredLobbyDeathUsesPlazaFeetEvenWhenVanillaSelectedBarrierRoof() { + World lobby = world(); + Location spawn = new Location(lobby, .5, 96, 43.5, 180, 0); + LobbyRespawn listener = new LobbyRespawn(lobby, spawn); + spawn.setY(300); // The listener retains its own configuration snapshot. + var event = event(lobby, new Location(lobby, .5, 131, 43.5), PlayerRespawnEvent.RespawnReason.DEATH); + listener.onRespawn(event); + assertDestination(event.getRespawnLocation(), lobby, .5, 96, 43.5, 180, 0); + event.getRespawnLocation().setY(200); + var next = event(lobby, new Location(world(), 8, 70, 9), PlayerRespawnEvent.RespawnReason.DEATH); + listener.onRespawn(next); + assertDestination(next.getRespawnLocation(), lobby, .5, 96, 43.5, 180, 0); + } + + @Test void otherWorldDeathsAreUnchangedEvenWhenTheirDestinationIsLobby() { + World lobby = world(), other = world(); + var listener = new LobbyRespawn(lobby, new Location(lobby, .5, 96, 43.5, 180, 0)); + var event = event(other, new Location(lobby, 11, 115, 23, 90, 10), PlayerRespawnEvent.RespawnReason.DEATH); + listener.onRespawn(event); + assertDestination(event.getRespawnLocation(), lobby, 11, 115, 23, 90, 10); + } + + @Test void absentConfigurationAndNonDeathRespawnsKeepOriginalBehavior() { + World lobby = world(); + var disabled = new LobbyRespawn(lobby, null); + var death = event(lobby, new Location(lobby, 11, 115, 23), PlayerRespawnEvent.RespawnReason.DEATH); + disabled.onRespawn(death); + assertDestination(death.getRespawnLocation(), lobby, 11, 115, 23, 0, 0); + var enabled = new LobbyRespawn(lobby, new Location(lobby, .5, 96, 43.5, 180, 0)); + for (var reason : PlayerRespawnEvent.RespawnReason.values()) { + if (reason == PlayerRespawnEvent.RespawnReason.DEATH) continue; + var event = event(lobby, new Location(lobby, 11, 115, 23), reason); + enabled.onRespawn(event); + assertDestination(event.getRespawnLocation(), lobby, 11, 115, 23, 0, 0); + } + } + + private static PlayerRespawnEvent event(World origin, Location destination, PlayerRespawnEvent.RespawnReason reason) { + Player player = (Player) Proxy.newProxyInstance(Player.class.getClassLoader(), new Class[]{Player.class}, + (proxy, method, args) -> { + if (method.getName().equals("getWorld")) return origin; + throw new AssertionError("Unexpected player call: " + method.getName()); + }); + return new PlayerRespawnEvent(player, destination, false, false, false, reason); + } + + private static World world() { + return (World) Proxy.newProxyInstance(World.class.getClassLoader(), new Class[]{World.class}, + (proxy, method, args) -> { throw new AssertionError("Must not query terrain or world state: " + method.getName()); }); + } + + private static void assertDestination(Location location, World world, double x, double y, double z, float yaw, float pitch) { + assertSame(world, location.getWorld()); + assertEquals(x, location.getX()); assertEquals(y, location.getY()); assertEquals(z, location.getZ()); + assertEquals(yaw, location.getYaw()); assertEquals(pitch, location.getPitch()); + } +} diff --git a/terrain-world-plugin/src/test/java/io/github/minecraftbuilder/terrainworld/LobbySpawnTest.java b/terrain-world-plugin/src/test/java/io/github/minecraftbuilder/terrainworld/LobbySpawnTest.java new file mode 100644 index 0000000..e228a76 --- /dev/null +++ b/terrain-world-plugin/src/test/java/io/github/minecraftbuilder/terrainworld/LobbySpawnTest.java @@ -0,0 +1,51 @@ +package io.github.minecraftbuilder.terrainworld; + +import org.bukkit.configuration.MemoryConfiguration; +import org.junit.jupiter.api.Test; +import java.util.Map; +import static org.junit.jupiter.api.Assertions.*; + +class LobbySpawnTest { + @Test void absentSectionKeepsOriginalRecipeFallback() { + assertEquals(new LobbySpawn(.5, 89, .5, 180, 0), + LobbySpawn.fromConfig(new MemoryConfiguration(), () -> 89)); + } + + @Test void explicitPlazaCoordinatesDoNotConsultGeneratedTerrainOrRoof() { + MemoryConfiguration config = configured(); + config.set("spawn.yaw", 137.5); + config.set("spawn.pitch", -12.5); + assertEquals(new LobbySpawn(.5, 96, 43.5, 137.5f, -12.5f), + LobbySpawn.fromConfig(config, () -> { throw new AssertionError("Fallback must not be read"); })); + config.set("spawn.yaw", null); + config.set("spawn.pitch", null); + assertEquals(new LobbySpawn(.5, 96, 43.5, 180, 0), LobbySpawn.fromConfig(config, () -> 200)); + } + + @Test void incompleteNonNumericAndNonFiniteSettingsFailInsteadOfFallingBack() { + MemoryConfiguration config = configured(); + config.set("spawn.z", null); + assertThrows(IllegalArgumentException.class, () -> LobbySpawn.fromConfig(config, () -> 89)); + for (Object value : new Object[]{"96", Double.NaN, Double.POSITIVE_INFINITY}) { + MemoryConfiguration invalid = configured(); invalid.set("spawn.y", value); + assertThrows(IllegalArgumentException.class, () -> LobbySpawn.fromConfig(invalid, () -> 89)); + } + config.set("spawn", "invalid"); + assertThrows(IllegalArgumentException.class, () -> LobbySpawn.fromConfig(config, () -> 89)); + } + + @Test void worldAndViewLimitsAreChecked() { + assertThrows(IllegalArgumentException.class, () -> new LobbySpawn(30_000_000, 96, 0, 180, 0)); + assertThrows(IllegalArgumentException.class, () -> new LobbySpawn(0, 96, 0, Float.POSITIVE_INFINITY, 0)); + assertThrows(IllegalArgumentException.class, () -> new LobbySpawn(0, 96, 0, 180, 91)); + assertThrows(IllegalArgumentException.class, () -> new LobbySpawn(0, -65, 0, 180, 0).requireHeight(-64, 320)); + assertThrows(IllegalArgumentException.class, () -> new LobbySpawn(0, 320, 0, 180, 0).requireHeight(-64, 320)); + assertDoesNotThrow(() -> new LobbySpawn(0, 96, 0, 180, 0).requireHeight(-64, 320)); + } + + private static MemoryConfiguration configured() { + MemoryConfiguration config = new MemoryConfiguration(); + config.createSection("spawn", Map.of("x", .5, "y", 96, "z", 43.5, "yaw", 180, "pitch", 0)); + return config; + } +} diff --git a/terrain-world-plugin/src/test/java/io/github/minecraftbuilder/terrainworld/RecipeGeneratorTest.java b/terrain-world-plugin/src/test/java/io/github/minecraftbuilder/terrainworld/RecipeGeneratorTest.java new file mode 100644 index 0000000..e0ee26f --- /dev/null +++ b/terrain-world-plugin/src/test/java/io/github/minecraftbuilder/terrainworld/RecipeGeneratorTest.java @@ -0,0 +1,132 @@ +package io.github.minecraftbuilder.terrainworld; + +import com.google.gson.*; +import org.bukkit.Material; +import org.bukkit.generator.ChunkGenerator.ChunkData; +import org.junit.jupiter.api.Test; +import java.lang.reflect.Proxy; +import java.nio.file.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class RecipeGeneratorTest { + private JsonObject naturalSource() throws Exception { + return JsonParser.parseString(Files.readString(Path.of("../examples/terrain/shacraft-natural-world.json"))).getAsJsonObject(); + } + @Test void productionFieldExactlyMatchesTheApprovedStudy() throws Exception { + var terrain = new NaturalTerrain(28092005); + var digest = java.security.MessageDigest.getInstance("SHA-256"); + var buffer = java.nio.ByteBuffer.allocate(4); + for(int z=-384;z<384;z++)for(int x=-384;x<384;x++) { + float h=terrain.height(x,z); + assertTrue(h>=16&&h<=239); + buffer.clear();buffer.putFloat(h);digest.update(buffer.array()); + } + assertEquals("3ac6a3d8d884adf8ef074ec0dd4c0e066b18832f45a50e771b6d9433e4c79096",HexFormat.of().formatHex(digest.digest())); + } + @Test void naturalCliffsHaveRockInsteadOfSoilStripes() throws Exception { + var generator=new RecipeGenerator(naturalSource(),48,"shacraft-natural-v1"); + var terrain=new NaturalTerrain(28092005); + var cells=chunk(generator,-18,-19); + int cliffs=0; + for(int x=0;x<16;x++)for(int z=0;z<16;z++) { + int wx=-288+x,wz=-304+z,top=generator.height(wx,wz); + if(terrain.slope(wx,wz)>1.5) { + cliffs++; + assertTrue(Set.of(Material.STONE,Material.ANDESITE).contains(cells[x][top+64][z])); + for(int y=top-3;y20); + assertEquals(Material.GRASS_BLOCK,generator.surfaceMaterial(0,0)); + } + @Test void naturalLakeHasContinuousWaterAndNoGrassUnderwater() throws Exception { + var generator=new RecipeGenerator(naturalSource(),48,"shacraft-natural-v1"); + var cells=chunk(generator,-14,-1); + for(int x=0;x<16;x++)for(int z=0;z<16;z++) { + int top=generator.height(-224+x,-16+z); + assertTrue(top<48); + assertTrue(Set.of(Material.STONE,Material.ANDESITE).contains(cells[x][top+64][z])); + for(int y=top+1;y<=48;y++)assertEquals(Material.WATER,cells[x][y+64][z]); + assertNull(cells[x][49+64][z]); + } + } + @Test void profileAndWaterArePartOfImmutableGenerationIdentity() throws Exception { + var source=naturalSource(); + var dry=new RecipeGenerator(source,null,"shacraft-natural-v1"); + var wet=new RecipeGenerator(source,48,"shacraft-natural-v1"); + assertNotEquals(dry.identity(),wet.identity()); + assertNotEquals(wet.identity(),new RecipeGenerator(source,48).identity()); + assertThrows(IllegalArgumentException.class,()->new RecipeGenerator(source,48,"unknown")); + assertThrows(IllegalArgumentException.class,()->new RecipeGenerator(source(),48,"shacraft-natural-v1")); + } + private JsonObject source() throws Exception { + return JsonParser.parseString(Files.readString(Path.of("../examples/terrain/shacraft-lobby-world.json"))).getAsJsonObject(); + } + private Material[][][] chunk(RecipeGenerator generator, int cx, int cz) { + Material[][][] cells = new Material[16][384][16]; + ChunkData data = (ChunkData) Proxy.newProxyInstance(ChunkData.class.getClassLoader(), new Class[]{ChunkData.class}, (p, m, args) -> { + switch (m.getName()) { + case "getMinHeight": return -64; + case "getMaxHeight": return 320; + case "setBlock": cells[(int)args[0]][(int)args[1]+64][(int)args[2]] = (Material)args[3]; return null; + case "setRegion": + for(int x=(int)args[0];x<(int)args[3];x++) for(int y=(int)args[1];y<(int)args[4];y++) for(int z=(int)args[2];z<(int)args[5];z++) + cells[x][y+64][z]=(Material)args[6]; + return null; + default: throw new UnsupportedOperationException(m.getName()); + } + }); + generator.generateNoise(null, new Random(1), cx, cz, data); + return cells; + } + @Test void spawnPlateauHasSolidFoundationAndDrySurface() throws Exception { + var generator = new RecipeGenerator(source(), 48); + var cells = chunk(generator, 0, 0); + assertEquals(106, generator.height(0, 0)); + assertEquals(Material.BEDROCK, cells[0][0][0]); + assertEquals(Material.STONE, cells[0][64][0]); + assertEquals(Material.DIRT, cells[0][169][0]); + assertEquals(Material.GRASS_BLOCK, cells[0][170][0]); + assertNull(cells[0][171][0]); + } + @Test void westernLakeHasRockBedAndSourceWaterWithoutGaps() throws Exception { + var generator = new RecipeGenerator(source(), 48); + var cells = chunk(generator, -14, -1); + int x = Math.floorMod(-220,16), z = Math.floorMod(-5,16); + assertEquals(38, generator.height(-220,-5)); + assertEquals(Material.STONE,cells[x][38+64][z]); + for(int y=39;y<=48;y++) assertEquals(Material.WATER,cells[x][y+64][z]); + assertNull(cells[x][49+64][z]); + assertEquals(48,generator.visibleHeight(-220,-5)); + assertEquals(38,new RecipeGenerator(source()).visibleHeight(-220,-5)); + } + @Test void neighboringNegativeChunksMatchTheGlobalField() throws Exception { + var generator = new RecipeGenerator(source(),48); + for(int cx=-2;cx<=-1;cx++) { + var cells=chunk(generator,cx,-18); + for(int x=0;x<16;x++)for(int z=0;z<16;z++) { + int top=generator.visibleHeight(cx*16+x,-18*16+z); + assertNotNull(cells[x][top+64][z]); + assertNull(cells[x][top+65][z]); + for(int y=-64;y=16&&h<=207,"Recipe clips at "+x+","+z); + } + assertEquals(22,low); + assertTrue(high>150); + assertEquals(118,generator.height(0,-120)); + assertEquals(124,generator.height(180,-140)); + } + @Test void cutOrPreservedRecipesCannotGenerateNewWorlds() throws Exception { + var source=source();source.addProperty("mode","cut"); + assertThrows(IllegalArgumentException.class,()->new RecipeGenerator(source)); + } +} diff --git a/terrain-world-plugin/src/test/java/io/github/minecraftbuilder/terrainworld/StationLiftTest.java b/terrain-world-plugin/src/test/java/io/github/minecraftbuilder/terrainworld/StationLiftTest.java new file mode 100644 index 0000000..e15d776 --- /dev/null +++ b/terrain-world-plugin/src/test/java/io/github/minecraftbuilder/terrainworld/StationLiftTest.java @@ -0,0 +1,39 @@ +package io.github.minecraftbuilder.terrainworld; + +import org.junit.jupiter.api.Test; +import org.bukkit.Material; +import static org.junit.jupiter.api.Assertions.*; + +class StationLiftTest { + @Test void partialAndProtrudingSupportsCannotBeLandingFloors() { + assertTrue(StationLift.supportsLanding(Material.WAXED_OXIDIZED_CUT_COPPER)); + assertTrue(StationLift.supportsLanding(Material.SMOOTH_SANDSTONE)); + for (Material block : new Material[]{Material.AIR,Material.SPRUCE_FENCE,Material.STONE_BRICK_WALL, + Material.SMOOTH_STONE_SLAB,Material.STONE_BRICK_STAIRS,Material.WATER}) + assertFalse(StationLift.supportsLanding(block)); + } + @Test void onlyCompletedStopsCanBeSelected() { + assertEquals(99, StationLift.feetY(1)); + assertEquals(113, StationLift.feetY(2)); + for (int floor : new int[]{-1,0,3,4,100}) + assertThrows(IllegalArgumentException.class, () -> StationLift.feetY(floor)); + } + + @Test void decorativeGoldAndOtherHeightsDoNotActAsControls() { + assertEquals(1, StationLift.selectorFloor(-6,101,-123)); + assertEquals(2, StationLift.selectorFloor(-6,115,-123)); + assertEquals(0, StationLift.selectorFloor(-5,101,-123)); + assertEquals(0, StationLift.selectorFloor(-6,101,-122)); + assertEquals(0, StationLift.selectorFloor(-6,129,-123)); + } + + @Test void controlRequiresPlayerInsideTheMatchingCabin() { + assertTrue(StationLift.inCabin(-5.5,99,-119.5,1)); + assertTrue(StationLift.inCabin(-5.5,114,-119.5,2)); + assertFalse(StationLift.inCabin(-5.5,99,-119.5,2)); + assertFalse(StationLift.inCabin(-5.5,113,-119.5,1)); + assertFalse(StationLift.inCabin(-5.5,98.9,-119.5,1)); + assertFalse(StationLift.inCabin(-5.5,99,-117,1)); + assertFalse(StationLift.inCabin(-8.1,99,-119.5,1)); + } +} diff --git a/terrain-world-plugin/src/test/java/io/github/minecraftbuilder/terrainworld/SurfaceMapCaptureTest.java b/terrain-world-plugin/src/test/java/io/github/minecraftbuilder/terrainworld/SurfaceMapCaptureTest.java new file mode 100644 index 0000000..f7fefa8 --- /dev/null +++ b/terrain-world-plugin/src/test/java/io/github/minecraftbuilder/terrainworld/SurfaceMapCaptureTest.java @@ -0,0 +1,44 @@ +package io.github.minecraftbuilder.terrainworld; + +import org.bukkit.Material; +import org.junit.jupiter.api.Test; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class SurfaceMapCaptureTest { + @Test void roofAndAirGapsDoNotHideGardenOrWater() { + for (Material visible : List.of(Material.SPRUCE_LEAVES, Material.WATER, Material.STONE_SLAB)) { + List reads = new ArrayList<>(); + var column = SurfaceMapCapture.visibleColumn(-64, 122, y -> { + reads.add(y); + return switch (y) { + case 122, 121 -> Material.BARRIER; + case 120 -> Material.AIR; + case 119 -> Material.CAVE_AIR; + case 118 -> Material.VOID_AIR; + case 117 -> visible; + default -> throw new AssertionError("Read beneath first visible block"); + }; + }); + assertEquals(117, column.y()); + assertEquals(visible, column.material()); + assertEquals(List.of(122, 121, 120, 119, 118, 117), reads); + } + } + + @Test void emptyColumnHasAirSentinelAndNeverReadsBelowWorld() { + List reads = new ArrayList<>(); + var column = SurfaceMapCapture.visibleColumn(-64, -62, y -> { + assertTrue(y >= -64); + reads.add(y); + return y == -63 ? Material.BARRIER : Material.AIR; + }); + assertEquals(new SurfaceMapCapture.Column(-64, Material.AIR), column); + assertEquals(List.of(-62, -63, -64), reads); + assertEquals(new SurfaceMapCapture.Column(-64, Material.BEDROCK), + SurfaceMapCapture.visibleColumn(-64, -65, y -> { + assertEquals(-64, y); + return Material.BEDROCK; + })); + } +} diff --git a/terrain-world-plugin/src/test/java/io/github/minecraftbuilder/terrainworld/SurfaceMapTest.java b/terrain-world-plugin/src/test/java/io/github/minecraftbuilder/terrainworld/SurfaceMapTest.java new file mode 100644 index 0000000..ade7c35 --- /dev/null +++ b/terrain-world-plugin/src/test/java/io/github/minecraftbuilder/terrainworld/SurfaceMapTest.java @@ -0,0 +1,76 @@ +package io.github.minecraftbuilder.terrainworld; + +import com.google.gson.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import javax.imageio.ImageIO; +import java.nio.file.*; +import java.time.Instant; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class SurfaceMapTest { + @TempDir Path directory; + + @Test void exportedGridPreservesCoordinatesWaterAndMarkersIndependentOfCaptureOrder() throws Exception { + SurfaceMap map = new SurfaceMap(-17, -1, -16, 0); + map.setColumn(-16, 0, 81, "minecraft:magenta_concrete"); + map.setColumn(-17, -1, 48, "minecraft:water"); + map.setColumn(-16, -1, 65, "minecraft:stone"); + map.setColumn(-17, 0, 80, "minecraft:grass_block"); + Instant start = Instant.parse("2026-09-13T00:00:00Z"), end = start.plusSeconds(2); + map.write(directory, "live-01", "lobby", "minecraft:lobby", "world-uuid", start, end); + JsonObject json = JsonParser.parseString(Files.readString(directory.resolve("live-01.json"))).getAsJsonObject(); + assertEquals(-17, json.get("min_x").getAsInt()); + assertEquals(2, json.get("width").getAsInt()); + assertEquals(4, json.get("columns").getAsInt()); + assertEquals(48, json.get("min_surface_y").getAsInt()); + assertEquals(81, json.get("max_surface_y").getAsInt()); + assertEquals(start.toString(), json.get("capture_started_at").getAsString()); + assertEquals(end.toString(), json.get("capture_finished_at").getAsString()); + assertFalse(json.get("atomic_snapshot").getAsBoolean()); + assertEquals(List.of("minecraft:barrier"), json.getAsJsonArray("ignored_materials").asList() + .stream().map(JsonElement::getAsString).toList()); + assertTrue(json.get("surface_policy").getAsString().contains("all air variants")); + assertEquals(List.of(48, 65, 80, 81), json.getAsJsonArray("surface_y").asList().stream().map(JsonElement::getAsInt).toList()); + JsonArray palette = json.getAsJsonArray("palette"), indices = json.getAsJsonArray("material_index"); + assertEquals("minecraft:water", palette.get(indices.get(0).getAsInt()).getAsString()); + assertEquals("minecraft:magenta_concrete", palette.get(indices.get(3).getAsInt()).getAsString()); + var image = ImageIO.read(directory.resolve("live-01.png").toFile()); + assertEquals(2, image.getWidth()); assertEquals(2, image.getHeight()); + assertEquals(SurfaceMap.color("minecraft:magenta_concrete"), image.getRGB(1, 1) & 0xffffff); + } + + @Test void incompleteOrDuplicateColumnsCannotProduceMisleadingMaps() { + SurfaceMap map = new SurfaceMap(0, 0, 1, 0); + map.setColumn(0, 0, 3, "minecraft:stone"); + assertThrows(IllegalStateException.class, () -> map.setColumn(0, 0, 4, "minecraft:stone")); + assertThrows(IllegalArgumentException.class, () -> map.setColumn(-1, 0, 4, "minecraft:stone")); + assertThrows(IllegalStateException.class, map::render); + assertThrows(IllegalStateException.class, () -> map.write(directory, "incomplete", "w", "w", "w", Instant.now(), Instant.now())); + assertFalse(Files.exists(directory.resolve("incomplete.json"))); + } + + @Test void filenamesCannotTraverseOrOverwriteExistingArtifacts() throws Exception { + for (String name : List.of("../bad", "/tmp/map", "UPPER", "", "a.b", "a".repeat(49))) + assertThrows(IllegalArgumentException.class, () -> SurfaceMap.validateName(name)); + SurfaceMap map = new SurfaceMap(0, 0, 0, 0); + map.setColumn(0, 0, 1, "minecraft:yellow_concrete"); + Files.writeString(directory.resolve("existing.png"), "keep"); + assertThrows(FileAlreadyExistsException.class, () -> map.write(directory, "existing", "w", "w", "w", Instant.now(), Instant.now())); + assertEquals("keep", Files.readString(directory.resolve("existing.png"))); + assertFalse(Files.exists(directory.resolve("existing.json"))); + } + + @Test void limitsRejectOverflowAndEveryConcreteColorHasItsOwnPigment() { + assertThrows(IllegalArgumentException.class, () -> new SurfaceMap(Integer.MIN_VALUE, 0, Integer.MAX_VALUE, 0)); + assertThrows(IllegalArgumentException.class, () -> new SurfaceMap(0, 0, 4096, 4096)); + assertThrows(IllegalArgumentException.class, () -> new SurfaceMap(1, 0, 0, 0)); + Set colors = new HashSet<>(); + for (String color : List.of("white", "orange", "magenta", "light_blue", "yellow", "lime", "pink", "gray", + "light_gray", "cyan", "purple", "blue", "brown", "green", "red", "black")) + colors.add(SurfaceMap.color("minecraft:" + color + "_concrete")); + assertEquals(16, colors.size()); + assertNotEquals(SurfaceMap.color("minecraft:water"), SurfaceMap.color("minecraft:grass_block")); + } +} diff --git a/world-core/src/main/java/io/github/minecraftbuilder/core/EditEngine.java b/world-core/src/main/java/io/github/minecraftbuilder/core/EditEngine.java index 5bbad9a..246eb11 100644 --- a/world-core/src/main/java/io/github/minecraftbuilder/core/EditEngine.java +++ b/world-core/src/main/java/io/github/minecraftbuilder/core/EditEngine.java @@ -29,6 +29,8 @@ import java.util.concurrent.ConcurrentHashMap; * No world writes occur before a durable intent, nor before the post-IO live recheck. */ public final class EditEngine { + /** Bounds durable snapshot memory before any plan persistence or world mutation. */ + static final long MAX_PLAN_SNAPSHOT_BYTES = 8L * 1024 * 1024; private final WorldAccess world; private final BlockPolicy policy; private final ContextGuard guard; @@ -160,14 +162,19 @@ public final class EditEngine { for (BlockPos position : dependencyPositions) requireInside(region, position); List changes = new ArrayList<>(); Map captured = new HashMap<>(); + long snapshotBytes = 0; for (var entry : new TreeMap<>(desired).entrySet()) { String before = read(entry.getKey()); requireSupported(before); captured.put(entry.getKey(), before); - changes.add(new Change(entry.getKey(), before, entry.getValue())); + String after = Objects.requireNonNull(world.prepareBlock(entry.getKey(), entry.getValue(), before)); + requireSupported(after); + snapshotBytes = addSnapshotBytes(snapshotBytes, before, after); + changes.add(new Change(entry.getKey(), before, after)); } List dependencies = new ArrayList<>(); for (BlockPos position : dependencyPositions.stream().sorted().toList()) { String before = captured.containsKey(position) ? captured.get(position) : read(position); + snapshotBytes = addSnapshotBytes(snapshotBytes, before); requireSupported(before); dependencies.add(new Plan.Dependency(position, before)); } Plan plan = new Plan(id, projectId, worldEpoch, region, changes, dependencies, now, @@ -321,7 +328,7 @@ public final class EditEngine { if (current.equals(change.desired())) { op.skipped++; op.cursor++; } else { try { - world.setBlock(change.pos(), change.desired()); + world.setCapturedBlock(change.pos(), change.desired()); String actual = read(change.pos()); if (!actual.equals(change.desired())) { recovery(op, "Write result is uncertain at " + change.pos()); break; @@ -616,9 +623,17 @@ public final class EditEngine { if (op == null) throw new IllegalArgumentException("Unknown operation"); return op; } - private String read(BlockPos pos) { return Objects.requireNonNull(world.getBlock(pos), "World returned null state"); } + private String read(BlockPos pos) { return Objects.requireNonNull(world.captureBlock(pos), "World returned null snapshot"); } + private static long addSnapshotBytes(long used, String... values) { + for (String value : values) { + used += value.getBytes(StandardCharsets.UTF_8).length; + if (used > MAX_PLAN_SNAPSHOT_BYTES) + throw new IllegalArgumentException("snapshot_budget_exceeded: split the edit into smaller plans (8 MiB snapshot limit)"); + } + return used; + } private void requireSupported(String state) { - if (state == null || !policy.supports(state)) throw new IllegalArgumentException("Unsupported block state: " + state); + if (state == null || !policy.supports(state)) throw new IllegalArgumentException("Unsupported block state or stored snapshot"); } private static void requireInside(Region region, BlockPos pos) { if (pos == null || !region.contains(pos)) throw new IllegalArgumentException("Position outside authorized region: " + pos); @@ -632,11 +647,17 @@ public final class EditEngine { if (plan.changes().size() > limits.maxChanges() || plan.dependencies().size() > limits.maxReadDependencies()) throw new IOException("Stored plan exceeds configured limits"); Set positions = new HashSet<>(); + long snapshotBytes = 0; for (Change change : plan.changes()) { if (!plan.region().contains(change.pos()) || !positions.add(change.pos())) throw new IOException("Stored plan contains invalid/duplicate positions"); + try { snapshotBytes = addSnapshotBytes(snapshotBytes, change.expected(), change.desired()); } + catch (IllegalArgumentException e) { throw new IOException("Stored plan exceeds snapshot budget", e); } } - for (Plan.Dependency dependency : plan.dependencies()) + for (Plan.Dependency dependency : plan.dependencies()) { if (!plan.region().contains(dependency.pos())) throw new IOException("Stored dependency is outside region"); + try { snapshotBytes = addSnapshotBytes(snapshotBytes, dependency.expected()); } + catch (IllegalArgumentException e) { throw new IOException("Stored plan exceeds snapshot budget", e); } + } } } diff --git a/world-core/src/main/java/io/github/minecraftbuilder/core/RecipeCompiler.java b/world-core/src/main/java/io/github/minecraftbuilder/core/RecipeCompiler.java index 5eac9c8..fea4e8c 100644 --- a/world-core/src/main/java/io/github/minecraftbuilder/core/RecipeCompiler.java +++ b/world-core/src/main/java/io/github/minecraftbuilder/core/RecipeCompiler.java @@ -117,7 +117,7 @@ public final class RecipeCompiler { } private static String block(JsonObject object) { String block = string(object, "block"); - if (block.length() > 512 || !block.matches("minecraft:[a-z0-9_]+(?:\\[[a-z0-9_=,]+\\])?")) + if (block.length() > 1024 || !block.matches("minecraft:[a-z0-9_]+(?:\\[[a-z0-9_=,]+\\])?")) throw new IllegalArgumentException("Expected a Minecraft block state string"); return block; } diff --git a/world-core/src/main/java/io/github/minecraftbuilder/core/TerrainBrush.java b/world-core/src/main/java/io/github/minecraftbuilder/core/TerrainBrush.java new file mode 100644 index 0000000..0a64c17 --- /dev/null +++ b/world-core/src/main/java/io/github/minecraftbuilder/core/TerrainBrush.java @@ -0,0 +1,117 @@ +package io.github.minecraftbuilder.core; + +import com.google.gson.*; +import java.util.*; + +/** Relative brush over one bounded immutable snapshot. Does not access or mutate a world. */ +public final class TerrainBrush { + public record Spec(Region bounds, int centerX, int centerZ, int radius, String action, + int amount, int targetHeight, double strength, double falloff, int smoothRadius, + List preserve) { + public Spec { + Objects.requireNonNull(bounds);preserve=List.copyOf(preserve); + if(bounds.volume()>4096 || bounds.volume()<1)throw bad("Brush scan window must contain at most 4096 cells"); + if(radius<1||radius>16||smoothRadius<1||smoothRadius>3)throw bad("Radius must be 1..16; smoothing radius 1..3"); + if(!Set.of("raise","lower","flatten","smooth").contains(action))throw bad("Unknown brush action"); + if(amount<1||amount>32||!Double.isFinite(strength)||strength<0||strength>1||!Double.isFinite(falloff)||falloff<0||falloff>1)throw bad("Invalid brush strength, falloff or amount"); + int halo=action.equals("smooth")?smoothRadius:0; + if((long)centerX-radius-halobounds.max().x() + ||(long)centerZ-radius-halobounds.max().z())throw bad("Scan window must include the whole brush and smoothing halo"); + if(preserve.size()>64)throw bad("At most 64 preserve boxes"); + for(Region p:preserve)if(!bounds.contains(p.min())||!bounds.contains(p.max()))throw bad("Preserve box exceeds scan window"); + } + } + public record Result(Map desired, Set dependencies, Region bounds, + int[][] before, int[][] after, int changedColumns, int raisedBlocks, int loweredBlocks) { } + private TerrainBrush() { } + public static Spec parse(JsonObject j) { + fields(j,"min","max","center","radius","action","amount","height","strength","falloff","smooth_radius","preserve"); + Region bounds=new Region("brush",pos(obj(j,"min")),pos(obj(j,"max"))); + JsonObject center=obj(j,"center");fields(center,"x","z");String action=text(j,"action"); + if((action.equals("raise")||action.equals("lower"))&&!j.has("amount"))throw bad("raise/lower require amount"); + if(action.equals("flatten")&&!j.has("height"))throw bad("flatten requires height"); + if(j.has("amount")&&!Set.of("raise","lower").contains(action))throw bad("amount is only for raise/lower"); + if(j.has("height")&&!action.equals("flatten"))throw bad("height is only for flatten"); + if(j.has("smooth_radius")&&!action.equals("smooth"))throw bad("smooth_radius is only for smooth"); + List preserves=new ArrayList<>(); + if(j.has("preserve")){ + if(!j.get("preserve").isJsonArray()||j.getAsJsonArray("preserve").size()>64)throw bad("Invalid preserve boxes"); + for(JsonElement e:j.getAsJsonArray("preserve")){if(!e.isJsonObject())throw bad("Invalid preserve box");JsonObject p=e.getAsJsonObject();fields(p,"min","max");preserves.add(new Region("brush",pos(obj(p,"min")),pos(obj(p,"max"))));} + } + return new Spec(bounds,integer(center,"x"),integer(center,"z"),integer(j,"radius"),action, + j.has("amount")?integer(j,"amount"):1,j.has("height")?integer(j,"height"):0, + j.has("strength")?number(j,"strength"):1,j.has("falloff")?number(j,"falloff"):0.5, + j.has("smooth_radius")?integer(j,"smooth_radius"):1,preserves); + } + public static Result compile(Spec spec,Map snapshot,int budget) { + Region b=spec.bounds();if(snapshot.size()!=b.volume())throw bad("Snapshot must cover the entire scan window"); + int width=b.max().x()-b.min().x()+1,length=b.max().z()-b.min().z()+1; + int[][] before=new int[length][width],after=new int[length][width]; + for(int z=b.min().z();z<=b.max().z();z++)for(int x=b.min().x();x<=b.max().x();x++){ + int top=Integer.MIN_VALUE; + for(int y=b.max().y();y>=b.min().y();y--){ + String state=snapshot.get(new BlockPos(x,y,z));if(state==null)throw bad("Missing snapshot cell"); + if(!TerrainRecipe.replaceable(state))throw bad("Brush scan contains a non-terrain block at "+new BlockPos(x,y,z)); + if(!air(state)&&top==Integer.MIN_VALUE)top=y; + } + if(top==Integer.MIN_VALUE)throw bad("No terrain surface in column "+x+","+z+"; extend scan downward"); + if(top==b.max().y())throw bad("Surface touches scan ceiling at "+x+","+z+"; include air above terrain"); + before[z-b.min().z()][x-b.min().x()]=top;after[z-b.min().z()][x-b.min().x()]=top; + } + Map desired=new LinkedHashMap<>();int columns=0,raised=0,lowered=0; + for(int z=b.min().z();z<=b.max().z();z++)for(int x=b.min().x();x<=b.max().x();x++){ + double distance=Math.hypot((long)x-spec.centerX(),(long)z-spec.centerZ()); + if(distance>spec.radius())continue; + double weight=weight(distance,spec.radius(),spec.falloff())*spec.strength(); + int old=before[z-b.min().z()][x-b.min().x()];double target; + switch(spec.action()){ + case "raise" -> target=old+spec.amount(); + case "lower" -> target=old-spec.amount(); + case "flatten" -> target=spec.targetHeight(); + case "smooth" -> { + long sum=0;int count=0,r=spec.smoothRadius(); + for(int dz=-r;dz<=r;dz++)for(int dx=-r;dx<=r;dx++){ + sum+=before[z+dz-b.min().z()][x+dx-b.min().x()];count++; + } + target=(double)sum/count; + } + default -> throw new AssertionError(); + } + // Symmetric rounding of displacement: lowering and raising have equal strength. + double delta=(target-old)*weight;int next=old+(int)(Math.copySign(Math.floor(Math.abs(delta)+0.5),delta)); + if(next==old)continue; + if(next=b.max().y())throw bad("Target surface leaves scan window; extend vertical bounds (keep air above)"); + int from=Math.min(old,next),to=Math.max(old,next); + boolean protectedColumn=false; + for(int y=from;y<=to;y++){BlockPos at=new BlockPos(x,y,z);if(spec.preserve().stream().anyMatch(p->p.contains(at))){protectedColumn=true;break;}} + if(protectedColumn)continue; // Preserve the whole edit column instead of tearing a hole in the terrain. + String top=snapshot.get(new BlockPos(x,old,z)); + if(next>old){ + String under=snapshot.get(new BlockPos(x,old-1,z)); + String fill=under!=null&&!air(under)?under:subsoil(top); + // Move the top surface up, using existing subsoil rather than burying grass layers. + for(int y=old;ye.getValue().equals(snapshot.get(e.getKey()))); + if(desired.size()>budget)throw bad("Brush exceeds plan budget; use a smaller radius or amount"); + return new Result(Collections.unmodifiableMap(desired),Set.copyOf(snapshot.keySet()),b,before,after,columns,raised,lowered); + } + private static boolean air(String s){return s!=null&&Set.of("minecraft:air","minecraft:cave_air","minecraft:void_air").contains(s);} + private static String subsoil(String top){return top.startsWith("minecraft:grass_block")||top.equals("minecraft:moss_block")?"minecraft:dirt":top;} + private static double weight(double d,int radius,double falloff){if(falloff==0)return 1;double core=radius*(1-falloff);if(d<=core)return 1;double t=Math.min(1,(d-core)/(radius-core));return 1-t*t*(3-2*t);} + private static IllegalArgumentException bad(String m){return new IllegalArgumentException(m);} + private static JsonObject obj(JsonObject j,String k){if(j==null||!j.has(k)||!j.get(k).isJsonObject())throw bad("Expected object: "+k);return j.getAsJsonObject(k);} + private static String text(JsonObject j,String k){if(!j.has(k)||!j.get(k).isJsonPrimitive()||!j.getAsJsonPrimitive(k).isString())throw bad("Expected text: "+k);return j.get(k).getAsString();} + private static double number(JsonObject j,String k){if(!j.has(k)||!j.get(k).isJsonPrimitive()||!j.getAsJsonPrimitive(k).isNumber())throw bad("Expected number: "+k);double n=j.get(k).getAsDouble();if(!Double.isFinite(n))throw bad("Nonfinite number");return n;} + private static int integer(JsonObject j,String k){number(j,k);try{return j.get(k).getAsBigDecimal().intValueExact();}catch(Exception e){throw bad("Expected 32-bit integer: "+k);}} + private static BlockPos pos(JsonObject j){fields(j,"x","y","z");int x=integer(j,"x"),y=integer(j,"y"),z=integer(j,"z");if(Math.abs((long)x)>30_000_000||Math.abs((long)z)>30_000_000||y< -4096||y>4096)throw bad("Coordinate out of range");return new BlockPos(x,y,z);} + private static void fields(JsonObject j,String... keys){if(j==null)throw bad("Expected brush object");Set allowed=Set.of(keys);for(String k:j.keySet())if(!allowed.contains(k))throw bad("Unknown brush field: "+k);} +} diff --git a/world-core/src/main/java/io/github/minecraftbuilder/core/TerrainRecipe.java b/world-core/src/main/java/io/github/minecraftbuilder/core/TerrainRecipe.java new file mode 100644 index 0000000..f243636 --- /dev/null +++ b/world-core/src/main/java/io/github/minecraftbuilder/core/TerrainRecipe.java @@ -0,0 +1,140 @@ +package io.github.minecraftbuilder.core; + +import com.google.gson.*; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.*; + +/** Pure world-coordinate height field. Tiling never changes noise, features or surface layers. */ +public final class TerrainRecipe { + public static final Set MATERIALS = Set.of("stone", "andesite", "granite", "diorite", + "deepslate", "cobbled_deepslate", "dirt", "grass_block", "moss_block", "sandstone", "terracotta"); + private final Region bounds; + private final int base, seed, depth; + private final double amplitude, scale; + private final String mode, rock, soil, surface, id; + private final List features; + private final List preserves; + private record Point(double x, double z) { } + private record Feature(String type, Point a, Point b, double radius, double falloff, + double height, List points) { } + public record Tile(Region bounds, Map blocks) { } + + public TerrainRecipe(JsonObject json) { + fields(json,"version","min","max","base_height","seed","noise","mode","palette","features","preserve"); + if (integer(json,"version",1,1)!=1) throw invalid("Unsupported terrain version"); + bounds=new Region("terrain",pos(object(json,"min")),pos(object(json,"max"))); + if (width()>2048 || length()>2048 || height()>384 || (long)width()*length()>1_048_576 + || bounds.volume()>134_217_728) throw invalid("Terrain envelope exceeds 2048 per side, 384 height, 1M columns or 128M voxels"); + base=integer(json,"base_height",-4096,4096); seed=integer(json,"seed",Integer.MIN_VALUE,Integer.MAX_VALUE); + mode=text(json,"mode"); if(!Set.of("sculpt","fill","cut").contains(mode))throw invalid("Mode must be sculpt, fill or cut"); + JsonObject noise=object(json,"noise");fields(noise,"amplitude","scale"); + amplitude=number(noise,"amplitude",0,256);scale=number(noise,"scale",1,4096); + JsonObject palette=object(json,"palette");fields(palette,"rock","soil","surface","soil_depth"); + rock=material(palette,"rock");soil=material(palette,"soil");surface=material(palette,"surface");depth=integer(palette,"soil_depth",0,16); + List parsed=new ArrayList<>(); + for(JsonElement e:array(json,"features",64)) { + if(!e.isJsonObject())throw invalid("Feature must be an object"); + JsonObject f=e.getAsJsonObject();String type=text(f,"type"); + switch(type) { + case "hill", "basin" -> { + fields(f,"type","center","radius","height","falloff"); + Point c=point(object(f,"center"));double r=number(f,"radius",1,2048); + parsed.add(new Feature(type,c,null,r,number(f,"falloff",1,2048),number(f,"height",-4096,4096),List.of())); + } + case "plateau" -> { + fields(f,"type","min","max","height","falloff");Point a=point(object(f,"min")),b=point(object(f,"max")); + if(a.x>b.x||a.z>b.z)throw invalid("Invalid plateau bounds"); + parsed.add(new Feature(type,a,b,0,number(f,"falloff",1,2048),number(f,"height",-4096,4096),List.of())); + } + case "ridge", "channel" -> { + fields(f,"type","points","width","falloff","height");List points=new ArrayList<>(); + for(JsonElement p:array(f,"points",32)) {if(!p.isJsonObject())throw invalid("Expected path point");points.add(point(p.getAsJsonObject()));} + if(points.size()<2)throw invalid("A path needs 2..32 points"); + parsed.add(new Feature(type,null,null,number(f,"width",1,1024),number(f,"falloff",1,2048),number(f,"height",-4096,4096),List.copyOf(points))); + } + case "terrace" -> { + fields(f,"type","step","strength"); + parsed.add(new Feature(type,null,null,number(f,"step",1,128),0,number(f,"strength",0,1),List.of())); + } + default -> throw invalid("Unsupported terrain feature: "+type); + } + } + features=List.copyOf(parsed);List excluded=new ArrayList<>(); + for(JsonElement e:array(json,"preserve",64)) { + if(!e.isJsonObject())throw invalid("Preserve box must be an object");JsonObject box=e.getAsJsonObject();fields(box,"min","max"); + Region r=new Region("terrain",pos(object(box,"min")),pos(object(box,"max"))); + if(!bounds.contains(r.min())||!bounds.contains(r.max()))throw invalid("Preserve box exceeds envelope");excluded.add(r); + } + preserves=List.copyOf(excluded); + try { id=HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical(json).toString().getBytes(StandardCharsets.UTF_8))); } + catch(java.security.NoSuchAlgorithmException e){throw new AssertionError(e);} + } + public Region bounds(){return bounds;} + public String id(){return id;} + public String mode(){return mode;} + public int width(){return bounds.max().x()-bounds.min().x()+1;} + public int length(){return bounds.max().z()-bounds.min().z()+1;} + public int height(){return bounds.max().y()-bounds.min().y()+1;} + public boolean preserved(BlockPos at){return preserves.stream().anyMatch(r->r.contains(at));} + public static boolean replaceable(String state) { + String name=state.split("\\[",2)[0];return name.equals("minecraft:air") || name.equals("minecraft:cave_air") + || name.equals("minecraft:void_air") || (name.startsWith("minecraft:") && MATERIALS.contains(name.substring(10))); + } + /** Unclamped surface. Layers use this height even across vertical tile boundaries. */ + public int surfaceHeight(int x,int z) { + double h=base+amplitude*(noise(x/scale,z/scale)+0.5*noise(x/(scale/2),z/(scale/2))+0.25*noise(x/(scale/4),z/(scale/4)))/1.75; + for(Feature f:features) { + if(f.type.equals("terrace")){h=lerp(h,Math.floor(h/f.radius)*f.radius,f.height);continue;} + double d; + if(f.type.equals("plateau"))d=Math.hypot(Math.max(Math.max(f.a.x-x,0),x-f.b.x),Math.max(Math.max(f.a.z-z,0),z-f.b.z)); + else if(f.type.equals("hill")||f.type.equals("basin"))d=Math.max(0,Math.hypot(x-f.a.x,z-f.a.z)-f.radius); + else {d=Double.POSITIVE_INFINITY;for(int i=1;i h+=f.height*w; + case "basin","channel" -> h=lerp(h,Math.min(h,f.height),w); + case "plateau" -> h=lerp(h,f.height,w); + default -> throw new AssertionError(f.type); + } + } + return (int)Math.floor(h); + } + private double noise(double x,double z){int ix=(int)Math.floor(x),iz=(int)Math.floor(z);return lerp(lerp(hash(ix,iz),hash(ix+1,iz),smooth(x-ix)),lerp(hash(ix,iz+1),hash(ix+1,iz+1),smooth(x-ix)),smooth(z-iz));} + private double hash(int x,int z){long v=seed^((long)x*0x9E3779B97F4A7C15L)^((long)z*0xC2B2AE3D27D4EB4FL);v=(v^(v>>>30))*0xBF58476D1CE4E5B9L;v=(v^(v>>>27))*0x94D049BB133111EBL;v^=v>>>31;return (v>>>11)*0x1.0p-53*2-1;} + private static double segment(double x,double z,Point a,Point b){double dx=b.x-a.x,dz=b.z-a.z,l=dx*dx+dz*dz;double t=l==0?0:Math.max(0,Math.min(1,((x-a.x)*dx+(z-a.z)*dz)/l));return Math.hypot(x-a.x-t*dx,z-a.z-t*dz);} + private static double smooth(double t){return t*t*(3-2*t);} + private static double lerp(double a,double b,double t){return a+(b-a)*t;} + public int tileEdge(int limit){if(limit<1||limit>4096)throw invalid("Tile budget must be 1..4096");int e=1;while((e+1)*(e+1)*(e+1)<=limit&&e<16)e++;return e;} + private static int ceil(int n,int d){return (n+d-1)/d;} + public int tileCount(int limit){int e=tileEdge(limit);return Math.multiplyExact(Math.multiplyExact(ceil(width(),e),ceil(length(),e)),ceil(height(),e));} + /** Tile index order: X fastest, then Z, then Y. Envelope minimum is the stable tiling origin. */ + public Tile tile(int index,int limit){ + int edge=tileEdge(limit),nx=ceil(width(),edge),nz=ceil(length(),edge); + if(index<0||index>=tileCount(limit))throw invalid("Tile index outside terrain"); + BlockPos min=bounds.min().add(new BlockPos(index%nx*edge,index/(nx*nz)*edge,index/nx%nz*edge)); + BlockPos max=new BlockPos(Math.min(min.x()+edge-1,bounds.max().x()),Math.min(min.y()+edge-1,bounds.max().y()),Math.min(min.z()+edge-1,bounds.max().z())); + Map desired=new LinkedHashMap<>(); + for(int z=min.z();z<=max.z();z++)for(int x=min.x();x<=max.x();x++){ + int h=surfaceHeight(x,z); + for(int y=min.y();y<=max.y();y++){ + if(mode.equals("fill")&&y>h || mode.equals("cut")&&y<=h)continue; + BlockPos at=new BlockPos(x,y,z);if(preserved(at))continue; + desired.put(at,y>h?"minecraft:air":y==h?surface:y>=h-depth?soil:rock); + } + } + return new Tile(new Region("terrain",min,max),Collections.unmodifiableMap(desired)); + } + private static String material(JsonObject o,String key){String s=text(o,key);if(!s.startsWith("minecraft:")||!MATERIALS.contains(s.substring(10)))throw invalid("Unsupported terrain material: "+s);return s;} + private static Point point(JsonObject o){fields(o,"x","z");return new Point(integer(o,"x",-30_000_000,30_000_000),integer(o,"z",-30_000_000,30_000_000));} + private static BlockPos pos(JsonObject o){fields(o,"x","y","z");return new BlockPos(integer(o,"x",-30_000_000,30_000_000),integer(o,"y",-4096,4096),integer(o,"z",-30_000_000,30_000_000));} + private static JsonElement required(JsonObject o,String k){if(o==null||!o.has(k)||o.get(k).isJsonNull())throw invalid("Missing field: "+k);return o.get(k);} + private static JsonObject object(JsonObject o,String k){JsonElement e=required(o,k);if(!e.isJsonObject())throw invalid(k+" must be an object");return e.getAsJsonObject();} + private static JsonArray array(JsonObject o,String k,int max){JsonElement e=required(o,k);if(!e.isJsonArray()||e.getAsJsonArray().size()>max)throw invalid(k+" must be an array of at most "+max);return e.getAsJsonArray();} + private static String text(JsonObject o,String k){JsonElement e=required(o,k);if(!e.isJsonPrimitive()||!e.getAsJsonPrimitive().isString())throw invalid(k+" must be text");return e.getAsString();} + private static double number(JsonObject o,String k,double min,double max){JsonElement e=required(o,k);if(!e.isJsonPrimitive()||!e.getAsJsonPrimitive().isNumber())throw invalid(k+" must be numeric");double v=e.getAsDouble();if(!Double.isFinite(v)||vmax)throw invalid(k+" out of range");return v;} + private static int integer(JsonObject o,String k,int min,int max){JsonElement e=required(o,k);if(!e.isJsonPrimitive()||!e.getAsJsonPrimitive().isNumber()||!e.getAsString().matches("-?(0|[1-9][0-9]*)"))throw invalid(k+" must be an integer");double v=number(o,k,min,max);return (int)v;} + private static void fields(JsonObject o,String... keys){if(o==null)throw invalid("Expected object");Set allowed=Set.of(keys);for(String k:o.keySet())if(!allowed.contains(k))throw invalid("Unknown field: "+k);} + private static IllegalArgumentException invalid(String message){return new IllegalArgumentException(message);} + private static JsonElement canonical(JsonElement e){if(e.isJsonObject()){JsonObject r=new JsonObject();new TreeSet<>(e.getAsJsonObject().keySet()).forEach(k->r.add(k,canonical(e.getAsJsonObject().get(k))));return r;}if(e.isJsonArray()){JsonArray a=new JsonArray();e.getAsJsonArray().forEach(v->a.add(canonical(v)));return a;}return e.deepCopy();} +} diff --git a/world-core/src/main/java/io/github/minecraftbuilder/core/WorldAccess.java b/world-core/src/main/java/io/github/minecraftbuilder/core/WorldAccess.java index 88ca582..21ab2b9 100644 --- a/world-core/src/main/java/io/github/minecraftbuilder/core/WorldAccess.java +++ b/world-core/src/main/java/io/github/minecraftbuilder/core/WorldAccess.java @@ -3,6 +3,15 @@ package io.github.minecraftbuilder.core; /** The adapter must call all engine methods that touch this interface on its world thread. */ public interface WorldAccess { String getBlock(BlockPos position); - /** Apply canonical state without physics; unsupported side effects must be excluded by BlockPolicy. */ + /** Public canonical block data; callers cannot supply private captured payloads through this method. */ void setBlock(BlockPos position, String canonicalState); + + /** Durable comparison/undo value, including private block-entity data when the adapter supports it. */ + default String captureBlock(BlockPos position) { return getBlock(position); } + + /** Resolve the desired durable value without mutating the world. Captured undo values stay exact. */ + default String prepareBlock(BlockPos position, String desired, String capturedBefore) { return desired; } + + /** Restore a durable captured value. Must suppress immediate drop/removal side effects. */ + default void setCapturedBlock(BlockPos position, String capturedState) { setBlock(position, capturedState); } } diff --git a/world-core/src/test/java/io/github/minecraftbuilder/core/BlockEntityJournalTest.java b/world-core/src/test/java/io/github/minecraftbuilder/core/BlockEntityJournalTest.java new file mode 100644 index 0000000..d0d4526 --- /dev/null +++ b/world-core/src/test/java/io/github/minecraftbuilder/core/BlockEntityJournalTest.java @@ -0,0 +1,95 @@ +package io.github.minecraftbuilder.core; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import static org.junit.jupiter.api.Assertions.*; +import static io.github.minecraftbuilder.core.EditEngineTest.*; + +/** Exercises the adapter snapshot contract through the actual durable journal and edit state machine. */ +final class BlockEntityJournalTest { + @TempDir Path directory; + private static final String CHEST = "minecraft:chest[facing=north]"; + private static final String ROTATED = "minecraft:chest[facing=east]"; + private static final String ITEMS = "|private inventory: diamond sword, signed book, custom plugin data"; + static final class EntityWorld implements WorldAccess { + final Map values = new HashMap<>(); + int writes; + public String getBlock(BlockPos p) { return state(captureBlock(p)); } + public String captureBlock(BlockPos p) { return values.getOrDefault(p, AIR); } + public String prepareBlock(BlockPos p, String desired, String before) { + if (desired.contains("|")) return desired; + if (desired.startsWith("minecraft:chest")) + return desired + (before.contains("|") ? before.substring(before.indexOf('|')) : "|empty inventory"); + return desired; + } + public void setBlock(BlockPos p, String desired) { fail("The editor must restore the complete captured value"); } + public void setCapturedBlock(BlockPos p, String desired) { values.put(p, desired); writes++; } + private static String state(String value) { return value.split("\\|", 2)[0]; } + } + private EditEngine engine(EntityWorld world, Journal journal) throws Exception { + return new EditEngine(world, state -> state.startsWith("minecraft:"), plan -> {}, journal, LIMITS); + } + @Test void overwriteAndUndoAfterRestartRestoresPrivateDataFromDurableJournal() throws Exception { + EntityWorld world = new EntityWorld(); world.values.put(A, CHEST + ITEMS); + var journal = new JsonJournal(directory); + EditEngine original = engine(world, journal); + Plan plan = prepare(original, Map.of(A, STONE)); + assertEquals(CHEST + ITEMS, plan.changes().get(0).expected()); + assertEquals(0, world.writes, "prepare must not temporarily place a default block"); + String id = start(original, plan); + assertEquals(OperationStatus.APPLIED, finish(original, id).status()); + assertEquals(STONE, world.captureBlock(A)); + + EditEngine restarted = engine(world, new JsonJournal(directory)); + Plan undo = restarted.prepareUndo(id); restarted.persistPlan(undo.id()); + assertEquals(CHEST + ITEMS, undo.changes().get(0).desired()); + assertEquals(OperationStatus.APPLIED, finish(restarted, start(restarted, undo)).status()); + assertEquals(CHEST + ITEMS, world.captureBlock(A)); + } + @Test void blockDataChangeRetainsContentsAndUndoDetectsManualInventoryChanges() throws Exception { + EntityWorld world = new EntityWorld(); world.values.put(A, CHEST + ITEMS); + EditEngine editor = engine(world, new JsonJournal(directory)); + Plan plan = prepare(editor, Map.of(A, ROTATED)); + assertEquals(ROTATED + ITEMS, plan.changes().get(0).desired()); + String id = start(editor, plan); + assertEquals(OperationStatus.APPLIED, finish(editor, id).status()); + world.values.put(A, ROTATED + "|player deposited a diamond"); + assertEquals(ROTATED, world.getBlock(A), "visible BlockData did not change"); + assertThrows(IllegalStateException.class, () -> editor.prepareUndo(id)); + assertEquals(1, world.writes); + } + @Test void inventoryEditDuringJournalIoStopsBeforeAnyWrite() throws Exception { + EntityWorld world = new EntityWorld(); world.values.put(A, CHEST + ITEMS); + EditEngine editor = engine(world, new JsonJournal(directory)); + Plan plan = prepare(editor, Map.of(A, STONE)); String id = start(editor, plan); + SliceIntent intent = nextIntent(editor, id); editor.persistIntent(intent); + world.values.put(A, CHEST + "|items moved by a hopper"); + assertEquals(OperationStatus.CONFLICT, editor.commitSlice(intent).status()); + assertEquals(0, world.writes); + } + @Test void planBudgetStopsPreparationBeforePersistenceOrWorldMutation() throws Exception { + EntityWorld world = new EntityWorld(); + String large = CHEST + "|" + "x".repeat(1024 * 1024); + Map desired = new HashMap<>(); + for (int x = 0; x < 9; x++) { var p = new BlockPos(x, 0, 0); world.values.put(p, large); desired.put(p, STONE); } + var journal = new MemoryJournal(); EditEngine editor = engine(world, journal); + var error = assertThrows(IllegalArgumentException.class, + () -> editor.prepare("project", "epoch", REGION, desired, Set.of())); + assertTrue(error.getMessage().contains("snapshot_budget_exceeded")); + assertEquals(0, world.writes); assertTrue(journal.plans.isEmpty()); + } + @Test void plainLegacyJournalStillAppliesAndUndoesWithSnapshotAwareAdapter() throws Exception { + EntityWorld world = new EntityWorld(); + EditEngine editor = engine(world, new JsonJournal(directory)); + String id = start(editor, prepare(editor, Map.of(A, STONE))); + assertEquals(OperationStatus.APPLIED, finish(editor, id).status()); + EditEngine restarted = engine(world, new JsonJournal(directory)); + Plan undo = restarted.prepareUndo(id); restarted.persistPlan(undo.id()); + assertEquals(OperationStatus.APPLIED, finish(restarted, start(restarted, undo)).status()); + assertEquals(AIR, world.captureBlock(A)); + } +} diff --git a/world-core/src/test/java/io/github/minecraftbuilder/core/TerrainBrushTest.java b/world-core/src/test/java/io/github/minecraftbuilder/core/TerrainBrushTest.java new file mode 100644 index 0000000..6e73655 --- /dev/null +++ b/world-core/src/test/java/io/github/minecraftbuilder/core/TerrainBrushTest.java @@ -0,0 +1,82 @@ +package io.github.minecraftbuilder.core; + +import com.google.gson.JsonParser; +import org.junit.jupiter.api.Test; +import java.util.*; +import java.util.function.IntBinaryOperator; +import static org.junit.jupiter.api.Assertions.*; + +class TerrainBrushTest { + static final Region BOX=new Region("world",new BlockPos(-4,-4,-4),new BlockPos(4,10,4)); + static TerrainBrush.Spec spec(String action,int amount,int height,double strength,double falloff){return new TerrainBrush.Spec(BOX,0,0,3,action,amount,height,strength,falloff,1,List.of());} + static Map snapshot(IntBinaryOperator height){Map map=new LinkedHashMap<>();for(int y=-4;y<=10;y++)for(int z=-4;z<=4;z++)for(int x=-4;x<=4;x++){ + int h=height.applyAsInt(x,z);map.put(new BlockPos(x,y,z),y>h?"minecraft:air":y==h?"minecraft:grass_block[snowy=false]":y>=h-2?"minecraft:dirt":"minecraft:stone"); + }return map;} + @Test void raiseAndLowerAreRelativeToEachOriginalColumnAndMoveSurface(){ + var snap=snapshot((x,z)->x/2+2);var up=TerrainBrush.compile(spec("raise",2,0,1,0),snap,4096); + var down=TerrainBrush.compile(spec("lower",2,0,1,0),snap,4096); + for(int z=-3;z<=3;z++)for(int x=-3;x<=3;x++)if(x*x+z*z<=9){ + int h=x/2+2;assertEquals(h+2,up.after()[z+4][x+4]);assertEquals(h-2,down.after()[z+4][x+4]); + assertEquals("minecraft:grass_block[snowy=false]",up.desired().get(new BlockPos(x,h+2,z))); + assertEquals("minecraft:dirt",up.desired().get(new BlockPos(x,h,z))); + assertEquals("minecraft:air",down.desired().get(new BlockPos(x,h,z))); + } + assertEquals(snap.keySet(),up.dependencies());assertEquals(1215,up.dependencies().size()); + } + @Test void softFalloffAndStrengthAreSymmetricAndLeaveEdgeUntouched(){ + var snap=snapshot((x,z)->3);var up=TerrainBrush.compile(spec("raise",4,0,.5,1),snap,4096);var down=TerrainBrush.compile(spec("lower",4,0,.5,1),snap,4096); + assertEquals(5,up.after()[4][4]);assertEquals(1,down.after()[4][4]);assertEquals(3,up.after()[4][7]); + for(int z=0;z<9;z++)for(int x=0;x<9;x++)assertEquals(up.after()[z][x]-3,3-down.after()[z][x]); + assertTrue(TerrainBrush.compile(spec("raise",4,0,0,.5),snap,4096).desired().isEmpty()); + } + @Test void flattenUsesAbsoluteLevelAndSmoothUsesImmutableHalo(){ + var snap=snapshot((x,z)->x==0&&z==0?8:2); + var flat=TerrainBrush.compile(spec("flatten",1,4,1,0),snap,4096);assertEquals(4,flat.after()[4][4]);assertEquals(4,flat.after()[4][5]); + var smooth=TerrainBrush.compile(spec("smooth",1,0,1,0),snap,4096); + assertEquals(3,smooth.after()[4][4]);assertEquals(3,smooth.after()[4][5]);assertEquals(3,smooth.after()[4][3]); + assertEquals(2,smooth.after()[4][6]); + var reversed=new LinkedHashMap();var entries=new ArrayList<>(snap.entrySet());Collections.reverse(entries);entries.forEach(e->reversed.put(e.getKey(),e.getValue())); + assertEquals(smooth.desired(),TerrainBrush.compile(spec("smooth",1,0,1,0),reversed,4096).desired()); + } + @Test void preserveSkipsTheWholeAffectedColumn(){ + var b=spec("raise",2,0,1,0);var s=new TerrainBrush.Spec(BOX,0,0,3,"raise",2,0,1,0,1,List.of(new Region("world",new BlockPos(0,3,0),new BlockPos(0,3,0)))); + var result=TerrainBrush.compile(s,snapshot((x,z)->2),4096); + assertEquals(2,result.after()[4][4]);assertFalse(result.desired().keySet().stream().anyMatch(p->p.x()==0&&p.z()==0));assertEquals(4,result.after()[4][5]); + } + @Test void refusesAmbiguousSurfacesStructuresFluidsVoidsAndClipping(){ + for(String state:List.of("minecraft:gold_block","minecraft:water","minecraft:bedrock","minecraft:oak_log")){ + var snap=snapshot((x,z)->2);snap.put(new BlockPos(0,2,0),state);assertThrows(IllegalArgumentException.class,()->TerrainBrush.compile(spec("raise",1,0,1,0),snap,4096)); + } + assertThrows(IllegalArgumentException.class,()->TerrainBrush.compile(spec("raise",1,0,1,0),snapshot((x,z)->10),4096)); + assertThrows(IllegalArgumentException.class,()->TerrainBrush.compile(spec("raise",1,0,1,0),snapshot((x,z)->-5),4096)); + assertThrows(IllegalArgumentException.class,()->TerrainBrush.compile(spec("raise",20,0,1,0),snapshot((x,z)->2),4096)); + var cave=snapshot((x,z)->2);cave.put(new BlockPos(0,0,0),"minecraft:cave_air");assertThrows(IllegalArgumentException.class,()->TerrainBrush.compile(spec("lower",2,0,1,0),cave,4096)); + assertThrows(IllegalArgumentException.class,()->TerrainBrush.compile(spec("raise",2,0,1,0),snapshot((x,z)->2),1)); + } + @Test void validatesHaloScanBudgetAndActionSpecificFields(){ + assertThrows(IllegalArgumentException.class,()->new TerrainBrush.Spec(BOX,0,0,4,"smooth",1,0,1,0,1,List.of())); + assertThrows(IllegalArgumentException.class,()->new TerrainBrush.Spec(new Region("w",new BlockPos(-20,-20,-20),new BlockPos(20,20,20)),0,0,3,"raise",1,0,1,0,1,List.of())); + String base="\"min\":{\"x\":-4,\"y\":-4,\"z\":-4},\"max\":{\"x\":4,\"y\":10,\"z\":4},\"center\":{\"x\":0,\"z\":0},\"radius\":3,"; + for(String tail:List.of("\"action\":\"raise\"","\"action\":\"flatten\"","\"action\":\"smooth\",\"amount\":2","\"action\":\"raise\",\"amount\":2,\"height\":3","\"action\":\"raise\",\"amount\":2,\"script\":\"bad\""))assertThrows(IllegalArgumentException.class,()->TerrainBrush.parse(JsonParser.parseString("{"+base+tail+"}").getAsJsonObject())); + } + @Test void realEngineAppliesAndUndoesBrushWithAllSnapshotDependencies() throws Exception { + var original=snapshot((x,z)->2);var world=new EditEngineTest.MemoryWorld();world.blocks.putAll(original); + var engine=new EditEngine(world,s->true,p->{},new EditEngineTest.MemoryJournal(),new Limits(4096,4096,128,1_000_000_000,600000,8)); + var brush=TerrainBrush.compile(spec("raise",2,0,1,.5),original,4096); + var plan=engine.prepare("project","epoch",BOX,brush.desired(),brush.dependencies());engine.persistPlan(plan.id()); + String id=EditEngineTest.start(engine,plan);assertEquals(OperationStatus.APPLIED,EditEngineTest.finish(engine,id).status()); + brush.desired().forEach((p,s)->assertEquals(s,world.getBlock(p))); + var undo=engine.prepareUndo(id);engine.persistPlan(undo.id());assertEquals(OperationStatus.APPLIED,EditEngineTest.finish(engine,EditEngineTest.start(engine,undo)).status());assertEquals(original,world.blocks); + } + @Test void haloEditDuringJournalIoStopsBeforeAnyBrushWrite() throws Exception { + var original=snapshot((x,z)->2);var world=new EditEngineTest.MemoryWorld();world.blocks.putAll(original); + var engine=new EditEngine(world,s->true,p->{},new EditEngineTest.MemoryJournal(),new Limits(4096,4096,128,1_000_000_000,600000,8)); + var brush=TerrainBrush.compile(spec("smooth",1,0,1,0),snapshot((x,z)->x==0&&z==0?8:2),4096); + // Engine must capture the exact same source that produced the smoothing result. + world.blocks.clear();world.blocks.putAll(snapshot((x,z)->x==0&&z==0?8:2)); + var plan=engine.prepare("project","epoch",BOX,brush.desired(),brush.dependencies());engine.persistPlan(plan.id());String id=EditEngineTest.start(engine,plan); + var intent=EditEngineTest.nextIntent(engine,id);engine.persistIntent(intent); + BlockPos halo=new BlockPos(4,3,0);assertFalse(brush.desired().containsKey(halo));world.blocks.put(halo,"minecraft:gold_block"); + assertEquals(OperationStatus.CONFLICT,engine.commitSlice(intent).status());assertEquals(0,world.writes); + } +} diff --git a/world-core/src/test/java/io/github/minecraftbuilder/core/TerrainRecipeTest.java b/world-core/src/test/java/io/github/minecraftbuilder/core/TerrainRecipeTest.java new file mode 100644 index 0000000..2c2c2a6 --- /dev/null +++ b/world-core/src/test/java/io/github/minecraftbuilder/core/TerrainRecipeTest.java @@ -0,0 +1,115 @@ +package io.github.minecraftbuilder.core; + +import com.google.gson.*; +import org.junit.jupiter.api.Test; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class TerrainRecipeTest { + static JsonObject json(){return JsonParser.parseString(""" + {"version":1,"min":{"x":-17,"y":-4,"z":-17},"max":{"x":17,"y":20,"z":17}, + "base_height":5,"seed":123,"mode":"sculpt","noise":{"amplitude":0,"scale":24}, + "palette":{"rock":"minecraft:stone","soil":"minecraft:dirt","surface":"minecraft:grass_block","soil_depth":2}, + "features":[],"preserve":[]} + """).getAsJsonObject();} + static TerrainRecipe feature(String features){var j=json();j.add("features",JsonParser.parseString(features));return new TerrainRecipe(j);} + static Map all(TerrainRecipe r,int budget){Map result=new HashMap<>();for(int i=0;i{assertTrue(r.bounds().contains(p));assertNull(result.put(p,s),"overlapping tile at "+p);}); + }return result;} + @Test void tilingHasNoGapsOverlapsOrSeamsIncludingNegativeCoordinatesAndVerticalLayers(){ + var j=json();j.getAsJsonObject("noise").addProperty("amplitude",4);var r=new TerrainRecipe(j); + var a=all(r,4096);var b=all(r,512);assertEquals(r.bounds().volume(),a.size());assertEquals(a,b); + for(int z=-17;z<=17;z++)for(int x=-17;x<=17;x++){ + int h=r.surfaceHeight(x,z);assertEquals("minecraft:grass_block",a.get(new BlockPos(x,h,z))); + assertEquals("minecraft:dirt",a.get(new BlockPos(x,h-2,z)));assertEquals("minecraft:stone",a.get(new BlockPos(x,h-3,z))); + assertEquals("minecraft:air",a.get(new BlockPos(x,h+1,z))); + } + } + @Test void deterministicSeedAndWorldCoordinatesIndependentOfEnvelope(){ + var j=json();j.getAsJsonObject("noise").addProperty("amplitude",20);var a=new TerrainRecipe(j);var b=new TerrainRecipe(j.deepCopy()); + assertEquals(a.id(),b.id());j.getAsJsonObject("min").addProperty("x",-9);var cropped=new TerrainRecipe(j); + for(int z=-9;z<=9;z++)for(int x=-9;x<=9;x++)assertEquals(a.surfaceHeight(x,z),cropped.surfaceHeight(x,z)); + j.addProperty("seed",124);var other=new TerrainRecipe(j);assertNotEquals(a.id(),other.id()); + assertTrue(java.util.stream.IntStream.range(-8,9).anyMatch(x->a.surfaceHeight(x,7)!=other.surfaceHeight(x,7))); + } + @Test void hashIgnoresObjectKeyOrderButPreservesFeatureOrder(){ + var j=json();var reversed=new JsonObject();var keys=new ArrayList<>(j.keySet());Collections.reverse(keys);keys.forEach(k->reversed.add(k,j.get(k))); + assertEquals(new TerrainRecipe(j).id(),new TerrainRecipe(reversed).id()); + } + @Test void plateauBlendsOnlyOutsideItsFootprintAndOrderMatters(){ + var r=feature(""" + [{"type":"plateau","min":{"x":-2,"z":-2},"max":{"x":2,"z":2},"height":13,"falloff":4}] + """); + assertEquals(13,r.surfaceHeight(0,0));assertEquals(13,r.surfaceHeight(2,2));assertEquals(9,r.surfaceHeight(4,0));assertEquals(5,r.surfaceHeight(6,0)); + var later=feature(""" + [{"type":"hill","center":{"x":0,"z":0},"radius":1,"height":10,"falloff":4}, + {"type":"plateau","min":{"x":-2,"z":-2},"max":{"x":2,"z":2},"height":8,"falloff":2}] + """);assertEquals(8,later.surfaceHeight(0,0)); + } + @Test void hillAndRidgeAddAndFallOffContinuously(){ + var hill=feature(""" +[{"type":"hill","center":{"x":0,"z":0},"radius":2,"height":8,"falloff":4}] +"""); + assertEquals(13,hill.surfaceHeight(0,0));assertEquals(9,hill.surfaceHeight(4,0));assertEquals(5,hill.surfaceHeight(6,0)); + var ridge=feature(""" +[{"type":"ridge","points":[{"x":-4,"z":0},{"x":4,"z":0}],"width":2,"height":8,"falloff":4}] +"""); + assertEquals(13,ridge.surfaceHeight(0,0));assertEquals(9,ridge.surfaceHeight(0,4));assertEquals(5,ridge.surfaceHeight(0,6)); + } + @Test void basinAndChannelOnlyLowerAndHandleRepeatedPoints(){ + var basin=feature(""" +[{"type":"basin","center":{"x":0,"z":0},"radius":2,"height":-3,"falloff":4}] +"""); + assertEquals(-3,basin.surfaceHeight(0,0));assertEquals(1,basin.surfaceHeight(4,0));assertEquals(5,basin.surfaceHeight(6,0)); + var high=feature(""" +[{"type":"basin","center":{"x":0,"z":0},"radius":2,"height":20,"falloff":4}] +""");assertEquals(5,high.surfaceHeight(0,0)); + var channel=feature(""" +[{"type":"channel","points":[{"x":0,"z":0},{"x":0,"z":0},{"x":8,"z":0}],"width":2,"height":-3,"falloff":4}] +""");assertEquals(-3,channel.surfaceHeight(4,0));assertEquals(1,channel.surfaceHeight(4,4)); + } + @Test void terracesUseFloorForNegativeElevations(){ + var j=json();j.addProperty("base_height",-1);j.add("features",JsonParser.parseString(""" +[{"type":"terrace","step":4,"strength":1}] +""")); + assertEquals(-4,new TerrainRecipe(j).surfaceHeight(0,0)); + } + @Test void modeAndPreserveMasksNeverLeakAcrossTiles(){ + var j=json();j.add("preserve",JsonParser.parseString(""" +[{"min":{"x":-1,"y":0,"z":-1},"max":{"x":1,"y":9,"z":1}}] +""")); + var r=new TerrainRecipe(j);var sculpt=all(r,4096);assertEquals(r.bounds().volume()-90,sculpt.size());assertFalse(sculpt.containsKey(new BlockPos(0,5,0))); + j.addProperty("mode","fill");var fill=all(new TerrainRecipe(j),4096);assertTrue(fill.keySet().stream().allMatch(p->p.y()<=5)); + j.addProperty("mode","cut");var cut=all(new TerrainRecipe(j),4096);assertTrue(cut.values().stream().allMatch(s->s.equals("minecraft:air")));assertTrue(cut.keySet().stream().allMatch(p->p.y()>5)); + var both=new HashMap<>(fill);both.putAll(cut);assertEquals(sculpt,both); + } + @Test void malformedOrExpensiveRecipesRejectedBeforeExpansion(){ + var j=json();j.addProperty("script","bad");assertThrows(IllegalArgumentException.class,()->new TerrainRecipe(j)); + for(String path:List.of("version","seed","base_height")){var n=json();n.addProperty(path,1.5);assertThrows(IllegalArgumentException.class,()->new TerrainRecipe(n));} + var big=json();big.getAsJsonObject("max").addProperty("x",30000000);assertThrows(IllegalArgumentException.class,()->new TerrainRecipe(big)); + var wet=json();wet.getAsJsonObject("palette").addProperty("rock","minecraft:water");assertThrows(IllegalArgumentException.class,()->new TerrainRecipe(wet)); + var empty=json();empty.add("features",JsonParser.parseString(""" +[{"type":"channel","points":[],"width":1,"height":0,"falloff":1}] +"""));assertThrows(IllegalArgumentException.class,()->new TerrainRecipe(empty)); + assertThrows(IllegalArgumentException.class,()->new TerrainRecipe(json()).tile(-1,4096));assertThrows(IllegalArgumentException.class,()->new TerrainRecipe(json()).tile(99999,4096)); + assertThrows(IllegalArgumentException.class,()->new TerrainRecipe(json()).tile(0,0)); + } + @Test void naturalPolicyProtectsStructuresAndBedrock(){ + assertTrue(TerrainRecipe.replaceable("minecraft:grass_block[snowy=false]"));assertTrue(TerrainRecipe.replaceable("minecraft:air")); + for(String s:List.of("bedrock","stone_bricks","oak_planks","water","chest","gold_block"))assertFalse(TerrainRecipe.replaceable("minecraft:"+s)); + } + @Test void terrainPlanUsesRealJournalConflictChecksAndCheckedUndo() throws Exception { + var world=new EditEngineTest.MemoryWorld();var journal=new EditEngineTest.MemoryJournal(); + var engine=new EditEngine(world,s->true,p->{},journal,new Limits(4096,16,128,1_000_000_000,600000,4)); + var j=json();j.add("max",JsonParser.parseString("{\"x\":-14,\"y\":0,\"z\":-14}"));var r=new TerrainRecipe(j); + var desired=r.tile(0,4096).blocks();var plan=engine.prepare("project","epoch",EditEngineTest.REGION,desired,Set.of());engine.persistPlan(plan.id()); + String id=EditEngineTest.start(engine,plan);assertEquals(OperationStatus.APPLIED,EditEngineTest.finish(engine,id).status()); + desired.forEach((p,s)->assertEquals(s,world.getBlock(p))); + var undo=engine.prepareUndo(id);engine.persistPlan(undo.id());assertEquals(OperationStatus.APPLIED,EditEngineTest.finish(engine,EditEngineTest.start(engine,undo)).status()); + desired.keySet().forEach(p->assertEquals("minecraft:air",world.getBlock(p))); + var stale=engine.prepare("project","epoch",EditEngineTest.REGION,desired,Set.of());engine.persistPlan(stale.id()); + var edited=desired.keySet().iterator().next();world.blocks.put(edited,"minecraft:gold_block");world.writes=0; + assertEquals(OperationStatus.CONFLICT,EditEngineTest.finish(engine,EditEngineTest.start(engine,stale)).status());assertEquals(0,world.writes); + } +}