diff --git a/app/src/main/java/com/messark/hawkerrush/MainViewModel.kt b/app/src/main/java/com/messark/hawkerrush/MainViewModel.kt index 358eb62..3f58473 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,8 +309,17 @@ 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, MutableSet>() + val updatedEnemies = state.enemies.mapNotNull { enemy -> if (enemy.isDead) return@mapNotNull null @@ -336,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, @@ -347,14 +371,14 @@ 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) + } + } } @@ -411,9 +435,31 @@ class MainViewModel @JvmOverloads constructor( ) } } + + if (affectingStalls.isNotEmpty()) { + val updatedHexes = mutableState.hexes.toMutableMap() + affectingStalls.forEach { (source, enemyIds) -> + val (coord, stallId) = source + updatedHexes[coord]?.stall?.let { stall -> + if (stall.id == stallId) { + val newTargetIds = stall.uniqueTargetIds + enemyIds + updatedHexes[coord] = updatedHexes[coord]!!.copy(stall = stall.copy(uniqueTargetIds = newTargetIds)) + } + } + } + mutableState = mutableState.copy(hexes = updatedHexes) + } + 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() @@ -445,7 +491,9 @@ class MainViewModel @JvmOverloads constructor( targetPosition = target.position, damage = stall.damage, color = stall.color, - sourceStallType = StallType.CHICKEN_RICE + sourceStallType = StallType.CHICKEN_RICE, + sourceStallCoord = coord, + sourceStallId = stall.id )) } StallType.TEH_TARIK -> { @@ -453,7 +501,9 @@ class MainViewModel @JvmOverloads constructor( id = UUID.randomUUID().toString(), position = target.position, spawnTimeMs = currentTimeMs, - durationMs = stall.effectDurationMs + durationMs = stall.effectDurationMs, + sourceStallCoord = coord, + sourceStallId = stall.id )) } StallType.SATAY -> { @@ -472,7 +522,9 @@ class MainViewModel @JvmOverloads constructor( aoeRadius = stall.aoeRadius, isArc = true, startPosition = stallPos, - sourceStallType = StallType.SATAY + sourceStallType = StallType.SATAY, + sourceStallCoord = coord, + sourceStallId = stall.id )) } StallType.ICE_KACHANG -> { @@ -485,7 +537,9 @@ class MainViewModel @JvmOverloads constructor( color = stall.color, isFreeze = true, freezeDurationMs = stall.freezeDurationMs, - sourceStallType = StallType.ICE_KACHANG + sourceStallType = StallType.ICE_KACHANG, + sourceStallCoord = coord, + sourceStallId = stall.id )) } StallType.DURIAN -> { @@ -497,7 +551,9 @@ class MainViewModel @JvmOverloads constructor( damage = stall.damage, color = stall.color, aoeRadius = stall.aoeRadius, - sourceStallType = StallType.DURIAN + sourceStallType = StallType.DURIAN, + sourceStallCoord = coord, + sourceStallId = stall.id )) } } @@ -508,6 +564,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>() @@ -564,15 +628,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 +660,28 @@ 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 + if (proj.sourceStallCoord != null && proj.sourceStallId != null) { + val coord = proj.sourceStallCoord + updatedHexes[coord]?.stall?.let { stall -> + 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 + )) + } + } + } } - 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 +693,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..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, @@ -56,7 +81,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) { @@ -120,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, @@ -134,18 +181,32 @@ 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, + 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 durationMs: Long = 3000L, + val sourceStallCoord: AxialCoordinate? = null, + val sourceStallId: String? = 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..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 @@ -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 = "Targets: ${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..73b86e0 --- /dev/null +++ b/app/src/test/java/com/messark/hawkerrush/StallStatsTest.kt @@ -0,0 +1,171 @@ +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.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.* + +@OptIn(ExperimentalCoroutinesApi::class) +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) + 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 stallId = "s1" + val stall = Stall( + id = stallId, + 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 + path = listOf(AxialCoordinate(0, 0), AxialCoordinate(1, 0), AxialCoordinate(2, 0)) + ) + + // Set up initial state manually + viewModel._gameState.value = 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, + sourceStallId = stallId + ) + ) + ) + + // 1. First hit + 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) + viewModel._gameState.value = newState.copy( + projectiles = listOf( + Projectile( + id = "p2", + position = PreciseAxialCoordinate(1f, 0f), + targetEnemyId = enemyId, + targetPosition = PreciseAxialCoordinate(1f, 0f), + damage = 50, + color = Color.Yellow, + sourceStallCoord = stallCoord, + sourceStallId = stallId + ) + ) + ) + + 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) + } +}