Files
Mortdecai/web/whitelist_app.py
Seth 7a31e500e4 Qwen3.5-9B on prod, Gemini 2.5 Flash for dev, error correction, branding
Prod deployment:
- paper-ai and shrink-world switched from gemma3n:e4b to qwen3.5:9b
- Error correction: detects RCON errors (<--[HERE]), asks model to fix, retries
- Broadened error patterns: Unknown game mode, Unknown enchantment, etc.
- Fixed fire fallback matching "firework" as fire intent
- Fixed command format examples (WRONG vs RIGHT in prompt)
- max_tokens bumped to 600 for command calls
- Removed template workflow commands from sudo prompt

Dev server:
- Gemini 2.5 Flash ($0.15/$0.60 per M tokens) replaces Flash Lite
- 10 bots for ~$1-1.5/hr training data generation
- Dynamic pricing by model name in cost tracker

Branding:
- Rajdhani Bold as official Mortdecai font
- Logo variants: mortdecai + mortdec.ai in 6 fonts
- Whitelist page updated with Mortdecai branding + mortdec.ai domain

Whitelist UUID fix:
- Looks up real Mojang UUID via api.mojang.com
- Patches all whitelist.json files directly
- No more offline-mode UUID mismatches

WorldEdit schematics:
- 77 schematics installed (villages, bridges, lighthouses, parks, etc.)

Mortdecai v4 training in progress: 63% complete on steel141

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 23:09:27 -04:00

321 lines
13 KiB
Python

#!/usr/bin/env python3
"""
Minecraft whitelist web app — lightweight self-service whitelisting.
Sethian Dark theme. minecraft.sethpc.xyz
"""
import html
import json
import os
import re
import socket
import struct
import time
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import parse_qs
PORT = 8099
INVITE_KEY = os.environ.get("INVITE_KEY", "REDACTED_INVITE_KEY")
SERVERS = [
{"name": "Paper AI", "host": "127.0.0.1", "rcon_port": 25577, "rcon_pass": "REDACTED_RCON", "show": True, "address": "sethpc.xyz:25567", "desc": "Full AI stack — pray to God, sudo commands, divine interventions"},
{"name": "Shrink World", "host": "127.0.0.1", "rcon_port": 25576, "rcon_pass": "REDACTED_RCON", "show": True, "address": "sethpc.xyz:25566", "desc": "Survival challenge — border shrinks on death, 5x creepers, pray for help"},
{"name": "Vanilla", "host": "127.0.0.1", "rcon_port": 25575, "rcon_pass": "REDACTED_RCON", "show": False, "address": None, "desc": None},
]
WHITELIST_LOG = "/var/log/mc_whitelist.log"
LOGO_URL = "https://git.sethpc.xyz/Seth/Mortdecai/raw/branch/master/branding/mortdec_ai_rajdhani.png"
def rcon_command(cmd, host, port, password):
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(5)
s.connect((host, port))
def send_packet(req_id, ptype, payload):
data = struct.pack("<ii", req_id, ptype) + payload.encode("utf-8") + b"\x00\x00"
s.sendall(struct.pack("<i", len(data)) + data)
def recv_packet():
raw = s.recv(4)
if len(raw) < 4: return None
length = struct.unpack("<i", raw)[0]
data = s.recv(length)
return data[8:-2].decode("utf-8", errors="replace")
send_packet(1, 3, password)
time.sleep(0.1)
recv_packet()
send_packet(2, 2, cmd)
time.sleep(0.2)
result = recv_packet()
s.close()
return result or ""
except Exception as e:
return f"ERROR: {e}"
def lookup_mojang_uuid(username):
"""Look up a player's real Mojang UUID from the API."""
try:
import urllib.request
url = f"https://api.mojang.com/users/profiles/minecraft/{username}"
req = urllib.request.Request(url, headers={"User-Agent": "MortdecaiWhitelist/1.0"})
resp = urllib.request.urlopen(req, timeout=5)
data = json.loads(resp.read())
raw = data.get("id", "")
if len(raw) == 32:
# Format as UUID with dashes
return f"{raw[:8]}-{raw[8:12]}-{raw[12:16]}-{raw[16:20]}-{raw[20:]}"
return raw
except Exception:
return None
def whitelist_player(username):
"""Whitelist player on all servers, fixing UUIDs to real Mojang UUIDs."""
results = {}
# Get real UUID from Mojang
uuid = lookup_mojang_uuid(username)
for srv in SERVERS:
# RCON whitelist add (creates entry, possibly with wrong offline UUID)
result = rcon_command(f"whitelist add {username}", srv["host"], srv["rcon_port"], srv["rcon_pass"])
results[srv["name"]] = result.strip()
# Fix UUIDs in whitelist.json files directly
if uuid:
WHITELIST_FILES = [
"/opt/paper-ai-25567/whitelist.json",
"/opt/mcsmanager/daemon/data/InstanceData/shrinkborder1234567890abcdef12345/whitelist.json",
"/opt/mcsmanager/daemon/data/InstanceData/d39f55861cb34204a92a18a9e1c78ca6/whitelist.json",
]
for wl_path in WHITELIST_FILES:
try:
with open(wl_path) as f:
wl = json.load(f)
changed = False
for entry in wl:
if entry["name"].lower() == username.lower() and entry["uuid"] != uuid:
entry["uuid"] = uuid
entry["name"] = username
changed = True
if changed:
with open(wl_path, "w") as f:
json.dump(wl, f, indent=2)
except Exception:
pass
# Reload whitelists
for srv in SERVERS:
try:
rcon_command("whitelist reload", srv["host"], srv["rcon_port"], srv["rcon_pass"])
except Exception:
pass
return results
def is_valid_username(name):
return bool(re.match(r'^[a-zA-Z0-9_]{3,16}$', name))
PAGE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mortdecai — Minecraft AI</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #1a1a1a; color: #e0e0e0; min-height: 100vh; }}
a {{ color: #D35400; text-decoration: none; }}
a:hover {{ color: #e65c00; }}
.header {{ background: #000; padding: 1rem; text-align: center; border-bottom: 2px solid #D35400; }}
.header img {{ height: 40px; vertical-align: middle; border: none; }}
.header span {{ font-size: 1.2rem; font-weight: 700; color: #D35400; margin-left: 0.5rem; vertical-align: middle; display: none; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 1.5rem; }}
.hero {{ text-align: center; margin: 2rem 0 1.5rem; }}
.hero h1 {{ font-size: 1.6rem; color: #D35400; margin-bottom: 0.5rem; }}
.hero p {{ color: #ccc; font-size: 0.95rem; line-height: 1.5; }}
.mission {{ background: #252525; border: 1px solid #333; border-radius: 8px;
padding: 1.2rem; margin: 1.5rem 0; }}
.mission h2 {{ color: #D35400; font-size: 1rem; margin-bottom: 0.5rem; }}
.mission p {{ color: #ccc; font-size: 0.85rem; line-height: 1.5; margin-bottom: 0.5rem; }}
.mission ul {{ color: #ccc; font-size: 0.85rem; line-height: 1.6; padding-left: 1.2rem; }}
.mission code {{ background: #333; padding: 0.15rem 0.4rem; border-radius: 3px; color: #D35400; font-size: 0.85rem; }}
.card {{ background: #252525; border: 1px solid #333; border-radius: 8px;
padding: 1.5rem; margin: 1.5rem 0; }}
.card h2 {{ color: #D35400; font-size: 1.1rem; margin-bottom: 1rem; text-align: center; }}
label {{ display: block; font-size: 0.85rem; color: #aaa; margin-bottom: 0.3rem; margin-top: 1rem; }}
input {{ width: 100%; padding: 0.6rem; border-radius: 6px; border: 1px solid #444;
background: #2a2a2a; color: #fff; font-size: 1rem; }}
input:focus {{ outline: none; border-color: #D35400; box-shadow: 0 0 5px rgba(211,84,0,0.5); }}
button {{ width: 100%; padding: 0.7rem; border-radius: 6px; border: none;
background: #D35400; color: #fff; font-size: 1rem; font-weight: 600;
cursor: pointer; margin-top: 1.5rem; }}
button:hover {{ background: #e65c00; }}
.error {{ color: #ff6b6b; font-size: 0.85rem; margin-top: 0.5rem; text-align: center; }}
.success {{ background: #1a3a1a; border: 1px solid #2d7a2d; border-radius: 8px;
padding: 1.2rem; margin-top: 1.5rem; }}
.success h2 {{ color: #4caf50; font-size: 1.1rem; margin-bottom: 0.5rem; }}
.server {{ background: #252525; border: 1px solid #333; border-left: 3px solid #D35400;
border-radius: 6px; padding: 0.8rem; margin-top: 0.8rem; }}
.server .name {{ color: #D35400; font-weight: 600; font-size: 0.95rem; }}
.server .addr {{ font-family: monospace; color: #fff; font-size: 1.1rem; margin: 0.3rem 0;
background: #1a1a1a; padding: 0.3rem 0.6rem; border-radius: 4px; display: inline-block; }}
.server .desc {{ color: #999; font-size: 0.8rem; margin-top: 0.3rem; }}
.commands {{ background: #252525; border: 1px solid #333; border-radius: 8px;
padding: 1rem; margin-top: 1rem; }}
.commands h3 {{ color: #D35400; font-size: 0.9rem; margin-bottom: 0.5rem; }}
.cmd {{ font-family: monospace; background: #1a1a1a; padding: 0.2rem 0.5rem;
border-radius: 3px; color: #D35400; }}
.commands p {{ font-size: 0.8rem; color: #bbb; margin-bottom: 0.3rem; }}
.footer {{ text-align: center; color: #555; font-size: 0.75rem; margin: 2rem 0 1rem; }}
</style>
</head>
<body>
<div class="header">
<img src="{logo}" alt="logo" class="noborder">
<span>mortdec.ai</span>
</div>
<div class="container">
<div class="hero">
<h1>Mortdecai</h1>
<p>An AI runs on this server that listens to in-game chat and does things in the world based on what you say.</p>
</div>
<div class="mission">
<h2>What Is This?</h2>
<p>There's an AI character playing <strong>God</strong> on the server. It runs on local hardware — no cloud, no OpenAI — using a small open-source model we're actively training.</p>
<p>Every interaction you have gets logged as training data to improve the model. The more you play, the smarter it gets.</p>
<h2 style="margin-top: 0.8rem;">What Can You Do?</h2>
<ul>
<li><code>pray &lt;message&gt;</code> — Talk to God. Pray for items, smite your enemies, or say something offensive and get punished.</li>
<li><code>sudo &lt;request&gt;</code> — Natural language commands. "sudo give me a diamond sword" just works.</li>
<li><code>bug_log &lt;description&gt;</code> — Report when something goes wrong. Helps us fix the AI.</li>
</ul>
<h2 style="margin-top: 0.8rem;">What We Need</h2>
<p>Try to break it. Ask for weird things. Confuse it. Phrase things in ways nobody would expect. Every interaction — good or bad — makes the model better.</p>
</div>
{content}
<div class="footer">
Java Edition 1.21.x &middot; Whitelisted &middot; Hosted on a homelab
</div>
</div>
</body>
</html>"""
FORM = """
<div class="card">
<h2>Join the Server</h2>
<form method="POST">
<label>Minecraft Java Edition Username</label>
<input name="username" placeholder="Your username" required autofocus>
<label>Invite Key</label>
<input name="key" type="password" placeholder="Paste the key you were given" required>
<button type="submit">Get Whitelisted</button>
</form>
{error}
</div>
"""
def success_content(username, results):
servers = ""
for srv in SERVERS:
if not srv["show"]:
continue
servers += f"""
<div class="server">
<div class="name">{srv['name']}</div>
<div class="addr">{srv['address']}</div>
<div class="desc">{srv['desc']}</div>
</div>"""
return f"""
<div class="success">
<h2>Welcome, {html.escape(username)}!</h2>
<p style="color:#ccc; font-size:0.9rem;">You're whitelisted. Add these servers in Minecraft:</p>
{servers}
</div>
<div class="commands">
<h3>Quick Start</h3>
<p><span class="cmd">pray lord give me tools</span> — ask God for help</p>
<p><span class="cmd">sudo give me a diamond sword</span> — direct command translation</p>
<p><span class="cmd">sudo set time to day</span> — world commands work too</p>
<p><span class="cmd">bug_log it gave me the wrong item</span> — report issues</p>
</div>"""
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
pass
def do_GET(self):
content = FORM.format(error="")
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(PAGE.format(content=content, logo=LOGO_URL).encode())
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length).decode()
params = parse_qs(body)
username = params.get("username", [""])[0].strip()
key = params.get("key", [""])[0].strip()
if key != INVITE_KEY:
content = FORM.format(error='<p class="error">Invalid invite key.</p>')
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(PAGE.format(content=content, logo=LOGO_URL).encode())
return
if not is_valid_username(username):
content = FORM.format(error='<p class="error">Invalid username. 3-16 characters, letters/numbers/underscore only.</p>')
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(PAGE.format(content=content, logo=LOGO_URL).encode())
return
results = whitelist_player(username)
try:
with open(WHITELIST_LOG, "a") as f:
f.write(json.dumps({"time": time.strftime("%Y-%m-%dT%H:%M:%SZ"), "username": username, "results": results}) + "\n")
except:
pass
content = success_content(username, results)
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(PAGE.format(content=content, logo=LOGO_URL).encode())
if __name__ == "__main__":
print(f"Whitelist app running on port {PORT}")
print(f"Invite key: {INVITE_KEY}")
HTTPServer(("0.0.0.0", PORT), Handler).serve_forever()