Files
minecraft-ai-god/Minecraft_Ai_God.md
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

47 KiB
Raw Permalink Blame History

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:

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

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:

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)

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:

{
  "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:

{
  "message": "Thy faith hath moved me, slingshooter08. Receive my blessing.",
  "commands": [
    "give {player} minecraft:diamond 16",
    "effect give {player} minecraft:regeneration 120 2"
  ]
}

Denial:

{
  "message": "Thine greed is known to me. I grant thee nothing this day.",
  "commands": []
}

Backfire — reward someone else, punish the requester:

{
  "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:

{
  "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.

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.

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:

{
  "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:

"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

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

pip3 install requests

No other dependencies — RCON uses stdlib sockets.

Step 2: Create /etc/mc_aigod.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

#!/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

[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

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:

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:

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:

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:

{
  "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:

{
  "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:

{
  "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:

{
  "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:

{
  "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:

{
  "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:

{
  "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:

{
  "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

# 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