So, we have discussed the important of clean code before. That article showed how economic exchanges could clean up otherwise messy steps involved in the processes of cloning. But what happens if your mess is bigger than a single step? – In our new technology blog post, we invite you to join us for (code) combat …
Battling Combat Logic
In Tau Station, when you have started combat, you may decide it isn’t going well and attempt to flee. The logic is pretty simple.
- Signal we want to attempt to flee
- The opponent gets a free attack
- Attempt to flee to a random area in the station
- End combat
That’s pretty straight forward. You may notice that a lot of the steps are very similar to steps we’d be likely using elsewhere in the code (attacks, location changes, ending combat). In our original code we had something like:
sub flee_combat ($self) {
my $combat = $self->in_combat or return 1;
my $defender = $combat->defender;
my $result = $defender->_attack($self);
if ( !$result ) {
# combat was ended by death or confinement
$self->add_message({
message => "You were unable to flee the combat.",
message_type => 'combat-attacker',
});
$self->log_event(
'Combat',
type => 'attacker_flee',
success => 0,
character => $self,
victim => $defender->name,
reason => "attacker died on defender's bonus attack"
);
return 1;
}
my $chance = $self->_chance_to_run_away($defender);
if ( $self->attempt_to('combat-flee', $chance) ) {
$self->change_station_area( $self->_random_area );
if ( 'security' eq $self->area->slug ) {
$self->_ran_into_security_fleeing_combat($defender);
$defender->add_message({
message => [
"%s fled from combat but was caught by security.",
$self->name
],
message_type => 'combat-defender',
});
$self->log_event(
'Combat',
type => 'attacker_flee',
success => 1,
character => $self,
victim => $defender->name,
reason => "attacker fled straight into security"
);
}
else {
$self->add_message({
message => "You got away!",
message_type => 'combat-attacker',
});
$defender->add_message(
{ message => [ "%s fled from combat", $self->name ] },
message_type => 'combat-defender',
);
$self->log_event(
'Combat',
type => 'attacker_flee',
success => 1,
character => $self,
victim => $defender->name
);
}
$combat->delete;
return 1;
}
else {
$self->add_message(
{ message => "You were too slow to get away!" },
message_type => 'combat-attacker',
);
$self->log_event(
'Combat',
type => 'attacker_flee',
success => 0,
character => $self,
victim => $defender->name,
reason => "attacker was too slow to get away"
);
}
return;
}
This has all of the problems we’ve mentioned in the previous article about cleaning up code. It’s messy, and doing a lot of cross cutting things.
Using a Cleaner Strategy
We can use an economic exchange to clean this up somewhat. We isolate everything into steps and checks for each piece of combat, and then, only if everything is successful, do we write the changes out to the database. That ends up looking something like this:
sub flee_combat ($self) {
my $combat = $self->in_combat or return 1;
my $defender = $combat->defender;
my $exchange = $self->new_exchange(
slug => $slug,
Steps(
Precondition( $self => allow_when => 'in_combat' ),
Combat( $combat => 'is_active' ),
FAILURE(
Location( $self => 'send_to_brig_for_combat_timeout' )
),
Combat( $combat => 'rounds_left' ),
FAILURE(
Location( $self => 'send_to_brig' )
),
Combat( $combat => 'start_round' ),
# give the defender a chance to attack us when we flee
Combat(
$combat => 'round' => {
actor => $self->as_combatant,
target => $defender->as_combatant,
actions => [
Veure::Combat::Action::Attack->new(
actor => $defender->as_combatant,
target => $self->as_combatant,
%$args,
),
# Now we give the defender a chance to flee.
# If their stat levels have dropped too low to stay
# in combat the target becomes the "actor" of
# the Flee step
Veure::Combat::Action::Flee->new(
actor => $defender->as_combatant,
target => $self->as_combatant,
when => sub ($self) {
my $character = $self->actor;
my $too_low = $character->stats_too_low_in_combat;
return $too_low;
},
%$args,
);
# now we *actually* attempt to flee
Veure::Combat::Action::Flee->new(
actor => $self->as_combatant,
target => $defender->as_combatant,
when => sub {1},
%$args,
);
],
}
),
# now save all the changes that happened in
# the Combat Round to the database
Stats(
$self => remove_points => {
slug => "combat_round.${who}_damage",
force_to_zero => 1,
}
),
Inventory(
$self->inventory => damage_items =>
"combat_round.${who}_item_damage"
),
Location( $self => flee_to => "combat_round.${who}_fled_to" ),
# and do the same for the defender
Stats(
$defender => remove_points => {
slug => "combat_round.${who}_damage",
force_to_zero => 1,
}
),
Inventory(
$defender->inventory => damage_items =>
"combat_round.${who}_item_damage"
),
Location( $defender => flee_to => "combat_round.${who}_fled_to" ),
ALWAYS( Combat( $combat => 'end_round' ) ),
)
);
}
That’s better, but still not great. We have a lot of boiler plate in there because a lot of checks happen the same for every step in combat. We have a lot of duplication of code too still, because actions we want to perform for the attacker we also want to perform for the defender. This is where higher order programming comes in.
In his book Higher Order Perl, Mark Jason Dominus states:
Higher-Order Perl is about functional programming techniques in Perl. It’s about how to write functions that can modify and manufacture other functions.
Our Weapon: Functional Programming
We already have some functional programming in our economic exchanges with things like Combat()
which are just functions that export data structures that the economic exchange expects to work on. So how can we apply this idea here? Let’s start with something basic, like cleaning up those outcome steps:
sub CombatOutcome ( $who, $character ) {
return (
Stats(
$character => remove_points => {
slug => "combat_round.${who}_damage",
force_to_zero => 1,
}
),
Inventory(
$character->inventory => damage_items =>
"combat_round.${who}_item_damage"
),
Location( $character => flee_to => "combat_round.${who}_fled_to" ),
);
}
This changes the 20 lines dealing with the outcome of combat to:
Combat( $combat => 'round' => { ... } ),
CombatOutcome( target => $self ),
CombatOutcome( actor => $defender );
ALWAYS( Combat( $combat => 'end_round' ) ),
Next, what about attempting to flee. We do it twice above (once in response to a combat situation, and once as the main thing we’re trying to achieve in the combat round). If we pull both of those steps out we can have a function like this:
sub Flee ( $actor, $target, %args) {
Veure::Combat::Action::Flee->new(
actor => $actor->as_combatant,
target => $target->as_combatant,
when => sub { 1 },
%args,
);
}
We will then end up with something like this:
Combat(
$combat => 'round' => {
actor => $self->as_combatant,
target => $defender->as_combatant,
actions => [
Veure::Combat::Action::Attack->new(
actor => $defender->as_combatant,
target => $self->as_combatant,
%$args,
),
# now we give the defender a chance to flee.
# If their stat levels have dropped too low to stay in combat
# the target becomes the "actor" of the Flee step
Flee(
$defender => $self,
when => sub ($self) {
my $character = $self->actor;
my $too_low = $character->stats_too_low_in_combat;
return $too_low;
}
);
# now we *actually* attempt to flee
Flee($self => $defender)
],
}
),
If we keep doing this, creating functions for Attack
(which would include the check for fleeing) and even a CombatRound
that wraps up the entire exchange creation, we would end up with a function that contains just the core part of our logic:
sub flee_combat ( $self ) {
my $combat = $self->current_combat // return;
my $defender = $self->combat_opponent;
my $exchange = CombatRound(
$combat => (
Attack( $defender => $self ),
Flee( $self => $defender ),
Combat( $combat => 'end_combat' ),
)
);
return $exchange->stash->get('combat_round.fled');
}
This is something that is much easier to read and follow, and assuming that each step does exactly what we think, easier to make sure that the underlying logic is correct. Which is all we can really hope for.