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

+8
View File
@@ -0,0 +1,8 @@
"""Test configuration"""
import pytest
import sys
from pathlib import Path
# Add src to path for tests
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
+45
View File
@@ -0,0 +1,45 @@
"""Tests for ANSI parser"""
import pytest
from core.ansi_parser import ANSIParser
def test_strip_ansi():
"""Test ANSI code stripping"""
text_with_ansi = "\x1b[31mRed text\x1b[0m normal text"
clean_text = ANSIParser.strip_ansi(text_with_ansi)
assert clean_text == "Red text normal text"
def test_strip_ansi_no_codes():
"""Test text without ANSI codes"""
text = "Plain text"
clean_text = ANSIParser.strip_ansi(text)
assert clean_text == "Plain text"
def test_strip_ansi_multiple_codes():
"""Test multiple ANSI codes"""
text = "\x1b[1m\x1b[32mBold Green\x1b[0m and \x1b[34mBlue\x1b[0m"
clean_text = ANSIParser.strip_ansi(text)
assert clean_text == "Bold Green and Blue"
def test_strip_ansi_empty():
"""Test empty string"""
assert ANSIParser.strip_ansi("") == ""
def test_strip_ansi_prompt_with_cursor_code():
"""Test stripping cursor movement codes from Dead Souls prompt.
Real example from game log: ``[32;1Hhp: 350/350 mp: 440/440 sp: 431/431 >``
Note: the ``[32;1H`` is a cursor position code, not a standard color code.
The current ANSI_PATTERN only matches ``\\x1b[...m`` sequences, so this
particular code would only be stripped if it starts with \\x1b.
"""
# Standard ANSI color-wrapped prompt
text = "\x1b[32;1mhp: 350/350 mp: 440/440 sp: 431/431 >\x1b[0m"
clean = ANSIParser.strip_ansi(text)
assert "hp: 350/350" in clean
assert "sp: 431/431" in clean
+111
View File
@@ -0,0 +1,111 @@
"""Tests for the OutputBuffer."""
import pytest
from core.parsers.buffer import OutputBuffer
from core.parsers.dead_souls import DeadSoulsParser
@pytest.fixture
def parser():
return DeadSoulsParser()
@pytest.fixture
def buffer(parser):
return OutputBuffer(parser)
class TestOutputBuffer:
def test_no_prompt_buffers_text(self, buffer):
"""Text without a prompt should be buffered, not returned."""
blocks = buffer.feed("You are in a dark room.\nA torch flickers.")
assert blocks == []
assert buffer.pending_lines == 2
def test_prompt_flushes_buffer(self, buffer):
"""When a prompt arrives, the buffered text is flushed."""
buffer.feed("Town Square\n\nYou are in the town square.\n")
blocks = buffer.feed("hp: 350/350 mp: 440/440 sp: 431/431 >")
# Should get 2 blocks: the accumulated room text + the prompt line
assert len(blocks) == 2
assert "Town Square" in blocks[0]
assert "hp:" in blocks[1]
assert buffer.pending_lines == 0
def test_multiple_prompts(self, buffer):
"""Two prompts in one feed should flush twice."""
text = (
"Room A\nhp: 100/100 mp: 50/50 sp: 75/75 >\n"
"Room B\nhp: 100/100 mp: 50/50 sp: 75/75 >\n"
)
blocks = buffer.feed(text)
# Expect: "Room A", prompt, "Room B", prompt
assert len(blocks) == 4
assert "Room A" in blocks[0]
assert "hp:" in blocks[1]
assert "Room B" in blocks[2]
assert "hp:" in blocks[3]
def test_flush_remaining(self, buffer):
"""flush() should return whatever is in the buffer."""
buffer.feed("Some text without prompt\nAnother line")
remaining = buffer.flush()
assert remaining is not None
assert "Some text" in remaining
assert buffer.pending_lines == 0
def test_flush_empty(self, buffer):
"""flush() on empty buffer returns None."""
assert buffer.flush() is None
def test_max_lines_safety(self, parser):
"""Buffer should auto-flush when exceeding max_lines."""
small_buffer = OutputBuffer(parser, max_lines=5)
# Feed 10 lines without a prompt
text = "\n".join(f"line {i}" for i in range(10))
blocks = small_buffer.feed(text)
# Should have flushed at least once due to exceeding 5 lines
assert len(blocks) >= 1
assert small_buffer.pending_lines < 5
def test_prompt_line_is_separate_block(self, buffer):
"""The prompt itself should be its own block, not mixed with room text."""
buffer.feed("Dark Forest\nTrees surround you.\n")
blocks = buffer.feed("hp: 200/300 mp: 100/200 sp: 150/200 >")
# blocks[0] = room text, blocks[1] = prompt
assert len(blocks) == 2
assert "hp:" not in blocks[0]
assert "hp:" in blocks[1]
class TestBufferWithRealGameOutput:
"""Integration-style tests using text patterns from real Dead Souls sessions."""
def test_login_then_room(self, buffer):
"""Simulate the login sequence followed by the first room."""
# Login messages (no prompt)
buffer.feed("Allowing login.\n")
# Then the game sends the prompt
blocks = buffer.feed("hp: 350/350 mp: 440/440 sp: 431/431 >")
assert len(blocks) == 2
assert "Allowing login." in blocks[0]
assert "hp: 350/350" in blocks[1]
def test_room_description_then_prompt(self, buffer):
"""Simulate receiving a room description followed by prompt."""
text = (
"Town Square\n"
"\n"
"You are standing in the town square.\n"
"There is one obvious exit: north.\n"
"A guard is standing here.\n"
"hp: 350/350 mp: 440/440 sp: 431/431 >\n"
)
blocks = buffer.feed(text)
# First block: room text, second: prompt
assert len(blocks) == 2
room_block = blocks[0]
assert "Town Square" in room_block
assert "guard" in room_block
assert "hp:" in blocks[1]
+249
View File
@@ -0,0 +1,249 @@
"""Tests for the Dead Souls LPMUD parser."""
import pytest
from core.parsers.dead_souls import DeadSoulsParser
from core.parsers.base import ParsedPrompt, ParsedRoom, ParsedCombat, EventType
@pytest.fixture
def parser():
return DeadSoulsParser()
# ======================================================================
# Prompt parsing
# ======================================================================
class TestPromptParsing:
def test_standard_prompt(self, parser):
"""Real prompt captured from game log."""
line = "hp: 350/350 mp: 440/440 sp: 431/431 >"
result = parser.parse_prompt(line)
assert result is not None
assert result.hp == 350
assert result.max_hp == 350
assert result.mp == 440
assert result.max_mp == 440
assert result.sp == 431
assert result.max_sp == 431
def test_prompt_with_damage(self, parser):
"""Prompt after taking damage."""
line = "hp: 250/350 mp: 400/440 sp: 431/431 >"
result = parser.parse_prompt(line)
assert result is not None
assert result.hp == 250
assert result.max_hp == 350
assert result.mp == 400
def test_prompt_case_insensitive(self, parser):
"""Prompt matching should be case insensitive."""
line = "HP: 100/200 MP: 50/100 SP: 75/150 >"
result = parser.parse_prompt(line)
assert result is not None
assert result.hp == 100
def test_not_a_prompt(self, parser):
"""Regular text should not parse as prompt."""
assert parser.parse_prompt("You are in a dark room.") is None
assert parser.parse_prompt("") is None
assert parser.parse_prompt("A guard says hello.") is None
def test_is_prompt_line(self, parser):
assert parser.is_prompt_line("hp: 350/350 mp: 440/440 sp: 431/431 >")
assert not parser.is_prompt_line("You look around.")
assert not parser.is_prompt_line("")
# ======================================================================
# Room parsing
# ======================================================================
class TestRoomParsing:
def test_full_room_description(self, parser):
"""Complete room with title, description, exits, NPC, and item."""
text = (
"Town Square\n"
"\n"
"You are standing in the town square of the village. A large fountain\n"
"dominates the center of the square. Roads lead off in several\n"
"directions.\n"
"There are three obvious exits: north, south, and east.\n"
"A town guard is standing here.\n"
"A small potion is here.\n"
)
room = parser.parse_room(text)
assert room is not None
assert room.title == "Town Square"
assert "fountain" in room.description
assert set(room.exits) == {"north", "south", "east"}
assert len(room.npcs) == 1
assert "town guard" in room.npcs[0]
assert len(room.items) == 1
assert "small potion" in room.items[0]
def test_single_exit(self, parser):
"""Room with only one exit."""
text = (
"Dead End\n"
"\n"
"You have reached a dead end.\n"
"There is one obvious exit: south.\n"
)
room = parser.parse_room(text)
assert room is not None
assert room.title == "Dead End"
assert room.exits == ["south"]
def test_two_exits_with_and(self, parser):
"""Room with two exits joined by 'and'."""
text = (
"Narrow Path\n"
"\n"
"A narrow path winds through the trees.\n"
"There are two obvious exits: north and south.\n"
)
room = parser.parse_room(text)
assert room is not None
assert set(room.exits) == {"north", "south"}
def test_bracket_exit_format(self, parser):
"""Exit format: [ Exits: n s w ]"""
text = (
"Market Street\n"
"\n"
"Stalls line both sides of the busy street.\n"
"[ Exits: n s w ]\n"
)
room = parser.parse_room(text)
assert room is not None
assert set(room.exits) == {"n", "s", "w"}
def test_obvious_exits_format(self, parser):
"""Exit format: Obvious exits: north, south"""
text = (
"Forest Clearing\n"
"\n"
"Sunlight filters through the canopy above.\n"
"Obvious exits: north, south\n"
)
room = parser.parse_room(text)
assert room is not None
assert set(room.exits) == {"north", "south"}
def test_multiple_npcs(self, parser):
"""Room with multiple NPCs."""
text = (
"Guard Room\n"
"\n"
"A dimly lit room with weapon racks on the walls.\n"
"There are two obvious exits: north and south.\n"
"A guard captain is standing here.\n"
"A young recruit is standing here.\n"
)
room = parser.parse_room(text)
assert room is not None
assert len(room.npcs) == 2
def test_empty_text_returns_none(self, parser):
"""Empty input should return None."""
assert parser.parse_room("") is None
assert parser.parse_room(" \n \n ") is None
def test_prompt_only_not_a_room(self, parser):
"""A prompt line alone is not a room."""
room = parser.parse_room("hp: 350/350 mp: 440/440 sp: 431/431 >")
# Should return None or a room with no title
assert room is None or room.title is None
def test_is_room_description(self, parser):
"""Heuristic check for room-like text."""
assert parser.is_room_description(
"A room.\nThere is one obvious exit: north."
)
assert not parser.is_room_description("You pick up the sword.")
# ======================================================================
# Combat parsing
# ======================================================================
class TestCombatParsing:
def test_hit_with_damage(self, parser):
result = parser.parse_combat("You hit the goblin for 15 damage.")
assert result is not None
assert result.is_combat is True
assert result.action == "hit"
assert result.damage == 15
assert "goblin" in result.target
def test_miss(self, parser):
result = parser.parse_combat("The goblin misses you.")
assert result is not None
assert result.is_combat is True
assert result.action == "miss"
def test_kill(self, parser):
result = parser.parse_combat("You killed the goblin!")
assert result is not None
assert result.action == "kill"
assert "goblin" in result.target
def test_attack(self, parser):
result = parser.parse_combat("You attack the goblin!")
assert result is not None
assert result.action == "attack"
def test_flee(self, parser):
result = parser.parse_combat("You flee north!")
assert result is not None
assert result.action == "flee"
def test_not_combat(self, parser):
assert parser.parse_combat("You look around.") is None
assert parser.parse_combat("") is None
assert parser.parse_combat("The sun is shining.") is None
# ======================================================================
# parse_block (high-level)
# ======================================================================
class TestParseBlock:
def test_room_with_prompt(self, parser):
"""A typical server output: room description followed by prompt."""
text = (
"Town Square\n"
"\n"
"You are in the town square.\n"
"There is one obvious exit: north.\n"
"A guard is standing here.\n"
"hp: 350/350 mp: 440/440 sp: 431/431 >\n"
)
events = parser.parse_block(text)
types = [e.event_type for e in events]
assert EventType.PROMPT in types
assert EventType.ROOM in types
def test_combat_block(self, parser):
"""Combat lines should produce combat events."""
text = (
"You attack the goblin!\n"
"You hit the goblin for 12 damage.\n"
"The goblin hits you for 5 damage.\n"
"hp: 345/350 mp: 440/440 sp: 431/431 >\n"
)
events = parser.parse_block(text)
combat_events = [e for e in events if e.event_type == EventType.COMBAT]
assert len(combat_events) >= 2 # at least 2 combat lines
prompt_events = [e for e in events if e.event_type == EventType.PROMPT]
assert len(prompt_events) == 1
def test_empty_block(self, parser):
"""Empty input returns no events."""
assert parser.parse_block("") == []
assert parser.parse_block(" \n\n ") == []
+48
View File
@@ -0,0 +1,48 @@
"""Tests for the ParserFactory."""
import pytest
from core.parsers.factory import ParserFactory
from core.parsers.dead_souls import DeadSoulsParser
from core.parsers.base import BaseMudParser
def test_create_dead_souls():
parser = ParserFactory.create("dead_souls")
assert isinstance(parser, DeadSoulsParser)
def test_create_unknown_raises():
with pytest.raises(ValueError, match="Unknown MUD type"):
ParserFactory.create("nonexistent_mud")
def test_available_types():
types = ParserFactory.available_types()
assert "dead_souls" in types
def test_register_custom():
"""Register a custom parser at runtime."""
class DummyParser(BaseMudParser):
def parse_prompt(self, line):
return None
def parse_room(self, text_block):
return None
def parse_combat(self, line):
return None
def is_prompt_line(self, line):
return False
def is_room_description(self, text_block):
return False
ParserFactory.register("dummy", DummyParser)
parser = ParserFactory.create("dummy")
assert isinstance(parser, DummyParser)
# Clean up
ParserFactory._registry.pop("dummy", None)
+95
View File
@@ -0,0 +1,95 @@
"""Tests for the updated GameState."""
import pytest
from core.game_state import GameState, CombatState
from core.parsers.base import ParsedPrompt, ParsedRoom, ParsedCombat
@pytest.fixture
def state():
return GameState()
class TestUpdateFromPrompt:
def test_updates_all_vitals(self, state):
prompt = ParsedPrompt(hp=300, max_hp=350, mp=400, max_mp=440, sp=431, max_sp=431)
state.update_from_prompt(prompt)
assert state.character.hp == 300
assert state.character.max_hp == 350
assert state.character.mp == 400
assert state.character.max_mp == 440
assert state.character.sp == 431
assert state.character.max_sp == 431
def test_partial_update(self, state):
"""Only non-None fields should update."""
state.character.hp = 100
state.character.max_hp = 200
prompt = ParsedPrompt(hp=150, max_hp=200)
state.update_from_prompt(prompt)
assert state.character.hp == 150
assert state.character.sp == 0 # untouched default
def test_is_healthy(self, state):
prompt = ParsedPrompt(hp=280, max_hp=400, mp=100, max_mp=100, sp=100, max_sp=100)
state.update_from_prompt(prompt)
assert state.is_healthy(threshold=0.7) # 280/400 = 0.7
assert not state.is_healthy(threshold=0.8)
class TestUpdateRoom:
def test_basic_room_update(self, state):
room = ParsedRoom(
title="Town Square",
description="A busy square.",
exits=["north", "south"],
npcs=["guard"],
items=["sword"],
)
state.update_room(room)
assert state.current_room is not None
assert state.current_room.title == "Town Square"
assert "north" in state.current_room.exits
assert state.current_room.npcs == ["guard"]
assert state.current_room.items == ["sword"]
def test_previous_room_preserved(self, state):
room1 = ParsedRoom(title="Room A", exits=["north"])
room2 = ParsedRoom(title="Room B", exits=["south"])
state.update_room(room1)
state.update_room(room2)
assert state.current_room.title == "Room B"
assert state.previous_room is not None
assert state.previous_room.title == "Room A"
class TestUpdateCombat:
def test_enter_combat(self, state):
combat = ParsedCombat(is_combat=True, attacker="You", target="goblin", action="attack")
state.update_combat(combat)
assert state.combat_state == CombatState.IN_COMBAT
assert state.target == "goblin"
def test_flee(self, state):
state.combat_state = CombatState.IN_COMBAT
state.target = "goblin"
combat = ParsedCombat(is_combat=True, attacker="You", action="flee")
state.update_combat(combat)
assert state.combat_state == CombatState.FLEEING
assert state.target is None
def test_kill_target(self, state):
state.combat_state = CombatState.IN_COMBAT
state.target = "goblin"
combat = ParsedCombat(is_combat=True, attacker="You", target="the goblin", action="kill")
state.update_combat(combat)
assert state.combat_state == CombatState.IDLE
assert state.target is None
def test_not_combat_ignored(self, state):
combat = ParsedCombat(is_combat=False)
state.update_combat(combat)
assert state.combat_state == CombatState.IDLE