OCaml : DnD Kata
#OCamlLorsque je découvre ou veux expliquer un concept, j'aime assez m'appuyer sur un Kata, un exercice court pour mettre en avant une pratique de programmation. Je vous propose donc le DnD Kata, dont l'objectif est de modéliser une équipe de personnages pour une partie du jeu de rôles Donjons et Dragons. Bien sûr modéliser l'ensemble des règles est un exercice complexe, nous nous contenterons ici de représenter un personnage par :
- son nom
- sa race
- ses compétences, en incluant bien sûr ses bonus raciaux
Une équipe est une collection de personnages, pouvant être de race différente.
L'objectif pédagogique de ce kata est d'illustrer les foncteurs OCaml et de démontrer comment ils contribuent à appliquer les principes S.O.L.I.D en OCaml.
A propos de Donjons et Dragons
Donjons & Dragons a.k.a DnD est un jeu de rôles où les joueurs incarnent des héros dans un univers fantastique. L'univers principal de ce jeu est Faerûn, un continent de la planète Abeir-Toril. Nous utiliserons le système Donjons & Dragons 5ème Édition sous la licence Open-Gaming (OGL).
Nous sommes les Nains
Nous allons commencer par modéliser les Nains, l'une des races jouables de Faerûn, par leur nom.
Nous savons déjà qu'un bon moyen d'avoir un espace de noms dans OCaml est d'utiliser des modules, nous pouvons donc commencer par cette représentation :
module Dwarf = struct
type t = string
end
Dans cette implémentation, le type du module est inféré. On peut aussi le rendre explicite en ajoutant une signature de module et en modélisant les Elfes en même temps :
module Dwarf : sig
type t = string
end = struct
type t = string
end
module Elf : sig
type t = string
end = struct
type t = string
end
A cette étape on remarque que les 2 modules partagent la même signature. Étant donné que les modules Elf et Dwarf représentent des héros jouables, cela semble légitime et nous préciserons que tous les héros jouables partagent la même signature. Pour ce faire, nous pouvons utiliser un type de module :
module type PLAYABLE = sig
type t = string
end
module Dwarf: PLAYABLE = struct
type t = string
end
module Elf : PLAYABLE = struct
type t = string
end
Les autres modules n'ont pas besoin de connaître la forme du type PLAYABLE.t
, ils ont seulement besoin de savoir qu'il existe et le module doit exposer des fonctions pour pouvoir être utilisé.
On appelle cela une abstraction, car on rend le type t
abstrait :
module type PLAYABLE = sig
type t
val to_string : t -> string
val of_string : string -> t
end
Chaque module de type PLAYABLE doit implémenter ces fonctions. Faisons-le :
module Dwarf: PLAYABLE = struct
type t = {name : string}
let to_string dwarf = dwarf.name
let of_string name = {name}
end
module Elf : PLAYABLE = struct
type t = string
let to_string elf = elf
let of_string name = name
end
Puisque t
est abstrait, vous remarquerez que chaque module implémentant PLAYABLE
peut avoir un type concret différent pour t
. C'est tout-à-fait correct tant qu'ils respectent leur contrat de type de module.
Depuis un autre module, nous ne pouvons pas accéder à une valeur concrète de t
, mais nous pouvons créer un Nain ou obtenir une représentation sous forme de string
.
let gimly = Dwarf.of_string "Gimly"
let () = Dwarf.to_string gimly |> print_endline
Les Héros ont des caractéristiques
Dans DnD, un héros est également représenté par ses caractéristiques :
- La Force représente la puissance physique, l'aptitude athlétique naturelle
- La Dextérité représente l'agilité, les réflexes, l'équilibre
- La Constitution représente la santé, l'endurance, la force vitale
- L'Intelligence représente l'acuité mentale, le raisonnement, la mémoire
- La Sagesse représente la perception, l'intuition, la perspicacité
- Le Charisme représente la force de personnalité, l'éloquence, le leadership
Il y a plusieurs règles optionnelles pour les caractéristiques à la création, nous n'implémenterons que celles des Standard scores. Au début, chaque capacité a une valeur de 10 :
module Abilities = struct
type t = {
strength : int
; dexterity : int
; constitution : int
; intelligence : int
; wisdom : int
; charisma : int
}
let init () = {
strength = 10
; dexterity = 10
; constitution = 10
; intelligence = 10
; wisdom = 10
; charisma = 10
}
end
Nous pouvons ainsi faire évoluer notre module Dwarf
:
module Dwarf: PLAYABLE = struct
type t = {name : string ; abilities : Abilities.t}
let to_string dwarf = dwarf.name
let of_string name = {name ; abilities = Abilities.init()}
end
Les noms de nos fonctions ne sont plus très appropriés, nous allons donc mettre à jour le type de module PLAYABLE
puis les modules Elf
et Dwarf
:
module type PLAYABLE = sig
type t
val name : t -> string
val make : string -> t
end
module Dwarf: PLAYABLE = struct
type t = {name : string ; abilities : Abilities.t}
let name dwarf = dwarf.name
let make name = {name ; abilities = Abilities.init()}
end
module Elf: PLAYABLE = struct
type t = {name : string ; abilities : Abilities.t}
let name elf = elf.name
let make name = {name ; abilities = Abilities.init()}
end
Les races donnent des modificateurs
Les nains ont un bonus de +2 en constitution
En OCaml, les modules sont de premier ordre, cela signifie que vous pouvez utiliser un module comme des valeurs. On peut donc créer un nouveau type de module pour représenter un bonus et des fonctions pour représenter un bonus de 2 :
module type BONUS = sig
type t
val value : t
end
Ainsi qu'une valeur qui permet d'obtenir un module de ce type :
let bonus_2 : (module BONUS with type t = int) = (module struct
type t = int
let value = 2
end)
bonus_2
est un module en tant que valeur. Puisque t
est abstrait, nous devons ajouter un type témoin with type t = int
.
Pour extraire la valeur du bonus, nous avons également besoin d'un getter :
let get_bonus b = let module M = (val (b : (module BONUS with type t = int))) in M.value
Je reconnais que la syntaxe des modules de premier ordre n'est pas des plus agréables, nous verrons que leur usage est somme toute limité. Si vous cherchez des explications supplémentaires vous pouvez lire : https://dev.realworldocaml.org/first-class-modules.html
Nous pouvons dorénavant écrire :
module Dwarf: PLAYABLE = struct
type t = {name : string ; abilities : Abilities.t}
let name dwarf = dwarf.name
let make name = {name ; abilities = Abilities.init()}
let constitution dwarf = dwarf.abilities.constitution + get_bonus bonus_2
end
N'oublions pas les Elfes, Demi-Orcs, Halflings ou Tieflings
Les Nains ne sont pas la seule race de Faerûn. Chacune a un bonus de constitution différent. Les Demi-orcs ont +1 tandis que les Elfes, les Halflings et les Tieflings n'ont pas de bonus de constitution.
Lorsque les données d'une fonction varient, nous ajoutons un paramètre de fonction pour éviter la duplication de code. Nous pouvons faire de même au niveau du module. OCaml possède des foncteurs qui sont des modules fonctionnels : des fonctions de module(s) à module.
Nous pouvons donc créer un foncteur Race
:
module Race (B : BONUS with type t = int) : PLAYABLE = struct
type t = {name : string ; abilities : Abilities.t}
let name character = character.name
let make name = {name ; abilities = Abilities.init()}
let constitution_bonus = B.value (* here we get the value from module B *)
let constitution character = character.abilities.constitution + constitution_bonus
end
Se lit : le foncteur
Race
prend comme paramètre un moduleB
de typeBONUS
dont le typet
estint
et retourne un module de typePLAYABLE
.
Ensuite, nous pouvons facilement construire nos modules :
(* we add a function to manage all bonus *)
let bonus (x:int) : (module BONUS with type t = int) = (module struct
type t = int
let value = x
end)
(* we use our Race functor to create the five races *)
module Dwarf = Race (val bonus 2)
module Elf = Race (val bonus 0)
module Tiefling = Race (val bonus 0)
module Halfling = Race (val bonus 0)
module HalfOrc = Race (val bonus 1)
Vous comprenez maintenant pourquoi nous avons introduit les modules de premier ordre précédemment. Ils facilitent le passage de valeur entre le Type level et le Module level, ce qui est très pratique ici.
Toutes les compétences peuvent avoir des bonus
Les foncteurs ne sont pas limités à un paramètre, nous pouvons donc utiliser la même astuce pour toutes les caractéristiques :
module Race
(BS : BONUS with type t = int)
(BD : BONUS with type t = int)
(BC : BONUS with type t = int)
(BI : BONUS with type t = int)
(BW : BONUS with type t = int)
(BCh : BONUS with type t = int) : PLAYABLE = struct
type t = {name : string ; abilities : Abilities.t}
let name character = character.name
let make name = {name ; abilities = Abilities.init()}
let bonus = Abilities.{
strength = BS.value
; dexterity = BD.value
; constitution = BC.value
; intelligence = BI.value
; wisdom = BW.value
; charisma = BCh.value
}
let abilities character = Abilities.{
strength = character.abilities.strength + bonus.strength
; dexterity = character.abilities.dexterity + bonus.dexterity
; constitution = character.abilities.constitution + bonus.constitution
; intelligence = character.abilities.intelligence + bonus.intelligence
; wisdom = character.abilities.wisdom + bonus.wisdom
; charisma = character.abilities.charisma + bonus.charisma
}
end
module Dwarf = Race (val bonus 0) (val bonus 0) (val bonus 2)(val bonus 0) (val bonus 0) (val bonus 0)
Pour notre cas d'utilisation, ce n'est pas pratique, nous devons nous souvenir de l'ordre des bonus. Nous avons déjà un type qui représente toutes les valeurs de capacités Abilities.t
, utilisons-le :
(* just create a bonus function that take a Abilities.t and return a Bonus module *)
let bonus (x:Abilities.t) : (module BONUS with type t = Abilities.t) = (module struct
type t = Abilities.t
let value = x
end)
(* the functor `Race` take a module `B` of type `BONUS` whom type `t` is `Abilities.t`
** as parameter and then return a module of type `PLAYBLE` *)
module Race
(B : BONUS with type t = Abilities.t) : PLAYABLE = struct
type t = {name : string ; abilities : Abilities.t}
let name character = character.name
let make name = {name ; abilities = Abilities.init()}
let bonus = Abilities.{
strength = B.value.strength
; dexterity = B.value.dexterity
; constitution = B.value.constitution
; intelligence = B.value.intelligence
; wisdom = B.value.wisdom
; charisma = B.value.charisma
}
let abilities character = Abilities.{
strength = character.abilities.strength + bonus.strength
; dexterity = character.abilities.dexterity + bonus.dexterity
; constitution = character.abilities.constitution + bonus.constitution
; intelligence = character.abilities.intelligence + bonus.intelligence
; wisdom = character.abilities.wisdom + bonus.wisdom
; charisma = character.abilities.charisma + bonus.charisma
}
end
(* create our Dwarf module *)
module Dwarf = Race (val bonus Abilities.{
strength = 0
; dexterity = 0
; constitution = 2
; intelligence = 0
; wisdom = 0
; charisma = 0
})
Pour être plus concis et explicite, nous pouvons travailler à partir d'une valeur no_bonus
:
let no_bonus = Abilities.{
strength = 0
; dexterity = 0
; constitution = 0
; intelligence = 0
; wisdom = 0
; charisma = 0
}
module Dwarf = Race (val bonus Abilities.{
no_bonus with constitution = 2
})
module Elf = Race (val bonus Abilities.{
no_bonus with dexterity = 2
})
module Halfling = Race (val bonus Abilities.{
no_bonus with dexterity = 2
})
module Tiefling = Race (val bonus Abilities.{
no_bonus with charisma = 2 ; intelligence = 1
})
module HalfOrc = Race (val bonus Abilities.{
no_bonus with strength = 2
})
Synthèse
À la fin de cette section, vous devriez avoir :
module Abilities = struct
type t = {
strength : int
; dexterity : int
; constitution : int
; intelligence : int
; wisdom : int
; charisma : int
}
let init () = {
strength = 10
; dexterity = 10
; constitution = 10
; intelligence = 10
; wisdom = 10
; charisma = 10
}
end
module type BONUS = sig
type t
val value : t
end
let bonus (x:Abilities.t) : (module BONUS with type t = Abilities.t) =
(module struct
type t = Abilities.t
let value = x
end)
let no_bonus = Abilities.{
strength = 0
; dexterity = 0
; constitution = 0
; intelligence = 0
; wisdom = 0
; charisma = 0
}
module type PLAYABLE = sig
type t
val make : string -> t
val name : t -> string
val abilities : t -> Abilities.t
end
module Race
(B : BONUS with type t = Abilities.t) : PLAYABLE = struct
type t = {name : string ; abilities : Abilities.t}
let name character = character.name
let make name = {name ; abilities = Abilities.init()}
let bonus = Abilities.{
strength = B.value.strength
; dexterity = B.value.dexterity
; constitution = B.value.constitution
; intelligence = B.value.intelligence
; wisdom = B.value.wisdom
; charisma = B.value.charisma
}
let abilities character = Abilities.{
strength = character.abilities.strength + bonus.strength
; dexterity = character.abilities.dexterity + bonus.dexterity
; constitution = character.abilities.constitution + bonus.constitution
; intelligence = character.abilities.intelligence + bonus.intelligence
; wisdom = character.abilities.wisdom + bonus.wisdom
; charisma = character.abilities.charisma + bonus.charisma
}
end
module Dwarf = Race (val bonus Abilities.{
no_bonus with constitution = 2
})
module Elf = Race (val bonus Abilities.{
no_bonus with dexterity = 2
})
module Halfling = Race (val bonus Abilities.{
no_bonus with dexterity = 2
})
module Tiefling = Race (val bonus Abilities.{
no_bonus with charisma = 2 ; intelligence = 1
})
module HalfOrc = Race (val bonus Abilities.{
no_bonus with strength = 2
})
On peut facilement ajouter n'importe quelle race, par exemple les humains ont +1 à toutes les caractéristiques :
module Human = Race (val bonus Abilities.{
strength = 1
; dexterity = 1
; constitution = 1
; intelligence = 1
; wisdom = 1
; charisma = 1
})
United color of Faerûn
Chaque joueur peut jouer un personnage de race différente. Comment modéliser une équipe ?
The companions of the Hall
The companions est un livre de R.A. Salvatore un romancier qui a écrit de nombreuses nouvelles situées à Faerûn
Commençons par créer les personnages, nous avons déjà tout ce qu'il faut :
let catti = Human.make "Catti-brie"
let regis = Halfling.make "Regis"
let bruenor = Dwarf.make "Bruenor Battlehammer"
let wulfgar = Human.make "Wulfgar"
let drizzt = Elf.make "Drizzt Do'Urden"
Que se passe-t-il si on crée les compagnons :
❌ let companions = [catti; regis; bruenor; wulfgar; drizzt]
Error: This expression has type Halfing.t but an expression was expected of type Human.t
Souvenez-vous que le type de list
est type 'a t = 'a list
, le moteur d'inférence a 'a = Human.t
car c'est le type du premier élément de notre liste catti
, mais le type regis
est Halfing.t
.
Comment pourrions-nous aider le compilateur ? Les paramètres de type doivent être des types concrets.
(* won't compile PLAYABLE is a module type *)
❌ type team = PLAYABLE.t list
(* won't compile RACE is a functor
** aka a function from module to module *)
❌ type team = RACE.t list
En réalité, il n'y a rien de bien compliqué, le point principal est que les listes OCaml sont monomorphiques, il nous faut donc un type unique qui puisse représenter un personnage, quelque soit sa race :
type buddy =
| Dwarf of Dwarf.t
| Elf of Elf.t
| Halfing of Halfling.t
| Tiefling of Tiefling.t
| HalfOrc of HalfOrc.t
| Human of Human.t
let companions = [Human catti; Halfing regis; Dwarf bruenor; Human wulfgar; Elf drizzt]
Cependant il existe beaucoup d'autres races dans Faerûn, ainsi que des variantes. Drizzt par exemple est en réalité un elfe noir et non un elf. Il serait plus opportun d'utiliser des variants polymorphes afin de faciliter l'extension de notre librairie, car nous en sommes encore à l'embryon d'un véritable générateur de personnages :
let companions_final =
[`Human catti; `Halfing regis; `Dwarf bruenor; `Human wulfgar; `Elf drizzt]
dont le type sera
val companions_final :
[> `Dwarf of Dwarf.t
| `Elf of Elf.t
| `Halfing of Halfling.t
| `Human of Human.t ]
list =
[`Human <abstr>; `Halfing <abstr>; `Dwarf <abstr>; `Human <abstr>;
`Elf <abstr>]
Take away
- OCaml propose des abstractions utiles pour :
- namespace : module
- protocole : module type
- extension : functor
- default value ou implementation : functor ou first-class module
- functors sont des fonctions de module(s) à module
- modules de premier ordre sont des valeurs et permettent de communiquer entre le type level et le module level.
- S.O.L.I.D n'est pas uniquement une bonne pratique de POO :
- Single responsibility principle => module
- Open/closed principle => module
- Liskov substitution principle => module type
- Interface segregation principle => module type
- Dependency inversion principle => functor