Phase 5: process_mobs() runs each tick, handling mob attack and defense decisions. Mobs pick random attacks from their move list when IDLE, swap roles if needed, and attempt defense during TELEGRAPH/WINDOW with a 40% chance of correct counter. 1-second cooldown between actions. Training dummies with empty moves never fight back.
114 lines
3.4 KiB
Python
114 lines
3.4 KiB
Python
"""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
|