From 037238561982ba8b2625c682d7e7e04d00394ad4 Mon Sep 17 00:00:00 2001 From: Rahul Tripathi Date: Wed, 13 May 2026 18:27:38 +0530 Subject: [PATCH] feat: add gamification-core module with rule engine skeleton and tests --- gamification-core/build.gradle.kts | 16 ++++ .../gamification/core/GamificationEvent.kt | 27 ++++++ .../core/GamificationRuleEngine.kt | 58 +++++++++++++ .../piramal/gamification/core/RewardRule.kt | 34 ++++++++ .../core/GamificationRuleEngineTest.kt | 84 +++++++++++++++++++ settings.gradle | 1 + 6 files changed, 220 insertions(+) create mode 100644 gamification-core/build.gradle.kts create mode 100644 gamification-core/src/main/kotlin/org/piramal/gamification/core/GamificationEvent.kt create mode 100644 gamification-core/src/main/kotlin/org/piramal/gamification/core/GamificationRuleEngine.kt create mode 100644 gamification-core/src/main/kotlin/org/piramal/gamification/core/RewardRule.kt create mode 100644 gamification-core/src/test/kotlin/org/piramal/gamification/core/GamificationRuleEngineTest.kt diff --git a/gamification-core/build.gradle.kts b/gamification-core/build.gradle.kts new file mode 100644 index 000000000..b72ac360f --- /dev/null +++ b/gamification-core/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + kotlin("jvm") +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +dependencies { + testImplementation(kotlin("test")) +} + +tasks.test { + useJUnitPlatform() +} diff --git a/gamification-core/src/main/kotlin/org/piramal/gamification/core/GamificationEvent.kt b/gamification-core/src/main/kotlin/org/piramal/gamification/core/GamificationEvent.kt new file mode 100644 index 000000000..0c82fdfaa --- /dev/null +++ b/gamification-core/src/main/kotlin/org/piramal/gamification/core/GamificationEvent.kt @@ -0,0 +1,27 @@ +package org.piramal.gamification.core + +/** + * A gamification event triggered by a health-worker action. + * + * Each event corresponds to a concrete workflow step (e.g., household + * registration, ANC visit, immunization) and carries the points earned + * as well as sync metadata for offline-first operation. + * + * @property eventId Unique event identifier (used for idempotent dedup). + * @property workerId The health worker who performed the action. + * @property actionType The type of health action (e.g., "registration", + * "anc_visit", "immunization"). + * @property points Points earned for this event. + * @property localDate ISO date string for daily-cap calculation. + * @property syncStatus Tracks offline-sync lifecycle: "pending" | "synced". + * @property rewardId Stable reward identifier across sync retries. + */ +data class GamificationEvent( + val eventId: String, + val workerId: String, + val actionType: String, + val points: Int, + val localDate: String, + val syncStatus: String = "pending", + val rewardId: String? = null, +) diff --git a/gamification-core/src/main/kotlin/org/piramal/gamification/core/GamificationRuleEngine.kt b/gamification-core/src/main/kotlin/org/piramal/gamification/core/GamificationRuleEngine.kt new file mode 100644 index 000000000..2ad6ed059 --- /dev/null +++ b/gamification-core/src/main/kotlin/org/piramal/gamification/core/GamificationRuleEngine.kt @@ -0,0 +1,58 @@ +package org.piramal.gamification.core + +/** + * Core rule engine for the FLW gamification module. + * + * Processes [GamificationEvent] instances against a worker's current + * [WorkerProgress] and returns the updated progress. All state is + * immutable — the engine returns a new copy on each invocation. + * + * Key behaviours: + * - **Idempotency**: duplicate event IDs are silently accepted without + * double-counting points. + * - **Daily cap**: points exceeding [RewardRule.DAILY_CAP_DEFAULT] for + * a single day are discarded. + * - **Offline safety**: events with any [syncStatus] are tracked in + * [WorkerProgress.acceptedEventIds] so retries after connectivity + * restoration do not double-count. + */ +class GamificationRuleEngine( + private val rules: Map = emptyMap(), +) { + /** + * Apply an event and return the resulting progress. + * + * @param event The incoming gamification event. + * @param progress The worker's current progress snapshot. + * @return Updated progress with points applied (subject to cap and dedup). + */ + fun apply(event: GamificationEvent, progress: WorkerProgress): WorkerProgress { + // Idempotency: skip point award for already-processed events. + if (event.eventId in progress.acceptedEventIds) { + return progress.copy( + acceptedEventIds = progress.acceptedEventIds + event.eventId, + ) + } + + val currentDayPoints = progress.dailyPoints[event.localDate] ?: 0 + val cap = rules[event.actionType]?.dailyCap ?: RewardRule.DAILY_CAP_DEFAULT + + // If the daily cap is already reached, record the event but award no points. + if (currentDayPoints >= cap) { + return progress.copy( + acceptedEventIds = progress.acceptedEventIds + event.eventId, + dailyPoints = progress.dailyPoints, + ) + } + + // Award points up to the remaining daily capacity. + val available = cap - currentDayPoints + val pointsToAdd = minOf(event.points, available) + + return progress.copy( + acceptedEventIds = progress.acceptedEventIds + event.eventId, + dailyPoints = progress.dailyPoints + (event.localDate to currentDayPoints + pointsToAdd), + totalPoints = progress.totalPoints + pointsToAdd, + ) + } +} diff --git a/gamification-core/src/main/kotlin/org/piramal/gamification/core/RewardRule.kt b/gamification-core/src/main/kotlin/org/piramal/gamification/core/RewardRule.kt new file mode 100644 index 000000000..4af187833 --- /dev/null +++ b/gamification-core/src/main/kotlin/org/piramal/gamification/core/RewardRule.kt @@ -0,0 +1,34 @@ +package org.piramal.gamification.core + +/** + * Configuration rule mapping a health-action type to its gamification parameters. + * + * @property actionType The health action this rule applies to. + * @property pointsAwarded Points granted when this action is recorded. + * @property dailyCap Maximum points per day from this action type. + * Applies globally across all action types unless overridden. + * @property badgeId Optional badge awarded on first completion. + */ +data class RewardRule( + val actionType: String, + val pointsAwarded: Int, + val dailyCap: Int = DAILY_CAP_DEFAULT, + val badgeId: String? = null, +) { + companion object { + const val DAILY_CAP_DEFAULT = 100 + } +} + +/** + * Aggregated progress snapshot for a single health worker. + * + * @property acceptedEventIds Set of event IDs already processed (dedup). + * @property dailyPoints Accumulated points per ISO date string. + * @property totalPoints Lifetime points across all days. + */ +data class WorkerProgress( + val acceptedEventIds: Set = emptySet(), + val dailyPoints: Map = emptyMap(), + val totalPoints: Int = 0, +) diff --git a/gamification-core/src/test/kotlin/org/piramal/gamification/core/GamificationRuleEngineTest.kt b/gamification-core/src/test/kotlin/org/piramal/gamification/core/GamificationRuleEngineTest.kt new file mode 100644 index 000000000..5707c5d82 --- /dev/null +++ b/gamification-core/src/test/kotlin/org/piramal/gamification/core/GamificationRuleEngineTest.kt @@ -0,0 +1,84 @@ +package org.piramal.gamification.core + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Unit tests for [GamificationRuleEngine]. + * + * Covers: + * - Duplicate event ID handling (dedup) + * - Daily cap enforcement + * - Offline event tracking + * - Stable reward ID across sync retries + */ +class GamificationRuleEngineTest { + + private val engine = GamificationRuleEngine() + + @Test + fun `duplicate event ID is idempotent`() { + val event = GamificationEvent( + eventId = "evt-001", + workerId = "worker-1", + actionType = "registration", + points = 10, + localDate = "2026-05-07", + ) + + val p1 = engine.apply(event, WorkerProgress()) + val p2 = engine.apply(event, p1) // same event repeated + + assertEquals(10, p1.totalPoints, "first application should award points") + assertEquals(p1.totalPoints, p2.totalPoints, "duplicate should not add points") + assertTrue(p2.acceptedEventIds.contains("evt-001")) + } + + @Test + fun `daily cap stops excess points`() { + val big = GamificationEvent("evt-001", "w1", "visit", 60, "2026-05-07") + val alsoBig = GamificationEvent("evt-002", "w1", "visit", 60, "2026-05-07") + + val p1 = engine.apply(big, WorkerProgress()) + val p2 = engine.apply(alsoBig, p1) + + assertEquals(100, p2.totalPoints, "total should be capped at 100") + assertEquals(60, p2.dailyPoints["2026-05-07"], "60 of 60 second-event points blocked") + } + + @Test + fun `offline event is tracked before sync`() { + val offline = GamificationEvent( + eventId = "evt-off-1", + workerId = "w1", + actionType = "screening", + points = 20, + localDate = "2026-05-07", + syncStatus = "pending", + ) + + val result = engine.apply(offline, WorkerProgress()) + + assertEquals(20, result.totalPoints) + assertTrue(result.acceptedEventIds.contains("evt-off-1")) + } + + @Test + fun `synced event retry with same reward ID is stable`() { + val synced = GamificationEvent( + eventId = "evt-sync-1", + workerId = "w1", + actionType = "immunization", + points = 15, + localDate = "2026-05-07", + syncStatus = "synced", + rewardId = "reward-001", + ) + + val p1 = engine.apply(synced, WorkerProgress()) + val p2 = engine.apply(synced, p1) + + assertEquals(p1.totalPoints, p2.totalPoints, "retry must not double-count") + } +} diff --git a/settings.gradle b/settings.gradle index 7dc7adf05..052034439 100644 --- a/settings.gradle +++ b/settings.gradle @@ -15,3 +15,4 @@ dependencyResolutionManagement { } rootProject.name = "Sakhi" include ':app' +include ':gamification-core'