8ee8be9cc0
- 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
162 lines
5.9 KiB
Python
162 lines
5.9 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Shrink-world server watcher:
|
|
- Gives full kit to every player on FIRST login only
|
|
- Sends stats on join, on death, and every hour
|
|
- Tracks total deaths and current world border size
|
|
|
|
Deployed to: /usr/local/bin/shrink_godkit.py on CT 644
|
|
Managed by: mc-shrink-kit.service
|
|
"""
|
|
import socket, struct, time, sys, subprocess, re, threading, json, os
|
|
|
|
LOG = '/opt/mcsmanager/daemon/data/InstanceData/shrinkborder1234567890abcdef12345/logs/latest.log'
|
|
RCON_HOST = '127.0.0.1'
|
|
RCON_PORT = 25576
|
|
RCON_PASS = 'REDACTED_RCON'
|
|
KIT_RECORD = '/opt/mcsmanager/daemon/data/InstanceData/shrinkborder1234567890abcdef12345/kit_given.json'
|
|
|
|
total_deaths = 0
|
|
|
|
def rcon(cmd):
|
|
try:
|
|
s = socket.socket()
|
|
s.settimeout(5)
|
|
s.connect((RCON_HOST, RCON_PORT))
|
|
def pkt(i, t, p):
|
|
p = p.encode() + b'\x00\x00'
|
|
return struct.pack('<iii', len(p)+8, i, t) + p
|
|
s.sendall(pkt(1, 3, RCON_PASS))
|
|
time.sleep(0.2)
|
|
s.recv(4096)
|
|
s.sendall(pkt(2, 2, cmd))
|
|
time.sleep(0.2)
|
|
r = s.recv(4096)
|
|
s.close()
|
|
return r[12:-2].decode()
|
|
except Exception as e:
|
|
print(f'RCON error: {e}', file=sys.stderr)
|
|
return ''
|
|
|
|
def load_kit_record():
|
|
if os.path.exists(KIT_RECORD):
|
|
with open(KIT_RECORD) as f:
|
|
return set(json.load(f))
|
|
return set()
|
|
|
|
def save_kit_record(players):
|
|
with open(KIT_RECORD, 'w') as f:
|
|
json.dump(list(players), f)
|
|
|
|
kit_given = load_kit_record()
|
|
|
|
def get_border_size():
|
|
try:
|
|
r = rcon('worldborder get')
|
|
m = re.search(r'([\d.]+) block', r)
|
|
if m:
|
|
return float(m.group(1))
|
|
except:
|
|
pass
|
|
return None
|
|
|
|
def get_online_players():
|
|
try:
|
|
r = rcon('list')
|
|
m = re.search(r'There are (\d+) of a max of \d+ players online:(.*)', r)
|
|
if m:
|
|
count = int(m.group(1))
|
|
names = [n.strip() for n in m.group(2).split(',') if n.strip()]
|
|
return count, names
|
|
except:
|
|
pass
|
|
return 0, []
|
|
|
|
def broadcast_stats(context=''):
|
|
border = get_border_size()
|
|
count, players = get_online_players()
|
|
border_str = f'{border:.0f}x{border:.0f}' if border else 'unknown'
|
|
player_str = ', '.join(players) if players else 'none'
|
|
|
|
lines = [
|
|
f'tellraw @a ["",{{"text":"--- World Stats ---","color":"gold","bold":true}}]',
|
|
f'tellraw @a ["",{{"text":"Border: ","color":"yellow"}},{{"text":"{border_str} blocks","color":"white"}}]',
|
|
f'tellraw @a ["",{{"text":"Total Deaths: ","color":"yellow"}},{{"text":"{total_deaths}","color":"white"}}]',
|
|
f'tellraw @a ["",{{"text":"Online ({count}): ","color":"yellow"}},{{"text":"{player_str}","color":"white"}}]',
|
|
]
|
|
if context:
|
|
lines.insert(0, f'tellraw @a ["",{{"text":"{context}","color":"aqua","italic":true}}]')
|
|
|
|
for cmd in lines:
|
|
rcon(cmd)
|
|
time.sleep(0.05)
|
|
|
|
def give_kit(player):
|
|
print(f'Giving kit to {player}')
|
|
cmds = [
|
|
f'gamemode survival {player}',
|
|
f'give {player} netherite_helmet[enchantments={{protection:4,unbreaking:3,mending:1,respiration:3,aqua_affinity:1}}]',
|
|
f'give {player} netherite_chestplate[enchantments={{protection:4,unbreaking:3,mending:1}}]',
|
|
f'give {player} netherite_leggings[enchantments={{protection:4,unbreaking:3,mending:1}}]',
|
|
f'give {player} netherite_boots[enchantments={{protection:4,unbreaking:3,mending:1,feather_falling:4,depth_strider:3}}]',
|
|
f'give {player} netherite_sword[enchantments={{sharpness:5,unbreaking:3,mending:1,looting:3,fire_aspect:2,sweeping_edge:3}}]',
|
|
f'give {player} bow[enchantments={{power:5,unbreaking:3,infinity:1,flame:1,punch:2}}]',
|
|
f'give {player} netherite_pickaxe[enchantments={{efficiency:5,unbreaking:3,mending:1,fortune:3}}]',
|
|
f'give {player} netherite_axe[enchantments={{efficiency:5,unbreaking:3,mending:1,sharpness:5}}]',
|
|
f'give {player} arrow 64',
|
|
f'give {player} golden_apple 64',
|
|
f'give {player} totem_of_undying 4',
|
|
f'give {player} ender_pearl 16',
|
|
f'give {player} cooked_beef 64',
|
|
f'tellraw {player} ["",{{"text":"Welcome to the Shrinking World! ","color":"gold","bold":true}},{{"text":"You have been given a full kit. Good luck!","color":"yellow"}}]',
|
|
]
|
|
for cmd in cmds:
|
|
result = rcon(cmd)
|
|
print(f' {result}')
|
|
time.sleep(0.05)
|
|
|
|
def hourly_broadcast():
|
|
while True:
|
|
time.sleep(3600)
|
|
print('Sending hourly stats...')
|
|
broadcast_stats('[ Hourly Update ]')
|
|
|
|
t = threading.Thread(target=hourly_broadcast, daemon=True)
|
|
t.start()
|
|
|
|
proc = subprocess.Popen(['tail', '-F', LOG], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
|
|
|
|
print('Shrink-world watcher started.')
|
|
|
|
for line in (proc.stdout or []):
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
|
|
# Player joined
|
|
join_match = re.search(r'\[Server thread/INFO\].*?(\w+) joined the game', line)
|
|
if join_match:
|
|
player = join_match.group(1)
|
|
print(f'JOIN: {player}')
|
|
time.sleep(1)
|
|
if player.lower() not in kit_given:
|
|
give_kit(player)
|
|
kit_given.add(player.lower())
|
|
save_kit_record(kit_given)
|
|
else:
|
|
print(f' Kit already given to {player}, skipping')
|
|
rcon(f'tellraw {player} ["",{{"text":"Welcome back, {player}!","color":"gold"}}]')
|
|
time.sleep(0.5)
|
|
broadcast_stats(f'{player} joined the game')
|
|
continue
|
|
|
|
# Player died
|
|
death_match = re.search(r'\[Server thread/INFO\]: (\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|walked into a cactus|was poked|was squashed|was struck by lightning)', line)
|
|
if death_match:
|
|
player = death_match.group(1)
|
|
total_deaths += 1
|
|
print(f'DEATH: {player} (total: {total_deaths})')
|
|
time.sleep(0.5)
|
|
broadcast_stats(f'{player} died!')
|
|
continue
|