From 24dbd24eb9a91589d93369ae5c9072325be5e009 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 11:20:35 +0000 Subject: [PATCH 1/8] feat: track unique targets hit and kills for every stall - Updated `Stall` model to include `uniqueTargetIds` and `kills`. - Modified `MainViewModel` to attribute hits (including AOE and puddles) and fatal shots to the source stall. - Updated `StallConsole` UI to display stats for selected stalls. - Added unit test to verify tracking logic. Co-authored-by: gundalow <940557+gundalow@users.noreply.github.com> --- .../com/messark/hawkerrush/MainViewModel.kt | 74 +++++++++--- .../messark/hawkerrush/model/GameModels.kt | 10 +- .../hawkerrush/ui/components/StallConsole.kt | 7 +- .../com/messark/hawkerrush/StallStatsTest.kt | 110 ++++++++++++++++++ 4 files changed, 178 insertions(+), 23 deletions(-) create mode 100644 app/src/test/java/com/messark/hawkerrush/StallStatsTest.kt diff --git a/app/src/main/java/com/messark/hawkerrush/MainViewModel.kt b/app/src/main/java/com/messark/hawkerrush/MainViewModel.kt index 358eb62..62e749e 100644 --- a/app/src/main/java/com/messark/hawkerrush/MainViewModel.kt +++ b/app/src/main/java/com/messark/hawkerrush/MainViewModel.kt @@ -306,6 +306,8 @@ class MainViewModel @JvmOverloads constructor( private fun handleEnemyMovement(state: GameState, currentTimeMs: Long): Pair> { var mutableState = state + val affectingStalls = mutableMapOf>() + val updatedEnemies = state.enemies.mapNotNull { enemy -> if (enemy.isDead) return@mapNotNull null @@ -347,14 +349,16 @@ class MainViewModel @JvmOverloads constructor( } var speedMultiplier = 1.0f - val inPuddle = state.puddles.any { puddle -> - axialDistance(enemy.position, puddle.position) < 0.8 - } - if (inPuddle) { - speedMultiplier = when (enemy.type) { - EnemyType.DELIVERY_RIDER -> 0.2f // double slow (80% reduction) - EnemyType.AUNTIE -> 0.8f // half slow (20% reduction) - else -> 0.6f // normal slow (40% reduction) + state.puddles.forEach { puddle -> + if (axialDistance(enemy.position, puddle.position) < 0.8) { + speedMultiplier = when (enemy.type) { + EnemyType.DELIVERY_RIDER -> 0.2f // double slow (80% reduction) + EnemyType.AUNTIE -> 0.8f // half slow (20% reduction) + else -> 0.6f // normal slow (40% reduction) + } + puddle.sourceStallCoord?.let { coord -> + affectingStalls.getOrPut(coord) { mutableSetOf() }.add(enemy.id) + } } } @@ -411,6 +415,18 @@ class MainViewModel @JvmOverloads constructor( ) } } + + if (affectingStalls.isNotEmpty()) { + val updatedHexes = mutableState.hexes.toMutableMap() + affectingStalls.forEach { (coord, enemyIds) -> + updatedHexes[coord]?.stall?.let { stall -> + val newTargetIds = stall.uniqueTargetIds + enemyIds + updatedHexes[coord] = updatedHexes[coord]!!.copy(stall = stall.copy(uniqueTargetIds = newTargetIds)) + } + } + mutableState = mutableState.copy(hexes = updatedHexes) + } + return Pair(mutableState, updatedEnemies) } @@ -445,7 +461,8 @@ class MainViewModel @JvmOverloads constructor( targetPosition = target.position, damage = stall.damage, color = stall.color, - sourceStallType = StallType.CHICKEN_RICE + sourceStallType = StallType.CHICKEN_RICE, + sourceStallCoord = coord )) } StallType.TEH_TARIK -> { @@ -453,7 +470,8 @@ class MainViewModel @JvmOverloads constructor( id = UUID.randomUUID().toString(), position = target.position, spawnTimeMs = currentTimeMs, - durationMs = stall.effectDurationMs + durationMs = stall.effectDurationMs, + sourceStallCoord = coord )) } StallType.SATAY -> { @@ -472,7 +490,8 @@ class MainViewModel @JvmOverloads constructor( aoeRadius = stall.aoeRadius, isArc = true, startPosition = stallPos, - sourceStallType = StallType.SATAY + sourceStallType = StallType.SATAY, + sourceStallCoord = coord )) } StallType.ICE_KACHANG -> { @@ -485,7 +504,8 @@ class MainViewModel @JvmOverloads constructor( color = stall.color, isFreeze = true, freezeDurationMs = stall.freezeDurationMs, - sourceStallType = StallType.ICE_KACHANG + sourceStallType = StallType.ICE_KACHANG, + sourceStallCoord = coord )) } StallType.DURIAN -> { @@ -497,7 +517,8 @@ class MainViewModel @JvmOverloads constructor( damage = stall.damage, color = stall.color, aoeRadius = stall.aoeRadius, - sourceStallType = StallType.DURIAN + sourceStallType = StallType.DURIAN, + sourceStallCoord = coord )) } } @@ -564,15 +585,18 @@ class MainViewModel @JvmOverloads constructor( var updatedGold = state.gold var updatedScore = state.score + val updatedHexes = state.hexes.toMutableMap() val finalEnemies = state.enemies.map { enemy -> val hits = hitEnemiesDetails[enemy.id] if (hits != null) { - var totalDamage = 0 + var currentHealth = enemy.health var maxFreezeDuration = enemy.freezeDurationMs var speedBoostDuration = enemy.speedBoostDurationMs hits.forEach { proj -> + if (currentHealth <= 0) return@forEach + var damage = proj.damage.toFloat() var freezeDuration = proj.freezeDurationMs @@ -593,12 +617,25 @@ class MainViewModel @JvmOverloads constructor( else -> {} } - totalDamage += damage.toInt() + val damageDealt = damage.toInt() + currentHealth = Math.max(0, currentHealth - damageDealt) maxFreezeDuration = Math.max(maxFreezeDuration, freezeDuration) + + // Track hit and kill + proj.sourceStallCoord?.let { coord -> + updatedHexes[coord]?.stall?.let { stall -> + val isKill = currentHealth <= 0 + val newTargetIds = stall.uniqueTargetIds + enemy.id + val newKills = if (isKill) stall.kills + 1 else stall.kills + updatedHexes[coord] = updatedHexes[coord]!!.copy(stall = stall.copy( + uniqueTargetIds = newTargetIds, + kills = newKills + )) + } + } } - val newHealth = Math.max(0, enemy.health - totalDamage) - if (newHealth <= 0) { + if (currentHealth <= 0) { updatedGold += enemy.reward updatedScore += enemy.reward val currentTime = System.currentTimeMillis() @@ -610,12 +647,13 @@ class MainViewModel @JvmOverloads constructor( } enemy.copy(health = 0, isDead = true) } else { - enemy.copy(health = newHealth, freezeDurationMs = maxFreezeDuration, speedBoostDurationMs = speedBoostDuration) + enemy.copy(health = currentHealth, freezeDurationMs = maxFreezeDuration, speedBoostDurationMs = speedBoostDuration) } } else enemy }.filter { !it.isDead } return state.copy( + hexes = updatedHexes, enemies = finalEnemies, projectiles = finalProjectiles, visualEffects = newVisualEffects, diff --git a/app/src/main/java/com/messark/hawkerrush/model/GameModels.kt b/app/src/main/java/com/messark/hawkerrush/model/GameModels.kt index c0d994c..47cc39f 100644 --- a/app/src/main/java/com/messark/hawkerrush/model/GameModels.kt +++ b/app/src/main/java/com/messark/hawkerrush/model/GameModels.kt @@ -56,7 +56,9 @@ data class Stall( val targetMode: TargetMode = TargetMode.FIRST, val aoeRadius: Float = 1.0f, val effectDurationMs: Long = 3000L, - val freezeDurationMs: Long = 500L + val freezeDurationMs: Long = 500L, + val uniqueTargetIds: Set = emptySet(), + val kills: Int = 0 ) { fun getUpgradeBenefit(category: String, level: Int, baseStall: Stall): String { return when (category) { @@ -134,7 +136,8 @@ data class Projectile( val freezeDurationMs: Long = 0L, val isArc: Boolean = false, val startPosition: PreciseAxialCoordinate? = null, - val sourceStallType: StallType? = null + val sourceStallType: StallType? = null, + val sourceStallCoord: AxialCoordinate? = null ) enum class VisualEffectType { @@ -145,7 +148,8 @@ data class StickyPuddle( val id: String, val position: PreciseAxialCoordinate, val spawnTimeMs: Long, - val durationMs: Long = 3000L + val durationMs: Long = 3000L, + val sourceStallCoord: AxialCoordinate? = null ) data class VisualEffect( diff --git a/app/src/main/java/com/messark/hawkerrush/ui/components/StallConsole.kt b/app/src/main/java/com/messark/hawkerrush/ui/components/StallConsole.kt index 6057c4a..cc21853 100644 --- a/app/src/main/java/com/messark/hawkerrush/ui/components/StallConsole.kt +++ b/app/src/main/java/com/messark/hawkerrush/ui/components/StallConsole.kt @@ -36,7 +36,7 @@ fun StallConsole( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Column { + Column(modifier = Modifier.weight(1f)) { Text(text = stall.name.uppercase(), color = Color.White, fontSize = 14.sp) if (stall.upgrades.isEmpty()) { Text(text = "No upgrades", color = Color.Gray, fontSize = 10.sp) @@ -48,7 +48,10 @@ fun StallConsole( Text(text = upgradeText, color = Color.Gray, fontSize = 10.sp) } } - Text(text = "Target: ${stall.targetMode.name}", color = Color.Cyan, fontSize = 12.sp, modifier = Modifier.clickable { onCycleTarget() }) + Column(horizontalAlignment = Alignment.End) { + Text(text = "Target: ${stall.targetMode.name}", color = Color.Cyan, fontSize = 12.sp, modifier = Modifier.clickable { onCycleTarget() }) + Text(text = "Hits: ${stall.uniqueTargetIds.size} | Kills: ${stall.kills}", color = Color.Green, fontSize = 10.sp) + } } Row( diff --git a/app/src/test/java/com/messark/hawkerrush/StallStatsTest.kt b/app/src/test/java/com/messark/hawkerrush/StallStatsTest.kt new file mode 100644 index 0000000..2f3b433 --- /dev/null +++ b/app/src/test/java/com/messark/hawkerrush/StallStatsTest.kt @@ -0,0 +1,110 @@ +package com.messark.hawkerrush + +import android.app.Application +import androidx.compose.ui.graphics.Color +import com.messark.hawkerrush.model.* +import com.messark.hawkerrush.utils.GameStateRepository +import com.messark.hawkerrush.utils.SettingsRepository +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.setMain +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import java.util.* + +@OptIn(ExperimentalCoroutinesApi::class) +class StallStatsTest { + private val testDispatcher = StandardTestDispatcher() + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + } + + @Test + fun `stalls track unique hits and kills`() { + val application = mockk(relaxed = true) + val settingsRepository = mockk() + val gameStateRepository = mockk(relaxed = true) + every { settingsRepository.settingsFlow } returns kotlinx.coroutines.flow.flowOf(Settings()) + + val viewModel = MainViewModel(application, settingsRepository, gameStateRepository) + + // Initial state with one stall and one enemy + val stallCoord = AxialCoordinate(0, 0) + val enemyId = "enemy1" + val stall = Stall( + id = "s1", + name = "Chicken Rice", + cost = 100, + color = Color.Yellow, + stallType = StallType.CHICKEN_RICE, + damage = 50 + ) + + val enemy = Enemy( + id = enemyId, + health = 100, + maxHealth = 100, + position = PreciseAxialCoordinate(1f, 0f), // within range (range is 4f) + path = listOf(AxialCoordinate(0, 0), AxialCoordinate(1, 0), AxialCoordinate(2, 0)) + ) + + // We use a private method via reflection or just trigger the game loop? + // Let's use reflection to call updateGame if it's private, but wait, + // handleProjectiles is what we really want to test. + + // Let's manually construct a state and call handleProjectiles + val state = GameState( + hexes = mapOf(stallCoord to HexTile(stallCoord, TileType.FLOOR, stall)), + enemies = listOf(enemy), + projectiles = listOf( + Projectile( + id = "p1", + position = PreciseAxialCoordinate(1f, 0f), // already at enemy + targetEnemyId = enemyId, + targetPosition = PreciseAxialCoordinate(1f, 0f), + damage = 50, + color = Color.Yellow, + sourceStallCoord = stallCoord + ) + ) + ) + + // Access handleProjectiles using reflection since it's private + val method = MainViewModel::class.java.getDeclaredMethod("handleProjectiles", GameState::class.java, Long::class.java) + method.isAccessible = true + + // 1. First hit + var newState = method.invoke(viewModel, state, 1000L) as GameState + var updatedStall = newState.hexes[stallCoord]?.stall + assertEquals(1, updatedStall?.uniqueTargetIds?.size) + assertEquals(0, updatedStall?.kills) + assertEquals(50, newState.enemies[0].health) + + // 2. Second hit on SAME enemy (should NOT increment unique hits, but should kill and increment kills) + val state2 = newState.copy( + projectiles = listOf( + Projectile( + id = "p2", + position = PreciseAxialCoordinate(1f, 0f), + targetEnemyId = enemyId, + targetPosition = PreciseAxialCoordinate(1f, 0f), + damage = 50, + color = Color.Yellow, + sourceStallCoord = stallCoord + ) + ) + ) + + newState = method.invoke(viewModel, state2, 1100L) as GameState + updatedStall = newState.hexes[stallCoord]?.stall + assertEquals(1, updatedStall?.uniqueTargetIds?.size) // Still 1 + assertEquals(1, updatedStall?.kills) // Now 1 + assertEquals(0, newState.enemies.size) // Dead + } +} From 00a773ec49ffdf7c13532120080565e38060c354 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 13:39:19 +0000 Subject: [PATCH 2/8] chore: acknowledge upstream PR comment and finalize stall stats feature Co-authored-by: gundalow <940557+gundalow@users.noreply.github.com> From feab645838b717422c4563bfe7139b950310e54f Mon Sep 17 00:00:00 2001 From: candour <4670475+candour@users.noreply.github.com> Date: Mon, 20 Apr 2026 08:19:38 +0800 Subject: [PATCH 3/8] Update app/src/main/java/com/messark/hawkerrush/ui/components/StallConsole.kt Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../java/com/messark/hawkerrush/ui/components/StallConsole.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/messark/hawkerrush/ui/components/StallConsole.kt b/app/src/main/java/com/messark/hawkerrush/ui/components/StallConsole.kt index cc21853..ce6c194 100644 --- a/app/src/main/java/com/messark/hawkerrush/ui/components/StallConsole.kt +++ b/app/src/main/java/com/messark/hawkerrush/ui/components/StallConsole.kt @@ -50,7 +50,7 @@ fun StallConsole( } Column(horizontalAlignment = Alignment.End) { Text(text = "Target: ${stall.targetMode.name}", color = Color.Cyan, fontSize = 12.sp, modifier = Modifier.clickable { onCycleTarget() }) - Text(text = "Hits: ${stall.uniqueTargetIds.size} | Kills: ${stall.kills}", color = Color.Green, fontSize = 10.sp) + Text(text = "Targets: ${stall.uniqueTargetIds.size} | Kills: ${stall.kills}", color = Color.Green, fontSize = 10.sp) } } From 981de47f0da521de20b24207ea0dcca697cef691 Mon Sep 17 00:00:00 2001 From: candour <4670475+candour@users.noreply.github.com> Date: Mon, 20 Apr 2026 08:20:04 +0800 Subject: [PATCH 4/8] Update app/src/test/java/com/messark/hawkerrush/StallStatsTest.kt Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../java/com/messark/hawkerrush/StallStatsTest.kt | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/com/messark/hawkerrush/StallStatsTest.kt b/app/src/test/java/com/messark/hawkerrush/StallStatsTest.kt index 2f3b433..ce274a1 100644 --- a/app/src/test/java/com/messark/hawkerrush/StallStatsTest.kt +++ b/app/src/test/java/com/messark/hawkerrush/StallStatsTest.kt @@ -20,11 +20,23 @@ import java.util.* class StallStatsTest { private val testDispatcher = StandardTestDispatcher() - @Before +import kotlinx.coroutines.test.resetMain +import org.junit.After + +class StallStatsTest { + private val testDispatcher = StandardTestDispatcher() + + `@Before` fun setup() { Dispatchers.setMain(testDispatcher) } + `@After` + fun tearDown() { + Dispatchers.resetMain() + } +} + @Test fun `stalls track unique hits and kills`() { val application = mockk(relaxed = true) From 637d2bec64e8898934700b6ff91f123489a39fb5 Mon Sep 17 00:00:00 2001 From: John Barker Date: Mon, 20 Apr 2026 06:28:10 +0100 Subject: [PATCH 5/8] fix: resolve syntax errors and apply non-null assertions in StallStatsTest --- .../com/messark/hawkerrush/StallStatsTest.kt | 36 +++++++++---------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/app/src/test/java/com/messark/hawkerrush/StallStatsTest.kt b/app/src/test/java/com/messark/hawkerrush/StallStatsTest.kt index ce274a1..1b4893a 100644 --- a/app/src/test/java/com/messark/hawkerrush/StallStatsTest.kt +++ b/app/src/test/java/com/messark/hawkerrush/StallStatsTest.kt @@ -10,7 +10,9 @@ import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test @@ -20,22 +22,15 @@ import java.util.* class StallStatsTest { private val testDispatcher = StandardTestDispatcher() -import kotlinx.coroutines.test.resetMain -import org.junit.After - -class StallStatsTest { - private val testDispatcher = StandardTestDispatcher() - - `@Before` + @Before fun setup() { Dispatchers.setMain(testDispatcher) } - `@After` + @After fun tearDown() { Dispatchers.resetMain() } -} @Test fun `stalls track unique hits and kills`() { @@ -66,10 +61,6 @@ class StallStatsTest { path = listOf(AxialCoordinate(0, 0), AxialCoordinate(1, 0), AxialCoordinate(2, 0)) ) - // We use a private method via reflection or just trigger the game loop? - // Let's use reflection to call updateGame if it's private, but wait, - // handleProjectiles is what we really want to test. - // Let's manually construct a state and call handleProjectiles val state = GameState( hexes = mapOf(stallCoord to HexTile(stallCoord, TileType.FLOOR, stall)), @@ -88,14 +79,19 @@ class StallStatsTest { ) // Access handleProjectiles using reflection since it's private - val method = MainViewModel::class.java.getDeclaredMethod("handleProjectiles", GameState::class.java, Long::class.java) + // Use javaPrimitiveType for the long parameter + val method = MainViewModel::class.java.getDeclaredMethod( + "handleProjectiles", + GameState::class.java, + Long::class.javaPrimitiveType !! + ) method.isAccessible = true // 1. First hit var newState = method.invoke(viewModel, state, 1000L) as GameState - var updatedStall = newState.hexes[stallCoord]?.stall - assertEquals(1, updatedStall?.uniqueTargetIds?.size) - assertEquals(0, updatedStall?.kills) + var updatedStall = newState.hexes[stallCoord]?.stall!! + assertEquals(1, updatedStall.uniqueTargetIds.size) + assertEquals(0, updatedStall.kills) assertEquals(50, newState.enemies[0].health) // 2. Second hit on SAME enemy (should NOT increment unique hits, but should kill and increment kills) @@ -114,9 +110,9 @@ class StallStatsTest { ) newState = method.invoke(viewModel, state2, 1100L) as GameState - updatedStall = newState.hexes[stallCoord]?.stall - assertEquals(1, updatedStall?.uniqueTargetIds?.size) // Still 1 - assertEquals(1, updatedStall?.kills) // Now 1 + updatedStall = newState.hexes[stallCoord]?.stall!! + assertEquals(1, updatedStall.uniqueTargetIds.size) // Still 1 + assertEquals(1, updatedStall.kills) // Now 1 assertEquals(0, newState.enemies.size) // Dead } } From 46109ef8f0cc4a6a57741215eea8273b291ee997 Mon Sep 17 00:00:00 2001 From: John Barker Date: Mon, 20 Apr 2026 06:46:10 +0100 Subject: [PATCH 6/8] refactor: address PR #52 review comments - Fixed incorrect attribution logic by using unique sourceStallId. - Refactored StallStatsTest to use internal updateGame entry point instead of reflection. - Strengthened test assertions to verify specific enemyId tracking. - Added comprehensive KDocs to Stall, Projectile, and StickyPuddle models and key ViewModel methods. - Fixed 'Immutable Release Error' in CI by keeping releases as drafts during asset upload. --- .github/workflows/android.yml | 13 +++ .../com/messark/hawkerrush/MainViewModel.kt | 80 ++++++++++++----- .../messark/hawkerrush/model/GameModels.kt | 61 ++++++++++++- .../com/messark/hawkerrush/StallStatsTest.kt | 89 +++++++++++++++---- 4 files changed, 202 insertions(+), 41 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 78fe5ce..a662dcb 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -41,8 +41,21 @@ jobs: prerelease: "${{ github.event_name == 'pull_request_target' }}" generate_release_notes: "${{ github.event_name == 'push' }}" make_latest: "${{ github.event_name == 'push' }}" + draft: true files: app/build/outputs/apk/release/app-release.apk + - name: Publish Release + if: github.event_name == 'push' + uses: actions/github-script@v7 + with: + script: | + github.rest.repos.updateRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: ${{ steps.create_release.outputs.id }}, + draft: false + }) + - name: Comment PR with Download Link if: github.event_name == 'pull_request_target' uses: actions/github-script@v7 diff --git a/app/src/main/java/com/messark/hawkerrush/MainViewModel.kt b/app/src/main/java/com/messark/hawkerrush/MainViewModel.kt index 62e749e..96f7a86 100644 --- a/app/src/main/java/com/messark/hawkerrush/MainViewModel.kt +++ b/app/src/main/java/com/messark/hawkerrush/MainViewModel.kt @@ -17,7 +17,7 @@ class MainViewModel @JvmOverloads constructor( private val settingsRepository: SettingsRepository = SettingsRepository(application), private val gameStateRepository: GameStateRepository = GameStateRepository(application) ) : AndroidViewModel(application) { - private val _gameState = MutableStateFlow(GameState()) + internal val _gameState = MutableStateFlow(GameState()) val gameState: StateFlow = _gameState.asStateFlow() private val _logoVisible = MutableStateFlow(true) @@ -217,7 +217,12 @@ class MainViewModel @JvmOverloads constructor( } } - private fun updateGame(currentTimeMs: Long) { + /** + * Updates game state by advancing spawning, movement, and combat. + * + * @param currentTimeMs Current game time in milliseconds. + */ + internal fun updateGame(currentTimeMs: Long) { _gameState.update { state -> var newState = state @@ -304,9 +309,16 @@ class MainViewModel @JvmOverloads constructor( return state } + /** + * Handles movement for all active enemies and applies puddle effects. + * + * @param state Current game state. + * @param currentTimeMs Current game time. + * @return Updated state and list of enemies. + */ private fun handleEnemyMovement(state: GameState, currentTimeMs: Long): Pair> { var mutableState = state - val affectingStalls = mutableMapOf>() + val affectingStalls = mutableMapOf, MutableSet>() val updatedEnemies = state.enemies.mapNotNull { enemy -> if (enemy.isDead) return@mapNotNull null @@ -356,8 +368,8 @@ class MainViewModel @JvmOverloads constructor( EnemyType.AUNTIE -> 0.8f // half slow (20% reduction) else -> 0.6f // normal slow (40% reduction) } - puddle.sourceStallCoord?.let { coord -> - affectingStalls.getOrPut(coord) { mutableSetOf() }.add(enemy.id) + if (puddle.sourceStallCoord != null && puddle.sourceStallId != null) { + affectingStalls.getOrPut(puddle.sourceStallCoord to puddle.sourceStallId) { mutableSetOf() }.add(enemy.id) } } } @@ -418,10 +430,13 @@ class MainViewModel @JvmOverloads constructor( if (affectingStalls.isNotEmpty()) { val updatedHexes = mutableState.hexes.toMutableMap() - affectingStalls.forEach { (coord, enemyIds) -> + affectingStalls.forEach { (source, enemyIds) -> + val (coord, stallId) = source updatedHexes[coord]?.stall?.let { stall -> - val newTargetIds = stall.uniqueTargetIds + enemyIds - updatedHexes[coord] = updatedHexes[coord]!!.copy(stall = stall.copy(uniqueTargetIds = newTargetIds)) + if (stall.id == stallId) { + val newTargetIds = stall.uniqueTargetIds + enemyIds + updatedHexes[coord] = updatedHexes[coord]!!.copy(stall = stall.copy(uniqueTargetIds = newTargetIds)) + } } } mutableState = mutableState.copy(hexes = updatedHexes) @@ -430,6 +445,13 @@ class MainViewModel @JvmOverloads constructor( return Pair(mutableState, updatedEnemies) } + /** + * Checks all stalls to see if they are ready to fire and creates projectiles/puddles. + * + * @param state Current game state. + * @param currentTimeMs Current game time. + * @return Updated state with new projectiles/puddles. + */ private fun handleStallFiring(state: GameState, currentTimeMs: Long): GameState { val newProjectiles = state.projectiles.toMutableList() val newPuddles = state.puddles.toMutableList() @@ -462,7 +484,8 @@ class MainViewModel @JvmOverloads constructor( damage = stall.damage, color = stall.color, sourceStallType = StallType.CHICKEN_RICE, - sourceStallCoord = coord + sourceStallCoord = coord, + sourceStallId = stall.id )) } StallType.TEH_TARIK -> { @@ -471,7 +494,8 @@ class MainViewModel @JvmOverloads constructor( position = target.position, spawnTimeMs = currentTimeMs, durationMs = stall.effectDurationMs, - sourceStallCoord = coord + sourceStallCoord = coord, + sourceStallId = stall.id )) } StallType.SATAY -> { @@ -491,7 +515,8 @@ class MainViewModel @JvmOverloads constructor( isArc = true, startPosition = stallPos, sourceStallType = StallType.SATAY, - sourceStallCoord = coord + sourceStallCoord = coord, + sourceStallId = stall.id )) } StallType.ICE_KACHANG -> { @@ -505,7 +530,8 @@ class MainViewModel @JvmOverloads constructor( isFreeze = true, freezeDurationMs = stall.freezeDurationMs, sourceStallType = StallType.ICE_KACHANG, - sourceStallCoord = coord + sourceStallCoord = coord, + sourceStallId = stall.id )) } StallType.DURIAN -> { @@ -518,7 +544,8 @@ class MainViewModel @JvmOverloads constructor( color = stall.color, aoeRadius = stall.aoeRadius, sourceStallType = StallType.DURIAN, - sourceStallCoord = coord + sourceStallCoord = coord, + sourceStallId = stall.id )) } } @@ -529,6 +556,14 @@ class MainViewModel @JvmOverloads constructor( return state.copy(hexes = updatedHexes, projectiles = newProjectiles, puddles = newPuddles) } + /** + * Updates projectile positions and handles impacts with enemies. + * Also attributes hits and kills to the source stalls. + * + * @param state Current game state. + * @param currentTimeMs Current game time. + * @return Updated state after projectile processing. + */ private fun handleProjectiles(state: GameState, currentTimeMs: Long): GameState { val finalProjectiles = mutableListOf() val hitEnemiesDetails = mutableMapOf>() @@ -622,15 +657,18 @@ class MainViewModel @JvmOverloads constructor( maxFreezeDuration = Math.max(maxFreezeDuration, freezeDuration) // Track hit and kill - proj.sourceStallCoord?.let { coord -> + if (proj.sourceStallCoord != null && proj.sourceStallId != null) { + val coord = proj.sourceStallCoord updatedHexes[coord]?.stall?.let { stall -> - val isKill = currentHealth <= 0 - val newTargetIds = stall.uniqueTargetIds + enemy.id - val newKills = if (isKill) stall.kills + 1 else stall.kills - updatedHexes[coord] = updatedHexes[coord]!!.copy(stall = stall.copy( - uniqueTargetIds = newTargetIds, - kills = newKills - )) + if (stall.id == proj.sourceStallId) { + val isKill = currentHealth <= 0 + val newTargetIds = stall.uniqueTargetIds + enemy.id + val newKills = if (isKill) stall.kills + 1 else stall.kills + updatedHexes[coord] = updatedHexes[coord]!!.copy(stall = stall.copy( + uniqueTargetIds = newTargetIds, + kills = newKills + )) + } } } } diff --git a/app/src/main/java/com/messark/hawkerrush/model/GameModels.kt b/app/src/main/java/com/messark/hawkerrush/model/GameModels.kt index 47cc39f..c862a12 100644 --- a/app/src/main/java/com/messark/hawkerrush/model/GameModels.kt +++ b/app/src/main/java/com/messark/hawkerrush/model/GameModels.kt @@ -38,6 +38,31 @@ enum class TargetMode { FIRST, CLOSEST, STRONGEST, WEAKEST } +/** + * Represents a hawker stall (tower) in the game. + * Stalls can be placed on the board to attack enemies. + * + * @property id Unique identifier for this specific stall instance. + * @property name Display name of the stall. + * @property cost Gold cost to purchase the stall. + * @property color Color used for the stall's projectile and UI elements. + * @property range Attack range in grid units. + * @property damage Damage dealt per hit. + * @property fireRateMs Time between shots in milliseconds. + * @property lastFiredMs Timestamp of the last shot fired. + * @property stallType The type of stall, determining its behavior. + * @property rotation Rotation angle for direction-based attacks. + * @property description Short flavor text and behavior summary. + * @property upgradeCount Total number of upgrades applied. + * @property upgrades Map of specific upgrade categories to their levels. + * @property totalInvestment Total gold spent on this stall (cost + upgrades). + * @property targetMode Strategy used to select which enemy to attack. + * @property aoeRadius Radius for area-of-effect damage. + * @property effectDurationMs Duration of secondary effects (e.g. puddles). + * @property freezeDurationMs Duration of freeze effect in milliseconds. + * @property uniqueTargetIds Set of enemy IDs that this stall has hit. + * @property kills Total number of enemies killed by this stall. + */ data class Stall( val id: String, val name: String, @@ -122,6 +147,26 @@ data class Enemy( val isFacingLeft: Boolean = false ) +/** + * Represents a projectile fired by a stall. + * + * @property id Unique identifier for the projectile. + * @property position Current precise axial coordinate. + * @property lastPosition Previous position for interpolation. + * @property targetEnemyId ID of the target enemy, if any. + * @property targetPosition Target coordinates. + * @property damage Damage to deal on impact. + * @property speed Movement speed in grid units per tick. + * @property color Color of the projectile. + * @property isFreeze Whether this projectile freezes enemies. + * @property aoeRadius Radius of area-of-effect damage. + * @property freezeDurationMs Duration of freeze effect. + * @property isArc Whether the projectile follows an arc path. + * @property startPosition Initial firing position. + * @property sourceStallType Type of the stall that fired this. + * @property sourceStallCoord Coordinate of the stall that fired this. + * @property sourceStallId Unique ID of the stall that fired this. + */ data class Projectile( val id: String, val position: PreciseAxialCoordinate, @@ -137,19 +182,31 @@ data class Projectile( val isArc: Boolean = false, val startPosition: PreciseAxialCoordinate? = null, val sourceStallType: StallType? = null, - val sourceStallCoord: AxialCoordinate? = null + val sourceStallCoord: AxialCoordinate? = null, + val sourceStallId: String? = null ) enum class VisualEffectType { EXPANDING_CIRCLE, GAS_CLOUD } +/** + * Represents a sticky puddle (e.g. Teh Tarik) that slows enemies. + * + * @property id Unique identifier for the puddle. + * @property position Precise axial coordinate on the grid. + * @property spawnTimeMs Timestamp when the puddle was created. + * @property durationMs Total lifespan of the puddle. + * @property sourceStallCoord Coordinate of the stall that created this puddle. + * @property sourceStallId Unique ID of the stall that created this puddle. + */ data class StickyPuddle( val id: String, val position: PreciseAxialCoordinate, val spawnTimeMs: Long, val durationMs: Long = 3000L, - val sourceStallCoord: AxialCoordinate? = null + val sourceStallCoord: AxialCoordinate? = null, + val sourceStallId: String? = null ) data class VisualEffect( diff --git a/app/src/test/java/com/messark/hawkerrush/StallStatsTest.kt b/app/src/test/java/com/messark/hawkerrush/StallStatsTest.kt index 1b4893a..73b86e0 100644 --- a/app/src/test/java/com/messark/hawkerrush/StallStatsTest.kt +++ b/app/src/test/java/com/messark/hawkerrush/StallStatsTest.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import java.util.* @@ -44,8 +45,9 @@ class StallStatsTest { // Initial state with one stall and one enemy val stallCoord = AxialCoordinate(0, 0) val enemyId = "enemy1" + val stallId = "s1" val stall = Stall( - id = "s1", + id = stallId, name = "Chicken Rice", cost = 100, color = Color.Yellow, @@ -57,12 +59,12 @@ class StallStatsTest { id = enemyId, health = 100, maxHealth = 100, - position = PreciseAxialCoordinate(1f, 0f), // within range (range is 4f) + position = PreciseAxialCoordinate(1f, 0f), // within range path = listOf(AxialCoordinate(0, 0), AxialCoordinate(1, 0), AxialCoordinate(2, 0)) ) - // Let's manually construct a state and call handleProjectiles - val state = GameState( + // Set up initial state manually + viewModel._gameState.value = GameState( hexes = mapOf(stallCoord to HexTile(stallCoord, TileType.FLOOR, stall)), enemies = listOf(enemy), projectiles = listOf( @@ -73,29 +75,24 @@ class StallStatsTest { targetPosition = PreciseAxialCoordinate(1f, 0f), damage = 50, color = Color.Yellow, - sourceStallCoord = stallCoord + sourceStallCoord = stallCoord, + sourceStallId = stallId ) ) ) - // Access handleProjectiles using reflection since it's private - // Use javaPrimitiveType for the long parameter - val method = MainViewModel::class.java.getDeclaredMethod( - "handleProjectiles", - GameState::class.java, - Long::class.javaPrimitiveType !! - ) - method.isAccessible = true - // 1. First hit - var newState = method.invoke(viewModel, state, 1000L) as GameState + viewModel.updateGame(1000L) + + var newState = viewModel.gameState.value var updatedStall = newState.hexes[stallCoord]?.stall!! assertEquals(1, updatedStall.uniqueTargetIds.size) + assertTrue("Stall should track specific enemyId", updatedStall.uniqueTargetIds.contains(enemyId)) assertEquals(0, updatedStall.kills) assertEquals(50, newState.enemies[0].health) // 2. Second hit on SAME enemy (should NOT increment unique hits, but should kill and increment kills) - val state2 = newState.copy( + viewModel._gameState.value = newState.copy( projectiles = listOf( Projectile( id = "p2", @@ -104,15 +101,71 @@ class StallStatsTest { targetPosition = PreciseAxialCoordinate(1f, 0f), damage = 50, color = Color.Yellow, - sourceStallCoord = stallCoord + sourceStallCoord = stallCoord, + sourceStallId = stallId ) ) ) - newState = method.invoke(viewModel, state2, 1100L) as GameState + viewModel.updateGame(1100L) + + newState = viewModel.gameState.value updatedStall = newState.hexes[stallCoord]?.stall!! assertEquals(1, updatedStall.uniqueTargetIds.size) // Still 1 + assertTrue("Stall should still contain enemyId", updatedStall.uniqueTargetIds.contains(enemyId)) assertEquals(1, updatedStall.kills) // Now 1 assertEquals(0, newState.enemies.size) // Dead } + + @Test + fun `stalls do not attribute hits if replaced`() { + val application = mockk(relaxed = true) + val settingsRepository = mockk() + val gameStateRepository = mockk(relaxed = true) + every { settingsRepository.settingsFlow } returns kotlinx.coroutines.flow.flowOf(Settings()) + + val viewModel = MainViewModel(application, settingsRepository, gameStateRepository) + + val stallCoord = AxialCoordinate(0, 0) + val enemyId = "enemy1" + val oldStallId = "old_s1" + val newStallId = "new_s1" + + val oldStall = Stall(id = oldStallId, name = "Old Stall", cost = 100, color = Color.Yellow) + val newStall = Stall(id = newStallId, name = "New Stall", cost = 100, color = Color.Green) + + val enemy = Enemy( + id = enemyId, + health = 100, + maxHealth = 100, + position = PreciseAxialCoordinate(1f, 0f), + path = listOf(AxialCoordinate(0, 0), AxialCoordinate(1, 0)) + ) + + // Projectile from OLD stall + viewModel._gameState.value = GameState( + hexes = mapOf(stallCoord to HexTile(stallCoord, TileType.FLOOR, newStall)), // NEW stall already there + enemies = listOf(enemy), + projectiles = listOf( + Projectile( + id = "p1", + position = PreciseAxialCoordinate(1f, 0f), + targetEnemyId = enemyId, + targetPosition = PreciseAxialCoordinate(1f, 0f), + damage = 50, + color = Color.Yellow, + sourceStallCoord = stallCoord, + sourceStallId = oldStallId + ) + ) + ) + + viewModel.updateGame(1000L) + + val newState = viewModel.gameState.value + val currentStall = newState.hexes[stallCoord]?.stall!! + + assertEquals(newStallId, currentStall.id) + assertEquals("New stall should NOT get hits from old stall projectile", 0, currentStall.uniqueTargetIds.size) + } } From 5dca7ae8da6e542a549d59a2a7f69366e3ecc944 Mon Sep 17 00:00:00 2001 From: John Barker Date: Mon, 20 Apr 2026 07:14:46 +0100 Subject: [PATCH 7/8] revert: remove unwanted changes to CI workflow --- .github/workflows/android.yml | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index a662dcb..78fe5ce 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -41,21 +41,8 @@ jobs: prerelease: "${{ github.event_name == 'pull_request_target' }}" generate_release_notes: "${{ github.event_name == 'push' }}" make_latest: "${{ github.event_name == 'push' }}" - draft: true files: app/build/outputs/apk/release/app-release.apk - - name: Publish Release - if: github.event_name == 'push' - uses: actions/github-script@v7 - with: - script: | - github.rest.repos.updateRelease({ - owner: context.repo.owner, - repo: context.repo.repo, - release_id: ${{ steps.create_release.outputs.id }}, - draft: false - }) - - name: Comment PR with Download Link if: github.event_name == 'pull_request_target' uses: actions/github-script@v7 From 4a73fffec1151a6f122a8e810429359c2241fdf8 Mon Sep 17 00:00:00 2001 From: John Barker Date: Mon, 20 Apr 2026 10:05:43 +0100 Subject: [PATCH 8/8] Fix: Address code review for puddle interactions in MainViewModel.kt --- .../java/com/messark/hawkerrush/MainViewModel.kt | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/messark/hawkerrush/MainViewModel.kt b/app/src/main/java/com/messark/hawkerrush/MainViewModel.kt index 96f7a86..3f58473 100644 --- a/app/src/main/java/com/messark/hawkerrush/MainViewModel.kt +++ b/app/src/main/java/com/messark/hawkerrush/MainViewModel.kt @@ -350,6 +350,16 @@ class MainViewModel @JvmOverloads constructor( speedBoostDuration = Math.max(0, speedBoostDuration - 32) } + state.puddles.forEach { puddle -> + if (axialDistance(enemy.position, puddle.position) < 0.8 && + puddle.sourceStallCoord != null && + puddle.sourceStallId != null + ) { + affectingStalls + .getOrPut(puddle.sourceStallCoord to puddle.sourceStallId) { mutableSetOf() } + .add(enemy.id) + } + } if (isStopped || freezeDuration > 0) { return@mapNotNull enemy.copy( isStopped = isStopped, @@ -368,9 +378,7 @@ class MainViewModel @JvmOverloads constructor( EnemyType.AUNTIE -> 0.8f // half slow (20% reduction) else -> 0.6f // normal slow (40% reduction) } - if (puddle.sourceStallCoord != null && puddle.sourceStallId != null) { - affectingStalls.getOrPut(puddle.sourceStallCoord to puddle.sourceStallId) { mutableSetOf() }.add(enemy.id) - } + } }