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: 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 <player> minecraft:<item_id> <count>\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 <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:
|
||||
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 <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):
|
||||
"""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()
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user