Tutorial for Love¶
While Love is designed to be a compilation target, it has its own syntax which will be used in this tutorial. We will implement in this tutorial a simple version of the Mastermind.
This a 2-player game, the Masterind and the Challenger. The Mastermind chooses secretly a sequence of five colors that the Challenger must guess. For every guess the Challenger makes, the Mastermind tells the challenger the number of colors well placed and the number of colors that are in the combination, but that are not well placed.
Types¶
First of all, we will define the different types that will structure the contract data.
type color = Red | Blue | Green | Yellow | Orange
Color is a sum type - it defines a set of constructors representing the different colors. Here, five colors are defined.
type combination = color * color * color
A combination is a tuple of colors in a given order. In our example,
there are three colors in a combination (for example, (Blue, Red, Green)
is a combination).
type player = address
A player is simply defined by its address, which represents the unique identifier of an account or a contract.
type result = {
well_placed : nat;
not_well_placed : nat;
wrong : nat
}
Once a combination is submitted, the mastermind will need to tell
the player how many colors are well placed, not well placed or wrong.
Its answer will have the form of a record, defining different named
fields. A value r of type result will hold three counters well_placed,
not_well_placed and wrong that can respectively be accessed with
r.well_placed, r.not_well_placed and r.wrong.
These counters have the type nat
, or positive integers.
type mastermind = {
player : player;
answer : combination
}
The type mastermind also is a record, holding the mastermind address and the correct answer, its secret combination.
type 'a seat = Empty | Filled of 'a
Before any player starts the game, the contract will wait for a mastermind and a challenger to take a seat. The type ‘a seat is a polymorphic type, i.e. it depends on a type argument ‘a. For example, int seat represents the type with two constructors Empty and Filled of int.
Fun fact : the type ‘a option - a Love built-in type - defines the similar constructors None and Some of ‘a.
type storage = {
mastermind : mastermind seat;
challenger : player seat;
guess_list : (combination * result) list;
}
The storage type represents the data structure saved by the contract. It contains the seat of the mastermind, the seat of the player and the list of combininations with their corresponding result, in which will be saved after each guess of the challenger.
Contract values¶
Now the data structure of the contract is defined, we will start to design the game rules. First of all, the initial storage of contract will represents two empty seats - one for the mastermind and one for the player - and an empty guess list.
val empty_table : storage =
let mastermind_seat = Empty [:mastermind] in
let challenger_seat = Empty [:player] in
let guess_list = [] [:combination * result] in {
mastermind = mastermind_seat;
challenger = challenger_seat;
guess_list;
}
The empty_table value of type storage is defined by
the mastermind seat and the challenger seat, which are both empty.
Empty
constructors are followed by type applications that allows
to determine what is its type.
While they are both Empty
, they cannot be compared as their type is
strictly different : the first is a mastermind seat, the second is a player seat.
At last the empty guess list, represented by the empty list constructor []
and its type application [:combination * result]
With these elements, the storage record can be built.
NB : Note that the field guess_list
is not explicitely assigned. This is because
there is already a variable with the exact same name, which will be choosen by default.
It is impossible to partially define records.
We have now defined a value of type storage
. This value, as well as every Love value,
is persistent: it cannot be modified.
It is however possible to build new values from these.
val add_challenger (challenger : player) (storage : storage) : storage =
{storage with challenger = Filled challenger [:player]}
The value add_challenger
defines a function with two arguments: challenger
of
type player
and storage
of type storage
. It returns a value of
type storage
.
The value returned is a copy of the value in argument where we modified the
challenger field to fill it.
val add_mastermind
(master : player)
(answer : combination)
(storage : storage) : storage =
let mastermind = {player = master; answer} in
{ storage with mastermind = Filled mastermind [:mastermind] }
More generally, every function argument always have the form (VAR_NAME : TYPE)
and, as for empty_table
, the list of arguments is followed by the type of
the body.
val is_valid (c : combination) : bool =
c.0 <> [:color] c.1 &&
c.1 <> [:color] c.2 &&
c.2 <> [:color] c.0
A mastermind combination is valid if and only if all its colors are different.
The function is_valid
requires an argument c
of type combination
, and will
return a boolean value.
The function is_valid
checks that every color is distinct. The distinct
operator <>
is a built-in construction expecting the type of the
elements being compared. As we compare colors, we apply to <>
the
type color
as we did for constructors when we defined empty_table
.
val in_combination (color : color) (combination : combination) : bool =
color =[:color] combination.0 ||
color =[:color] combination.1 ||
color =[:color] combination.2
Similarly, the function in_combination
tests that a given color belong to
a combination.
val%private empty_result : result = {
well_placed = 0p;
not_well_placed = 0p;
wrong = 0p
}
We now define utilities for the result type. First, an empty_resuly
that will be used for storing the result of a guess. Each field
is initially equal to 0p
, the zero of naturals.
Such a result has no meaning outside the contract; we can
hide it from other contracts with the token %private
.
val well_placed (s : result) : result =
{ s with well_placed = s.well_placed ++ 1p }
val not_well_placed (s : result) : result =
{ s with not_well_placed = s.not_well_placed ++ 1p }
val wrong (s : result) : result =
{ s with wrong = s.wrong ++ 1p }
val is_winning (result : result) : bool =
result.well_placed = [:nat] 3p
The functions well_placed
, not_well_placed
and wrong
increments
the different counters of the result in argument. The addition on naturals
have its own addition operator ++
. You can find more informations on primitives
on the reference manual.
Reminder: Love values are immutable. Each new record is a copy of the argument in which we updated a field.
val which_are_correct (guess : combination) (answer : combination) : result =
let test_color
(guess : color)
(answer_color : color)
(result : result) =
if guess = [:color] answer_color
then well_placed result
else if in_combination guess answer
then not_well_placed result
else result
in
let result = empty_result in
let result = test_color guess.0 answer.0 result in
let result = test_color guess.1 answer.1 result in
let result = test_color guess.2 answer.2 result in
result
The function which_are_correct
will compare the player’s guess and the
mastermind’s answer. For this purpose, it defined a function test_color
that will test if a color is well placed or not.
NB: The return type of test_color
is not required, as it can easily be guessed.
val mastermind_bet : dun = 15.0DUN
val challenger_bet : dun = 15.0DUN
val guess_cost : dun = 5.0DUN
When the mastermind and the challenger want to play, they will have to bet a certain amount of duns. Each guess will cost the player 5 duns, directly sent to the mastermind. When the challenger guesses the right combination, it gets back its initial bet and the masterminds.
Contract initializer, entry points and views¶
For players and masterminds to interact with the contract, we will define an initializer, multiple entrypoints and views.
Initializer¶
When a Love contract is originated, an initial value must be provided. If the contract has an initializer function, its result will be the storage of the contract.
Love initializers have strict definitions: they must start with val%init
,
be named storage
and take exactly one typed argument. It must always build
a element of type storage, hence the return type of the initializer must
not be provided.
val%init storage (_ : unit) = empty_table
In our case, the initializer argument is _
.
This pattern meaning that we don’t want to give the argument a name as
it is not used in the initializer’s body. However,
it must be given a value of type unit
to be called. The only value of
type unit
is ()
.
NB: If the contract has no initializer, the initial value given during the origination will be the initial storage of the contract.
Entry points¶
It is possible to interact with contracts through entry points. These values are the only elements of the contract that can alter the contract storage.
An entry point starts with val%entry
followed by its name
and three arguments: the storage, the amount sent in the transaction and
the transaction parameter. Only this last parameter is explicitely given a type
as the first argument always have the type storage
and the second
always have the type dun
.
Entry points return an operation list and a value of type storage. The returned storage value will replace the current contract storage, and then perform the operations of the list.
val%entry challenge storage bet (_ : unit) =
if bet <> [:dun] challenger_bet
then failwith [:string * dun] ("Bad challenger bet", bet) [:unit]
else ();
let () =
match storage.challenger with
Empty -> ()
| Filled a -> failwith [:string * player] ("There is already a challenger", a) [:unit]
in
let new_storage =
let sender = Current.sender () in
add_challenger sender storage
in
([][:operation], new_storage)
The challenge
entry point will register the person who
initiated the transaction as the mastermind of the current game.
It first performs several checks: is the bet correct?
Is there already a challenger?
if bet <> [:dun] challenger_bet
then failwith [:string * dun] ("Bad challenger bet", bet) [:unit]
else ();
The failwith instruction is a built-in Love primitive that raises an exception. It expects in this specific order the content type of the exception, the content of the exception and the return type. You can find more details on failwith on the reference manual.
This expression has no effect in the else
case, hence it has type unit
.
Such expressions can be separated by ;
so that they are calculated, and then
ignored (but when an exception is raised and not catched, the execution will stop).
let () =
match storage.challenger with
Empty -> ()
| Filled a -> failwith [:string * player] ("There is already a challenger", a) [:unit]
in
If there already is a challenger, the contract must fail. This expression matches the storage challenger to check if it is empty or filled. In the first case it does nothing, otherwise it raises an exception.
let new_storage =
let sender = Current.sender () in
add_challenger sender storage
in
([][:operation], new_storage)
If everything goes well, then the sender of the transaction is set as the challenger of the game in a copy of the storage, which is returned as the new storage of the contract.
val%entry be_mastermind storage bet (answer : combination) =
if bet <> [:dun] mastermind_bet
then failwith [:string * dun] ("Bad mastermind bet", bet) [:unit];
if not (is_valid answer)
then
failwith
[:string * combination]
("Answer is invalid: every color must be different", answer)
[:unit];
let () =
match storage.mastermind with
Empty -> ()
| Filled m ->
failwith [:string * player] ("There is already a mastermind", m.player) [:unit]
in
let new_storage =
let sender = Current.sender () in
add_mastermind sender answer storage
in
([][:operation], new_storage)
The be_mastermind
entrypoint is similar to the challenger
for registering the mastermind, but it requires a combination
that is set as the game answer.
NB: if the else branch of a condition is not explicited, it is
interpreted as else ()
val%entry guess storage bet (guess : combination) =
if bet <> [:dun] guess_cost
then failwith [:string * dun] ("To guess, you must may 5dun", bet) [:unit];
if not (is_valid guess)
then
failwith
[:string * combination]
("Guess is invalid: every color must be different", guess)
[:unit];
let player = Current.sender () in
let mastermind =
match storage.mastermind with
Empty -> failwith [:string] ("There is no mastermind") [:mastermind]
| Filled m -> m
in
let () =
match storage.challenger with
Empty -> failwith [:string] ("You must challenge the mastermind before playing") [:unit]
| Filled a -> (
if a <>[:player] player
then failwith [:string * player] ("You are not the challenger", a) [:unit]
)
in
let result = which_are_correct mastermind.answer guess in
let operation, new_storage =
if is_winning result
then ( (* Send everything on the contract to the player *)
let op =
let contract_funds = Current.balance () in
Account.transfer player contract_funds in
let new_storage = empty_table in
op, new_storage
) else ( (* Send the player's bet to the mastermind *)
let op = Account.transfer mastermind.player bet in
let new_storage =
let new_guess_list = (guess, result) :: storage.guess_list in
{storage with guess_list = new_guess_list}
in
op, new_storage
)
in [operation][:operation], new_storage
Finally, the guess
entrypoint compares the combination
parameter to the mastermind answer. The challenger must pay
5dun to guess the correct answer.
If the `combination is the correct one, then all the contract funds
are sent to the challenger and the storage is reinitialized.
Otherwise, the contract pays 5DUN to the mastermind.
Views¶
Other contracts on the chain can read the public content of a contract, but its storage is private. The only way to give a read-only access to a contract storage is by defining views.
Views are functions are defined by two arguments: the storage and a parameter. As for entry points, only the parameter is explicitely typed as the first argument always have the type storage. However, the return type of a view must be specified
val%view guesses storage (_ : unit) : (combination * result) list = storage.guess_list
This view gives an access to other contracts to the list of guesses of the current challenger. It may be useful if the challenger itself is a contract implementing an mastermind AI for example.
Many other things¶
This minimalist mastermind version was a short introduction to the strength of the Love language.
In the next tutorials, we will see how to hide the storage from the challenger by hashing the answer, how to create contracts that interact as a simple challenger AI, and much more !
If you are curious about the strength of Love, you can learn about modules, signatures, contract creation and polymorphism on the reference manual.