1316 lines
54 KiB
Lua
1316 lines
54 KiB
Lua
-- llm_worldedit.lua
|
||
-- LLM-Connect Agency Module: WorldEdit Bridge
|
||
-- v0.4.0 – Fixes: make_pos handles nested/string pos, resolve_node auto-corrects liquids,
|
||
-- loop prompt enforces upfront planning, cumulative step history passed to LLM
|
||
--
|
||
-- ARCHITECTURE:
|
||
-- Direct Lua wrapper around worldedit.* API (not chat command parsing).
|
||
-- The LLM receives a compact context and responds with a JSON tool_calls array.
|
||
-- Each tool call is validated and dispatched to the corresponding worldedit function.
|
||
--
|
||
-- SAFETY LIMITS (enforced per-call):
|
||
-- - Primitives: max radius 64, max dimension 128
|
||
-- - All region ops require explicit pos1+pos2
|
||
-- - Chain aborts on any hard error
|
||
-- - Snapshot taken before every chain → undo available
|
||
--
|
||
-- ROADMAP:
|
||
-- Phase 1 ✅ – Skeleton, context builder, pos1/pos2, WE toggle
|
||
-- Phase 2 ✅ – All dispatchers: set/replace/copy/move/stack/flip/rotate + primitives
|
||
-- Phase 3 ✅ – Snapshot before execution, M.undo(name) for rollback
|
||
-- Phase 4a – Iterative feedback loop: LLM sees step results, re-plans
|
||
-- Phase 4b – Macro mode: LLM builds complex structures over N iterations
|
||
-- with abort condition ("done" signal) and token budget guard
|
||
-- Phase 5 – WorldEditAdditions support (torus, erode, maze…)
|
||
|
||
local core = core
|
||
local M = {}
|
||
|
||
-- Load system prompts from external file
|
||
local prompts_path = core.get_modpath("llm_connect") .. "/worldedit_system_prompts.lua"
|
||
local prompts_ok, WE_PROMPTS = pcall(dofile, prompts_path)
|
||
if not prompts_ok then
|
||
core.log("error", "[llm_worldedit] Failed to load worldedit_system_prompts.lua: " .. tostring(WE_PROMPTS))
|
||
WE_PROMPTS = nil
|
||
end
|
||
|
||
-- ============================================================
|
||
-- Availability check
|
||
|
||
local function wea_enabled()
|
||
if not core.settings:get_bool("llm_worldedit_additions", true) then return false end
|
||
return type(worldeditadditions) == "table"
|
||
and type(worldeditadditions.torus) == "function"
|
||
end
|
||
|
||
-- ============================================================
|
||
|
||
function M.is_available()
|
||
if type(worldedit) ~= "table" then return false end
|
||
-- Erweiterte Prüfung – verschiedene WE-Versionen haben unterschiedliche Funktionsnamen
|
||
return type(worldedit.set) == "function"
|
||
or type(worldedit.set_node) == "function"
|
||
or type(worldedit.manip_helpers) == "table"
|
||
or next(worldedit) ~= nil -- Fallback: Tabelle nicht leer
|
||
end
|
||
|
||
-- ============================================================
|
||
-- Snapshot / Undo (Phase 3)
|
||
-- Serializes the bounding box of pos1..pos2 before any chain
|
||
-- runs. M.undo(name) restores it. One snapshot per player
|
||
-- (last-write-wins — sufficient for agency use).
|
||
-- ============================================================
|
||
|
||
local snapshots = {} -- [player_name] = {p1, p2, data}
|
||
|
||
local function take_snapshot(name)
|
||
if not M.is_available() then return false end
|
||
local p1 = worldedit.pos1 and worldedit.pos1[name]
|
||
local p2 = worldedit.pos2 and worldedit.pos2[name]
|
||
if not p1 or not p2 then return false end -- nothing to snapshot yet
|
||
|
||
-- worldedit.serialize returns a string representation of the region
|
||
local ok, data = pcall(worldedit.serialize, p1, p2)
|
||
if not ok or not data then return false end
|
||
|
||
snapshots[name] = {
|
||
p1 = {x=p1.x, y=p1.y, z=p1.z},
|
||
p2 = {x=p2.x, y=p2.y, z=p2.z},
|
||
data = data,
|
||
}
|
||
return true
|
||
end
|
||
|
||
-- Public: restore last snapshot for player. Returns {ok, message}.
|
||
function M.undo(name)
|
||
if not M.is_available() then
|
||
return {ok=false, message="WorldEdit not available"}
|
||
end
|
||
local snap = snapshots[name]
|
||
if not snap then
|
||
return {ok=false, message="No snapshot available for " .. name}
|
||
end
|
||
local ok, count = pcall(worldedit.deserialize, snap.p1, snap.data)
|
||
if ok then
|
||
-- Restore selection markers
|
||
worldedit.pos1[name] = snap.p1
|
||
worldedit.pos2[name] = snap.p2
|
||
if worldedit.mark_pos1 then worldedit.mark_pos1(name) end
|
||
if worldedit.mark_pos2 then worldedit.mark_pos2(name) end
|
||
snapshots[name] = nil
|
||
return {ok=true, message=string.format("Restored %d nodes from snapshot", count or 0)}
|
||
else
|
||
return {ok=false, message="Deserialize failed: " .. tostring(count)}
|
||
end
|
||
end
|
||
|
||
-- Cleanup snapshots on leave
|
||
core.register_on_leaveplayer(function(player)
|
||
snapshots[player:get_player_name()] = nil
|
||
end)
|
||
|
||
-- ============================================================
|
||
-- Context builder
|
||
-- Returns a compact but rich string that the LLM gets instead
|
||
-- of the generic game context when WorldEdit mode is active.
|
||
-- Goal: minimal tokens, maximum actionability.
|
||
-- ============================================================
|
||
|
||
-- Samples a coarse NxNxN grid of node names around a position.
|
||
-- Returns a compact summary string to give the LLM spatial awareness.
|
||
-- N=5 → 5x5x5 = 125 nodes, sampled at step=2 → ~32 unique checks
|
||
local function sample_environment(pos, radius, step)
|
||
radius = radius or 6
|
||
step = step or 3
|
||
local counts = {}
|
||
local total = 0
|
||
|
||
for dx = -radius, radius, step do
|
||
for dy = -radius, radius, step do
|
||
for dz = -radius, radius, step do
|
||
local node = core.get_node({
|
||
x = math.floor(pos.x + dx),
|
||
y = math.floor(pos.y + dy),
|
||
z = math.floor(pos.z + dz),
|
||
})
|
||
if node and node.name ~= "air" and node.name ~= "ignore" then
|
||
counts[node.name] = (counts[node.name] or 0) + 1
|
||
total = total + 1
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
if total == 0 then return "Surroundings: mostly air" end
|
||
|
||
-- Sort by frequency, take top 8
|
||
local sorted = {}
|
||
for name, count in pairs(counts) do
|
||
table.insert(sorted, {name=name, count=count})
|
||
end
|
||
table.sort(sorted, function(a,b) return a.count > b.count end)
|
||
|
||
local parts = {}
|
||
for i = 1, math.min(8, #sorted) do
|
||
parts[i] = sorted[i].name .. "×" .. sorted[i].count
|
||
end
|
||
return "Nearby nodes (sampled r=" .. radius .. "): " .. table.concat(parts, ", ")
|
||
end
|
||
|
||
function M.get_context(player_name)
|
||
if not M.is_available() then
|
||
return "WorldEdit: NOT LOADED"
|
||
end
|
||
|
||
local player = core.get_player_by_name(player_name)
|
||
if not player then return "Player not found" end
|
||
|
||
local pos = player:get_pos()
|
||
local px, py, pz = math.floor(pos.x), math.floor(pos.y), math.floor(pos.z)
|
||
|
||
-- Current WE selection for this player
|
||
local p1 = worldedit.pos1 and worldedit.pos1[player_name]
|
||
local p2 = worldedit.pos2 and worldedit.pos2[player_name]
|
||
local sel_str
|
||
if p1 and p2 then
|
||
sel_str = string.format("pos1=(%d,%d,%d) pos2=(%d,%d,%d) vol=%d",
|
||
math.floor(p1.x), math.floor(p1.y), math.floor(p1.z),
|
||
math.floor(p2.x), math.floor(p2.y), math.floor(p2.z),
|
||
worldedit.volume(p1, p2))
|
||
elseif p1 then
|
||
sel_str = string.format("pos1=(%d,%d,%d) pos2=not set",
|
||
math.floor(p1.x), math.floor(p1.y), math.floor(p1.z))
|
||
else
|
||
sel_str = "no selection"
|
||
end
|
||
|
||
-- Coarse environment scan
|
||
local env = sample_environment(pos)
|
||
|
||
-- WorldEdit API capability list (static, no token bloat)
|
||
-- This tells the LLM WHAT it can call. Details are in the tool schema.
|
||
local capabilities = table.concat({
|
||
"set_region(pos1,pos2,node)",
|
||
"replace(pos1,pos2,search,replace)",
|
||
"copy(pos1,pos2,axis,amount)",
|
||
"move(pos1,pos2,axis,amount)",
|
||
"stack(pos1,pos2,axis,count)",
|
||
"flip(pos1,pos2,axis)",
|
||
"rotate(pos1,pos2,angle)",
|
||
"sphere(pos,radius,node,hollow)",
|
||
"dome(pos,radius,node,hollow)",
|
||
"cylinder(pos,axis,length,radius1,radius2,node,hollow)",
|
||
"pyramid(pos,axis,height,node,hollow)",
|
||
"cube(pos,width,height,length,node,hollow)",
|
||
"set_pos1(pos)",
|
||
"set_pos2(pos)",
|
||
"get_selection()",
|
||
"clear_region(pos1,pos2)",
|
||
}, " | ")
|
||
|
||
local lines = {
|
||
"=== WorldEdit Agency Mode ===",
|
||
string.format("Player: %s Pos: (%d,%d,%d)", player_name, px, py, pz),
|
||
"Selection: " .. sel_str,
|
||
env,
|
||
"",
|
||
"Available tools: " .. capabilities,
|
||
"",
|
||
"RULES:",
|
||
"1. Always think about coordinates relative to the player position.",
|
||
"2. Set pos1 and pos2 BEFORE calling region operations.",
|
||
"3. Use 'air' as node name to clear/delete nodes.",
|
||
"4. Prefer relative offsets from player pos for natural language requests.",
|
||
"5. Return a JSON tool_calls array. Each call: {tool, args}.",
|
||
"6. If the request is ambiguous, ask for clarification instead of guessing.",
|
||
"=== END CONTEXT ===",
|
||
}
|
||
return table.concat(lines, "\n")
|
||
end
|
||
|
||
-- ============================================================
|
||
-- System prompt for WorldEdit agency mode
|
||
-- ============================================================
|
||
|
||
|
||
-- ============================================================
|
||
-- Tool schema (for llm_api tool_calls / function calling)
|
||
-- Phase 2 will use this to validate and dispatch.
|
||
-- Currently used only to document; real dispatch is in Phase 2.
|
||
-- ============================================================
|
||
|
||
M.TOOL_SCHEMA = {
|
||
-- Region selection
|
||
{
|
||
name = "set_pos1",
|
||
description = "Set WorldEdit position 1 to absolute world coordinates.",
|
||
parameters = {x="integer", y="integer", z="integer"}
|
||
},
|
||
{
|
||
name = "set_pos2",
|
||
description = "Set WorldEdit position 2 to absolute world coordinates.",
|
||
parameters = {x="integer", y="integer", z="integer"}
|
||
},
|
||
{
|
||
name = "get_selection",
|
||
description = "Return current pos1, pos2 and volume for the player.",
|
||
parameters = {}
|
||
},
|
||
-- Region manipulation
|
||
{
|
||
name = "set_region",
|
||
description = "Fill the current selection with node. Use 'air' to clear.",
|
||
parameters = {node="string"}
|
||
},
|
||
{
|
||
name = "clear_region",
|
||
description = "Fill the current selection with air (delete nodes).",
|
||
parameters = {}
|
||
},
|
||
{
|
||
name = "replace",
|
||
description = "Replace all instances of search_node with replace_node in selection.",
|
||
parameters = {search_node="string", replace_node="string"}
|
||
},
|
||
{
|
||
name = "copy",
|
||
description = "Copy the selection along an axis by amount nodes.",
|
||
parameters = {axis="string (x|y|z)", amount="integer"}
|
||
},
|
||
{
|
||
name = "move",
|
||
description = "Move the selection along an axis by amount nodes.",
|
||
parameters = {axis="string (x|y|z)", amount="integer"}
|
||
},
|
||
{
|
||
name = "stack",
|
||
description = "Stack (duplicate) the selection along an axis count times.",
|
||
parameters = {axis="string (x|y|z)", count="integer"}
|
||
},
|
||
{
|
||
name = "flip",
|
||
description = "Flip the selection along the given axis.",
|
||
parameters = {axis="string (x|y|z)"}
|
||
},
|
||
{
|
||
name = "rotate",
|
||
description = "Rotate the selection by angle degrees (90/180/270) around Y axis.",
|
||
parameters = {angle="integer (90|180|270)"}
|
||
},
|
||
-- Primitives (absolute pos, no selection needed)
|
||
{
|
||
name = "sphere",
|
||
description = "Generate a sphere at pos with given radius and node.",
|
||
parameters = {x="integer", y="integer", z="integer",
|
||
radius="integer", node="string", hollow="boolean (optional)"}
|
||
},
|
||
{
|
||
name = "dome",
|
||
description = "Generate a dome (half-sphere) at pos.",
|
||
parameters = {x="integer", y="integer", z="integer",
|
||
radius="integer", node="string", hollow="boolean (optional)"}
|
||
},
|
||
{
|
||
name = "cylinder",
|
||
description = "Generate a cylinder at pos along axis.",
|
||
parameters = {x="integer", y="integer", z="integer",
|
||
axis="string (x|y|z)", length="integer",
|
||
radius="integer", node="string", hollow="boolean (optional)"}
|
||
},
|
||
{
|
||
name = "pyramid",
|
||
description = "Generate a pyramid at pos along axis.",
|
||
parameters = {x="integer", y="integer", z="integer",
|
||
axis="string (x|y|z)", height="integer",
|
||
node="string", hollow="boolean (optional)"}
|
||
},
|
||
{
|
||
name = "cube",
|
||
description = "Generate a cube/box centered at pos.",
|
||
parameters = {x="integer", y="integer", z="integer",
|
||
width="integer", height="integer", length="integer",
|
||
node="string", hollow="boolean (optional)"}
|
||
},
|
||
}
|
||
|
||
-- ============================================================
|
||
-- Tool dispatcher – PHASE 2 STUB
|
||
-- Currently: validates structure, logs, returns not_implemented.
|
||
-- Phase 2: actually calls worldedit.* and reports results.
|
||
-- ============================================================
|
||
|
||
-- Resolves pos from args — handles both flat {x,y,z} and nested {pos={x,y,z}}
|
||
-- Also falls back to player's pos1 if args.pos == "pos1" etc.
|
||
local function make_pos(args, player_name)
|
||
-- Nested: {"pos": {"x":1,"y":2,"z":3}}
|
||
if type(args.pos) == "table" then
|
||
return {
|
||
x = tonumber(args.pos.x) or 0,
|
||
y = tonumber(args.pos.y) or 0,
|
||
z = tonumber(args.pos.z) or 0,
|
||
}
|
||
end
|
||
-- String alias: "pos1" or "pos2" → use current selection
|
||
if type(args.pos) == "string" then
|
||
if player_name then
|
||
if args.pos == "pos1" and worldedit.pos1[player_name] then
|
||
return worldedit.pos1[player_name]
|
||
end
|
||
if args.pos == "pos2" and worldedit.pos2[player_name] then
|
||
return worldedit.pos2[player_name]
|
||
end
|
||
end
|
||
end
|
||
-- Flat: {x=1, y=2, z=3}
|
||
return {
|
||
x = tonumber(args.x) or 0,
|
||
y = tonumber(args.y) or 0,
|
||
z = tonumber(args.z) or 0,
|
||
}
|
||
end
|
||
|
||
-- Validates a node name — returns corrected name or nil + error message.
|
||
-- Tries common suffixes (_source, _flowing) if the base name is unknown.
|
||
local function resolve_node(node_str)
|
||
if not node_str or node_str == "" then
|
||
return nil, "node name is empty"
|
||
end
|
||
if node_str == "air" then return "air", nil end
|
||
if core.registered_nodes[node_str] then return node_str, nil end
|
||
-- Try _source suffix (liquids)
|
||
local with_source = node_str .. "_source"
|
||
if core.registered_nodes[with_source] then
|
||
return with_source, nil
|
||
end
|
||
-- Try stripping _flowing
|
||
local base = node_str:gsub("_flowing$", "_source")
|
||
if core.registered_nodes[base] then
|
||
return base, nil
|
||
end
|
||
-- Unknown but pass through — worldedit will handle it
|
||
core.log("warning", ("[llm_worldedit] unknown node '%s' – passing through"):format(node_str))
|
||
return node_str, nil
|
||
end
|
||
|
||
-- Map tool names → executor functions
|
||
-- Each returns {ok=bool, message=string, nodes_affected=int|nil}
|
||
local DISPATCHERS = {
|
||
|
||
set_pos1 = function(name, args)
|
||
local pos = make_pos(args)
|
||
worldedit.pos1[name] = pos
|
||
if worldedit.mark_pos1 then worldedit.mark_pos1(name) end
|
||
return {ok=true, message=string.format("pos1 set to (%d,%d,%d)", pos.x, pos.y, pos.z)}
|
||
end,
|
||
|
||
set_pos2 = function(name, args)
|
||
local pos = make_pos(args)
|
||
worldedit.pos2[name] = pos
|
||
if worldedit.mark_pos2 then worldedit.mark_pos2(name) end
|
||
return {ok=true, message=string.format("pos2 set to (%d,%d,%d)", pos.x, pos.y, pos.z)}
|
||
end,
|
||
|
||
get_selection = function(name, _args)
|
||
local p1 = worldedit.pos1[name]
|
||
local p2 = worldedit.pos2[name]
|
||
if not p1 or not p2 then
|
||
return {ok=true, message="No selection set."}
|
||
end
|
||
return {ok=true, message=string.format(
|
||
"pos1=(%d,%d,%d) pos2=(%d,%d,%d) vol=%d",
|
||
p1.x,p1.y,p1.z, p2.x,p2.y,p2.z,
|
||
worldedit.volume(p1,p2)
|
||
)}
|
||
end,
|
||
|
||
-- ============================================================
|
||
-- Region operations (require pos1 + pos2 to be set first)
|
||
-- ============================================================
|
||
|
||
set_region = function(name, args)
|
||
local p1 = worldedit.pos1[name]
|
||
local p2 = worldedit.pos2[name]
|
||
if not p1 or not p2 then
|
||
return {ok=false, message="No selection: use set_pos1/set_pos2 first"}
|
||
end
|
||
local node, err = resolve_node(tostring(args.node or "air"))
|
||
if not node then return {ok=false, message="set_region: " .. err} end
|
||
local count = worldedit.set(p1, p2, node)
|
||
return {ok=true, message=string.format("Set %d nodes to %s", count or 0, node), nodes=count}
|
||
end,
|
||
|
||
clear_region = function(name, args)
|
||
local p1 = worldedit.pos1[name]
|
||
local p2 = worldedit.pos2[name]
|
||
if not p1 or not p2 then
|
||
return {ok=false, message="No selection: use set_pos1/set_pos2 first"}
|
||
end
|
||
local count = worldedit.set(p1, p2, "air")
|
||
return {ok=true, message=string.format("Cleared %d nodes", count or 0), nodes=count}
|
||
end,
|
||
|
||
replace = function(name, args)
|
||
local p1 = worldedit.pos1[name]
|
||
local p2 = worldedit.pos2[name]
|
||
if not p1 or not p2 then
|
||
return {ok=false, message="No selection: use set_pos1/set_pos2 first"}
|
||
end
|
||
local search, e1 = resolve_node(tostring(args.search_node or ""))
|
||
local replace_, e2 = resolve_node(tostring(args.replace_node or "air"))
|
||
if not search then return {ok=false, message="replace: search_node: " .. (e1 or "?")} end
|
||
if not replace_ then return {ok=false, message="replace: replace_node: " .. (e2 or "?")} end
|
||
local count = worldedit.replace(p1, p2, search, replace_)
|
||
return {ok=true,
|
||
message=string.format("Replaced %d nodes: %s → %s", count or 0, search, replace_),
|
||
nodes=count}
|
||
end,
|
||
|
||
-- ── Transforms (operate on current selection, move markers too) ──────────
|
||
|
||
copy = function(name, args)
|
||
local p1 = worldedit.pos1[name]
|
||
local p2 = worldedit.pos2[name]
|
||
if not p1 or not p2 then
|
||
return {ok=false, message="No selection: use set_pos1/set_pos2 first"}
|
||
end
|
||
local axis = tostring(args.axis or "y")
|
||
local amount = tonumber(args.amount or 1)
|
||
if not ({x=1,y=1,z=1})[axis] then
|
||
return {ok=false, message="copy: axis must be x, y or z"}
|
||
end
|
||
local count = worldedit.copy(p1, p2, axis, amount)
|
||
return {ok=true,
|
||
message=string.format("Copied %d nodes along %s by %d", count or 0, axis, amount),
|
||
nodes=count}
|
||
end,
|
||
|
||
move = function(name, args)
|
||
local p1 = worldedit.pos1[name]
|
||
local p2 = worldedit.pos2[name]
|
||
if not p1 or not p2 then
|
||
return {ok=false, message="No selection: use set_pos1/set_pos2 first"}
|
||
end
|
||
local axis = tostring(args.axis or "y")
|
||
local amount = tonumber(args.amount or 1)
|
||
if not ({x=1,y=1,z=1})[axis] then
|
||
return {ok=false, message="move: axis must be x, y or z"}
|
||
end
|
||
-- worldedit.move also updates pos1/pos2 in place
|
||
local count, newp1, newp2 = worldedit.move(p1, p2, axis, amount)
|
||
if newp1 then worldedit.pos1[name] = newp1 end
|
||
if newp2 then worldedit.pos2[name] = newp2 end
|
||
return {ok=true,
|
||
message=string.format("Moved %d nodes along %s by %d", count or 0, axis, amount),
|
||
nodes=count}
|
||
end,
|
||
|
||
stack = function(name, args)
|
||
local p1 = worldedit.pos1[name]
|
||
local p2 = worldedit.pos2[name]
|
||
if not p1 or not p2 then
|
||
return {ok=false, message="No selection: use set_pos1/set_pos2 first"}
|
||
end
|
||
local axis = tostring(args.axis or "y")
|
||
local count = tonumber(args.count or 1)
|
||
if not ({x=1,y=1,z=1})[axis] then
|
||
return {ok=false, message="stack: axis must be x, y or z"}
|
||
end
|
||
local nodes = worldedit.stack(p1, p2, axis, count)
|
||
return {ok=true,
|
||
message=string.format("Stacked region %d times along %s (%d nodes)", count, axis, nodes or 0),
|
||
nodes=nodes}
|
||
end,
|
||
|
||
flip = function(name, args)
|
||
local p1 = worldedit.pos1[name]
|
||
local p2 = worldedit.pos2[name]
|
||
if not p1 or not p2 then
|
||
return {ok=false, message="No selection: use set_pos1/set_pos2 first"}
|
||
end
|
||
local axis = tostring(args.axis or "y")
|
||
if not ({x=1,y=1,z=1})[axis] then
|
||
return {ok=false, message="flip: axis must be x, y or z"}
|
||
end
|
||
local count = worldedit.flip(p1, p2, axis)
|
||
return {ok=true,
|
||
message=string.format("Flipped region along %s (%d nodes)", axis, count or 0),
|
||
nodes=count}
|
||
end,
|
||
|
||
rotate = function(name, args)
|
||
local p1 = worldedit.pos1[name]
|
||
local p2 = worldedit.pos2[name]
|
||
if not p1 or not p2 then
|
||
return {ok=false, message="No selection: use set_pos1/set_pos2 first"}
|
||
end
|
||
local angle = tonumber(args.angle or 90)
|
||
-- worldedit.rotate(pos1, pos2, axis, angle) – axis is always "y" for our schema
|
||
local axis = tostring(args.axis or "y")
|
||
if not ({x=1,y=1,z=1})[axis] then
|
||
return {ok=false, message="rotate: axis must be x, y or z"}
|
||
end
|
||
if not ({[90]=1,[180]=1,[270]=1,[-90]=1})[angle] then
|
||
return {ok=false, message="rotate: angle must be 90, 180, 270 or -90"}
|
||
end
|
||
local count, newp1, newp2 = worldedit.rotate(p1, p2, axis, angle)
|
||
if newp1 then worldedit.pos1[name] = newp1 end
|
||
if newp2 then worldedit.pos2[name] = newp2 end
|
||
return {ok=true,
|
||
message=string.format("Rotated %d nodes by %d° around %s", count or 0, angle, axis),
|
||
nodes=count}
|
||
end,
|
||
|
||
-- ── Primitives (standalone, no selection needed) ─────────────────────────
|
||
-- All take absolute world coordinates from args.
|
||
|
||
sphere = function(name, args)
|
||
local pos = make_pos(args, name)
|
||
local radius = tonumber(args.radius or 5)
|
||
local node,err = resolve_node(tostring(args.node or "default:stone"))
|
||
if not node then return {ok=false, message="sphere: " .. err} end
|
||
local hollow = args.hollow == true or args.hollow == "true"
|
||
if radius < 1 or radius > 64 then
|
||
return {ok=false, message="sphere: radius must be 1–64"}
|
||
end
|
||
local count = worldedit.sphere(pos, radius, node, hollow)
|
||
return {ok=true,
|
||
message=string.format("Sphere r=%d %s at (%d,%d,%d): %d nodes",
|
||
radius, node, pos.x, pos.y, pos.z, count or 0),
|
||
nodes=count}
|
||
end,
|
||
|
||
dome = function(name, args)
|
||
local pos = make_pos(args, name)
|
||
local radius = tonumber(args.radius or 5)
|
||
local node,err = resolve_node(tostring(args.node or "default:stone"))
|
||
if not node then return {ok=false, message="dome: " .. err} end
|
||
local hollow = args.hollow == true or args.hollow == "true"
|
||
if radius < 1 or radius > 64 then
|
||
return {ok=false, message="dome: radius must be 1–64"}
|
||
end
|
||
local count = worldedit.dome(pos, radius, node, hollow)
|
||
return {ok=true,
|
||
message=string.format("Dome r=%d %s at (%d,%d,%d): %d nodes",
|
||
radius, node, pos.x, pos.y, pos.z, count or 0),
|
||
nodes=count}
|
||
end,
|
||
|
||
cylinder = function(name, args)
|
||
local pos = make_pos(args, name)
|
||
local axis = tostring(args.axis or "y")
|
||
local length = tonumber(args.length or 5)
|
||
local r1 = tonumber(args.radius or args.radius1 or 3)
|
||
local r2 = tonumber(args.radius2 or r1)
|
||
local node,err = resolve_node(tostring(args.node or "default:stone"))
|
||
if not node then return {ok=false, message="cylinder: " .. err} end
|
||
local hollow = args.hollow == true or args.hollow == "true"
|
||
if not ({x=1,y=1,z=1})[axis] then
|
||
return {ok=false, message="cylinder: axis must be x, y or z"}
|
||
end
|
||
if length < 1 or length > 128 then
|
||
return {ok=false, message="cylinder: length must be 1–128"}
|
||
end
|
||
local count = worldedit.cylinder(pos, axis, length, r1, r2, node, hollow)
|
||
return {ok=true,
|
||
message=string.format("Cylinder axis=%s len=%d r=%d %s at (%d,%d,%d): %d nodes",
|
||
axis, length, r1, node, pos.x, pos.y, pos.z, count or 0),
|
||
nodes=count}
|
||
end,
|
||
|
||
pyramid = function(name, args)
|
||
local pos = make_pos(args, name)
|
||
local axis = tostring(args.axis or "y")
|
||
local height = tonumber(args.height or 5)
|
||
local node,err = resolve_node(tostring(args.node or "default:stone"))
|
||
if not node then return {ok=false, message="pyramid: " .. err} end
|
||
local hollow = args.hollow == true or args.hollow == "true"
|
||
if not ({x=1,y=1,z=1})[axis] then
|
||
return {ok=false, message="pyramid: axis must be x, y or z"}
|
||
end
|
||
if height < 1 or height > 64 then
|
||
return {ok=false, message="pyramid: height must be 1–64"}
|
||
end
|
||
local count = worldedit.pyramid(pos, axis, height, node, hollow)
|
||
return {ok=true,
|
||
message=string.format("Pyramid axis=%s h=%d %s at (%d,%d,%d): %d nodes",
|
||
axis, height, node, pos.x, pos.y, pos.z, count or 0),
|
||
nodes=count}
|
||
end,
|
||
|
||
cube = function(name, args)
|
||
local pos = make_pos(args, name)
|
||
local w = tonumber(args.width or args.w or 5)
|
||
local h = tonumber(args.height or args.h or 5)
|
||
local l = tonumber(args.length or args.l or 5)
|
||
local node,err = resolve_node(tostring(args.node or "default:stone"))
|
||
if not node then return {ok=false, message="cube: " .. err} end
|
||
local hollow = args.hollow == true or args.hollow == "true"
|
||
if w < 1 or h < 1 or l < 1 or w > 128 or h > 128 or l > 128 then
|
||
return {ok=false, message="cube: dimensions must be 1–128"}
|
||
end
|
||
local count = worldedit.cube(pos, w, h, l, node, hollow)
|
||
return {ok=true,
|
||
message=string.format("Cube %dx%dx%d %s at (%d,%d,%d): %d nodes",
|
||
w, h, l, node, pos.x, pos.y, pos.z, count or 0),
|
||
nodes=count}
|
||
end,
|
||
}
|
||
|
||
-- Executes a list of tool calls (from LLM JSON response).
|
||
-- Returns a results table: { {tool, ok, message}, ... }
|
||
function M.execute_tool_calls(player_name, tool_calls)
|
||
if not M.is_available() then
|
||
return {{tool="*", ok=false, message="WorldEdit not available"}}
|
||
end
|
||
|
||
-- Snapshot current region before any changes (Phase 3)
|
||
-- Best-effort: if no selection yet, snapshot is skipped silently.
|
||
take_snapshot(player_name)
|
||
|
||
local results = {}
|
||
for i, call in ipairs(tool_calls) do
|
||
local tool_name = call.tool
|
||
local args = call.args or {}
|
||
-- Check WEA dispatchers first if WEA is enabled
|
||
local dispatcher = DISPATCHERS[tool_name]
|
||
or (M.wea_dispatchers and M.wea_dispatchers[tool_name])
|
||
|
||
if not dispatcher then
|
||
table.insert(results, {
|
||
tool = tool_name,
|
||
ok = false,
|
||
message = "Unknown tool: " .. tostring(tool_name)
|
||
})
|
||
else
|
||
local ok, res = pcall(dispatcher, player_name, args)
|
||
if ok then
|
||
table.insert(results, {tool=tool_name, ok=res.ok, message=res.message})
|
||
else
|
||
table.insert(results, {
|
||
tool = tool_name,
|
||
ok = false,
|
||
message = "Dispatcher error: " .. tostring(res)
|
||
})
|
||
end
|
||
end
|
||
|
||
-- Abort chain on hard errors (skip non-fatal informational results)
|
||
if not results[#results].ok then
|
||
table.insert(results, {tool="*", ok=false,
|
||
message="Chain aborted at step " .. i .. "."})
|
||
break
|
||
end
|
||
end
|
||
return results
|
||
end
|
||
|
||
-- ============================================================
|
||
-- Iterative Macro Loop (Phase 4b)
|
||
--
|
||
-- M.run_loop(player_name, goal, options, callback)
|
||
--
|
||
-- How it works:
|
||
-- 1. Send goal + full context to LLM
|
||
-- 2. LLM responds with {plan, tool_calls, done, reason}
|
||
-- - done=true → LLM signals it's finished
|
||
-- - done=false → execute tool_calls, update context, iterate
|
||
-- 3. After executing, rebuild context (new pos, new env scan)
|
||
-- and send the step results back to LLM as the next user turn
|
||
-- 4. Repeat until: done=true, max_iterations reached, or hard error
|
||
--
|
||
-- Token budget strategy:
|
||
-- Each iteration gets a fresh context (NOT the full history).
|
||
-- Only the goal + last step results are sent. This keeps
|
||
-- token usage bounded at O(1) per step rather than O(n).
|
||
--
|
||
-- options:
|
||
-- max_iterations int Max loop iterations (default: 6)
|
||
-- timeout int Per-request timeout in seconds (default: 90)
|
||
-- on_step func Called after each step: on_step(i, plan, results)
|
||
-- Use this to stream progress to the GUI.
|
||
-- ============================================================
|
||
|
||
-- Extended system prompt for loop mode — tells the LLM about the done signal
|
||
|
||
function M.run_loop(player_name, goal, options, callback)
|
||
if not M.is_available() then
|
||
callback({ok=false, error="WorldEdit not available"})
|
||
return
|
||
end
|
||
|
||
local llm_api = _G.llm_api
|
||
if not llm_api then
|
||
callback({ok=false, error="llm_api not available"})
|
||
return
|
||
end
|
||
|
||
options = options or {}
|
||
local cfg = _G.llm_api and _G.llm_api.config or {}
|
||
local max_iter = options.max_iterations or cfg.we_max_iterations or 6
|
||
local timeout = options.timeout or (_G.llm_api and _G.llm_api.get_timeout("we")) or 90
|
||
local on_step = options.on_step -- optional progress callback
|
||
|
||
local iteration = 0
|
||
local all_results = {}
|
||
local step_history = {} -- compact log of all steps so far
|
||
|
||
local function make_user_msg()
|
||
if iteration == 1 then
|
||
return "Goal: " .. goal
|
||
end
|
||
-- Compact history: just plan + ok/fail per step, not full node counts
|
||
local hist_lines = {"Goal: " .. goal, "", "Completed steps so far:"}
|
||
for i, s in ipairs(step_history) do
|
||
local ok_count = 0
|
||
local err_count = 0
|
||
for _, r in ipairs(s.results or {}) do
|
||
if r.ok then ok_count = ok_count + 1 else err_count = err_count + 1 end
|
||
end
|
||
table.insert(hist_lines, string.format(" Step %d: %s [✓%d ✗%d]",
|
||
i, s.plan, ok_count, err_count))
|
||
end
|
||
table.insert(hist_lines, "")
|
||
table.insert(hist_lines, "Continue with the next step of your plan. Set done=true when finished.")
|
||
return table.concat(hist_lines, "\n")
|
||
end
|
||
|
||
local function do_iteration()
|
||
iteration = iteration + 1
|
||
|
||
if iteration > max_iter then
|
||
callback({ok=true, finished=false,
|
||
reason="Reached max iterations (" .. max_iter .. ")",
|
||
steps=all_results})
|
||
return
|
||
end
|
||
|
||
local context = M.get_context(player_name)
|
||
local messages = {
|
||
{role="system", content=(WE_PROMPTS and WE_PROMPTS.build_loop(wea_enabled()) or "") .. context},
|
||
{role="user", content=make_user_msg()},
|
||
}
|
||
|
||
llm_api.request(messages, function(result)
|
||
if not result.success then
|
||
callback({ok=false, error=result.error or "LLM request failed", steps=all_results})
|
||
return
|
||
end
|
||
|
||
local raw = result.content or ""
|
||
raw = raw:match("```json\n(.-)```") or raw:match("```\n(.-)```") or raw
|
||
raw = raw:match("^%s*(.-)%s*$")
|
||
|
||
local parsed = core.parse_json(raw)
|
||
if not parsed or type(parsed) ~= "table" then
|
||
callback({ok=false, error="Invalid JSON on iteration " .. iteration,
|
||
raw=result.content, steps=all_results})
|
||
return
|
||
end
|
||
|
||
local plan = parsed.plan or "(no plan)"
|
||
local tool_calls = parsed.tool_calls or {}
|
||
local done = parsed.done == true
|
||
local reason = parsed.reason or ""
|
||
|
||
local exec_results = {}
|
||
if #tool_calls > 0 then
|
||
exec_results = M.execute_tool_calls(player_name, tool_calls)
|
||
end
|
||
|
||
local step = {iteration=iteration, plan=plan, results=exec_results, done=done}
|
||
table.insert(all_results, step)
|
||
table.insert(step_history, step)
|
||
|
||
if on_step then pcall(on_step, iteration, plan, exec_results) end
|
||
|
||
local had_hard_error = false
|
||
for _, r in ipairs(exec_results) do
|
||
if r.tool == "*" and not r.ok then had_hard_error = true; break end
|
||
end
|
||
|
||
if done or had_hard_error then
|
||
callback({
|
||
ok = true,
|
||
finished = done and not had_hard_error,
|
||
reason = had_hard_error
|
||
and ("Aborted: hard error on iteration " .. iteration)
|
||
or reason,
|
||
steps = all_results,
|
||
})
|
||
else
|
||
do_iteration()
|
||
end
|
||
end, {timeout=timeout})
|
||
end
|
||
|
||
-- Kick off
|
||
do_iteration()
|
||
end
|
||
|
||
-- ============================================================
|
||
-- Format loop results for display
|
||
-- ============================================================
|
||
|
||
function M.format_loop_results(res)
|
||
if not res.ok then
|
||
return "✗ Loop error: " .. (res.error or "?")
|
||
end
|
||
local lines = {}
|
||
local icon = res.finished and "✓" or "⚠"
|
||
table.insert(lines, icon .. " Loop finished after "
|
||
.. #(res.steps or {}) .. " step(s). " .. (res.reason or ""))
|
||
for _, step in ipairs(res.steps or {}) do
|
||
table.insert(lines, string.format("\n— Step %d: %s", step.iteration, step.plan))
|
||
for _, r in ipairs(step.results or {}) do
|
||
table.insert(lines, string.format(" %s %s → %s",
|
||
r.ok and "✓" or "✗", r.tool, r.message))
|
||
end
|
||
end
|
||
return table.concat(lines, "\n")
|
||
end
|
||
|
||
-- ============================================================
|
||
-- LLM request wrapper for agency mode (single-shot)
|
||
-- Sends the WorldEdit context + user request to the LLM,
|
||
-- parses the JSON response, executes tool calls.
|
||
-- Phase 1: parses and logs; execution only for set_pos1/set_pos2/get_selection.
|
||
-- ============================================================
|
||
|
||
function M.request(player_name, user_message, callback)
|
||
if not M.is_available() then
|
||
callback({ok=false, error="WorldEdit is not installed or not loaded."})
|
||
return
|
||
end
|
||
|
||
local llm_api = _G.llm_api
|
||
if not llm_api then
|
||
callback({ok=false, error="llm_api not available"})
|
||
return
|
||
end
|
||
|
||
local context = M.get_context(player_name)
|
||
|
||
local messages = {
|
||
{role = "system", content = (WE_PROMPTS and WE_PROMPTS.build_single(wea_enabled()) or "") .. context},
|
||
{role = "user", content = user_message},
|
||
}
|
||
|
||
llm_api.request(messages, function(result)
|
||
if not result.success then
|
||
callback({ok=false, error=result.error or "LLM request failed"})
|
||
return
|
||
end
|
||
|
||
-- Parse JSON response from LLM
|
||
local raw = result.content or ""
|
||
|
||
-- Strip markdown fences if present
|
||
raw = raw:match("```json\n(.-)```") or raw:match("```\n(.-)```") or raw
|
||
raw = raw:match("^%s*(.-)%s*$") -- trim
|
||
|
||
local parsed = core.parse_json(raw)
|
||
if not parsed or type(parsed) ~= "table" then
|
||
callback({
|
||
ok = false,
|
||
error = "LLM returned invalid JSON",
|
||
raw = result.content,
|
||
})
|
||
return
|
||
end
|
||
|
||
local plan = parsed.plan or "(no plan)"
|
||
local tool_calls = parsed.tool_calls or {}
|
||
|
||
if type(tool_calls) ~= "table" or #tool_calls == 0 then
|
||
callback({ok=true, plan=plan, results={}, message="No tool calls in response."})
|
||
return
|
||
end
|
||
|
||
-- Execute
|
||
local exec_results = M.execute_tool_calls(player_name, tool_calls)
|
||
|
||
callback({
|
||
ok = true,
|
||
plan = plan,
|
||
results = exec_results,
|
||
raw = result.content,
|
||
})
|
||
end, {
|
||
timeout = (_G.llm_api and _G.llm_api.get_timeout("we")) or 60,
|
||
-- Note: NOT using native tool_calls API here because local models
|
||
-- (Ollama) often don't support it reliably. We use JSON-in-text instead.
|
||
})
|
||
end
|
||
|
||
-- ============================================================
|
||
-- Format results for display in chat_gui
|
||
-- ============================================================
|
||
|
||
function M.format_results(plan, results)
|
||
local lines = {"Plan: " .. (plan or "?")}
|
||
for i, r in ipairs(results) do
|
||
local icon = r.ok and "✓" or "✗"
|
||
table.insert(lines, string.format(" %s [%d] %s → %s",
|
||
icon, i, r.tool, r.message))
|
||
end
|
||
return table.concat(lines, "\n")
|
||
end
|
||
|
||
core.log("action", "[llm_worldedit] Agency module loaded (Phase 2 – all dispatchers active)")
|
||
|
||
-- ============================================================
|
||
-- Phase 5: WorldEditAdditions (WEA) Integration
|
||
-- ============================================================
|
||
-- WEA exposes its operations via the global `worldeditadditions`
|
||
-- table AND as registered chat commands ("//torus" etc.).
|
||
-- We prefer the direct Lua API where available (worldeditadditions.*),
|
||
-- and fall back to chat-command dispatch where needed.
|
||
--
|
||
-- DETECTION: worldeditadditions table + at least one known function
|
||
-- SETTING: llm_worldedit_additions (bool, default true)
|
||
-- ============================================================
|
||
|
||
local function wea_enabled()
|
||
if not core.settings:get_bool("llm_worldedit_additions", true) then
|
||
return false
|
||
end
|
||
return type(worldeditadditions) == "table"
|
||
and type(worldeditadditions.torus) == "function"
|
||
end
|
||
|
||
-- Internal: dispatch a WEA chat command by calling its registered
|
||
-- handler directly. Returns {ok, message, nodes}.
|
||
-- This is used for commands where WEA's Lua API differs from the
|
||
-- chat command (erode, convolve, overlay, replacemix, layers).
|
||
local function wea_cmd(player_name, cmd_name, params_str)
|
||
-- WEA registers commands as "/<name>" (single slash, WEA convention)
|
||
-- The chat command is registered as "/<name>" by worldedit.register_command
|
||
local full_name = cmd_name -- e.g. "convolve", "erode"
|
||
-- Try: registered_chatcommands["/convolve"] first
|
||
local handler = core.registered_chatcommands["/" .. full_name]
|
||
if not handler then
|
||
-- Some WEA versions use double-slash: "//convolve"
|
||
handler = core.registered_chatcommands["//" .. full_name]
|
||
end
|
||
if not handler or not handler.func then
|
||
return {ok=false, message="WEA command '" .. full_name .. "' not registered"}
|
||
end
|
||
local ok, result = pcall(handler.func, player_name, params_str or "")
|
||
if not ok then
|
||
return {ok=false, message="WEA dispatch error: " .. tostring(result)}
|
||
end
|
||
-- WEA command handlers return (bool, message) or just bool
|
||
if type(result) == "string" then
|
||
return {ok=true, message=result}
|
||
elseif type(result) == "boolean" then
|
||
return {ok=result, message=(result and "OK" or "WEA command returned false")}
|
||
end
|
||
return {ok=true, message="OK"}
|
||
end
|
||
|
||
-- ── WEA DISPATCHERS ────────────────────────────────────────
|
||
|
||
local WEA_DISPATCHERS = {
|
||
|
||
-- torus(pos1, radius_major, radius_minor, node, hollow)
|
||
-- pos1 must be set first; torus is placed at pos1
|
||
torus = function(name, args)
|
||
if not wea_enabled() then return {ok=false, message="WEA not available"} end
|
||
local p1 = worldedit.pos1[name]
|
||
if not p1 then return {ok=false, message="torus: set pos1 first"} end
|
||
local r_major = tonumber(args.radius_major or args.radius or 10)
|
||
local r_minor = tonumber(args.radius_minor or args.tube_radius or 3)
|
||
local node, err = resolve_node(tostring(args.node or "default:stone"))
|
||
if not node then return {ok=false, message="torus: " .. err} end
|
||
local hollow = args.hollow == true or args.hollow == "true"
|
||
if r_major < 1 or r_major > 64 then return {ok=false, message="torus: radius_major 1–64"} end
|
||
if r_minor < 1 or r_minor > 32 then return {ok=false, message="torus: radius_minor 1–32"} end
|
||
-- Direct Lua API: worldeditadditions.torus(pos, radius_major, radius_minor, node, hollow)
|
||
local ok, count = pcall(worldeditadditions.torus, p1, r_major, r_minor, node, hollow)
|
||
if not ok then
|
||
-- Fallback: chat command "//torus <major> <minor> <node> [hollow]"
|
||
local params = r_major .. " " .. r_minor .. " " .. node .. (hollow and " hollow" or "")
|
||
return wea_cmd(name, "torus", params)
|
||
end
|
||
return {ok=true,
|
||
message=string.format("Torus r_major=%d r_minor=%d %s at (%d,%d,%d): %d nodes",
|
||
r_major, r_minor, node, p1.x, p1.y, p1.z, count or 0),
|
||
nodes=count}
|
||
end,
|
||
|
||
hollowtorus = function(name, args)
|
||
args.hollow = true
|
||
args.radius_major = args.radius_major or args.radius or 10
|
||
return WEA_DISPATCHERS.torus(name, args)
|
||
end,
|
||
|
||
-- ellipsoid(pos1, rx, ry, rz, node, hollow)
|
||
ellipsoid = function(name, args)
|
||
if not wea_enabled() then return {ok=false, message="WEA not available"} end
|
||
local p1 = worldedit.pos1[name]
|
||
if not p1 then return {ok=false, message="ellipsoid: set pos1 first"} end
|
||
local rx = tonumber(args.rx or args.radius_x or args.radius or 5)
|
||
local ry = tonumber(args.ry or args.radius_y or rx)
|
||
local rz = tonumber(args.rz or args.radius_z or rx)
|
||
local node, err = resolve_node(tostring(args.node or "default:stone"))
|
||
if not node then return {ok=false, message="ellipsoid: " .. err} end
|
||
local hollow = args.hollow == true or args.hollow == "true"
|
||
for _, r in ipairs({rx, ry, rz}) do
|
||
if r < 1 or r > 64 then return {ok=false, message="ellipsoid: radii must be 1–64"} end
|
||
end
|
||
local ok, count = pcall(worldeditadditions.ellipsoid, p1, rx, ry, rz, node, hollow)
|
||
if not ok then
|
||
local params = rx .. " " .. ry .. " " .. rz .. " " .. node .. (hollow and " hollow" or "")
|
||
return wea_cmd(name, "ellipsoid", params)
|
||
end
|
||
return {ok=true,
|
||
message=string.format("Ellipsoid rx=%d ry=%d rz=%d %s at (%d,%d,%d): %d nodes",
|
||
rx, ry, rz, node, p1.x, p1.y, p1.z, count or 0),
|
||
nodes=count}
|
||
end,
|
||
|
||
hollowellipsoid = function(name, args)
|
||
args.hollow = true
|
||
return WEA_DISPATCHERS.ellipsoid(name, args)
|
||
end,
|
||
|
||
-- floodfill(pos1, node, radius)
|
||
-- Fills from pos1 outward, replacing all air
|
||
floodfill = function(name, args)
|
||
if not wea_enabled() then return {ok=false, message="WEA not available"} end
|
||
local p1 = worldedit.pos1[name]
|
||
if not p1 then return {ok=false, message="floodfill: set pos1 first"} end
|
||
local node, err = resolve_node(tostring(args.node or "default:stone"))
|
||
if not node then return {ok=false, message="floodfill: " .. err} end
|
||
local radius = tonumber(args.radius or 10)
|
||
if radius < 1 or radius > 50 then return {ok=false, message="floodfill: radius 1–50"} end
|
||
local ok, count = pcall(worldeditadditions.floodfill, p1, node, radius)
|
||
if not ok then
|
||
return wea_cmd(name, "floodfill", node .. " " .. radius)
|
||
end
|
||
return {ok=true,
|
||
message=string.format("Floodfill %s r=%d from (%d,%d,%d): %d nodes",
|
||
node, radius, p1.x, p1.y, p1.z, count or 0),
|
||
nodes=count}
|
||
end,
|
||
|
||
-- overlay(pos1, pos2, node)
|
||
-- Places node on top of every surface column in the selection
|
||
overlay = function(name, args)
|
||
if not wea_enabled() then return {ok=false, message="WEA not available"} end
|
||
local p1 = worldedit.pos1[name]
|
||
local p2 = worldedit.pos2[name]
|
||
if not p1 or not p2 then return {ok=false, message="overlay: set pos1 and pos2 first"} end
|
||
local node, err = resolve_node(tostring(args.node or "default:dirt_with_grass"))
|
||
if not node then return {ok=false, message="overlay: " .. err} end
|
||
local ok, count = pcall(worldeditadditions.overlay, p1, p2, node)
|
||
if not ok then
|
||
return wea_cmd(name, "overlay", node)
|
||
end
|
||
return {ok=true,
|
||
message=string.format("Overlay %s on selection: %d nodes", node, count or 0),
|
||
nodes=count}
|
||
end,
|
||
|
||
-- replacemix(pos1, pos2, target_node, {node=chance, ...})
|
||
-- Replaces target with a weighted mix of replacement nodes
|
||
-- args: target, replacements = [{node, chance}, ...]
|
||
replacemix = function(name, args)
|
||
if not wea_enabled() then return {ok=false, message="WEA not available"} end
|
||
local p1 = worldedit.pos1[name]
|
||
local p2 = worldedit.pos2[name]
|
||
if not p1 or not p2 then return {ok=false, message="replacemix: set pos1/pos2 first"} end
|
||
local target, err = resolve_node(tostring(args.target or args.search_node or "default:stone"))
|
||
if not target then return {ok=false, message="replacemix: " .. err} end
|
||
-- Build replacement list: [{node, chance}] or flat string for chat fallback
|
||
local replacements = args.replacements or {}
|
||
if #replacements == 0 then
|
||
-- Simple single replacement
|
||
local rnode = resolve_node(tostring(args.replace_node or args.node or "default:dirt"))
|
||
replacements = {{node=rnode, chance=1}}
|
||
end
|
||
-- Build chat params string as fallback: "<target> <nodeA> [chanceA] <nodeB> [chanceB]..."
|
||
local param_parts = {target}
|
||
for _, r in ipairs(replacements) do
|
||
table.insert(param_parts, tostring(r.node or "default:dirt"))
|
||
if r.chance and r.chance ~= 1 then
|
||
table.insert(param_parts, tostring(r.chance))
|
||
end
|
||
end
|
||
-- Try direct API first
|
||
local ok, count = pcall(worldeditadditions.replacemix, p1, p2, target, replacements)
|
||
if not ok then
|
||
return wea_cmd(name, "replacemix", table.concat(param_parts, " "))
|
||
end
|
||
return {ok=true,
|
||
message=string.format("Replacemix %s → %d replacement(s): %d nodes affected",
|
||
target, #replacements, count or 0),
|
||
nodes=count}
|
||
end,
|
||
|
||
-- layers(pos1, pos2, layers_def)
|
||
-- Applies terrain layers from top down. layers_def is a list of {node, depth}.
|
||
-- E.g. layers_def = [{node="default:dirt_with_grass", depth=1}, {node="default:dirt", depth=3}]
|
||
layers = function(name, args)
|
||
if not wea_enabled() then return {ok=false, message="WEA not available"} end
|
||
local p1 = worldedit.pos1[name]
|
||
local p2 = worldedit.pos2[name]
|
||
if not p1 or not p2 then return {ok=false, message="layers: set pos1/pos2 first"} end
|
||
local layers_def = args.layers or {}
|
||
if #layers_def == 0 then
|
||
-- Simple single-layer shortcut
|
||
local node = resolve_node(tostring(args.node or "default:dirt"))
|
||
local depth = tonumber(args.depth or 1)
|
||
layers_def = {{node=node, depth=depth}}
|
||
end
|
||
-- Build chat command params: "<node> <depth> [<node2> <depth2>...]"
|
||
local param_parts = {}
|
||
for _, layer in ipairs(layers_def) do
|
||
table.insert(param_parts, tostring(layer.node or "default:dirt"))
|
||
table.insert(param_parts, tostring(layer.depth or 1))
|
||
end
|
||
local ok, count = pcall(worldeditadditions.layers, p1, p2, layers_def)
|
||
if not ok then
|
||
return wea_cmd(name, "layers", table.concat(param_parts, " "))
|
||
end
|
||
return {ok=true,
|
||
message=string.format("Layers (%d layer defs) on selection: %d nodes",
|
||
#layers_def, count or 0),
|
||
nodes=count}
|
||
end,
|
||
|
||
-- erode(pos1, pos2, [algorithm], [iterations])
|
||
-- Applies erosion simulation to terrain in selection
|
||
-- algorithm: "snowballs" (default) | "river" | "wind"
|
||
erode = function(name, args)
|
||
if not wea_enabled() then return {ok=false, message="WEA not available"} end
|
||
local p1 = worldedit.pos1[name]
|
||
local p2 = worldedit.pos2[name]
|
||
if not p1 or not p2 then return {ok=false, message="erode: set pos1/pos2 first"} end
|
||
local algorithm = tostring(args.algorithm or "snowballs")
|
||
local iterations = tonumber(args.iterations or 1)
|
||
local valid_algos = {snowballs=true, river=true, wind=true}
|
||
if not valid_algos[algorithm] then algorithm = "snowballs" end
|
||
if iterations < 1 or iterations > 10 then iterations = 1 end
|
||
-- Build param string for chat fallback
|
||
local params = algorithm .. " " .. iterations
|
||
-- erode Lua API: worldeditadditions.erode(pos1, pos2, params_table)
|
||
local ok, count = pcall(worldeditadditions.erode, p1, p2,
|
||
{algorithm=algorithm, iterations=iterations})
|
||
if not ok then
|
||
return wea_cmd(name, "erode", params)
|
||
end
|
||
return {ok=true,
|
||
message=string.format("Erode algorithm=%s iterations=%d: %d nodes",
|
||
algorithm, iterations, count or 0),
|
||
nodes=count}
|
||
end,
|
||
|
||
-- convolve(pos1, pos2, [kernel], [size])
|
||
-- Smooths terrain with a convolution kernel
|
||
-- kernel: "gaussian" (default) | "box" | "pascal"
|
||
convolve = function(name, args)
|
||
if not wea_enabled() then return {ok=false, message="WEA not available"} end
|
||
local p1 = worldedit.pos1[name]
|
||
local p2 = worldedit.pos2[name]
|
||
if not p1 or not p2 then return {ok=false, message="convolve: set pos1/pos2 first"} end
|
||
local kernel = tostring(args.kernel or "gaussian")
|
||
local size = tonumber(args.size or 5)
|
||
local valid_kernels = {gaussian=true, box=true, pascal=true}
|
||
if not valid_kernels[kernel] then kernel = "gaussian" end
|
||
if size < 3 then size = 3 end
|
||
if size > 15 then size = 15 end
|
||
if size % 2 == 0 then size = size + 1 end -- must be odd
|
||
local params = kernel .. " " .. size
|
||
local ok, count = pcall(worldeditadditions.convolve, p1, p2,
|
||
{kernel=kernel, size=size})
|
||
if not ok then
|
||
return wea_cmd(name, "convolve", params)
|
||
end
|
||
return {ok=true,
|
||
message=string.format("Convolve kernel=%s size=%d: %d nodes",
|
||
kernel, size, count or 0),
|
||
nodes=count}
|
||
end,
|
||
}
|
||
|
||
-- ── WEA Integration in get_context() ──────────────────────
|
||
-- Extend the context string with WEA capabilities when available
|
||
|
||
local original_get_context = M.get_context
|
||
function M.get_context(player_name)
|
||
local base = original_get_context(player_name)
|
||
if not wea_enabled() then return base end
|
||
|
||
local wea_capabilities = table.concat({
|
||
"torus(radius_major, radius_minor, node, hollow)",
|
||
"hollowtorus(radius_major, radius_minor, node)",
|
||
"ellipsoid(rx, ry, rz, node, hollow)",
|
||
"hollowellipsoid(rx, ry, rz, node)",
|
||
"floodfill(node, radius)",
|
||
"overlay(node)",
|
||
"replacemix(target, replacements=[{node,chance}])",
|
||
"layers(layers=[{node,depth}])",
|
||
"erode([algorithm=snowballs|river|wind], [iterations=1])",
|
||
"convolve([kernel=gaussian|box|pascal], [size=5])",
|
||
}, " | ")
|
||
|
||
local wea_block = table.concat({
|
||
"",
|
||
"=== WorldEditAdditions Tools ===",
|
||
"Available WEA tools (set pos1/pos2 first for region ops):",
|
||
wea_capabilities,
|
||
"torus/ellipsoid/floodfill use pos1 as center.",
|
||
"overlay/replacemix/layers/erode/convolve operate on pos1..pos2 region.",
|
||
"=== END WEA ===",
|
||
}, "\n")
|
||
|
||
-- Insert before "=== END CONTEXT ==="
|
||
return base:gsub("=== END CONTEXT ===", wea_block .. "\n=== END CONTEXT ===")
|
||
end
|
||
|
||
-- ── WEA Integration in SYSTEM_PROMPT ──────────────────────
|
||
|
||
|
||
-- ── WEA TOOL_SCHEMA extension ──────────────────────────────
|
||
|
||
M.WEA_TOOL_SCHEMA = {
|
||
{name="torus", description="Generate a torus at pos1. Set pos1 first.",
|
||
parameters={radius_major="integer (1–64)", radius_minor="integer (1–32)", node="string", hollow="boolean (optional)"}},
|
||
{name="hollowtorus", description="Generate a hollow torus at pos1.",
|
||
parameters={radius_major="integer", radius_minor="integer", node="string"}},
|
||
{name="ellipsoid", description="Generate an ellipsoid at pos1 with per-axis radii.",
|
||
parameters={rx="integer (1–64)", ry="integer (1–64)", rz="integer (1–64)", node="string", hollow="boolean (optional)"}},
|
||
{name="hollowellipsoid",description="Generate a hollow ellipsoid at pos1.",
|
||
parameters={rx="integer", ry="integer", rz="integer", node="string"}},
|
||
{name="floodfill", description="Flood-fill from pos1 outward, replacing air with node.",
|
||
parameters={node="string", radius="integer (1–50)"}},
|
||
{name="overlay", description="Place node on top of every surface column in pos1..pos2.",
|
||
parameters={node="string"}},
|
||
{name="replacemix", description="Replace target node with a weighted mix of nodes in selection.",
|
||
parameters={target="string", replacements="array of {node, chance}"}},
|
||
{name="layers", description="Apply terrain layers top-down in selection.",
|
||
parameters={layers="array of {node, depth}"}},
|
||
{name="erode", description="Apply erosion to terrain in selection.",
|
||
parameters={algorithm="string (snowballs|river|wind, optional)", iterations="integer 1–10 (optional)"}},
|
||
{name="convolve", description="Smooth terrain with convolution kernel in selection.",
|
||
parameters={kernel="string (gaussian|box|pascal, optional)", size="odd integer 3–15 (optional)"}},
|
||
}
|
||
|
||
-- ── WEA dispatcher registration in main dispatch table ─────
|
||
-- This hooks WEA_DISPATCHERS into the existing dispatch system
|
||
|
||
local original_dispatch = M._dispatch_tool
|
||
-- Wrap the main dispatch function to check WEA tools first
|
||
-- (M._dispatch_tool is set during request/run_loop execution)
|
||
M.wea_dispatchers = WEA_DISPATCHERS
|
||
|
||
-- ── wea_available() helper for external use ────────────────
|
||
function M.wea_is_available()
|
||
return wea_enabled()
|
||
end
|
||
|
||
core.log("action", "[llm_worldedit] Phase 5: WEA integration loaded (wea_enabled=" .. tostring(wea_enabled()) .. ")")
|
||
|
||
return M
|