GPU scheduler, 14-tool architecture, plugin deployment, event dispatcher
GPU Scheduler (gpu.sethpc.xyz): - Live dashboard with 4 GPUs, training monitor, loss sparklines - Preset-based job scheduler with 3 triggers (time, finish_training, cost) - Model selection per GPU, pipeline configuration - Tool self-play and training pipeline types - Behind Google OAuth, live-refresh without page reload Tool Architecture (14 tools): - 3 new tools: world.nearby_entities, memory.read, memory.write - 7 script.* tools: write, validate, execute, read, list, delete, schedule - ScriptManager: full mcfunction datapack CRUD with RCON validation - Training data: 1,430 tool examples (up from 1,159) Plugin Deployment (paper-ai-25567): - WorldGuard 7.0.12, CoreProtect CE 23.1, EssentialsX 2.21.2, Vault 1.7.3 - Fresh greenfield world reset - 104 RCON-validated plugin training examples Event Dispatcher: - Watches server log for deaths, joins, advancements, PvP kills - Configurable trigger probability and cooldowns per event type - Deployed to dev server, fires god_system prompts on events - 21 event-response training examples Training Infrastructure: - train_lora.py: --save-steps 50, --resume from checkpoint - run_training.sh: stops Ollama, activates conda, restarts after - Passwordless sudo for ollama services on steel141 - Dev server added to MCSManager with autoStart Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
"""
|
||||
Event Dispatcher — watches server log events and triggers AI God responses.
|
||||
|
||||
Parses deaths, joins, leaves, advancements, PvP kills, and chat blasphemy
|
||||
from the Minecraft server log. Each event type has a configurable trigger
|
||||
probability and cooldown. When fired, formats the event as a god_system
|
||||
prompt and sends it to the model.
|
||||
|
||||
Usage:
|
||||
from agent.tools.event_dispatcher import EventDispatcher
|
||||
|
||||
dispatcher = EventDispatcher(config, llm_callback, rcon_fn)
|
||||
dispatcher.process_log_line(line) # call for each new log line
|
||||
|
||||
Config keys:
|
||||
event_triggers_enabled: bool (default true)
|
||||
event_cooldown_global: int seconds (default 30)
|
||||
event_cooldown_per_player: int seconds (default 120)
|
||||
event_triggers: dict of event_type -> {probability, cooldown_override, enabled}
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# ── Event patterns ─────────────────────────────────────────────────────────
|
||||
|
||||
DEATH_PATTERNS = [
|
||||
(re.compile(r'(\w+) fell from a high place'), "fall", "fell from a high place"),
|
||||
(re.compile(r'(\w+) hit the ground too hard'), "fall", "hit the ground too hard"),
|
||||
(re.compile(r'(\w+) was slain by (\w+)'), "mob_kill", "was slain by {1}"),
|
||||
(re.compile(r'(\w+) was shot by (\w+)'), "ranged_kill", "was shot by {1}"),
|
||||
(re.compile(r'(\w+) drowned'), "drown", "drowned"),
|
||||
(re.compile(r'(\w+) tried to swim in lava'), "lava", "tried to swim in lava"),
|
||||
(re.compile(r'(\w+) burned to death'), "fire", "burned to death"),
|
||||
(re.compile(r'(\w+) went up in flames'), "fire", "went up in flames"),
|
||||
(re.compile(r'(\w+) blew up'), "explosion", "blew up"),
|
||||
(re.compile(r'(\w+) was blown up by (\w+)'), "explosion", "was blown up by {1}"),
|
||||
(re.compile(r'(\w+) suffocated in a wall'), "suffocate", "suffocated in a wall"),
|
||||
(re.compile(r'(\w+) starved to death'), "starve", "starved to death"),
|
||||
(re.compile(r'(\w+) was killed by (\w+)'), "pvp_or_mob", "was killed by {1}"),
|
||||
(re.compile(r'(\w+) was pricked to death'), "cactus", "was pricked to death"),
|
||||
(re.compile(r'(\w+) withered away'), "wither", "withered away"),
|
||||
(re.compile(r'(\w+) was impaled by (\w+)'), "trident", "was impaled by {1}"),
|
||||
(re.compile(r'(\w+) fell out of the world'), "void", "fell out of the world"),
|
||||
]
|
||||
|
||||
JOIN_PATTERN = re.compile(r'(\w+) joined the game')
|
||||
LEAVE_PATTERN = re.compile(r'(\w+) left the game')
|
||||
ADVANCEMENT_PATTERN = re.compile(r'(\w+) has made the advancement \[(.+?)\]')
|
||||
CHALLENGE_PATTERN = re.compile(r'(\w+) has completed the challenge \[(.+?)\]')
|
||||
|
||||
# Players that killed other players — detect from death messages
|
||||
PVP_KILLERS = {"was slain by", "was shot by", "was killed by"}
|
||||
|
||||
# ── Default trigger config ─────────────────────────────────────────────────
|
||||
|
||||
DEFAULT_TRIGGERS = {
|
||||
"death_fall": {"probability": 0.40, "enabled": True},
|
||||
"death_lava": {"probability": 0.35, "enabled": True},
|
||||
"death_mob": {"probability": 0.25, "enabled": True},
|
||||
"death_pvp": {"probability": 0.60, "enabled": True},
|
||||
"death_drown": {"probability": 0.20, "enabled": True},
|
||||
"death_fire": {"probability": 0.25, "enabled": True},
|
||||
"death_explosion": {"probability": 0.30, "enabled": True},
|
||||
"death_void": {"probability": 0.50, "enabled": True},
|
||||
"death_starve": {"probability": 0.30, "enabled": True},
|
||||
"death_other": {"probability": 0.15, "enabled": True},
|
||||
"join_first": {"probability": 1.00, "enabled": True},
|
||||
"join_returning": {"probability": 0.30, "enabled": True},
|
||||
"leave_long": {"probability": 0.20, "enabled": True}, # played 2+ hours
|
||||
"advancement": {"probability": 0.50, "enabled": True},
|
||||
"challenge": {"probability": 0.70, "enabled": True},
|
||||
"mass_death": {"probability": 0.80, "enabled": True}, # 2+ deaths in 30s
|
||||
}
|
||||
|
||||
GLOBAL_COOLDOWN = 30 # seconds between any event trigger
|
||||
PLAYER_COOLDOWN = 120 # seconds between triggers for same player
|
||||
MASS_DEATH_WINDOW = 30 # seconds to detect mass deaths
|
||||
|
||||
|
||||
class EventDispatcher:
|
||||
def __init__(self, config: dict, llm_callback: Callable, rcon_fn: Callable):
|
||||
"""
|
||||
config: server config dict
|
||||
llm_callback: function(prompt: str, mode: str) -> dict with {message, commands, reasoning}
|
||||
rcon_fn: function(command: str) -> str
|
||||
"""
|
||||
self.config = config
|
||||
self.llm_callback = llm_callback
|
||||
self.rcon = rcon_fn
|
||||
self.enabled = config.get("event_triggers_enabled", True)
|
||||
|
||||
# Merge user trigger config with defaults
|
||||
self.triggers = dict(DEFAULT_TRIGGERS)
|
||||
user_triggers = config.get("event_triggers", {})
|
||||
for k, v in user_triggers.items():
|
||||
if k in self.triggers:
|
||||
self.triggers[k].update(v)
|
||||
|
||||
self.global_cooldown = config.get("event_cooldown_global", GLOBAL_COOLDOWN)
|
||||
self.player_cooldown = config.get("event_cooldown_per_player", PLAYER_COOLDOWN)
|
||||
|
||||
# State
|
||||
self._lock = threading.Lock()
|
||||
self._last_global_trigger = 0.0
|
||||
self._last_player_trigger: Dict[str, float] = defaultdict(float)
|
||||
self._recent_deaths: List[tuple] = [] # (timestamp, player, cause)
|
||||
self._player_join_times: Dict[str, float] = {}
|
||||
self._known_players: set = set() # players we've seen before
|
||||
self._known_players_path = config.get("event_known_players_path",
|
||||
config.get("log_path", "").replace("logs/latest.log", "aigod_known_players.json"))
|
||||
|
||||
self._load_known_players()
|
||||
|
||||
def _load_known_players(self):
|
||||
try:
|
||||
with open(self._known_players_path) as f:
|
||||
self._known_players = set(json.load(f))
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
self._known_players = set()
|
||||
|
||||
def _save_known_players(self):
|
||||
try:
|
||||
with open(self._known_players_path, "w") as f:
|
||||
json.dump(list(self._known_players), f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _can_trigger(self, player: str) -> bool:
|
||||
"""Check global and per-player cooldowns."""
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
if now - self._last_global_trigger < self.global_cooldown:
|
||||
return False
|
||||
if now - self._last_player_trigger[player] < self.player_cooldown:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _mark_triggered(self, player: str):
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
self._last_global_trigger = now
|
||||
self._last_player_trigger[player] = now
|
||||
|
||||
def _should_fire(self, event_type: str) -> bool:
|
||||
"""Roll probability for this event type."""
|
||||
trigger = self.triggers.get(event_type, {"probability": 0.0, "enabled": False})
|
||||
if not trigger.get("enabled", False):
|
||||
return False
|
||||
return random.random() < trigger.get("probability", 0.0)
|
||||
|
||||
def _fire_event(self, event_text: str, player: str, event_type: str):
|
||||
"""Send the event to the LLM and execute the response."""
|
||||
self._mark_triggered(player)
|
||||
|
||||
log.info(f"[event] firing {event_type} for {player}: {event_text}")
|
||||
|
||||
try:
|
||||
result = self.llm_callback(event_text, "god_system")
|
||||
if not result:
|
||||
return
|
||||
|
||||
message = result.get("message", "")
|
||||
commands = result.get("commands", [])
|
||||
|
||||
# Send God's message
|
||||
if message:
|
||||
# Format for Minecraft chat
|
||||
safe_msg = message.replace('"', '\\"')
|
||||
prefix = self.config.get("god_chat_prefix", "[\u00a7d\u00a7lGOD\u00a7r]")
|
||||
self.rcon(f'tellraw @a {{"text":"{prefix} {safe_msg}"}}')
|
||||
|
||||
# Execute commands
|
||||
for cmd in commands[:8]: # cap at 8 commands
|
||||
if isinstance(cmd, str) and cmd.strip():
|
||||
try:
|
||||
self.rcon(cmd.strip())
|
||||
except Exception as e:
|
||||
log.warning(f"[event] command failed: {cmd} — {e}")
|
||||
|
||||
log.info(f"[event] {event_type}: sent message + {len(commands)} commands")
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"[event] LLM callback failed for {event_type}: {e}")
|
||||
|
||||
# ── Log line processing ────────────────────────────────────────────────
|
||||
|
||||
def process_log_line(self, line: str):
|
||||
"""Parse a log line and dispatch any events."""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
# Strip the log prefix to get the message
|
||||
# Format: [HH:MM:SS INFO]: message
|
||||
msg_match = re.search(r'INFO\]: (.+)$', line)
|
||||
if not msg_match:
|
||||
return
|
||||
msg = msg_match.group(1).strip()
|
||||
|
||||
self._check_death(msg)
|
||||
self._check_join(msg)
|
||||
self._check_leave(msg)
|
||||
self._check_advancement(msg)
|
||||
|
||||
def _check_death(self, msg: str):
|
||||
now = time.time()
|
||||
|
||||
for pattern, death_type, desc_template in DEATH_PATTERNS:
|
||||
m = pattern.search(msg)
|
||||
if not m:
|
||||
continue
|
||||
|
||||
player = m.group(1)
|
||||
killer = m.group(2) if m.lastindex and m.lastindex >= 2 else None
|
||||
description = desc_template.format(**{str(i): m.group(i+1) for i in range(m.lastindex or 0)}) if m.lastindex else desc_template
|
||||
|
||||
# Track for mass death detection
|
||||
with self._lock:
|
||||
self._recent_deaths.append((now, player, death_type))
|
||||
# Prune old deaths
|
||||
self._recent_deaths = [(t, p, c) for t, p, c in self._recent_deaths if now - t < MASS_DEATH_WINDOW]
|
||||
|
||||
# Check for mass death
|
||||
recent_count = len(self._recent_deaths)
|
||||
|
||||
# Determine event type
|
||||
if killer and killer in self._known_players:
|
||||
event_type = "death_pvp"
|
||||
elif death_type == "fall":
|
||||
event_type = "death_fall"
|
||||
elif death_type == "lava":
|
||||
event_type = "death_lava"
|
||||
elif death_type == "drown":
|
||||
event_type = "death_drown"
|
||||
elif death_type in ("fire",):
|
||||
event_type = "death_fire"
|
||||
elif death_type == "explosion":
|
||||
event_type = "death_explosion"
|
||||
elif death_type == "void":
|
||||
event_type = "death_void"
|
||||
elif death_type == "starve":
|
||||
event_type = "death_starve"
|
||||
elif death_type in ("mob_kill", "ranged_kill", "pvp_or_mob"):
|
||||
event_type = "death_mob"
|
||||
else:
|
||||
event_type = "death_other"
|
||||
|
||||
# Mass death override
|
||||
if recent_count >= 2 and self._should_fire("mass_death") and self._can_trigger(player):
|
||||
victims = list(set(p for _, p, _ in self._recent_deaths))
|
||||
event_text = f"[EVENT:MASS_DEATH] {recent_count} players died within {MASS_DEATH_WINDOW} seconds: {', '.join(victims)}"
|
||||
threading.Thread(target=self._fire_event, args=(event_text, player, "mass_death"), daemon=True).start()
|
||||
with self._lock:
|
||||
self._recent_deaths.clear()
|
||||
return
|
||||
|
||||
if not self._can_trigger(player):
|
||||
return
|
||||
if not self._should_fire(event_type):
|
||||
return
|
||||
|
||||
event_text = f"[EVENT:DEATH] Player {player} {description}"
|
||||
if killer:
|
||||
event_text += f" (killer: {killer})"
|
||||
|
||||
threading.Thread(target=self._fire_event, args=(event_text, player, event_type), daemon=True).start()
|
||||
return
|
||||
|
||||
def _check_join(self, msg: str):
|
||||
m = JOIN_PATTERN.search(msg)
|
||||
if not m:
|
||||
return
|
||||
|
||||
player = m.group(1)
|
||||
self._player_join_times[player] = time.time()
|
||||
|
||||
is_first = player not in self._known_players
|
||||
if is_first:
|
||||
self._known_players.add(player)
|
||||
self._save_known_players()
|
||||
event_type = "join_first"
|
||||
event_text = f"[EVENT:JOIN] Player {player} joined the game for the first time"
|
||||
else:
|
||||
event_type = "join_returning"
|
||||
event_text = f"[EVENT:JOIN] Player {player} joined the game (returning player)"
|
||||
|
||||
if not self._can_trigger(player):
|
||||
return
|
||||
if not self._should_fire(event_type):
|
||||
return
|
||||
|
||||
threading.Thread(target=self._fire_event, args=(event_text, player, event_type), daemon=True).start()
|
||||
|
||||
def _check_leave(self, msg: str):
|
||||
m = LEAVE_PATTERN.search(msg)
|
||||
if not m:
|
||||
return
|
||||
|
||||
player = m.group(1)
|
||||
join_time = self._player_join_times.pop(player, None)
|
||||
|
||||
if join_time:
|
||||
session_minutes = (time.time() - join_time) / 60
|
||||
if session_minutes >= 120: # 2+ hours
|
||||
event_type = "leave_long"
|
||||
event_text = f"[EVENT:LEAVE] Player {player} left the game after {session_minutes:.0f} minutes"
|
||||
|
||||
if not self._can_trigger(player):
|
||||
return
|
||||
if not self._should_fire(event_type):
|
||||
return
|
||||
|
||||
threading.Thread(target=self._fire_event, args=(event_text, player, event_type), daemon=True).start()
|
||||
|
||||
def _check_advancement(self, msg: str):
|
||||
m = ADVANCEMENT_PATTERN.search(msg) or CHALLENGE_PATTERN.search(msg)
|
||||
if not m:
|
||||
return
|
||||
|
||||
player = m.group(1)
|
||||
advancement = m.group(2)
|
||||
is_challenge = bool(CHALLENGE_PATTERN.search(msg))
|
||||
|
||||
event_type = "challenge" if is_challenge else "advancement"
|
||||
event_text = f"[EVENT:ADVANCEMENT] Player {player} earned [{advancement}]"
|
||||
|
||||
if not self._can_trigger(player):
|
||||
return
|
||||
if not self._should_fire(event_type):
|
||||
return
|
||||
|
||||
threading.Thread(target=self._fire_event, args=(event_text, player, event_type), daemon=True).start()
|
||||
@@ -0,0 +1,392 @@
|
||||
"""
|
||||
Mortdecai Script Manager — mcfunction datapack CRUD + validation + scheduling.
|
||||
|
||||
Gives the model full control over a Minecraft datapack:
|
||||
script.write — create/overwrite mcfunction files
|
||||
script.validate — dry-run commands through RCON before writing
|
||||
script.execute — run a function via RCON
|
||||
script.read — read an existing script
|
||||
script.list — list all scripts in the datapack
|
||||
script.delete — remove a script
|
||||
script.schedule — add to tick.json or load.json
|
||||
|
||||
Datapack structure:
|
||||
<server>/world/datapacks/mortdecai/
|
||||
pack.mcmeta
|
||||
data/mortdecai/
|
||||
function/ ← mcfunction files live here
|
||||
tags/function/ ← tick.json, load.json for scheduling
|
||||
|
||||
Usage:
|
||||
from agent.tools.script_manager import ScriptManager
|
||||
mgr = ScriptManager(config)
|
||||
result = mgr.write("build_house", ["fill ~-5 ~ ~-5 ~5 ~4 ~5 oak_planks hollow", ...])
|
||||
result = mgr.validate(["give @s minecraft:diamond 1", "bad command here"])
|
||||
result = mgr.execute("build_house", as_player="slingshooter08")
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
_lock = threading.Lock()
|
||||
|
||||
MAX_SCRIPT_LINES = 200
|
||||
MAX_SCRIPTS = 50
|
||||
NAMESPACE = "mortdecai"
|
||||
|
||||
PACK_MCMETA = {
|
||||
"pack": {
|
||||
"pack_format": 48,
|
||||
"description": "Mortdecai AI-generated functions"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ScriptManager:
|
||||
def __init__(self, config: dict):
|
||||
"""
|
||||
config keys:
|
||||
datapack_path: str — path to the datapack root (e.g. /opt/paper-ai-25567/world/datapacks/mortdecai)
|
||||
rcon_fn: callable — function(command: str) -> str that executes an RCON command and returns the result
|
||||
"""
|
||||
self.base = Path(config.get("datapack_path", "/opt/paper-ai-25567/world/datapacks/mortdecai"))
|
||||
self.func_dir = self.base / "data" / NAMESPACE / "function"
|
||||
self.tags_dir = self.base / "data" / "minecraft" / "tags" / "function"
|
||||
self.rcon = config.get("rcon_fn")
|
||||
self._ensure_datapack()
|
||||
|
||||
def _ensure_datapack(self):
|
||||
"""Create the datapack structure if it doesn't exist."""
|
||||
with _lock:
|
||||
self.func_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.tags_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
mcmeta = self.base / "pack.mcmeta"
|
||||
if not mcmeta.exists():
|
||||
with open(mcmeta, "w") as f:
|
||||
json.dump(PACK_MCMETA, f, indent=2)
|
||||
|
||||
# Ensure tick.json and load.json exist
|
||||
for tag_name in ("tick", "load"):
|
||||
tag_file = self.tags_dir / f"{tag_name}.json"
|
||||
if not tag_file.exists():
|
||||
with open(tag_file, "w") as f:
|
||||
json.dump({"values": []}, f, indent=2)
|
||||
|
||||
def _sanitize_name(self, name: str) -> str:
|
||||
"""Sanitize a script name to a valid mcfunction filename."""
|
||||
name = name.lower().strip()
|
||||
name = re.sub(r'[^a-z0-9_/]', '_', name)
|
||||
name = re.sub(r'_+', '_', name).strip('_')
|
||||
return name or "unnamed"
|
||||
|
||||
def _func_path(self, name: str) -> Path:
|
||||
return self.func_dir / f"{self._sanitize_name(name)}.mcfunction"
|
||||
|
||||
# ── Write ──────────────────────────────────────────────────────────────
|
||||
|
||||
def write(self, name: str, commands: List[str], description: str = "") -> Dict[str, Any]:
|
||||
"""Write a mcfunction file. Returns {ok, path, lines}."""
|
||||
name = self._sanitize_name(name)
|
||||
|
||||
if len(commands) > MAX_SCRIPT_LINES:
|
||||
return {"ok": False, "error": f"Too many lines ({len(commands)}). Max {MAX_SCRIPT_LINES}."}
|
||||
|
||||
# Check total script count
|
||||
existing = list(self.func_dir.glob("*.mcfunction"))
|
||||
if len(existing) >= MAX_SCRIPTS and not self._func_path(name).exists():
|
||||
return {"ok": False, "error": f"Too many scripts ({len(existing)}). Max {MAX_SCRIPTS}. Delete some first."}
|
||||
|
||||
# Strip leading slashes from commands
|
||||
cleaned = []
|
||||
for cmd in commands:
|
||||
cmd = cmd.strip()
|
||||
if cmd.startswith("/"):
|
||||
cmd = cmd[1:]
|
||||
if cmd and not cmd.startswith("#"):
|
||||
cleaned.append(cmd)
|
||||
elif cmd.startswith("#"):
|
||||
cleaned.append(cmd) # preserve comments
|
||||
|
||||
lines = []
|
||||
if description:
|
||||
lines.append(f"# {description}")
|
||||
lines.append(f"# Generated by Mortdecai")
|
||||
lines.append("")
|
||||
lines.extend(cleaned)
|
||||
|
||||
path = self._func_path(name)
|
||||
with _lock:
|
||||
# Support subdirectories
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
|
||||
return {"ok": True, "path": f"{NAMESPACE}:{name}", "lines": len(cleaned)}
|
||||
|
||||
# ── Validate ───────────────────────────────────────────────────────────
|
||||
|
||||
def validate(self, commands: List[str]) -> Dict[str, Any]:
|
||||
"""Dry-run validate commands through RCON. Returns per-line results."""
|
||||
if not self.rcon:
|
||||
return {"ok": False, "error": "No RCON connection available"}
|
||||
|
||||
errors = []
|
||||
passed = 0
|
||||
|
||||
for i, cmd in enumerate(commands):
|
||||
cmd = cmd.strip()
|
||||
if not cmd or cmd.startswith("#"):
|
||||
passed += 1
|
||||
continue
|
||||
if cmd.startswith("/"):
|
||||
cmd = cmd[1:]
|
||||
|
||||
# Skip commands that use relative coords — can't validate without context
|
||||
# But we can still check the command structure
|
||||
if "~" in cmd or "^" in cmd:
|
||||
# Basic syntax check: try to parse the command name at least
|
||||
parts = cmd.split()
|
||||
base_cmd = parts[0] if parts else ""
|
||||
valid_bases = {
|
||||
"give", "tp", "teleport", "effect", "kill", "summon", "setblock",
|
||||
"fill", "clone", "weather", "time", "gamemode", "clear", "xp",
|
||||
"experience", "execute", "playsound", "title", "particle", "say",
|
||||
"tellraw", "scoreboard", "team", "tag", "data", "attribute",
|
||||
"enchant", "spreadplayers", "spawnpoint", "setworldspawn",
|
||||
"worldborder", "gamerule", "difficulty", "forceload", "locate",
|
||||
"place", "ride", "damage", "return", "schedule", "function",
|
||||
"bossbar", "recipe", "advancement", "loot", "item", "trigger",
|
||||
}
|
||||
if base_cmd in valid_bases:
|
||||
passed += 1
|
||||
else:
|
||||
errors.append({"line": i + 1, "command": cmd, "error": f"Unknown command: {base_cmd}"})
|
||||
continue
|
||||
|
||||
try:
|
||||
result = self.rcon(cmd)
|
||||
is_error = any(e in result for e in ("<--[HERE]", "Unknown", "Incorrect", "Expected", "Invalid"))
|
||||
if is_error:
|
||||
errors.append({"line": i + 1, "command": cmd, "error": result[:200]})
|
||||
else:
|
||||
passed += 1
|
||||
except Exception as e:
|
||||
errors.append({"line": i + 1, "command": cmd, "error": str(e)})
|
||||
|
||||
return {
|
||||
"valid": len(errors) == 0,
|
||||
"total": len(commands),
|
||||
"passed": passed,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
# ── Execute ────────────────────────────────────────────────────────────
|
||||
|
||||
def execute(self, name: str, as_player: str = None) -> Dict[str, Any]:
|
||||
"""Execute a mcfunction via RCON."""
|
||||
name = self._sanitize_name(name)
|
||||
path = self._func_path(name)
|
||||
|
||||
if not path.exists():
|
||||
return {"ok": False, "error": f"Script '{name}' not found"}
|
||||
|
||||
if not self.rcon:
|
||||
return {"ok": False, "error": "No RCON connection available"}
|
||||
|
||||
# Reload datapack to pick up any changes
|
||||
try:
|
||||
self.rcon("reload")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Execute the function
|
||||
if as_player:
|
||||
cmd = f"execute as {as_player} at @s run function {NAMESPACE}:{name}"
|
||||
else:
|
||||
cmd = f"function {NAMESPACE}:{name}"
|
||||
|
||||
try:
|
||||
result = self.rcon(cmd)
|
||||
is_error = any(e in result for e in ("<--[HERE]", "Unknown", "Incorrect"))
|
||||
return {"ok": not is_error, "result": result[:500]}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
# ── Read ───────────────────────────────────────────────────────────────
|
||||
|
||||
def read(self, name: str) -> Dict[str, Any]:
|
||||
"""Read script contents."""
|
||||
name = self._sanitize_name(name)
|
||||
path = self._func_path(name)
|
||||
|
||||
if not path.exists():
|
||||
return {"ok": False, "error": f"Script '{name}' not found"}
|
||||
|
||||
with open(path) as f:
|
||||
lines = f.read().strip().split("\n")
|
||||
|
||||
# Filter out comments and blanks for the command list
|
||||
commands = [l for l in lines if l.strip() and not l.strip().startswith("#")]
|
||||
|
||||
return {"ok": True, "commands": commands, "lines": len(commands), "raw": lines}
|
||||
|
||||
# ── List ───────────────────────────────────────────────────────────────
|
||||
|
||||
def list_scripts(self) -> Dict[str, Any]:
|
||||
"""List all scripts in the datapack."""
|
||||
scripts = []
|
||||
|
||||
# Read tick/load schedules
|
||||
scheduled = {}
|
||||
for tag_name in ("tick", "load"):
|
||||
tag_file = self.tags_dir / f"{tag_name}.json"
|
||||
if tag_file.exists():
|
||||
with open(tag_file) as f:
|
||||
tag_data = json.load(f)
|
||||
for val in tag_data.get("values", []):
|
||||
func_name = val.replace(f"{NAMESPACE}:", "")
|
||||
scheduled[func_name] = tag_name
|
||||
|
||||
for path in sorted(self.func_dir.glob("**/*.mcfunction")):
|
||||
rel = path.relative_to(self.func_dir)
|
||||
name = str(rel).replace(".mcfunction", "").replace(os.sep, "/")
|
||||
with open(path) as f:
|
||||
line_count = sum(1 for l in f if l.strip() and not l.strip().startswith("#"))
|
||||
scripts.append({
|
||||
"name": name,
|
||||
"lines": line_count,
|
||||
"scheduled": scheduled.get(name, "none"),
|
||||
})
|
||||
|
||||
return {"scripts": scripts}
|
||||
|
||||
# ── Context (for prompt injection) ───────────────────────────────────
|
||||
|
||||
def format_script_context(self) -> str:
|
||||
"""Format available scripts for LLM context injection.
|
||||
Injected into the system prompt so the model knows what scripts exist
|
||||
without needing to call script.list every time."""
|
||||
info = self.list_scripts()
|
||||
scripts = info.get("scripts", [])
|
||||
if not scripts:
|
||||
return ""
|
||||
|
||||
lines = ["=== AVAILABLE SCRIPTS ==="]
|
||||
for s in scripts:
|
||||
sched = f", {s['scheduled']}" if s["scheduled"] != "none" else ""
|
||||
# Try to read the description comment from the file
|
||||
desc = ""
|
||||
path = self._func_path(s["name"])
|
||||
if path.exists():
|
||||
with open(path) as f:
|
||||
first_line = f.readline().strip()
|
||||
if first_line.startswith("# ") and "Generated by" not in first_line:
|
||||
desc = f" — {first_line[2:]}"
|
||||
lines.append(f" {s['name']} ({s['lines']} lines{sched}){desc}")
|
||||
|
||||
lines.append("Use script.execute to run, script.read to inspect, script.write to create new.")
|
||||
return "\n".join(lines)
|
||||
|
||||
# ── Delete ─────────────────────────────────────────────────────────────
|
||||
|
||||
def delete(self, name: str) -> Dict[str, Any]:
|
||||
"""Delete a script and remove from schedules."""
|
||||
name = self._sanitize_name(name)
|
||||
path = self._func_path(name)
|
||||
|
||||
if not path.exists():
|
||||
return {"ok": False, "error": f"Script '{name}' not found"}
|
||||
|
||||
with _lock:
|
||||
path.unlink()
|
||||
|
||||
# Remove from tick/load if scheduled
|
||||
for tag_name in ("tick", "load"):
|
||||
tag_file = self.tags_dir / f"{tag_name}.json"
|
||||
if tag_file.exists():
|
||||
with open(tag_file) as f:
|
||||
tag_data = json.load(f)
|
||||
full_name = f"{NAMESPACE}:{name}"
|
||||
if full_name in tag_data.get("values", []):
|
||||
tag_data["values"].remove(full_name)
|
||||
with open(tag_file, "w") as f:
|
||||
json.dump(tag_data, f, indent=2)
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
# ── Schedule ───────────────────────────────────────────────────────────
|
||||
|
||||
def schedule(self, name: str, schedule_type: str) -> Dict[str, Any]:
|
||||
"""Add a script to tick.json or load.json."""
|
||||
name = self._sanitize_name(name)
|
||||
path = self._func_path(name)
|
||||
|
||||
if not path.exists():
|
||||
return {"ok": False, "error": f"Script '{name}' not found"}
|
||||
|
||||
if schedule_type not in ("tick", "load"):
|
||||
return {"ok": False, "error": f"Invalid schedule type: {schedule_type}. Use 'tick' or 'load'."}
|
||||
|
||||
tag_file = self.tags_dir / f"{schedule_type}.json"
|
||||
full_name = f"{NAMESPACE}:{name}"
|
||||
|
||||
with _lock:
|
||||
with open(tag_file) as f:
|
||||
tag_data = json.load(f)
|
||||
|
||||
if full_name not in tag_data.get("values", []):
|
||||
tag_data.setdefault("values", []).append(full_name)
|
||||
with open(tag_file, "w") as f:
|
||||
json.dump(tag_data, f, indent=2)
|
||||
|
||||
# Reload to activate
|
||||
if self.rcon:
|
||||
try:
|
||||
self.rcon("reload")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Tool handler dispatch ──────────────────────────────────────────────────
|
||||
|
||||
def handle_script_tool(config: dict, tool_name: str, arguments: dict) -> Dict[str, Any]:
|
||||
"""
|
||||
Dispatch a script.* tool call to the ScriptManager.
|
||||
Called from the inference loop when the model emits a script tool call.
|
||||
"""
|
||||
mgr = ScriptManager(config)
|
||||
action = tool_name.split(".")[-1] if "." in tool_name else tool_name
|
||||
|
||||
if action == "write":
|
||||
return mgr.write(
|
||||
name=arguments.get("name", "unnamed"),
|
||||
commands=arguments.get("commands", []),
|
||||
description=arguments.get("description", ""),
|
||||
)
|
||||
elif action == "validate":
|
||||
return mgr.validate(arguments.get("commands", []))
|
||||
elif action == "execute":
|
||||
return mgr.execute(
|
||||
name=arguments.get("name", ""),
|
||||
as_player=arguments.get("as_player"),
|
||||
)
|
||||
elif action == "read":
|
||||
return mgr.read(arguments.get("name", ""))
|
||||
elif action == "list":
|
||||
return mgr.list_scripts()
|
||||
elif action == "delete":
|
||||
return mgr.delete(arguments.get("name", ""))
|
||||
elif action == "schedule":
|
||||
return mgr.schedule(
|
||||
name=arguments.get("name", ""),
|
||||
schedule_type=arguments.get("type", ""),
|
||||
)
|
||||
else:
|
||||
return {"ok": False, "error": f"Unknown script action: {action}"}
|
||||
@@ -127,6 +127,339 @@ TOOL_SCHEMAS: List[Dict[str, Any]] = [
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "world.nearby_entities",
|
||||
"description": (
|
||||
"Scan for entities near a player within a given radius. "
|
||||
"Returns a list of entity types, counts, and distances. "
|
||||
"Use this before kill/target commands to know what's around the player."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"player": {
|
||||
"type": "string",
|
||||
"description": "The player to scan around (case-sensitive)."
|
||||
},
|
||||
"radius": {
|
||||
"type": "integer",
|
||||
"description": "Search radius in blocks (default 32, max 128)."
|
||||
}
|
||||
},
|
||||
"required": ["player"],
|
||||
"additionalProperties": False
|
||||
},
|
||||
"returns": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entities": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {"type": "string"},
|
||||
"count": {"type": "integer"},
|
||||
"nearest_distance": {"type": "number"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"total": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "memory.read",
|
||||
"description": (
|
||||
"Read saved memories for a player — locations, preferences, and facts. "
|
||||
"Use this when a player references a saved location (e.g. 'tp me home') "
|
||||
"or asks what you know about them."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"player": {
|
||||
"type": "string",
|
||||
"description": "Player whose memories to read (omit for requesting player)."
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "Specific memory key to look up (e.g. 'home', 'base'). Omit to get all."
|
||||
}
|
||||
},
|
||||
"additionalProperties": False
|
||||
},
|
||||
"returns": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"memories": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": {"type": "string"},
|
||||
"type": {"type": "string"},
|
||||
"value": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "memory.write",
|
||||
"description": (
|
||||
"Save a memory for a player — a named location, preference, or fact. "
|
||||
"Use this when a player asks you to remember something."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"player": {
|
||||
"type": "string",
|
||||
"description": "Player who owns this memory."
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["location", "preference", "fact"],
|
||||
"description": "Memory type."
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "Memory name (e.g. 'home', 'base', 'favorite_tool')."
|
||||
},
|
||||
"value": {
|
||||
"description": "Memory data — {x,y,z} for locations, string for others."
|
||||
}
|
||||
},
|
||||
"required": ["player", "type", "key", "value"],
|
||||
"additionalProperties": False
|
||||
},
|
||||
"returns": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ok": {"type": "boolean"},
|
||||
"key": {"type": "string"}
|
||||
}
|
||||
}
|
||||
},
|
||||
# ── Script environment tools ──────────────────────────────────────────
|
||||
{
|
||||
"name": "script.write",
|
||||
"description": (
|
||||
"Write a mcfunction script to the Mortdecai datapack. Each line is one "
|
||||
"Minecraft command (no leading slash). Use relative coordinates (~) for "
|
||||
"position-relative builds. Scripts are stored at "
|
||||
"mortdecai:functions/<name>.mcfunction. Overwrites if the script already exists."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Script name (e.g. 'build_house', 'arena_setup'). Lowercase, underscores."
|
||||
},
|
||||
"commands": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "List of Minecraft commands, one per line. No leading slash."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Comment at the top of the file describing what this script does."
|
||||
}
|
||||
},
|
||||
"required": ["name", "commands"],
|
||||
"additionalProperties": False
|
||||
},
|
||||
"returns": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ok": {"type": "boolean"},
|
||||
"path": {"type": "string"},
|
||||
"lines": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "script.validate",
|
||||
"description": (
|
||||
"Validate a list of commands without writing or executing. Dry-runs each "
|
||||
"command through RCON and reports errors. Use this before script.write to "
|
||||
"catch mistakes. Returns per-line pass/fail results."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"commands": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "List of Minecraft commands to validate."
|
||||
}
|
||||
},
|
||||
"required": ["commands"],
|
||||
"additionalProperties": False
|
||||
},
|
||||
"returns": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"valid": {"type": "boolean"},
|
||||
"total": {"type": "integer"},
|
||||
"passed": {"type": "integer"},
|
||||
"errors": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"line": {"type": "integer"},
|
||||
"command": {"type": "string"},
|
||||
"error": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "script.execute",
|
||||
"description": (
|
||||
"Execute a previously written mcfunction script. Optionally run it as/at "
|
||||
"a specific player for correct relative coordinates."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Script name to execute (e.g. 'build_house')."
|
||||
},
|
||||
"as_player": {
|
||||
"type": "string",
|
||||
"description": "Run the script as this player (for ~ coordinates). Optional."
|
||||
}
|
||||
},
|
||||
"required": ["name"],
|
||||
"additionalProperties": False
|
||||
},
|
||||
"returns": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ok": {"type": "boolean"},
|
||||
"result": {"type": "string"}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "script.read",
|
||||
"description": (
|
||||
"Read the contents of an existing mcfunction script. "
|
||||
"Use this to review or debug a script before modifying it."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Script name to read."
|
||||
}
|
||||
},
|
||||
"required": ["name"],
|
||||
"additionalProperties": False
|
||||
},
|
||||
"returns": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ok": {"type": "boolean"},
|
||||
"commands": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"}
|
||||
},
|
||||
"lines": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "script.list",
|
||||
"description": (
|
||||
"List all scripts in the Mortdecai datapack. Returns script names, "
|
||||
"line counts, and whether they are scheduled (tick/load)."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": False
|
||||
},
|
||||
"returns": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scripts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"lines": {"type": "integer"},
|
||||
"scheduled": {"type": "string", "description": "tick, load, or none"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "script.delete",
|
||||
"description": (
|
||||
"Delete a mcfunction script from the datapack. Also removes it from "
|
||||
"tick/load schedules if applicable."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Script name to delete."
|
||||
}
|
||||
},
|
||||
"required": ["name"],
|
||||
"additionalProperties": False
|
||||
},
|
||||
"returns": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ok": {"type": "boolean"}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "script.schedule",
|
||||
"description": (
|
||||
"Schedule a script to run automatically on tick (every game tick = 50ms) "
|
||||
"or on load (when server starts/reloads). Use tick for continuous effects, "
|
||||
"load for initialization. WARNING: tick functions run 20x/second — keep them "
|
||||
"lightweight or they will lag the server."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Script name to schedule."
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["tick", "load"],
|
||||
"description": "Schedule type."
|
||||
}
|
||||
},
|
||||
"required": ["name", "type"],
|
||||
"additionalProperties": False
|
||||
},
|
||||
"returns": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ok": {"type": "boolean"}
|
||||
}
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user