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:
+236
@@ -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()
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user