feat(village-evolution): add village growth addon for survival worlds
All checks were successful
Deploy Addons / deploy (push) Successful in 14s
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
25
village-evolution-addon/village_evolution_BP/manifest.json
Normal file
25
village-evolution-addon/village_evolution_BP/manifest.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
526
village-evolution-addon/village_evolution_BP/scripts/main.js
Normal file
526
village-evolution-addon/village_evolution_BP/scripts/main.js
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user