From eb0ffb61cd67c19510b8ad624d05702ed014f63c Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Mon, 13 Nov 2023 18:37:45 -0700 Subject: [PATCH] surprise rolls and combat round stub --- osrlib/osrlib/encounter.py | 107 ++++++++++++++++++++++-------- osrlib/osrlib/game_manager.py | 6 ++ osrlib/osrlib/monster.py | 35 +++++----- osrlib/osrlib/party.py | 7 ++ osrlib/osrlib/player_character.py | 14 ++-- tests/pytest.ini | 2 +- tests/test_unit_combat.py | 38 ++++++++++- tests/test_unit_encounter.py | 4 +- 8 files changed, 158 insertions(+), 55 deletions(-) diff --git a/osrlib/osrlib/encounter.py b/osrlib/osrlib/encounter.py index 49dedd6..0090017 100644 --- a/osrlib/osrlib/encounter.py +++ b/osrlib/osrlib/encounter.py @@ -1,22 +1,10 @@ from collections import deque +from typing import Optional +import random from osrlib.party import Party -from osrlib.monster import MonsterParty - -# 1. **Surprise check**: The DM rolls `1d6` for monster party and PC party to check for surprise. -# - If monster roll is higher: party is surprised and monsters attack. -# - If PC roll is higher: monsters are surprised and PCs choose their reaction (fight, run, talk, pass). -# - If it's a tie: same as PC roll is higher. -# 2. **PC reactiont**: -# - If PC party chooses to fight, combat begins. -# - If PC party runs away, the encounter ends. -# -# 5. All combatants roll initiative (`1d6`). -# 6. The DM deques first combatant to act. -# 1. Combatant chooses target: -# - if combatant is PC, player chooses a monster to attack -# - if combatant is monster, DM chooses target PC at random -# 2. If weapon, roll `1d20` to hit; if PC, if melee add To Hit modifier, if ranged add Dexterity modifier and other to-hit modifiers -# 3. Roll damage if weapon/ranged hit was successful or target failed save vs. spell; if PC, add Strength modifier and other damage modifiers. +from osrlib.monster import Monster, MonsterParty +from osrlib.game_manager import logger +from osrlib.player_character import PlayerCharacter class Encounter: @@ -30,6 +18,9 @@ class Encounter: description (str): The description of the encounter (location, environment, etc.). monsters (MonsterParty): The party of monsters in the encounter. treasure (list): A list of the treasure in the encounter. The treasure can be any item like weapons, armor, quest pieces, or gold pieces (or gems or other valuables). Optional. + turn_order (deque): A deque of the combatants in the encounter, sorted by initiative roll. + is_started (bool): Whether the encounter has started. + is_ended (bool): Whether the encounter has ended. """ def __init__( @@ -37,7 +28,6 @@ def __init__( name, description: str = "", monster_party: MonsterParty = None, - # npc: NPC = None, # TODO: Implement NPC class treasure: list = [], ): """Initialize the encounter object. @@ -53,29 +43,92 @@ def __init__( self.monster_party = monster_party # self.npc = npc # TODO: Implement NPC class self.treasure = treasure - self.pc_party = None - self.turn_order = deque() + self.pc_party: Optional[Party] = None + self.combat_queue: deque = deque() + self.is_started: bool = False + self.is_ended: bool = False + self.current_round: Optional[CombatRound] = None def __str__(self): """Return a string representation of the encounter.""" return f"{self.name}: {self.description}" def start_encounter(self, party: Party): + self.is_started = True + logger.debug(f"Starting encounter '{self.name}'...") + self.pc_party = party - # TODO: Roll for surprise + if self.monster_party is None: + logger.debug(f"Encounter {self.name} has no monsters - continuing as non-combat encounter.") + return + + # 1. **Surprise check**: The DM rolls `1d6` for monster party and PC party to check for surprise. + # - If monster roll is higher: party is surprised and monsters attack. + # - If PC roll is higher: monsters are surprised and PCs choose their reaction (fight, run, talk, pass). + # - If it's a tie: same as PC roll is higher. + pc_party_surprise_roll = self.pc_party.get_surprise_roll() + monster_party_surprise_roll = self.monster_party.get_surprise_roll() + + if pc_party_surprise_roll > monster_party_surprise_roll: + logger.debug(f"Monsters are surprised! PC surprise roll: {pc_party_surprise_roll}, monster surprise roll: {monster_party_surprise_roll}") + elif monster_party_surprise_roll > pc_party_surprise_roll: + logger.debug(f"PCs are surprised! PC surprise roll:: {pc_party_surprise_roll}, monster surprise roll: {monster_party_surprise_roll}") + else: + logger.debug(f"PC party and monsters both rolled {pc_party_surprise_roll} for surprise.") + + # 2. **PC reaction**: + # - If PC party chooses to fight, combat begins. + # - If PC party runs away, the encounter ends. + + def start_combat(self): + logger.debug(f"Starting combat in encounter '{self.name}'...") # Get initiative rolls for both parties party_initiative = [(pc, pc.get_initiative_roll()) for pc in self.pc_party.members] monster_initiative = [(monster, monster.get_initiative_roll()) for monster in self.monster_party.members] # Combine and sort the combatants by initiative roll - combined_initiative = sorted(party_initiative + monster_initiative, key=lambda x: x[1].total_with_modifier, reverse=True) + combatants_sorted_by_initiative = sorted(party_initiative + monster_initiative, key=lambda x: x[1], reverse=True) + + # Populate the combat queue + logger.debug(f"Populating combat queue with {len(party_initiative)} PCs and {len(monster_initiative)} monsters ({len(combatants_sorted_by_initiative)} total):") + self.combat_queue.extend(combatants_sorted_by_initiative) + for combatant in self.combat_queue: + logger.debug(f"Initiative {combatant[1]}: {combatant[0].name}") + + def execute_combat_round(self): + + # Deque first combatant to act + attacker = self.combat_queue.popleft()[0] + + # If combatant is PC, player chooses a monster to attack + if attacker is PlayerCharacter: + logger.debug(f"PC {attacker.name} (HP: {attacker.character_class.hp}) turn to act.") + # defender = NOT_YET_IMPLEMENTED # TODO: Player needs to be given option to select a target here - how? + elif attacker is Monster: + logger.debug(f"{attacker.name}'s ({attacker.hit_points}/{attacker.max_hit_points}) turn to act.") + # Choose target at random since attacker is monster + defender = random.choice(self.pc_party.members) + + + # 3. If weapon attack, roll `1d20` to hit; if PC and melee add To Hit modifier, if ranged add Dexterity modifier and other to-hit modifiers + # - If weapon hits, roll damage + # - Add Strength and other damage modifiers as applicable + # 4. If spell attack, target rolls save vs. spells + + # Add the attacker back into the combat queue + self.combat_queue.append((attacker, 0)) + + def end_encounter(self): + + # TODO: Award XP and treasure to PCs if monster party is defeated + + self.is_started = False + self.is_ended = True - # Populate the turn order deque - self.turn_order.extend(combined_initiative) - # Get the current combatant and their initiative - current_combatant, initiative = self.turn_order.popleft() +class CombatRound: + """A combat round represents a single round of combat in an encounter.""" - # TODO: Implement combat methods \ No newline at end of file + pass \ No newline at end of file diff --git a/osrlib/osrlib/game_manager.py b/osrlib/osrlib/game_manager.py index bd708ae..94b3ebf 100644 --- a/osrlib/osrlib/game_manager.py +++ b/osrlib/osrlib/game_manager.py @@ -4,6 +4,12 @@ import logging import warnings +# Configure logging +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s [%(levelname)s][%(module)s::%(funcName)s] %(message)s' +) + logger = logging.getLogger(__name__) diff --git a/osrlib/osrlib/monster.py b/osrlib/osrlib/monster.py index 4f0927a..97e2ba5 100644 --- a/osrlib/osrlib/monster.py +++ b/osrlib/osrlib/monster.py @@ -4,6 +4,7 @@ from osrlib.player_character import Alignment from osrlib.treasure import TreasureType from osrlib.saving_throws import get_saving_throws_for_class_and_level +from osrlib.game_manager import logger monster_xp = { "Under 1": {"base": 5, "bonus": 1}, # Not handling the "under 1" hit dice case @@ -112,7 +113,7 @@ def _calculate_xp(self, hp_roll: DiceRoll, num_special_abilities: int = 0): """Get the total XP value of the monster. The XP value is based on the monster's hit dice and number of special abilities. Args: - hp_roll (DiceRoll): The roll of the monster's hit dice. + hp_roll (DiceRoll): The dice roll used to determine the monster's hit points. The number of hit dice is used to determine the XP value. Returns: int: The total XP value of the monster. @@ -127,26 +128,26 @@ def _calculate_xp(self, hp_roll: DiceRoll, num_special_abilities: int = 0): return base_xp + bonus * num_special_abilities # Handle monsters with 1 hit die and up - if hp_roll.count <= 8: + if hp_roll.num_dice <= 8: # XP values for monsters with 1-8 hit dice have single values if hp_roll.modifier > 0: plus = "+" - base_xp = monster_xp[f"{hp_roll.count}{plus}"]["base"] - bonus = monster_xp[f"{hp_roll.count}{plus}"]["bonus"] + base_xp = monster_xp[f"{hp_roll.num_dice}{plus}"]["base"] + bonus = monster_xp[f"{hp_roll.num_dice}{plus}"]["bonus"] # XP values for monsters with 9+ hit dice use a single value for a range of hit dice - elif hp_roll.count >= 9 and hp_roll.count <= 10: + elif hp_roll.num_dice >= 9 and hp_roll.num_dice <= 10: base_xp = monster_xp["9 to 10+"]["base"] bonus = monster_xp["9 to 10+"]["bonus"] - elif hp_roll.count >= 11 and hp_roll.count <= 12: + elif hp_roll.num_dice >= 11 and hp_roll.num_dice <= 12: base_xp = monster_xp["11 to 12+"]["base"] bonus = monster_xp["11 to 12+"]["bonus"] - elif hp_roll.count >= 13 and hp_roll.count <= 16: + elif hp_roll.num_dice >= 13 and hp_roll.num_dice <= 16: base_xp = monster_xp["13 to 16+"]["base"] bonus = monster_xp["13 to 16+"]["bonus"] - elif hp_roll.count >= 17 and hp_roll.count <= 20: + elif hp_roll.num_dice >= 17 and hp_roll.num_dice <= 20: base_xp = monster_xp["17 to 20+"]["base"] bonus = monster_xp["17 to 20+"]["bonus"] - elif hp_roll.count >= 21: + elif hp_roll.num_dice >= 21: base_xp = monster_xp["21+"]["base"] bonus = monster_xp["21+"]["bonus"] @@ -167,6 +168,7 @@ def is_alive(self): def get_initiative_roll(self): """Rolls a 1d6 and returns the total for the monster's initiative.""" roll = roll_dice("1d6") + logger.debug(f"{self.name} rolled {roll} for initiative and got {roll.total_with_modifier}.") return roll.total_with_modifier def apply_damage(self, hit_points_damage: int): @@ -198,7 +200,7 @@ class MonsterParty: """A group of monsters the party can encounter in a dungeon location. Attributes: - monsters (list): A list of the monsters in the monster party. + members (list): A list of the monsters in the monster party. is_alive (bool): True if at least one monster in the monster party is alive, otherwise False. """ @@ -211,7 +213,7 @@ def __init__(self, monster_stat_block: MonsterStatsBlock): Args: monster_stat_block (MonsterStatBlock): The stat block for the monsters in the party. """ - self.monsters = [ + self.members = [ Monster(monster_stat_block) for _ in range( roll_dice(monster_stat_block.num_appearing).total_with_modifier @@ -287,7 +289,7 @@ def is_alive(self): Returns: int: True if the monster party has more than 0 hit points, otherwise False. """ - return any(monster.is_alive for monster in self.monsters) + return any(monster.is_alive for monster in self.members) @property def xp_value(self): @@ -296,11 +298,12 @@ def xp_value(self): Returns: int: The total XP value of the monster party. """ - monster_xp = sum(monster.xp_value for monster in self.monsters) + monster_xp = sum(monster.xp_value for monster in self.members) treasure_xp = 0 # TODO: sum(item.xp_value for item in self.treasure) return monster_xp + treasure_xp - def get_reaction_check(self): - """Rolls a 2d6 and returns the total for the monster party's reaction check.""" - roll = roll_dice("2d6") + def get_surprise_roll(self) -> int: + """Rolls a 1d6 and returns the total for the monster party's surprise roll.""" + roll = roll_dice("1d6") + logger.debug(f"Monster party rolled {roll} for surprise and got {roll.total_with_modifier}.") return roll.total_with_modifier \ No newline at end of file diff --git a/osrlib/osrlib/party.py b/osrlib/osrlib/party.py index ee91008..adfb52c 100644 --- a/osrlib/osrlib/party.py +++ b/osrlib/osrlib/party.py @@ -7,6 +7,7 @@ from osrlib.game_manager import logger from osrlib.enums import CharacterClassType from osrlib.item_factories import equip_party +from osrlib.dice_roller import roll_dice class PartyAtCapacityError(Exception): @@ -399,6 +400,12 @@ def clear_party(self): """Removes all characters from the party.""" self.members.clear() + def get_surprise_roll(self) -> int: + """Rolls a 1d6 and returns the result for the party's surprise roll.""" + roll = roll_dice("1d6") + logger.debug(f"Player party rolled {roll} for surprise and got {roll.total_with_modifier}.") + return roll.total_with_modifier + def to_dict(self): party_dict = { "characters": [character.to_dict() for character in self.members], diff --git a/osrlib/osrlib/player_character.py b/osrlib/osrlib/player_character.py index 368991d..98ad1ad 100644 --- a/osrlib/osrlib/player_character.py +++ b/osrlib/osrlib/player_character.py @@ -14,12 +14,9 @@ from osrlib.character_classes import ( CharacterClass, CharacterClassType, - ClassLevel, ) from osrlib.inventory import Inventory -from osrlib import ( - dice_roller, -) +from osrlib.dice_roller import roll_dice, DiceRoll from osrlib.game_manager import logger @@ -116,15 +113,16 @@ def armor_class(self): def get_ability_roll(self): """Rolls a 4d6 and returns the sum of the three highest rolls.""" - roll = dice_roller.roll_dice("4d6", drop_lowest=True) + roll = roll_dice("4d6", drop_lowest=True) return roll.total - def get_initiative_roll(self): + def get_initiative_roll(self) -> int: """Rolls a 1d6, adds the character's Dexterity modifier, and returns the total.""" modifier_value = self.abilities[AbilityType.DEXTERITY].modifiers[ ModifierType.INITIATIVE ] - roll = dice_roller.roll_dice("1d6", modifier_value=modifier_value) + roll = roll_dice("1d6", modifier_value) + logger.debug(f"{self.name} ({self.character_class.class_type.value}) rolled {roll} for initiative and got {roll.total_with_modifier}.") return roll.total_with_modifier def _set_prime_requisite_xp_adjustment(self): @@ -203,7 +201,7 @@ def roll_abilities(self): f"{self.name} rolled {ability_instance.ability_type.name}:{roll}" ) - def roll_hp(self) -> dice_roller.DiceRoll: + def roll_hp(self) -> DiceRoll: """Rolls the character's hit points, taking into account their Constitution modifier, if any. The total value of the roll with modifier can be negative after if the roll was low and the character has a diff --git a/tests/pytest.ini b/tests/pytest.ini index 27ee635..8d9b9ad 100644 --- a/tests/pytest.ini +++ b/tests/pytest.ini @@ -2,7 +2,7 @@ [pytest] log_cli = true log_cli_level = INFO -log_cli_format = %(asctime)s [%(levelname)s] %(message)s +log_cli_format = %(asctime)s [%(levelname)s][%(module)s::%(funcName)s] %(message)s log_cli_date_format = %Y-%m-%d %H:%M:%S markers = flaky: mark a test as flaky (deselect with '-m "not flaky"') diff --git a/tests/test_unit_combat.py b/tests/test_unit_combat.py index 8687221..35b6ca9 100644 --- a/tests/test_unit_combat.py +++ b/tests/test_unit_combat.py @@ -51,7 +51,7 @@ def pc_party(): def goblin_party(): stats = MonsterStatsBlock( name="Goblin", - description="A small incredibly ugly humanoid with pale earthy color skin, like a chalky tan or livid gray.", + description="A small and incredibly ugly humanoid with pale earthy color skin, like a chalky tan or livid gray.", armor_class=6, hit_dice="1d8-1", num_appearing="2d4", @@ -110,6 +110,10 @@ def kobold_party(): monster_party = MonsterParty(stats) yield monster_party +@pytest.fixture +def goblin_encounter(goblin_party): + yield Encounter("Goblin Ambush", "A group of goblins ambush the party.", goblin_party) + def get_thac0_for_class_for_level(char_class_type, level): for level_range, thac0 in class_thac0[char_class_type].items(): if level_range[0] <= level <= level_range[1]: @@ -135,3 +139,35 @@ def test_thac0_for_classes_and_levels(pc_party): logger.error(f"[{pc.character_class.class_type.value}] Expected THAC0 {expected_thac0} != Actual THAC0 {actual_thac0} for level {level.level_num}!") assert expected_thac0 == actual_thac0 + +def test_encounter_start(pc_party, goblin_encounter): + encounter = goblin_encounter + encounter.start_encounter(pc_party) + assert encounter.is_started == True + +def test_encounter_start_combat(pc_party, goblin_encounter): + encounter = goblin_encounter + encounter.start_encounter(pc_party) + encounter.start_combat() + assert encounter.is_started == True + assert encounter.is_ended == False + +def test_encounter_end(pc_party, goblin_encounter): + encounter = goblin_encounter + encounter.start_encounter(pc_party) + encounter.start_combat() + encounter.end_encounter() + assert encounter.is_started == False + assert encounter.is_ended == True + +def test_encounter_combat_queue(pc_party, goblin_encounter): + encounter = goblin_encounter + encounter.start_encounter(pc_party) + encounter.start_combat() + assert len(encounter.combat_queue) == len(encounter.pc_party.members) + len(encounter.monster_party.members) + +def test_execute_combat_rount(pc_party, goblin_encounter): + encounter = goblin_encounter + encounter.start_encounter(pc_party) + encounter.start_combat() + encounter.execute_combat_round() diff --git a/tests/test_unit_encounter.py b/tests/test_unit_encounter.py index 13f5b80..fdde90a 100644 --- a/tests/test_unit_encounter.py +++ b/tests/test_unit_encounter.py @@ -77,5 +77,5 @@ def test_encounter_initialization(kobold_party): assert encounter.monster_party == kobold_party # TODO: These should probably move to a test for MonsterParty - assert len(encounter.monster_party.monsters) >= 4 and len(encounter.monster_party.monsters) <= 16 - assert encounter.monster_party.xp_value == len(kobold_party.monsters) * 5 # Under 1 HD = 5 XP + assert len(encounter.monster_party.members) >= 4 and len(encounter.monster_party.members) <= 16 + assert encounter.monster_party.xp_value == len(kobold_party.members) * 5 # Under 1 HD = 5 XP