# Play Games Services Android — Plan d'implémentation > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Sur Android uniquement, authentification Play Jeux Services v2 + 10 leaderboards (un par nombre de questions) avec un score pondéré, le tout silencieux et optionnel (l'app reste jouable hors connexion). **Architecture:** Interface `GamesServices` dans commonMain injectée dans `App()` (même pattern que `filesDirPath`), implémentée par `PlayGamesServices` dans `androidApp` avec `play-services-games-v2:22.0.0`, no-op sur desktop/iOS. Formule du score = `round((Σ produits réussis) × 30 ÷ secondsPerQuestion)`. **Tech Stack:** Kotlin Multiplatform 2.4.10, Compose Multiplatform 1.12, Google Play Services Games v2 22.0.0, kotlinx-coroutines-play-services 1.11.0. **Spec :** `docs/superpowers/specs/2026-08-28-play-games-services-design.md` ## Global Constraints - La dépendance GMS reste confinée à `androidApp` — sharedUI ne dépend d'aucune API Android/Google. - Version exacte : `com.google.android.gms:play-services-games-v2:22.0.0` ; `org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.11.0`. - Formule du score : `round((Σ produits des questions réussies) × 30 ÷ secondsPerQuestion)` ; 0 bonne réponse → pas de soumission. - Produit d'une question : `Direct(a, b)` → `a × b` ; `MissingFactor(a, b)` → `a × b` (⚠️ `correctAnswer` d'un `MissingFactor` est le facteur manquant `b`, pas le produit). - IDs de boards Play Console (custom) : `questions_5`, `questions_10`, … `questions_50`. - Tous les échecs GMS sont silencieux : pas d'UI d'erreur, pas de crash, retour `false`. - Pas de déconnexion in-app (standard PGS v2, gérée par l'app Play Jeux). - JVM 17, minSdk 23, compileSdk 37, Kotlin 2.4.10 (cataloge existant). - Tests : `./gradlew :sharedUI:jvmTest` ; vérif Android : `./gradlew :androidApp:assembleDebug` ; régression finale : `./gradlew build`. - Style des commits du dépôt : `feat:` / `docs:` en français. --- ### Task 1: Score leaderboard et ID de board (logique pure, TDD) **Files:** - Modify: `sharedUI/src/commonMain/kotlin/com/livrets/game/Question.kt` (ajout extension `product`) - Create: `sharedUI/src/commonMain/kotlin/com/livrets/games/LeaderboardScore.kt` - Create: `sharedUI/src/commonMain/kotlin/com/livrets/games/Leaderboards.kt` - Test: `sharedUI/src/commonTest/kotlin/com/livrets/games/LeaderboardScoreTest.kt` **Interfaces:** - Consumes: `GameResult` (`com.livrets.game.GameResult`, champs `config`, `records`, `correctCount`), `AnswerRecord` (`question`, `isCorrect`), `Question` (sealed : `Direct(a, b)`, `MissingFactor(a, b)`), `GameConfig` (`questionCount`). - Produces: `Question.product: Int`, `GameResult.leaderboardScore(): Long`, `GameConfig.leaderboardId(): String` — utilisés par les Tasks 3 et 4. - [ ] **Step 1: Write the failing test** Créer `sharedUI/src/commonTest/kotlin/com/livrets/games/LeaderboardScoreTest.kt` : ```kotlin package com.livrets.games import com.livrets.game.AnswerRecord import com.livrets.game.GameConfig import com.livrets.game.GameResult import com.livrets.game.Question import com.livrets.game.product import kotlin.test.Test import kotlin.test.assertEquals import kotlin.time.Instant class LeaderboardScoreTest { private fun result(config: GameConfig, records: List) = GameResult(date = Instant.parse("2026-08-28T10:00:00Z"), config = config, records = records) private fun direct(a: Int, b: Int, correct: Boolean) = AnswerRecord( question = Question.Direct(a, b), givenAnswer = if (correct) a * b else a * b + 1, correctAnswer = a * b, isCorrect = correct, durationMs = 1000, ) private fun missing(a: Int, b: Int, correct: Boolean) = AnswerRecord( question = Question.MissingFactor(a, b), givenAnswer = if (correct) b else b + 1, correctAnswer = b, isCorrect = correct, durationMs = 1000, ) @Test fun `produit d'une question directe et a facteur manquant`() { assertEquals(56, Question.Direct(7, 8).product) assertEquals(10, Question.MissingFactor(2, 5).product) } @Test fun `score nul sans bonne reponse`() { val r = result(GameConfig(5, 5, 12), listOf(direct(7, 8, false), missing(2, 5, false))) assertEquals(0L, r.leaderboardScore()) } @Test fun `score = somme des produits reussis fois le facteur vitesse`() { val r = result( GameConfig(10, 5, 12), listOf(direct(7, 8, true), direct(3, 4, false), missing(2, 5, true)), ) // (56 + 10) × 30 / 5 = 396 assertEquals(396L, r.leaderboardScore()) } @Test fun `toutes les questions reussies`() { val r = result( GameConfig(5, 3, 12), listOf( direct(4, 6, true), direct(7, 8, true), direct(9, 9, true), direct(2, 12, true), direct(5, 5, true), ), ) // (24 + 56 + 81 + 24 + 25) × 30 / 3 = 210 × 10 = 2100 assertEquals(2100L, r.leaderboardScore()) } @Test fun `arrondi au point le plus proche`() { // 8 × 30 / 4 = 60 (exact) assertEquals( 60L, result(GameConfig(10, 4, 12), listOf(direct(2, 4, true))).leaderboardScore(), ) // 7 × 30 / 4 = 52,5 → 53 assertEquals( 53L, result(GameConfig(10, 4, 12), listOf(direct(7, 1, true))).leaderboardScore(), ) } @Test fun `id de board par nombre de questions`() { assertEquals("questions_10", GameConfig(10, 5, 12).leaderboardId()) assertEquals("questions_5", GameConfig(5, 3, 1).leaderboardId()) } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `./gradlew :sharedUI:jvmTest --tests "com.livrets.games.LeaderboardScoreTest"` Expected: FAIL — compilation error, `product` et `leaderboardScore`/`leaderboardId` introuvables. - [ ] **Step 3: Write minimal implementation** Dans `sharedUI/src/commonMain/kotlin/com/livrets/game/Question.kt`, ajouter après l'extension `correctAnswer` : ```kotlin val Question.product: Int get() = when (this) { is Question.Direct -> a * b is Question.MissingFactor -> a * b } ``` Créer `sharedUI/src/commonMain/kotlin/com/livrets/games/LeaderboardScore.kt` : ```kotlin package com.livrets.games import com.livrets.game.GameResult import kotlin.math.roundToLong fun GameResult.leaderboardScore(): Long { if (correctCount == 0) return 0L val products = records.filter { it.isCorrect }.sumOf { it.question.product } return (products * 30.0 / config.secondsPerQuestion).roundToLong() } ``` Créer `sharedUI/src/commonMain/kotlin/com/livrets/games/Leaderboards.kt` : ```kotlin package com.livrets.games import com.livrets.game.GameConfig fun GameConfig.leaderboardId(): String = "questions_$questionCount" ``` - [ ] **Step 4: Run test to verify it passes** Run: `./gradlew :sharedUI:jvmTest --tests "com.livrets.games.LeaderboardScoreTest"` Expected: PASS (6 tests). Puis régression complète : `./gradlew :sharedUI:jvmTest` — PASS. - [ ] **Step 5: Commit** ```bash git add sharedUI/src/commonMain/kotlin/com/livrets/game/Question.kt \ sharedUI/src/commonMain/kotlin/com/livrets/games/LeaderboardScore.kt \ sharedUI/src/commonMain/kotlin/com/livrets/games/Leaderboards.kt \ sharedUI/src/commonTest/kotlin/com/livrets/games/LeaderboardScoreTest.kt git commit -m "feat: score leaderboard pondéré et id de board" ``` --- ### Task 2: Interface GamesServices et no-op **Files:** - Create: `sharedUI/src/commonMain/kotlin/com/livrets/games/GamesServices.kt` **Interfaces:** - Consumes: rien. - Produces: `interface GamesServices { suspend fun signIn(): Boolean ; suspend fun isSignedIn(): Boolean ; suspend fun submitScore(leaderboardId: String, score: Long): Boolean ; suspend fun showLeaderboard(leaderboardId: String) }` et `object NoopGamesServices : GamesServices` — utilisés par les Tasks 3 et 4. - [ ] **Step 1: Write the interface and the no-op implementation** Créer `sharedUI/src/commonMain/kotlin/com/livrets/games/GamesServices.kt` : ```kotlin package com.livrets.games interface GamesServices { suspend fun signIn(): Boolean suspend fun isSignedIn(): Boolean suspend fun submitScore(leaderboardId: String, score: Long): Boolean suspend fun showLeaderboard(leaderboardId: String) } object NoopGamesServices : GamesServices { override suspend fun signIn(): Boolean = false override suspend fun isSignedIn(): Boolean = false override suspend fun submitScore(leaderboardId: String, score: Long): Boolean = false override suspend fun showLeaderboard(leaderboardId: String) {} } ``` - [ ] **Step 2: Verify compilation (les 3 cibles consomment commonMain)** Run: `./gradlew :sharedUI:compileKotlinJvm` Expected: BUILD SUCCESSFUL - [ ] **Step 3: Commit** ```bash git add sharedUI/src/commonMain/kotlin/com/livrets/games/GamesServices.kt git commit -m "feat: interface GamesServices injectable" ``` --- ### Task 3: Câblage UI partagée (App, Home, Results) **Files:** - Modify: `sharedUI/src/commonMain/kotlin/com/livrets/App.kt` - Modify: `sharedUI/src/commonMain/kotlin/com/livrets/ui/HomeScreen.kt` - Modify: `sharedUI/src/commonMain/kotlin/com/livrets/ui/ResultsScreen.kt` **Interfaces:** - Consumes: `GamesServices`, `NoopGamesServices`, `leaderboardId()`, `leaderboardScore()` (Tasks 1-2). - Produces: `App(filesDirPath: String, gamesServices: GamesServices = NoopGamesServices, onThemeChanged: …)` ; `HomeScreen(onPlay: (GameConfig) -> Unit, onHistory: () -> Unit, onLeaderboard: (GameConfig) -> Unit)` ; `ResultsScreen` inchangé en signature (affiche `result.leaderboardScore()`). - [ ] **Step 1: Modifier HomeScreen (bouton Classement)** Dans `sharedUI/src/commonMain/kotlin/com/livrets/ui/HomeScreen.kt`, remplacer la signature : ```kotlin @Composable fun HomeScreen( onPlay: (GameConfig) -> Unit, onHistory: () -> Unit, onLeaderboard: (GameConfig) -> Unit, ) { ``` et remplacer la fin du `Column` (bouton Jouer + Historique) par : ```kotlin Spacer(Modifier.height(32.dp)) Button( onClick = { onPlay(GameConfig(questionCount, secondsPerQuestion, maxTable)) }, modifier = Modifier.fillMaxWidth().height(56.dp), ) { Text("Jouer", fontSize = 20.sp) } TextButton(onClick = onHistory) { Text("Historique") } TextButton( onClick = { onLeaderboard(GameConfig(questionCount, secondsPerQuestion, maxTable)) }, ) { Text("Classement") } ``` - [ ] **Step 2: Modifier ResultsScreen (ligne points classement)** Dans `sharedUI/src/commonMain/kotlin/com/livrets/ui/ResultsScreen.kt`, ajouter l'import : ```kotlin import com.livrets.games.leaderboardScore ``` et insérer sous le Text « Temps moyen de réponse » (ligne 55) : ```kotlin Text( text = "Points classement : ${result.leaderboardScore()}", style = MaterialTheme.typography.titleMedium, ) ``` - [ ] **Step 3: Modifier App.kt (injection, sign-in au lancement, envoi, onLeaderboard)** Remplacer tout le contenu de `sharedUI/src/commonMain/kotlin/com/livrets/App.kt` par : ```kotlin package com.livrets import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import com.livrets.data.HistoryRepository import com.livrets.game.GameConfig import com.livrets.game.GameResult import com.livrets.games.GamesServices import com.livrets.games.NoopGamesServices import com.livrets.games.leaderboardId import com.livrets.games.leaderboardScore import com.livrets.theme.AppTheme import com.livrets.ui.GameScreen import com.livrets.ui.HistoryScreen import com.livrets.ui.HomeScreen import com.livrets.ui.ResultsScreen import com.livrets.ui.SwipeBack import kotlinx.coroutines.launch import okio.Path.Companion.toPath sealed interface Screen { data object Home : Screen data class Game(val config: GameConfig) : Screen data class Results(val result: GameResult) : Screen data object History : Screen } @Composable fun App( filesDirPath: String, gamesServices: GamesServices = NoopGamesServices, onThemeChanged: @Composable (isDark: Boolean) -> Unit = {}, ) = AppTheme(onThemeChanged) { val scope = rememberCoroutineScope() val history = remember { HistoryRepository("$filesDirPath/history.json".toPath()) } var screen by remember { mutableStateOf(Screen.Home) } LaunchedEffect(gamesServices) { gamesServices.signIn() } val homePreview: @Composable () -> Unit = { HomeScreen(onPlay = {}, onHistory = {}, onLeaderboard = {}) } when (val current = screen) { Screen.Home -> HomeScreen( onPlay = { config -> screen = Screen.Game(config) }, onHistory = { screen = Screen.History }, onLeaderboard = { config -> scope.launch { if (gamesServices.isSignedIn() || gamesServices.signIn()) { gamesServices.showLeaderboard(config.leaderboardId()) } } }, ) is Screen.Game -> GameScreen( config = current.config, onFinish = { result -> screen = Screen.Results(result) scope.launch { history.save(result) } scope.launch { val score = result.leaderboardScore() if (score > 0 && gamesServices.isSignedIn()) { gamesServices.submitScore(result.config.leaderboardId(), score) } } }, onQuit = { screen = Screen.Home }, ) is Screen.Results -> SwipeBack( onBack = { screen = Screen.Home }, background = homePreview, ) { ResultsScreen( result = current.result, onReplay = { screen = Screen.Game(current.result.config) }, onHome = { screen = Screen.Home }, ) } Screen.History -> SwipeBack( onBack = { screen = Screen.Home }, background = homePreview, ) { HistoryScreen( history = history, onBack = { screen = Screen.Home }, ) } } } ``` - [ ] **Step 4: Run régression tests + build desktop** Run: `./gradlew :sharedUI:jvmTest` Expected: PASS (aucune régression). Run: `./gradlew :desktopApp:run` Expected: l'app s'ouvre (no-op : pas d'erreur), bouton « Classement » visible sur l'accueil, ligne « Points classement : X » sur l'écran de résultats. Quitter la fenêtre. - [ ] **Step 5: Commit** ```bash git add sharedUI/src/commonMain/kotlin/com/livrets/App.kt \ sharedUI/src/commonMain/kotlin/com/livrets/ui/HomeScreen.kt \ sharedUI/src/commonMain/kotlin/com/livrets/ui/ResultsScreen.kt git commit -m "feat: connexion et envoi du score Play Jeux dans l'UI partagée" ``` --- ### Task 4: Implémentation Android PlayGamesServices **Files:** - Modify: `gradle/libs.versions.toml` - Modify: `androidApp/build.gradle.kts` - Create: `androidApp/src/main/kotlin/com/livrets/androidApp/PlayGamesServices.kt` - Modify: `androidApp/src/main/kotlin/com/livrets/androidApp/AppActivity.kt` **Interfaces:** - Consumes: `GamesServices` (Task 2 — mêmes signatures exactes). - Produces: `PlayGamesServices(activity: Activity, launchIntent: (Intent) -> Unit) : GamesServices` — instancié dans `AppActivity`. - [ ] **Step 1: Ajouter les dépendances au catalogue** Dans `gradle/libs.versions.toml` : - Dans la section `[versions]`, ajouter : ```toml gms-games-v2 = "22.0.0" ``` - Dans la section `[libraries]`, ajouter : ```toml gms-games-v2 = { module = "com.google.android.gms:play-services-games-v2", version.ref = "gms-games-v2" } kotlinx-coroutines-play-services = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "kotlinx-coroutines" } ``` - [ ] **Step 2: Déclarer les dépendances dans androidApp** Dans `androidApp/build.gradle.kts`, remplacer le bloc `dependencies` par : ```kotlin dependencies { implementation(project(":sharedUI")) implementation(libs.androidx.activityCompose) implementation(libs.gms.games.v2) implementation(libs.kotlinx.coroutines.play.services) } ``` - [ ] **Step 3: Écrire PlayGamesServices** Créer `androidApp/src/main/kotlin/com/livrets/androidApp/PlayGamesServices.kt` : ```kotlin package com.livrets.androidApp import android.app.Activity import android.content.Intent import com.google.android.gms.games.PlayGames import com.livrets.games.GamesServices import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.tasks.await import kotlinx.coroutines.withContext class PlayGamesServices( private val activity: Activity, private val launchIntent: (Intent) -> Unit, ) : GamesServices { override suspend fun signIn(): Boolean = withContext(Dispatchers.IO) { runCatching { PlayGames.getGamesSignInClient(activity).signIn().await().isAuthenticated }.getOrDefault(false) } override suspend fun isSignedIn(): Boolean = withContext(Dispatchers.IO) { runCatching { PlayGames.getGamesSignInClient(activity).isAuthenticated().await().isAuthenticated }.getOrDefault(false) } override suspend fun submitScore(leaderboardId: String, score: Long): Boolean = withContext(Dispatchers.IO) { runCatching { PlayGames.getLeaderboardsClient(activity) .submitScoreImmediate(leaderboardId, score) .await() true }.getOrDefault(false) } override suspend fun showLeaderboard(leaderboardId: String) { val intent = withContext(Dispatchers.IO) { runCatching { PlayGames.getLeaderboardsClient(activity) .getLeaderboardIntent(leaderboardId) .await() }.getOrNull() } ?: return launchIntent(intent) } } ``` - [ ] **Step 4: Câbler dans AppActivity** Remplacer tout le contenu de `androidApp/src/main/kotlin/com/livrets/androidApp/AppActivity.kt` par : ```kotlin package com.livrets.androidApp import android.app.Activity import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowInsetsControllerCompat import com.livrets.App class AppActivity : ComponentActivity() { private val leaderboardLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() val gamesServices = PlayGamesServices(this) { intent -> leaderboardLauncher.launch(intent) } setContent { App( filesDirPath = filesDir.absolutePath, gamesServices = gamesServices, onThemeChanged = { ThemeChanged(it) }, ) } } } @Composable private fun ThemeChanged(isDark: Boolean) { val view = LocalView.current LaunchedEffect(isDark) { val window = (view.context as Activity).window WindowInsetsControllerCompat(window, window.decorView).apply { isAppearanceLightStatusBars = isDark isAppearanceLightNavigationBars = isDark } } } ``` - [ ] **Step 5: Verify compilation APK debug** Run: `./gradlew :androidApp:assembleDebug` Expected: BUILD SUCCESSFUL (la connexion échouera à l'exécution tant que la console n'est pas configurée — comportement silencieux attendu). - [ ] **Step 6: Commit** ```bash git add gradle/libs.versions.toml androidApp/build.gradle.kts \ androidApp/src/main/kotlin/com/livrets/androidApp/PlayGamesServices.kt \ androidApp/src/main/kotlin/com/livrets/androidApp/AppActivity.kt git commit -m "feat: implémentation Play Games Services v2 sur Android" ``` --- ### Task 5: README (prérequis console) + build complet **Files:** - Modify: `README.MD` **Interfaces:** - Consumes: rien. - Produces: documentation des prérequis Play Console (spec § 7) et de la checklist de vérification manuelle. - [ ] **Step 1: Documenter les prérequis dans le README** Dans `README.MD`, ajouter cette section après la section « ### macOS desktop » (avant « ## Tests ») : ```markdown ## Play Jeux Services (Android) Connexion silencieuse au lancement, envoi automatique du score en fin de partie et bouton « Classement » sur l'accueil (UI native Play Jeux). Hors connexion ou sans configuration console, tout échoue silencieusement : le jeu reste pleinement jouable. Score soumis : `round((somme des produits réussis) × 30 ÷ secondes par question)`. Prérequis manuels dans [Google Play Console](https://play.google.com/console) : 1. Créer l'app (package `com.livrets.androidApp`). 2. Jouer → Play Jeux Services : créer le jeu, lier l'app Android, ajouter les empreintes SHA-1 debug et release. 3. Créer les 10 leaderboards avec les IDs `questions_5` … `questions_50` (« 5 questions » … « 50 questions »). 4. Ajouter des testeurs Play Jeux Services pour valider la connexion en dev. ``` - [ ] **Step 2: Build complet (régression 3 cibles)** Run: `./gradlew build` Expected: BUILD SUCCESSFUL. - [ ] **Step 3: Vérification manuelle Android (si appareil + console configurés)** Checklist sur appareil Android avec compte Play (testeur PGS requis) : ```bash ./gradlew :androidApp:installDebug ``` 1. Lancement : aucune UI de connexion bloquante. 2. Jouer une partie → écran résultats : ligne « Points classement » cohérente avec la formule. 3. Accueil → « Classement » : l'UI Play Jeux s'ouvre sur le board correspondant au nombre de questions sélectionné. 4. Le score apparaît dans le board Play Jeux. 5. Sans compte / hors connexion : l'app se comporte comme avant (aucun crash, aucune erreur visible). Si la console n'est pas configurée, noter le résultat et passer à l'étape suivante (le comportement silencieux fait partie du spec). - [ ] **Step 4: Commit** ```bash git add README.MD git commit -m "docs: prérequis Play Console pour Play Jeux Services" ```