-
Notifications
You must be signed in to change notification settings - Fork 28
feat: add gamification-core module with rule engine skeleton and tests #453
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| plugins { | ||
| kotlin("jvm") | ||
| } | ||
|
|
||
| java { | ||
| sourceCompatibility = JavaVersion.VERSION_17 | ||
| targetCompatibility = JavaVersion.VERSION_17 | ||
| } | ||
|
|
||
| dependencies { | ||
| testImplementation(kotlin("test")) | ||
| } | ||
|
|
||
| tasks.test { | ||
| useJUnitPlatform() | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<String, RewardRule> = 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) | ||
|
Comment on lines
+48
to
+50
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use rule-configured points instead of trusting event payload points. Line 50 awards from Proposed fix- val available = cap - currentDayPoints
- val pointsToAdd = minOf(event.points, available)
+ val configuredPoints = rules[event.actionType]?.pointsAwarded ?: event.points
+ val available = cap - currentDayPoints
+ val pointsToAdd = minOf(configuredPoints, available)🤖 Prompt for AI Agents |
||
|
|
||
| return progress.copy( | ||
| acceptedEventIds = progress.acceptedEventIds + event.eventId, | ||
| dailyPoints = progress.dailyPoints + (event.localDate to currentDayPoints + pointsToAdd), | ||
| totalPoints = progress.totalPoints + pointsToAdd, | ||
| ) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } | ||
|
Comment on lines
+12
to
+20
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add invariants for non-negative rule values.
Proposed fix data class RewardRule(
val actionType: String,
val pointsAwarded: Int,
val dailyCap: Int = DAILY_CAP_DEFAULT,
val badgeId: String? = null,
) {
+ init {
+ require(pointsAwarded >= 0) { "pointsAwarded must be >= 0" }
+ require(dailyCap >= 0) { "dailyCap must be >= 0" }
+ }
+
companion object {
const val DAILY_CAP_DEFAULT = 100
}
}🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| /** | ||
| * 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<String> = emptySet(), | ||
| val dailyPoints: Map<String, Int> = emptyMap(), | ||
| val totalPoints: Int = 0, | ||
| ) | ||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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") | ||||||||||
|
Comment on lines
+46
to
+47
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Daily-cap assertion is internally inconsistent. After two 60-point events on the same date, asserting Proposed fix- assertEquals(60, p2.dailyPoints["2026-05-07"], "60 of 60 second-event points blocked")
+ assertEquals(100, p2.dailyPoints["2026-05-07"], "40 of 60 second-event points should be blocked by cap")📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
| } | ||||||||||
|
|
||||||||||
| @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") | ||||||||||
| } | ||||||||||
|
Comment on lines
+68
to
+83
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test does not actually verify rewardId retry behavior. It replays the exact same 🤖 Prompt for AI Agents |
||||||||||
| } | ||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,3 +15,4 @@ dependencyResolutionManagement { | |
| } | ||
| rootProject.name = "Sakhi" | ||
| include ':app' | ||
| include ':gamification-core' | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reject negative event points at model boundary.
pointsis trusted input and currently can be negative (Line 23), which allows decreasingtotalPointsin the engine.Proposed fix
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, -) +) { + init { + require(points >= 0) { "points must be >= 0" } + } +}🤖 Prompt for AI Agents