More work on combat
This commit is contained in:
parent
04200f8bfc
commit
a7ced1dbfc
5 changed files with 398 additions and 119 deletions
|
|
@ -8,6 +8,7 @@ from evennia.objects.objects import DefaultCharacter, DefaultObject
|
||||||
from evennia.typeclasses.attributes import AttributeProperty
|
from evennia.typeclasses.attributes import AttributeProperty
|
||||||
from evennia.utils.utils import lazy_property, int2str
|
from evennia.utils.utils import lazy_property, int2str
|
||||||
from .objects import EvAdventureObject
|
from .objects import EvAdventureObject
|
||||||
|
from . import rules
|
||||||
|
|
||||||
|
|
||||||
class EquipmentError(TypeError):
|
class EquipmentError(TypeError):
|
||||||
|
|
@ -284,6 +285,7 @@ class EvAdventureCharacter(DefaultCharacter):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# these are the ability bonuses. Defense is always 10 higher
|
||||||
strength = AttributeProperty(default=1)
|
strength = AttributeProperty(default=1)
|
||||||
dexterity = AttributeProperty(default=1)
|
dexterity = AttributeProperty(default=1)
|
||||||
constitution = AttributeProperty(default=1)
|
constitution = AttributeProperty(default=1)
|
||||||
|
|
@ -308,6 +310,24 @@ class EvAdventureCharacter(DefaultCharacter):
|
||||||
"""Allows to access equipment like char.equipment.worn"""
|
"""Allows to access equipment like char.equipment.worn"""
|
||||||
return EquipmentHandler(self)
|
return EquipmentHandler(self)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def weapon(self):
|
||||||
|
"""
|
||||||
|
Quick access to the character's currently wielded weapon.
|
||||||
|
Will return the "Unarmed" weapon if none other are found.
|
||||||
|
|
||||||
|
"""
|
||||||
|
# TODO
|
||||||
|
|
||||||
|
@property
|
||||||
|
def armor(self):
|
||||||
|
"""
|
||||||
|
Quick access to the character's current armor.
|
||||||
|
Will return the "Unarmored" armor if none other are found.
|
||||||
|
|
||||||
|
"""
|
||||||
|
# TODO
|
||||||
|
|
||||||
def at_pre_object_receive(self, moved_object, source_location, **kwargs):
|
def at_pre_object_receive(self, moved_object, source_location, **kwargs):
|
||||||
"""
|
"""
|
||||||
Hook called by Evennia before moving an object here. Return False to abort move.
|
Hook called by Evennia before moving an object here. Return False to abort move.
|
||||||
|
|
@ -358,6 +378,37 @@ class EvAdventureCharacter(DefaultCharacter):
|
||||||
self.equipment.remove(moved_object)
|
self.equipment.remove(moved_object)
|
||||||
|
|
||||||
|
|
||||||
|
def at_pre_damage(self, dmg, attacker=None):
|
||||||
|
"""
|
||||||
|
Called when receiving damage for whatever reason. This
|
||||||
|
is called *before* hp is evaluated for defeat/death.
|
||||||
|
|
||||||
|
"""
|
||||||
|
def at_post_damage(self, dmg, attacker=None):
|
||||||
|
"""
|
||||||
|
Called when receiving damage for whatever reason. This
|
||||||
|
is called *before* hp is evaluated for defeat/death.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def defeat_message(self, attacker, dmg):
|
||||||
|
return f"After {attacker.key}'s attack, {self.key} collapses in a heap."
|
||||||
|
|
||||||
|
def at_defeat(self, attacker, dmg):
|
||||||
|
"""
|
||||||
|
At this point, character has been defeated but is not killed (their
|
||||||
|
hp >= 0 but they lost ability bonuses). Called after being defeated in combat or
|
||||||
|
other situation where health is lost below or equal to 0.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def handle_death(self):
|
||||||
|
"""
|
||||||
|
Called when character dies.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class EvAdventureNPC(DefaultCharacter):
|
class EvAdventureNPC(DefaultCharacter):
|
||||||
"""
|
"""
|
||||||
This is the base class for all non-player entities, including monsters. These
|
This is the base class for all non-player entities, including monsters. These
|
||||||
|
|
|
||||||
|
|
@ -19,41 +19,25 @@ from dataclasses import dataclass
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from evennia.scripts.scripts import DefaultScript
|
from evennia.scripts.scripts import DefaultScript
|
||||||
from evennia.typeclasses.attributes import AttributeProperty
|
from evennia.typeclasses.attributes import AttributeProperty
|
||||||
|
from evennia.utils.utils import make_iter
|
||||||
from . import rules
|
from . import rules
|
||||||
|
|
||||||
MIN_RANGE = 0
|
MIN_RANGE = 0
|
||||||
MAX_RANGE = 4
|
MAX_RANGE = 4
|
||||||
|
|
||||||
@dataclass
|
RANGE_NAMES = {
|
||||||
class CombatantStats:
|
0: "close", # melee, short weapons, fists. long weapons with disadvantage
|
||||||
|
1: "near", # melee, long weapons, short weapons with disadvantage
|
||||||
|
2: "medium", # thrown, ranged with disadvantage
|
||||||
|
3: "far", # ranged, thrown with disadvantage
|
||||||
|
4: "disengaging" # no weapons
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AttackFailure(RuntimeError):
|
||||||
"""
|
"""
|
||||||
Represents temporary combat-only data we need to track
|
Cannot attack for some reason.
|
||||||
during combat for a single Character.
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
weapon = None
|
|
||||||
armor = None
|
|
||||||
# abstract distance relationship to other combatants
|
|
||||||
distance_matrix = {}
|
|
||||||
# actions may affect what works better/worse next round
|
|
||||||
advantage_actions_next_turn = []
|
|
||||||
disadvantage_actions_next_turn = []
|
|
||||||
|
|
||||||
def get_distance(self, target):
|
|
||||||
return self.distance_matrix.get(target)
|
|
||||||
|
|
||||||
def change_distance(self, target, change):
|
|
||||||
"""
|
|
||||||
Change the distance to an opponent. This is symmetric.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
target (Object): The target to change the distance to.
|
|
||||||
change (int): How to change the distance. Negative values to
|
|
||||||
approach, positive to move away from target.
|
|
||||||
"""
|
|
||||||
current_dist = self.distance_matrix.get(target) # will raise error if None, as it should
|
|
||||||
new_dist = max(MIN_RANGE, min(MAX_RANGE, current_dist + change))
|
|
||||||
self.distance_matrix[target] = target.distance_matrix[self] = new_dist
|
|
||||||
|
|
||||||
|
|
||||||
class EvAdventureCombat(DefaultScript):
|
class EvAdventureCombat(DefaultScript):
|
||||||
|
|
@ -62,12 +46,18 @@ class EvAdventureCombat(DefaultScript):
|
||||||
of all active participants. It's also possible to join (or leave) the fray later.
|
of all active participants. It's also possible to join (or leave) the fray later.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
combatants = AttributeProperty(default=dict())
|
combatants = AttributeProperty(default=list())
|
||||||
queue = AttributeProperty(default=list())
|
action_queue = AttributeProperty(default=dict())
|
||||||
|
|
||||||
# turn counter - abstract time
|
# turn counter - abstract time
|
||||||
turn = AttributeProperty(default=1)
|
turn = AttributeProperty(default=0)
|
||||||
# symmetric distance matrix
|
# symmetric distance matrix (handled dynamically). Mapping {combatant1: {combatant2: dist}, ...}
|
||||||
distance_matrix = {}
|
distance_matrix = defaultdict(dict)
|
||||||
|
# advantages or disadvantages gained against different targets
|
||||||
|
advantage_matrix = AttributeProperty(defaultdict(dict))
|
||||||
|
disadvantage_matrix = AttributeProperty(defaultdict(dict))
|
||||||
|
|
||||||
|
stunt_duration = 2
|
||||||
|
|
||||||
def _refresh_distance_matrix(self):
|
def _refresh_distance_matrix(self):
|
||||||
"""
|
"""
|
||||||
|
|
@ -85,55 +75,255 @@ class EvAdventureCombat(DefaultScript):
|
||||||
4. Disengaging/fleeing (no weapons can be used)
|
4. Disengaging/fleeing (no weapons can be used)
|
||||||
|
|
||||||
Distance is tracked to each opponent individually. One can move 1 step and attack
|
Distance is tracked to each opponent individually. One can move 1 step and attack
|
||||||
or up to 3 steps without attacking.
|
or up to 2 steps (closer or further away) without attacking.
|
||||||
|
|
||||||
New combatants will start at a distance averaged between the optimal ranges
|
New combatants will start at a distance averaged between the optimal ranges
|
||||||
of them and their opponents.
|
of them and their opponents.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
handled = []
|
combatants = self.combatants
|
||||||
for combatant1, combatant_stats1 in self.combatants.items():
|
distance_matrix = self.distance_matrix
|
||||||
for combatant2, combatant_stats2 in self.combatants.items():
|
|
||||||
|
for combatant1 in combatants:
|
||||||
|
for combatant2 in combatants:
|
||||||
|
|
||||||
if combatant1 == combatant2:
|
if combatant1 == combatant2:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# only update if data was not available already (or drifted
|
combatant1_distances = distance_matrix[combatant1]
|
||||||
# out of sync, which should not happen)
|
combatant2_distances = distance_matrix[combatant2]
|
||||||
dist1 = combatant_stats1.get_distance(combatant2)
|
|
||||||
dist2 = combatant_stats2.get_distance(combatant1)
|
|
||||||
if None in (dist1, dist2) or dist1 != dist2:
|
|
||||||
# a new distance-relation - start out at average distance
|
|
||||||
avg_range = round(0.5 * (combatant1.weapon.range_optimal
|
|
||||||
+ combatant2.weapon.range_optimal))
|
|
||||||
combatant_stats1.distance_matrix[combatant2] = avg_range
|
|
||||||
combatant_stats2.distance_matrix[combatant1] = avg_range
|
|
||||||
|
|
||||||
handled.append(combatant1)
|
if combatant2 not in combatant1_distances or combatant1 not in combatant2_distances:
|
||||||
handled.append(combatant2)
|
# this happens on initialization or when a new combatant is added.
|
||||||
|
# we make sure to update both sides to the distance of the longest
|
||||||
|
# optimal weapon range. So ranged weapons have advantage going in.
|
||||||
|
start_optimal = max(combatant1.weapon.distance_optimal,
|
||||||
|
combatant2.weapon.distance_optimal)
|
||||||
|
|
||||||
self.combatants = handled
|
combatant1_distances[combatant2] = start_optimal
|
||||||
|
combatant2_distances[combatant1] = start_optimal
|
||||||
|
|
||||||
def _move_relative_to(self, combatant, target_combatant, change):
|
def _start_turn(self):
|
||||||
|
"""
|
||||||
|
New turn events
|
||||||
|
|
||||||
|
"""
|
||||||
|
self.turn += 1
|
||||||
|
self.action_queue = {}
|
||||||
|
|
||||||
|
def _end_turn(self):
|
||||||
|
"""
|
||||||
|
End of turn cleanup.
|
||||||
|
|
||||||
|
"""
|
||||||
|
# refresh stunt timeouts
|
||||||
|
oldest_stunt_age = self.turn - self.stunt_duration
|
||||||
|
|
||||||
|
advantage_matrix = self.advantage_matrix
|
||||||
|
disadvantage_matrix = self.disadvantage_matrix
|
||||||
|
|
||||||
|
# to avoid modifying the dict while we iterate over it, we
|
||||||
|
# put the results in new dicts. This also avoids us having to
|
||||||
|
# delete from the old dicts.
|
||||||
|
new_advantage_matrix = {}
|
||||||
|
new_disadvantage_matrix = {}
|
||||||
|
|
||||||
|
for combatant in self.combatants:
|
||||||
|
new_advantage_matrix[combatant] = {
|
||||||
|
target: set_at_turn for target, turn in advantage_matrix.items()
|
||||||
|
if set_at_turn > oldest_stunt_age
|
||||||
|
}
|
||||||
|
new_disadvantage_matrix[combatant] = {
|
||||||
|
target: set_at_turn for target, turn in disadvantage_matrix.items()
|
||||||
|
if set_at_turn > oldest_stunt_age
|
||||||
|
}
|
||||||
|
self.advantage_matrix = new_advantage_matrix
|
||||||
|
self.disadvantage_matrix = new_disadvantage_matrix
|
||||||
|
|
||||||
|
def msg(self, message, targets=None):
|
||||||
|
"""
|
||||||
|
Central place for sending messages to combatants. This allows
|
||||||
|
for decorating the output in one place if needed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message (str): The message to send.
|
||||||
|
targets (Object or list, optional): Sends message only to
|
||||||
|
one or more particular combatants. If unset, send to
|
||||||
|
everyone in the combat.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if targets:
|
||||||
|
for target in make_iter(targets):
|
||||||
|
target.msg(message)
|
||||||
|
else:
|
||||||
|
for target in self.combatants:
|
||||||
|
target.msg(message)
|
||||||
|
|
||||||
|
def add_combatant(self, combatant):
|
||||||
|
if combatant not in self.combatants:
|
||||||
|
self.combatants.append(combatant)
|
||||||
|
self._refresh_distance_matrix()
|
||||||
|
|
||||||
|
def remove_combatant(self, combatant):
|
||||||
|
if combatant in self.combatants:
|
||||||
|
self.combatants.remove(combatant)
|
||||||
|
self._refresh_distance_matrix()
|
||||||
|
|
||||||
|
def move_relative_to(self, combatant, target_combatant, change):
|
||||||
"""
|
"""
|
||||||
Change the distance to a target.
|
Change the distance to a target.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
combatant (Character): The one doing the change.
|
combatant (Character): The one doing the change.
|
||||||
target_combatant (Character): The one changing towards.
|
target_combatant (Character): The one distance is changed to.
|
||||||
change (int): A +/- change value. Result is always in range 0..4.
|
change (int): A +/- change value. Result is always in range 0..4.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
self.combatants[combatant].change_distance(target_combatant, change)
|
current_dist = self.distance_matrix[combatant][target_combatant]
|
||||||
self.combatants[target_combatant].change_distance(combatant, change)
|
|
||||||
|
|
||||||
def add_combatant(self, combatant):
|
new_dist = max(MIN_RANGE, min(MAX_RANGE, current_dist + change))
|
||||||
self.combatants[combatant] = CombatantStats(
|
|
||||||
weapon=combatant.equipment.get("weapon"),
|
self.distance_matrix[combatant][target_combatant] = new_dist
|
||||||
armor=combatant.equipment.armor,
|
self.distance_matrix[target_combatant][combatant] = new_dist
|
||||||
|
|
||||||
|
def gain_advantage(self, combatant, target):
|
||||||
|
"""
|
||||||
|
Gain advantage against target. Spent by actions.
|
||||||
|
|
||||||
|
"""
|
||||||
|
self.advantage_matrix[combatant][target] = self.turn
|
||||||
|
|
||||||
|
def gain_disadvantage(self, combatant, target):
|
||||||
|
"""
|
||||||
|
Gain disadvantage against target. Spent by actions.
|
||||||
|
|
||||||
|
"""
|
||||||
|
self.disadvantage_matrix[combatant][target] = self.turn
|
||||||
|
|
||||||
|
def resolve_damage(self, attacker, defender, critical=False):
|
||||||
|
"""
|
||||||
|
Apply damage to defender. On a critical hit, the damage die
|
||||||
|
is rolled twice.
|
||||||
|
|
||||||
|
"""
|
||||||
|
weapon_dmg_roll = attacker.weapon.damage_roll
|
||||||
|
|
||||||
|
dmg = rules.EvAdventureRollEngine.roll(weapon_dmg_roll)
|
||||||
|
if critical:
|
||||||
|
dmg += rules.EvAdventureRollEngine.roll(weapon_dmg_roll)
|
||||||
|
|
||||||
|
defender.hp -= dmg
|
||||||
|
|
||||||
|
# call hook
|
||||||
|
defender.at_damage(dmg, attacker=attacker)
|
||||||
|
|
||||||
|
if defender.hp <= 0:
|
||||||
|
# roll on death table. This may or may not kill you
|
||||||
|
rules.EvAdventureRollEngine.roll_death(self)
|
||||||
|
|
||||||
|
# tell everyone
|
||||||
|
self.msg(defender.defeat_message(attacker, dmg))
|
||||||
|
|
||||||
|
if defender.hp > 0:
|
||||||
|
# they are weakened, but with hp
|
||||||
|
self.msg("You are alive, but out of the fight. If you want to press your luck, "
|
||||||
|
"you need to rejoin the combat.", targets=defender)
|
||||||
|
defender.at_defeat() # note - NPC monsters may still 'die' here
|
||||||
|
else:
|
||||||
|
# outright killed
|
||||||
|
defender.at_death()
|
||||||
|
|
||||||
|
# no matter the result, the combatant is out
|
||||||
|
self.remove_combatant(defender)
|
||||||
|
else:
|
||||||
|
# defender still alive
|
||||||
|
self.msg(defender)
|
||||||
|
|
||||||
|
def stunt(self, attacker, defender, attack_type="agility",
|
||||||
|
defense_type="agility", optimal_distance=0, suboptimal_distance=1,
|
||||||
|
advantage=True, beneficiary=None):
|
||||||
|
"""
|
||||||
|
Stunts does not hurt anyone, but are used to give advantage/disadvantage to combatants
|
||||||
|
for later turns. The 'attacker' here is the one attemting the stunt against the 'defender'.
|
||||||
|
If successful, advantage is given to attacker against defender and disadvantage to
|
||||||
|
defender againt attacker. It's also possible to replace the attacker with another combatant
|
||||||
|
against the defender - allowing to aid/hinder others on the battlefield.
|
||||||
|
|
||||||
|
Stunt-modifers last a maximum of two turns and are not additive. Advantages and
|
||||||
|
disadvantages against the same target cancel each other out.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
attacker (Object): The one attempting the stunt.
|
||||||
|
defender (Object): The one affected by the stunt.
|
||||||
|
attack_type (str): The ability tested to do the stunt.
|
||||||
|
defense_type (str): The ability used to defend against the stunt.
|
||||||
|
optimal_distance (int): At which distance the stunt works normally.
|
||||||
|
suboptimal_distance (int): At this distance, the stunt is performed at disadvantage.
|
||||||
|
advantage (bool): If False, try to apply disadvantage to defender
|
||||||
|
rather than advantage to attacker.
|
||||||
|
beneficiary (bool): If stunt succeeds, it may benefit another
|
||||||
|
combatant than the `attacker` doing the stunt. This allows for helping
|
||||||
|
allies.
|
||||||
|
|
||||||
|
"""
|
||||||
|
# check if stunt-attacker is at optimal distance
|
||||||
|
distance = self.distance_matrix[attacker][defender]
|
||||||
|
disadvantage = False
|
||||||
|
if suboptimal_distance == distance:
|
||||||
|
# fighting at the wrong range is not good
|
||||||
|
disadvantage = True
|
||||||
|
elif self._get_optimal_distance(attacker) != distance:
|
||||||
|
# if we are neither at optimal nor suboptimal distance, we can't do the stunt
|
||||||
|
# from here.
|
||||||
|
raise AttackFailure(f"You can't perform this stunt "
|
||||||
|
f"from {RANGE_NAMES[distance]} distance (must be "
|
||||||
|
f"{RANGE_NAMES[suboptimal_distance]} or, even better, "
|
||||||
|
f"{RANGE_NAMES[optimal_distance]}).")
|
||||||
|
# quality doesn't matter for stunts, they are either successful or not
|
||||||
|
is_success, _ = rules.EvAdventureRollEngine.opposed_saving_throw(
|
||||||
|
attacker, defender,
|
||||||
|
attack_type=attack_type,
|
||||||
|
defense_type=defense_type,
|
||||||
|
advantage=False, disadvantage=disadvantage,
|
||||||
)
|
)
|
||||||
self._refresh_distance_matrix()
|
if is_success:
|
||||||
|
beneficiary = beneficiary if beneficiary else attacker
|
||||||
|
if advantage:
|
||||||
|
self.gain_advantage(beneficiary, defender)
|
||||||
|
else:
|
||||||
|
self.gain_disadvantage(defender, beneficiary)
|
||||||
|
|
||||||
def remove_combatant(self, combatant):
|
return is_success
|
||||||
self.combatants.pop(combatant, None)
|
|
||||||
self._refresh_distance_matrix()
|
def attack(self, attacker, defender):
|
||||||
|
"""
|
||||||
|
Make an attack against a defender. This takes into account distance.
|
||||||
|
|
||||||
|
"""
|
||||||
|
# check if attacker is at optimal distance
|
||||||
|
distance = self.distance_matrix[attacker][defender]
|
||||||
|
|
||||||
|
# figure out advantage (gained by previous stunts)
|
||||||
|
advantage = bool(self.advantage_matrix[attacker].pop(defender, False))
|
||||||
|
|
||||||
|
# figure out disadvantage (by distance or by previous action)
|
||||||
|
disadvantage = bool(self.disadvantage_matrix[attacker].pop(defender, False))
|
||||||
|
if self._get_suboptimal_distance(attacker) == distance:
|
||||||
|
# fighting at the wrong range is not good
|
||||||
|
disadvantage = True
|
||||||
|
elif self._get_optimal_distance(attacker) != distance:
|
||||||
|
# if we are neither at optimal nor suboptimal distance, we can't
|
||||||
|
# attack from this range
|
||||||
|
raise AttackFailure(f"You can't attack with {attacker.weapon.key} "
|
||||||
|
f"from {RANGE_NAMES[distance]} distance.")
|
||||||
|
|
||||||
|
is_hit, quality = rules.EvAdventureRollEngine.opposed_saving_throw(
|
||||||
|
attacker, defender,
|
||||||
|
attack_type=attacker.weapon.attack_type,
|
||||||
|
defense_type=attacker.weapon.defense_type,
|
||||||
|
advantage=advantage, disadvantage=disadvantage
|
||||||
|
)
|
||||||
|
if is_hit:
|
||||||
|
self.resolve_damage(attacker, defender, critical=quality == "critical success")
|
||||||
|
|
||||||
|
return is_hit
|
||||||
|
|
|
||||||
|
|
@ -44,10 +44,14 @@ class EvAdventureWeapon(EvAdventureObject):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
wield_slot = AttributeProperty(default="weapon")
|
wield_slot = AttributeProperty(default="weapon")
|
||||||
|
|
||||||
|
attack_type = AttributeProperty(default="strength")
|
||||||
|
defense_type = AttributeProperty(default="armor")
|
||||||
damage_roll = AttributeProperty(default="1d6")
|
damage_roll = AttributeProperty(default="1d6")
|
||||||
|
|
||||||
# at which ranges this weapon can be used. If not listed, unable to use
|
# at which ranges this weapon can be used. If not listed, unable to use
|
||||||
range_optimal = AttributeProperty(default=0) # normal usage
|
distance_optimal = AttributeProperty(default=0) # normal usage (fists)
|
||||||
range_suboptimal = AttributeProperty(default=1) # usage with disadvantage
|
distance_suboptimal = AttributeProperty(default=None) # disadvantage (fists)
|
||||||
|
|
||||||
|
|
||||||
class EvAdventureRunestone(EvAdventureWeapon):
|
class EvAdventureRunestone(EvAdventureWeapon):
|
||||||
|
|
|
||||||
|
|
@ -366,3 +366,15 @@ initiative = [
|
||||||
('4-6', "PC acts first"),
|
('4-6', "PC acts first"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
death_and_dismemberment = [
|
||||||
|
"dead",
|
||||||
|
"dead", # original says 'dismemberment' here, we don't simulate this
|
||||||
|
"weakened", # -1d4 STR
|
||||||
|
"unsteady", # -1d4 DEX
|
||||||
|
"sickly", # -1d4 CON
|
||||||
|
"addled", # -1d4 INT
|
||||||
|
"rattled", # -1d4 WIS
|
||||||
|
"disfigured", # -1d4 CHA
|
||||||
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -175,61 +175,6 @@ class EvAdventureRollEngine:
|
||||||
quality = None
|
quality = None
|
||||||
return (dice_roll + attack_bonus + modifier) > defender_defense, quality
|
return (dice_roll + attack_bonus + modifier) > defender_defense, quality
|
||||||
|
|
||||||
# specific rolls / actions
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def melee_attack(attacker, defender, advantage=False, disadvantage=False):
|
|
||||||
"""Close attack (strength vs armor)"""
|
|
||||||
return opposed_saving_throw(
|
|
||||||
attacker, defender, attack_type="strength", defense_type="armor",
|
|
||||||
advantage=advantage, disadvantage=disadvantage)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def ranged_attack(attacker, defender, advantage=False, disadvantage=False):
|
|
||||||
"""Ranged attack (wisdom vs armor)"""
|
|
||||||
return opposed_saving_throw(
|
|
||||||
attacker, defender, attack_type="wisdom", defense_type="armor",
|
|
||||||
advantage=advantage, disadvantage=disadvantage)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def magic_attack(attacker, defender, advantage=False, disadvantage=False):
|
|
||||||
"""Magic attack (int vs dexterity)"""
|
|
||||||
return opposed_saving_throw(
|
|
||||||
attacker, defender, attack_type="intelligence", defense_type="dexterity",
|
|
||||||
advantage=advantage, disadvantage=disadvantage)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def morale_check(defender):
|
|
||||||
"""
|
|
||||||
A morale check is done for NPCs/monsters. It's done with a 2d6 against
|
|
||||||
their morale.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
defender (NPC): The entity trying to defend its morale.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: False if morale roll failed, True otherwise.
|
|
||||||
|
|
||||||
"""
|
|
||||||
return roll('2d6') <= defender.morale
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def healing_from_rest(character):
|
|
||||||
"""
|
|
||||||
A meal and a full night's rest allow for regaining 1d8 + Const bonus HP.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
character (Character): The one resting.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
int: How much HP was healed. This is never more than how damaged we are.
|
|
||||||
|
|
||||||
"""
|
|
||||||
# we can't heal more than our damage
|
|
||||||
damage = character.hp_max - character.hp
|
|
||||||
healed = roll('1d8') + character.constitution
|
|
||||||
return min(damage, healed)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def roll_random_table(dieroll, table, table_choices):
|
def roll_random_table(dieroll, table, table_choices):
|
||||||
"""
|
"""
|
||||||
|
|
@ -283,6 +228,83 @@ class EvAdventureRollEngine:
|
||||||
roll_result = max(1, min(len(table_choices), roll_result))
|
roll_result = max(1, min(len(table_choices), roll_result))
|
||||||
return table_choices[roll_result - 1]
|
return table_choices[roll_result - 1]
|
||||||
|
|
||||||
|
# specific rolls / actions
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def morale_check(defender):
|
||||||
|
"""
|
||||||
|
A morale check is done for NPCs/monsters. It's done with a 2d6 against
|
||||||
|
their morale.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
defender (NPC): The entity trying to defend its morale.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: False if morale roll failed, True otherwise.
|
||||||
|
|
||||||
|
"""
|
||||||
|
return roll('2d6') <= defender.morale
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def healing_from_rest(character):
|
||||||
|
"""
|
||||||
|
A meal and a full night's rest allow for regaining 1d8 + Const bonus HP.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character (Character): The one resting.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: How much HP was healed. This is never more than how damaged we are.
|
||||||
|
|
||||||
|
"""
|
||||||
|
# we can't heal more than our damage
|
||||||
|
damage = character.hp_max - character.hp
|
||||||
|
healed = roll('1d8') + character.constitution
|
||||||
|
return min(damage, healed)
|
||||||
|
|
||||||
|
death_map = {
|
||||||
|
"weakened": "strength",
|
||||||
|
"unsteady": "dexterity",
|
||||||
|
"sickly": "constitution",
|
||||||
|
"addled": "intelligence",
|
||||||
|
"rattled": "wisdom",
|
||||||
|
"disfigured": "charisma",
|
||||||
|
}
|
||||||
|
|
||||||
|
def roll_death(character):
|
||||||
|
"""
|
||||||
|
Happens when hitting <= 0 hp. unless dead,
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = self.roll_random_table('1d8', 'death_and_dismemberment')
|
||||||
|
if result == "dead":
|
||||||
|
character.handle_death()
|
||||||
|
else:
|
||||||
|
# survives with degraded abilities (1d4 roll)
|
||||||
|
abi = death_map[result]
|
||||||
|
|
||||||
|
current_abi = getattr(character, abi)
|
||||||
|
loss = self.roll("1d4")
|
||||||
|
|
||||||
|
current_abi =- loss
|
||||||
|
|
||||||
|
if current_abi < -10:
|
||||||
|
# can't lose more - die
|
||||||
|
character.handle_death()
|
||||||
|
else:
|
||||||
|
new_hp = max(character.hp_max, self.roll("1d4"))
|
||||||
|
setattr(character, abi, current_abi)
|
||||||
|
character.hp = new_hp
|
||||||
|
|
||||||
|
character.msg(
|
||||||
|
"~" * 78 +
|
||||||
|
"\n|yYou survive your brush with death, "
|
||||||
|
f"but are |r{result.upper()}|y and permenently |rlose {loss} {abi}|y.|n\n"
|
||||||
|
f"|GYou recover |g{new_hp}|G health|.\n"
|
||||||
|
+ "~" * 78
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# character generation
|
# character generation
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue