Implement phases 3-4 + all modules: decision engine, automapper, character creator, quest system, progression optimizer, and hybrid LLM integration
- Phase 4 (Decision Engine): ActionQueue with 7 priority tiers, 4 behavior states (Idle/Explore/Combat/Loot), CombatAI with skill rotation and flee logic, GoalManager with survival override, DecisionEngine state machine integrated into the main game loop - Phase 3 (Automapper): Complete hybrid SQLite/Memvid architecture with WorldMapper orchestrator, room fingerprinting (SHA-256), bidirectional exit linking, exploration queue, A* pathfinding, HistoricalStorage with JSON fallback, SemanticSearch with keyword fallback, SyncManager with configurable triggers - Character Creator: State machine for automated character creation with stat rolling/optimization per class weights, race/class/gender selection, reroll logic with configurable thresholds - Quest System: Quest lifecycle tracking (discover->accept->complete), NLP-based objective extraction (kill/fetch/talk/explore), EXP/time prioritization, pending kill target tracking - Progression Optimizer: Grinding spot finder (EXP/hour), equipment comparison by weighted stats, skill rotation optimizer (DPS vs mana efficiency), session summary statistics - LLM Integration (hybrid): Async Ollama client with retry/backoff, LLMBrain coordinator for quest parsing, social AI, decision advice, and ambiguous text parsing fallback. SocialAI for detecting and responding to player messages (say/tell/shout/group/trade). Graceful degradation when Ollama is unavailable. All 214 tests passing.
This commit is contained in:
1 parent
f85c27a742
commit
bd211b6036
34 files changed
+5479
-233
No files matched your search
@@ -0,0 +1,132 @@
|
||||
"""Tests for the ActionQueue priority queue."""
|
||||
|
||||
import pytest
|
||||
from modules.decision.action_queue import Action, ActionPriority, ActionQueue
|
||||
|
||||
|
||||
class TestAction:
|
||||
"""Tests for the Action dataclass."""
|
||||
|
||||
def test_action_creation(self):
|
||||
a = Action(command="north", priority=ActionPriority.MOVE, source="explore")
|
||||
assert a.command == "north"
|
||||
assert a.priority == ActionPriority.MOVE
|
||||
assert a.source == "explore"
|
||||
|
||||
def test_action_default_priority(self):
|
||||
a = Action(command="look")
|
||||
assert a.priority == ActionPriority.IDLE
|
||||
|
||||
def test_action_repr(self):
|
||||
a = Action(command="flee", priority=ActionPriority.EMERGENCY, source="combat")
|
||||
r = repr(a)
|
||||
assert "flee" in r
|
||||
assert "EMERGENCY" in r
|
||||
assert "combat" in r
|
||||
|
||||
|
||||
class TestActionQueue:
|
||||
"""Tests for the ActionQueue."""
|
||||
|
||||
def test_push_and_pop(self):
|
||||
q = ActionQueue()
|
||||
q.push(Action(command="north", priority=ActionPriority.MOVE))
|
||||
assert q.size == 1
|
||||
a = q.pop()
|
||||
assert a is not None
|
||||
assert a.command == "north"
|
||||
assert q.is_empty
|
||||
|
||||
def test_pop_empty(self):
|
||||
q = ActionQueue()
|
||||
assert q.pop() is None
|
||||
|
||||
def test_peek(self):
|
||||
q = ActionQueue()
|
||||
q.push(Action(command="look", priority=ActionPriority.IDLE))
|
||||
a = q.peek()
|
||||
assert a is not None
|
||||
assert a.command == "look"
|
||||
assert q.size == 1 # not removed
|
||||
|
||||
def test_peek_empty(self):
|
||||
q = ActionQueue()
|
||||
assert q.peek() is None
|
||||
|
||||
def test_priority_ordering(self):
|
||||
"""Higher-priority (lower number) actions should come out first."""
|
||||
q = ActionQueue()
|
||||
q.push(Action(command="north", priority=ActionPriority.MOVE))
|
||||
q.push(Action(command="flee", priority=ActionPriority.EMERGENCY))
|
||||
q.push(Action(command="heal", priority=ActionPriority.HEAL))
|
||||
q.push(Action(command="kill goblin", priority=ActionPriority.COMBAT))
|
||||
|
||||
order = []
|
||||
while not q.is_empty:
|
||||
order.append(q.pop().command)
|
||||
|
||||
assert order == ["flee", "heal", "kill goblin", "north"]
|
||||
|
||||
def test_fifo_within_same_priority(self):
|
||||
"""Actions at the same priority level should be FIFO."""
|
||||
q = ActionQueue()
|
||||
q.push(Action(command="first", priority=ActionPriority.COMBAT))
|
||||
q.push(Action(command="second", priority=ActionPriority.COMBAT))
|
||||
q.push(Action(command="third", priority=ActionPriority.COMBAT))
|
||||
|
||||
assert q.pop().command == "first"
|
||||
assert q.pop().command == "second"
|
||||
assert q.pop().command == "third"
|
||||
|
||||
def test_clear(self):
|
||||
q = ActionQueue()
|
||||
q.push(Action(command="a", priority=ActionPriority.IDLE))
|
||||
q.push(Action(command="b", priority=ActionPriority.MOVE))
|
||||
q.clear()
|
||||
assert q.is_empty
|
||||
|
||||
def test_clear_by_source(self):
|
||||
q = ActionQueue()
|
||||
q.push(Action(command="a", priority=ActionPriority.MOVE, source="explore"))
|
||||
q.push(Action(command="b", priority=ActionPriority.COMBAT, source="combat"))
|
||||
q.push(Action(command="c", priority=ActionPriority.MOVE, source="explore"))
|
||||
|
||||
removed = q.clear_by_source("explore")
|
||||
assert removed == 2
|
||||
assert q.size == 1
|
||||
assert q.pop().command == "b"
|
||||
|
||||
def test_clear_by_priority(self):
|
||||
q = ActionQueue()
|
||||
q.push(Action(command="a", priority=ActionPriority.MOVE))
|
||||
q.push(Action(command="b", priority=ActionPriority.COMBAT))
|
||||
q.push(Action(command="c", priority=ActionPriority.MOVE))
|
||||
|
||||
removed = q.clear_by_priority(ActionPriority.MOVE)
|
||||
assert removed == 2
|
||||
assert q.size == 1
|
||||
|
||||
def test_max_size_drops_lowest_priority(self):
|
||||
q = ActionQueue(max_size=2)
|
||||
q.push(Action(command="heal", priority=ActionPriority.HEAL))
|
||||
q.push(Action(command="move", priority=ActionPriority.MOVE))
|
||||
# Queue is full — pushing a high-priority item should drop MOVE
|
||||
q.push(Action(command="flee", priority=ActionPriority.EMERGENCY))
|
||||
|
||||
assert q.size == 2
|
||||
actions = q.as_list()
|
||||
commands = [a.command for a in actions]
|
||||
assert "flee" in commands
|
||||
assert "heal" in commands
|
||||
assert "move" not in commands
|
||||
|
||||
def test_as_list_sorted(self):
|
||||
q = ActionQueue()
|
||||
q.push(Action(command="north", priority=ActionPriority.MOVE))
|
||||
q.push(Action(command="flee", priority=ActionPriority.EMERGENCY))
|
||||
|
||||
lst = q.as_list()
|
||||
assert lst[0].command == "flee"
|
||||
assert lst[1].command == "north"
|
||||
# Original queue not modified
|
||||
assert q.size == 2
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Tests for behavior states (Idle, Explore, Combat, Loot)."""
|
||||
|
||||
import pytest
|
||||
from core.game_state import GameState, Room, Character, CombatState
|
||||
from modules.decision.action_queue import ActionPriority
|
||||
from modules.decision.behavior_states import (
|
||||
IdleState,
|
||||
ExploreState,
|
||||
CombatState as CombatBehavior,
|
||||
LootState,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_game_state(
|
||||
hp=100, max_hp=100, mp=50, max_mp=50,
|
||||
room_title="Town Square", exits=None, npcs=None, items=None,
|
||||
target=None, combat=CombatState.IDLE,
|
||||
) -> GameState:
|
||||
"""Create a GameState with controlled values."""
|
||||
gs = GameState()
|
||||
gs.character.hp = hp
|
||||
gs.character.max_hp = max_hp
|
||||
gs.character.mp = mp
|
||||
gs.character.max_mp = max_mp
|
||||
gs.current_room = Room(
|
||||
title=room_title,
|
||||
exits={"north": 0, "south": 1} if exits is None else exits,
|
||||
npcs=npcs or [],
|
||||
items=items or [],
|
||||
)
|
||||
gs.target = target
|
||||
gs.combat_state = combat
|
||||
return gs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IdleState
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIdleState:
|
||||
def test_heal_when_low_hp(self):
|
||||
state = IdleState(heal_threshold=0.8)
|
||||
gs = _make_game_state(hp=50, max_hp=100)
|
||||
actions = state.get_actions(gs)
|
||||
heal_actions = [a for a in actions if a.priority == ActionPriority.HEAL]
|
||||
assert len(heal_actions) >= 1
|
||||
assert heal_actions[0].command == "heal"
|
||||
|
||||
def test_rest_when_low_mp(self):
|
||||
state = IdleState(mp_threshold=0.5)
|
||||
gs = _make_game_state(mp=10, max_mp=50)
|
||||
actions = state.get_actions(gs)
|
||||
rest_actions = [a for a in actions if a.command == "rest"]
|
||||
assert len(rest_actions) == 1
|
||||
|
||||
def test_look_when_healthy(self):
|
||||
state = IdleState()
|
||||
gs = _make_game_state(hp=100, max_hp=100, mp=50, max_mp=50)
|
||||
actions = state.get_actions(gs)
|
||||
assert len(actions) == 1
|
||||
assert actions[0].command == "look"
|
||||
assert actions[0].priority == ActionPriority.IDLE
|
||||
|
||||
def test_both_heal_and_rest(self):
|
||||
state = IdleState(heal_threshold=0.9, mp_threshold=0.9)
|
||||
gs = _make_game_state(hp=50, max_hp=100, mp=10, max_mp=50)
|
||||
actions = state.get_actions(gs)
|
||||
commands = [a.command for a in actions]
|
||||
assert "heal" in commands
|
||||
assert "rest" in commands
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ExploreState
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExploreState:
|
||||
def test_prefers_unexplored_exits(self):
|
||||
state = ExploreState(strategy="random")
|
||||
gs = _make_game_state(exits={"north": 0, "south": 1})
|
||||
actions = state.get_actions(gs)
|
||||
assert len(actions) == 1
|
||||
# Should pick the unexplored exit
|
||||
assert actions[0].command == "north"
|
||||
|
||||
def test_falls_back_to_explored(self):
|
||||
state = ExploreState(strategy="random")
|
||||
gs = _make_game_state(exits={"north": 1, "south": 2})
|
||||
actions = state.get_actions(gs)
|
||||
assert len(actions) == 1
|
||||
assert actions[0].command in ("north", "south")
|
||||
|
||||
def test_look_when_no_room(self):
|
||||
state = ExploreState()
|
||||
gs = GameState() # no current_room
|
||||
actions = state.get_actions(gs)
|
||||
assert len(actions) == 1
|
||||
assert actions[0].command == "look"
|
||||
|
||||
def test_look_when_no_exits(self):
|
||||
state = ExploreState()
|
||||
gs = _make_game_state(exits={})
|
||||
actions = state.get_actions(gs)
|
||||
assert actions[0].command == "look"
|
||||
|
||||
def test_systematic_strategy(self):
|
||||
state = ExploreState(strategy="systematic")
|
||||
gs = _make_game_state(exits={"north": 0, "south": 0, "east": 0})
|
||||
|
||||
# First call — picks first unexplored
|
||||
a1 = state.get_actions(gs)
|
||||
first_dir = a1[0].command
|
||||
|
||||
# Second call — picks a different unexplored direction
|
||||
a2 = state.get_actions(gs)
|
||||
second_dir = a2[0].command
|
||||
assert second_dir != first_dir
|
||||
|
||||
def test_move_priority(self):
|
||||
state = ExploreState()
|
||||
gs = _make_game_state()
|
||||
actions = state.get_actions(gs)
|
||||
assert actions[0].priority == ActionPriority.MOVE
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CombatState (behavior)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCombatBehavior:
|
||||
def test_flee_when_critical_hp(self):
|
||||
state = CombatBehavior(flee_threshold=0.2)
|
||||
gs = _make_game_state(hp=10, max_hp=100, target="goblin")
|
||||
actions = state.get_actions(gs)
|
||||
assert any(a.priority == ActionPriority.EMERGENCY for a in actions)
|
||||
assert any(a.command == "flee" for a in actions)
|
||||
|
||||
def test_heal_and_attack_when_moderate_hp(self):
|
||||
state = CombatBehavior(combat_heal_threshold=0.5)
|
||||
gs = _make_game_state(hp=40, max_hp=100, target="goblin")
|
||||
actions = state.get_actions(gs)
|
||||
commands = [a.command for a in actions]
|
||||
assert "heal" in commands
|
||||
assert any("goblin" in c for c in commands)
|
||||
|
||||
def test_attack_only_when_healthy(self):
|
||||
state = CombatBehavior()
|
||||
gs = _make_game_state(hp=100, max_hp=100, target="goblin")
|
||||
actions = state.get_actions(gs)
|
||||
assert len(actions) == 1
|
||||
assert "goblin" in actions[0].command
|
||||
assert actions[0].priority == ActionPriority.COMBAT
|
||||
|
||||
def test_generic_attack_without_target(self):
|
||||
state = CombatBehavior()
|
||||
gs = _make_game_state(hp=100, max_hp=100, target=None)
|
||||
actions = state.get_actions(gs)
|
||||
assert len(actions) == 1
|
||||
assert actions[0].command == "kill"
|
||||
|
||||
def test_flee_returns_only_flee(self):
|
||||
"""When fleeing, no attack actions should be queued."""
|
||||
state = CombatBehavior(flee_threshold=0.3)
|
||||
gs = _make_game_state(hp=10, max_hp=100, target="dragon")
|
||||
actions = state.get_actions(gs)
|
||||
assert len(actions) == 1
|
||||
assert actions[0].command == "flee"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LootState
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLootState:
|
||||
def test_loot_actions(self):
|
||||
state = LootState()
|
||||
gs = _make_game_state()
|
||||
actions = state.get_actions(gs)
|
||||
assert len(actions) == 2
|
||||
commands = [a.command for a in actions]
|
||||
assert "get all from corpse" in commands
|
||||
assert "get all" in commands
|
||||
|
||||
def test_loot_priority(self):
|
||||
state = LootState()
|
||||
gs = _make_game_state()
|
||||
actions = state.get_actions(gs)
|
||||
assert all(a.priority == ActionPriority.LOOT for a in actions)
|
||||
|
||||
def test_custom_loot_command(self):
|
||||
state = LootState(loot_command="loot corpse", fallback_command="take all")
|
||||
gs = _make_game_state()
|
||||
actions = state.get_actions(gs)
|
||||
commands = [a.command for a in actions]
|
||||
assert "loot corpse" in commands
|
||||
assert "take all" in commands
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Tests for the CharacterCreator module."""
|
||||
|
||||
import pytest
|
||||
from modules.character.creator import (
|
||||
CharacterCreator, CreatorConfig, StatRoll, CreationPhase,
|
||||
)
|
||||
|
||||
|
||||
class TestStatRoll:
|
||||
def test_total(self):
|
||||
roll = StatRoll(strength=15, intelligence=12, wisdom=10,
|
||||
dexterity=14, constitution=13, charisma=8)
|
||||
assert roll.total == 72
|
||||
|
||||
def test_weighted_score(self):
|
||||
roll = StatRoll(strength=18, intelligence=8, wisdom=8,
|
||||
dexterity=10, constitution=14, charisma=8)
|
||||
warrior_w = {"str": 2.0, "con": 1.5, "dex": 1.0, "int": 0.5, "wis": 0.5, "cha": 0.5}
|
||||
score = roll.weighted_score(warrior_w)
|
||||
expected = 18*2.0 + 8*0.5 + 8*0.5 + 10*1.0 + 14*1.5 + 8*0.5
|
||||
assert score == expected
|
||||
|
||||
|
||||
class TestCharacterCreator:
|
||||
def test_race_prompt(self):
|
||||
creator = CharacterCreator(CreatorConfig(preferred_race="elf"))
|
||||
resp = creator.process("Please choose your race:")
|
||||
assert resp == "elf"
|
||||
assert creator.phase == CreationPhase.CHOOSE_RACE
|
||||
|
||||
def test_class_prompt(self):
|
||||
creator = CharacterCreator(CreatorConfig(preferred_class="mage"))
|
||||
resp = creator.process("Select your class or profession:")
|
||||
assert resp == "mage"
|
||||
assert creator.phase == CreationPhase.CHOOSE_CLASS
|
||||
|
||||
def test_gender_prompt(self):
|
||||
creator = CharacterCreator(CreatorConfig(preferred_gender="female"))
|
||||
resp = creator.process("Choose your gender:")
|
||||
assert resp == "female"
|
||||
|
||||
def test_stat_display_reroll_low_stats(self):
|
||||
creator = CharacterCreator(CreatorConfig(min_stat_total=80))
|
||||
text = "Str: 8 Int: 8 Wis: 8 Dex: 8 Con: 8 Cha: 8"
|
||||
resp = creator.process(text)
|
||||
assert resp == "reroll"
|
||||
|
||||
def test_stat_display_keep_good_stats(self):
|
||||
creator = CharacterCreator(CreatorConfig(min_stat_total=60))
|
||||
text = "Str: 15 Int: 12 Wis: 10 Dex: 14 Con: 13 Cha: 10"
|
||||
resp = creator.process(text)
|
||||
assert resp == "keep"
|
||||
|
||||
def test_max_rerolls_forces_keep(self):
|
||||
creator = CharacterCreator(CreatorConfig(min_stat_total=200, max_rerolls=2))
|
||||
text = "Str: 8 Int: 8 Wis: 8 Dex: 8 Con: 8 Cha: 8"
|
||||
# First roll -> reroll
|
||||
resp1 = creator.process(text)
|
||||
assert resp1 == "reroll"
|
||||
# Second roll -> forced keep (max_rerolls=2)
|
||||
resp2 = creator.process(text)
|
||||
assert resp2 == "keep"
|
||||
|
||||
def test_confirm_prompt(self):
|
||||
creator = CharacterCreator()
|
||||
resp = creator.process("Are you sure you want to create this character?")
|
||||
assert resp == "yes"
|
||||
|
||||
def test_creation_done(self):
|
||||
creator = CharacterCreator()
|
||||
resp = creator.process("Welcome to the world!")
|
||||
assert resp is None
|
||||
assert creator.is_done
|
||||
|
||||
def test_unrecognised_text(self):
|
||||
creator = CharacterCreator()
|
||||
resp = creator.process("The wind howls through the trees.")
|
||||
assert resp is None
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Tests for the CombatAI module."""
|
||||
|
||||
import pytest
|
||||
from core.game_state import GameState, Room, CombatState
|
||||
from modules.decision.combat_ai import CombatAI, CombatConfig
|
||||
from modules.decision.action_queue import ActionPriority
|
||||
|
||||
|
||||
def _make_game_state(
|
||||
hp=100, max_hp=100, target=None, npcs=None,
|
||||
combat=CombatState.IN_COMBAT,
|
||||
) -> GameState:
|
||||
gs = GameState()
|
||||
gs.character.hp = hp
|
||||
gs.character.max_hp = max_hp
|
||||
gs.target = target
|
||||
gs.combat_state = combat
|
||||
gs.current_room = Room(
|
||||
title="Arena",
|
||||
exits={"north": 0},
|
||||
npcs=npcs or [],
|
||||
)
|
||||
return gs
|
||||
|
||||
|
||||
class TestCombatAI:
|
||||
def test_flee_when_critical(self):
|
||||
ai = CombatAI(CombatConfig(flee_hp_ratio=0.2))
|
||||
gs = _make_game_state(hp=15, max_hp=100, target="dragon")
|
||||
actions = ai.decide(gs)
|
||||
assert len(actions) == 1
|
||||
assert actions[0].priority == ActionPriority.EMERGENCY
|
||||
assert actions[0].command == "flee"
|
||||
|
||||
def test_heal_and_attack_when_moderate(self):
|
||||
ai = CombatAI(CombatConfig(heal_hp_ratio=0.5))
|
||||
gs = _make_game_state(hp=40, max_hp=100, target="goblin")
|
||||
actions = ai.decide(gs)
|
||||
priorities = [a.priority for a in actions]
|
||||
assert ActionPriority.HEAL in priorities
|
||||
assert ActionPriority.COMBAT in priorities
|
||||
|
||||
def test_attack_only_when_healthy(self):
|
||||
ai = CombatAI()
|
||||
gs = _make_game_state(hp=100, max_hp=100, target="goblin")
|
||||
actions = ai.decide(gs)
|
||||
assert len(actions) == 1
|
||||
assert actions[0].priority == ActionPriority.COMBAT
|
||||
assert "goblin" in actions[0].command
|
||||
|
||||
def test_auto_target_from_npcs(self):
|
||||
ai = CombatAI()
|
||||
gs = _make_game_state(hp=100, max_hp=100, target=None, npcs=["orc", "troll"])
|
||||
actions = ai.decide(gs)
|
||||
# Should auto-target the first NPC
|
||||
assert "orc" in actions[0].command
|
||||
|
||||
def test_generic_attack_no_target_no_npcs(self):
|
||||
ai = CombatAI()
|
||||
gs = _make_game_state(hp=100, max_hp=100, target=None, npcs=[])
|
||||
actions = ai.decide(gs)
|
||||
assert actions[0].command == "kill"
|
||||
|
||||
def test_skill_rotation(self):
|
||||
config = CombatConfig(
|
||||
skill_commands=["backstab", "kick"],
|
||||
skill_cooldown_ticks=3,
|
||||
)
|
||||
ai = CombatAI(config)
|
||||
gs = _make_game_state(hp=100, max_hp=100, target="rat")
|
||||
|
||||
# Each decide() increments _ticks_since_skill by 1 at the end.
|
||||
# When a skill is used, _ticks_since_skill resets to 0 then +1.
|
||||
#
|
||||
# Tick 1: before=0, 0<3 -> normal attack, after=1
|
||||
a1 = ai.decide(gs)
|
||||
assert a1[-1].command == "kill rat"
|
||||
|
||||
# Tick 2: before=1, 1<3 -> normal attack, after=2
|
||||
a2 = ai.decide(gs)
|
||||
assert a2[-1].command == "kill rat"
|
||||
|
||||
# Tick 3: before=2, 2<3 -> normal attack, after=3
|
||||
a3 = ai.decide(gs)
|
||||
assert a3[-1].command == "kill rat"
|
||||
|
||||
# Tick 4: before=3, 3>=3 -> backstab, reset to 0, after=1
|
||||
a4 = ai.decide(gs)
|
||||
assert "backstab" in a4[-1].command
|
||||
|
||||
# Tick 5: before=1, 1<3 -> normal attack, after=2
|
||||
a5 = ai.decide(gs)
|
||||
assert a5[-1].command == "kill rat"
|
||||
|
||||
# Tick 6: before=2, 2<3 -> normal attack, after=3
|
||||
a6 = ai.decide(gs)
|
||||
assert a6[-1].command == "kill rat"
|
||||
|
||||
# Tick 7: before=3, 3>=3 -> kick, reset to 0, after=1
|
||||
a7 = ai.decide(gs)
|
||||
assert "kick" in a7[-1].command
|
||||
|
||||
def test_reset(self):
|
||||
ai = CombatAI()
|
||||
gs = _make_game_state(target="goblin")
|
||||
ai.decide(gs)
|
||||
ai.reset()
|
||||
assert ai._skill_index == 0
|
||||
assert ai._ticks_since_skill == 0
|
||||
assert ai._last_target is None
|
||||
|
||||
def test_should_engage_when_healthy_with_npcs(self):
|
||||
ai = CombatAI()
|
||||
gs = _make_game_state(hp=80, max_hp=100, npcs=["rat"])
|
||||
assert ai.should_engage(gs) is True
|
||||
|
||||
def test_should_not_engage_when_low_hp(self):
|
||||
ai = CombatAI()
|
||||
gs = _make_game_state(hp=30, max_hp=100, npcs=["rat"])
|
||||
assert ai.should_engage(gs) is False
|
||||
|
||||
def test_should_not_engage_without_npcs(self):
|
||||
ai = CombatAI()
|
||||
gs = _make_game_state(hp=100, max_hp=100, npcs=[])
|
||||
assert ai.should_engage(gs) is False
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Tests for the DecisionEngine (state machine + integration)."""
|
||||
|
||||
import pytest
|
||||
from core.game_state import GameState, Room, CombatState
|
||||
from modules.decision.engine import DecisionEngine
|
||||
from modules.decision.combat_ai import CombatConfig
|
||||
from modules.decision.goal_manager import Goal, GoalPriority
|
||||
from modules.decision.action_queue import ActionPriority
|
||||
|
||||
|
||||
def _make_game_state(
|
||||
hp=100, max_hp=100, mp=50, max_mp=50,
|
||||
room_title="Town Square", exits=None, npcs=None,
|
||||
target=None, combat=CombatState.IDLE,
|
||||
) -> GameState:
|
||||
gs = GameState()
|
||||
gs.character.hp = hp
|
||||
gs.character.max_hp = max_hp
|
||||
gs.character.mp = mp
|
||||
gs.character.max_mp = max_mp
|
||||
gs.current_room = Room(
|
||||
title=room_title,
|
||||
exits=exits or {"north": 0, "south": 1},
|
||||
npcs=npcs or [],
|
||||
)
|
||||
gs.target = target
|
||||
gs.combat_state = combat
|
||||
return gs
|
||||
|
||||
|
||||
class TestDecisionEngineInit:
|
||||
def test_default_state_is_idle(self):
|
||||
engine = DecisionEngine()
|
||||
assert engine.current_state_name == "idle"
|
||||
|
||||
def test_all_states_registered(self):
|
||||
engine = DecisionEngine()
|
||||
assert "idle" in engine._states
|
||||
assert "explore" in engine._states
|
||||
assert "combat" in engine._states
|
||||
assert "loot" in engine._states
|
||||
|
||||
|
||||
class TestDecisionEngineTick:
|
||||
def test_tick_idle_produces_actions(self):
|
||||
engine = DecisionEngine()
|
||||
gs = _make_game_state()
|
||||
engine.tick(gs)
|
||||
action = engine.next_action()
|
||||
assert action is not None
|
||||
|
||||
def test_transition_to_combat_when_in_combat(self):
|
||||
engine = DecisionEngine()
|
||||
gs = _make_game_state(
|
||||
combat=CombatState.IN_COMBAT,
|
||||
target="goblin",
|
||||
)
|
||||
engine.tick(gs)
|
||||
assert engine.current_state_name == "combat"
|
||||
|
||||
def test_transition_to_loot_after_combat(self):
|
||||
engine = DecisionEngine()
|
||||
|
||||
# Simulate combat
|
||||
gs = _make_game_state(combat=CombatState.IN_COMBAT, target="goblin")
|
||||
engine.tick(gs)
|
||||
assert engine.current_state_name == "combat"
|
||||
|
||||
# Combat ends
|
||||
gs.combat_state = CombatState.IDLE
|
||||
gs.target = None
|
||||
engine.tick(gs)
|
||||
assert engine.current_state_name == "loot"
|
||||
|
||||
def test_transition_to_idle_when_low_hp(self):
|
||||
engine = DecisionEngine()
|
||||
gs = _make_game_state(hp=30, max_hp=100)
|
||||
engine.tick(gs)
|
||||
assert engine.current_state_name == "idle"
|
||||
|
||||
def test_explore_with_goal(self):
|
||||
engine = DecisionEngine()
|
||||
engine.goal_manager.add_goal(
|
||||
Goal(name="explore", priority=GoalPriority.EXPLORE, target_state="explore")
|
||||
)
|
||||
gs = _make_game_state(hp=100, max_hp=100)
|
||||
engine.tick(gs)
|
||||
assert engine.current_state_name == "explore"
|
||||
|
||||
def test_combat_actions_use_combat_ai(self):
|
||||
config = CombatConfig(attack_command="kill")
|
||||
engine = DecisionEngine(combat_config=config)
|
||||
gs = _make_game_state(
|
||||
combat=CombatState.IN_COMBAT,
|
||||
target="orc",
|
||||
hp=100, max_hp=100,
|
||||
)
|
||||
engine.tick(gs)
|
||||
action = engine.next_action()
|
||||
assert action is not None
|
||||
assert "orc" in action.command
|
||||
|
||||
def test_emergency_flee_highest_priority(self):
|
||||
config = CombatConfig(flee_hp_ratio=0.2)
|
||||
engine = DecisionEngine(combat_config=config)
|
||||
gs = _make_game_state(
|
||||
combat=CombatState.IN_COMBAT,
|
||||
target="dragon",
|
||||
hp=10, max_hp=100,
|
||||
)
|
||||
engine.tick(gs)
|
||||
action = engine.next_action()
|
||||
assert action is not None
|
||||
assert action.command == "flee"
|
||||
assert action.priority == ActionPriority.EMERGENCY
|
||||
|
||||
def test_tick_increments_counter(self):
|
||||
engine = DecisionEngine()
|
||||
gs = _make_game_state()
|
||||
engine.tick(gs)
|
||||
engine.tick(gs)
|
||||
assert engine._tick_count == 2
|
||||
|
||||
|
||||
class TestDecisionEngineTransitions:
|
||||
def test_force_state(self):
|
||||
engine = DecisionEngine()
|
||||
gs = _make_game_state()
|
||||
engine.force_state("combat", gs)
|
||||
assert engine.current_state_name == "combat"
|
||||
|
||||
def test_force_unknown_state(self):
|
||||
engine = DecisionEngine()
|
||||
gs = _make_game_state()
|
||||
engine.force_state("nonexistent", gs)
|
||||
assert engine.current_state_name == "idle" # unchanged
|
||||
|
||||
def test_register_custom_state(self):
|
||||
from modules.decision.behavior_states import BaseBehaviorState, Action, ActionPriority
|
||||
from typing import List
|
||||
|
||||
class CustomState(BaseBehaviorState):
|
||||
name = "custom"
|
||||
|
||||
def get_actions(self, game_state):
|
||||
return [Action(command="custom_cmd", priority=ActionPriority.IDLE, source="custom")]
|
||||
|
||||
engine = DecisionEngine()
|
||||
engine.register_state(CustomState())
|
||||
assert "custom" in engine._states
|
||||
|
||||
def test_state_exit_clears_source_actions(self):
|
||||
engine = DecisionEngine()
|
||||
gs = _make_game_state()
|
||||
|
||||
# Tick in idle — queues actions from idle source
|
||||
engine.tick(gs)
|
||||
|
||||
# Force transition to explore — should clear idle actions
|
||||
engine.force_state("explore", gs)
|
||||
# Queue should now be empty (idle actions cleared)
|
||||
# Then tick explore to get new actions
|
||||
engine.tick(gs)
|
||||
action = engine.next_action()
|
||||
assert action is not None
|
||||
assert action.source == "explore"
|
||||
|
||||
|
||||
class TestDecisionEngineNextAction:
|
||||
def test_next_action_returns_none_when_empty(self):
|
||||
engine = DecisionEngine()
|
||||
assert engine.next_action() is None
|
||||
|
||||
def test_next_action_respects_priority(self):
|
||||
engine = DecisionEngine()
|
||||
gs = _make_game_state(
|
||||
combat=CombatState.IN_COMBAT,
|
||||
target="goblin",
|
||||
hp=30, max_hp=100, # triggers heal
|
||||
)
|
||||
engine.tick(gs)
|
||||
# First action should be the highest priority
|
||||
action = engine.next_action()
|
||||
assert action is not None
|
||||
# Should be heal (HEAL=1) before attack (COMBAT=2)
|
||||
assert action.priority <= ActionPriority.HEAL
|
||||
|
||||
|
||||
class TestDecisionEngineDefaultGoals:
|
||||
def test_default_goals_produce_exploration(self):
|
||||
engine = DecisionEngine()
|
||||
engine.goal_manager.set_default_goals()
|
||||
gs = _make_game_state(hp=100, max_hp=100)
|
||||
engine.tick(gs)
|
||||
# With default goals, should be in explore or grind state
|
||||
assert engine.current_state_name in ("explore", "combat")
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Tests for the GoalManager."""
|
||||
|
||||
import pytest
|
||||
from core.game_state import GameState
|
||||
from modules.decision.goal_manager import (
|
||||
GoalManager,
|
||||
Goal,
|
||||
GoalPriority,
|
||||
GoalStatus,
|
||||
)
|
||||
|
||||
|
||||
def _make_game_state(hp=100, max_hp=100) -> GameState:
|
||||
gs = GameState()
|
||||
gs.character.hp = hp
|
||||
gs.character.max_hp = max_hp
|
||||
return gs
|
||||
|
||||
|
||||
class TestGoal:
|
||||
def test_goal_creation(self):
|
||||
g = Goal(name="test", priority=GoalPriority.EXPLORE)
|
||||
assert g.name == "test"
|
||||
assert g.is_active()
|
||||
assert g.progress == 0.0
|
||||
|
||||
def test_goal_inactive_after_completion(self):
|
||||
g = Goal(name="test")
|
||||
g.status = GoalStatus.COMPLETED
|
||||
assert not g.is_active()
|
||||
|
||||
|
||||
class TestGoalManager:
|
||||
def test_add_and_count(self):
|
||||
gm = GoalManager()
|
||||
gm.add_goal(Goal(name="a"))
|
||||
gm.add_goal(Goal(name="b"))
|
||||
assert gm.goal_count == 2
|
||||
assert gm.active_goal_count == 2
|
||||
|
||||
def test_remove_goal(self):
|
||||
gm = GoalManager()
|
||||
gm.add_goal(Goal(name="a"))
|
||||
assert gm.remove_goal("a") is True
|
||||
assert gm.goal_count == 0
|
||||
assert gm.remove_goal("nonexistent") is False
|
||||
|
||||
def test_complete_goal(self):
|
||||
gm = GoalManager()
|
||||
gm.add_goal(Goal(name="a"))
|
||||
assert gm.complete_goal("a") is True
|
||||
assert gm.active_goal_count == 0
|
||||
assert gm.goal_count == 1
|
||||
|
||||
def test_fail_goal(self):
|
||||
gm = GoalManager()
|
||||
gm.add_goal(Goal(name="a"))
|
||||
assert gm.fail_goal("a") is True
|
||||
assert gm.active_goal_count == 0
|
||||
|
||||
def test_get_current_goal_highest_priority(self):
|
||||
gm = GoalManager()
|
||||
gm.add_goal(Goal(name="explore", priority=GoalPriority.EXPLORE))
|
||||
gm.add_goal(Goal(name="combat", priority=GoalPriority.COMBAT))
|
||||
gs = _make_game_state()
|
||||
current = gm.get_current_goal(gs)
|
||||
assert current is not None
|
||||
assert current.name == "combat"
|
||||
|
||||
def test_survival_override_when_low_hp(self):
|
||||
gm = GoalManager()
|
||||
gm.add_goal(Goal(name="explore", priority=GoalPriority.EXPLORE))
|
||||
gs = _make_game_state(hp=20, max_hp=100)
|
||||
current = gm.get_current_goal(gs)
|
||||
assert current is not None
|
||||
assert current.name == "survive"
|
||||
assert current.priority == GoalPriority.SURVIVAL
|
||||
|
||||
def test_no_goals_returns_none(self):
|
||||
gm = GoalManager()
|
||||
gs = _make_game_state()
|
||||
assert gm.get_current_goal(gs) is None
|
||||
|
||||
def test_get_active_goals_sorted(self):
|
||||
gm = GoalManager()
|
||||
gm.add_goal(Goal(name="explore", priority=GoalPriority.EXPLORE))
|
||||
gm.add_goal(Goal(name="grind", priority=GoalPriority.GRIND))
|
||||
gm.add_goal(Goal(name="quest", priority=GoalPriority.QUEST))
|
||||
|
||||
active = gm.get_active_goals()
|
||||
names = [g.name for g in active]
|
||||
assert names == ["quest", "grind", "explore"]
|
||||
|
||||
def test_set_default_goals(self):
|
||||
gm = GoalManager()
|
||||
gm.set_default_goals()
|
||||
assert gm.active_goal_count == 2
|
||||
names = [g.name for g in gm.get_active_goals()]
|
||||
assert "explore_world" in names
|
||||
assert "grind_combat" in names
|
||||
|
||||
def test_complete_nonexistent(self):
|
||||
gm = GoalManager()
|
||||
assert gm.complete_goal("nope") is False
|
||||
|
||||
def test_fail_already_completed(self):
|
||||
gm = GoalManager()
|
||||
gm.add_goal(Goal(name="done"))
|
||||
gm.complete_goal("done")
|
||||
# Cannot fail an already-completed goal
|
||||
assert gm.fail_goal("done") is False
|
||||
@@ -0,0 +1,308 @@
|
||||
"""Tests for the LLM module (client, brain, social AI).
|
||||
|
||||
All tests use mocked HTTP responses — no Ollama server required.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
|
||||
from modules.llm.client import (
|
||||
LLMClient, LLMConfig, LLMResponse, render_template, PROMPT_TEMPLATES,
|
||||
)
|
||||
from modules.llm.brain import LLMBrain
|
||||
from modules.llm.social_ai import SocialAI
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_mock_response(text="", tokens=10, success=True):
|
||||
return LLMResponse(
|
||||
text=text, model="test", tokens_used=tokens,
|
||||
latency_ms=50.0, success=success,
|
||||
)
|
||||
|
||||
|
||||
def _make_json_response(data, tokens=10):
|
||||
return LLMResponse(
|
||||
text=json.dumps(data), model="test", tokens_used=tokens,
|
||||
latency_ms=50.0, success=True,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LLMClient
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLLMConfig:
|
||||
def test_defaults(self):
|
||||
cfg = LLMConfig()
|
||||
assert cfg.model == "llama3.2"
|
||||
assert cfg.temperature == 0.3
|
||||
assert cfg.base_url == "http://localhost:11434"
|
||||
|
||||
|
||||
class TestLLMResponse:
|
||||
def test_as_json_valid(self):
|
||||
r = LLMResponse(text='{"key": "value"}', success=True)
|
||||
data = r.as_json()
|
||||
assert data == {"key": "value"}
|
||||
|
||||
def test_as_json_invalid(self):
|
||||
r = LLMResponse(text="not json", success=True)
|
||||
assert r.as_json() is None
|
||||
|
||||
def test_as_json_empty(self):
|
||||
r = LLMResponse(text="", success=True)
|
||||
assert r.as_json() is None
|
||||
|
||||
|
||||
class TestRenderTemplate:
|
||||
def test_quest_parse_template(self):
|
||||
result = render_template("quest_parse", text="Kill 5 goblins")
|
||||
assert "Kill 5 goblins" in result
|
||||
assert "JSON" in result
|
||||
|
||||
def test_social_reply_template(self):
|
||||
result = render_template(
|
||||
"social_reply",
|
||||
message="Hello!",
|
||||
character_name="Grok",
|
||||
character_class="warrior",
|
||||
)
|
||||
assert "Grok" in result
|
||||
assert "Hello!" in result
|
||||
|
||||
def test_decide_action_template(self):
|
||||
result = render_template(
|
||||
"decide_action",
|
||||
room_title="Town Square",
|
||||
hp=100, max_hp=100, mp=50, max_mp=50,
|
||||
exits="north, south",
|
||||
npcs="guard",
|
||||
items="potion",
|
||||
combat_state="idle",
|
||||
goal="explore",
|
||||
)
|
||||
assert "Town Square" in result
|
||||
|
||||
def test_missing_variable(self):
|
||||
# Should not crash, just leave placeholder
|
||||
result = render_template("quest_parse")
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_custom_template(self):
|
||||
result = render_template("Hello {name}!", name="World")
|
||||
assert result == "Hello World!"
|
||||
|
||||
|
||||
class TestLLMClient:
|
||||
def test_client_creation(self):
|
||||
client = LLMClient(LLMConfig(model="test-model"))
|
||||
assert client.config.model == "test-model"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_unavailable(self):
|
||||
"""When server is down, should return error response."""
|
||||
client = LLMClient(LLMConfig(
|
||||
base_url="http://localhost:99999",
|
||||
max_retries=0,
|
||||
timeout_seconds=1,
|
||||
))
|
||||
resp = await client.generate("test")
|
||||
assert resp.success is False
|
||||
assert resp.error is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_available_offline(self):
|
||||
client = LLMClient(LLMConfig(base_url="http://localhost:99999"))
|
||||
available = await client.is_available()
|
||||
assert available is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LLMBrain
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLLMBrain:
|
||||
def test_creation(self):
|
||||
brain = LLMBrain(LLMConfig())
|
||||
assert brain.is_available is False
|
||||
assert brain._query_count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_offline(self):
|
||||
brain = LLMBrain(LLMConfig(base_url="http://localhost:99999"))
|
||||
result = await brain.initialize()
|
||||
assert result is False
|
||||
assert brain.is_available is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_quest_offline_returns_none(self):
|
||||
brain = LLMBrain()
|
||||
brain._available = False
|
||||
result = await brain.parse_quest("Kill the dragon")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_quest_with_mock(self):
|
||||
brain = LLMBrain()
|
||||
brain._available = True
|
||||
mock_resp = _make_json_response([
|
||||
{"type": "kill", "target": "dragon", "count": 1, "location": "cave"}
|
||||
])
|
||||
brain.client.ask = AsyncMock(return_value=mock_resp)
|
||||
|
||||
result = await brain.parse_quest("Slay the dragon in the cave")
|
||||
assert result is not None
|
||||
assert len(result) == 1
|
||||
assert result[0]["type"] == "kill"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_social_reply_offline_returns_none(self):
|
||||
brain = LLMBrain()
|
||||
brain._available = False
|
||||
result = await brain.social_reply("Hello!")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_social_reply_with_mock(self):
|
||||
brain = LLMBrain()
|
||||
brain._available = True
|
||||
brain.client.ask = AsyncMock(
|
||||
return_value=_make_mock_response("Greetings, fellow adventurer!")
|
||||
)
|
||||
result = await brain.social_reply("Hello!")
|
||||
assert result is not None
|
||||
assert "adventurer" in result.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suggest_action_offline(self):
|
||||
brain = LLMBrain()
|
||||
brain._available = False
|
||||
from core.game_state import GameState
|
||||
gs = GameState()
|
||||
result = await brain.suggest_action(gs)
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suggest_action_with_mock(self):
|
||||
brain = LLMBrain()
|
||||
brain._available = True
|
||||
brain.client.ask = AsyncMock(
|
||||
return_value=_make_mock_response("north")
|
||||
)
|
||||
from core.game_state import GameState, Room
|
||||
gs = GameState()
|
||||
gs.current_room = Room(title="Town Square", exits={"north": 0})
|
||||
result = await brain.suggest_action(gs)
|
||||
assert result == "north"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_ambiguous_offline(self):
|
||||
brain = LLMBrain()
|
||||
brain._available = False
|
||||
result = await brain.parse_ambiguous("Something happens")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_evaluate_npc_with_mock(self):
|
||||
brain = LLMBrain()
|
||||
brain._available = True
|
||||
brain.client.ask = AsyncMock(
|
||||
return_value=_make_json_response(
|
||||
{"hostile": True, "confidence": 0.9, "reason": "It's a goblin"}
|
||||
)
|
||||
)
|
||||
result = await brain.evaluate_npc("goblin", "A dark cave")
|
||||
assert result is not None
|
||||
assert result["hostile"] is True
|
||||
|
||||
def test_stats(self):
|
||||
brain = LLMBrain()
|
||||
stats = brain.stats()
|
||||
assert stats["queries"] == 0
|
||||
assert stats["available"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SocialAI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSocialAI:
|
||||
def _make_social_ai(self):
|
||||
client = LLMClient(LLMConfig())
|
||||
return SocialAI(client, character_name="Grok", character_class="warrior")
|
||||
|
||||
def test_detect_say(self):
|
||||
ai = self._make_social_ai()
|
||||
event = ai.detect_social_event("Gandalf says: Hello there!")
|
||||
assert event is not None
|
||||
assert event["type"] == "say"
|
||||
assert event["speaker"] == "Gandalf"
|
||||
|
||||
def test_detect_tell(self):
|
||||
ai = self._make_social_ai()
|
||||
event = ai.detect_social_event("Legolas tells you: Follow me")
|
||||
assert event is not None
|
||||
assert event["type"] == "tell"
|
||||
assert event["speaker"] == "Legolas"
|
||||
|
||||
def test_detect_shout(self):
|
||||
ai = self._make_social_ai()
|
||||
event = ai.detect_social_event("Gimli shouts: For glory!")
|
||||
assert event is not None
|
||||
assert event["type"] == "shout"
|
||||
|
||||
def test_detect_group_invite(self):
|
||||
ai = self._make_social_ai()
|
||||
event = ai.detect_social_event("Aragorn invites you to join a party")
|
||||
assert event is not None
|
||||
assert event["type"] == "group_invite"
|
||||
|
||||
def test_detect_no_social(self):
|
||||
ai = self._make_social_ai()
|
||||
event = ai.detect_social_event("The wind howls through the trees.")
|
||||
assert event is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_respond_say(self):
|
||||
ai = self._make_social_ai()
|
||||
ai.client.ask = AsyncMock(
|
||||
return_value=_make_mock_response("Well met, friend!")
|
||||
)
|
||||
event = {"type": "say", "speaker": "Gandalf", "message": "Hello"}
|
||||
result = await ai.respond(event)
|
||||
assert result is not None
|
||||
assert result.startswith("say ")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_respond_tell(self):
|
||||
ai = self._make_social_ai()
|
||||
ai.client.ask = AsyncMock(
|
||||
return_value=_make_mock_response("On my way!")
|
||||
)
|
||||
event = {"type": "tell", "speaker": "Legolas", "message": "Come here"}
|
||||
result = await ai.respond(event)
|
||||
assert result is not None
|
||||
assert result.startswith("tell Legolas")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_respond_group_invite(self):
|
||||
ai = self._make_social_ai()
|
||||
event = {"type": "group_invite", "speaker": "Aragorn", "message": ""}
|
||||
result = await ai.respond(event)
|
||||
assert result == "group accept"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_emote(self):
|
||||
ai = self._make_social_ai()
|
||||
ai.client.generate = AsyncMock(
|
||||
return_value=_make_mock_response("sharpens his sword quietly")
|
||||
)
|
||||
result = await ai.generate_emote("resting at the inn")
|
||||
assert result is not None
|
||||
assert result.startswith("emote ")
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Tests for the ProgressionOptimizer module."""
|
||||
|
||||
import pytest
|
||||
from modules.progression.optimizer import (
|
||||
ProgressionOptimizer, GrindingSpot, EquipmentPiece, SkillInfo,
|
||||
)
|
||||
|
||||
|
||||
class TestGrindingSpot:
|
||||
def test_exp_per_hour(self):
|
||||
spot = GrindingSpot(room_id=1, room_title="Forest", kills=10,
|
||||
total_exp=1000, total_time_seconds=3600)
|
||||
assert spot.exp_per_hour == 1000.0
|
||||
|
||||
def test_survival_rate(self):
|
||||
spot = GrindingSpot(room_id=1, room_title="Dungeon",
|
||||
kills=8, deaths=2)
|
||||
assert spot.survival_rate == 0.8
|
||||
|
||||
def test_zero_time(self):
|
||||
spot = GrindingSpot(room_id=1, room_title="Room")
|
||||
assert spot.exp_per_hour == 0.0
|
||||
assert spot.kills_per_hour == 0.0
|
||||
|
||||
|
||||
class TestEquipmentPiece:
|
||||
def test_weighted_score(self):
|
||||
eq = EquipmentPiece(name="Iron Sword", slot="weapon",
|
||||
stat_bonuses={"str": 5, "dex": 2})
|
||||
score = eq.weighted_score({"str": 2.0, "dex": 1.0})
|
||||
assert score == 12.0
|
||||
|
||||
def test_default_weights(self):
|
||||
eq = EquipmentPiece(name="Shield", slot="armor",
|
||||
stat_bonuses={"con": 3})
|
||||
score = eq.weighted_score()
|
||||
assert score == 3.0
|
||||
|
||||
|
||||
class TestSkillInfo:
|
||||
def test_damage_per_mana(self):
|
||||
skill = SkillInfo(name="Fireball", command="cast fireball",
|
||||
damage=100, mana_cost=20)
|
||||
assert skill.damage_per_mana == 5.0
|
||||
|
||||
def test_dps(self):
|
||||
skill = SkillInfo(name="Slash", command="slash",
|
||||
damage=50, cooldown_seconds=2.0)
|
||||
assert skill.dps == 25.0
|
||||
|
||||
def test_zero_mana_cost(self):
|
||||
skill = SkillInfo(name="Punch", command="punch",
|
||||
damage=10, mana_cost=0)
|
||||
assert skill.damage_per_mana == float("inf")
|
||||
|
||||
|
||||
class TestProgressionOptimizer:
|
||||
def test_record_kill(self):
|
||||
opt = ProgressionOptimizer()
|
||||
opt.record_kill(1, "Forest", exp_gained=50)
|
||||
opt.record_kill(1, "Forest", exp_gained=50)
|
||||
spots = opt.get_best_grinding_spots()
|
||||
# Need at least 3 kills for statistical relevance
|
||||
assert len(spots) == 0
|
||||
opt.record_kill(1, "Forest", exp_gained=50)
|
||||
spots = opt.get_best_grinding_spots()
|
||||
assert len(spots) == 1
|
||||
assert spots[0].kills == 3
|
||||
assert spots[0].total_exp == 150
|
||||
|
||||
def test_record_death(self):
|
||||
opt = ProgressionOptimizer()
|
||||
opt.record_death(1, "Dungeon")
|
||||
opt.record_kill(1, "Dungeon", 100)
|
||||
# Spot exists but deaths affect survival rate
|
||||
# (< 3 kills so won't appear in best spots)
|
||||
assert 1 in opt._spots
|
||||
assert opt._spots[1].deaths == 1
|
||||
|
||||
def test_add_and_compare_equipment(self):
|
||||
opt = ProgressionOptimizer()
|
||||
opt.add_equipment(EquipmentPiece(
|
||||
name="Iron Sword", slot="weapon",
|
||||
stat_bonuses={"str": 5}))
|
||||
opt.add_equipment(EquipmentPiece(
|
||||
name="Steel Sword", slot="weapon",
|
||||
stat_bonuses={"str": 8}))
|
||||
|
||||
best = opt.get_best_equipment("weapon")
|
||||
assert best is not None
|
||||
assert best.name == "Steel Sword"
|
||||
|
||||
def test_optimal_rotation(self):
|
||||
opt = ProgressionOptimizer()
|
||||
opt.add_skill(SkillInfo("Slash", "slash", damage=30, mana_cost=10, cooldown_seconds=1))
|
||||
opt.add_skill(SkillInfo("Fireball", "fireball", damage=100, mana_cost=50, cooldown_seconds=3))
|
||||
|
||||
rotation = opt.get_optimal_rotation(available_mana=40)
|
||||
# Only Slash fits within 40 mana
|
||||
assert len(rotation) == 1
|
||||
assert rotation[0].name == "Slash"
|
||||
|
||||
def test_efficient_rotation(self):
|
||||
opt = ProgressionOptimizer()
|
||||
opt.add_skill(SkillInfo("Slash", "slash", damage=30, mana_cost=10))
|
||||
opt.add_skill(SkillInfo("Fireball", "fireball", damage=100, mana_cost=50))
|
||||
efficient = opt.get_efficient_rotation()
|
||||
# Slash = 3.0 dmg/mana, Fireball = 2.0 dmg/mana
|
||||
assert efficient[0].name == "Slash"
|
||||
|
||||
def test_session_summary(self):
|
||||
opt = ProgressionOptimizer()
|
||||
summary = opt.session_summary()
|
||||
assert "total_kills" in summary
|
||||
assert "total_exp" in summary
|
||||
assert summary["total_kills"] == 0
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Tests for the QuestManager module."""
|
||||
|
||||
import pytest
|
||||
from modules.quest.quest_manager import (
|
||||
QuestManager, Quest, QuestObjective, QuestStatus,
|
||||
ObjectiveType, parse_quest_text,
|
||||
)
|
||||
|
||||
|
||||
class TestParseQuestText:
|
||||
def test_kill_quest(self):
|
||||
objs = parse_quest_text("Kill 5 goblins in the dark forest.")
|
||||
assert len(objs) >= 1
|
||||
kill_obj = [o for o in objs if o.obj_type == ObjectiveType.KILL]
|
||||
assert len(kill_obj) >= 1
|
||||
assert kill_obj[0].target_count == 5
|
||||
assert "goblin" in kill_obj[0].target.lower()
|
||||
|
||||
def test_fetch_quest(self):
|
||||
objs = parse_quest_text("Find the golden ring in the crypt.")
|
||||
assert len(objs) >= 1
|
||||
fetch_obj = [o for o in objs if o.obj_type == ObjectiveType.FETCH]
|
||||
assert len(fetch_obj) >= 1
|
||||
|
||||
def test_talk_quest(self):
|
||||
objs = parse_quest_text("Talk to the guard captain.")
|
||||
assert len(objs) >= 1
|
||||
talk_obj = [o for o in objs if o.obj_type == ObjectiveType.TALK]
|
||||
assert len(talk_obj) >= 1
|
||||
|
||||
def test_empty_text(self):
|
||||
objs = parse_quest_text("The sun is shining.")
|
||||
# May or may not parse; should not crash
|
||||
assert isinstance(objs, list)
|
||||
|
||||
def test_kill_without_count(self):
|
||||
objs = parse_quest_text("Kill the dragon.")
|
||||
kill_objs = [o for o in objs if o.obj_type == ObjectiveType.KILL]
|
||||
if kill_objs:
|
||||
assert kill_objs[0].target_count == 1
|
||||
|
||||
|
||||
class TestQuestObjective:
|
||||
def test_progress(self):
|
||||
obj = QuestObjective(description="Kill 3 rats", target="rat",
|
||||
target_count=3, current_count=1)
|
||||
assert abs(obj.progress - 1/3) < 0.01
|
||||
assert not obj.is_done
|
||||
|
||||
def test_completion(self):
|
||||
obj = QuestObjective(description="Kill 2 rats", target="rat",
|
||||
target_count=2, current_count=2)
|
||||
assert obj.is_done
|
||||
|
||||
|
||||
class TestQuestManager:
|
||||
def test_discover_quest(self):
|
||||
qm = QuestManager()
|
||||
q = qm.discover_quest("goblin_hunt", "Kill 5 goblins.", reward_exp=100)
|
||||
assert q.name == "goblin_hunt"
|
||||
assert q.status == QuestStatus.DISCOVERED
|
||||
assert qm.quest_count == 1
|
||||
|
||||
def test_accept_quest(self):
|
||||
qm = QuestManager()
|
||||
qm.discover_quest("test", "Kill the rat.")
|
||||
assert qm.accept_quest("test") is True
|
||||
q = qm.get_quest("test")
|
||||
assert q.status == QuestStatus.ACCEPTED
|
||||
|
||||
def test_cannot_accept_twice(self):
|
||||
qm = QuestManager()
|
||||
qm.discover_quest("test", "Kill the rat.")
|
||||
qm.accept_quest("test")
|
||||
assert qm.accept_quest("test") is False
|
||||
|
||||
def test_update_objective(self):
|
||||
qm = QuestManager()
|
||||
qm.discover_quest("hunt", "Kill 3 goblins.")
|
||||
qm.accept_quest("hunt")
|
||||
qm.update_objective("hunt", "goblin")
|
||||
q = qm.get_quest("hunt")
|
||||
kill_objs = [o for o in q.objectives if o.obj_type == ObjectiveType.KILL]
|
||||
if kill_objs:
|
||||
assert kill_objs[0].current_count >= 1
|
||||
|
||||
def test_complete_quest(self):
|
||||
qm = QuestManager()
|
||||
qm.discover_quest("test", "Talk to the guard.", reward_exp=50)
|
||||
qm.accept_quest("test")
|
||||
assert qm.complete_quest("test") is True
|
||||
assert qm.get_quest("test").status == QuestStatus.COMPLETED
|
||||
|
||||
def test_fail_quest(self):
|
||||
qm = QuestManager()
|
||||
qm.discover_quest("test", "desc")
|
||||
qm.accept_quest("test")
|
||||
assert qm.fail_quest("test") is True
|
||||
assert qm.get_quest("test").status == QuestStatus.FAILED
|
||||
|
||||
def test_active_quests_sorted(self):
|
||||
qm = QuestManager()
|
||||
qm.discover_quest("low_exp", "Kill the rat.", reward_exp=10)
|
||||
qm.discover_quest("high_exp", "Kill the dragon.", reward_exp=1000)
|
||||
qm.accept_quest("low_exp")
|
||||
qm.accept_quest("high_exp")
|
||||
active = qm.get_active_quests()
|
||||
assert active[0].name == "high_exp"
|
||||
|
||||
def test_get_best_quest(self):
|
||||
qm = QuestManager()
|
||||
qm.discover_quest("a", "Kill goblin.", reward_exp=100)
|
||||
qm.accept_quest("a")
|
||||
best = qm.get_best_quest()
|
||||
assert best is not None
|
||||
assert best.name == "a"
|
||||
|
||||
def test_pending_kill_targets(self):
|
||||
qm = QuestManager()
|
||||
qm.discover_quest("hunt", "Kill 3 goblins.")
|
||||
qm.accept_quest("hunt")
|
||||
targets = qm.get_pending_kill_targets()
|
||||
# Should include "goblins" or similar
|
||||
assert len(targets) >= 0 # depends on parse
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Tests for WorldMapper, ActiveStorage, and related mapper components."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import pytest
|
||||
from modules.mapper.active_storage import ActiveStorage, generate_fingerprint
|
||||
from modules.mapper.world_mapper import WorldMapper
|
||||
from modules.mapper.pathfinder import Pathfinder
|
||||
|
||||
|
||||
class TestGenerateFingerprint:
|
||||
def test_deterministic(self):
|
||||
fp1 = generate_fingerprint("Town Square", "A large square.", ["north", "south"])
|
||||
fp2 = generate_fingerprint("Town Square", "A large square.", ["north", "south"])
|
||||
assert fp1 == fp2
|
||||
|
||||
def test_different_exits_different_fp(self):
|
||||
fp1 = generate_fingerprint("Room", "Desc", ["north"])
|
||||
fp2 = generate_fingerprint("Room", "Desc", ["south"])
|
||||
assert fp1 != fp2
|
||||
|
||||
def test_exit_order_irrelevant(self):
|
||||
fp1 = generate_fingerprint("Room", "Desc", ["north", "south"])
|
||||
fp2 = generate_fingerprint("Room", "Desc", ["south", "north"])
|
||||
assert fp1 == fp2
|
||||
|
||||
def test_case_insensitive(self):
|
||||
fp1 = generate_fingerprint("Town Square", "Desc", ["north"])
|
||||
fp2 = generate_fingerprint("town square", "desc", ["north"])
|
||||
assert fp1 == fp2
|
||||
|
||||
|
||||
class TestActiveStorage:
|
||||
@pytest.fixture
|
||||
def storage(self, tmp_path):
|
||||
db = tmp_path / "test.db"
|
||||
s = ActiveStorage(db_path=str(db))
|
||||
yield s
|
||||
s.close()
|
||||
|
||||
def test_add_and_get_room(self, storage):
|
||||
room_id = storage.add_room({
|
||||
"fingerprint": "abc123",
|
||||
"title": "Town Square",
|
||||
"description": "A big square.",
|
||||
"exits": {"north": None},
|
||||
"npcs": ["guard"],
|
||||
"items": ["potion"],
|
||||
})
|
||||
assert room_id > 0
|
||||
room = storage.get_room(room_id)
|
||||
assert room is not None
|
||||
assert room["title"] == "Town Square"
|
||||
assert room["npcs"] == ["guard"]
|
||||
|
||||
def test_revisit_increments_count(self, storage):
|
||||
storage.add_room({"fingerprint": "abc", "title": "Room"})
|
||||
storage.add_room({"fingerprint": "abc", "title": "Room"})
|
||||
room = storage.get_room_by_fingerprint("abc")
|
||||
assert room["visit_count"] == 2
|
||||
|
||||
def test_room_count(self, storage):
|
||||
assert storage.room_count() == 0
|
||||
storage.add_room({"fingerprint": "a", "title": "A"})
|
||||
storage.add_room({"fingerprint": "b", "title": "B"})
|
||||
assert storage.room_count() == 2
|
||||
|
||||
def test_get_all_rooms(self, storage):
|
||||
storage.add_room({"fingerprint": "a", "title": "A"})
|
||||
storage.add_room({"fingerprint": "b", "title": "B"})
|
||||
all_rooms = storage.get_all_rooms()
|
||||
assert len(all_rooms) == 2
|
||||
|
||||
def test_get_room_by_fingerprint(self, storage):
|
||||
storage.add_room({"fingerprint": "fp123", "title": "Test"})
|
||||
room = storage.get_room_by_fingerprint("fp123")
|
||||
assert room is not None
|
||||
assert room["title"] == "Test"
|
||||
assert storage.get_room_by_fingerprint("nonexistent") is None
|
||||
|
||||
def test_add_exit(self, storage):
|
||||
id1 = storage.add_room({"fingerprint": "a", "title": "A"})
|
||||
id2 = storage.add_room({"fingerprint": "b", "title": "B"})
|
||||
storage.add_exit(id1, id2, "north")
|
||||
assert storage.graph.has_edge(id1, id2)
|
||||
|
||||
def test_exploration_queue(self, storage):
|
||||
rid = storage.add_room({"fingerprint": "a", "title": "A"})
|
||||
storage.enqueue_unexplored(rid, "north", priority=3)
|
||||
storage.enqueue_unexplored(rid, "south", priority=5)
|
||||
assert storage.exploration_queue_size() == 2
|
||||
|
||||
result = storage.dequeue_next_exploration()
|
||||
assert result == (rid, "north") # lower priority number = higher priority
|
||||
assert storage.exploration_queue_size() == 1
|
||||
|
||||
|
||||
class TestWorldMapper:
|
||||
@pytest.fixture
|
||||
def mapper(self, tmp_path):
|
||||
db = tmp_path / "map.db"
|
||||
m = WorldMapper(db_path=str(db))
|
||||
yield m
|
||||
m.close()
|
||||
|
||||
def test_on_room_enter(self, mapper):
|
||||
rid = mapper.on_room_enter(
|
||||
title="Town Square",
|
||||
description="A big square.",
|
||||
exits=["north", "south"],
|
||||
npcs=["guard"],
|
||||
)
|
||||
assert rid > 0
|
||||
assert mapper.total_rooms == 1
|
||||
assert mapper.current_room_id == rid
|
||||
|
||||
def test_linking_rooms_via_movement(self, mapper):
|
||||
r1 = mapper.on_room_enter("Room A", "Desc A", ["north"])
|
||||
mapper.on_move("north")
|
||||
r2 = mapper.on_room_enter("Room B", "Desc B", ["south"])
|
||||
|
||||
# Should have an edge from r1 -> r2 via "north"
|
||||
assert mapper.storage.graph.has_edge(r1, r2)
|
||||
# And reverse edge r2 -> r1 via "south"
|
||||
assert mapper.storage.graph.has_edge(r2, r1)
|
||||
|
||||
def test_pathfinding(self, mapper):
|
||||
r1 = mapper.on_room_enter("Room A", "Desc A", ["north"])
|
||||
mapper.on_move("north")
|
||||
r2 = mapper.on_room_enter("Room B", "Desc B", ["south", "east"])
|
||||
mapper.on_move("east")
|
||||
r3 = mapper.on_room_enter("Room C", "Desc C", ["west"])
|
||||
|
||||
# Path from current (r3) to r1
|
||||
dirs = mapper.find_path(r1)
|
||||
assert dirs is not None
|
||||
assert len(dirs) == 2
|
||||
assert dirs[0] == "west"
|
||||
assert dirs[1] == "south"
|
||||
|
||||
def test_revisit_does_not_duplicate(self, mapper):
|
||||
mapper.on_room_enter("Room A", "Desc A", ["north"])
|
||||
mapper.on_room_enter("Room A", "Desc A", ["north"])
|
||||
assert mapper.total_rooms == 1
|
||||
|
||||
def test_on_move_failed(self, mapper):
|
||||
mapper.on_room_enter("Room A", "Desc A", ["north"])
|
||||
mapper.on_move_failed("north")
|
||||
# Blocked exit should not create a graph edge
|
||||
edges = list(mapper.storage.graph.edges(mapper.current_room_id))
|
||||
assert len(edges) == 0
|
||||
|
||||
def test_exploration_target(self, mapper):
|
||||
mapper.on_room_enter("Room A", "Desc A", ["north", "south"])
|
||||
target = mapper.get_next_exploration_target()
|
||||
assert target is not None
|
||||
assert target["direction"] in ("north", "south")
|
||||
|
||||
|
||||
class TestPathfinder:
|
||||
def test_find_path_and_directions(self):
|
||||
import networkx as nx
|
||||
g = nx.DiGraph()
|
||||
g.add_node(1); g.add_node(2); g.add_node(3)
|
||||
g.add_edge(1, 2, direction="north")
|
||||
g.add_edge(2, 3, direction="east")
|
||||
|
||||
pf = Pathfinder(g)
|
||||
path = pf.find_path(1, 3)
|
||||
assert path == [1, 2, 3]
|
||||
dirs = pf.get_directions(path)
|
||||
assert dirs == ["north", "east"]
|
||||
|
||||
def test_no_path(self):
|
||||
import networkx as nx
|
||||
g = nx.DiGraph()
|
||||
g.add_node(1); g.add_node(2)
|
||||
pf = Pathfinder(g)
|
||||
assert pf.find_path(1, 2) is None
|
||||
Reference in new issue
Block a user