Implémentation Glicko2 en PHP 5.3

Glicko2 est le nom d’une méthode de calcul de la valeur de joueurs qui s’affrontent lors de compétition. J’ai eu besoin d’implémenter ce calcul pour un site que j’ai mis en place, j’ai donc naturellement choisi PHP.

Préambule

Glicko2 est le nom d’une méthode de calcul de la valeur de joueurs qui s’affrontent lors de compétition. Pour eux qui connaissent les échecs, l’esprit est le même que le calcul ELO.

Les informations originales correspondant à ce système sont disponibles sur le site de l’auteur Mark E. Glickman.

Le calcul

Chaque joueur démarre avec un niveau de départ (Rating) et une fiabilité de ce niveau (Rating Deviation). Son niveau et sa fiabilité vont évoluer en fonction des joueurs qu’il va rencontrer.

Le code

Le code et les tests associés sont disponibles sur GitHub : http://github.com/mikaelkael/Ranking. Celui-ci est exclusivement destiné à PHP5.3 minimum puisqu’il utilise les espaces de noms.

Le principe

Vous déclarez une partie :

use Mkk\Game\Ranking\Glicko2 as RankingGlicko2;
$game = new RankingGlicko2();

Vous déclarez 2 joueurs :

use Mkk\Game\Player\Glicko2 as PlayerGlicko2;
$player1 = new PlayerGlicko2(array('id' => 1,
                                   'rating' => 1500,
                                   'ratingDeviation' => 350,
                                   'volatility' => 0.06));
$player2 = new PlayerGlicko2(array('id' => 2,
                                   'rating' => 1500,
                                   'ratingDeviation' => 350,
                                   'volatility' => 0.06));

Vous les associez à la partie dans l’ordre vainqueur->perdant donc si le joueur 1 à battu le joueur 2 :

$game->addPlayers(array($player1, $player2));

Ceci est le fonctionnement par défaut. L’interface Mkk\Game\PlayerInterface propose setPosition() et getPosition(). La position par défaut d’un joueur est « null ». Lors de l’utilisation d’addPlayer() ou addPlayers(), la position du joueur est testée. Si elle vaut nulle, la position suivante lui est attribuée.

Si toutefois vous souhaitez forcer une position dans le cas par exemple d’un match nul, vous pouvez faire :

$game->addPlayers(array($player1->setPosition(1),
                        $player2->setPosition(1)));

Ou le faire lors à la construction :

use Mkk\Game\Player\Glicko2 as PlayerGlicko2;
$player1 = new PlayerGlicko2(array('id' => 1,
                                   'rating' => 1500,
                                   'ratingDeviation' => 350,
                                   'volatility' => 0.06,
                                   'position' => 1));

Vous demandez la mise à jour de leur niveau :

$game->updateRanking();

Vous obtenez le nouveau niveau, la nouvelle fiabilité et la nouvelle volatilité en récupérant les joueurs :

$player = $game->getPlayerById(1);
echo $player->getNewRating();
echo $player->getNewRatingDeviation();
echo $player->getNewVolatility();

Comme le fait remarquer eMeRiKa, ces 3 chiffres (incluant la volatilité) sont nécessaires pour le calcul suivant. La volatilité est forte quand les résultats sont erratiques, si les résultats sont constants alors elle est faible.

Quelques exemples

Dans les exemples suivants, j’ai volontairement effacé la volatilité qui alourdit le nombre des informations et qui n’est pas nécessaire à l’établissement de la valeur d’un joueur mais elle est nécessaire à l’établissement de sa variation.

2 joueurs de même niveau avec une fiabilité faible (c’est-à-dire une valeur forte) s’affrontent :

Info Avant Après
Joueur 1 Niveau 1500 1578.8
Fiabilité 200 180.1
Joueur 2 Niveau 1500 1421.2
Fiabilité 200 180.1

2 joueurs de même niveau avec une fiabilité forte (c’est-à-dire une valeur forte) s’affrontent :

Info Avant Après
Joueur 1 Niveau 1500 1507.3
Fiabilité 50 50.5
Joueur 2 Niveau 1500 1492.7
Fiabilité 50 50.5

1 joueur fort bat 1 joueur faible tous les 2 avec fiabilité forte :

Info Avant Après
Joueur 1 Niveau 1800 1800.5
Fiabilité 50 51
Joueur 2 Niveau 1200 1199.5
Fiabilité 50 51

1 joueur faible bat 1 joueur fort tous les 2 avec fiabilité faible :

Info Avant Après
Joueur 1 Niveau 1800 1623.2
Fiabilité 200 195.8
Joueur 2 Niveau 1200 1376.8
Fiabilité 200 195.8

Les autres systèmes

Zend_Barcode – partie 1 : utilisation

Zend Framework 1.10 intégrera Zend_Barcode initialement appelé dans les proposals Zend_Image_Barcode.

La différence primordiale entre la première proposition et Zend_Barcode, c’est le découplage entre les objets et les générateurs de rendu. Ceci a permis d’écrire un générateur Image (Zend_Barcode_Renderer_Image) et un générateur PDF (Zend_Barcode_Renderer_Pdf).

Ce composant s’appuie sur une nouvelle version de Zend_Validate_Barcode spécialement réécrit pour la version 1.10 par Thomas (http://www.thomasweidner.com/flatpress/2009/12/17/validating-barcodes/).

La documentation officielle intégrera bien sûr les exemples permettant de faire fonctionner ce composant mais voyons quelques exemples d’utilisations.

1. Le fichier de génération d’image « barcode.php »

Vous le placez à la racine de votre serveur Web :

<?php
set_include_path('../library');
require_once 'Zend/Barcode.php';
Zend_Barcode::render($_GET('barcodeType'), 'image', $_GET, $_GET);

2. Un appel dans votre vue et … une première erreur

<img src="barcode.php?barcodeType=code39" />

Ce qui vous donnera bien sûr une image d’erreur :

Car aucun texte n’a été fourni.

3. Votre premier code-barres

Vous l’obtenez en ajoutant, simplement le paramètre « text » :

<img src="barcode.php?barcodeType=code39&text=ZEND-FRAMEWORK" />

Vous obtenez donc en 5 lignes de code votre premier code-barres:

4. Une police plus attrayante

Si vous utilisez le générateur Image basé sur l’extension GD et que vous ne spécifiez pas de police TTF, les polices internes de GD seront utilisées. Vous pouvez spécifier votre police soit dans les options de génération soit de manière plus globale :

<?php
set_include_path('../library');
require_once 'Zend/Barcode.php';
Zend_Barcode::setBarcodeFont('../data/fonts/tahoma.ttf');
Zend_Barcode::render($_GET('barcodeType'), 'image', $_GET, $_GET);

Vous obtiendrez ainsi :

Breizh Défi

Le Cercle Celtique de Guérande, champion de Bretagne en titre de danse bretonne, organise le plus grand Bal Paludier jamais effectué le 7 août 2009. A l’occasion de cet événement, ils ont lancé un site dédié : http://www.bro-gwenrann.org/breizhdefi/index.php (avec une petite couche de Zend Framework : Zend_Form et Zend_Pdf entre autres).

Mais ils ont surtout lancé une série de teasers, afin de trouver les 2000 danseurs attendus :

N’hésitez pas à les regarder, c’est très bien fait !

Centraliser la gestion de Zend_Session et des Zend_Session_Namespace

La classe suivante me permet de centraliser la gestion des sessions grâce à Zend_Session.

class Mp_Session
{

    /**
     * Name of the actual application
     *
     * @var string
     */
    protected static $_application = null;

    /**
     * Default namespace
     *
     * @var string
     */
    protected static $_default_namespace = 'default';

    /**
     * @var array
     */
    protected static $_session_app = array();

    /**
     * Retourne le namespace de session de l'appli courante
     *
     * @param  string $namespace
     * @return Zend_Session_Namespace
     */
    private static function _getSessionApp ($namespace = null)
    {
        if ($namespace === null) {
            $namespace = self::$_default_namespace;
        }
        if (! isset(self::$_session_app{($namespace)}) || null === self::$_session_app{($namespace)}) {
            self::$_session_app{($namespace)} = new Zend_Session_Namespace(
                    self::getApplication() . '_' . ucfirst(
                            strtolower(
                                    $namespace)));
        }
        return self::$_session_app{($namespace)};
    }

    /**
     * Lit une variable de session
     * (de l'application en cours par défaut)
     *
     * @param string $cle
     * @param mixed $alternatif valeur par défaut
     * @return mixed
     */
    public static function get ($cle, $alternatif = null, $namespace = null)
    {
        if ($cle == '') {
            return null;
        }
        $session_app = self::_getSessionApp($namespace);
        if (isset($session_app->{$cle})) {
            return $session_app->{$cle};
        } else {
            return $alternatif;
        }
    }

    /**
     * Détruit une (ou plusieurs) variable de session
     *
     */
    public static function del ($namespace)
    {
        $session_app = self::_getSessionApp($namespace);
        $cles = func_get_args();
        $cles = array_shift($cles); // namespace
        foreach ($cles as $cle) {
            unset($session_app->{$cle});
        }
    }

    /**
     * Ecrit une variable de session
     *
     * @param string $cle
     * @param mixed $valeur
     * @return mixed
     */
    public static function set ($cle, $valeur, $namespace = null)
    {
        if ($cle == '') {
            return null;
        }
        $session_app = self::_getSessionApp($namespace);
        $session_app->{$cle} = $valeur;
        return $valeur;
    }

    /**
     * Ecrit une(des) variable(s) de session
     *
     * @param mixed $valeur
     * @return mixed
     */
    public static function setMulti ($valeurs, $namespace = null)
    {
        $session_app = self::_getSessionApp($namespace);
        $valeurs = (array) $valeurs;
        while (list ($cle, $valeur) = each($valeurs)) {
            $session_app->{$cle} = $valeur;
        }
        reset($valeurs);
        return $valeurs;
    }

    /**
     * @param  string $name
     * @return void
     */
    public static function setApplication ($name)
    {
        self::$_application = ucfirst(strtolower($name));
    }

    /**
     * @return string
     */
    public static function getApplication ()
    {
        return self::$_application;
    }

    /**
     * @param  string $name
     * @return void
     */
    public static function setDefaultNamespace ($name)
    {
        self::$_default_namespace = ucfirst(strtolower($name));
    }

    /**
     * @return string
     */
    public static function getDefaultNamespace ()
    {
        return self::$_default_namespace;
    }
}

Voyons maintenant son utilisation :

  • dans le bootstrap, j’initialise l’application :
Mp_Session::setApplication('MonAppli');
  • dans un plugin en preDispatch j’initialise le namespace, dans mon cas il s’agit du module de l’application, s’il n’est pas initialisé sa valeur est « default » ce qui correspond à un namespace global :
Mp_Session::setNamespace($this->getRequest()
                              ->getModuleName());
  • dans l’application, je peux écrire des données :
 // dans le namespace en cours
Mp_Session::set('maVar', 'toto');
// dans le namespace du module Tata
Mp_Session::set('maVar', 'toto', 'Tata');
// avec plusieurs variables
Mp_Session::setMulti(array('maVar1' => 'toto',
                           'maVar2' => 'titi'));
// ou
Mp_Session::setMulti(array('maVar1' => 'toto',
                           'maVar2' => 'titi')
                     'Tata');
  • ou les lire :
$var = Mp_Session::get('toto');
// ou
$var = Mp_Session::get('toto', 'Tata');

Piloter le chargement d’une application avec Zend_Test

Contexte

On m’a demandé de convertir une application existante écrite en C avec une BDD Access en une application PHP+Oracle. La structure de la base de données a été entièrement modifié pour accepter de nouvelles évolutions. Il y avait donc une possibilité toute simple d’injecter les anciennes données issues d’Access dans la nouvelle base Oracle par l’intermédiaire d’un script SQL. J’ai cependant opté pour un pilotage de la nouvelle application avec Zend_Test chaque test correspondant à une donnée issue d’Access à entrer dans Oracle.

Note

Ce qui suit ne correspond pas aux tests de mon application (qui sont d’ailleurs eux aussi réalisés avec Zend_Test).

Initialisation

L’application est une supervision de production d’éléments. Chaque élément suit une gamme de fabrication. Les étapes de fabrication sont sauvegardées (qui à fait quoi et quand). Cette application est de type CRUD. Elle est composée de formulaires construits avec Zend_Form. Le script va donc consisté en l’appel des pages d’ajout (ou plus rarement de modification) avec les bons paramètres. Chaque ajout ou modification est assigné à un utilisateur. L’authentification est donc nécessaire pour chaque action puisque l’utilisateur peut changer à chaque étape.

La structure est similaire à la structure préconisée sur le site du Zend Framework.

Le bootstrap de l’application

Il doit permettre l’initialisation de l’application indépendamment de la distribution de la requête. 2 fichiers sont dans mon cas utilisés :

// index.php dans /appli/html (seul dossier accessible par Apache)
require_once ('../library/Mp/Site.php');
echo Mp_Site::run('MONAPPLI', 'PROD');
// Site.php dans /appli/library/Mp
class Mp_Site
{
    ... // mes variables (toutes statiques)

    public static function run ($app_name, $mode = 'PROD')
    {
        self::prepare($app_name, $mode);
        self::execute();
    }

    public static function prepare ($app_name, $mode = 'TEST')
    {
        self::setupEnvironment($mode);
        self::setupRegistry();
        self::setupLogger();
        self::setupConfiguration();
        self::setupDatabase();
        self::setupSession();
        self::setupFrontController();
        self::setupLayout();
        self::setupView();
        self::setupCache();
        self::setupApplication();
        self::$_already_loaded = true;
    }

    public static function execute ()
    {
        $request = new Zend_Controller_Request_Http();
        $response = new Zend_Controller_Response_Http();
        $response->append('body', '');
        try {
            self::$front->dispatch($request, $response);
        } catch (Exception $e) {
            exit($e->getMessage());
        }
    }

    ... // autres méthodes (elles aussi statiques)
}

Dans le cas de l’appel de l’application en mode Web, on passe par index.php qui prépare l’appli et l’exécute. Dans notre cas, nous allons seulement faire appel à prepare().

Script global (à l’image d’une suite PHPUnit)

Chaque table à remplir est dans un fichier séparé.

if (! defined('PHPUnit_MAIN_METHOD')) {
    define('PHPUnit_MAIN_METHOD', 'AllChargementMonappli::main');
}
class AllChargementMonappli
{

    public static function main ()
    {
        PHPUnit_TextUI_TestRunner::run(self::suite());
    }

    public static function suite ()
    {
        $suite = new PHPUnit_Framework_TestSuite('ChargementMonappli');
        $suite->addTestSuite('ChargementMonappli_TypeFournisMatprem');
        $suite->addTestSuite('ChargementMonappli_Table2');
        $suite->addTestSuite('ChargementMonappli_Table3');
        // ...
        $suite->addTestSuite('ChargementMonappli_TableN');
        return $suite;
    }
}
if (PHPUnit_MAIN_METHOD == 'AllChargementMonappli::main') {
    AllChargementMonappli::main();
}

Classe commune pour toutes les tables

  • La fonction setUp() est appelée à chaque démarrage de test. Elle permet de réinitialiser l’environnement ZF (requête, réponse, dispatcheur…). Il faut lui fournir les infos permettant l’amorçage de votre application (dans mon cas il faut définir $this->bootstrap comme un callback valide).
  • La fonction appBootstrap() est nécessaire car mon bootstrap est une fonction statique.
  • La fonction connect() permet de connecter un utilisateur en mode ‘TEST’ le password n’est pas vérifié.
  • La fonction _loadPage() charge une page suivant les paramètres fournis, comme dans notre cas il s’agit de pilotages de formulaires (ajout ou modif) : la méthode est toujours de type POST.
  • La fonction _verifError() vérifie les erreurs retournées par Zend_Form (normalement aucune !)
  • Les fonctions _searchAjax() et _readAjax() permettent d’aller lire une information au format json (je n’utilise pas dans ce cas le ContextSwitch, mon application ne fonctionne pas sans Javascript car il s’agit d’un Intranet donc je connais l’ensemble du parc). Toutes les requêtes pures Ajax sont donc tout simplement stockées dans un contrôleur AjaxController.
class ChargementMonappli_Commun
      extends Zend_Test_PHPUnit_ControllerTestCase
{

    public function setUp ()
    {
        $this->bootstrap = array($this , 'appBootstrap');
        parent::setUp();
    }

    public function appBootstrap ()
    {
        Mp_Site::prepare('MONAPPLI', 'TEST');
    }

    public function connect ($user, $password = null)
    {
        if ($password == null) {
            $password = $user;
        }
        $request = $this->getRequest();
        $request->setMethod('POST')->setPost(array('user' => $user,
                                                   'password' => $password,
                                                   'page_demandee' => '/toto'));
        $this->dispatch('/default/login/authentifie');
        $this->assertRedirectTo('/toto');
        $this->resetResponse();
        $request->setMethod('GET')->clearPost();
    }

    protected function _loadPage ($page, $param, $ajax = false)
    {
        if ($ajax) {
            $param = $this->_searchAjax($param);
        }
        $this->getRequest()->setMethod('POST');
        $this->getRequest()->setPost($param);
        $this->dispatch($page);
        $this->_verifError();
        $this->resetResponse();
        $this->getRequest()->clearPost();
    }

    protected function _verifError ()
    {
        $retour = null;
        $text = $this->getResponse()->getBody();
        if (preg_match("`<ul class=\"errors\">(.*)</ul>`", $text, $retour)) {
            $this->assertTrue(0, $retour[1]);
        }
        $retour = null;
        if (preg_match("`<div id=\"texte\">L'élément n'a pas été ajouté(.*)<br/>`", $text, $retour)) {
            $this->assertTrue(0, $retour[1]);
        }
    }
    protected function _searchAjax ($table)
    {
        foreach ($table as &$v) {
            if (is_string($v) && substr($v, 0, 26) == '/paimbinfo/outillage/ajax/') {
                $v = $this->_readAjax($v);
            }
        }
        return $table;
    }
    protected function _readAjax ($address)
    {
        $this->dispatch($address);
        $response = Zend_Json::decode($this->getResponse()->getBody());
        $this->resetResponse();
        $this->getRequest()->setMethod('GET')->clearPost();
        return $response[1];
    }
}

 Chargement de la table 1

Il s’agit de la table des fournisseurs de matière première. Chaque testTypeFournisMatpremX() correspond à une entrée dans la base et renverra un ‘.’, un ‘F’ ou un ‘E’ comme les tests classiques de PHUnit m’avertissant ainsi des problèmes rencontrés à l’injection des données. Le fichier ci-dessous est entièrement créé via un script VBA.

class ChargementMonappli_TypeFournisMatprem extends ChargementMonappli_Commun
{
    public function testTypeFournisMatprem1 ()
    {
        $this->connect('toto', 'password');
        $this->_loadPage('/monappli/base/ajout/table/type_fournis_matprem',
                         array('s_type_fournis_matprem' => 'FOURNISSEUR 1', 'n_type_outillage' => 0, 's_adresse_fm' => 'USA'));
    }
    public function testTypeFournisMatprem2 ()
    {
        $this->connect('toto', 'password');
        $this->_loadPage('/monappli/base/ajout/table/type_fournis_matprem',
                         array('s_type_fournis_matprem' => 'FOURNISSEUR 2', 'n_type_outillage' => 0, 'n_telephone_fm' => 33240000000, 'n_fax_fm' => 33240000001, 's_adresse_fm' => '44300 NANTES'));
    }
    public function testTypeFournisMatprem3 ()
    {
        $this->connect('toto', 'password');
        $this->_loadPage('/monappli/base/ajout/table/type_fournis_matprem',
                         array('s_type_fournis_matprem' => 'FOURNISSEUR 3', 'n_type_outillage' => 0, 'n_telephone_fm' => 33240000002, 'n_fax_fm' => 33240000003, 's_adresse_fm' => '44600 SAINT-NAZAIRE'));
    }
    public function testTypeFournisMatprem4 ()
    {
        $this->connect('toto', 'password');
        $this->_loadPage('/monappli/base/ajout/table/type_fournis_matprem',
                         array('s_type_fournis_matprem' => 'FOURNISSEUR 4', 'n_type_outillage' => 0));
    }
    public function testTypeFournisMatprem5 ()
    {
        $this->connect('toto', 'password');
        $this->_loadPage('/monappli/base/ajout/table/type_fournis_matprem',
                         array('s_type_fournis_matprem' => 'FOURNISSEUR 5', 'n_type_outillage' => 0));
    }
}

Pour terminer

Il suffit de lancer en ligne de commande : phpunit --verbose AllChargementMonappli > export.txt pour récupérer l’ensemble du résultat dans un fichier ‘export.txt’. Zend_Test permet donc bien plus que le simple test unitaire.

Zend_View_Helper_Abstract, enfin !

Avec la version 1.6 du Zend Framework apparaît une classe qui manquait singulièrement.

Elle ne fait certes pas grand chose mais c’est tellement utile plutôt que de le rappeler à chaque création d’aides de vue :

abstract class Zend_View_Helper_Abstract
         implements Zend_View_Helper_Interface
{
    /**
     * View object
     *
     * @var Zend_View_Interface
     */
    public $view = null;

    /**
     * Set the View object
     *
     * @param  Zend_View_Interface $view
     * @return Zend_View_Helper_Abstract
     */
    public function setView(Zend_View_Interface $view)
    {
        $this->view = $view;
        return $this;
    }

    /**
     * Strategy pattern: currently unutilized
     *
     * @return void
     */
    public function direct()
    {}
}

Vos aides de vue peuvent maitenant étendre Zend_View_Helper_Abstract (mais ce n’est pas obligatoire du moment qu’elles implémentent Zend_View_Helper_Interface). C’est cependant fortement recommandé.

Documentation du Zend Framework

Etendre Zend_Auth

Pourquoi vouloir étendre Zend_Auth ?

Dans mon cas, il s’agissait initialement de plusieurs applications situées sur le même serveur auxquelles on pouvait se connecter au cours de la même session avec des logins différents. Ces applications partageaient les mêmes librairies, le même bootstrap.

Et ensuite d’avoir un accès simple aux données d’authentification.

Vous allez vous dire pourquoi étendre Zend_Auth pour si peu ou encore pourquoi écrire un article sur tout ceci. Principalement parce que Zend_Auth est un singleton et qu’étendre un singleton nécessite de bien comprendre son fonctionnement.

Étendre un singleton

Extrait de Wikipédia :

Le singleton est un patron de conception (design pattern) dont l’objet est de restreindre l’instanciation d’une classe à un seul objet

Quand on veut étendre un singleton en PHP : il faut donc surtout penser à surcharger la méthode qui permet de récupérer l’instance (souvent getInstance()). Sinon vous récupèrerez une instance de la classe parente.

Code de l’extension

require_once 'Zend/Auth.php';

class Extension_Auth extends Zend_Auth
{

    /**
     * Name of the actual application
     *
     * @var string
     */
    protected static $_application = null;

    protected static $_infos = null;

    public static function getInstance()
    {
        if (null === self::$_instance) {
            self::$_instance = new self();
        }
        return self::$_instance;
    }

    public static function readInfo($info)
    {
        if (null === $info) {
            return null;
        }
        if (null === self ::$_infos) {
            self ::$_infos = self::getInstance()
                                 ->getStorage()
                                 ->read() ;
        }
        if (isset(self::$_infos->$info)) {
            return self::$_infos->$info;
        } else {
            return null;
        }
    }

    public function getStorage()
    {
        if (null === $this->_storage) {
            require_once 'Zend/Auth/Storage/Session.php';
            $this->setStorage(new Zend_Auth_Storage_Session(
                    'Auth_' . $this->getApplication()
                ));
        }
        return $this->_storage;
    }

    public static function setApplication($name)
    {
        self::$_application = $name;
    }

    public static function getApplication()
    {
        return self::$_application;
    }

    public function clearIdentity()
    {
        parent::clearIdentity();
        self::$_infos = null;
    }
}

Utilisation

Dans mon bootstrap, je paramètre le nom de l’application :

Extension_Auth::setApplication('toto');

Dans mes scripts de vues, je peux faire :

Actuellement connecté :  
<?=Extension_Auth::readInfo('s_first_name')?>  
<?=Extension_Auth::readInfo('s_last_name')?>