Un projet d'apprentissage : refaire un tirage au sort en s'imposant une architecture propre
- 📌 Statut du projet
- 📸 Aperçu
- 🧱 Stack technique
- 🏗️ Architecture
- 🎯 Pourquoi cette architecture ?
- 🔍 Exemple concret : un tirage pondéré
⚠️ Dette technique et limites connues- 📚 Ce que ce projet m’a servi à travailler
- 🚀 Installation
- 🧪 Tests et qualité de code
- 🗺️ Pistes pour la suite
- 💡 Pourquoi documenter les limites ?
Terrain d’expérimentation : une fonctionnalité simple (tirage au sort) pour se concentrer sur l’architecture.
| Mode | Description | État | Détails |
|---|---|---|---|
| 🎡 Roue classique | Tirage aléatoire simple et rapide pour désigner un seul gagnant. | ✅ Fonctionnel | |
| ⚔️ Roue par élimination | Élimination progressive des participants jusqu'à ce qu'il n'en reste qu'un. | ✅ Fonctionnel | |
| 🎯 Roue pondérée | Tirage aléatoire où chaque participant a un poids personnalisé pour influencer les résultats. | ✅ Fonctionnel | |
| 🪙 Pile ou face | Simule un lancer de pièce équitable entre deux options, avec système de paris (pile/face) et libellés personnalisables. | ✅ Fonctionnel | Carte active sur l’accueil, route et composant Livewire opérationnels, y compris les paris. Couvert par 6 fichiers de tests (Domain, Application, Livewire) et par le throttle:120,1 partagé avec les routes de tirage. |
| 🎲 421 (dés) | Jeu de dés classique : gardez les dés qui vous arrangent, relancez les autres, visez la combinaison 4-2-1 en 3 lancers maximum. | ✅ Fonctionnel | Carte active sur l’accueil, route et composant Livewire opérationnels (RollDiceAction, FourTwoOneStrategy, Dice421Page). Couverture ajoutée dans cette révision (voir Corrigé récemment). |
| 👥 Tirage par équipes | Permet de former des équipes de manière aléatoire (non encore développé). | 🔒 Non implémenté | Carte visible mais grisée sur l’accueil. |
Historique des tirages en cache : chaque tirage (tous modes) est conservé côté serveur dans le cache (
App\Application\History\HistoryStore), rattaché à la session du visiteur (1 mois de rétention). Page/historique: liste unifiée filtrable par mode, popup de détails (participants, poids, ordre des éliminations) pour les roues. Un résumé rapide des derniers tirages est aussi affiché directement sur chaque page de mode. Aucune persistance en base de données : les participants et résultats vivent dans l’état des composants Livewire (session uniquement) ; seul l’historique des tirages passe par le cache, pas de table dédiée. Déploiement continu : synchronisation automatique demastervers un VPS via GitHub Actions (sans porte de qualité, voir Dette technique). Accueil organisé par catégorie : les modes sont regroupés en sections (GameModeCategory: Roues / Autres tirages / Jeux / En Développement), générées dynamiquement — ajouter une nouvelle catégorie n’implique aucune modification de la vue.
| Accueil | Roue classique |
|---|---|
![]() |
![]() |
| Roue pondérée | Roue par élimination |
|---|---|
![]() |
![]() |
| Pile ou face | 421 |
|---|---|
![]() |
![]() |
| Catégorie | Technologies |
|---|---|
| Backend | PHP 8.4 · Laravel 13 |
| Interactivité | Livewire 4 + Alpine.js (JS dédié pour l'animation des dés du 421, voir resources/js/dice-game.js) |
| Frontend | Tailwind CSS 4 (via @tailwindcss/vite) |
| Tests | Pest 4 (avec plugin Laravel) |
| Analyse statique | Larastan / PHPStan (niveau 5) |
| Style de code | Laravel Pint |
| CI/CD | GitHub Actions → déploiement rsync sur VPS |
| Polices | Work Sans, Bungee, Press Start 2P — auto-hébergées via laravel-vite-plugin (Bunny Fonts), sans appel externe |
| SEO | Meta title/description, Open Graph et Twitter Card par page, sitemap.xml généré automatiquement à partir des routes via spatie/laravel-sitemap (php artisan sitemap:generate), robots.txt |
Séparation Domain / Application / UI, inspirée de la Clean Architecture et du DDD léger.
app/
├── Domain/Draw/ # Règles métier pures (0 dépendance à Laravel)
│ ├── Entities/ # Draw (garantit l'invariant "≥ 2 participants")
│ ├── ValueObjects/ # Participant (nom + poids), DrawResult (immuables)
│ ├── Collections/ # Participants (typée, itérable)
│ ├── Enums/ # DrawType (Random, Weighted), DrawDisplay
│ ├── Strategies/ # RandomDrawStrategy, WeightedDrawStrategy
│ ├── Contracts/ # DrawStrategy (interface)
│ └── Exceptions/
│
├── Domain/CoinFlip/ # Même logique appliquée au pile ou face
│ ├── Enums/ # CoinSide
│ ├── ValueObjects/ # CoinFlipResult, CoinFlipBet (règle gagné/perdu)
│ ├── Strategies/ # RandomCoinFlipStrategy
│ └── Contracts/ # CoinFlipStrategy
│
├── Domain/Dice/ # Jeu de dés (421)
│ ├── Enums/ # DiceCombination (421, Brelan, Suite, Aucune)
│ ├── ValueObjects/ # DiceRoll, DiceThrowResult (immuables)
│ ├── Support/ # DiceCombinationEvaluator (détection de combinaison)
│ ├── Strategies/ # FourTwoOneStrategy (règles du 421 classique)
│ └── Contracts/ # DiceGameStrategy
│
├── Application/Draw/ # Orchestration (pont Domain ↔ UI)
│ ├── Actions/ # RunDrawAction (construit `Draw`, délègue à la stratégie)
│ ├── DTOs/ # DrawData (transmet les données de l’UI au Domain)
│ ├── Resolvers/ # DrawStrategyResolver (point d'entrée unique)
│ └── Support/ # WheelSegmentBuilder (segments SVG + rotation cumulée)
│
├── Application/CoinFlip/ # Orchestration du pile ou face
│ └── Actions/ # FlipCoinAction
│
├── Application/Dice/ # Orchestration du 421
│ └── Actions/ # RollDiceAction (relance les dés non gardés, détecte fin de partie)
│
├── Application/Home/ # Read-model de la page d'accueil
│ ├── DTOs/ # GameMode (carte affichée sur la home)
│ └── Enums/ # GameModeType, GameModeCategory (regroupement de la home)
│
├── Application/History/ # Historique des tirages (transverse à tous les modes)
│ └── HistoryStore.php # Lecture/écriture en cache, rattaché à la session (push/all/allModes/clear)
│
├── Http/Middleware/
│ └── SecurityHeaders.php # CSP (prod uniquement) + en-têtes de sécurité HTTP
│
├── Console/Commands/
│ └── GenerateSitemap.php # Commande `sitemap:generate` — découvre les routes GET publiques automatiquement
│
└── Livewire/
├── Draw/ # WheelPage, EliminationWheelPage, WeightedWheelPage
│ └── Concerns/ # ManagesParticipants, HandlesDraw (traits partagés)
├── CoinFlip/
│ └── CoinFlipPage.php # Tirage simple/multiple + paris + libellés personnalisables
├── Dice/
│ └── Dice421Page.php # Partie de 421 : lancers, dés gardés, historique local
└── History/
└── HistoryPage.php # Page /historique : liste unifiée tous modes, filtrable
Nomenclature : les DTO/enums de la home s'appellent désormais
GameModeType/GameModeCategory(et non plusDrawModeType/DrawModeCategory), suite à l'ajout du 421 qui n'est pas un mode de tirage à proprement parler.
- Domain : Ignore Laravel/Livewire. Testable en PHP pur.
- Application : Orchestre les cas d’usage (ex. :
RunDrawActionconstruitDrawpuis appelle$strategy->draw()). - Livewire : Gère uniquement l’état UI et délègue la logique métier.
- Strategy :
RandomDrawStrategyetWeightedDrawStrategyimplémententDrawStrategy. Le branchement se fait en un seul point (DrawStrategyResolver::resolve()). - Double Dispatch :
Draw::execute($strategy)délègue à la stratégie sans connaître son implémentation. - Always-Valid Entity :
Drawvalide ses invariants (ex. : ≥ 2 participants) dans son constructeur. - DTO :
DrawDatatransmet les données de l’UI au Domain sans couplage.
Un tirage au sort tient en 30 lignes avec array_rand(). Découper le code en couches ajoute de la complexité... mais c’est voulus.
- Testabilité : Le Domain se teste sans base de données, sans HTTP, sans Livewire.
- Portabilité :
Draw,Participant,RandomDrawStrategypourraient être copiés dans un projet PHP sans Laravel. - Évolutivité : Ajouter un nouveau type de tirage (ex. : équipes) ne nécessite que :
- Une nouvelle stratégie (
TeamDrawStrategy). - Une entrée dans
DrawType. - Une route et un composant Livewire.
- Une nouvelle stratégie (
- Plus de fichiers : 1 fonctionnalité simple = plus de couches, plus d’indirection.
- Courbe d’apprentissage : Un nouveau contributeur doit comprendre la structure avant de modifier le code.
- Risque de bugs : Plus de couches = plus d’endroits où une intention peut se perdre (ex. : le bug du tirage pondéré, voir ci-dessous).
Le compromis : Ce projet n’est pas optimisé pour la livraison rapide, mais pour comprendre où cette architecture aide et où elle coûte.
Exemple concret de trajet à travers les couches :
WeightedWheelPage(Livewire) : SurchargedrawType()pour retournerDrawType::WEIGHTED.HandlesDraw::executeDraw(): Construit unDrawData(DTO) avec les participants, leurs poids, et appelle$this->drawType()pour transmettre le bon type.RunDrawAction::execute():- Demande à
DrawStrategyResolverla stratégie pourDrawType::WEIGHTED, qui résout bienWeightedDrawStrategy. - Construit l’entité
Drawà partir de$data->participantsCollection(). Le constructeur valide qu’il y a ≥ 2 participants.
- Demande à
Draw::execute($strategy): Délègue à$strategy->draw($this->participants)(Double Dispatch).WeightedDrawStrategy::draw():- Calcule la somme des poids.
- Tire un
random_int(1, $totalWeight). - Parcourt les participants en cumulant leurs poids jusqu’à dépasser le nombre tiré (algorithme Roulette Wheel Selection).
- Résultat : Le
DrawResult(Value Object immuable) remonte jusqu’à Livewire pour mise à jour de l’affichage.- En parallèle :
WheelSegmentBuildercalcule la taille des segments SVG proportionnellement aux poids (les parts inégales sont désormais visibles à l’écran).
- En parallèle :
- Déploiement sans porte de qualité : Le workflow GitHub Actions déploie directement sur
mastersans exécutercomposer testoucomposer run analyse. - Pas de persistance : Les tirages ne sont pas sauvegardés en base de données (choix assumé pour l’instant).
- Tirage par équipes non implémenté : Carte visible mais désactivée (
available: false) sur l’accueil.
- Historique des tirages en cache :
App\Application\History\HistoryStoreenregistre chaque tirage (tous modes) en cache, rattaché à la session du visiteur (session étendue à 1 mois dansconfig/session.phppour ne pas perdre l'historique après 2h d'inactivité ; même durée de rétention (1 mois) côté cache). Nouvelle page/historique(composantHistoryPage) : liste unifiée de tous les tirages, filtrable par mode, avec popup de détails (participants, poids, ordre des éliminations) pour les roues. Chaque page de mode affiche aussi un résumé rapide de ses derniers tirages, avec lien vers l'historique complet. SurCoinFlipPage, les tirages multiples (série auto) sont désormais des entrées distinctes des tirages simples (type: 'single'|'multiple'), avec gagnant de la série calculé au max de faces. - Résultat affiché seulement après la fin de l'animation : sur les 5 modes, le résultat n'atterrit dans l'historique/le résumé rapide qu'après la fin de l'animation côté client (+ court délai réglable, actuellement 500ms) plutôt qu'instantanément à la requête Livewire — évite de spoiler le résultat avant que l'animation ne soit terminée visuellement (
pendingHistoryEntries/confirmFlip()pour le pile ou face,pendingHistoryEntry/confirmDraw()pour les roues classique et pondérée,pendingTournamentEntry/confirmTournamentHistory()pour la roue d'élimination — cette dernière ne différant que la mise en historique du tournoi, pas la logique de jeu elle-même qui doit rester synchrone). - Refonte visuelle complète, direction « borne d’arcade » : nouvelle palette néo-brutaliste à ombres dures (
--shadow-hard,--shadow-press), nouvelles polices auto-hébergées (Work Sans, Bungee, Press Start 2P via Bunny Fonts/Vite), nouveaux utilitaires CSS (btn-press,card-hard,card-hard-hover,tile-selected,text-outline) et prise en charge deprefers-reduced-motion. Appliquée à l’accueil, aux cartes de mode, à la roue, au pile ou face et au 421. Ajout d’un logo. - Résolution du bug de polices bloquées par la CSP sur
/421: le<link>direct versfonts.googleapis.com/fonts.gstatic.coma été supprimé ; les polices passent désormais par le même pipeline Vite que le reste du site (plus de retombée silencieuse sur la police système en production). - Historique et compteur du 421 différés jusqu’à la fin de l’animation :
roll()ne pousse plus l’entrée d’historique immédiatement mais la stocke danspendingHistoryEntry(#[Locked]) et appelleskipRender(); c’estfinalizeRoll(), déclenché par Alpine à la fin de l’animation des dés, qui l’applique. Évite que le résultat ou l’historique n’apparaissent avant que l’animation soit terminée. - Ajout des tests du jeu de dés 421 :
RollDiceAction,FourTwoOneStrategy,DiceCombinationEvaluator,DiceRoll,Dice421Pagesont désormais couverts par des tests (Domain, Application, Livewire) — l’implémentation existait déjà mais n’était pas testée et n’apparaissait pas dans ce README. - Correction du statut « Pile ou face » dans ce README : contrairement à ce qu’indiquait une précédente version,
FlipCoinAction,RandomCoinFlipStrategy,CoinFlipBet,CoinFlipResultetCoinFlipPage(y compris les paris) sont bien couverts par 6 fichiers de tests, et la route/pile-ou-facea bien unthrottle:120,1. - Sitemap généré automatiquement à partir des routes :
GenerateSitemapn’énumère plus les routes à la main ; il parcourtRoute::getRoutes()et ne garde que les routes GET, nommées, sans paramètre et hors routes internes Livewire (livewire.*). Toute nouvelle route publique apparaît donc dans le sitemap sans y toucher, avec une priorité par défaut (0.5) surchargeable via la constantePRIORITIES. - Sécurité HTTP : ajout de
SecurityHeaders(CSP appliquée uniquement en production — Vite/HMR ont besoin d’une origine séparée en dev —,X-Frame-Options,X-Content-Type-Options,Referrer-Policy,Permissions-Policy), etthrottle:120,1sur les routes de tirage. - Aléatoire non cryptographique corrigé :
Participants::random()utilise désormaisrandom_int()(CSPRNG) au lieu d’array_rand(), cohérent avecWeightedDrawStrategy. - Bug de rotation de roue à la relance :
WheelPageetWeightedWheelPagedispatchaient un angle absolu à un composant Alpine qui accumule les rotations (rotation += ...) — la roue s’arrêtait au mauvais endroit après un premier tirage. Corrigé viaWheelSegmentBuilder::cumulativeRotationFor(), qui calcule un delta relatif à la rotation déjà appliquée (logique déjà en place surEliminationWheelPage, désormais centralisée et partagée par les trois pages). - Blocage possible sur la roue d’élimination : ajout d’un détecteur d’état bloqué (
isStuck()/unstick()) siconfirmElimination()n’est jamais rappelée (ex. coupure réseau pendant l’animation). CoinFlipPage::$betverrouillée (#[Locked]) : empêche un payload Livewire forgé de fixer$betà une valeur arbitraire côté client ;evaluateBet()utiliseCoinSide::tryFrom()en défense en profondeur plutôt quefrom().- Accueil regroupé par catégorie : nouvel enum
GameModeCategory(Roues / Autres tirages / Jeux),GameModeType::grouped()génère les sections dynamiquement — une catégorie sans mode actif n’apparaît pas, et en ajouter une nouvelle ne nécessite aucune modification dehome.blade.php. (Ces enums s'appelaientDrawModeCategory/DrawModeTypeau moment de ce commit, renommés depuis enGameModeCategory/GameModeTypesuite à l'ajout du 421.) - Accueil responsive : catégories en colonnes auto-adaptatives (
grid-cols-[repeat(auto-fit,minmax(280px,1fr))], pas de nombre codé en dur) à partir demd:; sur mobile, chaque catégorie est un accordéon repliable (Alpine.js, première catégorie ouverte par défaut) pour éviter une page à rallonge.<x-mode-card>a deux mises en page distinctes : carte verticale complète dèsmd:, rangée horizontale compacte (icône + titre/description tronqués + chevron) en dessous. Les effetshoversont désormais gated parmd:group-hover:pour ne jamais rester "collés" après un tap tactile. - Ajout des paris sur pile ou face :
CoinFlipBet(Domain) porte la règle gagné/perdu, historique des paris et libellés de faces personnalisables (pileLabel/faceLabel). - Bug du tirage pondéré résolu :
HandlesDraw::executeDraw()appelle désormais$this->drawType()au lieu d’unDrawType::RANDOMcodé en dur ;WeightedDrawStrategyest bien invoquée. - Segments SVG proportionnels aux poids :
WheelSegmentBuildercalcule désormais la taille des parts selonParticipant::$weight. - Implémentation du Pile ou face :
FlipCoinAction,RandomCoinFlipStrategy,CoinFlipPageet la route/pile-ou-facesont en place et actifs sur l’accueil. - Fusion de
DrawFactoryetDrawStrategyResolveren un seul point de résolution. - Alignement des namespaces et dossiers.
- Réparation de la suite de tests (
phpunit.xml, scriptcomposer test). - Implémentation de
WeightedDrawStrategy(algorithme Roulette Wheel Selection). - Passage de
RunDrawActionpar l’entitéDrawpour appliquer ses invariants. - Correction de
composer run setuppour un clone frais.
Une liste non exhaustive des concepts et bonnes pratiques appliqués.
| Concept | Implémentation dans NexaSpin |
|---|---|
| Séparation des couches | Domain (règles métier) / Application (orchestration) / UI (Livewire). |
| Entity vs Value Object | Draw (Entity avec identité et invariants) vs Participant/DrawResult (Value Objects immuables). |
| Pattern Strategy | RandomDrawStrategy et WeightedDrawStrategy derrière l’interface DrawStrategy. |
| Double Dispatch | Draw::execute($strategy) délègue à la stratégie sans connaître son type. |
| DTO | DrawData pour transmettre des données de l’UI au Domain sans couplage. |
| Traits PHP | ManagesParticipants, HandlesDraw pour éviter la duplication entre composants Livewire. |
| Génération SVG dynamique | WheelSegmentBuilder calcule les coordonnées polaires → cartésiennes pour la roue. |
| Algorithme Roulette Wheel | Tirage pondéré via cumul des poids et random_int. |
| Tests Livewire | Couverture des composants (ajout/suppression/édition de participants, tirages). |
| Analyse statique | Larastan/PHPStan niveau 5 + documentation des ignoreErrors. |
| CI/CD | Déploiement automatique via GitHub Actions (à améliorer avec des portes de qualité). |
| En-têtes de sécurité HTTP | Middleware SecurityHeaders (CSP conditionnelle à l’environnement, X-Frame-Options, etc.) + throttle sur les routes sensibles. |
| Read-model + regroupement par enum | GameModeCategory pilote les sections de la home ; GameModeType::grouped() reste la seule source de vérité, la vue ne fait qu’itérer. |
📜 Historique des commits clés
| Commit | Apport |
|---|---|
init roulette v2 |
Reprise d’une v1 plus simple. |
add livewire |
Introduction de Livewire pour l’interactivité. |
Domain puis Application |
Extraction des règles métier hors des composants Livewire. |
Début UI + debug + ajout roue visuel |
Construction de la roue en SVG généré côté serveur. |
elimination |
Mode multi-étapes avec confirmation d’animation. |
refonte + rename drawfactory |
Retour sur des noms et une structure plus cohérents. |
merge factory/resolver |
Fusion des mécanismes concurrents de résolution de stratégie. |
fix setup script + tests |
Réparation de composer run setup, phpunit.xml, et tests Livewire. |
WeightedDrawStrategy + WeightedWheelPage |
Implémentation du tirage pondéré et de son écran dédié. |
wire Draw entity into RunDrawAction |
RunDrawAction passe par l’entité Draw (Double Dispatch). |
test updateParticipant + Larastan + deploy.yml |
Couverture de l’édition inline, analyse statique, et pipeline de déploiement. |
tirage ponderer segment = poids |
Correction du bug : les segments SVG suivent enfin les poids réels. |
implémentation Coin Flip |
Ajout du mode Pile ou face (FlipCoinAction, RandomCoinFlipStrategy, CoinFlipPage), désormais actif sur l’accueil. |
Label coin flip + paris |
Ajout des paris pile/face et des libellés de faces personnalisables. |
Securité |
SecurityHeaders (CSP prod-only), throttle sur les routes de tirage, random_int() au lieu d’array_rand(), verrouillage de CoinFlipPage::$bet. |
debug wheel a la relance de la roue |
Correction du bug de rotation absolue vs cumulative sur WheelPage/WeightedWheelPage, ajout du détecteur de blocage sur EliminationWheelPage. |
accueil par catégories |
DrawModeCategory + DrawModeType::grouped() : sections de la home générées dynamiquement. |
accueil responsive mobile |
Colonnes auto-adaptatives, accordéon Alpine.js par catégorie sur mobile, carte compacte dédiée, hover limité à md:. |
implémentation 421 + Debug 421 + test 421 |
Ajout du jeu de dés 421 et de sa suite de tests. |
design type borne arcade + redisign |
Refonte visuelle néo-brutaliste « borne d’arcade » : nouvelle palette à ombres dures, nouvelles polices auto-hébergées (Work Sans, Bungee, Press Start 2P), nouveaux utilitaires CSS, ajout du logo. |
attendre fin annimation pour compteur et historique |
Historique et compteur du 421 appliqués seulement après la fin de l’animation Alpine (pendingHistoryEntry + finalizeRoll()). |
historique en cache + popup details roues |
HistoryStore, page /historique unifiée et filtrable, résumés rapides par mode, résultat différé après animation sur les 5 modes, popup de détails (participants/poids/éliminations) pour les roues. |
- PHP 8.4
- Composer
- Node.js (pour Tailwind CSS)
# Cloner le dépôt
git clone https://github.com/DocCreeps/NexaSpinV2.git
cd NexaSpinV2
# Installer les dépendances PHP et générer la clé d'application
composer install
cp .env.example .env
php artisan key:generate
# Installer et builder les assets front
npm install
npm run buildAlternative : Un script composer run setup regroupe toutes ces étapes (sauf la configuration de base de données, non nécessaire).
composer run devL’application sera accessible sur http://localhost:8000.
composer test- 160+ déclarations de test (Pest) couvrant :
- Gestion des participants (ajout/suppression/édition, y compris pondération).
- Résolution de stratégie et exécution des tirages.
- Tirage pondéré (résultat et segments SVG proportionnels).
- Déroulé complet d’une élimination.
- Pile ou face : tirage simple/multiple, système de paris, libellés personnalisés, statistiques.
- 421 : lancer de dés (dés gardés vs relancés), détection de combinaison (421, brelan, suite), fin de partie, historique.
- Chargement des routes.
- Génération des segments SVG.
composer run analyse- Larastan / PHPStan niveau 5.
- Les exceptions volontaires sont documentées dans
ignoreErrors.
Par ordre de priorité.
- Design :
- Responsive, accessibilité.
- Améliorer le pipeline de déploiement :
- Ajouter
composer testetcomposer run analysecomme portes de qualité dansdeploy.yml.
- Ajouter
- Implémenter le mode manquant :
- Tirage par équipes (
GameModeType::TEAMS).
- Tirage par équipes (
- Peaufiner Pile ou face (voir
TODO) :- Amélioration du design.
- Persistance en base de données :
- L'historique des tirages passe aujourd'hui par le cache (rattaché à la session, 1 mois) plutôt qu'une vraie table — suffisant pour l'usage actuel, mais une migration vers une table dédiée permettrait un historique multi-appareils/compte et sans limite de rétention, si un besoin réel émerge.
Parce que repérer et assumer les trous fait partie de l’exercice autant que d’écrire le code.
"Un README qui ne mentionne que ce qui marche est un mensonge par omission. Celui-ci liste aussi ce qui est cassé, incomplet, ou à améliorer — pour que le prochain qui lit ce code (moi y compris) sache exactement où en est le projet."
Fait avec 🎲 par DocCreeps





