From cdb245df99f53339407d2b69c4e3383396f6cce9 Mon Sep 17 00:00:00 2001 From: Anna Wiggins Date: Thu, 29 Mar 2012 18:28:21 -0400 Subject: [PATCH] battleman.py: Broke program into multiple components - data classes and input functions are now in separate modules. --- battleman.py | 718 +++++------------------------------------------ lib/battle.py | 558 ++++++++++++++++++++++++++++++++++++ lib/easyinput.py | 36 +++ 3 files changed, 662 insertions(+), 650 deletions(-) create mode 100644 lib/battle.py create mode 100644 lib/easyinput.py diff --git a/battleman.py b/battleman.py index cbafaf7..234d3e8 100755 --- a/battleman.py +++ b/battleman.py @@ -7,551 +7,22 @@ # to do anything fancier... may develop that at some point. # # future features: -# * keep a pickled file or a shelve db of the current state, and add a --resume option -# for resuming a battle later # * an option for passing in multiple files that contain combatant definitions -# * down combatants go into a separate list +import sys +sys.path.append('lib/') -from dice import Dice import cPickle as pickle import argparse import os.path -import sys - - -class CombatGroup(): - # What we're mostly getting here is a definition of the *members* - # of the group... then we build them all and stick them in - # the group - @classmethod - def from_input(cls): - name = input_str("name") - hp = input_int('hp') - init_mod = input_int('init mod', 0) - ap = input_int('action points', 0) - surges = input_int('healing surges', 0) - - recharges = [] - while True: - data = [] - data = input_str("recharge", default='').split(',') - if len(data) == 2: - recharge = {} - recharge['name'] = data[0] - recharge['value'] = int(data[1]) - recharge['used'] = False - recharges.append(recharge) - else: - break - - count = input_int('count', 1) - - # Now make the combatants... - members = [] - for i in range(count): - members.append(Combatant(name, hp, pc=False, surges=surges, ap=ap, sw=0, recharges=recharges)) - - if count > 1: - name = name + 's' - - return CombatGroup(name, members, init_mod) - - - def __init__(self, name, members, init_mod=0): - self.name = name - self.members = members - self.init_mod = init_mod - self.init = 0 - - - def roll_init(self): - d = Dice.from_str('1d20+{}'.format(self.init_mod)) - self.set_init(d.roll()['total']) - - - def set_init(self, init): - self.init = init - - - def add_member(self, c): - self.members.append(c) - - - def is_solo_group(self): - return len(self.members) == 1 - - - def begin_turn(self): - print '{} {} initiative.'.format(self.name, ['has', 'have'][len(self.members) != 1]) - - for c in self.members: - c.begin_turn() - - - def end_turn(self): - for c in self.members: - c.end_turn() - - - -class Combatant(): - next_index = 0 - - def __init__(self, name, hp, pc=False, init_mod=0, surges=0, ap=0, sw=0, recharges=[]): - self.name = name - self.max_hp = hp - self.hp = self.max_hp - self.temp_hp = 0 - self.pc = pc - self.surges = surges - self.ap = ap - self.sw = sw - self.conditions = {} - self.index = Combatant.next_index - self.next_condition_index = 0 - Combatant.next_index += 1 - - self.recharges = {} - recharge_index = 0 - for r in recharges: - r['index'] = recharge_index - r['just_used'] = False - self.recharges[recharge_index] = r - recharge_index += 1 - - - def __str__(self): - return "{}: {} ({})".format(self.index, self.name, self.format_health_summary()) - - - # cond_type can be 's' or 't', for 'save' or 'timed'. If it is 't', condition expires at the end of the players turn - # 'duration' rounds from now - def add_condition(self, name, cond_type, duration=None, end_type='e'): - condition = {} - condition['name'] = name - condition['cond_type'] = cond_type - condition['duration'] = duration - condition['end_type'] = end_type - condition['index'] = self.next_condition_index - if cond_type == 'timed' and duration == None: - print 'Error: specified a timed condition with no duration.' - return - self.conditions[self.next_condition_index] = condition - self.next_condition_index += 1 - - - # Removes a condition, prints a message about it, and returns a copy of the condition - def remove_condition(self, index): - if index not in self.conditions: - print "Error: invalid condition index." - return None - - c = self.conditions.pop(index) - print '{} is no longer affected by {}.'.format(self, c['name']) - - return c - - - def choose_condition(self): - if not len(self.conditions): - print '{} has no conditions.'.format(self) - return None - - print self.format_condition_summary() - index = input_int('choice') - - if index not in self.conditions: - print 'Error: {} is not a valid index'.format(index) - return self.choose_condition() - - return self.conditions[index] - - - def tick_conditions(self): - for c in self.conditions.values(): - if c['cond_type'] == 't': - c['duration'] -= 1 - - - def begin_turn(self): - for c in self.conditions.values(): - if c['cond_type'] == 't' and c['end_type'] == 's': - if c['duration'] <= 0: - self.remove_condition(c['index']) - else: - print '{} is still affected by {} ({} round{} left).'.format(self, c['name'], c['duration'], 's'[c['duration']==1:]) - - - def end_turn(self): - for c in self.conditions.values(): - if c['cond_type'] == 's': - r = None - if self.pc: - print '{}: save against {}.'.format(self, c['name']) - r = input_int('saving throw') - else: - save_die = Dice.from_str('1d20') - r = save_die.roll()['total'] - - if r >= 10: - self.remove_condition(c['index']) - print '{} successfully saved against {}.'.format(self, c['name']) - else: - print '{} failed a save against {}.'.format(self, c['name']) - - elif c['cond_type'] == 't' and c['end_type'] == 'e': - if c['duration'] <= 0: - self.remove_condition(c['index']) - else: - print '{} is still affected by {} ({} round{} left).'.format(self, c['name'], c['duration'], 's'[c['duration']==1:]) - - for r in self.recharges.values(): - if r['used']: - if r['just_used']: - r['just_used'] = False - continue - - # Roll to recharge - d = Dice.from_str('1d6') - n = d.roll()['total'] - if n >= r['value']: - r['used'] = False - print '{} can use {} again!'.format(self, r['name']) - - - def damage(self, amount): - was_bloodied = self.is_bloodied() - - if self.temp_hp > 0: - self.temp_hp -= amount - if self.temp_hp < 0: - amount = abs(self.temp_hp) - self.temp_hp = 0 - - self.hp -= amount - - print '{} took {} points of damage.'.format(self, amount) - - if self.is_down(): - print '{} is down!'.format(self) - elif self.is_bloodied() and not was_bloodied: - print '{} is bloodied!'.format(self) - - - def heal(self, amount): - was_down = self.is_down() - was_bloodied = self.is_bloodied() - - if self.hp < 0: - self.hp = 0 - - amount_healed = amount - if self.hp + amount_healed > self.max_hp: - amount_healed = self.max_hp - self.hp - - self.hp += amount_healed - - if was_down: - print '{} regains consciousness.'.format(self) - - print '{} regained {} hit points.'.format(self, amount_healed) - - if was_bloodied and not self.is_bloodied(): - print '{} is no longer bloodied.'.format(self) - elif was_bloodied and self.is_bloodied(): - print '{} is still bloodied.'.format(self) - - - def add_temp_hp(self, amount): - self.temp_hp += amount - - print '{} gained {} temporary hit points.'.format(self, amount) - - - def use_surge(self, heal=True): - if self.surges <= 0: - print '{} has no healing surges.'.format(self) - return - - self.surges -= 1 - noheal = '' - if not heal: - noheal = " (but didn't regain hit points)" - print '{} spent a healing surge{}.'.format(self, noheal) - - if heal: - self.heal(self.max_hp / 4) - - - def use_second_wind(self): - if self.sw <= 0: - print "{} doesn't have a second wind.".format(self) - return - - self.surges -= 1 - - print '{} got their second wind!'.format(self) - - # Now the actual effects of the SW - self.use_surge() - self.add_condition('Second Wind (+2 all def)', 't', 1) - - - def use_recharge_power(self, index): - if index not in self.recharges: - print "Error: Invalid recharge index" - return - - self.recharges[index]['used'] = True - self.recharges[index]['just_used'] = True - - - def choose_recharge_power(self): - if not len(self.recharges): - print '{} has no rechargable powers.'.format(self) - return None - - print self.format_recharge_summary() - index = input_int('choice') - - if index not in self.recharges: - print 'Error: {} is not a valid index'.format(index) - return self.choose_recharge_power() - - return self.recharges[index] - - - def is_bloodied(self): - return self.hp <= self.max_hp / 2 - - - def is_down(self): - return self.hp <= 0 - - - def format_full_info(self): - return """{name} -{separator} -index: {index} -hp: {hp}/{max_hp} -temp hp: {temp_hp} -surges: {surge} -ap: {ap} -sw: {sw} -conditions: -{conditions} -recharge powers: -{recharge}""".format(index=self.index, name=self.name, hp=self.hp, max_hp=self.max_hp, temp_hp=self.temp_hp, surge=self.surges, ap=self.ap, sw=self.sw, conditions=self.format_condition_summary(' '), recharge=self.format_recharge_summary(' '), separator='='*len(self.name)) - - - def format_health_summary(self): - bloodied = '' - temp_info = '' - if self.is_bloodied(): - bloodied = ', bloodied' - if len(self.conditions): - bloodied = bloodied + ', ' - - if self.temp_hp > 0: - temp_info = ', {} temp hp'.format(self.temp_hp) - - return '{} hp{}{}{}'.format(self.hp, temp_info, bloodied, ', '.join([x['name'] for x in self.conditions.values()])) - - - def format_condition_summary(self, initial=''): - summary = '' - for (index, c) in self.conditions.items(): - type_string = '' - if c['cond_type'] == 's': - type_string = 'Save Ends' - elif c['cond_type'] == 't': - type_string = '{} Round{}'.format(c['duration'], 's'[ c['duration']==1: ] ) - summary = summary + '{}{}: {} ({})\n'.format(initial, index, c['name'], type_string) - - return summary.rstrip() - - def format_recharge_summary(self, initial): - summary = '' - for (index, r) in self.recharges.items(): - summary = summary + '{}{}: {} (Recharge: {}, Available: {})\n'.format(initial, index, r['name'], r['value'], ['Yes', 'No'][ r['used'] ]) - return summary.rstrip() - - -# data about the battle - includes combatant list, etc -class Battle(): - def __init__(self): - self.combatant_hash = {} - self.groups = [] - self.current = None - self.round = None - - - def __str__(self): - ret = '' - if self.is_started(): - ret = 'Battle underway, currently on round {}\n\n'.format(self.round) - else: - ret = 'Battle not yet started\n\n' - - ret = ret + 'Combatants\n==========\n' - ret = ret + self.format_combatants() - - return ret - - - def is_started(self): - return self.current != None - - - def add_group(self, group): - # If battle is already going, need to know - # where in the init order the new mooks go - if self.is_started(): - if group.is_solo_group() and group.members[0].pc: - group.set_init(input_int('Initiative for {}'.format(group.name))) - else: - group.roll_init() - - added = False - for i in range(len(self.groups)): - if group.init > self.groups[i].init: - self.groups.insert(i, group) - added = True - break - - if not added: - self.groups.append(group) - else: - self.groups.append(group) - - for c in group.members: - self.combatant_hash[c.index] = c - - - def get_current_group(self): - if self.current != None: - return self.groups[self.current] - else: - return None - - - # If you're in a multi-member group, the first - # member of the group gets returned. - def get_current_combatant(self): - if self.current != None: - return self.groups[self.current].members[0] - else: - return None - - - def get_combatant(self, index): - if index in self.combatant_hash: - return self.combatant_hash[index] - else: - return None - - - def choose_combatant(self): - print self.format_combatants() - index = input_int('choose combatant') - if index not in self.combatant_hash: - print 'Error: {} is not a valid index'.format(index) - return self.choose_combatant() - return self.combatant_hash[index] - - - def begin(self): - if self.is_started(): - print "Error: battle is already running" - - for g in self.groups: - if g.is_solo_group() and g.members[0].pc: - g.set_init(input_int('Initiative for {}'.format(g.name))) - else: - g.roll_init() - - self.groups.sort(reverse=True, key=lambda group: group.init) - self.current = 0 - - print '\nInitiative Roster:\n' - for g in self.groups: - print '{} ({})'.format(g.name, g.init) - print '' - - self.next_round() - self.get_current_group().begin_turn() - - - # Returns a formatted string with all of the combatants - def format_combatants(self): - ret = '' - - for g in self.groups: - if g.is_solo_group(): - ret = ret + '{}\n'.format(g.members[0]) - else: - ret = ret + '{}:\n'.format(g.name) - for c in g.members: - ret = ret + ' {}\n'.format(c) - - return ret.rstrip() - - - # Returns a formatted string with just the current group - def format_current_group(self): - if self.validate_started(): - return self.validate_started() - - g = self.groups[self.current] - if g.is_solo_group(): - return '{}'.format(g.members[0].format_full_info()) - else: - ret = '{}\n'.format(g.name) - for c in g.members: - ret = ret + ' {}\n'.format(c) - return ret.rstrip() - - - def next_combatant(self): - if self.validate_started(): - print self.validate_started() - return - - g = self.get_current_group() - g.end_turn() - - self.current += 1 - - if self.current >= len(self.groups): - self.current = 0 - self.next_round() - - g = self.get_current_group() - g.begin_turn() - - - def next_round(self): - if self.round == None: - self.round = 1 - else: - self.round += 1 - - # Decrement all timed conditions - for c in self.combatant_hash.values(): - c.tick_conditions() - - print 'Beginning round {}'.format(self.round) - - - def validate_started(self): - if not self.is_started(): - return 'Error: you can only run this command after starting the battle' - return None +import battle +from battle import CombatGroup +from battle import Combatant +import easyinput def main(): - battle = Battle() + btl = battle.Battle() ### This is the pickling jar battle_pickle = None @@ -573,33 +44,31 @@ def main(): if settings.resume: try: with open(BP_FILE, 'r') as f: - battle = pickle.load(f) - battle_pickle = pickle.dumps(battle) + btl = pickle.load(f) + battle_pickle = pickle.dumps(btl) except: print "Error: Couldn't resume. Quitting to preserve our pickle." sys.exit(1) - print 'Resuming battle. We are in round: {}\n'.format(battle.round) - else: # hard-coding test cases for now. # Eventually, use a state-saving text file that's easy to edit, or at least copy... - battle.add_group(CombatGroup("Adele", [Combatant("Adele", hp=26, pc=True, surges=8, sw=1)], 2)) - battle.add_group(CombatGroup("Aristaire", [Combatant("Aristaire", hp=20, pc=True, surges=6, sw=1)], 0)) + btl.add_group(CombatGroup("Adele", [Combatant("Adele", hp=26, pc=True, surges=8, sw=1)], 2)) + btl.add_group(CombatGroup("Aristaire", [Combatant("Aristaire", hp=20, pc=True, surges=6, sw=1)], 0)) - battle.add_group(CombatGroup("Foobolds", [Combatant("Foobold", hp=50), Combatant("Foobold", hp=50), Combatant("Foobold", hp=50), Combatant("Foobold", hp=50), Combatant("Foobold", hp=50)], 20)) - battle.add_group(CombatGroup("Barglins", [Combatant("Barglin", hp=1), Combatant("Barglin", hp=1)], 3)) - battle.add_group(CombatGroup("Orcs of Baz", [Combatant("Orc", hp=32), Combatant("Orc", hp=32)], 1)) + btl.add_group(CombatGroup("Foobolds", [Combatant("Foobold", hp=50), Combatant("Foobold", hp=50), Combatant("Foobold", hp=50), Combatant("Foobold", hp=50), Combatant("Foobold", hp=50)], 20)) + btl.add_group(CombatGroup("Barglins", [Combatant("Barglin", hp=1), Combatant("Barglin", hp=1)], 3)) + btl.add_group(CombatGroup("Orcs of Baz", [Combatant("Orc", hp=32), Combatant("Orc", hp=32)], 1)) - print battle + print btl while True: - do_prompt(battle, battle_pickle, bp_io_failed, BP_FILE) + do_prompt(btl, battle_pickle, bp_io_failed, BP_FILE) -def do_prompt(battle, battle_pickle, bp_io_failed, BP_FILE): +def do_prompt(btl, battle_pickle, bp_io_failed, BP_FILE): print '' - (comm, rdata) = input_str('', default='n', show_default=False, prompt_str='>').partition(' ')[::2] + (comm, rdata) = easyinput.input_str('', default='n', show_default=False, prompt_str='>').partition(' ')[::2] data = rdata.split(' ') if data == ['']: @@ -608,39 +77,39 @@ def do_prompt(battle, battle_pickle, bp_io_failed, BP_FILE): if comm == '?': do_help() # fixme - add ability to get command-specific help elif comm == 'a': - do_add_combatants(battle, data) + do_add_combatants(btl, data) elif comm == 'p': - do_print_combatant_info(battle, data) + do_print_combatant_info(btl, data) elif comm == 'l': - print battle.format_combatants() + print btl.format_combatants() elif comm == 'b': - battle.begin() + btl.begin() elif comm == 'd': - do_damage(battle, data) + do_damage(btl, data) elif comm == 'h': - do_heal(battle, data) + do_heal(btl, data) elif comm == 't': - do_add_temp_hp(battle, data) + do_add_temp_hp(btl, data) elif comm == 'T': - do_remove_temp_hp(battle, data) + do_remove_temp_hp(btl, data) elif comm == 's': - do_surge(battle, data) + do_surge(btl, data) elif comm == 'so': - do_surge(battle, data, heal=False) + do_surge(btl, data, heal=False) elif comm == 'sw': - do_second_wind(battle, data) + do_second_wind(btl, data) elif comm == 'c': - do_add_condition(battle, data) + do_add_condition(btl, data) elif comm == 'C': - do_remove_condition(battle, data) + do_remove_condition(btl, data) elif comm == 'n': - battle.next_combatant() + btl.next_combatant() elif comm == 'r': - do_use_recharge_power(battle, data) + do_use_recharge_power(btl, data) elif comm == 'w': - do_wait(battle, data) + do_wait(btl, data) elif comm == 'W': - do_unwait(battle, data) + do_unwait(btl, data) elif comm == 'x': do_stub() elif comm == 'q': @@ -649,7 +118,7 @@ def do_prompt(battle, battle_pickle, bp_io_failed, BP_FILE): # Re-pickle and write if changed after every query. It's cheap # and we only have to run at user-speed anyway old_bp = battle_pickle - battle_pickle = pickle.dumps(battle) + battle_pickle = pickle.dumps(btl) if old_bp != battle_pickle: try: @@ -685,105 +154,105 @@ q - quit""") # Core data parsing functions -def do_add_combatants(battle, data): +def do_add_combatants(btl, data): if len(data) >= 1: ngroups = int(data[0]) else: - ngroups = input_int('number of groups') + ngroups = easyinput.input_int('number of groups') for i in range(1, ngroups+1): print "Adding group {}".format(i) - battle.add_group(CombatGroup.from_input()) + btl.add_group(CombatGroup.from_input()) -def do_print_combatant_info(battle, data): +def do_print_combatant_info(btl, data): if len(data) >= 1: - c = battle.get_combatant(int(data[0])) + c = btl.get_combatant(int(data[0])) if not c: print('Error: Invalid combatant index.') else: print c.format_full_info() else: - print battle.format_current_group() + print btl.format_current_group() -def do_damage(battle, data): - c = do_combatant_select(battle, data) +def do_damage(btl, data): + c = do_combatant_select(btl, data) if not c: return if len(data) >= 2: amount = int(data[1]) else: - amount = input_int('damage') + amount = easyinput.input_int('damage') c.damage(amount) -def do_heal(battle, data): - c = do_combatant_select(battle, data) +def do_heal(btl, data): + c = do_combatant_select(btl, data) if not c: return if len(data) >= 2: amount = int(data[1]) else: - amount = input_int('amount') + amount = easyinput.input_int('amount') c.heal(amount) -def do_add_temp_hp(battle, data): - c = do_combatant_select(battle, data) +def do_add_temp_hp(btl, data): + c = do_combatant_select(btl, data) if not c: return if len(data) >= 2: amount = int(data[1]) else: - amount = input_int('amount') + amount = easyinput.input_int('amount') c.add_temp_hp(amount) -def do_remove_temp_hp(battle, data): +def do_remove_temp_hp(btl, data): do_stub() -def do_surge(battle, data, heal=True): - c = do_combatant_select(battle, data) +def do_surge(btl, data, heal=True): + c = do_combatant_select(btl, data) if not c: return c.use_surge(heal) -def do_second_wind(battle, data): - c = do_combatant_select(battle, data) +def do_second_wind(btl, data): + c = do_combatant_select(btl, data) if not c: return c.use_second_wind() -def do_add_condition(battle, data): +def do_add_condition(btl, data): duration = None end_type = 'e' - c = do_combatant_select(battle, data) + c = do_combatant_select(btl, data) if not c: return - name = do_data_input_str(data, 1, 'condition name') - ctype = do_data_input_str(data, 2, 'condition type', default='s', show_default=True) + name = easyinput.do_data_input_str(data, 1, 'condition name') + ctype = easyinput.do_data_input_str(data, 2, 'condition type', default='s', show_default=True) if ctype == 't': - duration = do_data_input_int(data, 3, 'duration') - end_type = do_data_input_str(data, 4, '(s)tart|(e)nd', default='e', show_default=True) + duration = easyinput.do_data_input_int(data, 3, 'duration') + end_type = easyinput.do_data_input_str(data, 4, '(s)tart|(e)nd', default='e', show_default=True) c.add_condition(name, ctype, duration, end_type) -def do_remove_condition(battle, data): - c = do_combatant_select(battle, data) +def do_remove_condition(btl, data): + c = do_combatant_select(btl, data) if not c: return @@ -799,8 +268,8 @@ def do_remove_condition(battle, data): c.remove_condition(index) -def do_use_recharge_power(battle, data): - c = do_combatant_select(battle, data) +def do_use_recharge_power(btl, data): + c = do_combatant_select(btl, data) if not c: return @@ -816,11 +285,11 @@ def do_use_recharge_power(battle, data): c.use_recharge_power(index) -def do_wait(battle, data): +def do_wait(btl, data): do_stub() -def do_unwait(battle, data): +def do_unwait(btl, data): do_stub() @@ -835,56 +304,5 @@ def parse_args(): return parser.parse_args() -# Data parsing helper functions - -def do_combatant_select(battle, data): - c = None - - if len(data) >= 1: - c = battle.get_combatant(int(data[0])) - if not c: - print 'Error: Invalid combatant index.' - else: - c = battle.get_current_combatant() - if not c: - print 'Error: Battle not started and no combatant specified.' - - return c - - -def do_data_input_str(battle, data, index, prompt, default=None, show_default=False, prompt_str=':'): - if len(data) >= index + 1: - return data[index] - else: - return input_str(prompt, default, show_default, prompt_str) - - -def do_data_input_int(battle, data, index, prompt, default=None, show_default=True, prompt_str=':'): - return int(do_data_select_str(battle, data, index, prompt, default, show_default, prompt_str)) - - -# Input primitives - -def input_str(prompt, default=None, show_default=False, prompt_str=':'): - full_prompt = prompt - if default != None and show_default: - full_prompt = full_prompt + ' [{}]'.format(default) - full_prompt = full_prompt + '{} '.format(prompt_str) - - data = raw_input(full_prompt) - if not data: - if default == None: - print 'Error: you must provide a value!' - return input_str(prompt, default, show_default, prompt_str) - else: - return default - else: - return data - - -def input_int(prompt, default=None, show_default=True, prompt_str=':'): - return int(input_str(prompt, default, show_default)) - - if __name__ == '__main__': main() diff --git a/lib/battle.py b/lib/battle.py new file mode 100644 index 0000000..a45bffc --- /dev/null +++ b/lib/battle.py @@ -0,0 +1,558 @@ +# battle.py +# +# A set of classes and convenience functions +# for representing a D&D 4e battle +# +# future features: +# * down combatants go into a separate list + +from dice import Dice +import easyinput + +# data about the battle - includes combatant list, etc +class Battle(): + def __init__(self): + self.combatant_hash = {} + self.groups = [] + self.current = None + self.round = None + + + def __str__(self): + ret = '' + if self.is_started(): + ret = 'Battle underway, currently on round {}\n\n'.format(self.round) + else: + ret = 'Battle not yet started\n\n' + + ret = ret + 'Combatants\n==========\n' + ret = ret + self.format_combatants() + + return ret + + + def is_started(self): + return self.current != None + + + def add_group(self, group): + # If battle is already going, need to know + # where in the init order the new mooks go + if self.is_started(): + if group.is_solo_group() and group.members[0].pc: + group.set_init(easyinput.input_int('Initiative for {}'.format(group.name))) + else: + group.roll_init() + + added = False + for i in range(len(self.groups)): + if group.init > self.groups[i].init: + self.groups.insert(i, group) + added = True + break + + if not added: + self.groups.append(group) + else: + self.groups.append(group) + + for c in group.members: + self.combatant_hash[c.index] = c + + + def get_current_group(self): + if self.current != None: + return self.groups[self.current] + else: + return None + + + # If you're in a multi-member group, the first + # member of the group gets returned. + def get_current_combatant(self): + if self.current != None: + return self.groups[self.current].members[0] + else: + return None + + + def get_combatant(self, index): + if index in self.combatant_hash: + return self.combatant_hash[index] + else: + return None + + + def choose_combatant(self): + print self.format_combatants() + index = easyinput.input_int('choose combatant') + if index not in self.combatant_hash: + print 'Error: {} is not a valid index'.format(index) + return self.choose_combatant() + return self.combatant_hash[index] + + + def begin(self): + if self.is_started(): + print "Error: battle is already running" + + for g in self.groups: + if g.is_solo_group() and g.members[0].pc: + g.set_init(easyinput.input_int('Initiative for {}'.format(g.name))) + else: + g.roll_init() + + self.groups.sort(reverse=True, key=lambda group: group.init) + self.current = 0 + + print '\nInitiative Roster:\n' + for g in self.groups: + print '{} ({})'.format(g.name, g.init) + print '' + + self.next_round() + self.get_current_group().begin_turn() + + + # Returns a formatted string with all of the combatants + def format_combatants(self): + ret = '' + + for g in self.groups: + if g.is_solo_group(): + ret = ret + '{}\n'.format(g.members[0]) + else: + ret = ret + '{}:\n'.format(g.name) + for c in g.members: + ret = ret + ' {}\n'.format(c) + + return ret.rstrip() + + + # Returns a formatted string with just the current group + def format_current_group(self): + if self.validate_started(): + return self.validate_started() + + g = self.groups[self.current] + if g.is_solo_group(): + return '{}'.format(g.members[0].format_full_info()) + else: + ret = '{}\n'.format(g.name) + for c in g.members: + ret = ret + ' {}\n'.format(c) + return ret.rstrip() + + + def next_combatant(self): + if self.validate_started(): + print self.validate_started() + return + + g = self.get_current_group() + g.end_turn() + + self.current += 1 + + if self.current >= len(self.groups): + self.current = 0 + self.next_round() + + g = self.get_current_group() + g.begin_turn() + + + def next_round(self): + if self.round == None: + self.round = 1 + else: + self.round += 1 + + # Decrement all timed conditions + for c in self.combatant_hash.values(): + c.tick_conditions() + + print 'Beginning round {}'.format(self.round) + + + def validate_started(self): + if not self.is_started(): + return 'Error: you can only run this command after starting the battle' + return None + + +class CombatGroup(): + # What we're mostly getting here is a definition of the *members* + # of the group... then we build them all and stick them in + # the group + @classmethod + def from_input(cls): + name = easyinput.input_str("name") + hp = easyinput.input_int('hp') + init_mod = easyinput.input_int('init mod', 0) + ap = easyinput.input_int('action points', 0) + surges = easyinput.input_int('healing surges', 0) + + recharges = [] + while True: + data = [] + data = easyinput.input_str("recharge", default='').split(',') + if len(data) == 2: + recharge = {} + recharge['name'] = data[0] + recharge['value'] = int(data[1]) + recharge['used'] = False + recharges.append(recharge) + else: + break + + count = easyinput.input_int('count', 1) + + # Now make the combatants... + members = [] + for i in range(count): + members.append(Combatant(name, hp, pc=False, surges=surges, ap=ap, sw=0, recharges=recharges)) + + if count > 1: + name = name + 's' + + return CombatGroup(name, members, init_mod) + + + def __init__(self, name, members, init_mod=0): + self.name = name + self.members = members + self.init_mod = init_mod + self.init = 0 + + + def roll_init(self): + d = Dice.from_str('1d20+{}'.format(self.init_mod)) + self.set_init(d.roll()['total']) + + + def set_init(self, init): + self.init = init + + + def add_member(self, c): + self.members.append(c) + + + def is_solo_group(self): + return len(self.members) == 1 + + + def begin_turn(self): + print '{} {} initiative.'.format(self.name, ['has', 'have'][len(self.members) != 1]) + + for c in self.members: + c.begin_turn() + + + def end_turn(self): + for c in self.members: + c.end_turn() + + + +class Combatant(): + next_index = 0 + + def __init__(self, name, hp, pc=False, init_mod=0, surges=0, ap=0, sw=0, recharges=[]): + self.name = name + self.max_hp = hp + self.hp = self.max_hp + self.temp_hp = 0 + self.pc = pc + self.surges = surges + self.ap = ap + self.sw = sw + self.conditions = {} + self.index = Combatant.next_index + self.next_condition_index = 0 + Combatant.next_index += 1 + + self.recharges = {} + recharge_index = 0 + for r in recharges: + r['index'] = recharge_index + r['just_used'] = False + self.recharges[recharge_index] = r + recharge_index += 1 + + + def __str__(self): + return "{}: {} ({})".format(self.index, self.name, self.format_health_summary()) + + + # cond_type can be 's' or 't', for 'save' or 'timed'. If it is 't', condition expires at the end of the players turn + # 'duration' rounds from now + def add_condition(self, name, cond_type, duration=None, end_type='e'): + condition = {} + condition['name'] = name + condition['cond_type'] = cond_type + condition['duration'] = duration + condition['end_type'] = end_type + condition['index'] = self.next_condition_index + if cond_type == 'timed' and duration == None: + print 'Error: specified a timed condition with no duration.' + return + self.conditions[self.next_condition_index] = condition + self.next_condition_index += 1 + + + # Removes a condition, prints a message about it, and returns a copy of the condition + def remove_condition(self, index): + if index not in self.conditions: + print "Error: invalid condition index." + return None + + c = self.conditions.pop(index) + print '{} is no longer affected by {}.'.format(self, c['name']) + + return c + + + def choose_condition(self): + if not len(self.conditions): + print '{} has no conditions.'.format(self) + return None + + print self.format_condition_summary() + index = easyinput.input_int('choice') + + if index not in self.conditions: + print 'Error: {} is not a valid index'.format(index) + return self.choose_condition() + + return self.conditions[index] + + + def tick_conditions(self): + for c in self.conditions.values(): + if c['cond_type'] == 't': + c['duration'] -= 1 + + + def begin_turn(self): + for c in self.conditions.values(): + if c['cond_type'] == 't' and c['end_type'] == 's': + if c['duration'] <= 0: + self.remove_condition(c['index']) + else: + print '{} is still affected by {} ({} round{} left).'.format(self, c['name'], c['duration'], 's'[c['duration']==1:]) + + + def end_turn(self): + for c in self.conditions.values(): + if c['cond_type'] == 's': + r = None + if self.pc: + print '{}: save against {}.'.format(self, c['name']) + r = easyinput.input_int('saving throw') + else: + save_die = Dice.from_str('1d20') + r = save_die.roll()['total'] + + if r >= 10: + self.remove_condition(c['index']) + print '{} successfully saved against {}.'.format(self, c['name']) + else: + print '{} failed a save against {}.'.format(self, c['name']) + + elif c['cond_type'] == 't' and c['end_type'] == 'e': + if c['duration'] <= 0: + self.remove_condition(c['index']) + else: + print '{} is still affected by {} ({} round{} left).'.format(self, c['name'], c['duration'], 's'[c['duration']==1:]) + + for r in self.recharges.values(): + if r['used']: + if r['just_used']: + r['just_used'] = False + continue + + # Roll to recharge + d = Dice.from_str('1d6') + n = d.roll()['total'] + if n >= r['value']: + r['used'] = False + print '{} can use {} again!'.format(self, r['name']) + + + def damage(self, amount): + was_bloodied = self.is_bloodied() + + if self.temp_hp > 0: + self.temp_hp -= amount + if self.temp_hp < 0: + amount = abs(self.temp_hp) + self.temp_hp = 0 + + self.hp -= amount + + print '{} took {} points of damage.'.format(self, amount) + + if self.is_down(): + print '{} is down!'.format(self) + elif self.is_bloodied() and not was_bloodied: + print '{} is bloodied!'.format(self) + + + def heal(self, amount): + was_down = self.is_down() + was_bloodied = self.is_bloodied() + + if self.hp < 0: + self.hp = 0 + + amount_healed = amount + if self.hp + amount_healed > self.max_hp: + amount_healed = self.max_hp - self.hp + + self.hp += amount_healed + + if was_down: + print '{} regains consciousness.'.format(self) + + print '{} regained {} hit points.'.format(self, amount_healed) + + if was_bloodied and not self.is_bloodied(): + print '{} is no longer bloodied.'.format(self) + elif was_bloodied and self.is_bloodied(): + print '{} is still bloodied.'.format(self) + + + def add_temp_hp(self, amount): + self.temp_hp += amount + + print '{} gained {} temporary hit points.'.format(self, amount) + + + def use_surge(self, heal=True): + if self.surges <= 0: + print '{} has no healing surges.'.format(self) + return + + self.surges -= 1 + noheal = '' + if not heal: + noheal = " (but didn't regain hit points)" + print '{} spent a healing surge{}.'.format(self, noheal) + + if heal: + self.heal(self.max_hp / 4) + + + def use_second_wind(self): + if self.sw <= 0: + print "{} doesn't have a second wind.".format(self) + return + + self.surges -= 1 + + print '{} got their second wind!'.format(self) + + # Now the actual effects of the SW + self.use_surge() + self.add_condition('Second Wind (+2 all def)', 't', 1) + + + def use_recharge_power(self, index): + if index not in self.recharges: + print "Error: Invalid recharge index" + return + + self.recharges[index]['used'] = True + self.recharges[index]['just_used'] = True + + + def choose_recharge_power(self): + if not len(self.recharges): + print '{} has no rechargable powers.'.format(self) + return None + + print self.format_recharge_summary() + index = easyinput.input_int('choice') + + if index not in self.recharges: + print 'Error: {} is not a valid index'.format(index) + return self.choose_recharge_power() + + return self.recharges[index] + + + def is_bloodied(self): + return self.hp <= self.max_hp / 2 + + + def is_down(self): + return self.hp <= 0 + + + def format_full_info(self): + return """{name} +{separator} +index: {index} +hp: {hp}/{max_hp} +temp hp: {temp_hp} +surges: {surge} +ap: {ap} +sw: {sw} +conditions: +{conditions} +recharge powers: +{recharge}""".format(index=self.index, name=self.name, hp=self.hp, max_hp=self.max_hp, temp_hp=self.temp_hp, surge=self.surges, ap=self.ap, sw=self.sw, conditions=self.format_condition_summary(' '), recharge=self.format_recharge_summary(' '), separator='='*len(self.name)) + + + def format_health_summary(self): + bloodied = '' + temp_info = '' + if self.is_bloodied(): + bloodied = ', bloodied' + if len(self.conditions): + bloodied = bloodied + ', ' + + if self.temp_hp > 0: + temp_info = ', {} temp hp'.format(self.temp_hp) + + return '{} hp{}{}{}'.format(self.hp, temp_info, bloodied, ', '.join([x['name'] for x in self.conditions.values()])) + + + def format_condition_summary(self, initial=''): + summary = '' + for (index, c) in self.conditions.items(): + type_string = '' + if c['cond_type'] == 's': + type_string = 'Save Ends' + elif c['cond_type'] == 't': + type_string = '{} Round{}'.format(c['duration'], 's'[ c['duration']==1: ] ) + summary = summary + '{}{}: {} ({})\n'.format(initial, index, c['name'], type_string) + + return summary.rstrip() + + def format_recharge_summary(self, initial): + summary = '' + for (index, r) in self.recharges.items(): + summary = summary + '{}{}: {} (Recharge: {}, Available: {})\n'.format(initial, index, r['name'], r['value'], ['Yes', 'No'][ r['used'] ]) + return summary.rstrip() + + + + +# Data parsing helper function +# Modelled on the data_input functions in easyinput +def do_combatant_select(battle, data): + c = None + + if len(data) >= 1: + c = battle.get_combatant(int(data[0])) + if not c: + print 'Error: Invalid combatant index.' + else: + c = battle.get_current_combatant() + if not c: + print 'Error: Battle not started and no combatant specified.' + + return c diff --git a/lib/easyinput.py b/lib/easyinput.py new file mode 100644 index 0000000..cb2a4af --- /dev/null +++ b/lib/easyinput.py @@ -0,0 +1,36 @@ +# easyinput.py +# +# These are just some simple helper functions for doing input. They produce a prompt, allow default values, +# and allow conditional prompting based on the presence or absence of data in a list + + +def input_str(prompt, default=None, show_default=False, prompt_str=':'): + full_prompt = prompt + if default != None and show_default: + full_prompt = full_prompt + ' [{}]'.format(default) + full_prompt = full_prompt + '{} '.format(prompt_str) + + data = raw_input(full_prompt) + if not data: + if default == None: + print 'Error: you must provide a value!' + return input_str(prompt, default, show_default, prompt_str) + else: + return default + else: + return data + + +def input_int(prompt, default=None, show_default=True, prompt_str=':'): + return int(input_str(prompt, default, show_default)) + + +def do_data_input_str(data, index, prompt, default=None, show_default=False, prompt_str=':'): + if len(data) >= index + 1: + return data[index] + else: + return input_str(prompt, default, show_default, prompt_str) + + +def do_data_input_int(battle, data, index, prompt, default=None, show_default=True, prompt_str=':'): + return int(do_data_select_str(battle, data, index, prompt, default, show_default, prompt_str))