diff --git a/mc_aigod.py b/mc_aigod.py index a8d31d7..c08dac5 100644 --- a/mc_aigod.py +++ b/mc_aigod.py @@ -63,6 +63,13 @@ recent_log: deque = deque() # entries: (timestamp_float, str) PRAYER_MEMORY_SIZE = 10 prayer_memory: deque = deque() # entries: (player, prayer, god_message) +# First-login benevolence memory — players already blessed on first join +first_login_seen = set() + +# Sudo translator memory — last N sudo translations/executions +SUDO_HISTORY_SIZE = 10 +sudo_history: deque = deque() # entries: (ts, player, prompt, translated_cmds, executed_cmds) + _memory_lock = threading.Lock() @@ -130,6 +137,53 @@ def add_prayer_memory(player: str, prayer: str, god_message: str, config=None): save_prayer_memory(config) +def _first_login_path(config) -> str: + return config.get( + "first_login_path", + "/opt/mcsmanager/daemon/data/InstanceData/shrinkborder1234567890abcdef12345/aigod_first_login_seen.json" + ) + + +def load_first_login_seen(config): + """Load first-login blessing memory from disk.""" + try: + path = _first_login_path(config) + with open(path) as f: + data = json.load(f) + with _memory_lock: + first_login_seen.clear() + for name in data: + first_login_seen.add(str(name)) + log.info(f"First-login memory loaded ({len(data)} players from {path})") + except FileNotFoundError: + log.info("No first-login memory file found — starting fresh.") + except Exception as e: + log.warning(f"Could not load first-login memory: {e}") + + +def save_first_login_seen(config): + """Persist first-login blessing memory to disk.""" + try: + path = _first_login_path(config) + with _memory_lock: + data = sorted(first_login_seen) + with open(path, 'w') as f: + json.dump(data, f) + except Exception as e: + log.warning(f"Could not save first-login memory: {e}") + + +def has_first_login_seen(player: str) -> bool: + with _memory_lock: + return player in first_login_seen + + +def mark_first_login_seen(player: str, config): + with _memory_lock: + first_login_seen.add(player) + save_first_login_seen(config) + + def get_log_context_block() -> str: """Return recent server events as a formatted string for the LLM.""" with _memory_lock: @@ -161,6 +215,35 @@ def get_prayer_history_messages() -> list: messages.append({"role": "assistant", "content": f'{{"message": "{god_msg}", "commands": []}}'}) return messages + +def add_sudo_history(player: str, prompt: str, translated_cmds: list, executed_cmds: list): + """Record a sudo translation/execution so future sudo requests can reference it.""" + with _memory_lock: + sudo_history.append((time.time(), player, prompt[:220], translated_cmds[:6], executed_cmds[:6])) + while len(sudo_history) > SUDO_HISTORY_SIZE: + sudo_history.popleft() + + +def get_sudo_history_block() -> str: + """Return last N sudo commands/translations as context for translator model.""" + with _memory_lock: + entries = list(sudo_history) + if not entries: + return "" + now = time.time() + lines = [] + for ts, player, prompt, translated, executed in entries: + mins_ago = int((now - ts) / 60) + tlabel = f"{mins_ago}m ago" + trans = " | ".join(translated) if translated else "(none)" + execd = " | ".join(executed) if executed else "(none)" + lines.append( + f" [{tlabel}] {player} asked: {prompt}" + f"\n translated: {trans}" + f"\n executed: {execd}" + ) + return "\n=== LAST 10 SUDO ACTIONS ===\n" + "\n".join(lines) + "\n" + # --------------------------------------------------------------------------- # RCON # --------------------------------------------------------------------------- @@ -616,11 +699,27 @@ SUDO_COMMANDS_SYSTEM_PROMPT = ( "- Use commands from this whitelist only: give, effect, xp, tp, time, weather, execute, kill, summon, tellraw, worldborder.\n" "- If the request cannot be mapped safely, return commands: [].\n" "- If player says 'me' or 'my', target the requesting player.\n" + "- You will receive LAST 10 SUDO ACTIONS. Use them for continuity and corrections when the player says previous output was wrong.\n" "- For give syntax: give minecraft: \n" "- Count is last. Namespace minecraft: is required.\n" "- Return commands only. No commentary.\n" ) +FIRST_LOGIN_BENEVOLENCE_PROMPT = ( + "You are generating FIRST-LOGIN benevolence actions for a Minecraft server.\n" + "This is a celebratory blessing event when a player joins for the first time.\n\n" + "Respond ONLY with valid JSON:\n" + "{\"commands\": [\"cmd1\", \"cmd2\", \"cmd3\"]}\n\n" + "Rules:\n" + "- MUST output MULTIPLE commands (at least 2).\n" + "- Actions should benefit the joining player directly or indirectly.\n" + "- You may include dramatic/world flavor (daylight, fireworks, clear weather).\n" + "- Avoid cruelty in this mode. This is benevolence mode.\n" + "- If you include kill commands against players, kill at most one player total.\n" + "- For give syntax: give minecraft: (count LAST).\n" + "- Use only whitelisted command families: give, effect, xp, tp, time, weather, execute, kill, summon, tellraw, worldborder.\n" +) + def build_message_system_prompt(config) -> str: base = ( "You are God in a Minecraft server. You are benevolent but just. " @@ -841,6 +940,9 @@ def fix_give_command(cmd: str) -> str: "wood": "oak_log", "logs": "oak_log", "log": "oak_log", + "door": "oak_door", + "doors": "oak_door", + "wooden_door": "oak_door", "planks": "oak_planks", "plank": "oak_planks", "food": "bread", @@ -868,10 +970,26 @@ def fix_give_command(cmd: str) -> str: return cmd + +def fix_effect_command(cmd: str) -> str: + """ + Correct common malformed effect syntax: + - effect + -> effect give + """ + m = re.match(r'^effect\s+(\w+)\s+(minecraft:\w+)\s+(\d+)\s+(\d+)$', cmd) + if m: + player, eff, dur, amp = m.groups() + fixed = f"effect give {player} {eff} {dur} {amp}" + log.warning(f"Fixed malformed effect: '{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) + resolved = fix_effect_command(resolved) if not any(resolved.startswith(p) for p in SAFE_PREFIXES): log.warning(f"Command blocked (unknown prefix): {resolved}") return resolved, False @@ -944,6 +1062,111 @@ def execute_response(response, context, config, praying_player=None): elif "clear" in resolved: config["_weather_state"] = "clear" time.sleep(0.3) + +def _limit_player_kills(commands: list, online_players: list) -> list: + """Allow at most one player-kill command in a command list.""" + out = [] + player_kills = 0 + for cmd in commands: + m = re.search(r'\bkill\s+(\w+)\b', cmd) + if m and m.group(1) in online_players: + if player_kills >= 1: + continue + player_kills += 1 + out.append(cmd) + return out + + +def process_first_login_benevolence(player, config): + """ + On a player's first observed login, perform a random benevolent act. + Uses command model for actions and message model for flavor text. + """ + if not config.get("first_login_benevolence_enabled", True): + return + if has_first_login_seen(player): + return + + # Mark first to avoid duplicate firing on reconnect storms + mark_first_login_seen(player, config) + + try: + context = get_server_context(config) + except Exception as e: + log.warning(f"First-login benevolence: could not fetch context: {e}") + context = { + "online_players": [player], + "player_details": {}, + "time_of_day": "unknown", + "weather": "unknown", + "world_border": None, + "scoreboards": {}, + } + + user_msg = ( + f"Player first login event: {player}\n" + + _build_context_block(context, extras=get_log_context_block()) + + f"\nGenerate a benevolent first-login blessing for {player}." + ) + + command_model = config.get("command_model", config["model"]) + message_model = config.get("model") + + try: + cmd_content = _llm_call( + model=command_model, + system=FIRST_LOGIN_BENEVOLENCE_PROMPT, + user=user_msg, + config=config, + fmt="json", + temperature=0.4, + max_tokens=220, + ) + parsed = _parse_llm_json(cmd_content) + commands = (parsed.get("commands") or []) + except Exception as e: + log.error(f"First-login benevolence commands call failed: {e}") + commands = [] + + commands = _limit_player_kills(commands, context.get("online_players", [])) + + # Ensure there are multiple beneficial commands even if model under-produces + if len(commands) < 2: + commands = [ + f"effect give {player} minecraft:regeneration 120 1", + f"give {player} minecraft:bread 16", + f"execute at {player} run summon minecraft:firework_rocket ~ ~1 ~", + ] + + # Optional message + try: + msg_user = ( + f"First login blessing for {player}.\n" + f"Commands chosen: {commands}\n" + "Write a benevolent divine proclamation to all players." + ) + message = _llm_call( + model=message_model, + system=build_message_system_prompt(config), + user=msg_user, + config=config, + fmt=None, + temperature=0.85, + max_tokens=min(220, int(config.get("max_tokens", 600))), + ).strip() + except Exception: + message = f"A blessing descends upon {player} for their first steps in this world." + + max_cmds = int(config.get("first_login_benevolence_max_commands", 10)) + execute_response( + {"message": message, "commands": commands[:max_cmds]}, + context, + config, + praying_player=player, + ) + + log.info(f"First-login benevolence executed for {player}: {commands[:max_cmds]}") + def process_sudo(player, prompt, config): """ sudo translator mode: @@ -975,6 +1198,7 @@ def process_sudo(player, prompt, config): f"Requesting player: {player}\n" f"Online players: {', '.join(online) or 'none'}\n" f"Natural language request: {prompt}\n" + + get_sudo_history_block() ) command_model = config.get("command_model", config["model"]) @@ -1002,6 +1226,7 @@ def process_sudo(player, prompt, config): commands = commands[:max_cmds] if not commands: + add_sudo_history(player, prompt, [], []) rcon( f'tellraw {player} {{"text":"[SUDO] No safe command generated.","color":"yellow"}}', config["rcon_host"], config["rcon_port"], config["rcon_password"] @@ -1015,6 +1240,7 @@ def process_sudo(player, prompt, config): config["rcon_host"], config["rcon_port"], config["rcon_password"] ) + executed = [] for cmd in commands: resolved, is_safe = validate_command(cmd, online, player) if not is_safe: @@ -1022,8 +1248,11 @@ def process_sudo(player, prompt, config): log.info(f"SUDO execute: {resolved}") result = rcon(resolved, config["rcon_host"], config["rcon_port"], config["rcon_password"]) log.info(f"SUDO result: {result!r}") + executed.append(resolved) time.sleep(0.2) + add_sudo_history(player, prompt, commands, executed) + # --------------------------------------------------------------------------- # Prayer handler # --------------------------------------------------------------------------- @@ -1201,6 +1430,7 @@ def main(): log.info(f"RCON: {config['rcon_host']}:{config['rcon_port']}") load_prayer_memory(config) + load_first_login_seen(config) cooldowns = {} @@ -1271,5 +1501,11 @@ def main(): except Exception as e: log.error(f"Error sending login notice to {player}: {e}", exc_info=True) + # First-login benevolence (once per player) + try: + process_first_login_benevolence(player, config) + except Exception as e: + log.error(f"Error running first-login benevolence for {player}: {e}", exc_info=True) + if __name__ == '__main__': main() diff --git a/mc_aigod_shrink.json b/mc_aigod_shrink.json index 159d6fa..b40eced 100644 --- a/mc_aigod_shrink.json +++ b/mc_aigod_shrink.json @@ -17,6 +17,9 @@ "sudo_enabled": true, "sudo_user": "slingshooter08", "sudo_max_commands": 3, + "first_login_benevolence_enabled": true, + "first_login_benevolence_max_commands": 10, + "first_login_path": "/opt/mcsmanager/daemon/data/InstanceData/shrinkborder1234567890abcdef12345/aigod_first_login_seen.json", "god_lore": "This is the shrink-world server. The world border started at 1000x1000 and shrinks by 1 block each time a player dies, alternating N/S and E/W sides. There are more creepers than normal (5x spawn rate). The goal is survival. Players who die too often will eventually have nowhere left to go.", "memory_path": "/opt/mcsmanager/daemon/data/InstanceData/shrinkborder1234567890abcdef12345/aigod_memory.json" }