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
@@ -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"))
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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 ") == []
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in new issue
Block a user