Skip to content

Commit

Permalink
Merge pull request #19 from mmacy/combat-work-03
Browse files Browse the repository at this point in the history
combat work 3 of n
  • Loading branch information
mmacy committed Nov 14, 2023
2 parents 849d8ca + eb0ffb6 commit 5f8e101
Show file tree
Hide file tree
Showing 8 changed files with 158 additions and 55 deletions.
107 changes: 80 additions & 27 deletions osrlib/osrlib/encounter.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -30,14 +18,16 @@ 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__(
self,
name,
description: str = "",
monster_party: MonsterParty = None,
# npc: NPC = None, # TODO: Implement NPC class
treasure: list = [],
):
"""Initialize the encounter object.
Expand All @@ -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
pass
6 changes: 6 additions & 0 deletions osrlib/osrlib/game_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)


Expand Down
35 changes: 19 additions & 16 deletions osrlib/osrlib/monster.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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"]

Expand All @@ -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):
Expand Down Expand Up @@ -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.
"""

Expand All @@ -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
Expand Down Expand Up @@ -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):
Expand All @@ -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
7 changes: 7 additions & 0 deletions osrlib/osrlib/party.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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],
Expand Down
14 changes: 6 additions & 8 deletions osrlib/osrlib/player_character.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/pytest.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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"')
Expand Down
38 changes: 37 additions & 1 deletion tests/test_unit_combat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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]:
Expand All @@ -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()
Loading

0 comments on commit 5f8e101

Please sign in to comment.