Files
livrets/docs/superpowers/plans/2026-08-28-play-games-services.md
T

23 KiB
Raw Blame History

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 :

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<AnswerRecord>) =
        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 :

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 :

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 :

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
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 :

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
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 :

@Composable
fun HomeScreen(
    onPlay: (GameConfig) -> Unit,
    onHistory: () -> Unit,
    onLeaderboard: (GameConfig) -> Unit,
) {

et remplacer la fin du Column (bouton Jouer + Historique) par :

        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 :

import com.livrets.games.leaderboardScore

et insérer sous le Text « Temps moyen de réponse » (ligne 55) :

        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 :

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>(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
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 :
gms-games-v2 = "22.0.0"
  • Dans la section [libraries], ajouter :
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 :

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 :

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 :

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
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 ») :

## 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) :

./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
git add README.MD
git commit -m "docs: prérequis Play Console pour Play Jeux Services"