diff --git a/docs/superpowers/plans/2026-08-28-play-games-services.md b/docs/superpowers/plans/2026-08-28-play-games-services.md new file mode 100644 index 0000000..00f6d8f --- /dev/null +++ b/docs/superpowers/plans/2026-08-28-play-games-services.md @@ -0,0 +1,644 @@ +# 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" +```