Add pluggable parser system, perception loop, and project infrastructure

Phase 0 (housekeeping):
- Add .gitignore to protect credentials and runtime files
- Track all project source code, docs, and config

Phase 1 (perception loop):
- Implement pluggable parser architecture (BaseMudParser ABC + ParserFactory)
- Add DeadSoulsParser with regex for HP/MP/SP prompt, room descriptions,
  exits (multiple formats), NPC/item detection, and combat events
- Add OutputBuffer for accumulating text between prompts
- Update GameState with SP support and update_from_prompt/room/combat methods
- Rewire main loop to use parser + buffer for real-time game state updates
- Add reconnection logic to TelnetClient (retry with backoff)
- Remove old TextParser (replaced by pluggable system)
- Add mud_type config field for selecting parser at runtime
- Add comprehensive test suite (50 tests, all passing)
This commit is contained in:
truetype committed 2026-02-08 19:08:59 +01:00
1 parent 0f370ec089
commit f85c27a742
42 files changed
+3490 -134

No files matched your search

+221
View File
@@ -0,0 +1,221 @@
"""Main application entry point"""
import asyncio
import argparse
import sys
from pathlib import Path
# Add src to path
sys.path.insert(0, str(Path(__file__).parent))
from core.telnet_client import TelnetClient
from core.ansi_parser import ANSIParser
from core.game_state import GameState
from core.parsers import ParserFactory, OutputBuffer, EventType
from config.settings import load_config
from utils.logger import setup_logger
from utils.human_behavior import HumanBehavior
logger = None
async def main_loop(config):
"""Main game loop"""
global logger
# Initialize components
telnet = TelnetClient(config.mud.host, config.mud.port)
ansi_parser = ANSIParser()
game_state = GameState()
human_behavior = HumanBehavior(
config.behavior.reaction_time_min_ms,
config.behavior.reaction_time_max_ms,
config.behavior.pause_probability,
)
# Create MUD-specific parser via factory
mud_parser = ParserFactory.create(config.mud.mud_type)
output_buffer = OutputBuffer(mud_parser)
logger.info(
"Parser: %s (mud_type=%s)",
type(mud_parser).__name__,
config.mud.mud_type,
)
# Connect to MUD
logger.info("Connecting to %s:%s", config.mud.host, config.mud.port)
if not await telnet.connect():
logger.error("Failed to connect to MUD server")
return
# Login state machine
login_state = "username" # username -> password -> logged_in
# ------------------------------------------------------------------
# Receive callback — called for every chunk from the server
# ------------------------------------------------------------------
async def on_receive(data: str):
nonlocal login_state
# Strip ANSI codes
clean_text = ansi_parser.strip_ansi(data)
logger.debug("Received: %s", clean_text.strip()[:200])
# ----- Handle login sequence -----
if login_state == "username":
if "login" in clean_text.lower() or "name" in clean_text.lower():
logger.info("Sending username: %s", config.mud.username)
await human_behavior.delay()
await telnet.send(config.mud.username)
login_state = "password"
return
elif login_state == "password":
if "password" in clean_text.lower():
logger.info("Sending password")
await human_behavior.delay()
await telnet.send(config.mud.password)
login_state = "logged_in"
return
# Handle reconnect / take over
if "take over" in clean_text.lower() or "copy" in clean_text.lower():
logger.info("Taking over existing session")
await human_behavior.delay()
await telnet.send("y")
login_state = "logged_in"
return
# ----- Game logic (only after login) -----
if login_state != "logged_in":
return
# Feed text into the output buffer; get back complete blocks
blocks = output_buffer.feed(clean_text)
for block in blocks:
# Parse the block into structured events
events = mud_parser.parse_block(block)
for event in events:
if event.event_type == EventType.PROMPT and event.prompt:
game_state.update_from_prompt(event.prompt)
logger.info(
"Status: HP=%d/%d MP=%d/%d SP=%d/%d",
event.prompt.hp or 0,
event.prompt.max_hp or 0,
event.prompt.mp or 0,
event.prompt.max_mp or 0,
event.prompt.sp or 0,
event.prompt.max_sp or 0,
)
elif event.event_type == EventType.ROOM and event.room:
game_state.update_room(event.room)
logger.info(
"Room: '%s' | Exits: %s | NPCs: %s | Items: %s",
event.room.title,
event.room.exits,
event.room.npcs,
event.room.items,
)
elif event.event_type == EventType.COMBAT and event.combat:
game_state.update_combat(event.combat)
logger.info(
"Combat: %s -> %s (%s, dmg=%s)",
event.combat.attacker,
event.combat.target,
event.combat.action,
event.combat.damage,
)
telnet.set_receive_callback(on_receive)
# Start receive loop
receive_task = asyncio.create_task(telnet.receive_loop())
try:
# Main game loop
logger.info("Starting main game loop")
# Keep connection alive
last_keep_alive = asyncio.get_running_loop().time()
while telnet.connected:
current_time = asyncio.get_running_loop().time()
# Send keep-alive if logged in
if login_state == "logged_in":
if current_time - last_keep_alive >= config.mud.keep_alive_interval:
logger.debug(
"Sending keep-alive: '%s'", config.mud.keep_alive_command
)
await telnet.send(config.mud.keep_alive_command)
last_keep_alive = current_time
await asyncio.sleep(1.0)
except KeyboardInterrupt:
logger.info("Shutting down...")
finally:
# Flush any remaining buffered text
remaining = output_buffer.flush()
if remaining:
events = mud_parser.parse_block(remaining)
for event in events:
if event.event_type == EventType.ROOM and event.room:
game_state.update_room(event.room)
await telnet.disconnect()
receive_task.cancel()
def main():
"""Entry point"""
global logger
# Parse arguments
parser = argparse.ArgumentParser(description="MUD AI Player")
parser.add_argument(
"--config",
default="config.yaml",
help="Path to configuration file",
)
args = parser.parse_args()
# Load configuration
try:
config = load_config(args.config)
except FileNotFoundError:
print(f"Error: Configuration file '{args.config}' not found")
print("Please create a config.yaml file (see examples/config.example.yaml)")
sys.exit(1)
except Exception as e:
print(f"Error loading configuration: {e}")
sys.exit(1)
# Setup logging
logger = setup_logger(
level=config.logging.level,
log_file=config.logging.file,
console=config.logging.console,
max_file_size_mb=config.logging.max_file_size_mb,
backup_count=config.logging.backup_count,
)
logger.info("=" * 60)
logger.info("MUD AI Player Starting")
logger.info("=" * 60)
# Run main loop
try:
asyncio.run(main_loop(config))
except Exception as e:
logger.exception("Fatal error: %s", e)
sys.exit(1)
if __name__ == "__main__":
main()