Skip to content

Commit

Permalink
Merge pull request #21 from mmacy/enable-active-weapon
Browse files Browse the repository at this point in the history
combat work: weapon support for PCs
  • Loading branch information
mmacy committed Nov 16, 2023
2 parents 5530b6e + 13a19e3 commit 967a793
Show file tree
Hide file tree
Showing 14 changed files with 218 additions and 86 deletions.
18 changes: 13 additions & 5 deletions osrlib/osrlib/character_classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,16 +69,22 @@ def __init__(self, character_class_type: CharacterClassType, level: int = 1, con

# If the character is not first level, roll hit points for the character using the hit dice for that level
if level > 1:
self.hp = max(roll_dice(self.levels[level].hit_dice, constitution_modifier).total_with_modifier, 1)
self.max_hp = max(roll_dice(self.levels[level].hit_dice, constitution_modifier).total_with_modifier, 1)
else:
self.hp = max(self.roll_hp(constitution_modifier).total_with_modifier, 1)
self.max_hp = max(self.roll_hp(constitution_modifier).total_with_modifier, 1)

self.hp = self.max_hp
self.xp = self.current_level.xp_required_for_level

def __str__(self) -> str:
"""Return a string representation of the CharacterClass instance."""
return self.class_type.value

@property
def xp_needed_for_next_level(self) -> int:
"""Return the XP needed to reach the next level."""
return self.levels[self.current_level.level_num + 1].xp_required_for_level

def roll_hp(self, hp_modifier: int = 0) -> DiceRoll:
"""Roll hit points for the character.
Expand Down Expand Up @@ -121,11 +127,13 @@ def level_up(self, hp_modifier: int = 0) -> bool:
if self.xp >= xp_needed_for_next_level:
if self.current_level.level_num < len(self.levels) - 1:
self.current_level = self.levels[self.current_level.level_num + 1]
self.hp += max(self.roll_hp(hp_modifier).total_with_modifier, 1)
self.max_hp += max(self.roll_hp(hp_modifier).total_with_modifier, 1)
self.hp = self.max_hp
logger.debug(f"{self.class_type.name} is now level {self.current_level.level_num}!")
else:
logger.info(f"[{self.class_type.name}] Can't level up: already at max level {self.current_level.level_num}.")
logger.debug(f"{self.class_type.name} Can't level up: already at max level {self.current_level.level_num}.")
else:
logger.info(f"[{self.class_type.name}] Can't level up: {self.xp} XP is less than {xp_needed_for_next_level} XP needed for next level.")
logger.debug(f"{self.class_type.name} needs {xp_needed_for_next_level - self.xp} XP to reach level {self.current_level.level_num + 1}.")

return self.current_level.level_num > level_num_before

Expand Down
6 changes: 3 additions & 3 deletions osrlib/osrlib/dice_roller.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import re
from collections import namedtuple


# TODO: Change total_with_modifier to total_without_modifier and make total the total_with_modifer
class DiceRoll(
namedtuple(
"RollResultBase",
Expand Down Expand Up @@ -80,8 +80,8 @@ def roll_dice(notation: str, modifier: int = 0, drop_lowest: bool = False):
num_sides = int(num_sides)
modifier = int(modifier) if modifier else 0

if num_sides not in [4, 6, 8, 10, 12, 20, 100]:
raise ValueError("Invalid number of dice sides. Choose from 4, 6, 8, 10, 12, 20, 100.")
if num_sides not in [1, 2, 3, 4, 6, 8, 10, 12, 20, 100]:
raise ValueError("Invalid number of dice sides. Choose from 1, 2, 3, 4, 6, 8, 10, 12, 20, 100.")

die_rolls = [rand_gen.randint(1, num_sides) for _ in range(num_dice)]

Expand Down
7 changes: 7 additions & 0 deletions osrlib/osrlib/dungeon_master.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@
"paper (but don't mention location IDs or the map): "
)

battle_summary_prompt = (
"Summarize the battle that appears in this log. Keep the summary to a single paragraph. Include highlights of the battle, "
"focusing on notable die rolls like high damage rolls by the player characters.Do not mention the monster's attack "
"roll values. Alternate between using the character's name and their class when referring to them in the summary. "
"Keep the summary under 90 words.Await for the next message before responding with the summary."
)

class DungeonMaster:
"""The DungeonMaster is the primary interface between the player, the game engine, and the OpenAI API.
Expand Down
60 changes: 35 additions & 25 deletions osrlib/osrlib/encounter.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
from collections import deque
from typing import Optional
import math
import random
from osrlib.party import Party
from osrlib.monster import Monster, MonsterParty
from osrlib.monster import MonsterParty
from osrlib.game_manager import logger
from osrlib.player_character import PlayerCharacter


class Encounter:
Expand Down Expand Up @@ -84,61 +84,71 @@ def _start_combat(self):
# Combine and sort the combatants by initiative roll
combatants_sorted_by_initiative = sorted(party_initiative + monster_initiative, key=lambda x: x[1], reverse=True)

# Populate the combat queue
self.combat_queue.extend(combatants_sorted_by_initiative)
# Populate the combat queue with only the combatant objects
self.combat_queue.extend([combatant[0] for combatant in combatants_sorted_by_initiative])

# Start combat
round_num = 0 # Track rounds for spell and other time-based effects
while self.pc_party.is_alive and self.monster_party.is_alive and round_num < 1000:
round_num += 1
self.execute_combat_round(round_num)

if self.pc_party.is_alive:
logger.debug(f"{self.pc_party.name} won the battle!")
elif self.monster_party.is_alive:
logger.debug("The monsters won the battle!")

# No living members in one of the parties - end the encounter
# All members of one party killed - end the encounter
self.end_encounter()

def execute_combat_round(self, round_num: int):
logger.debug(f"Starting combat round {round_num}...")

# Deque first combatant to act
attacker = self.combat_queue.popleft()[0]
attacker = self.combat_queue.popleft()

# If combatant is PC, player chooses a monster to attack
if attacker in self.pc_party.members:

# TODO: Get player input for next action, but for now, just attack a random monster
defender = random.choice([monster for monster in self.monster_party.members if monster.is_alive])
needed_to_hit = attacker.character_class.current_level.get_to_hit_target_ac(defender.armor_class)
attack_roll = attacker.get_attack_roll()
if attack_roll.total_with_modifier >= needed_to_hit:
damage_roll = attacker.get_damage_roll()
defender.apply_damage(damage_roll.total_with_modifier)
logger.debug(f"{attacker.name} ({attacker.character_class.class_type.value}) attacked {defender.name} and hit for {damage_roll.total} damage.")
attack_roll = attacker.get_attack_roll() # TODO: Pass attack type (e.g., MELEE, RANGED, SPELL, etc.) to get_attack_roll()
# TODDO: attack_item = attacker.inventory.get_equipped_item_by_type(attack_roll.attack_type)
weapon = attacker.inventory.get_equipped_weapon().name.lower()

# Natural 20 always hits and a 1 always misses
if attack_roll.total == 20 or (attack_roll.total > 1 and attack_roll.total_with_modifier >= needed_to_hit):
damage_roll = attacker.get_damage_roll() #Pass attack type (e.g., MELEE, RANGED, SPELL, etc.) to get_damage_roll()
damage_multiplier = 1.5 if attack_roll.total == 20 else 1
damage_mesg_suffix = " CRITICAL HIT!" if attack_roll.total == 20 else ""
damage_amount = math.ceil(damage_roll.total_with_modifier * damage_multiplier)
defender.apply_damage(damage_amount)

attack_mesg_suffix = f" for {damage_amount} damage.{damage_mesg_suffix}"
else:
logger.debug(f"{attacker.name} ({attacker.character_class.class_type.value}) attacked {defender.name} and missed.")
attack_mesg_suffix = f" and missed."
logger.debug(f"{attacker.name} ({attacker.character_class}) attacked {defender.name} with their {weapon} ({attack_roll.total_with_modifier} on {attack_roll}){attack_mesg_suffix}")
elif attacker in self.monster_party.members:
defender = random.choice([pc for pc in self.pc_party.members if pc.is_alive])
needed_to_hit = 15 # TODO: attacker.get_to_hit_target_ac(defender.armor_class)
for roll in attacker.get_attack_rolls():
if defender.is_alive and roll.total_with_modifier >= needed_to_hit:
needed_to_hit = attacker.get_to_hit_target_ac(defender.armor_class)
for attack_roll in attacker.get_attack_rolls():
if defender.is_alive and attack_roll.total_with_modifier >= needed_to_hit:
damage_roll = attacker.get_damage_roll()
defender.apply_damage(damage_roll.total_with_modifier)
logger.debug(f"{attacker.name} attacked {defender.name} and hit for {damage_roll.total} damage.")
logger.debug(f"{attacker.name} attacked {defender.name} and rolled {attack_roll.total_with_modifier} on {attack_roll} for {damage_roll.total_with_modifier} damage.")
else:
logger.debug(f"{attacker.name} attacked {defender.name} and missed.")
logger.debug(f"{attacker.name} attacked {defender.name} and rolled {attack_roll.total_with_modifier} on {attack_roll} and missed.")

if not defender.is_alive:
logger.debug(f"{defender.name} was killed!")
self.combat_queue.remove(defender)

# Add the attacker back into the combat queue
self.combat_queue.append((attacker, 0))
self.combat_queue.append(attacker)

def end_encounter(self):

# TODO: Award XP and treasure to PCs if monster party is defeated
logger.debug(f"Ending encounter '{self.name}'...")
if self.pc_party.is_alive and not self.monster_party.is_alive:
logger.debug(f"{self.pc_party.name} won the battle! Awarding {self.monster_party.xp_value} experience points to the party...")
self.pc_party.grant_xp(self.monster_party.xp_value)
elif not self.pc_party.is_alive and self.monster_party.is_alive:
logger.debug(f"{self.pc_party.name} lost the battle.")

self.is_started = False
self.is_ended = True
13 changes: 10 additions & 3 deletions osrlib/osrlib/game_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@
import json
import logging
import warnings
import logging
import queue
import threading

# Configure logging
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s [%(levelname)s][%(module)s::%(funcName)s] %(message)s'
format="%(asctime)s [%(levelname)s][%(module)s::%(funcName)s] %(message)s",
)

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -46,15 +49,19 @@ def __init__(
logger.info("Initializing the GameManager...")
self.adventures = adventures
self.parties = parties
logger.info(f"GameManager initialized. There are {len(self.adventures)} adventures available.")
logger.info(
f"GameManager initialized. There are {len(self.adventures)} adventures available."
)

def save_game(self, storage_type: StorageType = StorageType.JSON):
"""Save the game state to persistent storage in the given format.
Args:
storage_type (StorageType): The format to use for saving the game state.
"""
logger.info(f"Saving the game to persistent storage in {storage_type} format...")
logger.info(
f"Saving the game to persistent storage in {storage_type} format..."
)
if storage_type == StorageType.JSON:
with open("game_manager.json", "w") as f:
json.dump({"parties": self.parties, "adventures": self.adventures}, f)
Expand Down
8 changes: 8 additions & 0 deletions osrlib/osrlib/inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,14 @@ def weapons(self):
"""
return self.items[ItemType.WEAPON]

def get_equipped_weapon(self) -> Weapon:
"""Gets the first equipped weapon in the inventory.
Returns:
Weapon: The equipped weapon. Returns "Fists" (1 HP damage) if no other weapon is equipped.
"""
return next((weapon for weapon in self.weapons if weapon.is_equipped), Weapon("Fists", "1d1"))

@property
def spells(self):
"""Gets all spell items stored in the items defaultdict inventory property.
Expand Down
8 changes: 4 additions & 4 deletions osrlib/osrlib/item.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ class Weapon(Item):
**kwargs: Arbitrary keyword arguments inherited from the Item class.
Attributes:
to_hit_damage_die (str): The to-hit and damage die for the weapon, formatted like '1d8', '2d4', '1d6+1', etc.
damage_die (str): The damage die for the weapon, formatted like '1d8', '2d4', '1d6+1', etc.
range (Optional[int]): The range of the weapon in feet.
Note:
Expand All @@ -315,18 +315,18 @@ def __init__(
"""Initialize a weapon item with the specified properties."""
super().__init__(name, ItemType.WEAPON, **kwargs)
self.owner = kwargs.get("owner", None)
self.to_hit_damage_die = (
self.damage_die = (
to_hit_damage_die # format like "1d8", "1d6+1", "1d4-1" etc.
)
self.range = range # in feet (None for melee weapons)
self.max_equipped = kwargs.get("max_equipped", 1) # Weapons are typically 1 per PC

def __str__(self):
return f"{self.name} (Damage: {self.to_hit_damage_die}, Range: {self.range})"
return f"{self.name} (Damage: {self.damage_die}, Range: {self.range})"

def to_dict(self) -> dict:
weapon_dict = super().to_dict()
weapon_dict["to_hit_damage_die"] = self.to_hit_damage_die
weapon_dict["to_hit_damage_die"] = self.damage_die
weapon_dict["range"] = self.range
return weapon_dict

Expand Down
60 changes: 50 additions & 10 deletions osrlib/osrlib/monster.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,30 @@
"21+": {"base": 2500, "bonus": 2000},
}

monster_thac0 = {
"0+ to 1": 19,
"1+ to 2": 18,
"2+ to 3": 17,
"3+ to 4": 16,
"4+ to 5": 15,
"5+ to 6": 14,
"6+ to 7": 13,
"7+ to 8": 12,
"8+ to 9": 12,
"9+ to 10": 11,
"10+ to 11": 11,
"11+ to 12": 10,
"12+ to 13": 10,
"13+ to 14": 9,
"14+ to 15": 9,
"15+ to 16": 8,
"16+ to 17": 8,
"17+ to 18": 7,
"18+ to 19": 7,
"19+ to 20": 6,
"20+ to 21": 6,
"21+ or more": 5,
}

class MonsterStatsBlock:
"""Specifies the statistics of the monsters in a monster party.
Expand All @@ -45,7 +69,7 @@ class MonsterStatsBlock:
num_special_abilities (int): The special ability count of the monster; this value corresponds to the number of asterisks after the monster's hit dice in the Basic and Expert rule books. Default is 0.
attacks_per_round (int): The number of attacks the monster can make per round. Default is 1.
damage_per_attack (str): The damage the monster does per attack in "ndn" or "ndn+n" format, for example '1d4', '1d4+2', '3d4'). Default is '1d4'.
num_appearing (str): The number of monsters that appear in the monster party in "ndn" or "ndn+n" format, for example '1d6', '1d6+2', '3d6'). Default is '1d6'.
num_appearing (int): The number of monsters in the monster party. Default is 1-6 (1d6).
save_as_class (CharacterClassType): The character class type the monster saves as. Default is CharacterClassType.FIGHTER.
save_as_level (int): The level of the character class the monster saves as. Default is 1.
"""
Expand All @@ -57,13 +81,13 @@ def __init__(
armor_class: int = 10,
hit_dice: str = "1d8",
movement: int = 120,
num_special_abilities=0, # corresponds to the number of asterisks on the monster's hit dice
attacks_per_round=1,
num_special_abilities=0, # corresponds to the number of asterisks on the monster's hit dice
attacks_per_round=1, # TODO: Add support for attack and damage modifiers (e.g. Cyclops has -2 on attack rolls)
damage_per_attack="1d4",
num_appearing="1d6",
save_as_class=CharacterClassType.FIGHTER,
save_as_level=1,
morale: int = 12, # roll 2d6, if result is higher, monster flees
morale: int = 12, # roll 2d6, if result is higher than this, monster flees
treasure_type=TreasureType.NONE,
alignment=Alignment.NEUTRAL,
):
Expand All @@ -76,7 +100,7 @@ def __init__(
self.num_special_abilities = num_special_abilities
self.attacks_per_round = attacks_per_round
self.damage_per_attack = damage_per_attack
self.num_appearing = num_appearing
self.num_appearing = roll_dice(num_appearing).total_with_modifier
self.save_as_class = save_as_class
self.save_as_level = save_as_level
self.morale = morale
Expand All @@ -93,8 +117,8 @@ def __init__(self, monster_stats: MonsterStatsBlock):
self.description = monster_stats.description
self.armor_class = monster_stats.armor_class

hp_roll = roll_dice(monster_stats.hit_dice)
self.hit_points = hp_roll.total_with_modifier
self.hp_roll = roll_dice(monster_stats.hit_dice)
self.hit_points = self.hp_roll.total_with_modifier
self.max_hit_points = self.hit_points

self.movement = monster_stats.movement
Expand All @@ -107,7 +131,7 @@ def __init__(self, monster_stats: MonsterStatsBlock):
self.morale = monster_stats.morale
self.alignment = monster_stats.alignment

self.xp_value = self._calculate_xp(hp_roll, monster_stats.num_special_abilities)
self.xp_value = self._calculate_xp(self.hp_roll, monster_stats.num_special_abilities)

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.
Expand Down Expand Up @@ -171,6 +195,22 @@ def get_initiative_roll(self):
logger.debug(f"{self.name} rolled {roll} for initiative and got {roll.total_with_modifier}.")
return roll.total_with_modifier

def get_to_hit_target_ac(self, target_ac: int) -> int:
"""Get the to-hit roll needed to hit a target with the given armor class."""
if self.hp_roll.modifier > 0:
if self.hp_roll.num_dice < 21:
thac0_key = f"{self.hp_roll.num_dice}+ to {self.hp_roll.num_dice + 1}"
else:
thac0_key = f"{self.hp_roll.num_dice}+ or more"
else:
thac0_key = f"{self.hp_roll.num_dice - 1}+ to {self.hp_roll.num_dice}"

thac0 = monster_thac0[thac0_key]
needed_to_hit = max(thac0 - target_ac, 2) # 1 always misses, so 2 is the lowest to-hit value possible
logger.debug(f"{self.name} THAC0: {thac0} ({thac0_key}) | To hit target AC {target_ac}: {needed_to_hit}")

return needed_to_hit

def get_attack_rolls(self) -> List[DiceRoll]:
"""Roll a 1d20 for each attack this monster has per round and return the collection of rolls."""
attack_rolls = []
Expand All @@ -181,7 +221,6 @@ def get_attack_rolls(self) -> List[DiceRoll]:
# Return collection of attack rolls
return attack_rolls


def get_damage_roll(self) -> DiceRoll:
"""Roll the monster's damage dice and return the total."""
return roll_dice(self.damage_per_attack)
Expand Down Expand Up @@ -231,7 +270,8 @@ def __init__(self, monster_stat_block: MonsterStatsBlock):
self.members = [
Monster(monster_stat_block)
for _ in range(
roll_dice(monster_stat_block.num_appearing).total_with_modifier
#roll_dice(monster_stat_block.num_appearing).total_with_modifier
monster_stat_block.num_appearing
)
]
self.treasure = self._get_treasure(monster_stat_block.treasure_type)
Expand Down
Loading

0 comments on commit 967a793

Please sign in to comment.