feat(village-evolution): add village growth addon for survival worlds
All checks were successful
Deploy Addons / deploy (push) Successful in 14s

Villages now evolve organically as villager populations grow. The addon
scans every 5 minutes, clusters villagers by proximity, and places new
buildings (well, lamp post, houses, farm, blacksmith) adjacent to
existing villages as population thresholds are reached. State is
persisted across restarts via world dynamic properties.

Deploys to jamie, lyla, mya survival worlds only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-07 02:26:12 +01:00
parent 23f2e6c3bd
commit d283de4e6d
4 changed files with 557 additions and 2 deletions

View File

@@ -8,6 +8,7 @@ on:
- 'lobby-addon/**' - 'lobby-addon/**'
- 'hub-return-addon/**' - 'hub-return-addon/**'
- 'easter-egg-addon/**' - 'easter-egg-addon/**'
- 'village-evolution-addon/**'
- 'docker-compose.yml' - 'docker-compose.yml'
- 'scripts/**' - 'scripts/**'
@@ -32,11 +33,11 @@ jobs:
git init git init
git remote add origin https://git.silverlabs.uk/SilverLABS/minecraft-aiworld.git git remote add origin https://git.silverlabs.uk/SilverLABS/minecraft-aiworld.git
git fetch origin main 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 else
cd "$APP_DIR" cd "$APP_DIR"
git fetch origin main 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 fi
# Recreate containers so any new docker-compose volume mounts are applied, # Recreate containers so any new docker-compose volume mounts are applied,

View File

@@ -56,6 +56,7 @@ services:
- ./addon/heyhe_pet_RP:/data/resource_packs/heyhe_pet_RP - ./addon/heyhe_pet_RP:/data/resource_packs/heyhe_pet_RP
- ./addon/anthrax_cat_BP:/data/behavior_packs/anthrax_cat_BP - ./addon/anthrax_cat_BP:/data/behavior_packs/anthrax_cat_BP
- ./addon/anthrax_cat_RP:/data/resource_packs/anthrax_cat_RP - ./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 restart: unless-stopped
networks: networks:
- mc-network - mc-network
@@ -86,6 +87,7 @@ services:
- ./addon/heyhe_pet_RP:/data/resource_packs/heyhe_pet_RP - ./addon/heyhe_pet_RP:/data/resource_packs/heyhe_pet_RP
- ./addon/anthrax_cat_BP:/data/behavior_packs/anthrax_cat_BP - ./addon/anthrax_cat_BP:/data/behavior_packs/anthrax_cat_BP
- ./addon/anthrax_cat_RP:/data/resource_packs/anthrax_cat_RP - ./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 restart: unless-stopped
networks: networks:
- mc-network - mc-network
@@ -116,6 +118,7 @@ services:
- ./addon/heyhe_pet_RP:/data/resource_packs/heyhe_pet_RP - ./addon/heyhe_pet_RP:/data/resource_packs/heyhe_pet_RP
- ./addon/anthrax_cat_BP:/data/behavior_packs/anthrax_cat_BP - ./addon/anthrax_cat_BP:/data/behavior_packs/anthrax_cat_BP
- ./addon/anthrax_cat_RP:/data/resource_packs/anthrax_cat_RP - ./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 restart: unless-stopped
networks: networks:
- mc-network - mc-network

View File

@@ -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"
}
]
}

View File

@@ -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);
});