diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 70132de..de3ef71 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -8,6 +8,7 @@ on: - 'lobby-addon/**' - 'hub-return-addon/**' - 'easter-egg-addon/**' + - 'village-evolution-addon/**' - 'docker-compose.yml' - 'scripts/**' @@ -32,11 +33,11 @@ jobs: git init git remote add origin https://git.silverlabs.uk/SilverLABS/minecraft-aiworld.git git fetch origin main - git checkout -f origin/main -- addon/ lobby-addon/ hub-return-addon/ easter-egg-addon/ docker-compose.yml + git checkout -f origin/main -- addon/ lobby-addon/ hub-return-addon/ easter-egg-addon/ village-evolution-addon/ docker-compose.yml else cd "$APP_DIR" git fetch origin main - git checkout -f origin/main -- addon/ lobby-addon/ hub-return-addon/ easter-egg-addon/ docker-compose.yml + git checkout -f origin/main -- addon/ lobby-addon/ hub-return-addon/ easter-egg-addon/ village-evolution-addon/ docker-compose.yml fi # Recreate containers so any new docker-compose volume mounts are applied, diff --git a/docker-compose.yml b/docker-compose.yml index 83eda98..606464e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,6 +56,7 @@ services: - ./addon/heyhe_pet_RP:/data/resource_packs/heyhe_pet_RP - ./addon/anthrax_cat_BP:/data/behavior_packs/anthrax_cat_BP - ./addon/anthrax_cat_RP:/data/resource_packs/anthrax_cat_RP + - ./village-evolution-addon/village_evolution_BP:/data/behavior_packs/village_evolution_BP restart: unless-stopped networks: - mc-network @@ -86,6 +87,7 @@ services: - ./addon/heyhe_pet_RP:/data/resource_packs/heyhe_pet_RP - ./addon/anthrax_cat_BP:/data/behavior_packs/anthrax_cat_BP - ./addon/anthrax_cat_RP:/data/resource_packs/anthrax_cat_RP + - ./village-evolution-addon/village_evolution_BP:/data/behavior_packs/village_evolution_BP restart: unless-stopped networks: - mc-network @@ -116,6 +118,7 @@ services: - ./addon/heyhe_pet_RP:/data/resource_packs/heyhe_pet_RP - ./addon/anthrax_cat_BP:/data/behavior_packs/anthrax_cat_BP - ./addon/anthrax_cat_RP:/data/resource_packs/anthrax_cat_RP + - ./village-evolution-addon/village_evolution_BP:/data/behavior_packs/village_evolution_BP restart: unless-stopped networks: - mc-network diff --git a/village-evolution-addon/village_evolution_BP/manifest.json b/village-evolution-addon/village_evolution_BP/manifest.json new file mode 100644 index 0000000..9c899be --- /dev/null +++ b/village-evolution-addon/village_evolution_BP/manifest.json @@ -0,0 +1,25 @@ +{ + "format_version": 2, + "header": { + "name": "Village Evolution", + "description": "Villages grow organically as villager populations increase — new buildings appear over time.", + "uuid": "c7e91f32-4a8b-4d6e-b21c-9f3a05d8e047", + "version": [1, 0, 0], + "min_engine_version": [1, 21, 0] + }, + "modules": [ + { + "type": "script", + "language": "javascript", + "uuid": "d3b82a51-7c4f-4e9d-a30e-6b1c74f2d895", + "version": [1, 0, 0], + "entry": "scripts/main.js" + } + ], + "dependencies": [ + { + "module_name": "@minecraft/server", + "version": "1.17.0" + } + ] +} diff --git a/village-evolution-addon/village_evolution_BP/scripts/main.js b/village-evolution-addon/village_evolution_BP/scripts/main.js new file mode 100644 index 0000000..fdf7627 --- /dev/null +++ b/village-evolution-addon/village_evolution_BP/scripts/main.js @@ -0,0 +1,526 @@ +import { world, system } from "@minecraft/server"; + +// ─── Configuration ──────────────────────────────────────────────────────────── + +const SCAN_INTERVAL_TICKS = 6000; // How often to scan for villages (5 min) +const CLUSTER_RADIUS = 48; // Max distance (blocks) between villagers in same village +const MIN_VILLAGERS_TO_TRIGGER = 3; // Minimum villagers before any expansion happens +const BUILD_QUEUE_RATE = 20; // Max blocks placed per tick during construction +const MAX_PLOT_SEARCH_RADIUS = 80; // How far to search for a valid building plot +const PLOT_CLEAR_SIZE = 7; // Square size to check for obstructions +const DYNAMIC_PROP_KEY = "village_state"; // Key for world dynamic property persistence +const DYNAMIC_PROP_MAX = 32000; // Max chars for a single dynamic property value + +// ─── Building Templates ─────────────────────────────────────────────────────── +// Each template is an array of [dx, dy, dz, blockId, blockStates?] +// dx/dz are relative to plot origin (northwest corner at ground level), dy is height offset + +const TEMPLATES = { + + LAMP_POST: { + name: "lamp post", + footprint: 1, + blocks: [ + [0, 0, 0, "oak_fence"], + [0, 1, 0, "oak_fence"], + [0, 2, 0, "lantern", '["hanging":false]'], + ], + }, + + WELL: { + name: "well", + footprint: 3, + blocks: [ + // Base ring of cobblestone + [0, 0, 0, "cobblestone"], [1, 0, 0, "cobblestone"], [2, 0, 0, "cobblestone"], + [0, 0, 1, "cobblestone"], [2, 0, 1, "cobblestone"], + [0, 0, 2, "cobblestone"], [1, 0, 2, "cobblestone"], [2, 0, 2, "cobblestone"], + // Water in the center + [1, 0, 1, "water"], + // Raised walls (level 1) + [0, 1, 0, "cobblestone_wall"], [1, 1, 0, "cobblestone_wall"], [2, 1, 0, "cobblestone_wall"], + [0, 1, 1, "cobblestone_wall"], [2, 1, 1, "cobblestone_wall"], + [0, 1, 2, "cobblestone_wall"], [1, 1, 2, "cobblestone_wall"], [2, 1, 2, "cobblestone_wall"], + // Roof supports + [0, 2, 0, "oak_fence"], [2, 2, 0, "oak_fence"], + [0, 2, 2, "oak_fence"], [2, 2, 2, "oak_fence"], + // Roof ridge + [0, 3, 0, "oak_slab"], [1, 3, 0, "oak_slab"], [2, 3, 0, "oak_slab"], + [0, 3, 1, "oak_slab"], [1, 3, 1, "oak_slab"], [2, 3, 1, "oak_slab"], + ], + }, + + SMALL_HOUSE: { + name: "small house", + footprint: 7, + blocks: [ + // Floor + [0,0,0,"oak_planks"],[1,0,0,"oak_planks"],[2,0,0,"oak_planks"],[3,0,0,"oak_planks"],[4,0,0,"oak_planks"], + [0,0,1,"oak_planks"],[1,0,1,"oak_planks"],[2,0,1,"oak_planks"],[3,0,1,"oak_planks"],[4,0,1,"oak_planks"], + [0,0,2,"oak_planks"],[1,0,2,"oak_planks"],[2,0,2,"oak_planks"],[3,0,2,"oak_planks"],[4,0,2,"oak_planks"], + [0,0,3,"oak_planks"],[1,0,3,"oak_planks"],[2,0,3,"oak_planks"],[3,0,3,"oak_planks"],[4,0,3,"oak_planks"], + [0,0,4,"oak_planks"],[1,0,4,"oak_planks"],[2,0,4,"oak_planks"],[3,0,4,"oak_planks"],[4,0,4,"oak_planks"], + // Walls - south face (z=0) + [0,1,0,"oak_log"],[1,1,0,"oak_planks"],[2,1,0,"oak_planks"],[3,1,0,"oak_planks"],[4,1,0,"oak_log"], + [0,2,0,"oak_log"],[1,2,0,"glass_pane"],[2,2,0,"oak_planks"],[3,2,0,"glass_pane"],[4,2,0,"oak_log"], + [0,3,0,"oak_log"],[1,3,0,"oak_planks"],[2,3,0,"oak_planks"],[3,3,0,"oak_planks"],[4,3,0,"oak_log"], + // Walls - north face (z=4) + [0,1,4,"oak_log"],[1,1,4,"oak_planks"],[2,1,4,"oak_planks"],[3,1,4,"oak_planks"],[4,1,4,"oak_log"], + [0,2,4,"oak_log"],[1,2,4,"glass_pane"],[2,2,4,"oak_planks"],[3,2,4,"glass_pane"],[4,2,4,"oak_log"], + [0,3,4,"oak_log"],[1,3,4,"oak_planks"],[2,3,4,"oak_planks"],[3,3,4,"oak_planks"],[4,3,4,"oak_log"], + // Walls - west face (x=0) + [0,1,1,"oak_planks"],[0,1,2,"oak_planks"],[0,1,3,"oak_planks"], + [0,2,1,"oak_planks"],[0,2,2,"glass_pane"],[0,2,3,"oak_planks"], + [0,3,1,"oak_planks"],[0,3,2,"oak_planks"],[0,3,3,"oak_planks"], + // Walls - east face (x=4) with door gap at y=1,2 z=2 + [4,1,1,"oak_planks"],[4,1,3,"oak_planks"], + [4,2,1,"oak_planks"],[4,2,3,"oak_planks"], + [4,3,1,"oak_planks"],[4,3,2,"oak_planks"],[4,3,3,"oak_planks"], + // Door (east face, z=2) - oak door bottom then top + [4,1,2,"oak_door",'["door_hinge_bit":false,"open_bit":false,"upper_block_bit":false,"direction":0]'], + [4,2,2,"oak_door",'["door_hinge_bit":false,"open_bit":false,"upper_block_bit":true,"direction":0]'], + // Roof (spruce slabs) + [0,4,0,"spruce_slab"],[1,4,0,"spruce_slab"],[2,4,0,"spruce_slab"],[3,4,0,"spruce_slab"],[4,4,0,"spruce_slab"], + [0,4,1,"spruce_slab"],[1,4,1,"spruce_slab"],[2,4,1,"spruce_slab"],[3,4,1,"spruce_slab"],[4,4,1,"spruce_slab"], + [0,4,2,"spruce_slab"],[1,4,2,"spruce_slab"],[2,4,2,"spruce_slab"],[3,4,2,"spruce_slab"],[4,4,2,"spruce_slab"], + [0,4,3,"spruce_slab"],[1,4,3,"spruce_slab"],[2,4,3,"spruce_slab"],[3,4,3,"spruce_slab"],[4,4,3,"spruce_slab"], + [0,4,4,"spruce_slab"],[1,4,4,"spruce_slab"],[2,4,4,"spruce_slab"],[3,4,4,"spruce_slab"],[4,4,4,"spruce_slab"], + // Interior torch + [2,2,2,"torch"], + ], + }, + + FARM_PLOT: { + name: "farm", + footprint: 9, + blocks: [ + // Farmland rows (8x1 strips with water channel in middle) + // Water channel at x=4 + [4,0,0,"water"],[4,0,1,"water"],[4,0,2,"water"],[4,0,3,"water"], + [4,0,4,"water"],[4,0,5,"water"],[4,0,6,"water"],[4,0,7,"water"], + // Farmland west of channel + [0,0,0,"farmland"],[1,0,0,"farmland"],[2,0,0,"farmland"],[3,0,0,"farmland"], + [0,0,1,"farmland"],[1,0,1,"farmland"],[2,0,1,"farmland"],[3,0,1,"farmland"], + [0,0,2,"farmland"],[1,0,2,"farmland"],[2,0,2,"farmland"],[3,0,2,"farmland"], + [0,0,3,"farmland"],[1,0,3,"farmland"],[2,0,3,"farmland"],[3,0,3,"farmland"], + [0,0,4,"farmland"],[1,0,4,"farmland"],[2,0,4,"farmland"],[3,0,4,"farmland"], + [0,0,5,"farmland"],[1,0,5,"farmland"],[2,0,5,"farmland"],[3,0,5,"farmland"], + [0,0,6,"farmland"],[1,0,6,"farmland"],[2,0,6,"farmland"],[3,0,6,"farmland"], + [0,0,7,"farmland"],[1,0,7,"farmland"],[2,0,7,"farmland"],[3,0,7,"farmland"], + // Farmland east of channel + [5,0,0,"farmland"],[6,0,0,"farmland"],[7,0,0,"farmland"],[8,0,0,"farmland"], + [5,0,1,"farmland"],[6,0,1,"farmland"],[7,0,1,"farmland"],[8,0,1,"farmland"], + [5,0,2,"farmland"],[6,0,2,"farmland"],[7,0,2,"farmland"],[8,0,2,"farmland"], + [5,0,3,"farmland"],[6,0,3,"farmland"],[7,0,3,"farmland"],[8,0,3,"farmland"], + [5,0,4,"farmland"],[6,0,4,"farmland"],[7,0,4,"farmland"],[8,0,4,"farmland"], + [5,0,5,"farmland"],[6,0,5,"farmland"],[7,0,5,"farmland"],[8,0,5,"farmland"], + [5,0,6,"farmland"],[6,0,6,"farmland"],[7,0,6,"farmland"],[8,0,6,"farmland"], + [5,0,7,"farmland"],[6,0,7,"farmland"],[7,0,7,"farmland"],[8,0,7,"farmland"], + // Crops on farmland + [0,1,0,"wheat"],[2,1,0,"wheat"],[6,1,0,"carrots"],[8,1,0,"potatoes"], + [0,1,2,"wheat"],[2,1,2,"wheat"],[6,1,2,"carrots"],[8,1,2,"potatoes"], + [0,1,4,"wheat"],[2,1,4,"wheat"],[6,1,4,"carrots"],[8,1,4,"potatoes"], + [0,1,6,"wheat"],[2,1,6,"wheat"],[6,1,6,"carrots"],[8,1,6,"potatoes"], + // Fence border + [0,-1,0,"oak_fence"],[1,-1,0,"oak_fence"],[2,-1,0,"oak_fence"],[3,-1,0,"oak_fence"], + [4,-1,0,"oak_fence"],[5,-1,0,"oak_fence"],[6,-1,0,"oak_fence"],[7,-1,0,"oak_fence"],[8,-1,0,"oak_fence"], + [0,-1,7,"oak_fence"],[1,-1,7,"oak_fence"],[2,-1,7,"oak_fence"],[3,-1,7,"oak_fence"], + [4,-1,7,"oak_fence"],[5,-1,7,"oak_fence"],[6,-1,7,"oak_fence"],[7,-1,7,"oak_fence"],[8,-1,7,"oak_fence"], + [0,-1,1,"oak_fence"],[0,-1,2,"oak_fence"],[0,-1,3,"oak_fence"],[0,-1,4,"oak_fence"],[0,-1,5,"oak_fence"],[0,-1,6,"oak_fence"], + [8,-1,1,"oak_fence"],[8,-1,2,"oak_fence"],[8,-1,3,"oak_fence"],[8,-1,4,"oak_fence"],[8,-1,5,"oak_fence"],[8,-1,6,"oak_fence"], + // Gate on south side + [4,-1,0,"oak_fence_gate",'["direction":0,"open_bit":false,"in_wall_bit":false]'], + ], + }, + + BLACKSMITH: { + name: "blacksmith", + footprint: 9, + blocks: [ + // Foundation + [0,0,0,"cobblestone"],[1,0,0,"cobblestone"],[2,0,0,"cobblestone"],[3,0,0,"cobblestone"],[4,0,0,"cobblestone"],[5,0,0,"cobblestone"],[6,0,0,"cobblestone"], + [0,0,1,"cobblestone"],[1,0,1,"stone_bricks"],[2,0,1,"stone_bricks"],[3,0,1,"stone_bricks"],[4,0,1,"stone_bricks"],[5,0,1,"stone_bricks"],[6,0,1,"cobblestone"], + [0,0,2,"cobblestone"],[1,0,2,"stone_bricks"],[2,0,2,"stone_bricks"],[3,0,2,"stone_bricks"],[4,0,2,"stone_bricks"],[5,0,2,"stone_bricks"],[6,0,2,"cobblestone"], + [0,0,3,"cobblestone"],[1,0,3,"stone_bricks"],[2,0,3,"stone_bricks"],[3,0,3,"stone_bricks"],[4,0,3,"stone_bricks"],[5,0,3,"stone_bricks"],[6,0,3,"cobblestone"], + [0,0,4,"cobblestone"],[1,0,4,"cobblestone"],[2,0,4,"cobblestone"],[3,0,4,"cobblestone"],[4,0,4,"cobblestone"],[5,0,4,"cobblestone"],[6,0,4,"cobblestone"], + // Walls level 1 + [0,1,0,"stone_brick_wall"],[6,1,0,"stone_brick_wall"], + [0,1,1,"stone_brick_wall"],[6,1,1,"stone_brick_wall"], + [0,1,2,"stone_brick_wall"],[6,1,2,"stone_brick_wall"], + [0,1,3,"stone_brick_wall"],[6,1,3,"stone_brick_wall"], + [0,1,4,"stone_brick_wall"],[1,1,4,"stone_brick_wall"],[2,1,4,"stone_brick_wall"],[3,1,4,"stone_brick_wall"],[4,1,4,"stone_brick_wall"],[5,1,4,"stone_brick_wall"],[6,1,4,"stone_brick_wall"], + // Back wall with window + [0,1,0,"stone_bricks"],[1,1,0,"stone_bricks"],[2,1,0,"stone_bricks"],[3,1,0,"stone_bricks"],[4,1,0,"stone_bricks"],[5,1,0,"stone_bricks"],[6,1,0,"stone_bricks"], + [0,2,0,"stone_bricks"],[1,2,0,"stone_bricks"],[2,2,0,"glass_pane"],[3,2,0,"glass_pane"],[4,2,0,"glass_pane"],[5,2,0,"stone_bricks"],[6,2,0,"stone_bricks"], + [0,3,0,"stone_bricks"],[1,3,0,"stone_bricks"],[2,3,0,"stone_bricks"],[3,3,0,"stone_bricks"],[4,3,0,"stone_bricks"],[5,3,0,"stone_bricks"],[6,3,0,"stone_bricks"], + // Side walls + [0,2,1,"stone_bricks"],[0,2,2,"stone_bricks"],[0,2,3,"stone_bricks"], + [0,3,1,"stone_bricks"],[0,3,2,"stone_bricks"],[0,3,3,"stone_bricks"], + [6,2,1,"stone_bricks"],[6,2,2,"stone_bricks"],[6,2,3,"stone_bricks"], + [6,3,1,"stone_bricks"],[6,3,2,"stone_bricks"],[6,3,3,"stone_bricks"], + // Front open face (partial) - pillars only + [0,2,4,"stone_bricks"],[0,3,4,"stone_bricks"], + [6,2,4,"stone_bricks"],[6,3,4,"stone_bricks"], + // Roof slabs + [0,4,0,"stone_brick_slab"],[1,4,0,"stone_brick_slab"],[2,4,0,"stone_brick_slab"],[3,4,0,"stone_brick_slab"],[4,4,0,"stone_brick_slab"],[5,4,0,"stone_brick_slab"],[6,4,0,"stone_brick_slab"], + [0,4,1,"stone_brick_slab"],[1,4,1,"stone_brick_slab"],[2,4,1,"stone_brick_slab"],[3,4,1,"stone_brick_slab"],[4,4,1,"stone_brick_slab"],[5,4,1,"stone_brick_slab"],[6,4,1,"stone_brick_slab"], + [0,4,2,"stone_brick_slab"],[1,4,2,"stone_brick_slab"],[2,4,2,"stone_brick_slab"],[3,4,2,"stone_brick_slab"],[4,4,2,"stone_brick_slab"],[5,4,2,"stone_brick_slab"],[6,4,2,"stone_brick_slab"], + [0,4,3,"stone_brick_slab"],[1,4,3,"stone_brick_slab"],[2,4,3,"stone_brick_slab"],[3,4,3,"stone_brick_slab"],[4,4,3,"stone_brick_slab"],[5,4,3,"stone_brick_slab"],[6,4,3,"stone_brick_slab"], + // Interior furnace and crafting table + [1,1,1,"furnace",'["facing_direction":3]'], + [2,1,1,"furnace",'["facing_direction":3]'], + [4,1,1,"crafting_table"], + [5,1,1,"chest",'["facing_direction":3]'], + // Lava source for forge effect (sunken into floor, covered by grate effect) + [2,0,2,"lava"], + // Lava light + [3,1,1,"torch"], + ], + }, +}; + +// ─── Tier → building sequence ───────────────────────────────────────────────── +// Each tier entry: what to build when village reaches that population +const TIER_BUILDS = [ + { minPop: 3, tier: 1, template: "LAMP_POST", label: "a lamp post" }, + { minPop: 3, tier: 1, template: "WELL", label: "a well" }, + { minPop: 6, tier: 2, template: "SMALL_HOUSE", label: "a house" }, + { minPop: 8, tier: 2, template: "SMALL_HOUSE", label: "another house" }, + { minPop: 10, tier: 3, template: "FARM_PLOT", label: "a farm" }, + { minPop: 12, tier: 3, template: "SMALL_HOUSE", label: "a third house" }, + { minPop: 15, tier: 4, template: "BLACKSMITH", label: "a blacksmith" }, +]; + +// ─── State Management ───────────────────────────────────────────────────────── + +/** @type {{ [villageId: string]: { center: {x:number,y:number,z:number}, buildIndex: number, builtPlots: [number,number][], lastScanTick: number } }} */ +let villageRegistry = {}; + +function loadState() { + try { + const raw = world.getDynamicProperty(DYNAMIC_PROP_KEY); + if (raw && typeof raw === "string") { + villageRegistry = JSON.parse(raw); + } + } catch (e) { + world.sendMessage("§c[Village Evolution] Failed to load state: " + e.message); + villageRegistry = {}; + } +} + +function saveState() { + try { + const serialized = JSON.stringify(villageRegistry); + if (serialized.length > DYNAMIC_PROP_MAX) { + // Trim oldest built plots to stay under limit + for (const id of Object.keys(villageRegistry)) { + if (villageRegistry[id].builtPlots.length > 5) { + villageRegistry[id].builtPlots = villageRegistry[id].builtPlots.slice(-5); + } + } + } + world.setDynamicProperty(DYNAMIC_PROP_KEY, JSON.stringify(villageRegistry)); + } catch (e) { + world.sendMessage("§c[Village Evolution] Failed to save state: " + e.message); + } +} + +// ─── Village Detection ──────────────────────────────────────────────────────── + +function dist2D(a, b) { + const dx = a.x - b.x; + const dz = a.z - b.z; + return Math.sqrt(dx * dx + dz * dz); +} + +/** + * Cluster villager locations into groups using simple single-linkage clustering. + * Returns array of clusters, each cluster is array of locations. + */ +function clusterVillagers(locations) { + const clusters = []; + const assigned = new Array(locations.length).fill(false); + + for (let i = 0; i < locations.length; i++) { + if (assigned[i]) continue; + const cluster = [locations[i]]; + assigned[i] = true; + + // Grow cluster by proximity + let changed = true; + while (changed) { + changed = false; + for (let j = 0; j < locations.length; j++) { + if (assigned[j]) continue; + for (const member of cluster) { + if (dist2D(locations[j], member) <= CLUSTER_RADIUS) { + cluster.push(locations[j]); + assigned[j] = true; + changed = true; + break; + } + } + } + } + clusters.push(cluster); + } + return clusters; +} + +/** + * Compute centroid of a cluster and snap to a stable ID grid (32-block grid). + */ +function clusterToVillage(cluster) { + const cx = cluster.reduce((s, v) => s + v.x, 0) / cluster.length; + const cy = cluster.reduce((s, v) => s + v.y, 0) / cluster.length; + const cz = cluster.reduce((s, v) => s + v.z, 0) / cluster.length; + + // Snap to 32-block grid for stable ID + const gridX = Math.round(cx / 32) * 32; + const gridZ = Math.round(cz / 32) * 32; + const id = `${gridX}_${gridZ}`; + + return { + id, + center: { x: Math.floor(cx), y: Math.floor(cy), z: Math.floor(cz) }, + population: cluster.length, + }; +} + +// ─── Plot Finding ───────────────────────────────────────────────────────────── + +const SAFE_GROUND_BLOCKS = new Set([ + "minecraft:grass_block", "minecraft:dirt", "minecraft:coarse_dirt", + "minecraft:podzol", "minecraft:grass_path", "minecraft:farmland", + "minecraft:sand", "minecraft:gravel", "minecraft:stone", + "minecraft:cobblestone", "minecraft:oak_planks", "minecraft:spruce_planks", +]); + +const UNSAFE_BLOCKS = new Set([ + "minecraft:water", "minecraft:flowing_water", "minecraft:lava", + "minecraft:flowing_lava", "minecraft:bedrock", "minecraft:obsidian", +]); + +/** + * Check whether a ground block exists at (x, y, z) and the space above is clear. + */ +function isPlotClear(dimension, ox, oy, oz, size) { + for (let dx = 0; dx < size; dx++) { + for (let dz = 0; dz < size; dz++) { + const gx = ox + dx; + const gz = oz + dz; + + // Ground block check + const ground = dimension.getBlock({ x: gx, y: oy - 1, z: gz }); + if (!ground) return false; + if (UNSAFE_BLOCKS.has(ground.typeId)) return false; + + // Space above (2 blocks) must be air or leaves + for (let dy = 0; dy <= 3; dy++) { + const above = dimension.getBlock({ x: gx, y: oy + dy, z: gz }); + if (!above) return false; + const tid = above.typeId; + if (tid !== "minecraft:air" && !tid.includes("leaves") && !tid.includes("grass") && !tid.includes("flower") && !tid.includes("tallgrass")) { + return false; + } + } + } + } + return true; +} + +/** + * Find a valid plot near the village center using an outward spiral search. + * Returns {x, y, z} origin of plot or null if none found. + */ +function findPlot(dimension, center, footprint, builtPlots) { + const halfSize = Math.ceil(footprint / 2); + const ground = center.y; + + // Spiral outward from center + let x = 0, z = 0; + let step = 1, turn = 0, stepCount = 0, totalSteps = 0; + + while (totalSteps < 400) { + const wx = center.x + x * 8 - halfSize; + const wz = center.z + z * 8 - halfSize; + + // Skip if too close to an existing built plot + const tooClose = builtPlots.some(([px, pz]) => { + return Math.abs(px - wx) < footprint + 4 && Math.abs(pz - wz) < footprint + 4; + }); + + if (!tooClose) { + // Search vertically for the right ground level + for (let dy = -5; dy <= 5; dy++) { + const testY = ground + dy; + if (isPlotClear(dimension, wx, testY, wz, footprint)) { + const dist = Math.sqrt(x * x + z * z) * 8; + if (dist <= MAX_PLOT_SEARCH_RADIUS) { + return { x: wx, y: testY, z: wz }; + } + } + } + } + + // Advance spiral + if (x === 0 && z === 0) { x = 1; totalSteps++; continue; } + + stepCount++; + if (turn === 0) z--; + else if (turn === 1) x--; + else if (turn === 2) z++; + else if (turn === 3) x++; + + if (stepCount >= step) { + stepCount = 0; + turn = (turn + 1) % 4; + if (turn === 0 || turn === 2) step++; + } + totalSteps++; + } + + return null; +} + +// ─── Block Placement Queue ──────────────────────────────────────────────────── + +/** @type {Array<{dimension: any, cmd: string}>} */ +let buildQueue = []; +let buildQueueInterval = null; + +function enqueueBuild(dimension, template, ox, oy, oz) { + for (const [dx, dy, dz, blockId, states] of template.blocks) { + const x = ox + dx; + const y = oy + dy; + const z = oz + dz; + const stateStr = states ? ` ${states}` : ""; + buildQueue.push({ dimension, cmd: `setblock ${x} ${y} ${z} ${blockId}${stateStr} replace` }); + } +} + +function startBuildQueue() { + if (buildQueueInterval !== null) return; + buildQueueInterval = system.runInterval(() => { + if (buildQueue.length === 0) { + system.clearRun(buildQueueInterval); + buildQueueInterval = null; + return; + } + const batch = buildQueue.splice(0, BUILD_QUEUE_RATE); + for (const { dimension, cmd } of batch) { + try { + dimension.runCommand(cmd); + } catch (_) { + // Non-fatal — chunk may not be loaded + } + } + }, 1); +} + +// ─── Village Expansion Logic ────────────────────────────────────────────────── + +function runVillageScan() { + const overworld = world.getDimension("overworld"); + + let allVillagers; + try { + allVillagers = overworld.getEntities({ type: "minecraft:villager" }); + } catch (e) { + return; // World not ready + } + + if (allVillagers.length === 0) return; + + const locations = allVillagers.map(v => v.location); + const clusters = clusterVillagers(locations); + + for (const cluster of clusters) { + if (cluster.length < MIN_VILLAGERS_TO_TRIGGER) continue; + + const village = clusterToVillage(cluster); + const id = village.id; + + // Initialize registry entry if new village + if (!villageRegistry[id]) { + villageRegistry[id] = { + center: village.center, + buildIndex: 0, + builtPlots: [], + lastScanTick: 0, + }; + } + + const state = villageRegistry[id]; + + // Update center (villages drift slightly as villagers move) + state.center = village.center; + + const buildIndex = state.buildIndex; + if (buildIndex >= TIER_BUILDS.length) continue; // Village fully developed + + const nextBuild = TIER_BUILDS[buildIndex]; + if (village.population < nextBuild.minPop) continue; // Not enough villagers yet + + // Find a valid plot + const template = TEMPLATES[nextBuild.template]; + const plot = findPlot(overworld, village.center, template.footprint, state.builtPlots); + + if (!plot) { + // No valid plot found — skip this cycle, try again next scan + continue; + } + + // Clear the plot (remove grass/flowers above) + for (let dx = 0; dx < template.footprint; dx++) { + for (let dz = 0; dz < template.footprint; dz++) { + for (let dy = 0; dy <= 3; dy++) { + buildQueue.push({ + dimension: overworld, + cmd: `setblock ${plot.x + dx} ${plot.y + dy} ${plot.z + dz} air replace`, + }); + } + } + } + + // Enqueue the building + enqueueBuild(overworld, template, plot.x, plot.y, plot.z); + startBuildQueue(); + + // Update state + state.builtPlots.push([plot.x, plot.z]); + state.buildIndex++; + state.lastScanTick = system.currentTick; + saveState(); + + // Announce + world.sendMessage( + `§a[Village] §7A village near §e(${village.center.x}, ${village.center.z})§7 has grown — ${nextBuild.label} has been built!` + ); + } +} + +// ─── Startup ────────────────────────────────────────────────────────────────── + +// Register dynamic property schema (max string length) +world.afterEvents.worldInitialize.subscribe((event) => { + try { + event.propertyRegistry.defineWorldProperty({ + id: DYNAMIC_PROP_KEY, + type: "string", + maxLength: DYNAMIC_PROP_MAX, + }); + } catch (_) { + // Already registered (e.g. pack was loaded before) + } +}); + +system.run(() => { + loadState(); + world.sendMessage("§a[Village Evolution] §7Village growth system active."); + + // Main scan loop + system.runInterval(() => { + runVillageScan(); + }, SCAN_INTERVAL_TICKS); +});