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:
1 parent
0f370ec089
commit
f85c27a742
42 files changed
+3490
-134
No files matched your search
+221
@@ -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()
|
||||
Reference in new issue
Block a user