Start adding quest module
This commit is contained in:
parent
e7bc8d9836
commit
d19eac8ac9
2 changed files with 315 additions and 330 deletions
|
|
@ -10,8 +10,11 @@ The combat is handled with a `Script` shared between all combatants; this tracks
|
||||||
of combat and handles all timing elements.
|
of combat and handles all timing elements.
|
||||||
|
|
||||||
Unlike in base _Knave_, the MUD version's combat is simultaneous; everyone plans and executes
|
Unlike in base _Knave_, the MUD version's combat is simultaneous; everyone plans and executes
|
||||||
their turns simultaneously with minimum downtime. This version also includes a stricter
|
their turns simultaneously with minimum downtime.
|
||||||
handling of optimal distances than base _Knave_ (this would be handled by the GM normally).
|
|
||||||
|
This version is simplified to not worry about things like optimal range etc. So a bow can be used
|
||||||
|
the same as a sword in battle. One could add a 1D range mechanism to add more strategy by requiring
|
||||||
|
optimizal positioning.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -21,21 +24,11 @@ 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 evennia.utils.utils import make_iter
|
||||||
from evennia.utils import evmenu, evtable
|
from evennia.utils import evmenu, evtable
|
||||||
|
from .enums import Ability
|
||||||
from . import rules
|
from . import rules
|
||||||
|
|
||||||
MIN_RANGE = 0
|
|
||||||
MAX_RANGE = 4
|
|
||||||
MAX_MOVE_RATE = 2
|
|
||||||
STUNT_DURATION = 2
|
STUNT_DURATION = 2
|
||||||
|
|
||||||
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 CombatFailure(RuntimeError):
|
class CombatFailure(RuntimeError):
|
||||||
"""
|
"""
|
||||||
|
|
@ -44,19 +37,17 @@ class CombatFailure(RuntimeError):
|
||||||
|
|
||||||
class CombatAction:
|
class CombatAction:
|
||||||
"""
|
"""
|
||||||
This describes a combat-action, like 'attack'.
|
This is the base of a combat-action, like 'attack' or defend.
|
||||||
|
Inherit from this to make new actions.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
key = 'action'
|
key = 'action'
|
||||||
help_text = "Combat action to perform."
|
help_text = "Combat action to perform."
|
||||||
# action to echo to everyone.
|
# action to echo to everyone.
|
||||||
post_action_text = "{combatant} performed an action."
|
post_action_text = "{combatant} performed an action."
|
||||||
optimal_range = 0
|
max_uses = None # None for unlimited
|
||||||
# None for unlimited
|
# in which order (highest first) to perform the action. If identical, use random order
|
||||||
max_uses = None
|
priority = 0
|
||||||
suboptimal_range = 1
|
|
||||||
# move actions can be combined with other actions
|
|
||||||
is_move_action = False
|
|
||||||
|
|
||||||
def __init__(self, combathandler, combatant):
|
def __init__(self, combathandler, combatant):
|
||||||
self.combathandler = combathandler
|
self.combathandler = combathandler
|
||||||
|
|
@ -71,33 +62,13 @@ class CombatAction:
|
||||||
# send only to the combatant.
|
# send only to the combatant.
|
||||||
self.combatant.msg(message)
|
self.combatant.msg(message)
|
||||||
|
|
||||||
def get_help(self):
|
def get_help(self, *args, **kwargs):
|
||||||
return ""
|
return self.help_text
|
||||||
|
|
||||||
def check_distance(self, distance, optimal_range=None, suboptimal_range=None):
|
|
||||||
"""Call to easily check and warn for out-of-bound distance"""
|
|
||||||
|
|
||||||
if optimal_range is None:
|
|
||||||
optimal_range = self.optimal_range
|
|
||||||
if suboptimal_range is None:
|
|
||||||
suboptimal_range = self.suboptimal_range
|
|
||||||
|
|
||||||
if distance not in (self.suboptimal_distance, self.optimal_distance):
|
|
||||||
# if we are neither at optimal nor suboptimal distance, we can't do the stunt
|
|
||||||
# from here.
|
|
||||||
self.msg(f"|rYou can't perform {self.key} from {range_names[distance]} distance "
|
|
||||||
"(must be {range_names[suboptimal_distance]} or, even better, "
|
|
||||||
"{range_names[optimal_distance]}).|n")
|
|
||||||
return False
|
|
||||||
elif self.distance == self.suboptimal_distance:
|
|
||||||
self.msg(f"|yNote: Performing {self.key} from {range_names[distance]} works, but "
|
|
||||||
f"the optimal range is {range_names[optimal_range]} (you'll "
|
|
||||||
"act with disadvantage).")
|
|
||||||
return True
|
|
||||||
|
|
||||||
def can_use(self, combatant, *args, **kwargs):
|
def can_use(self, combatant, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
Determine if combatant can use this action.
|
Determine if combatant can use this action. In this implementation,
|
||||||
|
it fails if already use all of a usage-limited action.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
combatant (Object): The one performing the action.
|
combatant (Object): The one performing the action.
|
||||||
|
|
@ -111,13 +82,13 @@ class CombatAction:
|
||||||
"""
|
"""
|
||||||
return True if self.uses is None else self.uses < self.max_uses
|
return True if self.uses is None else self.uses < self.max_uses
|
||||||
|
|
||||||
def pre_perform(self, *args, **kwargs):
|
def pre_use(self, *args, **kwargs):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def perform(self, *args, **kwargs):
|
def use(self, *args, **kwargs):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def post_perform(self, *args, **kwargs):
|
def post_use(self, *args, **kwargs):
|
||||||
self.uses += 1
|
self.uses += 1
|
||||||
self.combathandler.msg(self.post_action_text.format(combatant=combatant))
|
self.combathandler.msg(self.post_action_text.format(combatant=combatant))
|
||||||
|
|
||||||
|
|
@ -134,21 +105,31 @@ class CombatActionDoNothing(CombatAction):
|
||||||
|
|
||||||
class CombatActionStunt(CombatAction):
|
class CombatActionStunt(CombatAction):
|
||||||
"""
|
"""
|
||||||
Perform a stunt.
|
Perform a stunt. A stunt grants an advantage to yours or another player for their next
|
||||||
|
action, or a disadvantage to yours or an enemy's next action.
|
||||||
|
|
||||||
|
Note that while the check happens between the user and a target, another (the 'beneficiary'
|
||||||
|
could still gain the effect. This allows for boosting allies or making them better
|
||||||
|
defend against an enemy.
|
||||||
|
|
||||||
|
Note: We only count a use if the stunt is successful; they will still spend their turn, but won't
|
||||||
|
spend a use unless they succeed.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
optimal_distance = 0
|
|
||||||
suboptimal_distance = 1
|
|
||||||
give_advantage = True
|
give_advantage = True
|
||||||
give_disadvantage = False
|
give_disadvantage = False
|
||||||
uses = 1
|
max_uses = 1
|
||||||
attack_type = "dexterity"
|
priority = -1
|
||||||
defense_type = "dexterity"
|
# how many turns the stunt's effect apply (that is, how quickly it must be used before the
|
||||||
|
# advantage/disadvantage is lost).
|
||||||
|
duration = 5
|
||||||
|
attack_type = Ability.DEX
|
||||||
|
defense_type = Ability.DEX
|
||||||
help_text = ("Perform a stunt against a target. This will give you or an ally advantage "
|
help_text = ("Perform a stunt against a target. This will give you or an ally advantage "
|
||||||
"on your next action against the same target [range 0-1, one use per combat. "
|
"on your next action against the same target [range 0-1, one use per combat. "
|
||||||
"Bonus lasts for two turns].")
|
"Bonus lasts for two turns].")
|
||||||
|
|
||||||
def perform(self, attacker, defender, *args, beneficiary=None, **kwargs):
|
def use(self, attacker, defender, *args, beneficiary=None, **kwargs):
|
||||||
# quality doesn't matter for stunts, they are either successful or not
|
# quality doesn't matter for stunts, they are either successful or not
|
||||||
|
|
||||||
is_success, _ = rules.EvAdventureRollEngine.opposed_saving_throw(
|
is_success, _ = rules.EvAdventureRollEngine.opposed_saving_throw(
|
||||||
|
|
@ -160,11 +141,125 @@ class CombatActionStunt(CombatAction):
|
||||||
if is_success:
|
if is_success:
|
||||||
beneficiary = beneficiary if beneficiary else attacker
|
beneficiary = beneficiary if beneficiary else attacker
|
||||||
if advantage:
|
if advantage:
|
||||||
self.gain_advantage(beneficiary, defender)
|
self.combathandler.gain_advantage(beneficiary, defender)
|
||||||
else:
|
else:
|
||||||
self.gain_disadvantage(defender, beneficiary)
|
self.combathandler.gain_disadvantage(defender, beneficiary)
|
||||||
|
|
||||||
self.msg
|
self.msg
|
||||||
|
# only spend a use after being successful
|
||||||
|
uses += 1
|
||||||
|
|
||||||
|
|
||||||
|
class CombatActionAttack(CombatAction):
|
||||||
|
"""
|
||||||
|
A regular attack, using a wielded melee weapon.
|
||||||
|
|
||||||
|
"""
|
||||||
|
key = "attack"
|
||||||
|
priority = 1
|
||||||
|
|
||||||
|
def use(self, attacker, defender, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Make an attack against a defender.
|
||||||
|
|
||||||
|
"""
|
||||||
|
# figure out advantage (gained by previous stunts)
|
||||||
|
advantage = bool(self.combathandler.advantage_matrix[attacker].pop(defender, False))
|
||||||
|
|
||||||
|
# figure out disadvantage (by distance or by previous action)
|
||||||
|
disadvantage = bool(self.combathandler.disadvantage_matrix[attacker].pop(defender, False))
|
||||||
|
|
||||||
|
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.combathandler.resolve_damage(attacker, defender,
|
||||||
|
critical=quality == "critical success")
|
||||||
|
|
||||||
|
# TODO messaging here
|
||||||
|
|
||||||
|
|
||||||
|
class CombatActionUseItem(CombatAction):
|
||||||
|
"""
|
||||||
|
Use an item in combat. This is meant for one-off or limited-use items, like potions, scrolls or
|
||||||
|
wands. We offload the usage checks and usability to the item's own hooks. It's generated dynamically
|
||||||
|
from the items in the character's inventory (you could also consider using items in the room this way).
|
||||||
|
|
||||||
|
Each usable item results in one possible action.
|
||||||
|
|
||||||
|
It relies on the combat_* hooks on the item:
|
||||||
|
combat_get_help
|
||||||
|
combat_can_use
|
||||||
|
combat_pre_use
|
||||||
|
combat_pre
|
||||||
|
combat_post_use
|
||||||
|
|
||||||
|
"""
|
||||||
|
def get_help(self, item, *args):
|
||||||
|
return item.combat_get_help(*args)
|
||||||
|
|
||||||
|
def can_use(self, item, combatant, *args, **kwargs):
|
||||||
|
return item.combat_can_use(combatant, self.combathandler, *args, **kwargs)
|
||||||
|
|
||||||
|
def pre_use(self, item, *args, **kwargs):
|
||||||
|
item.combat_pre_use(*args, **kwargs)
|
||||||
|
|
||||||
|
def use(self, item, combatant, target, *args, **kwargs):
|
||||||
|
item.combat_use(combatant, target, *args, **kwargs)
|
||||||
|
|
||||||
|
def post_use(self, item, *args, **kwargs):
|
||||||
|
item.combat_post_use(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class CombatActionFlee(CombatAction):
|
||||||
|
"""
|
||||||
|
Fleeing/disengaging from combat means doing nothing but 'running away' for two turn. Unless
|
||||||
|
someone attempts and succeeds in their 'chase' action, you will leave combat by fleeing at the
|
||||||
|
end of the second turn.
|
||||||
|
|
||||||
|
"""
|
||||||
|
key = "flee"
|
||||||
|
priority = -1
|
||||||
|
|
||||||
|
def use(self, combatant, target, *args, **kwargs):
|
||||||
|
# it's safe to do this twice
|
||||||
|
self.combathandler.flee(combatant)
|
||||||
|
|
||||||
|
class CombatActionChase(CombatAction):
|
||||||
|
|
||||||
|
"""
|
||||||
|
Chasing is a way to counter a 'flee' action. It is a maximum movement towards the target
|
||||||
|
and will mean a DEX contest, if the fleeing target loses, they are moved back from
|
||||||
|
'disengaging' range and remain in combat at the new distance (likely 2 if max movement
|
||||||
|
is 2). Advantage/disadvantage are considered.
|
||||||
|
|
||||||
|
"""
|
||||||
|
key = "chase"
|
||||||
|
priority = -5 # checked last
|
||||||
|
|
||||||
|
attack_type = Ability.DEX # or is it CON?
|
||||||
|
defense_type = Ability.DEX
|
||||||
|
|
||||||
|
def use(self, combatant, fleeing_target, *args, **kwargs):
|
||||||
|
|
||||||
|
advantage = bool(self.advantage_matrix[attacker].pop(fleeing_target, False))
|
||||||
|
disadvantage = bool(self.disadvantage_matrix[attacker].pop(fleeing_target, False))
|
||||||
|
|
||||||
|
is_success, _ = rules.EvAdventureRollEngine.opposed_saving_throw(
|
||||||
|
combatant, fleeing_target,
|
||||||
|
attack_type=self.attack_type, defense_type=self.defense_type,
|
||||||
|
advantage=advantage, disadvantage=disadvantage
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_success:
|
||||||
|
# managed to stop the target from fleeing/disengaging
|
||||||
|
self.combatant.unflee(fleeing_target)
|
||||||
|
else:
|
||||||
|
pass # they are getting away!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class EvAdventureCombatHandler(DefaultScript):
|
class EvAdventureCombatHandler(DefaultScript):
|
||||||
|
|
@ -180,13 +275,11 @@ class EvAdventureCombatHandler(DefaultScript):
|
||||||
|
|
||||||
# turn counter - abstract time
|
# turn counter - abstract time
|
||||||
turn = AttributeProperty(default=0)
|
turn = AttributeProperty(default=0)
|
||||||
# symmetric distance matrix (handled dynamically). Mapping {combatant1: {combatant2: dist}, ...}
|
|
||||||
distance_matrix = defaultdict(dict)
|
|
||||||
# advantages or disadvantages gained against different targets
|
# advantages or disadvantages gained against different targets
|
||||||
advantage_matrix = AttributeProperty(defaultdict(dict))
|
advantage_matrix = AttributeProperty(defaultdict(dict))
|
||||||
disadvantage_matrix = AttributeProperty(defaultdict(dict))
|
disadvantage_matrix = AttributeProperty(defaultdict(dict))
|
||||||
|
|
||||||
disengaging_combatants = AttributeProperty(default=list())
|
fleeing_combatants = AttributeProperty(default=list())
|
||||||
|
|
||||||
# actions that will be performed before a normal action
|
# actions that will be performed before a normal action
|
||||||
move_actions = ("approach", "withdraw")
|
move_actions = ("approach", "withdraw")
|
||||||
|
|
@ -197,50 +290,6 @@ class EvAdventureCombatHandler(DefaultScript):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _refresh_distance_matrix(self):
|
|
||||||
"""
|
|
||||||
Refresh the distance matrix, either after movement or when a
|
|
||||||
new combatant enters combat - everyone must have a symmetric
|
|
||||||
distance to every other combatant (that is, if you are 'near' an opponent,
|
|
||||||
they are also 'near' to you).
|
|
||||||
|
|
||||||
Distances are abstract and divided into four steps:
|
|
||||||
|
|
||||||
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/fleeing (no weapons can be used)
|
|
||||||
|
|
||||||
Distance is tracked to each opponent individually. One can move 1 step and attack
|
|
||||||
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.
|
|
||||||
|
|
||||||
"""
|
|
||||||
combatants = self.combatants
|
|
||||||
distance_matrix = self.distance_matrix
|
|
||||||
|
|
||||||
for combatant1 in combatants:
|
|
||||||
for combatant2 in combatants:
|
|
||||||
|
|
||||||
if combatant1 == combatant2:
|
|
||||||
continue
|
|
||||||
|
|
||||||
combatant1_distances = distance_matrix[combatant1]
|
|
||||||
combatant2_distances = distance_matrix[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)
|
|
||||||
|
|
||||||
combatant1_distances[combatant2] = start_optimal
|
|
||||||
combatant2_distances[combatant1] = start_optimal
|
|
||||||
|
|
||||||
def _update_turn_stats(self, combatant, message):
|
def _update_turn_stats(self, combatant, message):
|
||||||
"""
|
"""
|
||||||
Store combat messages to display at the end of turn.
|
Store combat messages to display at the end of turn.
|
||||||
|
|
@ -261,22 +310,15 @@ class EvAdventureCombatHandler(DefaultScript):
|
||||||
"""
|
"""
|
||||||
End of turn operations.
|
End of turn operations.
|
||||||
|
|
||||||
1. Do all moves
|
1. Do all regular actions
|
||||||
2. Do all regular actions
|
2. Remove combatants that disengaged successfully
|
||||||
3. Remove combatants that disengaged successfully
|
3. Timeout advantages/disadvantages set for longer than STUNT_DURATION
|
||||||
4. Timeout advantages/disadvantages set for longer than STUNT_DURATION
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# first do all moves
|
# do all actions
|
||||||
for combatant in self.combatants:
|
for combatant in self.combatants:
|
||||||
action, args, kwargs = self.action_queue[combatant].get(
|
action, args, kwargs = self.action_queue[combatant]
|
||||||
"move", ("do_nothing", (), {}))
|
action.use(combatant, *args, **kwargs)
|
||||||
getattr(self, f"action_{action}")(combatant, *args, **kwargs)
|
|
||||||
# next do all regular actions
|
|
||||||
for combatant in self.combatants:
|
|
||||||
action, args, kwargs = self.action_qeueue[combatant].get(
|
|
||||||
"action", ("do_nothing", (), {}))
|
|
||||||
getattr(self, f"action_{action}")(combatant, *args, **kwargs)
|
|
||||||
|
|
||||||
# handle disengaging combatants
|
# handle disengaging combatants
|
||||||
|
|
||||||
|
|
@ -285,13 +327,8 @@ class EvAdventureCombatHandler(DefaultScript):
|
||||||
for combatant in self.combatants:
|
for combatant in self.combatants:
|
||||||
# check disengaging combatants (these are combatants that managed
|
# check disengaging combatants (these are combatants that managed
|
||||||
# to stay at disengaging distance for a turn)
|
# to stay at disengaging distance for a turn)
|
||||||
if combatant in self.disengaging_combatants:
|
if combatant in self.fleeing_combatants:
|
||||||
self.disengaging_combatants.remove(combatant)
|
self.disengaging_combatants.remove(combatant)
|
||||||
to_remove.append(combatant)
|
|
||||||
elif all(1 for distance in self.distance_matrix[combatant].values()
|
|
||||||
if distance == MAX_RANGE):
|
|
||||||
# if at max distance (disengaging) from everyone, they are disengaging
|
|
||||||
self.disengaging_combatants.append(combatant)
|
|
||||||
|
|
||||||
for combatant in to_remove:
|
for combatant in to_remove:
|
||||||
# for clarity, we remove here rather than modifying the combatant list
|
# for clarity, we remove here rather than modifying the combatant list
|
||||||
|
|
@ -327,27 +364,29 @@ class EvAdventureCombatHandler(DefaultScript):
|
||||||
def add_combatant(self, combatant):
|
def add_combatant(self, combatant):
|
||||||
if combatant not in self.combatants:
|
if combatant not in self.combatants:
|
||||||
self.combatants.append(combatant)
|
self.combatants.append(combatant)
|
||||||
self._refresh_distance_matrix()
|
|
||||||
|
|
||||||
def remove_combatant(self, combatant):
|
def remove_combatant(self, combatant):
|
||||||
if combatant in self.combatants:
|
if combatant in self.combatants:
|
||||||
self.combatants.remove(combatant)
|
self.combatants.remove(combatant)
|
||||||
self._refresh_distance_matrix()
|
|
||||||
|
|
||||||
def get_combat_summary(self, combatant):
|
def get_combat_summary(self, combatant):
|
||||||
"""
|
"""
|
||||||
Get a summary of the current combat state.
|
Get a summary of the current combat state from the perspective of a
|
||||||
|
given combatant.
|
||||||
|
|
||||||
You (5/10 health)
|
You (5/10 health)
|
||||||
Foo (Hurt) distance: You__0__1___X____3_____4 (medium)
|
Foo (Hurt) [Running away - use 'chase' to stop them!]
|
||||||
Bar (Perfect health): You__X__1___2____3_____4 (close)
|
Bar (Perfect health)
|
||||||
|
|
||||||
"""
|
"""
|
||||||
table = evtable.EvTable(border_width=0)
|
table = evtable.EvTable(border_width=0)
|
||||||
|
|
||||||
table.add_row(f"You ({combatant.hp} / {combatant.hp_max} health)")
|
# 'You' display
|
||||||
|
fleeing = ""
|
||||||
|
if combatant in self.fleeing_combatants:
|
||||||
|
fleeing = " You are running away! Use 'flee' again next turn."
|
||||||
|
|
||||||
dist_template = "|x(You)__{0}|x__{1}|x___{2}|x____{3}|x_____|R{4} |x({distname})"
|
table.add_row(f"You ({combatant.hp} / {combatant.hp_max} health){fleeing}")
|
||||||
|
|
||||||
for comb in self.combatants:
|
for comb in self.combatants:
|
||||||
|
|
||||||
|
|
@ -355,20 +394,19 @@ class EvAdventureCombatHandler(DefaultScript):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
name = combatant.key
|
name = combatant.key
|
||||||
distance = self.distance_matrix[combatant][comb]
|
|
||||||
dist_map = {i: '|wX' if i == distance else i for i in range(MAX_RANGE)}
|
|
||||||
dist_map["distname"] = RANGE_NAMES[distance]
|
|
||||||
health = f"{comb.hurt_level}"
|
health = f"{comb.hurt_level}"
|
||||||
distance_string = dist_template.format(**dist_map)
|
fleeing = ""
|
||||||
|
if comb in self.fleeing_combatants:
|
||||||
|
fleeing = " [Running away! Use 'chase' to stop them!"
|
||||||
|
|
||||||
table.add_row(f"{name} ({health})", distance_string)
|
table.add_row(f"{name} ({health}){fleeing}")
|
||||||
|
|
||||||
return str(table)
|
return str(table)
|
||||||
|
|
||||||
def msg(self, message, targets=None):
|
def msg(self, message, targets=None):
|
||||||
"""
|
"""
|
||||||
Central place for sending messages to combatants. This allows
|
Central place for sending messages to combatants. This allows
|
||||||
for decorating the output in one place if needed.
|
for adding any combat-specific text-decoration in one place.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
message (str): The message to send.
|
message (str): The message to send.
|
||||||
|
|
@ -384,26 +422,6 @@ class EvAdventureCombatHandler(DefaultScript):
|
||||||
for target in self.combatants:
|
for target in self.combatants:
|
||||||
target.msg(message)
|
target.msg(message)
|
||||||
|
|
||||||
def move_relative_to(self, combatant, target_combatant, change,
|
|
||||||
min_dist=MIN_RANGE, max_dist=MAX_RANGE):
|
|
||||||
"""
|
|
||||||
Change the distance to a target.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
combatant (Character): The one doing the change.
|
|
||||||
target_combatant (Character): The one distance is changed to.
|
|
||||||
change (int): A +/- change value. Result is always in range 0..4.
|
|
||||||
|
|
||||||
"""
|
|
||||||
current_dist = self.distance_matrix[combatant][target_combatant]
|
|
||||||
|
|
||||||
change = max(0, min(MAX_MOVE_RATE, change))
|
|
||||||
|
|
||||||
new_dist = max(min_dist, min(max_dist, 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):
|
def gain_advantage(self, combatant, target):
|
||||||
"""
|
"""
|
||||||
Gain advantage against target. Spent by actions.
|
Gain advantage against target. Spent by actions.
|
||||||
|
|
@ -418,6 +436,14 @@ class EvAdventureCombatHandler(DefaultScript):
|
||||||
"""
|
"""
|
||||||
self.disadvantage_matrix[combatant][target] = self.turn
|
self.disadvantage_matrix[combatant][target] = self.turn
|
||||||
|
|
||||||
|
def flee(self, combatant):
|
||||||
|
if combatant not in self.fleeing_combatants:
|
||||||
|
self.fleeing_combatants.append(combatant)
|
||||||
|
|
||||||
|
def unflee(self, combatant):
|
||||||
|
if combatant in self.fleeing_combatants:
|
||||||
|
self.fleeing_combatants.remove(combatant)
|
||||||
|
|
||||||
def resolve_damage(self, attacker, defender, critical=False):
|
def resolve_damage(self, attacker, defender, critical=False):
|
||||||
"""
|
"""
|
||||||
Apply damage to defender. On a critical hit, the damage die
|
Apply damage to defender. On a critical hit, the damage die
|
||||||
|
|
@ -457,7 +483,7 @@ class EvAdventureCombatHandler(DefaultScript):
|
||||||
# defender still alive
|
# defender still alive
|
||||||
self.msg(defender)
|
self.msg(defender)
|
||||||
|
|
||||||
def register_action(self, combatant, action="do_nothing", *args, **kwargs):
|
def register_action(self, combatant, action=None, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
Register an action by-name.
|
Register an action by-name.
|
||||||
|
|
||||||
|
|
@ -465,186 +491,24 @@ class EvAdventureCombatHandler(DefaultScript):
|
||||||
combatant (Object): The one performing the action.
|
combatant (Object): The one performing the action.
|
||||||
action (str): An available action, will be prepended with `action_` and
|
action (str): An available action, will be prepended with `action_` and
|
||||||
used to call the relevant handler on this script.
|
used to call the relevant handler on this script.
|
||||||
*args: Will be passed to the action method `action_<action>`.
|
|
||||||
**kwargs: Will be passed into the action method `action_<action>`.
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if action in self.move_actions:
|
if not action:
|
||||||
self.action_queue[combatant]["move"] = (action, args, kwargs)
|
action = CombatActionDoNothing
|
||||||
else:
|
self.action_queue[combatant] = (action, args, kwargs)
|
||||||
self.action_queue[combatant]["action"] = (action, args, kwargs)
|
|
||||||
|
|
||||||
# action verbs. All of these start with action_* and should also accept
|
|
||||||
# *args, **kwargs so that we can make the call-mechanism generic.
|
|
||||||
|
|
||||||
def action_do_nothing(self, combatant, *args, **kwargs):
|
|
||||||
"""Do nothing for a turn."""
|
|
||||||
|
|
||||||
def action_stunt(self, attacker, defender, attack_type="agility",
|
|
||||||
defense_type="agility", optimal_distance=0, suboptimal_distance=1,
|
|
||||||
advantage=True, beneficiary=None, *args, **kwargs):
|
|
||||||
"""
|
|
||||||
Stunts does not cause damage 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 relative to 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:
|
|
||||||
# stunts need to be within range
|
|
||||||
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 combatfailure(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,
|
|
||||||
)
|
|
||||||
if is_success:
|
|
||||||
beneficiary = beneficiary if beneficiary else attacker
|
|
||||||
if advantage:
|
|
||||||
self.gain_advantage(beneficiary, defender)
|
|
||||||
else:
|
|
||||||
self.gain_disadvantage(defender, beneficiary)
|
|
||||||
|
|
||||||
return is_success
|
|
||||||
|
|
||||||
def action_attack(self, attacker, defender, *args, **kwargs):
|
|
||||||
"""
|
|
||||||
Make an attack against a defender. This takes into account distance. The
|
|
||||||
attack type/defense depends on the weapon/spell/whatever used.
|
|
||||||
|
|
||||||
"""
|
|
||||||
# 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 CombatFailure(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
|
|
||||||
|
|
||||||
def action_heal(self, combatant, target, max_distance=1, healing_roll="1d6", *args, **kwargs):
|
|
||||||
"""
|
|
||||||
Heal a target. Target can be the combatant itself.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
combatant (Object): The one performing the heal.
|
|
||||||
target (Object): The one to be healed (can be the same as combatant).
|
|
||||||
max_distance (int): Distances *up to* this range allow for healing.
|
|
||||||
healing_roll (str): The die roll for how many HP to heal.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
CombatFailure: If too far away to heal target.
|
|
||||||
|
|
||||||
"""
|
|
||||||
if target is not combatant:
|
|
||||||
distance = self.distance_matrix[attacker][defender]
|
|
||||||
if distance > max_distance:
|
|
||||||
raise CombatFailure(f"Too far away to heal {target.key}.")
|
|
||||||
|
|
||||||
target.heal(rules.EvAdventureRollEngine.roll(healing_roll), healer=combatant)
|
|
||||||
|
|
||||||
def action_approach(self, combatant, other_combatant, change, *args, **kwargs):
|
|
||||||
"""
|
|
||||||
Approach target. Closest is 0. This can be combined with another action.
|
|
||||||
|
|
||||||
"""
|
|
||||||
self.move_relative_to(combatant, other_combatant, -abs(change), min_dist=MIN_RANGE)
|
|
||||||
|
|
||||||
def action_withdraw(self, combatant, other_combatant, change):
|
|
||||||
"""
|
|
||||||
Withdraw from target. Most distant is range 3 - further and you'll be disengaging.
|
|
||||||
This can be combined with another action.
|
|
||||||
|
|
||||||
"""
|
|
||||||
self.move_relative_to(combatant, other_combatant, abs(change), max_dist=3)
|
|
||||||
|
|
||||||
def action_flee(self, combatant, *args, **kwargs):
|
|
||||||
"""
|
|
||||||
Fleeing/disengaging from combat means moving towards 'disengaging' range from
|
|
||||||
everyone else and staying there for one turn.
|
|
||||||
|
|
||||||
"""
|
|
||||||
for other_combatant in self.combatants:
|
|
||||||
self.move_relative_to(combatant, other_combatant, MAX_MOVE_RATE, max_dist=MAX_RANGE)
|
|
||||||
|
|
||||||
def action_chase(self, combatant, fleeing_target, *args, **kwargs):
|
|
||||||
"""
|
|
||||||
Chasing is a way to counter a 'flee' action. It is a maximum movement towards the target
|
|
||||||
and will mean a DEX contest, if the fleeing target loses, they are moved back from
|
|
||||||
'disengaging' range and remain in combat at the new distance (likely 2 if max movement
|
|
||||||
is 2). Advantage/disadvantage are considered.
|
|
||||||
|
|
||||||
"""
|
|
||||||
ability = "dexterity"
|
|
||||||
|
|
||||||
advantage = bool(self.advantage_matrix[attacker].pop(fleeing_target, False))
|
|
||||||
disadvantage = bool(self.disadvantage_matrix[attacker].pop(fleeing_target, False))
|
|
||||||
|
|
||||||
is_success, _ = rules.EvAdventureRollEngine.opposed_saving_throw(
|
|
||||||
combatant, fleeing_target,
|
|
||||||
attack_type=ability, defense_type=ability,
|
|
||||||
advantage=advantage, disadvantage=disadvantage
|
|
||||||
)
|
|
||||||
|
|
||||||
if is_success:
|
|
||||||
# managed to stop the target from fleeing/disengaging - move closer
|
|
||||||
if fleeing_target in self.disengaging_combatants:
|
|
||||||
self.disengaging_combatants.remove(fleeing_target)
|
|
||||||
self.approach(combatant, fleeing_target, change=MAX_MOVE_RATE)
|
|
||||||
|
|
||||||
return is_success
|
|
||||||
|
|
||||||
|
|
||||||
# combat menu
|
# combat menu
|
||||||
|
|
||||||
|
combat_script = """
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _register_action(caller, raw_string, **kwargs):
|
def _register_action(caller, raw_string, **kwargs):
|
||||||
"""
|
"""
|
||||||
Register action with handler.
|
Register action with handler.
|
||||||
|
|
|
||||||
121
evennia/contrib/tutorials/evadventure/quests.py
Normal file
121
evennia/contrib/tutorials/evadventure/quests.py
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
"""
|
||||||
|
A simple quest system for EvAdventure.
|
||||||
|
|
||||||
|
A quest is represented by a quest-handler sitting as
|
||||||
|
.quest on a Character. Individual Quests are objects
|
||||||
|
that track the state and can have multiple steps, each
|
||||||
|
of which are checked off during the quest's progress.
|
||||||
|
|
||||||
|
The player can use the quest handler to track the
|
||||||
|
progress of their quests.
|
||||||
|
|
||||||
|
A quest ending can mean a reward or the start of
|
||||||
|
another quest.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class EvAdventureQuest:
|
||||||
|
"""
|
||||||
|
This represents a single questing unit of quest.
|
||||||
|
|
||||||
|
Properties:
|
||||||
|
name (str): Main identifier for the quest.
|
||||||
|
category (str, optional): This + name must be globally unique.
|
||||||
|
steps (list): A list of strings, representing how many steps are
|
||||||
|
in the quest. The first step is always the beginning, when the quest is presented.
|
||||||
|
The last step is always the end of the quest. It is possible to abort the quest before
|
||||||
|
it ends - it then pauses after the last completed step.
|
||||||
|
|
||||||
|
each step is represented by two methods on this object:
|
||||||
|
check_<name> and complete_<name>
|
||||||
|
|
||||||
|
"""
|
||||||
|
# name + category must be globally unique. They are
|
||||||
|
# queried as name:category or just name, if category is empty.
|
||||||
|
name = ""
|
||||||
|
category = ""
|
||||||
|
# example: steps = ["start", "step1", "step2", "end"]
|
||||||
|
steps = []
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
step = 0
|
||||||
|
|
||||||
|
def check():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def progress(self, quester, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
class EvAdventureQuestHandler:
|
||||||
|
"""
|
||||||
|
This sits on the Character, as `.quests`.
|
||||||
|
|
||||||
|
It's initiated using a lazy property on the Character:
|
||||||
|
|
||||||
|
```
|
||||||
|
@lazy_property
|
||||||
|
def quests(self):
|
||||||
|
return EvAdventureQuestHandler(self)
|
||||||
|
```
|
||||||
|
|
||||||
|
"""
|
||||||
|
quest_storage_attribute = "_quests"
|
||||||
|
quest_storage_attribute_category = "evadventure"
|
||||||
|
|
||||||
|
def __init__(self, obj):
|
||||||
|
self.obj = obj
|
||||||
|
self.storage = obj.attributes.get(
|
||||||
|
self.quest_storage_attribute,
|
||||||
|
category=self.quest_storage_attribute_category,
|
||||||
|
default={}
|
||||||
|
)
|
||||||
|
|
||||||
|
def quest_storage_key(self, name, category):
|
||||||
|
return f"{name}:{category}"
|
||||||
|
|
||||||
|
def has(self, quest_name, quest_category=""):
|
||||||
|
"""
|
||||||
|
Check if a given quest is registered with the Character.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
quest_name (str): The name of the quest to check for.
|
||||||
|
quest_category (str, optional): Quest category, if any.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: If the character is following this quest or not.
|
||||||
|
|
||||||
|
"""
|
||||||
|
return bool(self.get(quest_name, quest_category))
|
||||||
|
|
||||||
|
def get(self, quest_name, quest_category=""):
|
||||||
|
"""
|
||||||
|
Get the quest stored on character, if any.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
quest_name (str): The name of the quest to check for.
|
||||||
|
quest_category (str, optional): Quest category, if any.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
EvAdventureQuest or None: The quest stored, or None if
|
||||||
|
Character is not on this quest.
|
||||||
|
|
||||||
|
"""
|
||||||
|
return self.storage.get(self.quest_key(quest_storage_name, quest_category))
|
||||||
|
|
||||||
|
def add(self, quest, autostart=True):
|
||||||
|
"""
|
||||||
|
Add a new quest
|
||||||
|
|
||||||
|
Args:
|
||||||
|
quest (EvAdventureQuest): The quest to start.
|
||||||
|
autostart (bool, optional): If set, the quest will
|
||||||
|
start immediately.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue