Skip to content

Commit

Permalink
Merge pull request #18 from mmacy/combat-work-02
Browse files Browse the repository at this point in the history
combat work part 2 of n
  • Loading branch information
mmacy committed Nov 11, 2023
2 parents 5c36be5 + 445947c commit 849d8ca
Show file tree
Hide file tree
Showing 15 changed files with 613 additions and 233 deletions.
9 changes: 5 additions & 4 deletions osrlib/osrlib/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,16 @@
CharacterClass,
CharacterClassType,
ClassLevel,
all_character_classes,
class_levels,
cleric_levels,
commoner_levels,
dwarf_levels,
elf_levels,
fighter_levels,
halfling_levels,
magic_user_levels,
saving_throws,
thief_levels,
class_levels,
saving_throws,
all_character_classes,
)
from .combat import (
AttackType,
Expand All @@ -49,6 +47,7 @@
)
from .enums import (
CharacterClassType,
PartyReaction,
)
from .game_manager import (
GameManager,
Expand All @@ -71,10 +70,12 @@
)
from .item_factories import (
armor_data,
magic_armor_data,
ArmorFactory,
equipment_data,
EquipmentFactory,
weapon_data,
magic_weapon_data,
WeaponFactory,
ItemDataNotFoundError,
equip_party,
Expand Down
251 changes: 122 additions & 129 deletions osrlib/osrlib/character_classes.py

Large diffs are not rendered by default.

79 changes: 74 additions & 5 deletions osrlib/osrlib/encounter.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,81 @@
from collections import deque
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.


class Encounter:
"""An encounter represents a location like a room or cavern and can contain monsters, traps, treasure, and quest pieces.
"""An encounter represents something the party discovers, confronts, or experiences within a location in a dungeon.
An encounter typically represents a group of monsters the party must fight, but it can also represent a trap, a
puzzle, or other non-combat encounter.
Attributes:
name (str): The name or ID of the encounter.
description (str): The description of the encounter (location, environment, etc.).
monsters (list): A list of the monsters in the encounter.
traps (list): A list of the traps 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).
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.
"""

pass
def __init__(
self,
name,
description: str = "",
monster_party: MonsterParty = None,
# npc: NPC = None, # TODO: Implement NPC class
treasure: list = [],
):
"""Initialize the encounter object.
Args:
name (str): The name or ID of the encounter.
description (str): The description of the encounter (location, environment, etc.). Optional.
monsters (MonsterParty): The party of monsters in the encounter. Optional.
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.
"""
self.name = name
self.description = description
self.monster_party = monster_party
# self.npc = npc # TODO: Implement NPC class
self.treasure = treasure
self.pc_party = None
self.turn_order = deque()

def __str__(self):
"""Return a string representation of the encounter."""
return f"{self.name}: {self.description}"

def start_encounter(self, party: Party):
self.pc_party = party

# TODO: Roll for surprise

# 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)

# 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()

# TODO: Implement combat methods
10 changes: 9 additions & 1 deletion osrlib/osrlib/enums.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from enum import Enum

class CharacterClassType(Enum):
"""Enum representing the types of character classes."""
"""Specifies the class, or profession, of a player character or NPC."""

CLERIC = "Cleric"
DWARF = "Dwarf"
Expand All @@ -11,3 +11,11 @@ class CharacterClassType(Enum):
MAGIC_USER = "Magic User"
THIEF = "Thief"
COMMONER = "Commoner"

class PartyReaction(Enum):
"""Specifies the reaction of a PC or monster party at the start of an encounter."""

FIGHT = "Fight"
RUN = "Run"
TALK = "Talk"
PASS = "Pass"
2 changes: 1 addition & 1 deletion osrlib/osrlib/item_factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ def equip_magic_user(character: "PlayerCharacter"):

def equip_party(party: "Party"):
"""Equip a party with default starting gear based on their character classes."""
for character in party.characters:
for character in party.members:
if character.character_class.class_type == CharacterClassType.FIGHTER:
equip_fighter(character)
elif character.character_class.class_type == CharacterClassType.ELF:
Expand Down
47 changes: 41 additions & 6 deletions osrlib/osrlib/monster.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from typing import List
from osrlib.dice_roller import roll_dice, DiceRoll
from osrlib.enums import CharacterClassType
from osrlib.player_character import Alignment
from osrlib.treasure import TreasureType
from osrlib.saving_throws import get_saving_throws_for_class_and_level

monster_xp = {
"Under 1": {"base": 5, "bonus": 1}, # Not handling the "under 1" hit dice case
Expand All @@ -27,7 +29,7 @@
}


class MonsterStatBlock:
class MonsterStatsBlock:
"""Specifies the statistics of the monsters in a monster party.
Pass a MonsterStatBlock instance to the MonsterParty constructor to create a new
Expand All @@ -37,7 +39,7 @@ class MonsterStatBlock:
name (str): The name of the monster.
description (str): The monster's description.
armor_class (int): The monster's armor class (AC). Lower is better.
hit_dice (str): The monster's hit dice in "nd8" or "nd8+n" format, for example '1d8', '1d8+2', '3d8'). Monster hit die are always a d8. Default is '1d8'.
hit_dice (str): The monster's hit dice in "nd8" or "nd8+n" format, for example '1d8', '1d8+2', '3d8'). Default is '1d8'.
movement (int): The monster's movement rate in feet per round. Default is 120.
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.
Expand Down Expand Up @@ -84,7 +86,7 @@ def __init__(
class Monster:
"""A Monster is a creature the party can encounter in a dungeon and defeat to obtain experience points and optionally treasure and quest pieces."""

def __init__(self, monster_stats: MonsterStatBlock):
def __init__(self, monster_stats: MonsterStatsBlock):
"""Initialize a new Monster instance."""
self.name = monster_stats.name
self.description = monster_stats.description
Expand All @@ -97,8 +99,10 @@ def __init__(self, monster_stats: MonsterStatBlock):
self.movement = monster_stats.movement
self.attacks_per_round = monster_stats.attacks_per_round
self.damage_per_attack = monster_stats.damage_per_attack
self.save_as_class = monster_stats.save_as_class # TODO: Populate a saving throw table for the monster based on the save_as_class and save_as_level
self.save_as_level = monster_stats.save_as_level # TODO: Populate a saving throw table for the monster based on the save_as_class and save_as_level
self.saving_throws = get_saving_throws_for_class_and_level(
monster_stats.save_as_class,
monster_stats.save_as_level
) # TODO: Instead of populating a saving_throws property, maybe we call a function in saving_throws to make the saving throw check?
self.morale = monster_stats.morale
self.alignment = monster_stats.alignment

Expand All @@ -115,11 +119,21 @@ def _calculate_xp(self, hp_roll: DiceRoll, num_special_abilities: int = 0):
"""
base_xp = 0
plus = ""

# Handle monsters with less than 1 hit die
if hp_roll.num_sides < 8:
base_xp = monster_xp["Under 1"]["base"]
bonus = monster_xp["Under 1"]["bonus"]
return base_xp + bonus * num_special_abilities

# Handle monsters with 1 hit die and up
if hp_roll.count <= 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"]
# 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:
base_xp = monster_xp["9 to 10+"]["base"]
bonus = monster_xp["9 to 10+"]["bonus"]
Expand Down Expand Up @@ -150,6 +164,11 @@ def is_alive(self):
"""
return self.hit_points > 0

def get_initiative_roll(self):
"""Rolls a 1d6 and returns the total for the monster's initiative."""
roll = roll_dice("1d6")
return roll.total_with_modifier

def apply_damage(self, hit_points_damage: int):
"""Apply damage to the monster by reducing the monster's hit points by the given amount, down to a minimum of 0.
Expand Down Expand Up @@ -183,7 +202,7 @@ class MonsterParty:
is_alive (bool): True if at least one monster in the monster party is alive, otherwise False.
"""

def __init__(self, monster_stat_block: MonsterStatBlock):
def __init__(self, monster_stat_block: MonsterStatsBlock):
"""Initialize a new MonsterParty instance.
The number of monsters that comprise the monster party, as well as hit points, armor class, and other
Expand Down Expand Up @@ -269,3 +288,19 @@ def is_alive(self):
int: True if the monster party has more than 0 hit points, otherwise False.
"""
return any(monster.is_alive for monster in self.monsters)

@property
def xp_value(self):
"""Get the total XP value of the monster party.
Returns:
int: The total XP value of the monster party.
"""
monster_xp = sum(monster.xp_value for monster in self.monsters)
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")
return roll.total_with_modifier
Loading

0 comments on commit 849d8ca

Please sign in to comment.