Add first-login benevolence + sudo history context

- First-login benevolence hook on player join (once per player, persisted)
- New first-login memory file and config keys
- Benevolence command prompt enforces multi-command blessing behavior
- Limit first-login player-kill commands to at most one
- Add sudo history buffer (last 10 actions) and inject into sudo translator context
- Add effect syntax auto-fix for malformed 'effect <player> ...' outputs
This commit is contained in:
2026-03-15 20:20:33 -04:00
parent 52d288406a
commit d7d69d37af
2 changed files with 239 additions and 0 deletions
+236
View File
@@ -63,6 +63,13 @@ recent_log: deque = deque() # entries: (timestamp_float, str)
PRAYER_MEMORY_SIZE = 10 PRAYER_MEMORY_SIZE = 10
prayer_memory: deque = deque() # entries: (player, prayer, god_message) 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() _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) 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: def get_log_context_block() -> str:
"""Return recent server events as a formatted string for the LLM.""" """Return recent server events as a formatted string for the LLM."""
with _memory_lock: with _memory_lock:
@@ -161,6 +215,35 @@ def get_prayer_history_messages() -> list:
messages.append({"role": "assistant", "content": f'{{"message": "{god_msg}", "commands": []}}'}) messages.append({"role": "assistant", "content": f'{{"message": "{god_msg}", "commands": []}}'})
return messages 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 # 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" "- 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 the request cannot be mapped safely, return commands: [].\n"
"- If player says 'me' or 'my', target the requesting player.\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 <player> minecraft:<item_id> <count>\n" "- For give syntax: give <player> minecraft:<item_id> <count>\n"
"- Count is last. Namespace minecraft: is required.\n" "- Count is last. Namespace minecraft: is required.\n"
"- Return commands only. No commentary.\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 <player> minecraft:<item_id> <count> (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: def build_message_system_prompt(config) -> str:
base = ( base = (
"You are God in a Minecraft server. You are benevolent but just. " "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", "wood": "oak_log",
"logs": "oak_log", "logs": "oak_log",
"log": "oak_log", "log": "oak_log",
"door": "oak_door",
"doors": "oak_door",
"wooden_door": "oak_door",
"planks": "oak_planks", "planks": "oak_planks",
"plank": "oak_planks", "plank": "oak_planks",
"food": "bread", "food": "bread",
@@ -868,10 +970,26 @@ def fix_give_command(cmd: str) -> str:
return cmd return cmd
def fix_effect_command(cmd: str) -> str:
"""
Correct common malformed effect syntax:
- effect <player> <effect> <duration> <amplifier>
-> effect give <player> <effect> <duration> <amplifier>
"""
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): def validate_command(cmd, online_players, fallback_player):
"""Replace placeholders, auto-fix common give syntax errors, check safe prefix.""" """Replace placeholders, auto-fix common give syntax errors, check safe prefix."""
resolved = cmd.replace("{player}", fallback_player).replace("{target}", fallback_player) resolved = cmd.replace("{player}", fallback_player).replace("{target}", fallback_player)
resolved = fix_give_command(resolved) resolved = fix_give_command(resolved)
resolved = fix_effect_command(resolved)
if not any(resolved.startswith(p) for p in SAFE_PREFIXES): if not any(resolved.startswith(p) for p in SAFE_PREFIXES):
log.warning(f"Command blocked (unknown prefix): {resolved}") log.warning(f"Command blocked (unknown prefix): {resolved}")
return resolved, False return resolved, False
@@ -944,6 +1062,111 @@ def execute_response(response, context, config, praying_player=None):
elif "clear" in resolved: config["_weather_state"] = "clear" elif "clear" in resolved: config["_weather_state"] = "clear"
time.sleep(0.3) 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): def process_sudo(player, prompt, config):
""" """
sudo translator mode: sudo translator mode:
@@ -975,6 +1198,7 @@ def process_sudo(player, prompt, config):
f"Requesting player: {player}\n" f"Requesting player: {player}\n"
f"Online players: {', '.join(online) or 'none'}\n" f"Online players: {', '.join(online) or 'none'}\n"
f"Natural language request: {prompt}\n" f"Natural language request: {prompt}\n"
+ get_sudo_history_block()
) )
command_model = config.get("command_model", config["model"]) command_model = config.get("command_model", config["model"])
@@ -1002,6 +1226,7 @@ def process_sudo(player, prompt, config):
commands = commands[:max_cmds] commands = commands[:max_cmds]
if not commands: if not commands:
add_sudo_history(player, prompt, [], [])
rcon( rcon(
f'tellraw {player} {{"text":"[SUDO] No safe command generated.","color":"yellow"}}', f'tellraw {player} {{"text":"[SUDO] No safe command generated.","color":"yellow"}}',
config["rcon_host"], config["rcon_port"], config["rcon_password"] 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"] config["rcon_host"], config["rcon_port"], config["rcon_password"]
) )
executed = []
for cmd in commands: for cmd in commands:
resolved, is_safe = validate_command(cmd, online, player) resolved, is_safe = validate_command(cmd, online, player)
if not is_safe: if not is_safe:
@@ -1022,8 +1248,11 @@ def process_sudo(player, prompt, config):
log.info(f"SUDO execute: {resolved}") log.info(f"SUDO execute: {resolved}")
result = rcon(resolved, config["rcon_host"], config["rcon_port"], config["rcon_password"]) result = rcon(resolved, config["rcon_host"], config["rcon_port"], config["rcon_password"])
log.info(f"SUDO result: {result!r}") log.info(f"SUDO result: {result!r}")
executed.append(resolved)
time.sleep(0.2) time.sleep(0.2)
add_sudo_history(player, prompt, commands, executed)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Prayer handler # Prayer handler
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1201,6 +1430,7 @@ def main():
log.info(f"RCON: {config['rcon_host']}:{config['rcon_port']}") log.info(f"RCON: {config['rcon_host']}:{config['rcon_port']}")
load_prayer_memory(config) load_prayer_memory(config)
load_first_login_seen(config)
cooldowns = {} cooldowns = {}
@@ -1271,5 +1501,11 @@ def main():
except Exception as e: except Exception as e:
log.error(f"Error sending login notice to {player}: {e}", exc_info=True) 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__': if __name__ == '__main__':
main() main()
+3
View File
@@ -17,6 +17,9 @@
"sudo_enabled": true, "sudo_enabled": true,
"sudo_user": "slingshooter08", "sudo_user": "slingshooter08",
"sudo_max_commands": 3, "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.", "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" "memory_path": "/opt/mcsmanager/daemon/data/InstanceData/shrinkborder1234567890abcdef12345/aigod_memory.json"
} }