From 8ee8be9cc05be87a6342b81f212f4fbe4e6b2ed2 Mon Sep 17 00:00:00 2001 From: Seth Date: Sun, 15 Mar 2026 19:02:16 -0400 Subject: [PATCH] =?UTF-8?q?Initial=20commit=20=E2=80=94=20Minecraft=20AI?= =?UTF-8?q?=20God=20plugin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 4 + COMMANDS.md | 104 ++ CONTEXT.md | 115 ++ Minecraft_Ai_God.md | 1307 +++++++++++++++++ README.md | 314 ++++ .../biome_modifier/more_creepers.json | 12 + datapacks/morespawns/pack.mcmeta | 6 + .../data/minecraft/tags/function/load.json | 1 + .../data/minecraft/tags/function/tick.json | 1 + .../function/check_deaths.mcfunction | 5 + .../shrinkborder/function/disable.mcfunction | 2 + .../shrinkborder/function/enable.mcfunction | 5 + .../shrinkborder/function/load.mcfunction | 18 + .../shrinkborder/function/on_death.mcfunction | 12 + .../shrinkborder/function/reset.mcfunction | 5 + .../function/shrink_ew.mcfunction | 2 + .../function/shrink_ns.mcfunction | 2 + .../shrinkborder/function/tick.mcfunction | 6 + datapacks/shrinkborder/pack.mcmeta | 6 + mc-aigod.service | 14 + mc_aigod.py | 1078 ++++++++++++++ mc_aigod_shrink.json | 18 + scripts/mc-godmode.service | 12 + scripts/mc-shrink-kit.service | 12 + scripts/mc_godmode_rcon.py | 28 + scripts/mc_godmode_watch.sh | 14 + scripts/shrink_godkit.py | 161 ++ 27 files changed, 3264 insertions(+) create mode 100644 .gitignore create mode 100644 COMMANDS.md create mode 100644 CONTEXT.md create mode 100644 Minecraft_Ai_God.md create mode 100644 README.md create mode 100644 datapacks/morespawns/data/minecraft/worldgen/biome_modifier/more_creepers.json create mode 100644 datapacks/morespawns/pack.mcmeta create mode 100644 datapacks/shrinkborder/data/minecraft/tags/function/load.json create mode 100644 datapacks/shrinkborder/data/minecraft/tags/function/tick.json create mode 100644 datapacks/shrinkborder/data/shrinkborder/function/check_deaths.mcfunction create mode 100644 datapacks/shrinkborder/data/shrinkborder/function/disable.mcfunction create mode 100644 datapacks/shrinkborder/data/shrinkborder/function/enable.mcfunction create mode 100644 datapacks/shrinkborder/data/shrinkborder/function/load.mcfunction create mode 100644 datapacks/shrinkborder/data/shrinkborder/function/on_death.mcfunction create mode 100644 datapacks/shrinkborder/data/shrinkborder/function/reset.mcfunction create mode 100644 datapacks/shrinkborder/data/shrinkborder/function/shrink_ew.mcfunction create mode 100644 datapacks/shrinkborder/data/shrinkborder/function/shrink_ns.mcfunction create mode 100644 datapacks/shrinkborder/data/shrinkborder/function/tick.mcfunction create mode 100644 datapacks/shrinkborder/pack.mcmeta create mode 100644 mc-aigod.service create mode 100644 mc_aigod.py create mode 100644 mc_aigod_shrink.json create mode 100644 scripts/mc-godmode.service create mode 100644 scripts/mc-shrink-kit.service create mode 100644 scripts/mc_godmode_rcon.py create mode 100644 scripts/mc_godmode_watch.sh create mode 100644 scripts/shrink_godkit.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f8dfb4d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.pyc +__pycache__/ +GITEA_API.md +aigod_memory.json diff --git a/COMMANDS.md b/COMMANDS.md new file mode 100644 index 0000000..f8ac626 --- /dev/null +++ b/COMMANDS.md @@ -0,0 +1,104 @@ +# Minecraft Quick Command Reference + +## RCON — Send a command from CT 644 + +### Server 1 (mc1, port 25575) +```bash +ssh pve112 "pct exec 644 -- python3 -c \" +import socket, struct, time +def rcon(cmd): + s = socket.socket(); s.settimeout(5); s.connect(('127.0.0.1', 25575)) + def pkt(i,t,p): p=p.encode()+b'\x00\x00'; return struct.pack(' ` | +| Give item | `give ` | +| Set gamemode | `gamemode creative/survival/spectator ` | +| Get player position | `data get entity Pos` | +| List online players | `list` | +| Get world border size | `worldborder get` | +| Set world border | `worldborder set ` | +| Shrink world border | `worldborder add - ` | + +## God Kit (1.21 syntax) +``` +give netherite_helmet[enchantments={protection:4,unbreaking:3,mending:1,respiration:3,aqua_affinity:1}] +give netherite_chestplate[enchantments={protection:4,unbreaking:3,mending:1}] +give netherite_leggings[enchantments={protection:4,unbreaking:3,mending:1}] +give netherite_boots[enchantments={protection:4,unbreaking:3,mending:1,feather_falling:4,depth_strider:3}] +give netherite_sword[enchantments={sharpness:5,unbreaking:3,mending:1,looting:3,fire_aspect:2,sweeping_edge:3}] +give bow[enchantments={power:5,unbreaking:3,infinity:1,flame:1,punch:2}] +give netherite_pickaxe[enchantments={efficiency:5,unbreaking:3,mending:1,fortune:3}] +give netherite_axe[enchantments={efficiency:5,unbreaking:3,mending:1,sharpness:5}] +give arrow 64 +give golden_apple 64 +give totem_of_undying 4 +give ender_pearl 16 +give cooked_beef 64 +``` + +--- + +## Shrinkborder Datapack (in-game, op required) + +| Command | Effect | +|---|---| +| `/function shrinkborder:enable` | Start death-shrinking | +| `/function shrinkborder:disable` | Stop death-shrinking | +| `/function shrinkborder:reset` | Reset border to 1000x1000, disable shrinking | + +--- + +## Services on CT 644 + +```bash +# Access CT +ssh pve112 "pct exec 644 -- bash" + +# God mode watcher (mc1 - slingshooter08 auto creative) +systemctl start/stop/status mc-godmode.service + +# Shrink world kit watcher (shrink-world - auto kit on first join + stats) +systemctl start/stop/status mc-shrink-kit.service + +# MCSManager itself +systemctl start/stop/status mcsm-web.service +systemctl start/stop/status mcsm-daemon.service +``` + +--- + +## Datapacks — Deploy to shrink-world server + +```bash +# From this machine (CT 629), copy a datapack folder to the server +DEST=/opt/mcsmanager/daemon/data/InstanceData/shrinkborder1234567890abcdef12345/world/datapacks/ + +# Then reload in-game or via RCON: +# datapack enable "file/" +# reload +``` + +--- + +## Kit Record — Reset a player's first-join kit + +```bash +ssh pve112 "pct exec 644 -- cat /opt/mcsmanager/daemon/data/InstanceData/shrinkborder1234567890abcdef12345/kit_given.json" + +# To reset a specific player (edit the JSON): +ssh pve112 "pct exec 644 -- nano /opt/mcsmanager/daemon/data/InstanceData/shrinkborder1234567890abcdef12345/kit_given.json" +``` diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..e2a93c5 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,115 @@ +# Sethpc Minecraft Project Context + +## Infrastructure + +### MCSManager Panel +- **CT:** 644 on node-112 (sethpc, 192.168.0.112) +- **IP:** 192.168.0.244 +- **Web panel:** http://mc.sethpc.xyz (via Caddy on CT 600, node-241) +- **Panel ports:** 23333 (web), 24444 (daemon) +- **OS:** Debian 12, Java 21 (Temurin) +- **Installed via:** https://script.mcsmanager.com/setup.sh +- **Systemd services:** `mcsm-web.service`, `mcsm-daemon.service` + +--- + +## Server 1 — mc1 (Vanilla survival) + +| Property | Value | +|---|---| +| Instance ID | `d39f55861cb34204a92a18a9e1c78ca6` | +| Game port | `25565` | +| RCON port | `25575` | +| RCON password | `REDACTED_RCON` | +| Data dir | `/opt/mcsmanager/daemon/data/InstanceData/d39f55861cb34204a92a18a9e1c78ca6/` | +| Version | Minecraft 1.21.x (vanilla) | +| Connect | `192.168.0.244:25565` | + +### Services on CT 644 +- `mc-godmode.service` — watches `latest.log`, sets `slingshooter08` to creative on every login + - Script: `/usr/local/bin/mc_godmode_watch.sh` + - Calls: `/usr/local/bin/mc_godmode_rcon.py` + - Toggle: `systemctl start/stop mc-godmode.service` + +--- + +## Server 2 — shrink-world + +| Property | Value | +|---|---| +| Instance ID | `shrinkborder1234567890abcdef12345` | +| Game port | `25566` | +| RCON port | `25576` | +| RCON password | `REDACTED_RCON` | +| Data dir | `/opt/mcsmanager/daemon/data/InstanceData/shrinkborder1234567890abcdef12345/` | +| Version | Minecraft 1.21.x (vanilla, same server.jar as mc1) | +| Connect | `192.168.0.244:25566` | +| World border | 500x500 centered at 0,0 (started at 1000x1000) | +| Difficulty | Hard | + +### Datapacks installed +1. **shrinkborder** — detects player deaths, shrinks world border by 1 block per death, alternating N/S and E/W sides. Starts DISABLED. +2. **morespawns** — increases creeper spawn weight from 100 to 500 (5x more creepers) + +### Services on CT 644 +- `mc-shrink-kit.service` — main watcher script for shrink-world + - Script: `/usr/local/bin/shrink_godkit.py` + - On first login: gives full god kit (netherite armor + tools + consumables) + - On every login: broadcasts world stats + - On every death: broadcasts world stats + increments death counter + - Every hour: broadcasts world stats + - Kit record persisted at: `/kit_given.json` + +--- + +## RCON Helper (Python) + +All server communication uses raw RCON sockets (no external library needed): + +```python +import socket, struct, time + +def rcon(cmd, host='127.0.0.1', port=25575, password='REDACTED_RCON'): + s = socket.socket() + s.settimeout(5) + s.connect((host, port)) + def pkt(i, t, p): + p = p.encode() + b'\x00\x00' + return struct.pack(' │ │ 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 ` and `bible` as **plain chat messages** (no slash). These always reach the server and are logged as: + +``` +[12:34:56] [Server thread/INFO]: pray O Lord, grant me diamonds +[12:34:56] [Server thread/INFO]: 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('` in chat. God responds with dramatic prose and optionally executes server commands via RCON. + +No mods, no plugins, no server restarts required. Works with any vanilla Minecraft 1.21+ server. + +--- + +## How It Works + +``` +Player types: pray + │ + ▼ +latest.log ←─ tailed by mc_aigod.py + │ + ▼ +RCON: immediate acknowledgment to player ("The heavens stir...") + │ + ▼ +RCON: fetch live server context + - Online players, time of day, weather, world border + │ + ▼ +RCON: fetch praying player's state + - Full inventory (with rarity annotations), position, health, food, XP, deaths + │ + ▼ +Call 1 — command_model (qwen3-coder:30b or similar) + - Decides what server commands to execute (JSON only, no prose) + - Low temperature (0.3) for precise structured output + │ + ▼ +Call 2 — model (gemma3:12b or similar) + - Writes God's spoken message knowing what was decided + - No token competition with commands — full creative freedom + │ + ▼ +RCON: execute commands + broadcast message +``` + +**Divine Intervention Timer** — a background thread fires at random intervals (Poisson process, user-defined average per day). If players are online, God acts unprompted. LLM can choose silence (`commands: []`) and nothing happens. + +**Memory** — last 10 prayer exchanges stored as conversation history and passed to every LLM call. Persists across service restarts via JSON file. God remembers. + +**Server log context** — last 20 minutes of meaningful server events (chat, deaths, joins, leaves) included with every prayer. God knows what's been happening. + +--- + +## Requirements + +- Python 3.11+ +- `requests` library (`apt install python3-requests`) +- Ollama instance with at least one model pulled +- Minecraft vanilla server with RCON enabled +- Server running on Linux (systemd for service management) + +### Minecraft server.properties requirements + +```properties +enable-rcon=true +rcon.port=25575 +rcon.password=yourpassword +broadcast-rcon-to-ops=false +``` + +--- + +## File Structure + +``` +mc_aigod.py # Main script — deploy to /usr/local/bin/ +mc_aigod_shrink.json # Example config — deploy to /etc/mc_aigod.json +mc-aigod.service # Systemd unit — deploy to /etc/systemd/system/ +Minecraft_Ai_God.md # Full design document with architecture details +``` + +--- + +## Configuration + +`/etc/mc_aigod.json`: + +| Key | Type | Default | Description | +|---|---|---|---| +| `server_name` | string | required | Server name passed to God's persona | +| `log_path` | string | required | Absolute path to `logs/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 | required | Ollama base URL e.g. `http://192.168.0.1:11434` | +| `model` | string | required | Message model — creative writing (e.g. `gemma3:12b`) | +| `command_model` | string | falls back to `model` | Commands model — structured JSON (e.g. `qwen3-coder:30b`) | +| `temperature` | float | `0.85` | Message model temperature | +| `max_tokens` | int | `600` | Max tokens for message call | +| `cooldown_seconds` | int | `20` | Per-player prayer cooldown | +| `max_commands_per_response` | int | `6` | Max commands God can issue per prayer | +| `interventions_per_day` | float | `4` | Avg unprompted interventions per 24h. `0` to disable | +| `debug_commands` | bool | `false` | Show executed commands in-game via dark gray tellraw | +| `memory_path` | string | see below | Path to persist prayer memory JSON | +| `god_chat_prefix` | string | `"[GOD]"` | Chat prefix (supports Minecraft color codes) | + +Default memory path: `/aigod_memory.json` + +### Example config + +```json +{ + "server_name": "my-server", + "log_path": "/path/to/minecraft/logs/latest.log", + "rcon_host": "127.0.0.1", + "rcon_port": 25575, + "rcon_password": "yourpassword", + "ollama_url": "http://localhost:11434", + "model": "gemma3:12b", + "command_model": "qwen3-coder:30b", + "temperature": 0.85, + "max_tokens": 600, + "cooldown_seconds": 20, + "max_commands_per_response": 6, + "interventions_per_day": 4, + "debug_commands": false, + "memory_path": "/path/to/minecraft/aigod_memory.json", + "god_chat_prefix": "[§6§lGOD§r]" +} +``` + +--- + +## Player Usage + +Type in chat (no slash — vanilla 1.21 rejects unknown slash commands client-side): + +``` +pray — send a prayer to God +bible — show help/guidance +``` + +On login players see: +``` +[GOD] GOD ENABLED — Type "bible" in chat for guidance. Type "pray " to pray. +``` + +--- + +## God's Capabilities + +### Commands God can issue + +**Give any item:** +``` +give minecraft: +give minecraft:[enchantments={sharpness:4,unbreaking:3}] 1 +xp add levels +``` + +**Effects (positive/negative):** regeneration, strength, speed, night_vision, fire_resistance, water_breathing, instant_health, blindness, slowness, weakness, hunger, nausea, levitation, effect clear + +**Movement:** `tp ` + +**World:** `time set day/night`, `weather clear/thunder/rain ` + +**Punishment:** `execute at run summon minecraft:lightning_bolt ~ ~ ~`, `kill ` + +**Mobs:** `execute at run summon minecraft:creeper ~ ~ ~3` + +### Item naming rules (Minecraft 1.21) + +- Always use `minecraft:` namespace prefix +- Beds: `white_bed`, `red_bed` etc — there is no `minecraft:bed` +- Logs: `oak_log`, `spruce_log` etc — there is no `minecraft:log` +- Wool: `white_wool`, `red_wool` etc — there is no `minecraft:wool` +- Enchantments use component syntax: `item[enchantments={sharpness:5,unbreaking:3}]` +- Give syntax: `give minecraft: ` — count is LAST + +### God's persona + +- Benevolent but just, theatrical and dramatic (Old Testament style) +- Aware of player inventory, health, food, position, deaths +- Aware of server state: time, weather, world border, online players +- Aware of recent server events: deaths, chat, joins, leaves (last 20 min) +- Remembers last 10 prayer exchanges across all players +- Acts on own accord via intervention timer — may choose silence +- Not obligated to grant requests — may reward someone else, punish the requester, or do something unexpected + +--- + +## Model Recommendations + +**Command model** — needs reliable structured JSON output and Minecraft syntax knowledge: +- `qwen3-coder:30b` (recommended, ~19GB Q4) +- `qwen2.5:1.5b` (fast/small, acceptable for commands) + +**Message model** — needs creative writing, roleplay, dramatic biblical prose: +- `gemma3:12b` (recommended, ~8GB) +- `llama3.1:8b` (good alternative) + +Avoid coding models for the message role. Avoid general models for the command role. + +Both calls go to the same Ollama instance. If both models fit in VRAM simultaneously there is no swap overhead. If not, Ollama swaps them — adds a few seconds per prayer. + +--- + +## Deployment + +```bash +# Install dependencies +apt install python3-requests + +# Deploy files +cp mc_aigod.py /usr/local/bin/mc_aigod.py +chmod +x /usr/local/bin/mc_aigod.py +cp mc_aigod_shrink.json /etc/mc_aigod.json # edit as needed +cp mc-aigod.service /etc/systemd/system/mc-aigod.service + +# Enable and start +systemctl daemon-reload +systemctl enable --now mc-aigod.service + +# Monitor +journalctl -fu mc-aigod.service +tail -f /var/log/mc_aigod.log +tail -f /var/log/mc_aigod_responses.log # full untruncated LLM responses +``` + +--- + +## Debugging + +**`debug_commands: true` in config** — shows executed commands in-game as dark gray italic text: +``` +[~] give slingshooter08 minecraft:spruce_log 64 | weather thunder 6000 +``` +Never appears in `latest.log`. Toggle off by setting `false` and restarting. + +**Log files:** +- `/var/log/mc_aigod.log` — startup, prayers received, RCON results, errors +- `/var/log/mc_aigod_responses.log` — full untruncated LLM responses with commands and messages + +**Common issues:** + +| Symptom | Cause | Fix | +|---|---|---| +| `Unknown item 'minecraft:64'` | LLM put count before item | Auto-fixed by `fix_give_command()`, also update command model | +| `Unknown item 'minecraft:bed'` | Missing colour prefix | Item library includes warning; auto-namespaced | +| Message truncated | Token limit hit | Increase `max_tokens`; two-call split helps | +| `commands: []` but message says it will give something | LLM treating message as action | CRITICAL rule in prompt: commands array is the only way things happen | +| Prayer not detected | Typed as `/pray` (slash command) | Must type `pray` in chat without slash | + +--- + +## Architecture Notes + +### Why no Minecraft plugin? + +No Java plugin required. The script tails `latest.log` for chat lines matching `pray ` and `bible`, then acts via RCON. This means: +- Works with any vanilla server version that has RCON +- No server restart required to install or update +- Script restarts independently of the server + +### Log detection patterns + +```python +# Chat messages in vanilla 1.21: +# [HH:MM:SS] [Server thread/INFO]: pray message here +# [HH:MM:SS] [Server thread/INFO]: bible + +PRAY_PATTERN = re.compile(r'\[.*?\]: <(\w+)> [Pp]ray (.+)') +BIBLE_PATTERN = re.compile(r'\[.*?\]: <(\w+)> [Bb]ible\s*$') +JOIN_PATTERN = re.compile(r'\[.*?\]: (\w+) joined the game') +``` + +Note: `/pray` as a slash command does NOT work — vanilla 1.21 rejects unknown commands client-side before they reach the server log. + +### Two-call LLM architecture + +``` +Prayer received + │ + ├─► Command call (command_model, temp=0.3, max_tokens=200, format=json) + │ System: terse spec, command palette, item rules + │ Returns: {"commands": [...]} + │ + └─► Message call (model, temp=0.85, max_tokens=600, no format constraint) + System: God persona only + User: prayer + context + "You decided to execute: [commands]" + Returns: plain prose, any length +``` + +Separating the calls means: +- Commands are never truncated by a long message +- Message has full token budget for dramatic prose +- Each model does what it's best at + +### Prayer memory format + +Stored as a list of `[player, prayer, god_message]` tuples in JSON. Loaded at startup, appended after every successful prayer, capped at 10 entries. Injected into the message call as alternating `user`/`assistant` messages so the LLM sees genuine conversation history. + +### Divine intervention timing + +Uses exponential distribution (`random.expovariate`) — the correct model for Poisson arrivals. `interventions_per_day=4` means an average gap of 6 hours but intervals are random and memoryless. Could be 3 in one hour, then nothing for 18 hours. + +--- + +## Sethpc Infrastructure Context + +This was developed and deployed on: +- MCSManager on CT 644 (Proxmox, Debian 12, node-112) +- Minecraft shrink-world server: port 25566, RCON 25576 +- Ollama on steel141 (192.168.0.141:11434) +- Models: `gemma3:12b` (messages), `qwen3-coder:30b` (commands) +- Service: `mc-aigod.service` on CT 644 +- Config: `/etc/mc_aigod.json` +- Script: `/usr/local/bin/mc_aigod.py` diff --git a/datapacks/morespawns/data/minecraft/worldgen/biome_modifier/more_creepers.json b/datapacks/morespawns/data/minecraft/worldgen/biome_modifier/more_creepers.json new file mode 100644 index 0000000..88887b0 --- /dev/null +++ b/datapacks/morespawns/data/minecraft/worldgen/biome_modifier/more_creepers.json @@ -0,0 +1,12 @@ +{ + "type": "minecraft:add_spawns", + "biomes": "#minecraft:is_overworld", + "spawners": [ + { + "type": "minecraft:creeper", + "weight": 400, + "minCount": 1, + "maxCount": 4 + } + ] +} diff --git a/datapacks/morespawns/pack.mcmeta b/datapacks/morespawns/pack.mcmeta new file mode 100644 index 0000000..dbf9933 --- /dev/null +++ b/datapacks/morespawns/pack.mcmeta @@ -0,0 +1,6 @@ +{ + "pack": { + "pack_format": 26, + "description": "Increased creeper spawn rate" + } +} diff --git a/datapacks/shrinkborder/data/minecraft/tags/function/load.json b/datapacks/shrinkborder/data/minecraft/tags/function/load.json new file mode 100644 index 0000000..fe8547e --- /dev/null +++ b/datapacks/shrinkborder/data/minecraft/tags/function/load.json @@ -0,0 +1 @@ +{"values": ["shrinkborder:load"]} diff --git a/datapacks/shrinkborder/data/minecraft/tags/function/tick.json b/datapacks/shrinkborder/data/minecraft/tags/function/tick.json new file mode 100644 index 0000000..7d11a3f --- /dev/null +++ b/datapacks/shrinkborder/data/minecraft/tags/function/tick.json @@ -0,0 +1 @@ +{"values": ["shrinkborder:tick"]} diff --git a/datapacks/shrinkborder/data/shrinkborder/function/check_deaths.mcfunction b/datapacks/shrinkborder/data/shrinkborder/function/check_deaths.mcfunction new file mode 100644 index 0000000..fe226b1 --- /dev/null +++ b/datapacks/shrinkborder/data/shrinkborder/function/check_deaths.mcfunction @@ -0,0 +1,5 @@ +# Check if total deaths increased since last tick +execute unless score $deaths_total deaths_total = $deaths_prev deaths_prev run function shrinkborder:on_death + +# Update previous death count +scoreboard players operation $deaths_prev deaths_prev = $deaths_total deaths_total diff --git a/datapacks/shrinkborder/data/shrinkborder/function/disable.mcfunction b/datapacks/shrinkborder/data/shrinkborder/function/disable.mcfunction new file mode 100644 index 0000000..8314e62 --- /dev/null +++ b/datapacks/shrinkborder/data/shrinkborder/function/disable.mcfunction @@ -0,0 +1,2 @@ +scoreboard players set $shrink_enabled shrink_enabled 0 +tellraw @a ["",{"text":"[ShrinkBorder] ","color":"gold"},{"text":"Border shrinking is now ","color":"white"},{"text":"DISABLED","color":"red"},{"text":".","color":"gray"}] diff --git a/datapacks/shrinkborder/data/shrinkborder/function/enable.mcfunction b/datapacks/shrinkborder/data/shrinkborder/function/enable.mcfunction new file mode 100644 index 0000000..2b8797f --- /dev/null +++ b/datapacks/shrinkborder/data/shrinkborder/function/enable.mcfunction @@ -0,0 +1,5 @@ +scoreboard players set $shrink_enabled shrink_enabled 1 +scoreboard players set $deaths_total deaths_total 0 +execute as @a run scoreboard players operation $deaths_total deaths_total += @s player_deaths +scoreboard players operation $deaths_prev deaths_prev = $deaths_total deaths_total +tellraw @a ["",{"text":"[ShrinkBorder] ","color":"gold"},{"text":"Border shrinking is now ","color":"white"},{"text":"ENABLED","color":"green"},{"text":"! Die and the walls close in.","color":"gray"}] diff --git a/datapacks/shrinkborder/data/shrinkborder/function/load.mcfunction b/datapacks/shrinkborder/data/shrinkborder/function/load.mcfunction new file mode 100644 index 0000000..e8d6100 --- /dev/null +++ b/datapacks/shrinkborder/data/shrinkborder/function/load.mcfunction @@ -0,0 +1,18 @@ +# Initialise scoreboards on world load +scoreboard objectives add deaths_total dummy "Total Deaths" +scoreboard objectives add deaths_prev dummy "Previous Deaths" +scoreboard objectives add border_parity dummy "Border Parity" +scoreboard objectives add shrink_enabled dummy "Shrink Enabled" +scoreboard objectives add player_deaths deathCount + +# Set shrink feature to DISABLED by default +scoreboard players set $shrink_enabled shrink_enabled 0 + +# Initialise parity tracker (0=shrink N/S, 1=shrink E/W) +scoreboard players set $border_parity border_parity 0 + +# Set world border +worldborder center 0 0 +worldborder set 1000 + +tellraw @a ["",{"text":"[ShrinkBorder] ","color":"gold"},{"text":"Loaded. Shrinking is ","color":"white"},{"text":"DISABLED","color":"red"},{"text":". Use /function shrinkborder:enable to start.","color":"gray"}] diff --git a/datapacks/shrinkborder/data/shrinkborder/function/on_death.mcfunction b/datapacks/shrinkborder/data/shrinkborder/function/on_death.mcfunction new file mode 100644 index 0000000..50eaa66 --- /dev/null +++ b/datapacks/shrinkborder/data/shrinkborder/function/on_death.mcfunction @@ -0,0 +1,12 @@ +# A death occurred - shrink the border by 1 on alternating axes +tellraw @a ["",{"text":"[ShrinkBorder] ","color":"gold"},{"text":"A player died! Border shrinking...","color":"red"}] + +# Even deaths: shrink N/S +execute if score $border_parity border_parity matches 0 run function shrinkborder:shrink_ns + +# Odd deaths: shrink E/W +execute if score $border_parity border_parity matches 1 run function shrinkborder:shrink_ew + +# Flip parity +execute if score $border_parity border_parity matches 0 run scoreboard players set $border_parity border_parity 1 +execute if score $border_parity border_parity matches 1 run scoreboard players set $border_parity border_parity 0 diff --git a/datapacks/shrinkborder/data/shrinkborder/function/reset.mcfunction b/datapacks/shrinkborder/data/shrinkborder/function/reset.mcfunction new file mode 100644 index 0000000..5c45440 --- /dev/null +++ b/datapacks/shrinkborder/data/shrinkborder/function/reset.mcfunction @@ -0,0 +1,5 @@ +scoreboard players set $shrink_enabled shrink_enabled 0 +scoreboard players set $border_parity border_parity 0 +worldborder center 0 0 +worldborder set 1000 +tellraw @a ["",{"text":"[ShrinkBorder] ","color":"gold"},{"text":"Border reset to 1000x1000 and shrinking DISABLED.","color":"green"}] diff --git a/datapacks/shrinkborder/data/shrinkborder/function/shrink_ew.mcfunction b/datapacks/shrinkborder/data/shrinkborder/function/shrink_ew.mcfunction new file mode 100644 index 0000000..6446e71 --- /dev/null +++ b/datapacks/shrinkborder/data/shrinkborder/function/shrink_ew.mcfunction @@ -0,0 +1,2 @@ +worldborder add -1 1 +tellraw @a ["",{"text":"[ShrinkBorder] ","color":"gold"},{"text":"East wall closed in. ","color":"yellow"},{"text":"Stay inside!","color":"red"}] diff --git a/datapacks/shrinkborder/data/shrinkborder/function/shrink_ns.mcfunction b/datapacks/shrinkborder/data/shrinkborder/function/shrink_ns.mcfunction new file mode 100644 index 0000000..88c2124 --- /dev/null +++ b/datapacks/shrinkborder/data/shrinkborder/function/shrink_ns.mcfunction @@ -0,0 +1,2 @@ +worldborder add -1 1 +tellraw @a ["",{"text":"[ShrinkBorder] ","color":"gold"},{"text":"North wall closed in. ","color":"yellow"},{"text":"Stay inside!","color":"red"}] diff --git a/datapacks/shrinkborder/data/shrinkborder/function/tick.mcfunction b/datapacks/shrinkborder/data/shrinkborder/function/tick.mcfunction new file mode 100644 index 0000000..215a30c --- /dev/null +++ b/datapacks/shrinkborder/data/shrinkborder/function/tick.mcfunction @@ -0,0 +1,6 @@ +# Run every tick - count total deaths across all players +scoreboard players set $deaths_total deaths_total 0 +execute as @a run scoreboard players operation $deaths_total deaths_total += @s player_deaths + +# Only process if shrinking is enabled +execute if score $shrink_enabled shrink_enabled matches 1 run function shrinkborder:check_deaths diff --git a/datapacks/shrinkborder/pack.mcmeta b/datapacks/shrinkborder/pack.mcmeta new file mode 100644 index 0000000..e930800 --- /dev/null +++ b/datapacks/shrinkborder/pack.mcmeta @@ -0,0 +1,6 @@ +{ + "pack": { + "pack_format": 26, + "description": "Shrinking border on death" + } +} diff --git a/mc-aigod.service b/mc-aigod.service new file mode 100644 index 0000000..2ec5ad4 --- /dev/null +++ b/mc-aigod.service @@ -0,0 +1,14 @@ +[Unit] +Description=Minecraft AI God (/pray watcher) +After=network.target + +[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 diff --git a/mc_aigod.py b/mc_aigod.py new file mode 100644 index 0000000..ee146c9 --- /dev/null +++ b/mc_aigod.py @@ -0,0 +1,1078 @@ +#!/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. +Config: /etc/mc_aigod.json +""" + +import json, random, re, socket, struct, threading, time, logging +from collections import deque +from datetime import datetime +import requests + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s [aigod] %(levelname)s: %(message)s', + handlers=[ + logging.StreamHandler(), + logging.FileHandler('/var/log/mc_aigod.log'), + ] +) +log = logging.getLogger(__name__) + +CONFIG_PATH = '/etc/mc_aigod.json' + +PRAY_PATTERNS = [ + re.compile(r'\[.*?\]: <(\w+)> [Pp]ray (.+)'), +] + +BIBLE_PATTERNS = [ + re.compile(r'\[.*?\]: <(\w+)> [Bb]ible\s*$'), +] + +JOIN_PATTERN = re.compile(r'\[.*?\]: (\w+) joined the game') +LEAVE_PATTERN = re.compile(r'\[.*?\]: (\w+) left the game') + +# Interesting server events to capture for God's awareness +# Matches: chat, deaths, joins, leaves — skips RCON/thread noise +LOG_INTERESTING = re.compile( + r'\[.*?(?:Server thread|Async Chat).*?INFO\]: ' + r'(?:<\w+>.*|' # chat + r'\w+ joined the game|' # join + r'\w+ left the game|' # leave + r'\w+ (?:died|was slain|was shot|drowned|fell|burned|blew up|suffocated|starved|was killed|hit the ground|went up in flames|tried to swim in lava).*)' +) + +# --------------------------------------------------------------------------- +# Shared memory buffers (module-level, shared across threads) +# --------------------------------------------------------------------------- + +# Rolling window of recent server log events (last LOG_WINDOW_MINUTES minutes) +LOG_WINDOW_MINUTES = 20 +recent_log: deque = deque() # entries: (timestamp_float, str) + +# God's prayer memory — last N prayer/response pairs across all players +# Stored as (player, prayer_text, god_message) tuples +PRAYER_MEMORY_SIZE = 10 +prayer_memory: deque = deque() # entries: (player, prayer, god_message) + +_memory_lock = threading.Lock() + + +def add_log_event(line: str): + """Add a meaningful log line to the rolling buffer, pruning old entries.""" + if not LOG_INTERESTING.search(line): + return + # Extract just the meaningful part after the thread prefix + m = re.search(r'\[.*?INFO\]: (.+)', line) + if not m: + return + entry = m.group(1).strip() + now = time.time() + with _memory_lock: + recent_log.append((now, entry)) + # Prune entries older than the window + cutoff = now - (LOG_WINDOW_MINUTES * 60) + while recent_log and recent_log[0][0] < cutoff: + recent_log.popleft() + + +def _memory_path(config) -> str: + return config.get( + "memory_path", + "/opt/mcsmanager/daemon/data/InstanceData/shrinkborder1234567890abcdef12345/aigod_memory.json" + ) + +def save_prayer_memory(config): + """Persist prayer memory to disk.""" + try: + path = _memory_path(config) + with _memory_lock: + data = list(prayer_memory) + with open(path, 'w') as f: + json.dump(data, f) + log.debug(f"Prayer memory saved ({len(data)} entries)") + except Exception as e: + log.warning(f"Could not save prayer memory: {e}") + +def load_prayer_memory(config): + """Load prayer memory from disk on startup.""" + try: + path = _memory_path(config) + with open(path) as f: + data = json.load(f) + with _memory_lock: + prayer_memory.clear() + for entry in data[-PRAYER_MEMORY_SIZE:]: + prayer_memory.append(tuple(entry)) + log.info(f"Prayer memory loaded ({len(data)} entries from {path})") + except FileNotFoundError: + log.info("No prayer memory file found — starting fresh.") + except Exception as e: + log.warning(f"Could not load prayer memory: {e}") + +def add_prayer_memory(player: str, prayer: str, god_message: str, config=None): + """Record a completed prayer exchange and persist to disk.""" + with _memory_lock: + prayer_memory.append((player, prayer[:200], god_message[:300])) + while len(prayer_memory) > PRAYER_MEMORY_SIZE: + prayer_memory.popleft() + if config: + save_prayer_memory(config) + + +def get_log_context_block() -> str: + """Return recent server events as a formatted string for the LLM.""" + with _memory_lock: + entries = list(recent_log) + if not entries: + return "" + now = time.time() + lines = [] + for ts, entry in entries: + mins_ago = int((now - ts) / 60) + lines.append(f" [{mins_ago}m ago] {entry}") + return "\n=== RECENT SERVER EVENTS (last 20 min) ===\n" + "\n".join(lines) + "\n" + + +def get_prayer_history_messages() -> list: + """ + Return prayer memory as alternating user/assistant message dicts + for insertion into the Ollama messages array before the current prayer. + """ + with _memory_lock: + history = list(prayer_memory) + messages = [] + for player, prayer, god_msg in history: + messages.append({"role": "user", "content": f"{player} prayed: {prayer}"}) + messages.append({"role": "assistant", "content": f'{{"message": "{god_msg}", "commands": []}}'}) + return messages + +# --------------------------------------------------------------------------- +# 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(' str: + """ + Parse RCON 'data get entity Inventory' output into a + human-readable summary with rarity annotations for the LLM. + """ + # Extract item entries: {count: N, id: "minecraft:xxx", ...} + items = re.findall(r'\{[^}]+\}', raw) + counts: dict = {} + for item in items: + id_m = re.search(r'id:\s*"minecraft:(\w+)"', item) + count_m = re.search(r'count:\s*(\d+)', item) + if id_m: + item_id = id_m.group(1) + count = int(count_m.group(1)) if count_m else 1 + counts[item_id] = counts.get(item_id, 0) + count + + if not counts: + return "empty inventory" + + lines = [] + for item_id, count in sorted(counts.items(), key=lambda x: -x[1]): + rarity = ITEM_RARITY.get(item_id, "") + rarity_str = f" ({rarity})" if rarity else "" + lines.append(f" {count}x {item_id}{rarity_str}") + return "\n".join(lines) + +def get_player_context(player: str, config) -> str: + """ + Fetch player-specific state via RCON and return a formatted block + for injection into the LLM user message. + """ + def q(cmd): + return rcon(cmd, config["rcon_host"], config["rcon_port"], config["rcon_password"]) + + lines = [] + + # Inventory + inv_raw = q(f"data get entity {player} Inventory") + inv_summary = parse_inventory(inv_raw) + lines.append(f"Inventory:\n{inv_summary}") + + # Position + pos_raw = q(f"data get entity {player} Pos") + pos_m = re.findall(r'(-?[\d.]+)d', pos_raw) + if pos_m and len(pos_m) >= 3: + x, y, z = float(pos_m[0]), float(pos_m[1]), float(pos_m[2]) + dist = int((x**2 + z**2) ** 0.5) + lines.append(f"Position: x={int(x)}, y={int(y)}, z={int(z)} ({dist} blocks from spawn)") + + # Health (max 20.0) + health_raw = q(f"data get entity {player} Health") + health_m = re.search(r'([\d.]+)f', health_raw) + if health_m: + hp = float(health_m.group(1)) + lines.append(f"Health: {hp:.1f}/20.0 ({'critical' if hp < 6 else 'low' if hp < 12 else 'moderate' if hp < 18 else 'full'})") + + # Food level (max 20) + food_raw = q(f"data get entity {player} foodLevel") + food_m = re.search(r':\s*(\d+)', food_raw) + if food_m: + food = int(food_m.group(1)) + lines.append(f"Food: {food}/20 ({'starving' if food < 4 else 'hungry' if food < 10 else 'satisfied' if food < 18 else 'full'})") + + # XP level + xp_raw = q(f"data get entity {player} XpLevel") + xp_m = re.search(r':\s*(\d+)', xp_raw) + if xp_m: + lines.append(f"XP level: {xp_m.group(1)}") + + # Death count from scoreboard + score_raw = q(f"scoreboard players get {player} player_deaths") + score_m = re.search(r'has\s+(\d+)', score_raw) + if score_m: + lines.append(f"Deaths this session: {score_m.group(1)}") + + if not lines: + return "" + return "\n=== PRAYING PLAYER STATE ===\n" + "\n".join(lines) + "\n" + +# --------------------------------------------------------------------------- +# LLM +# --------------------------------------------------------------------------- + +COMMAND_PALETTE = """ +GIVE (any item, based on player need — see Item Naming Rules below): + SYNTAX: give minecraft: + The order is ALWAYS: give, then player name, then minecraft:item_id, then count number. + CORRECT: give slingshooter08 minecraft:spruce_log 64 + INCORRECT: give slingshooter08 64 minecraft:spruce_log <- count must come LAST + INCORRECT: give slingshooter08 spruce_log 64 <- namespace prefix required + give {target} minecraft:[enchantments={:}] 1 + xp add {target} levels + +EFFECTS (replace {target} with any online player's username): + 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 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 + effect clear {target} + +MOVEMENT: + tp {target} 0 64 0 + +WORLD/ENVIRONMENT (affects all players): + time set day + time set night + weather clear 6000 + weather thunder 6000 + weather rain 3000 + +PUNISHMENTS: + execute at {target} run summon minecraft:lightning_bolt ~ ~ ~ + execute at {target} run summon minecraft:creeper ~ ~ ~3 + kill {target} + +CELEBRATIONS: + execute at {target} run summon minecraft:firework_rocket ~ ~1 ~ +""" + +ITEM_LIBRARY = """ +=== ITEM NAMING RULES === +All item IDs use the minecraft: namespace and snake_case. There is no item called +"minecraft:bed" — beds are colour-prefixed: white_bed, red_bed, blue_bed, etc. +There is no "minecraft:log" — use oak_log, spruce_log, birch_log, etc. +There is no "minecraft:wool" — use white_wool, red_wool, etc. +There is no "minecraft:dye" — use red_dye, blue_dye, etc. +Enchantments use 1.21 component syntax: item[enchantments={sharpness:5,unbreaking:3}] + +COMMON VALID IDs (not exhaustive — use your knowledge of Minecraft item names): +FOOD: bread, cooked_beef, cooked_chicken, golden_apple, enchanted_golden_apple, honey_bottle, cake +SURVIVAL: torch, crafting_table, furnace, chest, white_bed, flint_and_steel, compass, map +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, fishing_rod, shears +ARMOR: diamond_helmet, diamond_chestplate, diamond_leggings, diamond_boots + netherite_helmet, netherite_chestplate, netherite_leggings, netherite_boots + elytra, shield, turtle_helmet +UTILITY: totem_of_undying, experience_bottle, ender_pearl, ender_eye, blaze_rod, + name_tag, saddle, lead, clock, spyglass, bundle, recovery_compass +BLOCKS: obsidian, crying_obsidian, ancient_debris, cobblestone, stone, dirt, + oak_planks, oak_log, glass, bookshelf, ladder, vine +POTIONS: potion (requires component syntax for type — prefer effect give instead) +""" + +def build_system_prompt(config): + return ( + f"You are God in a Minecraft server called {config['server_name']}.\n" + "You are benevolent but just. Theatrical, ancient, and dramatic in speech.\n" + "You answer every prayer with a message. You pass judgement on players when they pray.\n\n" + "Respond ONLY with a valid JSON object — no markdown, no explanation, nothing else:\n" + '{\n' + ' "message": "Your divine words to all players",\n' + ' "commands": ["command1", "command2"]\n' + '}\n\n' + "Rules:\n" + "- message: always present and non-empty. Speak as God. Be dramatic and biblical. KEEP IT UNDER 100 WORDS. Do not ramble — God is powerful, not verbose.\n" + "- commands: list of server commands WITHOUT a leading slash. May be empty [] ONLY if you are deliberately granting nothing.\n" + "- CRITICAL: If your message says you will give something, grant something, or fulfil a request, you MUST include the corresponding command in the commands array. Words alone do nothing. The commands array is the ONLY way anything happens in the world. If commands is empty, nothing happens regardless of what your message says.\n" + "- {player} is the player who prayed. You may also use any other online player's literal username as a target.\n" + "- You are NOT obligated to do what the praying player asked. You may reward someone else,\n" + " punish the requester, change the weather, or do something entirely unexpected.\n" + "- Use the current server state (time, weather, online players) and the praying player's state (inventory, health, food, position) to inform your judgement.\n" + "- Consider what the player actually has and what they realistically need. A player with full diamond gear asking for more is greedy. A starving player with nothing deserves compassion.\n" + "- Do not ask a player to gather materials they clearly don't have access to or that are rare relative to their current situation.\n" + "- For give commands: use any valid Minecraft 1.21 item ID following the Item Naming Rules. Do not guess item IDs — consult the naming rules and common IDs list.\n" + "- For all other commands: only use forms shown in the Command Palette. Do not invent new command types.\n" + "- Reward humble, genuine prayers. Punish hubris, blasphemy, or naked greed.\n" + "- Powerful rewards (netherite, enchanted_golden_apple, totem) must be rare.\n" + "- kill {target} is reserved for extreme blasphemy only.\n" + "- When angered, chain commands: thunder + lightning + debuffs = divine wrath.\n\n" + "=== COMMAND PALETTE ===\n" + f"{COMMAND_PALETTE}\n" + "=== ITEM LIBRARY ===\n" + f"{ITEM_LIBRARY}" + ) + +def _build_context_block(context, extras=""): + online = ', '.join(context['online_players']) or 'none' + border = str(context['world_border']) if context['world_border'] else 'N/A' + return ( + "\n=== CURRENT SERVER STATE ===\n" + f"Time of day: {context['time_of_day']}\n" + f"Weather: {context['weather']}\n" + f"Online players: {online}\n" + f"World border: {border}\n" + f"{extras}" + ) + +def _parse_llm_json(content: str) -> dict: + """ + Parse LLM JSON response, repairing truncation if necessary. + If max_tokens cuts the response mid-string, we attempt to salvage + whatever message and commands were already present. + """ + try: + return json.loads(content) + except json.JSONDecodeError: + log.warning("LLM response truncated — attempting repair") + + # Extract message if present, even if truncated + msg_m = re.search(r'"message"\s*:\s*"((?:[^"\\]|\\.)*)', content) + message = msg_m.group(1) if msg_m else "" + # Truncate at last complete sentence if mid-sentence + for end in ('.', '!', '?'): + idx = message.rfind(end) + if idx != -1: + message = message[:idx+1] + break + + # Extract commands array if present + commands = [] + cmd_m = re.search(r'"commands"\s*:\s*\[(.*?)(?:\]|$)', content, re.DOTALL) + if cmd_m: + raw_cmds = cmd_m.group(1) + commands = re.findall(r'"([^"]+)"', raw_cmds) + + # If message was truncated mid-sentence, trim to last complete sentence + if message and message[-1] not in '.!?': + for end in ('.', '!', '?'): + idx = message.rfind(end) + if idx != -1: + message = message[:idx+1] + break + + result = {"message": message, "commands": commands} + log.warning(f"Repaired JSON: message={len(message)}chars, commands={commands}") + return result + +COMMANDS_SYSTEM_PROMPT = ( + "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" + "Respond ONLY with a valid JSON object, nothing else:\n" + "{\"commands\": [\"cmd1\", \"cmd2\"]}\n\n" + "Rules:\n" + "- commands may be empty [] if no action is warranted.\n" + "- {player} = the praying player. You may target any other online player by name.\n" + "- Reward humble prayers. Punish hubris or blasphemy. Be unpredictable.\n" + "- Consider the player's inventory and state — don't give items they already have plenty of.\n" + "- Powerful rewards (netherite, enchanted_golden_apple, totem) must be rare.\n" + "- kill is reserved for extreme blasphemy only.\n" + "- For give: syntax is always give minecraft: \n" + "- Count comes LAST. Namespace prefix minecraft: is REQUIRED.\n" + "- Beds: white_bed not bed. Logs: oak_log not log. Wool: white_wool not wool.\n" + "- Chain commands for dramatic effect: thunder + lightning + blindness = wrath.\n\n" + + "=== COMMAND PALETTE ===\n" + + COMMAND_PALETTE + + "\n=== ITEM LIBRARY ===\n" + + ITEM_LIBRARY +) + +MESSAGE_SYSTEM_PROMPT = ( + "You are God in a Minecraft server. You are benevolent but just. " + "Theatrical, ancient, and dramatic in speech — like the Old Testament.\n" + "You will be told what action was taken (if any) in response to a player's prayer. " + "Write a single spoken message to all players reacting to this prayer and action.\n" + "Respond with ONLY the message text — no JSON, no quotes, no formatting. " + "Be vivid and dramatic. Any length is fine." +) + +def _llm_call(model: str, system: str, user: str, config: dict, + fmt = None, temperature: float = 0.85, + max_tokens: int = 400, timeout: int = 60) -> str: + """Single Ollama chat call. Returns raw content string.""" + payload = { + "model": model, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + "stream": False, + "options": { + "temperature": temperature, + "num_predict": max_tokens, + }, + } + if fmt: + payload["format"] = fmt + r = requests.post(f"{config['ollama_url']}/api/chat", json=payload, timeout=timeout) + r.raise_for_status() + return r.json()["message"]["content"] + +def _build_prayer_context(player, prayer, context, config) -> str: + """Build the full user message block shared by both calls.""" + try: + player_ctx = get_player_context(player, config) + except Exception as e: + log.warning(f"Could not fetch player context for {player}: {e}") + player_ctx = "" + others = [p for p in context["online_players"] if p != player] + ctx = _build_context_block( + context, + extras=( + f"Other targetable players: {', '.join(others) or 'none'}\n" + + player_ctx + + get_log_context_block() + ) + ) + return f"{player} prays: {prayer}{ctx}" + +def ask_god(player, prayer, context, config): + """ + Two-call approach: + 1. command_model (qwen3-coder:30b) decides what commands to run — pure JSON, no prose. + 2. model (gemma3:12b) writes the message — pure prose, no JSON, no token competition. + """ + command_model = config.get("command_model", config["model"]) + message_model = config["model"] + history = get_prayer_history_messages() + user_msg = _build_prayer_context(player, prayer, context, config) + + # --- Call 1: commands --- + log.info(f"Commands call ({command_model}) — {player}: {prayer[:60]} (history={len(history)//2})") + try: + cmd_content = _llm_call( + model=command_model, + system=COMMANDS_SYSTEM_PROMPT, + user=user_msg, + config=config, + fmt="json", + temperature=0.3, # low temp for precise structured output + max_tokens=200, + ) + cmd_result = _parse_llm_json(cmd_content) + commands = cmd_result.get("commands") or [] + log.info(f"Commands decided: {commands}") + except Exception as e: + log.error(f"Commands call failed: {e}") + commands = [] + + # --- Call 2: message --- + # Tell the message model what was decided so it can write accordingly + if commands: + action_summary = f"You decided to execute these server commands: {commands}" + else: + action_summary = "You decided to take no action." + + msg_user = ( + f"{user_msg}\n\n" + f"=== YOUR DECISION ===\n{action_summary}\n" + f"Now write your spoken message to all players." + ) + + # Include prayer history so God's voice is consistent + msg_messages = ( + [{"role": "system", "content": MESSAGE_SYSTEM_PROMPT}] + + history + + [{"role": "user", "content": msg_user}] + ) + log.info(f"Message call ({message_model})") + try: + msg_payload = { + "model": message_model, + "messages": msg_messages, + "stream": False, + "options": { + "temperature": config.get("temperature", 0.9), + "num_predict": config.get("max_tokens", 600), + }, + } + r = requests.post(f"{config['ollama_url']}/api/chat", json=msg_payload, timeout=60) + r.raise_for_status() + message = r.json()["message"]["content"].strip() + log.info(f"Message: {message[:200]}") + except Exception as e: + log.error(f"Message call failed: {e}") + message = "" + + try: + with open('/var/log/mc_aigod_responses.log', 'a') as rf: + rf.write( + f"\n--- {time.strftime('%Y-%m-%d %H:%M:%S')} prayer:{player} ---\n" + f"COMMANDS: {commands}\n" + f"MESSAGE: {message}\n" + ) + except Exception: + pass + + return {"message": message, "commands": commands} + +INTERVENTION_PROMPT = ( + "=== DIVINE MOMENT ===\n" + "No player has prayed. You are simply watching over your world.\n" + "You may choose to act upon what you see, or remain silent.\n" + "If commands is [], take no action and set message to null.\n" + "Do not feel obligated to act — restraint is also divine.\n" + "If you do act, it may be subtle (weather, soft blessing) or dramatic.\n" +) + +def ask_god_intervention(context, config): + """Two-call intervention: commands first, then message.""" + command_model = config.get("command_model", config["model"]) + message_model = config["model"] + ctx = _build_context_block(context, extras=get_log_context_block()) + user_msg = INTERVENTION_PROMPT + ctx + + # --- Call 1: commands --- + log.info(f"Intervention commands call ({command_model})") + try: + cmd_content = _llm_call( + model=command_model, + system=COMMANDS_SYSTEM_PROMPT, + user=user_msg, + config=config, + fmt="json", + temperature=0.3, + max_tokens=200, + ) + commands = (_parse_llm_json(cmd_content) or {}).get("commands") or [] + log.info(f"Intervention commands: {commands}") + except Exception as e: + log.error(f"Intervention commands call failed: {e}") + commands = [] + + if not commands: + log.info("God chose silence (no commands).") + return {"message": None, "commands": []} + + # --- Call 2: message --- + action_summary = f"You decided to execute: {commands}" + msg_user = f"{user_msg}\n\n=== YOUR DECISION ===\n{action_summary}\nNow write your spoken message." + log.info(f"Intervention message call ({message_model})") + try: + message = _llm_call( + model=message_model, + system=MESSAGE_SYSTEM_PROMPT, + user=msg_user, + config=config, + fmt=None, + temperature=0.9, + max_tokens=config.get("max_tokens", 600), + ).strip() + except Exception as e: + log.error(f"Intervention message call failed: {e}") + message = "" + + return {"message": message, "commands": commands} + +# --------------------------------------------------------------------------- +# Command validation & execution +# --------------------------------------------------------------------------- + +SAFE_PREFIXES = [ + 'give ', 'effect ', 'xp ', 'tp ', 'time ', 'weather ', + 'execute ', 'kill ', 'summon ', 'tellraw ', 'worldborder ', +] + +def fix_give_command(cmd: str) -> str: + """ + Correct common LLM give command mistakes: + - Wrong argument order: give → give minecraft: + - Missing namespace: give → give minecraft: + """ + # Only attempt to fix give commands + m = re.match(r'^give\s+(\S+)\s+(\S+)\s+(\S+)(.*)$', cmd) + if not m: + return cmd + player, arg2, arg3, rest = m.group(1), m.group(2), m.group(3), m.group(4) + + # Detect transposed order: give player + if arg2.isdigit(): + count, item = arg2, arg3 + if not item.startswith("minecraft:"): + item = f"minecraft:{item}" + fixed = f"give {player} {item} {count}{rest}" + log.warning(f"Fixed transposed give: '{cmd}' -> '{fixed}'") + return fixed + + # Detect missing namespace: give player + if not arg2.startswith("minecraft:") and not arg2.startswith("{"): + item = f"minecraft:{arg2}" + fixed = f"give {player} {item} {arg3}{rest}" + log.warning(f"Fixed missing namespace: '{cmd}' -> '{fixed}'") + return fixed + + return cmd + +def validate_command(cmd, online_players, fallback_player): + """Replace placeholders, auto-fix common give syntax errors, check safe prefix.""" + resolved = cmd.replace("{player}", fallback_player).replace("{target}", fallback_player) + resolved = fix_give_command(resolved) + 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 + +def execute_response(response, context, config, praying_player=None): + message = response.get("message") or "" + commands = response.get("commands") or [] + + # --- DEBUG_COMMANDS toggle --- + # Set "debug_commands": true in /etc/mc_aigod.json to show commands in-game. + # Uses tellraw (never appears in server logs). Set to false to disable silently. + debug = config.get("debug_commands", False) + + prefix = config.get("god_chat_prefix", "[GOD]") + if message: + safe_msg = message.replace("\\", "\\\\").replace('"', '\\"').replace("\n", " ").replace("\r", "") + + # Split on sentence boundaries first, then chunk anything still too long + sentences = re.split(r'(?<=[.!?])\s+', safe_msg) + lines = [] + current = "" + for sentence in sentences: + if len(current) + len(sentence) + 1 <= 180: + current = (current + " " + sentence).strip() + else: + if current: + lines.append(current) + # If a single sentence is still too long, hard-chunk it + while len(sentence) > 180: + lines.append(sentence[:180]) + sentence = sentence[180:] + current = sentence + if current: + lines.append(current) + + for i, line in enumerate(lines): + if i == 0: + rcon( + f'tellraw @a {{"text":"{prefix} {line}","color":"gold","bold":false}}', + config["rcon_host"], config["rcon_port"], config["rcon_password"] + ) + else: + rcon( + f'tellraw @a [{{"text":" ","color":"gold"}},{{"text":"{line}","color":"yellow","italic":true}}]', + config["rcon_host"], config["rcon_port"], config["rcon_password"] + ) + time.sleep(0.2) + + fallback = praying_player or (context["online_players"][0] if context["online_players"] else "") + max_cmds = config.get("max_commands_per_response", 6) + + if debug and commands: + safe_cmds = " | ".join(commands[:max_cmds]).replace("\\", "\\\\").replace('"', '\\"') + rcon( + f'tellraw @a {{"text":"[~] {safe_cmds}","color":"dark_gray","italic":true}}', + config["rcon_host"], config["rcon_port"], config["rcon_password"] + ) + + 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 RCON: {resolved}") + result = rcon(resolved, config["rcon_host"], config["rcon_port"], config["rcon_password"]) + log.info(f"RCON result: {result!r}") + 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) + +# --------------------------------------------------------------------------- +# Prayer handler +# --------------------------------------------------------------------------- + +BIBLE_LINES = [ + ("", "gold", True), + ("[=== THE HOLY SCRIPTURE ===]", "gold", True), + ("", "gold", True), + ("God watches over this server.", "yellow", False), + ("Speak to him by typing in chat:", "white", False), + (" pray ", "green", True), + ("", "white", False), + ("God is benevolent, but just.", "yellow", False), + ("He hears every prayer — but answers as he sees fit.", "white", False), + ("He may reward you, punish you, or act upon another player entirely.", "white", False), + ("", "white", False), + ("Examples:", "yellow", False), + (" pray Lord, bless my journey through the mines.", "gray", False), + (" pray Smite my enemy, for they have wronged me.", "gray", False), + (" pray Forgive me, I have sinned against thy creations.", "gray", False), + ("", "white", False), + ("Thou may only pray once every 20 seconds.", "red", False), + ("Type \"bible\" in chat to see this again.", "gray", False), + ("God intervenes unprompted. Watch the skies.", "dark_purple", True), + ("", "gold", True), + ("[========================]", "gold", True), + ("", "gold", True), +] + +def send_bible(player, config): + log.info(f"/bible requested by {player}") + h = config["rcon_host"] + p = config["rcon_port"] + pw = config["rcon_password"] + for text, color, bold in BIBLE_LINES: + bold_str = "true" if bold else "false" + safe = text.replace('"', '\\"') + rcon(f'tellraw {player} {{"text":"{safe}","color":"{color}","bold":{bold_str}}}', h, p, pw) + +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...", + "The cosmos trembles with thy supplication. Patience...", +] + +def process_prayer(player, prayer, config, cooldowns): + 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_secs = config.get("cooldown_seconds", 60) + + if now - last < cooldown_secs: + remaining = int(cooldown_secs - (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 + 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] ...","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) + + # Store in prayer memory so God remembers this exchange + god_msg = response.get("message") or "" + if god_msg: + add_prayer_memory(player, prayer, god_msg, config) + +# --------------------------------------------------------------------------- +# Divine intervention timer +# --------------------------------------------------------------------------- + +def next_intervention_delay(avg_per_day): + 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 {avg_per_day}/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) + + online = players_online(config) + if not online: + log.info("Intervention timer fired — no players online, skipping") + continue + + try: + context = get_server_context(config) + context["online_players"] = online + 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 + + if not (response.get("message") or response.get("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(): + with open(CONFIG_PATH) as f: + config = json.load(f) + + log.info(f"mc_aigod starting — server: {config['server_name']}") + log.info(f"Log: {config['log_path']}") + log.info(f"LLM: {config['ollama_url']} model={config['model']}") + log.info(f"RCON: {config['rcon_host']}:{config['rcon_port']}") + + load_prayer_memory(config) + + cooldowns = {} + + t = threading.Thread(target=divine_intervention_loop, args=(config,), daemon=True) + t.start() + + for line in tail_log(config["log_path"]): + # Feed every line into the rolling log buffer + add_log_event(line) + + # /pray + matched = False + for pat in PRAY_PATTERNS: + m = pat.search(line) + if m: + player = m.group(1) + prayer = m.group(2).strip() + log.info(f"Prayer from {player}: {prayer}") + try: + process_prayer(player, prayer, config, cooldowns) + except Exception as e: + log.error(f"Error processing prayer: {e}", exc_info=True) + matched = True + break + + if matched: + continue + + # /bible + for pat in BIBLE_PATTERNS: + m = pat.search(line) + if m: + player = m.group(1) + try: + send_bible(player, config) + except Exception as e: + log.error(f"Error sending bible to {player}: {e}", exc_info=True) + break + + # login notice + m = JOIN_PATTERN.search(line) + if m: + player = m.group(1) + log.info(f"Login notice → {player}") + try: + rcon( + f'tellraw {player} {{"text":"[GOD] GOD ENABLED — Type \\"bible\\" in chat for guidance. Type \\"pray \\" to pray.","color":"gold","bold":true}}', + config["rcon_host"], config["rcon_port"], config["rcon_password"] + ) + except Exception as e: + log.error(f"Error sending login notice to {player}: {e}", exc_info=True) + +if __name__ == '__main__': + main() diff --git a/mc_aigod_shrink.json b/mc_aigod_shrink.json new file mode 100644 index 0000000..ba82ce6 --- /dev/null +++ b/mc_aigod_shrink.json @@ -0,0 +1,18 @@ +{ + "server_name": "shrink-world", + "log_path": "/opt/mcsmanager/daemon/data/InstanceData/shrinkborder1234567890abcdef12345/logs/latest.log", + "rcon_host": "127.0.0.1", + "rcon_port": 25576, + "rcon_password": "REDACTED_RCON", + "ollama_url": "http://192.168.0.141:11434", + "model": "gemma3:12b", + "command_model": "qwen3-coder:30b", + "temperature": 0.85, + "max_tokens": 600, + "cooldown_seconds": 20, + "max_commands_per_response": 6, + "interventions_per_day": 4, + "god_chat_prefix": "[§6§lGOD§r]", + "debug_commands": true, + "memory_path": "/opt/mcsmanager/daemon/data/InstanceData/shrinkborder1234567890abcdef12345/aigod_memory.json" +} diff --git a/scripts/mc-godmode.service b/scripts/mc-godmode.service new file mode 100644 index 0000000..00ecea5 --- /dev/null +++ b/scripts/mc-godmode.service @@ -0,0 +1,12 @@ +[Unit] +Description=Minecraft God Mode Watcher (mc1 - slingshooter08) +After=mcsm-daemon.service +Requires=mcsm-daemon.service + +[Service] +ExecStart=/usr/local/bin/mc_godmode_watch.sh +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/scripts/mc-shrink-kit.service b/scripts/mc-shrink-kit.service new file mode 100644 index 0000000..121df2b --- /dev/null +++ b/scripts/mc-shrink-kit.service @@ -0,0 +1,12 @@ +[Unit] +Description=Minecraft Shrink World Auto Kit on Join +After=mcsm-daemon.service +Requires=mcsm-daemon.service + +[Service] +ExecStart=/usr/bin/python3 /usr/local/bin/shrink_godkit.py +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/scripts/mc_godmode_rcon.py b/scripts/mc_godmode_rcon.py new file mode 100644 index 0000000..846e1b6 --- /dev/null +++ b/scripts/mc_godmode_rcon.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +""" +Sets slingshooter08 to creative mode on server 1 (mc1). +Called by mc_godmode_watch.sh whenever slingshooter08 joins. + +Deployed to: /usr/local/bin/mc_godmode_rcon.py on CT 644 +""" +import socket, struct, time, sys + +PLAYER = 'slingshooter08' + +def rcon(cmd, host='127.0.0.1', port=25575, password='REDACTED_RCON'): + s = socket.socket() + s.settimeout(5) + s.connect((host, port)) + def pkt(i, t, p): + p = p.encode() + b'\x00\x00' + return struct.pack('