singe/docs/fr/lessons/28-online.adoc

589 lines
24 KiB
Text
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

[#_lesson_28_online]
== Leçon 28 : En ligne
image::learn/28-online.png[La leçon terminée, 480]
À la leçon douze, vous avez sauvegardé un meilleur score. Il est toujours là le
lendemain matin, ce qui est l'essentiel de ce à quoi sert un meilleur score,
mais c'est un meilleur score sur une seule machine. Cette leçon met un score
sur un tableau que d'autres peuvent voir.
C'est aussi la leçon sur l'échec. Tout ce que vous avez écrit jusqu'ici échoue
pour des raisons que vous pouvez trouver : un fichier manque, un nombre est
faux, une fonction est mal orthographiée. Le réseau échoue pour des raisons
que personne dans la pièce ne peut voir. Un câble a été débranché dans un
bâtiment où vous n'avez jamais mis les pieds, et votre jeu doit continuer à
tourner. L'essentiel de ce qui suit parle de cela.
[#_what_the_service_is]
=== Ce qu'est le service
Singe peut parler à un *service maître*, un serveur qui détient trois choses :
* un *compte*, qui appartient à la machine et non à un jeu en particulier ;
* un *catalogue* de jeux à télécharger et à mettre à jour ;
* des *tableaux des meilleurs scores*, un ou plusieurs par jeu.
Le service par défaut est `master.singeengine.com`, et un exploitant qui fait
tourner le sien fait pointer la machine ailleurs. La section du manuel sur le
service en ligne décrit les deux côtés.
Maintenant, la partie que les gens sautent. Le service n'est *pas* du
multijoueur : deux joueurs n'ont aucun moyen d'être dans la même partie. Ce
n'est pas un nuage de sauvegardes ; la sauvegarde de la leçon douze reste sur
la machine. Ce n'est pas une protection contre la copie, et rien en lui
n'empêche qui que ce soit de jouer à votre jeu. Il n'est pas obligatoire : un
jeu qui ne se connecte jamais tourne exactement comme il l'a toujours fait, et
c'est le seul comportement par défaut raisonnable pour quelque chose qui a
besoin d'une connexion qui marche.
Le tableau est donc un bonus. Gardez le meilleur score local de la leçon
douze, continuez à l'afficher, et laissez le tableau en ligne être la chose qui
est là de temps en temps.
[#_signing_in_happens_once_and_not_in_your_game]
=== On se connecte une fois, et pas dans votre jeu
Le compte appartient à la machine. On le crée et on s'y connecte depuis le menu
fourni, derrière la touche affectée à `INPUT_SERVICE`, dans l'outil appelé
*Online Account*. Le jeton qu'il obtient est écrit dans `master.dat` à la
racine des données, un niveau au-dessus du dossier de données propre à chaque
jeu, si bien que tous les jeux de la machine envoient leurs scores sous la même
connexion.
Votre jeu ne demande jamais d'adresse e-mail et ne demande jamais de mot de
passe. Il n'existe aucune fonction pour le faire, et c'est voulu : un joueur
qui tape un mot de passe dans un jeu qu'il a téléchargé n'a aucun moyen de
savoir où il part.
Ce sous quoi un joueur apparaît sur un tableau est un *nom de joueur* : de
trois à vingt caractères, lettres, chiffres, traits d'union et tirets bas, qui
commencent et finissent par une lettre ou un chiffre. Pas d'espaces, pas de
points, et pas de `@`, si bien qu'un nom de joueur ne peut même pas ressembler
à une adresse e-mail. Il est séparé du compte, c'est la seule chose d'un joueur
que les autres joueurs voient, et il est choisi une fois pour toutes -- un
exploitant peut en changer un, un joueur ne le peut pas, parce qu'un tableau
dont les noms bougent sans cesse n'est vraiment l'histoire de personne. Tant
qu'aucun nom de joueur n'a été choisi, les scores sont refusés, puisqu'un score
qui ne porte le nom de personne n'est sur un tableau dans aucun sens utile.
L'outil Online Account montre le nom sous lequel cette machine envoie ses
scores, et votre jeu le lit avec `scorePlayerName()`, qui répond `nil` quand il
n'y en a pas. Bon à savoir avant que cela ne vous intrigue : un score envoyé
sans nom de joueur derrière lui est refusé par le *service*, pas par
`scoreSubmit`, si bien que votre jeu n'en est pas informé. Si
`scorePlayerName()` vaut `nil`, dites-le à l'écran tant qu'il y a encore
quelqu'un pour le lire.
[#_your_game_needs_an_id]
=== Votre jeu a besoin d'un identifiant
Un tableau doit être rangé sous quelque chose. Ce quelque chose, c'est
`GAME_ID`, dans l'entrée de votre jeu dans `games.dat`, le fichier que vous
avez créé à la leçon treize :
[source,lua]
----
GAME_ID = "6f1e7b62-0a4e-4d9c-9b2f-1c7a5e3d8a10"
----
C'est un UUID, exactement de cette forme : huit chiffres hexadécimaux, trois
groupes de quatre, puis douze, séparés par des traits d'union. Générez-en un
comme vous voulez et collez-le ; peu importe lequel vous obtenez, l'important
est qu'il ne change plus jamais. L'entrée de la Référence pour `singeGetGameId`
explique pourquoi rien d'autre n'est accepté.
`singeGetGameId()` le renvoie, ou `nil` quand l'entrée n'en a pas. Un jeu sans
identifiant peut être joué mais pas classé, et demander d'abord vous évite de
proposer un tableau des scores qui ne pourra jamais marcher :
[source,lua]
----
if singeGetGameId() then
-- showLeaderboardButton est votre propre fonction, pas une de celles de Singe.
showLeaderboardButton()
end
----
[#_the_six_calls]
=== Les six appels
Tout ce qu'un jeu fait avec le service tient en six fonctions, et elles
viennent de `Singe/Framework.singe`, alors le `dofile` en haut de votre script
n'est pas facultatif ici :
[source,lua]
----
dofile("Singe/Framework.singe")
----
`scoreBegin()` dit qu'une partie commence. `scoreSubmit(value)` met un score en
file d'attente. `scoreBoard(onDone)` récupère le tableau. `scorePlayerName()`
est le nom de joueur. `scoreWaiting()` est le nombre de scores qui ne sont pas
encore partis. Et `scoreUpdate()` doit s'exécuter une fois par image, depuis
`onOverlayUpdate`, sinon aucun des autres ne se termine jamais.
Ce dernier est toute l'astuce de cette leçon, alors il est dit deux fois. Rien
ici ne parle au réseau quand vous l'appelez. `scoreSubmit` écrit dans une file
d'attente et rend la main aussitôt. `scoreBoard` retient ce que vous voulez et
rend la main aussitôt. Le vrai travail se fait par petites tranches dans
`scoreUpdate`, entre vos images, et c'est pour cela que votre jeu ne saccade
jamais quand la ligne est lente. Oubliez `scoreUpdate` et rien ne casse, aucune
erreur ne s'affiche, et il ne se passe jamais rien.
[source,lua]
----
function onOverlayUpdate()
scoreUpdate()
overlayClear()
return OVERLAY_UPDATED
end
----
[#_posting_a_score]
=== Envoyer un score
À la fin d'une partie :
[source,lua]
----
if scoreSubmit(score) then
fetchBoard()
else
saying = "Kept here only: " .. whyNotPosted()
end
----
`scoreSubmit` répond `false` quand il n'y a pas de compte ou que le jeu n'a pas
de `GAME_ID`, et `true` quand le score est entré dans la file d'attente.
Remarquez ce que le `else` ne fait *pas* : il ne jette pas le score, il ne
s'excuse pas longuement, et il n'empêche pas le joueur de commencer une autre
partie. Il dit une courte ligne, parce que le joueur n'a rien fait de mal et ne
peut rien y corriger d'ici.
C'est la file d'attente qui rend cela sûr. Un score en file d'attente est écrit
sur le disque, il survit donc à l'extinction de la machine, et il est remis au
service la prochaine fois qu'il y a une connexion -- une heure plus tard, ou
mardi prochain. Voilà pourquoi une borne dans une salle au wifi médiocre finit
quand même sur le tableau.
Soyez honnête avec vous-même sur la limite de cette promesse : la file
d'attente garde les scores d'une machine qui *a* un compte. Si personne ne
s'est jamais connecté, `scoreSubmit` répond `false` et le score n'est pas gardé
pour plus tard, parce qu'il n'y a personne pour qui le garder. `scoreWaiting()`
vous dit combien il en reste dans la file d'attente, et afficher ce nombre
quelque part est une gentillesse : c'est la différence entre « le service est
en panne » et « trois de vos scores attendent le retour d'Internet ».
`scoreBegin()` mérite un paragraphe à lui seul. Appelez-le quand une partie
commence, et le service note le moment d'après sa propre horloge. Le score qui
suit porte la durée de la partie, mesurée sur le serveur, et c'est le seul
nombre d'un envoi qui n'est pas passé par la machine du joueur. Rien n'est
rejeté sur cette base : la durée est montrée à celui qui fait tourner le
service, à côté du score, et c'est une personne qui décide. Une machine qui
était hors ligne quand la partie a commencé n'enregistre aucune durée, ce qui
est honnête plutôt que cassé.
Et puisque nous sommes dans l'honnêteté : un score envoyé est une
*déclaration*. Le jeu tourne sur l'ordinateur du joueur, alors rien de ce que
peut faire le client n'en fait davantage, et une clé secrète livrée dans votre
jeu n'aurait que l'apparence de la sécurité. Le service enregistre qui a
déclaré quoi, limite la vitesse à laquelle les scores peuvent arriver, et
signale les scores aberrants pour qu'une personne les examine. Concevez votre
tableau en le sachant.
[#_reading_the_board_back]
=== Relire le tableau
`scoreBoard` est la première fonction de ce livre qui prend une fonction comme
argument.
[source,lua]
----
scoreBoard(function(ok, result)
...
end)
----
Vous avez écrit des fonctions à la leçon cinq et vous leur avez donné des noms.
Celle-ci n'a pas de nom : elle est écrite là où elle sert, passée directement à
`scoreBoard`, et appelée plus tard -- peut-être plusieurs secondes plus tard --
quand la réponse arrive. C'est encore un *callback*, la même idée que
`onOverlayUpdate`, sauf que cette fois, c'est vous qui choisissez qui
l'appelle.
`ok` vaut `true` ou `false`. Quand il vaut `false`, `result` est une courte
chaîne qui dit pourquoi. Quand il vaut `true`, `result` est une table en deux
parties :
* `result.top`, une liste de lignes, chacune avec un `name` et une `value` ;
* `result.standing`, la propre `value` de ce joueur, son `rank`, et le nombre
de `players` sur le tableau -- qui peut manquer, si ce joueur n'a rien
dessus.
Entre l'appel et le callback, votre jeu continue de tourner et de dessiner. Ce
délai est réel et le joueur le voit, alors mettez-y quelque chose. Le script
ci-dessous règle une ligne de texte sur `"Asking the service..."` avant
l'appel, et le callback remplace cette ligne soit par le tableau, soit par la
raison pour laquelle il n'y en a pas. Un écran qui ne dit rien pendant quatre
secondes a l'air cassé ; un écran qui dit ce qu'il fait est juste lent.
[#_the_catalogue]
=== Le catalogue
La troisième chose que fait le service, c'est lister des jeux. L'outil *Get
Games* du menu montre tout ce que le service propose, marque ce qui est déjà
installé sur cette machine, et le télécharge, le met à jour ou le supprime. Un
téléchargement est vérifié d'après l'empreinte publiée par le catalogue avant
de remplacer quoi que ce soit, si bien qu'un transfert qui arrive abîmé échoue
au lieu d'installer un jeu qui ne tournera pas. Un jeu que le service a retiré
continue de marcher et apparaît comme installé mais plus proposé : la seule
chose qui s'arrête, ce sont les mises à jour, parce que quelqu'un qui a un jeu
l'a.
Votre jeu n'appelle rien de tout cela, et ne doit pas le faire. Le catalogue
vous concerne en tant qu'auteur d'un jeu plutôt qu'en tant que rédacteur d'un
script : c'est là que va votre fichier `.game` quand vous le publiez, ce qui
est le sujet de la leçon trente, et c'est une raison de plus pour que votre
`games.dat` ait un `GAME_ID` qui ne change jamais. Si un jour vous voulez la
liste depuis un script, `Singe/Master.singe` est le module qui se trouve sous
tout cela, et il vaut la peine d'être lu une fois.
[#_what_the_service_knows_about_a_player]
=== Ce que le service sait d'un joueur
Si vous publiez un jeu qui envoie des scores, vous confiez les données de
quelqu'un d'autre à un serveur, et c'est votre responsabilité même si vous
n'avez pas écrit le serveur. Alors dites-le clairement, dans l'écran « À
propos » de votre jeu ou dans son fichier lisez-moi :
* Le service détient une *adresse e-mail* et un mot de passe pour le compte.
L'adresse sert à récupérer un mot de passe perdu, et à rien d'autre. Elle
n'est jamais montrée aux autres joueurs et ne peut pas être changée ensuite.
* Le *nom de joueur* est ce qui apparaît sur les tableaux. Il est
volontairement séparé du compte pour qu'une adresse ne soit jamais ce que
voient les autres joueurs, et il est vérifié d'après une liste de noms que
personne ne devrait prendre et de mots qui n'ont pas leur place sur un écran
dans une salle publique.
* Un *score* porte sa valeur, le tableau où il se trouve, l'heure qu'a
enregistrée le service, et le nom de joueur. C'est tout.
* Le catalogue sait *quels jeux sont installés* sur la machine, et en quelle
version, parce que c'est ainsi qu'il vous propose une mise à jour.
Ce que rien de tout cela ne fait, c'est suivre un joueur d'un jeu à l'autre,
vendre quoi que ce soit, ou aller chercher dans la machine quoi que ce soit que
vous n'avez pas envoyé. Votre jeu ne doit rien ajouter à la liste. Si vous
joignez des informations supplémentaires à un score -- `scoreSubmit` prend pour
cela un troisième argument facultatif -- qu'elles concernent le jeu.
[#_the_script]
=== Le script
Un test de réflexes : attendez le mot, appuyez sur le bouton, marquez ce qui
reste de mille. C'est un petit jeu, et tout le reste, c'est la partie en
ligne.
Créez un dossier appelé `reaction` et mettez ceci dans un fichier
`reaction.singe`, dedans. Donnez au dossier un `games.dat` comme celui de la
leçon treize, qui désigne `reaction/reaction.singe`, avec dans son entrée la
ligne `GAME_ID` vue plus haut.
[source,lua]
----
dofile("Singe/Framework.singe")
local WAIT_MIN = 1000
local WAIT_MAX = 3000
local PERFECT = 1000
local state = "attract"
local goAt = 0
local score = 0
local best = saveGet("best", 0)
local saying = "Button 1 to play."
local top = nil
local standing = nil
local function whyNotPosted()
if not singeGetGameId() then
return "this game has no GAME_ID."
end
return "this machine is not signed in."
end
local function fetchBoard()
top = nil
standing = nil
saying = "Asking the service..."
scoreBoard(function(ok, result)
if not ok then
saying = "No board: " .. tostring(result)
return
end
top = result.top
standing = result.standing
if #top == 0 then
saying = "The board is empty. You could be first."
else
saying = ""
end
end)
end
local function finish(reaction)
score = PERFECT - reaction
if score < 0 then
score = 0
end
if score > best then
best = score
saveSet("best", best)
end
state = "result"
if scoreSubmit(score) then
fetchBoard()
else
saying = "Kept here only: " .. whyNotPosted()
end
end
local function play()
state = "waiting"
goAt = singeGetTicks() + math.random(WAIT_MIN, WAIT_MAX)
score = 0
top = nil
standing = nil
saying = ""
scoreBegin()
end
function onInputPressed(what)
if what ~= SWITCH_BUTTON1 then
return
end
if state == "waiting" then
state = "result"
score = 0
saying = "Too soon. Nothing posted."
elseif state == "go" then
finish(singeGetTicks() - goAt)
else
play()
end
end
function onOverlayUpdate()
scoreUpdate()
if state == "waiting" and singeGetTicks() >= goAt then
state = "go"
end
overlayClear()
overlayPrint(2, 0, "REACTION TEST")
if state == "attract" then
overlayPrint(2, 2, "Button 1 to play.")
elseif state == "waiting" then
overlayPrint(2, 2, "Wait for it...")
elseif state == "go" then
overlayPrint(2, 2, "NOW!")
else
overlayPrint(2, 2, "You scored " .. score .. ". Button 1 plays again.")
end
overlayPrint(2, 4, "Best on this machine: " .. best)
overlayPrint(2, 5, "Posting as: " .. (scorePlayerName() or "nobody yet"))
overlayPrint(2, 6, "Waiting to send: " .. scoreWaiting())
overlayPrint(2, 8, saying)
if top then
for place, row in ipairs(top) do
if place > 5 then
break
end
overlayPrint(2, 9 + place, place .. ". " .. row.name .. " " .. row.value)
end
end
if standing then
overlayPrint(2, 16, "You are " .. standing.rank .. " of " .. standing.players .. ".")
end
return OVERLAY_UPDATED
end
----
Lancez-le avec `Singe -R reaction`. Sur une machine qui ne s'est jamais
connectée, il se joue, il garde votre meilleur score, et il dit `Kept here only: this machine is not signed in.`
C'est l'exécution importante, et c'est celle qu'auront la plupart de vos
joueurs.
[#_what_just_happened_23]
=== Ce qui vient de se passer
[source,lua]
----
local top = nil
local standing = nil
----
Deux variables qui valent `nil` la plupart du temps. `nil` veut dire « rien
ici », et c'est ce que contient une variable avant qu'on y mette quoi que ce
soit. Celles-ci contiennent le tableau quand il y en a un, et `nil` quand il
n'y en a pas : pendant que la requête est en route, après un échec, et avant
la première partie. Le code de dessin demande `if top then` et ne dessine rien
quand il n'y a rien, ce qui est la forme de presque tout le code réseau que
vous écrirez.
[source,lua]
----
saying = "Asking the service..."
scoreBoard(function(ok, result)
----
L'ordre compte. La ligne de texte est réglée *avant* l'appel, parce que
l'appel rend la main aussitôt et que le callback peut ne s'exécuter que des
secondes plus tard. Si vous ne réglez le texte que dans le callback, l'écran
montre le message de la partie précédente jusqu'à ce que la réponse arrive, ce
qui est exactement le mauvais message au mauvais moment.
[source,lua]
----
if not ok then
saying = "No board: " .. tostring(result)
return
end
----
L'échec vient en premier, et il est court. `tostring` transforme ce qui est
revenu en texte, pour que le joindre avec `..` ne puisse pas échouer ;
`result` est une chaîne quand `ok` vaut false, mais une ligne de code de
dessin qui fait confiance au type de quelque chose reçu d'un serveur est une
ligne qui attend de planter. Le joueur voit une phrase, et le jeu continue.
[source,lua]
----
if scoreSubmit(score) then
fetchBoard()
else
saying = "Kept here only: " .. whyNotPosted()
end
----
Que `scoreSubmit` réponde `false` n'est pas une erreur. C'est un fait
concernant cette machine, et les deux faits qu'il peut signifier valent d'être
distingués, ce que fait `whyNotPosted` : un `GAME_ID` manquant est *votre*
erreur, et une machine non connectée n'est l'erreur de personne. Le meilleur
score local a déjà été sauvegardé, deux lignes plus haut, avant toute
tentative. Sauvegardez d'abord, envoyez ensuite, toujours.
[source,lua]
----
overlayPrint(2, 6, "Waiting to send: " .. scoreWaiting())
----
La file d'attente, à l'écran. Sur une machine qui a une connexion, cela
affiche `0` pour toujours et personne ne le remarque. Sur une machine qui n'en
a pas, le nombre monte, et le joueur sait que le jeu garde ses scores au lieu
de les avaler.
[source,lua]
----
function onOverlayUpdate()
scoreUpdate()
----
Première ligne de l'image, avant tout le reste. Mettez-la à un endroit où aucun
`if` ne peut la sauter, parce qu'un `scoreUpdate` qui ne s'exécute que sur
l'écran de fin de partie, c'est une file d'attente qui ne se vide que sur
l'écran de fin de partie.
[#_try_it_28]
=== Essayez
. *Débranchez la prise.* Déconnectez la machine du réseau, jouez quelques
parties, et regardez le nombre `Waiting to send`. Rebranchez-la et
regardez-le descendre sans que vous fassiez quoi que ce soit.
. *Supprimez l'identifiant.* Retirez `GAME_ID` de votre `games.dat` et
relancez. Le message change. Remettez-le.
. *Montrez plus du tableau.* La boucle s'arrête à cinq lignes. L'overlay fait
par défaut dix-huit lignes de caractères de haut, alors calculez combien vous
pouvez vraiment en faire tenir, et déplacez la ligne `standing` s'il le faut.
. *Nommez le tableau.* `scoreSubmit(score, "reaction")` et
`scoreBoard(onDone, "reaction")` utilisent un tableau de ce nom au lieu de
celui qui porte le nom de votre script. Envoyez vers deux tableaux depuis le
même jeu -- par exemple un pour le score et un pour la réaction la plus
rapide -- et laissez le bouton 2 choisir lequel est affiché.
. *Rendez l'attente visible.* Entre l'appel à `scoreBoard` et l'exécution du
callback, dessinez un point qui bouge. Puis découvrez combien de temps cela
dure vraiment sur votre connexion, et si ça valait la peine de le dessiner.
[#_break_it_on_purpose_27]
=== Cassez-le exprès
Retirez la première ligne de `onOverlayUpdate`, pour que l'appel à
`scoreUpdate()` disparaisse, et jouez une partie.
Il ne se passe rien. Pas d'erreur, pas de message, pas de tableau, et le
nombre `Waiting to send` monte et reste en haut pour toujours. Le jeu se joue
parfaitement.
C'est la pire sorte de bug, et elle vaut la peine d'être rencontrée une fois
exprès : une étape manquante qui produit du silence plutôt qu'une plainte. Il
n'y a aucun numéro de ligne à regarder, parce que rien ne s'est mal passé --
vous avez demandé du travail et ne lui avez jamais laissé l'occasion de se
faire. Quand quelque chose dans Singe qui prend un callback ne vous rappelle
jamais, la première question est toujours de savoir si ce qui le fait avancer
s'exécute à chaque image.
L'autre, que vous rencontrerez aussi, est plus bruyant. Remettez
`scoreUpdate()`, supprimez la ligne `dofile` en haut, et lancez :
----
Error executing function 'onOverlayUpdate': reaction/reaction.singe:89: attempt to call a nil value (global 'scoreUpdate')
stack traceback:
reaction/reaction.singe:89: in function 'onOverlayUpdate'
----
La ligne 89 est le `scoreUpdate()` que vous avez remis, une ligne plus haut
maintenant que le `dofile` a disparu. Lisez la première ligne, comme
toujours : la fonction que le moteur appelait, le fichier, la ligne et la
plainte. Encore `nil` -- le nom `scoreUpdate` ne
contient rien, parce que le fichier qui le définit n'a jamais été chargé. Les
six appels vivent tous dans `Singe/Framework.singe`.
[#_what_you_learned_28]
=== Ce que vous avez appris
* Le service détient un compte, un catalogue et des tableaux des meilleurs
scores. Ce n'est pas du multijoueur, ni un nuage de sauvegardes, et il n'est
jamais obligatoire.
* Le compte appartient à la machine, et on s'y connecte depuis les outils de
service du menu. Un jeu ne demande jamais de mot de passe.
* Un tableau est rangé sous `GAME_ID` dans `games.dat`, un UUID qui ne doit
jamais changer. `singeGetGameId()` le lit.
* `scoreSubmit` met un score en file d'attente sur le disque et rend la main
aussitôt ; la file d'attente survit à l'extinction de la machine.
* `scoreUpdate()` doit s'exécuter à chaque image, sinon rien de ce que fait le
service ne se termine jamais.
* `scoreBoard` donne sa réponse à une fonction que vous écrivez, et qui
s'exécute plus tard. Dessinez quelque chose en attendant.
* Chaque appel réseau peut échouer, et échouer n'est pas une erreur. Dites une
courte ligne et continuez à jouer.
* Un score envoyé est une déclaration, et le service le traite comme tel.
* Gardez le meilleur score local. C'est celui qui marche toujours.
[#_next_time_28]
=== La prochaine fois
Un meilleur score, c'est un nombre, et la sauvegarde de la leçon douze est le
bon endroit pour lui. La leçon vingt-neuf est pour tout le reste de ce qu'un
jeu veut garder : chaque manche jouée par qui que ce soit, les niveaux que vous
livrez, et les questions que vous voulez poser aux deux. Elle reprend ce test
de réflexes et lui donne une base de données.