Add teleport border guard, dimension-aware TP, fully enchanted context, enchantment guidance for god

This commit is contained in:
Claude Code
2026-03-16 19:28:51 -04:00
parent 83b9037a94
commit fe36ae3739
3 changed files with 367 additions and 5 deletions
+180
View File
@@ -0,0 +1,180 @@
# LangGraph Implementation Idea
## Goal
Add a session-based agent gateway in front of Ollama so the Minecraft AI system can:
- keep per-player/session memory,
- run multi-step tool calls (MCP/web search),
- return a final `message + commands` payload to the existing plugin.
This is a future enhancement. Current system is working and remains the source of truth for command execution safety.
---
## Why This Exists
Current `mc_aigod.py` calls Ollama directly. That is effectively stateless unless full history is re-sent every call.
A LangGraph sidecar can provide:
- persistent sessions/threads,
- tool loop orchestration,
- model routing,
- better observability of intermediate reasoning steps.
---
## Proposed Architecture
```text
Minecraft chat -> mc_aigod.py -> LangGraph Gateway API -> Ollama
| \
| -> MCP tools (web search/wiki/etc)
-> Session store (SQLite/Redis)
```
`mc_aigod.py` remains responsible for:
- trigger parsing (`pray`, `bible`, `sudo`),
- RCON command execution,
- whitelist/validation/fixups,
- hard safety rules (e.g. first-login kill limits).
LangGraph gateway is responsible for:
- session state,
- tool calls,
- composing final JSON output.
---
## API Contract Sketch
### Start session
`POST /v1/session/start`
Request:
```json
{
"player": "slingshooter08",
"mode": "god"
}
```
Response:
```json
{
"session_id": "sess_abc123"
}
```
### Send message
`POST /v1/session/{session_id}/message`
Request:
```json
{
"role": "user",
"text": "pray I need wood for shelter",
"context": {
"server_state": {},
"player_state": {},
"recent_events": []
},
"allow_tools": true,
"max_tool_steps": 4
}
```
Response:
```json
{
"message": "Divine response text",
"commands": [
"give slingshooter08 minecraft:oak_log 64"
],
"tool_trace": [
{
"tool": "web.search",
"input": "minecraft oak log item id",
"ok": true
}
]
}
```
### End session (optional)
`POST /v1/session/{session_id}/close`
---
## LangGraph Flow (Draft)
1. Load session state by `session_id`
2. Add user message + context
3. Call command model
4. If tool requested:
- execute MCP tool
- append tool result
- loop until final commands or step limit reached
5. Call message model with chosen commands
6. Return final `{message, commands}`
7. Persist updated session state
---
## Tooling Plan
Primary tools to add first:
- `web.search` (general search)
- `minecraft.wiki_lookup` (targeted page fetch/search)
Potential later tools:
- local documentation lookup,
- server analytics lookup,
- schematic index lookup.
---
## Modes to Support
- `god` (prayer flow, tool-enabled)
- `sudo` (translator flow, likely tool-disabled or very limited)
- `god_system` (intervention/first-login internal events)
---
## Safety Model (Keep in mc_aigod.py)
Even after LangGraph is added, keep hard enforcement in plugin runtime:
- whitelist command families,
- syntax repair + normalization,
- max commands cap,
- per-flow constraints (e.g. first-login benevolence restrictions),
- unauthorized sudo user rejection.
This ensures model/tool errors cannot directly bypass execution safeguards.
---
## MVP Steps (Later)
1. Build FastAPI LangGraph gateway with in-memory sessions
2. Add `/session/start` + `/session/{id}/message`
3. Mirror current two-call behavior (no tools yet)
4. Switch `mc_aigod.py` to gateway endpoint
5. Add one MCP search tool and bounded tool loop
6. Add persistence (SQLite/Redis)
7. Add structured logs + tool traces
---
## Open Questions
- Session lifetime policy (per player, per login, time-based expiry?)
- Whether `sudo` should ever be tool-enabled
- How much tool trace to expose in-game vs log-only
- Which MCP stack to standardize on for web search
---
## Notes
This document is a planning scratchpad for future implementation. It is intentionally practical and API-first so a coder bot can pick it up and implement directly.
+183 -5
View File
@@ -526,6 +526,14 @@ EFFECTS (replace {target} with any online player's username):
MOVEMENT: MOVEMENT:
tp {target} 0 64 0 tp {target} 0 64 0
tp {target} <x> <y> <z>
tp {target} ~ ~10 ~
execute in minecraft:the_nether run tp {target} <x> <y> <z>
execute in minecraft:the_end run tp {target} 0 64 0
execute in minecraft:overworld run tp {target} <x> <y> <z>
NOTE: To teleport a player to another dimension ALWAYS use:
execute in minecraft:<dimension> run tp <player> <x> <y> <z>
NEVER use: tp <player> minecraft:the_nether (this is wrong syntax)
WORLD/ENVIRONMENT (affects all players): WORLD/ENVIRONMENT (affects all players):
time set day time set day
@@ -668,6 +676,36 @@ def _parse_llm_json(content: str) -> dict:
log.warning(f"Repaired JSON: message={len(message)}chars, commands={commands}") log.warning(f"Repaired JSON: message={len(message)}chars, commands={commands}")
return result return result
ENCHANTMENT_CONTEXT = """
=== ENCHANTMENT RULES FOR GOD ===
When giving weapons, tools, or armor as a divine gift, you should ALMOST ALWAYS enchant them.
Enchanted gifts feel more divine. Unenchanted items are acceptable only as a deliberate
choice (e.g. giving basic materials, a punishment of mediocrity, or items that cannot be enchanted).
Use 1.21 component syntax: give <player> minecraft:<item>[enchantments={ench1:lvl,ench2:lvl}] 1
MAX ENCHANTMENT REFERENCE (use as baseline for "fully enchanted" or "blessed" gifts):
SWORD: netherite_sword[enchantments={sharpness:5,unbreaking:3,looting:3,fire_aspect:2,mending:1,sweeping_edge:3}]
PICKAXE: netherite_pickaxe[enchantments={efficiency:5,unbreaking:3,fortune:3,mending:1}]
AXE: netherite_axe[enchantments={efficiency:5,unbreaking:3,fortune:3,sharpness:5,mending:1}]
SHOVEL: netherite_shovel[enchantments={efficiency:5,unbreaking:3,fortune:3,mending:1}]
HOE: netherite_hoe[enchantments={efficiency:5,unbreaking:3,fortune:3,mending:1}]
BOW: bow[enchantments={power:5,unbreaking:3,infinity:1,flame:1,punch:2}]
CROSSBOW: crossbow[enchantments={multishot:1,quick_charge:3,unbreaking:3,mending:1}]
TRIDENT: trident[enchantments={channeling:1,loyalty:3,unbreaking:3,mending:1,impaling:5}]
HELMET: netherite_helmet[enchantments={protection:4,unbreaking:3,respiration:3,aqua_affinity:1,thorns:3,mending:1}]
CHEST: netherite_chestplate[enchantments={protection:4,unbreaking:3,thorns:3,mending:1}]
LEGS: netherite_leggings[enchantments={protection:4,unbreaking:3,swift_sneak:3,mending:1}]
BOOTS: netherite_boots[enchantments={protection:4,unbreaking:3,feather_falling:4,depth_strider:3,soul_speed:3,mending:1}]
FISHING: fishing_rod[enchantments={luck_of_the_sea:3,lure:3,unbreaking:3,mending:1}]
ELYTRA: elytra[enchantments={unbreaking:3,mending:1}]
SHIELD: shield[enchantments={unbreaking:3,mending:1}]
You do NOT need to always give max enchants — a modest reward may have fewer.
But unenchanted weapons/tools/armor from God should be the exception, not the rule.
"""
COMMANDS_SYSTEM_PROMPT = ( COMMANDS_SYSTEM_PROMPT = (
"You are a Minecraft server command executor. Given a player's prayer and server context, " "You are a Minecraft server command executor. Given a player's prayer and server context, "
"decide what server commands to run (if any) as an act of God.\n\n" "decide what server commands to run (if any) as an act of God.\n\n"
@@ -688,6 +726,7 @@ COMMANDS_SYSTEM_PROMPT = (
+ COMMAND_PALETTE + COMMAND_PALETTE
+ "\n=== ITEM LIBRARY ===\n" + "\n=== ITEM LIBRARY ===\n"
+ ITEM_LIBRARY + ITEM_LIBRARY
+ ENCHANTMENT_CONTEXT
) )
SUDO_COMMANDS_SYSTEM_PROMPT = ( SUDO_COMMANDS_SYSTEM_PROMPT = (
@@ -700,9 +739,68 @@ SUDO_COMMANDS_SYSTEM_PROMPT = (
"- If the request cannot be mapped safely, return commands: [].\n" "- If the request cannot be mapped safely, return commands: [].\n"
"- If player says 'me' or 'my', target the requesting player.\n" "- If player says 'me' or 'my', target the requesting player.\n"
"- You will receive LAST 10 SUDO ACTIONS. Use them for continuity and corrections when the player says previous output was wrong.\n" "- You will receive LAST 10 SUDO ACTIONS. Use them for continuity and corrections when the player says previous output was wrong.\n"
"- For give syntax: give <player> minecraft:<item_id> <count>\n" "- For give syntax: give <player> minecraft:<item_id> <count> (count LAST, namespace required)\n"
"- Count is last. Namespace minecraft: is required.\n"
"- Return commands only. No commentary.\n" "- Return commands only. No commentary.\n"
"\n"
"=== TELEPORT SYNTAX ===\n"
"Same dimension: tp <player> <x> <y> <z>\n"
"Relative: tp <player> ~ ~10 ~\n"
"To Nether: execute in minecraft:the_nether run tp <player> <x> <y> <z>\n"
"To End: execute in minecraft:the_end run tp <player> <x> 64 <z>\n"
"To Overworld: execute in minecraft:overworld run tp <player> <x> <y> <z>\n"
"WRONG (never do this): tp <player> minecraft:the_nether\n"
"When dimension is unspecified, use a sensible default spawn coord for that dimension.\n"
"\n"
"=== FULLY ENCHANTED (max enchantments per item type, 1.21 syntax) ===\n"
"Use item[enchantments={...}] syntax. Count is always 1 for enchanted items.\n"
"\n"
"SWORD (netherite_sword):\n"
" give <p> minecraft:netherite_sword[enchantments={sharpness:5,unbreaking:3,looting:3,fire_aspect:2,mending:1,sweeping_edge:3}] 1\n"
"\n"
"PICKAXE (netherite_pickaxe):\n"
" give <p> minecraft:netherite_pickaxe[enchantments={efficiency:5,unbreaking:3,fortune:3,mending:1}] 1\n"
"\n"
"AXE (netherite_axe):\n"
" give <p> minecraft:netherite_axe[enchantments={efficiency:5,unbreaking:3,fortune:3,sharpness:5,mending:1}] 1\n"
"\n"
"SHOVEL (netherite_shovel):\n"
" give <p> minecraft:netherite_shovel[enchantments={efficiency:5,unbreaking:3,fortune:3,mending:1}] 1\n"
"\n"
"HOE (netherite_hoe):\n"
" give <p> minecraft:netherite_hoe[enchantments={efficiency:5,unbreaking:3,fortune:3,mending:1}] 1\n"
"\n"
"BOW:\n"
" give <p> minecraft:bow[enchantments={power:5,unbreaking:3,infinity:1,flame:1,punch:2}] 1\n"
"\n"
"CROSSBOW:\n"
" give <p> minecraft:crossbow[enchantments={multishot:1,quick_charge:3,unbreaking:3,mending:1}] 1\n"
"\n"
"TRIDENT:\n"
" give <p> minecraft:trident[enchantments={channeling:1,loyalty:3,riptide:3,unbreaking:3,mending:1,impaling:5}] 1\n"
" (note: riptide and loyalty/channeling are mutually exclusive — pick one set)\n"
"\n"
"HELMET (netherite_helmet):\n"
" give <p> minecraft:netherite_helmet[enchantments={protection:4,unbreaking:3,respiration:3,aqua_affinity:1,thorns:3,mending:1}] 1\n"
"\n"
"CHESTPLATE (netherite_chestplate):\n"
" give <p> minecraft:netherite_chestplate[enchantments={protection:4,unbreaking:3,thorns:3,mending:1}] 1\n"
"\n"
"LEGGINGS (netherite_leggings):\n"
" give <p> minecraft:netherite_leggings[enchantments={protection:4,unbreaking:3,swift_sneak:3,mending:1}] 1\n"
"\n"
"BOOTS (netherite_boots):\n"
" give <p> minecraft:netherite_boots[enchantments={protection:4,unbreaking:3,feather_falling:4,depth_strider:3,soul_speed:3,mending:1}] 1\n"
"\n"
"FISHING ROD:\n"
" give <p> minecraft:fishing_rod[enchantments={luck_of_the_sea:3,lure:3,unbreaking:3,mending:1}] 1\n"
"\n"
"ELYTRA:\n"
" give <p> minecraft:elytra[enchantments={unbreaking:3,mending:1}] 1\n"
"\n"
"SHIELD:\n"
" give <p> minecraft:shield[enchantments={unbreaking:3,mending:1}] 1\n"
"\n"
"When player asks for 'fully enchanted', 'max enchanted', 'best', 'godlike' gear — use the above templates.\n"
) )
FIRST_LOGIN_BENEVOLENCE_PROMPT = ( FIRST_LOGIN_BENEVOLENCE_PROMPT = (
@@ -921,6 +1019,83 @@ SAFE_PREFIXES = [
'execute ', 'kill ', 'summon ', 'tellraw ', 'worldborder ', 'execute ', 'kill ', 'summon ', 'tellraw ', 'worldborder ',
] ]
_border_cache = {"ts": 0.0, "half": None}
def _tp_border_guard_enabled(config) -> bool:
return bool(config.get("tp_border_guard_enabled", True))
def _parse_abs_coord(tok: str):
tok = (tok or "").strip()
if not tok or tok.startswith("~") or tok.startswith("^"):
return None
try:
return float(tok)
except Exception:
return None
def _extract_tp_abs_xyz(cmd: str):
for m in re.finditer(r'\btp\s+\S+\s+(\S+)\s+(\S+)\s+(\S+)', cmd):
x = _parse_abs_coord(m.group(1))
y = _parse_abs_coord(m.group(2))
z = _parse_abs_coord(m.group(3))
if x is None or y is None or z is None:
continue
return x, y, z
return None
def _worldborder_half_extent(config):
now = time.time()
if now - float(_border_cache.get("ts", 0.0)) < 10.0 and _border_cache.get("half") is not None:
return float(_border_cache["half"])
try:
raw = rcon("worldborder get", config["rcon_host"], config["rcon_port"], config["rcon_password"])
nums = re.findall(r'(-?[\d.]+)', raw or "")
if not nums:
return None
width = float(nums[0])
half = max(0.0, width / 2.0)
_border_cache["ts"] = now
_border_cache["half"] = half
return half
except Exception:
return None
_OTHER_DIMENSION_RE = re.compile(
r'\bexecute\s+in\s+minecraft:(the_nether|the_end|nether|end)\b', re.IGNORECASE
)
def _tp_inside_worldborder(cmd: str, config) -> bool:
if not _tp_border_guard_enabled(config):
return True
# Nether/End dimension teleports use different coordinate spaces — skip border check.
if _OTHER_DIMENSION_RE.search(cmd):
return True
xyz = _extract_tp_abs_xyz(cmd)
if not xyz:
# Relative/dynamic TP can't be statically checked here.
return True
half = _worldborder_half_extent(config)
if half is None:
return True
x, _, z = xyz
cx = float(config.get("worldborder_center_x", 0.0))
cz = float(config.get("worldborder_center_z", 0.0))
margin = max(0.0, float(config.get("tp_border_margin", 2.0)))
limit = max(0.0, half - margin)
return abs(x - cx) <= limit and abs(z - cz) <= limit
def fix_give_command(cmd: str) -> str: def fix_give_command(cmd: str) -> str:
""" """
Correct common LLM give command mistakes: Correct common LLM give command mistakes:
@@ -985,7 +1160,7 @@ def fix_effect_command(cmd: str) -> str:
return fixed return fixed
return cmd return cmd
def validate_command(cmd, online_players, fallback_player): def validate_command(cmd, online_players, fallback_player, config=None):
"""Replace placeholders, auto-fix common give syntax errors, check safe prefix.""" """Replace placeholders, auto-fix common give syntax errors, check safe prefix."""
resolved = cmd.replace("{player}", fallback_player).replace("{target}", fallback_player) resolved = cmd.replace("{player}", fallback_player).replace("{target}", fallback_player)
resolved = fix_give_command(resolved) resolved = fix_give_command(resolved)
@@ -993,6 +1168,9 @@ def validate_command(cmd, online_players, fallback_player):
if not any(resolved.startswith(p) for p in SAFE_PREFIXES): if not any(resolved.startswith(p) for p in SAFE_PREFIXES):
log.warning(f"Command blocked (unknown prefix): {resolved}") log.warning(f"Command blocked (unknown prefix): {resolved}")
return resolved, False return resolved, False
if config and _extract_tp_abs_xyz(resolved) and not _tp_inside_worldborder(resolved, config):
log.warning(f"Command blocked (tp outside worldborder): {resolved}")
return resolved, False
return resolved, True return resolved, True
def execute_response(response, context, config, praying_player=None): def execute_response(response, context, config, praying_player=None):
@@ -1050,7 +1228,7 @@ def execute_response(response, context, config, praying_player=None):
) )
for cmd in commands[:max_cmds]: for cmd in commands[:max_cmds]:
resolved, is_safe = validate_command(cmd, context["online_players"], fallback) resolved, is_safe = validate_command(cmd, context["online_players"], fallback, config)
if not is_safe: if not is_safe:
continue continue
log.info(f"Executing RCON: {resolved}") log.info(f"Executing RCON: {resolved}")
@@ -1242,7 +1420,7 @@ def process_sudo(player, prompt, config):
executed = [] executed = []
for cmd in commands: for cmd in commands:
resolved, is_safe = validate_command(cmd, online, player) resolved, is_safe = validate_command(cmd, online, player, config)
if not is_safe: if not is_safe:
continue continue
log.info(f"SUDO execute: {resolved}") log.info(f"SUDO execute: {resolved}")
+4
View File
@@ -17,6 +17,10 @@
"sudo_enabled": true, "sudo_enabled": true,
"sudo_user": "slingshooter08", "sudo_user": "slingshooter08",
"sudo_max_commands": 3, "sudo_max_commands": 3,
"tp_border_guard_enabled": true,
"worldborder_center_x": 0,
"worldborder_center_z": 0,
"tp_border_margin": 2,
"first_login_benevolence_enabled": true, "first_login_benevolence_enabled": true,
"first_login_benevolence_max_commands": 10, "first_login_benevolence_max_commands": 10,
"first_login_path": "/opt/mcsmanager/daemon/data/InstanceData/shrinkborder1234567890abcdef12345/aigod_first_login_seen.json", "first_login_path": "/opt/mcsmanager/daemon/data/InstanceData/shrinkborder1234567890abcdef12345/aigod_first_login_seen.json",