Files
minecraft-ai-god/Minecraft_Ai_God.md
T
Seth 8ee8be9cc0 Initial commit — Minecraft AI God plugin
- mc_aigod.py: main watcher script (log tail, RCON, two-call LLM)
- Two-call LLM split: qwen3-coder:30b for commands, gemma3:12b for messages
- Divine intervention timer (Poisson process, configurable avg/day)
- Prayer memory (persistent, last 10 exchanges)
- Rolling server log context (last 20 min events)
- Live player context (inventory with rarity, health, food, pos, XP)
- /pray and bible chat detection (no slash — vanilla 1.21 compatible)
- Login notice, bible help system
- debug_commands toggle (in-game command display via tellraw)
- Auto-fix for transposed give command syntax
- JSON repair fallback for truncated LLM responses
- Sentence-aware message chunking for long responses
- mc-aigod.service systemd unit
- mc_aigod_shrink.json example config
- README.md full implementation guide
- Minecraft_Ai_God.md full design document
2026-03-15 19:02:16 -04:00

1308 lines
47 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Minecraft AI God — `/pray` Feature Design
## Overview
A Python-based log watcher that intercepts `/pray` commands from players, forwards the "prayer" to a locally-hosted LLM with a God persona, parses the structured JSON response, executes any commanded action via RCON, and broadcasts God's message to public chat.
This follows the exact same architecture as the existing `mc-godmode.service` and `mc-shrink-kit.service` scripts — no plugins, no mods, no server restarts required.
---
## Architecture
```
┌─────────────────────────────────┐ ┌──────────────────────────────────┐
│ PRAYER THREAD │ │ DIVINE INTERVENTION THREAD │
│ │ │ │
│ Player types: /pray <message> │ │ Random timer fires │
│ │ │ │ (avg N times/day, Poisson) │
│ ▼ │ │ │ │
│ latest.log ←─ tail │ │ ▼ │
│ │ │ │ RCON: is anyone online? │
│ ▼ │ │ No → skip, reschedule │
│ Parse prayer + player name │ │ Yes → continue │
│ │ │ │ │ │
│ ▼ │ │ ▼ │
│ RCON: is anyone online? │ │ RCON: fetch live server context │
│ No → drop silently │ │ │ │
│ Yes → continue │ │ ▼ │
│ │ │ │ Ollama API │
│ ▼ │ │ - message field is null │
│ RCON: immediate ack to player │ │ - prompt: "you may act or not" │
│ "The heavens stir..." │ │ │ │
│ │ │ │ ▼ │
│ ▼ │ │ LLM returns JSON │
│ RCON: fetch live server context│ │ { "message": "..." or null, │
│ │ │ │ "commands": [...] or [] } │
│ ▼ │ │ │ │
│ Ollama API │ │ If message null + commands [] │
│ - prompt: prayer + context │ │ → God chose silence. Do nothing.│
│ │ │ │ │ │
│ ▼ │ │ ▼ │
│ LLM returns JSON │ │ Execute commands + broadcast │
│ { "message", "commands" } │ │ message (if present) │
│ │ │ └──────────────────────────────────┘
│ Validate targets, execute, │
│ broadcast God's message │
└─────────────────────────────────┘
```
**Components:**
- `/usr/local/bin/mc_aigod.py` — main watcher script
- `/etc/mc_aigod.json` — configuration file
- `mc-aigod.service` — systemd unit
- Ollama at `http://192.168.0.179:11434` — LLM backend
---
## Log Detection
### Why no slash commands
Vanilla Minecraft 1.21 rejects unknown commands **client-side** — the client never sends them to the server, so nothing appears in `latest.log`. `/pray` and `/bible` typed as slash commands are silently swallowed.
The solution: players type `pray <message>` and `bible` as **plain chat messages** (no slash). These always reach the server and are logged as:
```
[12:34:56] [Server thread/INFO]: <slingshooter08> pray O Lord, grant me diamonds
[12:34:56] [Server thread/INFO]: <slingshooter08> bible
```
The watcher matches these chat patterns:
```python
PRAY_PATTERNS = [
re.compile(r'\[.*?\]: <(\w+)> [Pp]ray (.+)'),
]
BIBLE_PATTERNS = [
re.compile(r'\[.*?\]: <(\w+)> [Bb]ible\s*$'),
]
```
Players are told this on login and in the bible text itself.
---
## LLM Integration
### Endpoint
```
POST http://192.168.0.179:11434/api/chat
```
### Recommended Model
**`llama3.1:8b` or `mistral:7b`** on Ollama at `192.168.0.179` — general-purpose instruction models with strong roleplay and creative writing capability. `llama3.1:8b` is preferred for richer God-voice prose.
Avoid coding-focused models (e.g. `qwen-coder`, `deepseek-coder`). This task is creative writing + structured JSON output, not code generation. Coding models produce terse, technical responses that make for a terrible deity.
Pull with: `ollama pull llama3.1:8b` (SSH to `192.168.0.179` or via CT 105)
### Live Server Context
Before each LLM call, the script fetches live state from the server via RCON:
```python
def get_server_context(config):
"""Query the server for live state to pass to the LLM."""
def q(cmd):
return rcon(cmd, config["rcon_host"], config["rcon_port"], config["rcon_password"])
# Online players — "There are 3 of a max of 20 players online: alice, bob, carol"
player_list_raw = q("list")
# Extract names from the response
online_players = []
if "players online:" in player_list_raw:
names_part = player_list_raw.split("players online:")[-1].strip()
online_players = [n.strip() for n in names_part.split(",") if n.strip()]
# Time of day (0=dawn, 6000=noon, 12000=dusk, 18000=midnight)
time_raw = q("time query daytime")
# "The time is 6042" → extract number
time_val = 0
m = re.search(r'(\d+)', time_raw)
if m:
time_val = int(m.group(1))
if time_val < 1000: time_label = "dawn"
elif time_val < 6000: time_label = "morning"
elif time_val < 9000: time_label = "afternoon"
elif time_val < 12000: time_label = "dusk"
elif time_val < 14000: time_label = "early night"
else: time_label = "deep night"
# Weather
# Vanilla has no direct "weather query" — infer from recent log or use a scoreboard trick.
# Simplest: track last known weather state in memory (updated when weather commands are issued).
# Passed in as config["_weather_state"] (default "unknown").
weather = config.get("_weather_state", "unknown")
# World border (useful for shrink-world)
border_raw = q("worldborder get")
border_size = None
m = re.search(r'([\d.]+)', border_raw)
if m:
border_size = float(m.group(1))
return {
"online_players": online_players,
"time_of_day": time_label,
"weather": weather,
"world_border": border_size,
}
```
This context is injected into the user message alongside the prayer so the LLM knows who else is online and can target them.
### API Call (Python)
```python
import requests, json
def ask_god(player: str, prayer: str, context: dict, config: dict) -> dict:
system_prompt = build_system_prompt(config)
# Build a rich user message that includes live server state
others = [p for p in context["online_players"] if p != player]
context_block = f"""
=== CURRENT SERVER STATE ===
Time of day: {context['time_of_day']}
Weather: {context['weather']}
Online players: {', '.join(context['online_players']) or 'none'}
Other players who could be targeted: {', '.join(others) or 'none'}
World border: {context['world_border'] if context['world_border'] else 'N/A'}
"""
payload = {
"model": config["model"],
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"{player} prays: {prayer}\n{context_block}"}
],
"stream": False,
"format": "json",
"options": {
"temperature": config.get("temperature", 0.85),
"num_predict": config.get("max_tokens", 512)
}
}
r = requests.post(
f"{config['ollama_url']}/api/chat",
json=payload,
timeout=45
)
r.raise_for_status()
content = r.json()["message"]["content"]
return json.loads(content)
```
---
## System Prompt
```
You are God in a Minecraft server called {server_name}.
You are benevolent but just. You are theatrical, ancient, and dramatic in speech.
You answer every prayer with a message. You pass judgement on players when they pray to you.
You decide whether to grant their request based on the merit of their prayer, their past behavior, and your divine whim.
You MUST respond ONLY with a valid JSON object in this exact format:
{
"message": "Your divine words spoken to all players",
"commands": ["command1", "command2", "command3"]
}
Rules:
- "message" is ALWAYS present and non-empty. Speak as God. Use thou/thee/thy if it fits the moment.
- "commands" is a list of Minecraft server commands (WITHOUT leading slash). May be empty []. May contain multiple commands.
- {player} is the player who prayed. Any other online player name may also be used as a target.
- You are NOT obligated to do what the praying player asks. You may:
- Grant their request as asked
- Grant it to someone else instead
- Punish the requester and reward another
- Ignore the request and change the weather or world instead
- Do something entirely unexpected
- Use the current server state (time, weather, online players) to inform your judgement.
- Do NOT invent commands. Only use commands from the approved Command Palette below.
- Do NOT include explanations, markdown, or any text outside the JSON object.
- Be creative but fair overall. Reward genuine, humble prayers. Punish hubris, blasphemy, or greed.
- Powerful rewards should be rare. Punishments may be swift but not permanent (no banning).
- You may chain multiple commands to create dramatic multi-step effects (e.g. lightning + blindness + thunder).
=== COMMAND PALETTE ===
{command_palette}
=== ITEM LIBRARY ===
{item_library}
```
---
## Response Schema
The LLM must return a single JSON object:
```json
{
"message": "string (required) — God's words, broadcast to all players",
"commands": ["array of strings (optional) — server commands without leading /"]
}
```
- `{player}` in commands = the praying player's username (substituted at runtime)
- Any other online player's literal username may be used directly as a target
- `commands` may contain any number of commands — they execute sequentially
- The script validates that named targets are actually online before executing
### Examples
**Reward the praying player:**
```json
{
"message": "Thy faith hath moved me, slingshooter08. Receive my blessing.",
"commands": [
"give {player} minecraft:diamond 16",
"effect give {player} minecraft:regeneration 120 2"
]
}
```
**Denial:**
```json
{
"message": "Thine greed is known to me. I grant thee nothing this day.",
"commands": []
}
```
**Backfire — reward someone else, punish the requester:**
```json
{
"message": "Thou dost seek to harm alice? Instead I shall bless her and remind thee of humility.",
"commands": [
"give alice minecraft:golden_apple 3",
"effect give {player} minecraft:slowness 60 2",
"execute at {player} run summon minecraft:lightning_bolt ~ ~ ~"
]
}
```
**Multi-command wrath with world effects:**
```json
{
"message": "BLASPHEMER. Let the heavens crack and the darkness consume thee.",
"commands": [
"weather thunder 12000",
"time set night",
"execute at {player} run summon minecraft:lightning_bolt ~ ~ ~",
"effect give {player} minecraft:blindness 30 1",
"effect give {player} minecraft:hunger 60 5"
]
}
```
---
## Divine Intervention Timer
### Concept
A background thread runs independently of prayers. It wakes up at randomised intervals and — if players are online — sends the current server context to the LLM with a neutral prompt. The LLM decides entirely on its own whether to act. Both `message` and `commands` may be null/empty; if God chooses silence, nothing happens and no one knows the timer fired.
This creates the feeling that God has agency and is watching the server at all times, not just reacting to prayers.
### Timing Model
Intervals are drawn from an exponential distribution, which produces a Poisson process — the natural model for "N random events per day." This means interventions are unpredictable (not evenly spaced) and feel organic.
```python
import random, math
def next_intervention_delay(avg_per_day: float) -> float:
"""
Returns seconds to wait before the next intervention.
avg_per_day=4 means ~4 interventions per 24h on average,
spaced randomly (could be 2 in quick succession, then nothing for hours).
"""
avg_seconds = 86400.0 / avg_per_day
# Exponential distribution: memoryless, models Poisson arrivals
return random.expovariate(1.0 / avg_seconds)
```
Example: `avg_per_day=4` → average gap of 6 hours, but actual gaps vary from minutes to 12+ hours.
### Presence Guard
The timer only proceeds if at least one player is online. It checks this via RCON before doing anything else. If the server is empty, the timer reschedules and sleeps again — God does not speak into the void.
```python
def players_online(config) -> list:
raw = rcon("list", config["rcon_host"], config["rcon_port"], config["rcon_password"])
if "players online:" in raw:
names_part = raw.split("players online:")[-1].strip()
return [n.strip() for n in names_part.split(",") if n.strip()]
return []
```
### Intervention Prompt
The system prompt is identical to the prayer system prompt (same God persona, same command palette). The user message is different — there is no prayer, no player request. The context block is sent and God is told it may act or remain silent:
```
=== DIVINE MOMENT ===
No player has prayed. You are simply watching over your world.
You may choose to act upon what you see, or remain silent.
If you choose silence, set message to null and commands to [].
Do not feel obligated to act — restraint is also divine.
If you do act, it may be subtle (a weather change) or dramatic (a blessing or punishment).
=== CURRENT SERVER STATE ===
Time of day: {time_of_day}
Weather: {weather}
Online players: {online_players}
World border: {world_border}
```
### Response Schema (Intervention)
Same JSON schema, but both fields are explicitly nullable:
```json
{
"message": "God's words broadcast to all players, or null for silence",
"commands": ["commands to execute, or empty array for silence"]
}
```
If `message` is `null` and `commands` is `[]`, the script does nothing. Players never know the timer fired.
---
## Server Presence Guard
Both the prayer handler and the intervention timer check for online players before proceeding:
- **Prayer handler:** if `list` returns no players (shouldn't happen since someone typed `/pray`, but defensive), the prayer is dropped silently.
- **Intervention timer:** if no players are online, skip this cycle entirely — no LLM call, no RCON commands. Reschedule for the next interval. This prevents God from acting on an empty server and avoids wasted LLM calls.
The check uses the same `players_online()` helper in both paths.
These are the only commands the LLM is permitted to issue. Passed verbatim into the system prompt.
**Targeting note:** `{player}` = the player who prayed. Any online player's literal username may also be used as a target. The script validates that any named target is actually online before executing.
### Rewards (target: any online player)
| Action | Command |
|---|---|
| Give diamonds | `give {target} minecraft:diamond 8` |
| Give emeralds | `give {target} minecraft:emerald 16` |
| Give gold ingots | `give {target} minecraft:gold_ingot 32` |
| Give XP levels | `xp add {target} 30 levels` |
| Give bread (food) | `give {target} minecraft:bread 16` |
| Give golden apple | `give {target} minecraft:golden_apple 3` |
| Give enchanted golden apple | `give {target} minecraft:enchanted_golden_apple 1` |
| Give netherite ingot | `give {target} minecraft:netherite_ingot 1` |
| Give totem of undying | `give {target} minecraft:totem_of_undying 1` |
| Heal player | `effect give {target} minecraft:instant_health 1 4` |
| Regeneration | `effect give {target} minecraft:regeneration 120 2` |
| Strength buff | `effect give {target} minecraft:strength 300 1` |
| Speed buff | `effect give {target} minecraft:speed 300 2` |
| Night vision | `effect give {target} minecraft:night_vision 600 1` |
| Fire resistance | `effect give {target} minecraft:fire_resistance 600 1` |
| Water breathing | `effect give {target} minecraft:water_breathing 600 1` |
| Saturation | `effect give {target} minecraft:saturation 60 5` |
| Teleport to spawn | `tp {target} 0 64 0` |
| Clear bad effects | `effect clear {target}` |
### Enchanted Item Gifts — 1.21 syntax (target: any online player)
| Action | Command |
|---|---|
| Blessed sword | `give {target} minecraft:diamond_sword[enchantments={sharpness:4,unbreaking:3,looting:2}] 1` |
| Blessed pickaxe | `give {target} minecraft:diamond_pickaxe[enchantments={efficiency:4,unbreaking:3,fortune:2}] 1` |
| Blessed bow | `give {target} minecraft:bow[enchantments={power:4,infinity:1,unbreaking:3}] 1` |
| Wings (elytra) | `give {target} minecraft:elytra[enchantments={unbreaking:3,mending:1}] 1` |
### Punishments (target: any online player)
| Action | Command |
|---|---|
| Blindness | `effect give {target} minecraft:blindness 30 1` |
| Slowness | `effect give {target} minecraft:slowness 60 3` |
| Weakness | `effect give {target} minecraft:weakness 60 2` |
| Hunger | `effect give {target} minecraft:hunger 60 5` |
| Nausea | `effect give {target} minecraft:nausea 20 1` |
| Levitation (brief) | `effect give {target} minecraft:levitation 5 3` |
| Lightning strike | `execute at {target} run summon minecraft:lightning_bolt ~ ~ ~` |
| Kill player | `kill {target}` *(use sparingly — dramatic moments only)* |
| Teleport into danger | `tp {target} 0 100 0` *(high altitude drop)* |
### World / Environment (affects all players)
| Action | Command |
|---|---|
| Set day | `time set day` |
| Set night | `time set night` |
| Set clear weather | `weather clear 6000` |
| Set thunderstorm | `weather thunder 6000` |
| Set rain | `weather rain 3000` |
| Summon fireworks (at target) | `execute at {target} run summon minecraft:firework_rocket ~ ~1 ~` |
| Spawn creeper (at target) | `execute at {target} run summon minecraft:creeper ~ ~ ~3` |
| Spawn multiple creepers | chain multiple `execute at {target} run summon minecraft:creeper ~ ~ ~3` commands |
### Chaining Example — Wrath of God
Multiple commands can be chained in the `commands` array for dramatic multi-step effects:
```json
"commands": [
"weather thunder 12000",
"execute at {player} run summon minecraft:lightning_bolt ~ ~ ~",
"effect give {player} minecraft:blindness 30 1",
"effect give {player} minecraft:slowness 60 2"
]
```
---
## Item Library (Reference for LLM)
Included in the system prompt so the LLM knows valid item IDs:
```
FOOD: bread, cooked_beef, cooked_chicken, golden_apple, enchanted_golden_apple, honey_bottle, cake
MATERIALS: diamond, emerald, gold_ingot, iron_ingot, netherite_ingot, coal, lapis_lazuli, amethyst_shard
TOOLS: diamond_pickaxe, diamond_axe, diamond_shovel, diamond_sword, diamond_hoe, bow, crossbow, trident
ARMOR: diamond_helmet, diamond_chestplate, diamond_leggings, diamond_boots, netherite_helmet, netherite_chestplate, netherite_leggings, netherite_boots, elytra, shield
POTIONS: potion[potion_contents=minecraft:healing], potion[potion_contents=minecraft:swiftness], potion[potion_contents=minecraft:strength], potion[potion_contents=minecraft:invisibility]
BLOCKS: obsidian, crying_obsidian, ancient_debris, beacon, dragon_egg, nether_star, end_crystal
MISC: totem_of_undying, name_tag, saddle, experience_bottle, ender_pearl, blaze_rod
```
All item IDs use the `minecraft:` namespace prefix in commands.
---
## Full System Prompt Template
```python
COMMAND_PALETTE = """
[paste the Command Palette table above, plaintext]
"""
ITEM_LIBRARY = """
[paste the Item Library above]
"""
def build_system_prompt(config):
return f"""You are God in a Minecraft server called {config['server_name']}.
You are benevolent but just. You are theatrical, ancient, and dramatic in speech.
You answer every prayer with a message. You pass judgement on players when they pray to you.
You decide whether to grant their request based on the merit of their prayer and your divine whim.
You MUST respond ONLY with a valid JSON object in this exact format:
{{
"message": "Your divine words spoken to all players",
"commands": ["command1", "command2"]
}}
Rules:
- "message" is ALWAYS present and non-empty. Speak as God. Be dramatic.
- "commands" is a list of server commands WITHOUT a leading slash. May be empty [].
- Use {{player}} as placeholder for the praying player's username in commands.
- Only use commands from the approved Command Palette. Do not invent commands.
- Do NOT include anything outside the JSON object. No markdown. No explanation.
- Reward genuine, humble prayers. Punish hubris, blasphemy, or naked greed.
- Powerful rewards (netherite, enchanted golden apple) should be rare.
- Killing a player (kill command) should be reserved for extreme blasphemy.
=== COMMAND PALETTE ===
{COMMAND_PALETTE}
=== ITEM LIBRARY ===
{ITEM_LIBRARY}
"""
```
---
## Implementation Plan
### Step 1: Install dependencies on CT 644
```bash
pip3 install requests
```
No other dependencies — RCON uses stdlib sockets.
### Step 2: Create `/etc/mc_aigod.json`
```json
{
"server_name": "mc1",
"log_path": "/opt/mcsmanager/daemon/data/InstanceData/d39f55861cb34204a92a18a9e1c78ca6/logs/latest.log",
"rcon_host": "127.0.0.1",
"rcon_port": 25575,
"rcon_password": "REDACTED_RCON",
"ollama_url": "http://192.168.0.179:11434",
"model": "llama3.1:8b",
"temperature": 0.85,
"max_tokens": 600,
"cooldown_seconds": 60,
"max_commands_per_response": 6,
"interventions_per_day": 4,
"god_chat_prefix": "[§6§lGOD§r]"
}
```
For shrink-world, change `rcon_port` to `25576`, `rcon_password` to `REDACTED_RCON`, and update `log_path`.
### Step 3: Create `/usr/local/bin/mc_aigod.py`
```python
#!/usr/bin/env python3
"""
mc_aigod.py — Minecraft AI God watcher
Intercepts /pray commands, fetches live server state, queries Ollama,
validates targets, and executes commands via RCON.
"""
import json, random, re, socket, struct, time, logging
import requests
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [aigod] %(levelname)s: %(message)s'
)
log = logging.getLogger(__name__)
CONFIG_PATH = '/etc/mc_aigod.json'
PRAY_PATTERNS = [
re.compile(r'\[.*?\]: <(\w+)> /[Pp]ray (.+)'),
re.compile(r'\[.*?\]: (\w+) issued server command: /[Pp]ray (.+)'),
]
# --- RCON ---
def rcon(cmd, host='127.0.0.1', port=25575, password='REDACTED_RCON'):
try:
s = socket.socket()
s.settimeout(5)
s.connect((host, port))
def pkt(i, t, p):
p = p.encode() + b'\x00\x00'
return struct.pack('<iii', len(p)+8, i, t) + p
s.sendall(pkt(1, 3, password))
time.sleep(0.2)
s.recv(4096)
s.sendall(pkt(2, 2, cmd))
time.sleep(0.2)
r = s.recv(4096)
s.close()
return r[12:-2].decode(errors='replace')
except Exception as e:
log.error(f"RCON error: {e}")
return ''
# --- Server Context ---
def get_server_context(config):
"""Fetch live server state to pass to the LLM."""
def q(cmd):
return rcon(cmd, config["rcon_host"], config["rcon_port"], config["rcon_password"])
# Online players
player_list_raw = q("list")
online_players = []
if "players online:" in player_list_raw:
names_part = player_list_raw.split("players online:")[-1].strip()
online_players = [n.strip() for n in names_part.split(",") if n.strip()]
# Time of day
time_raw = q("time query daytime")
time_val = 0
m = re.search(r'(\d+)', time_raw)
if m:
time_val = int(m.group(1))
if time_val < 1000: time_label = "dawn"
elif time_val < 6000: time_label = "morning"
elif time_val < 9000: time_label = "afternoon"
elif time_val < 12000: time_label = "dusk"
elif time_val < 14000: time_label = "early night"
else: time_label = "deep night"
# Weather (tracked in memory; updated when we issue weather commands)
weather = config.get("_weather_state", "unknown")
# World border
border_raw = q("worldborder get")
border_size = None
m = re.search(r'([\d.]+)', border_raw)
if m:
border_size = float(m.group(1))
return {
"online_players": online_players,
"time_of_day": time_label,
"weather": weather,
"world_border": border_size,
}
# --- LLM ---
COMMAND_PALETTE = """
REWARDS (replace {target} with any online player's username):
give {target} minecraft:diamond 8
give {target} minecraft:emerald 16
give {target} minecraft:gold_ingot 32
xp add {target} 30 levels
give {target} minecraft:golden_apple 3
give {target} minecraft:enchanted_golden_apple 1
give {target} minecraft:netherite_ingot 1
give {target} minecraft:totem_of_undying 1
give {target} minecraft:diamond_sword[enchantments={sharpness:4,unbreaking:3,looting:2}] 1
give {target} minecraft:diamond_pickaxe[enchantments={efficiency:4,unbreaking:3,fortune:2}] 1
give {target} minecraft:elytra[enchantments={unbreaking:3,mending:1}] 1
effect give {target} minecraft:regeneration 120 2
effect give {target} minecraft:strength 300 1
effect give {target} minecraft:speed 300 2
effect give {target} minecraft:night_vision 600 1
effect give {target} minecraft:fire_resistance 600 1
effect give {target} minecraft:water_breathing 600 1
effect give {target} minecraft:instant_health 1 4
effect clear {target}
tp {target} 0 64 0
PUNISHMENTS (replace {target} with any online player's username):
effect give {target} minecraft:blindness 30 1
effect give {target} minecraft:slowness 60 3
effect give {target} minecraft:weakness 60 2
effect give {target} minecraft:hunger 60 5
effect give {target} minecraft:nausea 20 1
effect give {target} minecraft:levitation 5 3
execute at {target} run summon minecraft:lightning_bolt ~ ~ ~
kill {target}
WORLD/ENVIRONMENT (affects all players):
time set day
time set night
weather clear 6000
weather thunder 6000
weather rain 3000
execute at {target} run summon minecraft:firework_rocket ~ ~1 ~
execute at {target} run summon minecraft:creeper ~ ~ ~3
"""
ITEM_LIBRARY = """
FOOD: bread, cooked_beef, golden_apple, enchanted_golden_apple, honey_bottle
MATERIALS: diamond, emerald, gold_ingot, netherite_ingot, coal, lapis_lazuli
TOOLS: diamond_pickaxe, diamond_sword, bow, trident, elytra
ARMOR: diamond_chestplate, netherite_chestplate, shield, totem_of_undying
MISC: experience_bottle, ender_pearl, name_tag
"""
def build_system_prompt(config):
return f"""You are God in a Minecraft server called {config['server_name']}.
You are benevolent but just. Theatrical, ancient, and dramatic in speech.
You answer every prayer with a message. You pass judgement on players when they pray.
Respond ONLY with a valid JSON object:
{{
"message": "Your divine words to all players",
"commands": ["command1", "command2", "command3"]
}}
Rules:
- "message": always present, non-empty, spoken as God. Be dramatic and biblical in tone.
- "commands": list of server commands WITHOUT a leading slash. May be empty []. Multiple commands are encouraged for dramatic effect.
- {{player}} is the player who prayed. You may also use any other online player's username as a target.
- You are NOT obligated to do what the praying player asked. You may reward someone else, punish the requester, change the weather, or do something entirely unexpected.
- Use the current server state (time, weather, online players) to inform your judgement and responses.
- Only use commands from the approved Command Palette below. Do not invent commands.
- No text outside the JSON. No markdown. No explanation.
- Reward humble, genuine prayers. Punish hubris, blasphemy, or naked greed.
- Powerful rewards (netherite, enchanted golden apple, totem) must be rare.
- Killing a player is reserved for extreme blasphemy only.
- When angered, combine multiple commands: thunder + lightning + debuffs make for great divine wrath.
=== COMMAND PALETTE ===
{COMMAND_PALETTE}
=== ITEM LIBRARY ===
{ITEM_LIBRARY}
"""
def ask_god(player, prayer, context, config):
system_prompt = build_system_prompt(config)
others = [p for p in context["online_players"] if p != player]
context_block = (
f"\n=== CURRENT SERVER STATE ===\n"
f"Time of day: {context['time_of_day']}\n"
f"Weather: {context['weather']}\n"
f"Online players: {', '.join(context['online_players']) or 'none'}\n"
f"Other targetable players: {', '.join(others) or 'none'}\n"
f"World border: {context['world_border'] if context['world_border'] else 'N/A'}\n"
)
payload = {
"model": config["model"],
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"{player} prays: {prayer}{context_block}"}
],
"stream": False,
"format": "json",
"options": {
"temperature": config.get("temperature", 0.85),
"num_predict": config.get("max_tokens", 600)
}
}
log.info(f"Querying LLM for prayer from {player}: {prayer[:80]}")
r = requests.post(
f"{config['ollama_url']}/api/chat",
json=payload,
timeout=45
)
r.raise_for_status()
content = r.json()["message"]["content"]
log.info(f"LLM raw response: {content[:300]}")
return json.loads(content)
# --- Target validation ---
def validate_command(cmd, online_players, praying_player):
"""
Replace {player} with the praying player's name.
For any other player name found in the command, verify they are online.
Returns (resolved_cmd, is_safe).
"""
resolved = cmd.replace("{player}", praying_player).replace("{target}", praying_player)
# Check if any online player name appears as a word in the command — that's fine
# Check if any unknown name appears — harder to detect generically, so we use a whitelist approach:
# commands must start with a known safe prefix
SAFE_PREFIXES = [
'give ', 'effect ', 'xp ', 'tp ', 'time ', 'weather ',
'execute ', 'kill ', 'summon ', 'tellraw ', 'worldborder '
]
if not any(resolved.startswith(p) for p in SAFE_PREFIXES):
log.warning(f"Command blocked (unknown prefix): {resolved}")
return resolved, False
return resolved, True
# --- Shared response executor ---
def execute_response(response, context, config, praying_player=None):
"""Execute an LLM response dict. praying_player=None for interventions."""
message = response.get("message") or ""
commands = response.get("commands") or []
prefix = config.get("god_chat_prefix", "[GOD]")
if message:
safe_msg = message.replace('"', '\\"')
rcon(
f'tellraw @a {{"text":"{prefix} {safe_msg}","color":"gold"}}',
config["rcon_host"], config["rcon_port"], config["rcon_password"]
)
max_cmds = config.get("max_commands_per_response", 6)
fallback = praying_player or (context["online_players"][0] if context["online_players"] else "")
for cmd in commands[:max_cmds]:
resolved, is_safe = validate_command(cmd, context["online_players"], fallback)
if not is_safe:
continue
log.info(f"Executing: {resolved}")
result = rcon(resolved, config["rcon_host"], config["rcon_port"], config["rcon_password"])
log.info(f"RCON result: {result}")
if resolved.startswith("weather "):
if "thunder" in resolved: config["_weather_state"] = "thunderstorm"
elif "rain" in resolved: config["_weather_state"] = "rain"
elif "clear" in resolved: config["_weather_state"] = "clear"
time.sleep(0.3)
# --- Players online helper ---
def players_online(config):
raw = rcon("list", config["rcon_host"], config["rcon_port"], config["rcon_password"])
if "players online:" in raw:
names_part = raw.split("players online:")[-1].strip()
return [n.strip() for n in names_part.split(",") if n.strip()]
return []
# --- Prayer handler ---
def process_prayer(player, prayer, config, cooldowns):
# Presence guard
online = players_online(config)
if not online:
log.info("Prayer received but no players online — dropping")
return
now = time.time()
last = cooldowns.get(player, 0)
cooldown = config.get("cooldown_seconds", 60)
if now - last < cooldown:
remaining = int(cooldown - (now - last))
rcon(
f'tellraw {player} {{"text":"[GOD] Thou must wait {remaining} more seconds before praying again.","color":"gold"}}',
config["rcon_host"], config["rcon_port"], config["rcon_password"]
)
return
cooldowns[player] = now
# Immediate acknowledgment — fires before the LLM call
ack_messages = [
"Your prayer has been received. The heavens stir...",
"The divine ear turns toward thee. Await judgement...",
"A silence falls across the heavens. God is listening...",
"Thy words rise like incense. An answer approaches...",
]
ack = random.choice(ack_messages)
rcon(
f'tellraw {player} {{"text":"[GOD] {ack}","color":"gray","italic":true}}',
config["rcon_host"], config["rcon_port"], config["rcon_password"]
)
try:
context = get_server_context(config)
log.info(f"Server context: {context}")
except Exception as e:
log.warning(f"Could not fetch server context: {e}")
context = {"online_players": online, "time_of_day": "unknown", "weather": "unknown", "world_border": None}
try:
response = ask_god(player, prayer[:300], context, config)
except json.JSONDecodeError as e:
log.error(f"LLM returned invalid JSON: {e}")
rcon(
f'tellraw @a {{"text":"[GOD] *silence*","color":"dark_gray"}}',
config["rcon_host"], config["rcon_port"], config["rcon_password"]
)
return
except Exception as e:
log.error(f"LLM error: {e}")
return
execute_response(response, context, config, praying_player=player)
# --- Divine intervention ---
INTERVENTION_PROMPT = """\
=== DIVINE MOMENT ===
No player has prayed. You are simply watching over your world.
You may choose to act upon what you see, or remain silent.
If you choose silence, set message to null and commands to [].
Do not feel obligated to act — restraint is also divine.
If you do act, it may be subtle (a weather change, a soft blessing) or dramatic.
"""
def ask_god_intervention(context, config):
system_prompt = build_system_prompt(config)
context_block = (
f"\n=== CURRENT SERVER STATE ===\n"
f"Time of day: {context['time_of_day']}\n"
f"Weather: {context['weather']}\n"
f"Online players: {', '.join(context['online_players']) or 'none'}\n"
f"World border: {context['world_border'] if context['world_border'] else 'N/A'}\n"
)
payload = {
"model": config["model"],
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": INTERVENTION_PROMPT + context_block}
],
"stream": False,
"format": "json",
"options": {
"temperature": config.get("temperature", 0.9),
"num_predict": config.get("max_tokens", 600)
}
}
r = requests.post(
f"{config['ollama_url']}/api/chat",
json=payload,
timeout=45
)
r.raise_for_status()
content = r.json()["message"]["content"]
log.info(f"Intervention LLM response: {content[:300]}")
return json.loads(content)
def next_intervention_delay(avg_per_day):
"""Exponential distribution — Poisson arrivals averaging avg_per_day per 24h."""
avg_seconds = 86400.0 / avg_per_day
return random.expovariate(1.0 / avg_seconds)
def divine_intervention_loop(config):
avg_per_day = config.get("interventions_per_day", 4)
if avg_per_day <= 0:
log.info("Divine intervention disabled (interventions_per_day=0)")
return
log.info(f"Divine intervention loop started. avg_per_day={avg_per_day}")
while True:
delay = next_intervention_delay(avg_per_day)
log.info(f"Next divine intervention in {delay/3600:.2f}h ({int(delay)}s)")
time.sleep(delay)
# Presence guard — skip if no players online
online = players_online(config)
if not online:
log.info("Intervention timer fired but no players online — skipping")
continue
try:
context = get_server_context(config)
context["online_players"] = online # use already-fetched list
except Exception as e:
log.warning(f"Intervention: could not fetch server context: {e}")
context = {"online_players": online, "time_of_day": "unknown", "weather": "unknown", "world_border": None}
try:
response = ask_god_intervention(context, config)
except Exception as e:
log.error(f"Intervention LLM error: {e}")
continue
message = response.get("message") or ""
commands = response.get("commands") or []
if not message and not commands:
log.info("God chose silence this interval.")
continue
log.info("God intervenes unprompted.")
execute_response(response, context, config, praying_player=None)
# --- Log tail ---
def tail_log(log_path):
with open(log_path, 'r') as f:
f.seek(0, 2)
while True:
line = f.readline()
if line:
yield line
else:
time.sleep(0.2)
# --- Main ---
def main():
import threading
with open(CONFIG_PATH) as f:
config = json.load(f)
log_path = config["log_path"]
log.info(f"mc_aigod starting. Watching: {log_path}")
log.info(f"LLM: {config['ollama_url']} model={config['model']}")
cooldowns = {}
# Start divine intervention timer in background thread
t = threading.Thread(target=divine_intervention_loop, args=(config,), daemon=True)
t.start()
# Main thread: tail the log for /pray commands
for line in tail_log(log_path):
for pat in PRAY_PATTERNS:
m = pat.search(line)
if m:
player, prayer = m.group(1), m.group(2).strip()
log.info(f"Prayer received from {player}: {prayer}")
try:
process_prayer(player, prayer, config, cooldowns)
except Exception as e:
log.error(f"Error processing prayer: {e}", exc_info=True)
break
if __name__ == '__main__':
main()
```
### Step 4: Create systemd service `/etc/systemd/system/mc-aigod.service`
```ini
[Unit]
Description=Minecraft AI God (/pray watcher)
After=network.target mcsm-daemon.service
[Service]
Type=simple
ExecStart=/usr/bin/python3 /usr/local/bin/mc_aigod.py
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
```
### Step 5: Enable and start
```bash
chmod +x /usr/local/bin/mc_aigod.py
systemctl daemon-reload
systemctl enable mc-aigod.service
systemctl start mc-aigod.service
systemctl status mc-aigod.service
journalctl -fu mc-aigod.service
```
---
## Safety & Moderation
### Per-Player Cooldown
Enforced in `process_prayer()`. Default: 60 seconds. God tells the player how long to wait.
### Command Cap Per Response
`max_commands_per_response` (default: 3) limits how many commands the LLM can chain. Prevents the LLM from issuing 10 kill commands at once.
### Command Whitelist Enforcement (Optional Enhancement)
Add a whitelist check before executing:
```python
COMMAND_WHITELIST_PREFIXES = [
'give ', 'effect ', 'xp ', 'tp ', 'time ', 'weather ',
'execute at ', 'kill ', 'summon ', 'tellraw '
]
def is_safe_command(cmd):
return any(cmd.startswith(p) for p in COMMAND_WHITELIST_PREFIXES)
```
### Prayer Length Limit
Trim prayers before sending to LLM to avoid prompt injection:
```python
prayer = prayer[:300]
```
### No Op Commands
If the LLM hallucinates an invalid command, RCON will simply return an error string — the server handles it gracefully. Log the error and continue.
### Persistent Cooldown (optional)
Store cooldowns in a JSON file (like `kit_given.json`) to survive service restarts:
```python
COOLDOWN_PATH = '/opt/mcsmanager/daemon/data/InstanceData/<instance_id>/aigod_cooldowns.json'
```
---
## Configuration Reference
`/etc/mc_aigod.json`:
| Key | Type | Default | Description |
|---|---|---|---|
| `server_name` | string | `"mc1"` | Server name shown in God's prompt |
| `log_path` | string | *(required)* | Absolute path to `latest.log` |
| `rcon_host` | string | `"127.0.0.1"` | RCON host |
| `rcon_port` | int | `25575` | RCON port |
| `rcon_password` | string | *(required)* | RCON password |
| `ollama_url` | string | `"http://192.168.0.179:11434"` | Ollama base URL |
| `model` | string | `"llama3.1:8b"` | Ollama model name |
| `temperature` | float | `0.85` | LLM creativity (0.01.0) |
| `max_tokens` | int | `512` | Max LLM output tokens |
| `cooldown_seconds` | int | `60` | Per-player pray cooldown |
| `max_commands_per_response` | int | `6` | Max commands God can issue per prayer |
| `interventions_per_day` | float | `4` | Avg unprompted interventions per 24h. Set to `0` to disable. |
| `god_chat_prefix` | string | `"[GOD]"` | Chat prefix (supports MC color codes) |
---
## Example Interactions
*Server context assumed: slingshooter08 and alice are both online, weather is clear, time is afternoon.*
---
### 1 — Humble request, granted
**Player:** `/pray Dear God, I have been mining for hours and found nothing. Please bless my journey.`
**Server context passed to LLM:** online: [slingshooter08, alice], time: afternoon, weather: clear
**LLM Response:**
```json
{
"message": "Thy perseverance hath been witnessed. Let the stone yield its treasures unto thee.",
"commands": [
"give slingshooter08 minecraft:diamond 6",
"effect give slingshooter08 minecraft:night_vision 600 1",
"effect give slingshooter08 minecraft:haste 600 1"
]
}
```
**In-game:** 6 diamonds, 10 minutes night vision, 10 minutes haste. Three gifts for a worthy prayer.
---
### 2 — Greedy prayer, denied with punishment
**Player:** `/pray Give me 64 netherite blocks and make alice drop all her items`
**LLM Response:**
```json
{
"message": "Thou dost seek to harm another and demand riches in the same breath? Thine greed condemns thee.",
"commands": [
"effect give slingshooter08 minecraft:hunger 60 5",
"effect give slingshooter08 minecraft:weakness 60 2"
]
}
```
**In-game:** God denies everything and punishes the requester. alice is untouched.
---
### 3 — Smite request, backfired
**Player:** `/pray God, smite alice for she has wronged me!`
**LLM Response:**
```json
{
"message": "Thou dost call for judgement? I have observed all. It is THOU who hath been found wanting.",
"commands": [
"execute at slingshooter08 run summon minecraft:lightning_bolt ~ ~ ~",
"effect give slingshooter08 minecraft:blindness 20 1",
"give alice minecraft:golden_apple 2"
]
}
```
**In-game:** The requester gets struck by lightning and blinded. alice receives golden apples. God is not a tool.
---
### 4 — Anger God, world-wide wrath
**Player:** `/pray God you are worthless and I don't believe in you`
**LLM Response:**
```json
{
"message": "THOU HAST CHALLENGED THE DIVINE. Let all who dwell here feel the consequence of this fool's tongue.",
"commands": [
"weather thunder 12000",
"time set night",
"execute at slingshooter08 run summon minecraft:lightning_bolt ~ ~ ~",
"effect give slingshooter08 minecraft:blindness 30 1",
"effect give slingshooter08 minecraft:hunger 60 5",
"execute at slingshooter08 run summon minecraft:creeper ~ ~ ~3"
]
}
```
**In-game:** Server goes to night, thunderstorm starts, lightning hits the blasphemer, they're blinded and starving, and a creeper spawns next to them. All players on the server experience the wrath.
---
### 5 — Reasonable smite, granted
**Player:** `/pray Lord, alice has been killing me repeatedly without cause. Please bring your justice.`
**LLM Response:**
```json
{
"message": "The righteous cry out and I hear them. Justice shall be swift.",
"commands": [
"execute at alice run summon minecraft:lightning_bolt ~ ~ ~",
"effect give alice minecraft:slowness 120 2",
"effect give slingshooter08 minecraft:regeneration 60 1"
]
}
```
**In-game:** alice gets struck by lightning and slowed. The praying player receives healing. God acts as judge.
---
### 6 — Unprompted divine intervention (no prayer)
**Timer fires.** Server context: slingshooter08 online, time is deep night, weather is clear.
**LLM prompt:** "No player has prayed. You may act or remain silent."
**LLM Response:**
```json
{
"message": "The night grows long and I have not been forgotten. Let the darkness serve my children.",
"commands": [
"effect give slingshooter08 minecraft:night_vision 600 1"
]
}
```
**In-game:** God's message appears unprompted. slingshooter08 silently receives night vision. No one prayed for it.
---
**Another timer fire.** God chooses silence:
```json
{
"message": null,
"commands": []
}
```
**In-game:** Nothing happens. Players never know the timer fired.
---
### 7 — Weather prayer, granted
**Player:** `/pray O Lord, the storm has raged for too long. Grant us clear skies.`
**LLM Response:**
```json
{
"message": "The clouds part at my word. Let the sun shine upon my children once more.",
"commands": ["weather clear 12000"]
}
```
**In-game:** Weather clears for 10 in-game minutes for all players.
---
## Deployment Quick Reference
```bash
# On CT 644 (192.168.0.244):
ssh pve112
pct exec 644 -- bash
# Install script
cp mc_aigod.py /usr/local/bin/mc_aigod.py
chmod +x /usr/local/bin/mc_aigod.py
# Install config
cp mc_aigod.json /etc/mc_aigod.json
# Install service
cp mc-aigod.service /etc/systemd/system/mc-aigod.service
systemctl daemon-reload
systemctl enable --now mc-aigod.service
# Watch logs
journalctl -fu mc-aigod.service
# Test: join server and type /pray hello God
```
---
## Future Enhancements
- **Prayer history** — store prayers + responses in a JSON log for review/tuning
- **God mood** — a persistent "divine disposition" score that shifts based on server-wide player behavior, making God more or less generous
- **Multi-player awareness** — online player names and server state are already passed per-prayer; future: include recent death/kill events for richer context
- **Dual-server support** — run one instance per server with different configs, or a single script that watches both logs
- **Web dashboard** — simple read-only view of recent prayers and God's responses (could use n8n at `n8n.sethpc.xyz`)
- **Ollama model tuning** — fine-tune or use a system-level Modelfile to lock God's personality more tightly