"""Mob AI — game loop processor for mob combat decisions.""" import random import time from mudlib.combat.encounter import CombatState from mudlib.combat.engine import get_encounter from mudlib.combat.moves import CombatMove from mudlib.mobs import mobs # Seconds between mob actions (gives player time to read and react) MOB_ACTION_COOLDOWN = 1.0 async def process_mobs(combat_moves: dict[str, CombatMove]) -> None: """Called once per game loop tick. Handles mob combat decisions.""" now = time.monotonic() for mob in mobs[:]: # copy list in case of modification if not mob.alive: continue encounter = get_encounter(mob) if encounter is None: continue if now < mob.next_action_at: continue # Determine if mob is attacker or defender in this encounter mob_is_defender = encounter.defender is mob # Defense AI: react during TELEGRAPH or WINDOW when mob is defender if mob_is_defender and encounter.state in ( CombatState.TELEGRAPH, CombatState.WINDOW, ): _try_defend(mob, encounter, combat_moves, now) continue # Attack AI: act when encounter is IDLE if encounter.state == CombatState.IDLE: _try_attack(mob, encounter, combat_moves, now) def _try_attack(mob, encounter, combat_moves, now): """Attempt to pick and execute an attack move.""" # Filter to affordable attack moves from mob's move list affordable = [] for move_name in mob.moves: move = combat_moves.get(move_name) if ( move is not None and move.move_type == "attack" and mob.stamina >= move.stamina_cost ): affordable.append(move) if not affordable: return move = random.choice(affordable) # Swap roles if mob is currently the defender if encounter.defender is mob: encounter.attacker, encounter.defender = ( encounter.defender, encounter.attacker, ) encounter.attack(move) mob.next_action_at = now + MOB_ACTION_COOLDOWN # Send telegraph to the player (the other participant) # This is fire-and-forget since mob.send is a no-op def _try_defend(mob, encounter, combat_moves, now): """Attempt to pick and queue a defense move.""" # Don't double-defend if encounter.pending_defense is not None: return # Filter to affordable defense moves from mob's move list affordable_defenses = [] for move_name in mob.moves: move = combat_moves.get(move_name) if ( move is not None and move.move_type == "defense" and mob.stamina >= move.stamina_cost ): affordable_defenses.append(move) if not affordable_defenses: return # 40% chance to pick a correct counter, 60% random chosen = None current_move = encounter.current_move if current_move and random.random() < 0.4: # Try to find a correct counter counters = [] for defense in affordable_defenses: if defense.name in current_move.countered_by: counters.append(defense) if counters: chosen = random.choice(counters) if chosen is None: chosen = random.choice(affordable_defenses) encounter.defend(chosen) mob.stamina -= chosen.stamina_cost