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

645 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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` :
```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>(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"
```