Skip to content

Implementation and write-up of my main take-aways of Eric Lippert's article series Wizards and Warriors.

Notifications You must be signed in to change notification settings

devbridie/wizardsandwarriors

Repository files navigation

wizardsandwarriors

Documentation

Eric Lippert wrote an article series called Wizards and Warriors introducing a problem in Object Orientated Programming that seems simple at first glance but quickly reveals itself to be a complicated software engineering. I found the article very thought-provoking. It inspired me to write this related work and code that implements the rule system as proposed by Eric, as well as a simple demonstration that uses this system in a simple text adventure with two scenes. You can find the completed code on GitHub.

Introduction

If you've already read Eric Lippert's Wizards and Warriors (15 min.), you can probably skip to Implementation. Though I will attempt to summarize my main takeaways from the article below, I would still recommend that you read the original.

It starts with a simple set of objects and is-a relationships (using a fantasy RPG genre game as an illustration).

  • A wizard is a kind of player.
  • A warrior is a kind of player.
  • A staff is a kind of weapon.
  • A sword is a kind of weapon.
  • A player has a weapon.

Intuitively, you might think of the following code:

sealed class Weapon
class Staff : Weapon()
class Sword : Weapon()

sealed class Player
class Wizard : Player()
class Warrior : Player()

The relations listed above have been captured. Job well done.

However, the following constraints are added to the system:

  • A Warrior can only use a Sword.
  • A Wizard can only use a Staff.

Eric then shows possible attempts at modeling such a system, of which I will summarize some.

'Solution' with Subtyping

sealed class Weapon
class Staff : Weapon()
class Sword : Weapon()

sealed class Player(open var weapon: Weapon)
class Wizard(override var weapon: Staff) : Player(weapon)
class Warrior(override var weapon: Sword) : Player(weapon)

Though this might seem sound, the Kotlin compiler will throw the following exception: Kotlin: Type of 'weapon' doesn't match the type of the overridden var-property 'public open var weapon: Weapon defined in Player'.

If the compiler did not throw this exception, then a Wizard casted to a Player can be assigned a Sword, while the class definition for Wizard disallows this.

'Solution' with Generics

sealed class Weapon
class Staff : Weapon()
class Sword : Weapon()

sealed class Player<T: Weapon>(open var weapon: T)
class Wizard : Player<Staff>(weapon)
class Warrior : Player<Sword>(weapon)

Great! The compiler will protect us from assigning a Sword to a Wizard. But this does not scale: what if we were to introduce class-specific armor? And what if our specifications add that everybody can also wield Daggers?

Placement of Concerns

Eric then shows us a related problem: Suppose the problems above were solved in an ideal manner. Our game is extended with the ability to attack monsters given the following classes:

sealed class Player
class Warrior : Player()

sealed class Monster
class Werewolf : Monster()

To quote: "Where exactly does the code go that determines whether the Warrior successfully hits the Werewolf?" "Somewhere there is a method, let’s call it Attack; what does that method look like?"

Or, more generally: where should such logic belong? Eric justly argues against the use of a Visitor Pattern in his article: the notation is verbose and error-prone. Also, it does not scale as the complexity of the system grows: one would have to continue to add parameters.

Rules

In Part 5, Eric proposes a paradigm shift: the business logic of this game can be modelled by a set of Rules. This allows our examples to be expressed:

  • Warriors cannot wield Staffs.
  • Wizards cannot wield Swords.
  • Everybody can wield Daggers.

What if a new weapon type is added? Then add new rules that represent the underlying business logic.

Furthermore, the nature of exceptions are used to support the paradigm shift: they are used to handle errors and other exceptional events. If a Wizard tries to wield a Sword, should that result in an Exception?

Eric proposes that the actual business logic is the following:

  • The core objects of the system are users, commands, state, and rules.
  • A user provides a sequence of commands.
    • This models a request to perform an action in the system, possibly changing state.
  • These commands are evaluated in the context of a series of rules, possibly carrying parameters.
    • Some are applicable to the current command (is logic concerning Daggers relevant to a Warrior wielding a Staff?)
  • These evaluations produce one or more effects.
    • Examples of such an effect are 'do nothing', mutate state, display a message.
    • Certain effects can be composited.

As I was curious to see how such a system could be implemented and used, I set out to create a framework utilizing these ideas.

Implementation

I started with a simple goal in mind: implement the example game of Wizards and Warriors wielding Staffs, Swords, and Daggers. Using the Inform7 rules as in part 5 as a starting point of what I would want the API to become.

Core Representation

The representation of the functionality that should be implemented would contain the following nouns: Rule, Parameter, Effect, State, and System.

  • A Rule is a small chunk of business logic. A rule is given some Parameters and results in some Effect.
  • A System is the glue of the framework. It knows which Rules exist, it handles commands, and it handles the resulting Effects. It also maintains a given State, dispatching mutations to this state where needed.

These resulted in the rule framework.

Representing State

I separated rules that represented definitions into classes. An example of this is the following:

A wizard is a kind of person.
A warrior is a kind of person.

would become:

data class Person(var type: PersonType)

sealed class PersonType(val name: String)
object Wizard : PersonType("Wizard")
object Warrior : PersonType("Warrior")

In context of the game, these object definitions can be found in sample objects.

Creating a WieldRule

A concrete implementation of such a Rule is a WieldRule. It will express what the effects of a command for a 'Person to wield a Weapon' should be.

The notion of wielding a weapon concerns two instances: The wielder and the weapon being wielded. Then, abstractly, the applicability of the rule When wielding a sword: if the wielder is not a warrior, it is too heavy. could be expressed as weapon is Sword && wielder.type !is Warrior. The effect of this rule would be WeaponTooHeavyWieldEffect. This leads to the composition of the rule expressed in the following DSL Type-Safe Builders.:

wieldRule(
    applicable = { weapon is Sword && person.type !is Warrior },
    effect = { WeaponTooHeavyWieldEffect }
)

And the 'normal' case of wielding a weapon:

wieldRule(
    applicable = always(),
    effect = { UpdateWeaponWieldEffect(this) }
)

where this refers to the incoming WieldParameters. The resolution of this Rule leads to an Effect that represents a Wizard wielding a Staff.

Rule Resolution

However, resolution of these rules was not as simple as thought. Some Rules should be final in their chain; if one rule dictates that a Wizard may not wield a Sword, the default rule (wield the weapon) should not be resolved. This led to the introduction of the BreakChainEffect, a tag that determines that no further rules should be resolved.

Rule resolution is done with using a modified functional reduce, mapping applicable Rules to Effects. When a BreakChainEffect is encountered, the reduce is stopped.

Stengths of the Implementation

The flexibility of the framework allows for arbitrarily complex rules and effects.

Effects can be composed by creating Rules that take an Effect as Parameter. An example of this is displaying the result of a wield command: WieldEffect -> DisplayWieldEffectEffect. In the context of a certain scene, a wield might result in a diffrerent display text. This allows the business logic (A warrior cannot wield a staff) to remain context-independent.

Rule composition can be created by creating new rules that combine other rules. An example of this is a conditional group that makes rules more specific:

conditional({ wieldParameters.person.type is Wizard }) {
	+shopSceneRule(
		applicable = { wieldEffect is UpdateWeaponWieldEffect && wieldParameters.weapon is Staff },
		effect = { DisplayWieldEffectEffect("${wieldParameters.person} looks satisfied with his new ${wieldParameters.weapon}.") }
	)

	+shopSceneRule(
		applicable = { wieldEffect is UpdateWeaponWieldEffect && wieldParameters.weapon is Dagger },
		effect = { DisplayWieldEffectEffect("${wieldParameters.person} takes the ${wieldParameters.weapon} begrudgingly.") }
	)
}

Demonstration

Creative thinking led to the narrative shown in the demonstration, demonstrating the basics of the rule system.

With this, the initial goal was completed.

Extensions

Suppose I were a user of the framework: what features would I want? In context of game development, I immediately thought of expansion packs: What if a new class called Paladin were added to the game, Wizards and Warriors + Paladins? How could this new type of person and other related logic be added to the system without disturbing existing logic?

For this, being able to nest rulebooks became a vital feature. In this way, related logic (What are Paladins allowed to wield? What special effects do they have when attacking?) can be kept together and added as one unit to an existing rulebook.

About

Implementation and write-up of my main take-aways of Eric Lippert's article series Wizards and Warriors.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages