More work on combat

This commit is contained in:
Griatch 2022-03-27 23:36:38 +02:00
parent 04200f8bfc
commit a7ced1dbfc
5 changed files with 398 additions and 119 deletions

View file

@ -8,6 +8,7 @@ from evennia.objects.objects import DefaultCharacter, DefaultObject
from evennia.typeclasses.attributes import AttributeProperty
from evennia.utils.utils import lazy_property, int2str
from .objects import EvAdventureObject
from . import rules
class EquipmentError(TypeError):
@ -284,6 +285,7 @@ class EvAdventureCharacter(DefaultCharacter):
"""
# these are the ability bonuses. Defense is always 10 higher
strength = AttributeProperty(default=1)
dexterity = AttributeProperty(default=1)
constitution = AttributeProperty(default=1)
@ -308,6 +310,24 @@ class EvAdventureCharacter(DefaultCharacter):
"""Allows to access equipment like char.equipment.worn"""
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):
"""
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)
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):
"""
This is the base class for all non-player entities, including monsters. These

View file

@ -19,41 +19,25 @@ from dataclasses import dataclass
from collections import defaultdict
from evennia.scripts.scripts import DefaultScript
from evennia.typeclasses.attributes import AttributeProperty
from evennia.utils.utils import make_iter
from . import rules
MIN_RANGE = 0
MAX_RANGE = 4
@dataclass
class CombatantStats:
RANGE_NAMES = {
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
during combat for a single Character.
Cannot attack for some reason.
"""
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):
@ -62,12 +46,18 @@ class EvAdventureCombat(DefaultScript):
of all active participants. It's also possible to join (or leave) the fray later.
"""
combatants = AttributeProperty(default=dict())
queue = AttributeProperty(default=list())
combatants = AttributeProperty(default=list())
action_queue = AttributeProperty(default=dict())
# turn counter - abstract time
turn = AttributeProperty(default=1)
# symmetric distance matrix
distance_matrix = {}
turn = AttributeProperty(default=0)
# symmetric distance matrix (handled dynamically). Mapping {combatant1: {combatant2: dist}, ...}
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):
"""
@ -85,55 +75,255 @@ class EvAdventureCombat(DefaultScript):
4. Disengaging/fleeing (no weapons can be used)
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
of them and their opponents.
"""
handled = []
for combatant1, combatant_stats1 in self.combatants.items():
for combatant2, combatant_stats2 in self.combatants.items():
combatants = self.combatants
distance_matrix = self.distance_matrix
for combatant1 in combatants:
for combatant2 in combatants:
if combatant1 == combatant2:
continue
# only update if data was not available already (or drifted
# out of sync, which should not happen)
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
combatant1_distances = distance_matrix[combatant1]
combatant2_distances = distance_matrix[combatant2]
handled.append(combatant1)
handled.append(combatant2)
if combatant2 not in combatant1_distances or combatant1 not in combatant2_distances:
# 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.
Args:
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.
"""
self.combatants[combatant].change_distance(target_combatant, change)
self.combatants[target_combatant].change_distance(combatant, change)
current_dist = self.distance_matrix[combatant][target_combatant]
def add_combatant(self, combatant):
self.combatants[combatant] = CombatantStats(
weapon=combatant.equipment.get("weapon"),
armor=combatant.equipment.armor,
new_dist = max(MIN_RANGE, min(MAX_RANGE, current_dist + change))
self.distance_matrix[combatant][target_combatant] = new_dist
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):
self.combatants.pop(combatant, None)
self._refresh_distance_matrix()
return is_success
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

View file

@ -44,10 +44,14 @@ class EvAdventureWeapon(EvAdventureObject):
"""
wield_slot = AttributeProperty(default="weapon")
attack_type = AttributeProperty(default="strength")
defense_type = AttributeProperty(default="armor")
damage_roll = AttributeProperty(default="1d6")
# at which ranges this weapon can be used. If not listed, unable to use
range_optimal = AttributeProperty(default=0) # normal usage
range_suboptimal = AttributeProperty(default=1) # usage with disadvantage
distance_optimal = AttributeProperty(default=0) # normal usage (fists)
distance_suboptimal = AttributeProperty(default=None) # disadvantage (fists)
class EvAdventureRunestone(EvAdventureWeapon):

View file

@ -366,3 +366,15 @@ initiative = [
('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
]

View file

@ -175,61 +175,6 @@ class EvAdventureRollEngine:
quality = None
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
def roll_random_table(dieroll, table, table_choices):
"""
@ -283,6 +228,83 @@ class EvAdventureRollEngine:
roll_result = max(1, min(len(table_choices), roll_result))
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