diff --git a/app/src/main/java/com/messark/hawkerrush/MainViewModel.kt b/app/src/main/java/com/messark/hawkerrush/MainViewModel.kt index fd1cd77..dd8961b 100644 --- a/app/src/main/java/com/messark/hawkerrush/MainViewModel.kt +++ b/app/src/main/java/com/messark/hawkerrush/MainViewModel.kt @@ -5,8 +5,8 @@ import androidx.compose.ui.graphics.Color import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.messark.hawkerrush.model.* +import com.messark.hawkerrush.registry.* import com.messark.hawkerrush.utils.* -import com.messark.hawkerrush.utils.LegendaryNames import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import java.time.Instant @@ -32,13 +32,7 @@ class MainViewModel @JvmOverloads constructor( ) private val _availableStalls = MutableStateFlow( - listOf( - Stall("t1", "Teh Tarik", baseName = "Teh Tarik", cost = 150, color = Color.Blue, stallType = StallType.TEH_TARIK, range = 3f, description = "Creates slowing puddles"), - Stall("t2", "Satay", baseName = "Satay", cost = 200, color = Color.Red, stallType = StallType.SATAY, range = 2.5f, damage = 20, fireRateMs = 1500, description = "Area chili sauce damage"), - Stall("t3", "Chicken Rice", baseName = "Chicken Rice", cost = 100, color = Color.Yellow, stallType = StallType.CHICKEN_RICE, range = 4f, damage = 15, fireRateMs = 700, description = "High single-target damage"), - Stall("t4", "Durian", baseName = "Durian", cost = 300, color = Color(0xFF4CAF50), stallType = StallType.DURIAN, range = 3f, damage = 120, fireRateMs = 2000, description = "Massive damage, slow fire"), - Stall("t5", "Ice Kachang", baseName = "Ice Kachang", cost = 250, color = Color.Cyan, stallType = StallType.ICE_KACHANG, range = 3.5f, damage = 2, fireRateMs = 1500, freezeDurationMs = 500L, description = "Freezes enemies in place") - ) + StallRegistry.all().map { it.toStall() } ) val availableStalls: StateFlow> = _availableStalls.asStateFlow() @@ -134,15 +128,21 @@ class MainViewModel @JvmOverloads constructor( val newEnemyTypes = enemyList.distinct().filter { !settings.shownTutorials.contains("enemy_${it.name.lowercase()}") } if (newEnemyTypes.isNotEmpty()) { val firstNewEnemy = newEnemyTypes.first() - val tutorial = TutorialContent.ENEMY_TUTORIALS[firstNewEnemy] - if (tutorial != null) { - _gameState.update { it.copy(activeTutorial = tutorial) } - // Mark as shown - settingsRepository.updateSettings { - it.copy(shownTutorials = it.shownTutorials + "enemy_${firstNewEnemy.name.lowercase()}") - } - return@launch + val enemyDef = EnemyRegistry.get(firstNewEnemy) + val tutorial = TutorialData( + id = "enemy_${firstNewEnemy.name.lowercase()}", + type = TutorialType.ENEMY, + title = enemyDef.name, + description = enemyDef.description, + enemyType = firstNewEnemy + ) + + _gameState.update { it.copy(activeTutorial = tutorial) } + // Mark as shown + settingsRepository.updateSettings { + it.copy(shownTutorials = it.shownTutorials + "enemy_${firstNewEnemy.name.lowercase()}") } + return@launch } } @@ -181,7 +181,15 @@ class MainViewModel @JvmOverloads constructor( } fun showStallTutorial(stallType: StallType) { - val tutorial = TutorialContent.STALL_TUTORIALS[stallType] ?: return + val def = StallRegistry.get(stallType) + val tutorial = TutorialData( + id = "stall_${stallType.name.lowercase()}", + type = TutorialType.STALL, + title = def.tutorialTitle, + signatureMove = def.signatureMove, + description = def.tutorialDescription, + stallType = stallType + ) _gameState.update { it.copy(activeTutorial = tutorial) } } @@ -244,13 +252,7 @@ class MainViewModel @JvmOverloads constructor( } private fun getEnemyHP(type: EnemyType, wave: Int): Int { - val baseHp = when (type) { - EnemyType.SALARYMAN -> 50 - EnemyType.TOURIST -> 100 - EnemyType.AUNTIE -> 150 - EnemyType.DELIVERY_RIDER -> 500 - } - return (baseHp * Math.pow(1.1, (wave - 1).toDouble())).toInt() + return EnemyRegistry.get(type).getHp(wave) } private fun startGameLoop() { @@ -323,29 +325,13 @@ class MainViewModel @JvmOverloads constructor( val type = state.enemiesToSpawnList.first() val remainingSpawnList = state.enemiesToSpawnList.drop(1) - val enemyHealth = getEnemyHP(type, state.currentWave) - - val speed = when (type) { - EnemyType.SALARYMAN -> 0.08f - EnemyType.TOURIST -> 0.04f - EnemyType.AUNTIE -> 0.03f - EnemyType.DELIVERY_RIDER -> 0.06f - } - val firstTarget = path.getOrNull(1) ?: startPos val isFacingLeft = firstTarget.q + firstTarget.r / 2f < startPos.q + startPos.r / 2f - val newEnemy = Enemy( - id = UUID.randomUUID().toString(), - type = type, - health = enemyHealth, - maxHealth = enemyHealth, + val newEnemy = EnemyRegistry.get(type).toEnemy( + wave = state.currentWave, position = PreciseAxialCoordinate(startPos.q.toFloat(), startPos.r.toFloat()), - baseSpeed = speed, - currentSpeed = speed, path = path, - currentPathIndex = 0, - reward = if (type == EnemyType.DELIVERY_RIDER) 100 else 20, isFacingLeft = isFacingLeft ) return state.copy( @@ -372,33 +358,23 @@ class MainViewModel @JvmOverloads constructor( val updatedEnemies = state.enemies.mapNotNull { enemy -> if (enemy.isDead) return@mapNotNull null + val enemyDef = EnemyRegistry.get(enemy.type) + var freezeDuration = enemy.freezeDurationMs if (freezeDuration > 0) { freezeDuration = Math.max(0, freezeDuration - 32) } - var isStopped = enemy.isStopped - var stopDurationMs = enemy.stopDurationMs - var lastStopMs = enemy.lastStopMs var speedBoostDuration = enemy.speedBoostDurationMs - - if (enemy.type == EnemyType.TOURIST) { - if (isStopped) { - stopDurationMs -= 32 - if (stopDurationMs <= 0) { - isStopped = false - lastStopMs = currentTimeMs - } - } else if (currentTimeMs - lastStopMs > 8000) { - isStopped = true - stopDurationMs = 2000L - } - } - if (speedBoostDuration > 0) { speedBoostDuration = Math.max(0, speedBoostDuration - 32) } + val behaviorUpdatedEnemy = enemyDef.updateSpecialBehavior(enemy, currentTimeMs) + var isStopped = behaviorUpdatedEnemy.isStopped + var stopDurationMs = behaviorUpdatedEnemy.stopDurationMs + var lastStopMs = behaviorUpdatedEnemy.lastStopMs + state.puddles.forEach { puddle -> if (axialDistance(enemy.position, puddle.position) < 0.8 && puddle.sourceStallCoord != null && @@ -422,12 +398,7 @@ class MainViewModel @JvmOverloads constructor( var speedMultiplier = 1.0f 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) - } - + speedMultiplier = enemyDef.getPuddleSlowMultiplier(enemy.type) } } @@ -530,80 +501,17 @@ class MainViewModel @JvmOverloads constructor( } if (target != null) { - var updatedStall = stall.copy(lastFiredMs = currentTimeMs) - when (stall.stallType) { - StallType.CHICKEN_RICE -> { - newProjectiles.add(Projectile( - id = UUID.randomUUID().toString(), - position = stallPos, - targetEnemyId = target.id, - targetPosition = target.position, - damage = stall.damage, - color = stall.color, - sourceStallType = StallType.CHICKEN_RICE, - sourceStallCoord = coord, - sourceStallId = stall.id - )) - } - StallType.TEH_TARIK -> { - newPuddles.add(StickyPuddle( - id = UUID.randomUUID().toString(), - position = target.position, - spawnTimeMs = currentTimeMs, - durationMs = stall.effectDurationMs, - sourceStallCoord = coord, - sourceStallId = stall.id - )) - } - StallType.SATAY -> { - val dq = target.position.q - coord.q - val dr = target.position.r - coord.r - val angle = Math.atan2(dr.toDouble(), dq.toDouble()).toFloat() - updatedStall = updatedStall.copy(rotation = angle) - newProjectiles.add(Projectile( - id = UUID.randomUUID().toString(), - position = stallPos, - targetEnemyId = null, - targetPosition = target.position, - damage = stall.damage, - color = Color.White, - speed = 0.3f, - aoeRadius = stall.aoeRadius, - isArc = true, - startPosition = stallPos, - sourceStallType = StallType.SATAY, - sourceStallCoord = coord, - sourceStallId = stall.id - )) + val stallDef = StallRegistry.get(stall.stallType) + val fireResult = stallDef.fire(stall, coord, target, currentTimeMs) + var updatedStall = (fireResult as? FireResult.NewProjectile)?.updatedStall ?: stall + updatedStall = updatedStall.copy(lastFiredMs = currentTimeMs) + + when (fireResult) { + is FireResult.NewProjectile -> { + newProjectiles.add(fireResult.projectile) } - StallType.ICE_KACHANG -> { - newProjectiles.add(Projectile( - id = UUID.randomUUID().toString(), - position = stallPos, - targetEnemyId = target.id, - targetPosition = target.position, - damage = stall.damage, - color = stall.color, - isFreeze = true, - freezeDurationMs = stall.freezeDurationMs, - sourceStallType = StallType.ICE_KACHANG, - sourceStallCoord = coord, - sourceStallId = stall.id - )) - } - StallType.DURIAN -> { - newProjectiles.add(Projectile( - id = UUID.randomUUID().toString(), - position = stallPos, - targetEnemyId = target.id, - targetPosition = target.position, - damage = stall.damage, - color = stall.color, - aoeRadius = stall.aoeRadius, - sourceStallType = StallType.DURIAN, - sourceStallCoord = coord, - sourceStallId = stall.id - )) + is FireResult.NewPuddle -> { + newPuddles.add(fireResult.puddle) } } updatedHexes[coord] = tile.copy(stall = updatedStall) @@ -639,19 +547,15 @@ class MainViewModel @JvmOverloads constructor( if (dist < proj.speed) { // Visual Effect - if (proj.aoeRadius > 0) { - val (effectColor, effectType, duration) = when { - proj.isArc -> Triple(Color.Red.copy(alpha = 0.3f), VisualEffectType.GAS_CLOUD, 500L) // Satay - proj.color == Color(0xFF4CAF50) -> Triple(Color(0xFFCDDC39).copy(alpha = 0.5f), VisualEffectType.EXPANDING_CIRCLE, 150L) // Durian (greeny-yellow) - else -> Triple(proj.color.copy(alpha = 0.5f), VisualEffectType.EXPANDING_CIRCLE, 150L) - } + if (proj.aoeRadius > 0 && proj.sourceStallType != null) { + val stallDef = StallRegistry.get(proj.sourceStallType) newVisualEffects.add(VisualEffect( id = UUID.randomUUID().toString(), position = targetPos, - color = effectColor, + color = stallDef.visualEffectColor ?: proj.color.copy(alpha = 0.5f), startTimeMs = currentTimeMs, - durationMs = duration, - type = effectType + durationMs = stallDef.visualEffectDuration, + type = stallDef.visualEffectType )) } @@ -693,20 +597,12 @@ class MainViewModel @JvmOverloads constructor( var freezeDuration = proj.freezeDurationMs // Apply modifiers - when (proj.sourceStallType) { - StallType.SATAY -> { - if (enemy.type == EnemyType.TOURIST) damage *= 2f - else if (enemy.type == EnemyType.AUNTIE) damage *= 0.5f - } - StallType.DURIAN -> { - if (enemy.type == EnemyType.DELIVERY_RIDER) damage *= 1.5f - else if (enemy.type == EnemyType.SALARYMAN) speedBoostDuration = 2000L - } - StallType.ICE_KACHANG -> { - if (enemy.type == EnemyType.SALARYMAN) freezeDuration *= 2 - else if (enemy.type == EnemyType.TOURIST) freezeDuration /= 2 - } - else -> {} + if (proj.sourceStallType != null) { + val stallDef = StallRegistry.get(proj.sourceStallType) + damage = stallDef.applyDamageModifiers(enemy, damage) + freezeDuration = stallDef.getFreezeModifier(enemy, freezeDuration) + val boost = stallDef.getSpeedBoost(enemy) + if (boost > 0) speedBoostDuration = boost } val damageDealt = damage.toInt() @@ -852,6 +748,7 @@ class MainViewModel @JvmOverloads constructor( if (state.gold >= upgradeCost) { val upgradeCategories = mutableListOf(0, 1, 2).apply { shuffle() } + val stallDef = StallRegistry.get(stall.stallType) while (upgradeCategories.isNotEmpty()) { val upgradeTypeIndex = upgradeCategories.removeAt(0) @@ -869,11 +766,7 @@ class MainViewModel @JvmOverloads constructor( 0 -> { if (kotlin.random.Random.nextBoolean()) { currentCategoryName = "Damage" - val damageIncrease = if (stall.stallType == StallType.CHICKEN_RICE) { - (baseStall.damage * 0.3f).toInt() + 2 - } else { - (baseStall.damage * 0.2f).toInt() + 1 - } + val damageIncrease = stallDef.getUpgradeDamageIncrease(baseStall.damage) newDamage += damageIncrease val newLevel = mutableUpgrades.getOrDefault("Damage", 0) + 1 if (newLevel % 10 == 0) { @@ -937,7 +830,7 @@ class MainViewModel @JvmOverloads constructor( } StallType.CHICKEN_RICE -> { currentCategoryName = "Damage" - val damageIncrease = (baseStall.damage * 0.3f).toInt() + 2 + val damageIncrease = stallDef.getUpgradeDamageIncrease(baseStall.damage) newDamage += damageIncrease val newLevel = mutableUpgrades.getOrDefault("Damage", 0) + 1 if (newLevel % 10 == 0) { 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 d70da38..c27dedc 100644 --- a/app/src/main/java/com/messark/hawkerrush/model/GameModels.kt +++ b/app/src/main/java/com/messark/hawkerrush/model/GameModels.kt @@ -1,6 +1,7 @@ package com.messark.hawkerrush.model import androidx.compose.ui.graphics.Color +import com.messark.hawkerrush.registry.StallRegistry data class AxialCoordinate(val q: Int, val r: Int) @@ -98,80 +99,10 @@ data class Stall( return Math.round(cost * (0.2f + nextUpgradeIndex * 0.1f)).toInt() } - fun getUpgradeBenefit(category: String, level: Int, baseStall: Stall): String { + fun getUpgradeBenefit(category: String, level: Int): String { if (level <= 0) return "" - - return when (category) { - "Damage" -> { - var currentDamage = baseStall.damage - val increasePerLevel = if (stallType == StallType.CHICKEN_RICE) { - (baseStall.damage * 0.3f).toInt() + 2 - } else { - (baseStall.damage * 0.2f).toInt() + 1 - } - for (l in 1..level) { - currentDamage += increasePerLevel - if (l % 10 == 0) { - currentDamage = Math.round(currentDamage * 1.25f) - } - } - val percentage = Math.round(((currentDamage - baseStall.damage).toFloat() / baseStall.damage) * 100) - "+$percentage%" - } - "Rate" -> { - var currentRate = baseStall.fireRateMs - val rateReduction = (baseStall.fireRateMs * 0.1f).toLong() - for (l in 1..level) { - currentRate = Math.max(50L, currentRate - rateReduction) - if (l % 10 == 0) { - currentRate = Math.max(50L, Math.round(currentRate * 0.75)) - } - } - val percentage = Math.round(((baseStall.fireRateMs - currentRate).toFloat() / baseStall.fireRateMs) * 100) - "+$percentage%" - } - "Range" -> { - var currentRange = baseStall.range - for (l in 1..level) { - currentRange += 0.5f - if (l % 10 == 0) { - currentRange *= 1.25f - } - } - "+${String.format("%.1f", currentRange - baseStall.range)}" - } - "Radius" -> { - var currentRadius = baseStall.aoeRadius - for (l in 1..level) { - currentRadius += 0.2f - if (l % 10 == 0) { - currentRadius *= 1.25f - } - } - "+${String.format("%.1f", currentRadius - baseStall.aoeRadius)}" - } - "Duration" -> { - var currentDuration = baseStall.effectDurationMs - for (l in 1..level) { - currentDuration += 500 - if (l % 10 == 0) { - currentDuration = Math.round(currentDuration * 1.25f).toLong() - } - } - "+${currentDuration - baseStall.effectDurationMs}ms" - } - "Effect" -> { - var currentEffect = baseStall.freezeDurationMs - for (l in 1..level) { - currentEffect += 100 - if (l % 10 == 0) { - currentEffect = Math.round(currentEffect * 1.25f).toLong() - } - } - "+${currentEffect - baseStall.freezeDurationMs}ms" - } - else -> "" - } + val stallDef = StallRegistry.get(stallType) + return stallDef.getUpgradeBenefit(category, level, stallDef) } } diff --git a/app/src/main/java/com/messark/hawkerrush/model/TutorialModels.kt b/app/src/main/java/com/messark/hawkerrush/model/TutorialModels.kt index 3266ee7..7dbe1d9 100644 --- a/app/src/main/java/com/messark/hawkerrush/model/TutorialModels.kt +++ b/app/src/main/java/com/messark/hawkerrush/model/TutorialModels.kt @@ -13,79 +13,3 @@ data class TutorialData( val enemyType: EnemyType? = null, val stallType: StallType? = null ) - -object TutorialContent { - val ENEMY_TUTORIALS = mapOf( - EnemyType.SALARYMAN to TutorialData( - id = "enemy_salaryman", - type = TutorialType.ENEMY, - title = "Salaryman", - description = "The fast-paced office worker. They move quickly across the grid, eager to reach their destination. Their high speed makes them difficult to hit, but they don't have much health.", - enemyType = EnemyType.SALARYMAN - ), - EnemyType.TOURIST to TutorialData( - id = "enemy_tourist", - type = TutorialType.ENEMY, - title = "Tourist", - description = "A curious visitor who frequently stops to take pictures of the local sights. While stationary, they are easy targets for your stalls, but they have more health than a Salaryman.", - enemyType = EnemyType.TOURIST - ), - EnemyType.AUNTIE to TutorialData( - id = "enemy_auntie", - type = TutorialType.ENEMY, - title = "Auntie", - description = "A veteran of the hawker scene. She moves slowly and deliberately, but possesses high health. It takes sustained fire from multiple stalls to stop her progress.", - enemyType = EnemyType.AUNTIE - ), - EnemyType.DELIVERY_RIDER to TutorialData( - id = "enemy_delivery_rider", - type = TutorialType.ENEMY, - title = "Delivery Rider", - description = "A formidable boss on two wheels. He has massive health and moves at a significant speed. He is particularly cautious on wet surfaces, slowing down considerably when passing through sticky puddles.", - enemyType = EnemyType.DELIVERY_RIDER - ) - ) - - val STALL_TUTORIALS = mapOf( - StallType.SATAY to TutorialData( - id = "stall_satay", - type = TutorialType.STALL, - title = "Uncle's Satay Stall (AoE Damage)", - signatureMove = "The Chili Conflagration", - description = "Wah, smells so shiok! Behind this unassuming grill, the Satay Uncle is fanning a fiery revolution. Watch out for his signature Chili Conflagration—the chili isn't just spicy; it's explosive. He loads up a massive spoon and, with a precision usually reserved for satay-counting, launches a gigantic splash of his secret, explosive chili sauce. When it hits, it covers a wide circle, dousing groups of enemies in a sticky, burning chili storm that eats away at their health (and their willpower). If you need a crowd-control burn, this Uncle is the OG.", - stallType = StallType.SATAY - ), - StallType.CHICKEN_RICE to TutorialData( - id = "stall_chicken_rice", - type = TutorialType.STALL, - title = "Ah Hock’s Chicken Rice Stand (Single-Target DPS)", - signatureMove = "The Garlic-Ginger Gatling Gun", - description = "Ah Hock’s Chicken Rice is famous for two things: the tenderest steamed chicken and the single-minded focus of his attacks. Don’t be fooled by the simple setup; this stand is your base single-target workhorse. When an enemy is targeted, Ah Hock deploys his Garlic-Ginger Gatling Gun. Instead of bullets, he’s launching high-velocity, precision-aimed balls of marinated meat, dousing targets in flavor-infused damage. It’s consistent, it’s powerful, and it never runs out of stock. A classic choice that never fails.", - stallType = StallType.CHICKEN_RICE - ), - StallType.ICE_KACHANG to TutorialData( - id = "stall_ice_kachang", - type = TutorialType.STALL, - title = "Auntie's Ice Kachang Cart (Stun/Freezer)", - signatureMove = "The Absolute Zero Brain Freeze", - description = "Want something to really chill out the enemies? Then you need the Auntie at the Ice Kachang Cart! She’s taken traditional dessert techniques to the cryo-level. Her specialized ice shaver can launch a massive, compacted ball of shaved ice, syrup, and cold, cold, red beans, aimed precisely at the lead enemy. Upon impact, it doesn't just damage; it delivers an Absolute Zero Brain Freeze. The target is frozen solid, encased in a giant colorful ice cube, completely immobilized for several precious seconds. A perfect stall for controlling boss units.", - stallType = StallType.ICE_KACHANG - ), - StallType.TEH_TARIK to TutorialData( - id = "stall_teh_tarik", - type = TutorialType.STALL, - title = "Teh Tarik Maestro (Movement Slow)", - signatureMove = "The Perpetual Tarik Puddle", - description = "Welcome to the Teh Tarik Maestro, where the art of 'pulling' tea is a high-level tactical maneuver. This Maestro doesn't just make your enemies slower; he makes the very ground they walk on sticky. Utilizing a massive pair of custom cups, he performs a continuous, mesmerizing 'tarik' high in the air. Each 'pull' perfectly places a wide, frothy Perpetual Tarik Puddle of viscous, sweet milk tea. The tea is so thick and syrupy that enemies stepping into it are immediately bogged down, their speed cut in half as they struggle through the delicious, sticky mess. A crowd favorite for slowing the rush.", - stallType = StallType.TEH_TARIK - ), - StallType.DURIAN to TutorialData( - id = "stall_durian", - type = TutorialType.STALL, - title = "The King Durian Bunker (High Damage/Slight AoE)", - signatureMove = "The Spiky Cataclysm", - description = "They call the Durian the King of Fruits, and this stall is the King of Damage. The King Durian Bunker is fortified with armor-plating and smells… well, like a durian. When the King’s crew makes a sale, they aren't selling just fruit; they are deploying a localized explosive. Using a heavy-duty pneumatic launcher, they fire an overripe, spikey Durian bomb into the largest cluster of enemies. Upon impact, it delivers a high-damage, single-target blow, followed immediately by a Spiky Cataclysm AoE explosion as the potent, heavy aroma bursts outward. It’s high-cost and slow-reloading, but the raw damage (and the scent) is devastating.", - stallType = StallType.DURIAN - ) - ) -} diff --git a/app/src/main/java/com/messark/hawkerrush/registry/Registry.kt b/app/src/main/java/com/messark/hawkerrush/registry/Registry.kt new file mode 100644 index 0000000..fca0d81 --- /dev/null +++ b/app/src/main/java/com/messark/hawkerrush/registry/Registry.kt @@ -0,0 +1,416 @@ +package com.messark.hawkerrush.registry + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.IntRect +import com.messark.hawkerrush.model.* +import java.util.* + +sealed class FireResult { + data class NewProjectile(val projectile: Projectile, val updatedStall: Stall? = null) : FireResult() + data class NewPuddle(val puddle: StickyPuddle) : FireResult() +} + +data class StallDefinition( + val type: StallType, + val name: String, + val cost: Int, + val color: Color, + val range: Float, + val damage: Int, + val fireRateMs: Long, + val description: String, + val tutorialTitle: String, + val signatureMove: String, + val tutorialDescription: String, + val spriteRect: IntRect, + val aoeRadius: Float = 0f, + val effectDurationMs: Long = 0L, + val freezeDurationMs: Long = 0L, + val projectileSpeed: Float = 0.2f, + val isArc: Boolean = false, + val projectileColor: Color = color, + val visualEffectType: VisualEffectType = VisualEffectType.EXPANDING_CIRCLE, + val visualEffectColor: Color? = null, + val visualEffectDuration: Long = 150L +) { + fun getUpgradeDamageIncrease(baseDamage: Int): Int { + return if (type == StallType.CHICKEN_RICE) { + (baseDamage * 0.3f).toInt() + 2 + } else { + (baseDamage * 0.2f).toInt() + 1 + } + } + + fun applyDamageModifiers(enemy: Enemy, baseDamage: Float): Float { + return when (type) { + StallType.SATAY -> when (enemy.type) { + EnemyType.TOURIST -> baseDamage * 2f + EnemyType.AUNTIE -> baseDamage * 0.5f + else -> baseDamage + } + StallType.DURIAN -> when (enemy.type) { + EnemyType.DELIVERY_RIDER -> baseDamage * 1.5f + else -> baseDamage + } + else -> baseDamage + } + } + + fun getFreezeModifier(enemy: Enemy, baseDuration: Long): Long { + if (type != StallType.ICE_KACHANG) return 0L + return when (enemy.type) { + EnemyType.SALARYMAN -> baseDuration * 2 + EnemyType.TOURIST -> baseDuration / 2 + else -> baseDuration + } + } + + fun getSpeedBoost(enemy: Enemy): Long { + if (type == StallType.DURIAN && enemy.type == EnemyType.SALARYMAN) { + return 2000L + } + return 0L + } + + fun fire( + stall: Stall, + stallCoord: AxialCoordinate, + target: Enemy, + currentTimeMs: Long + ): FireResult { + val stallPos = PreciseAxialCoordinate(stallCoord.q.toFloat(), stallCoord.r.toFloat()) + return when (type) { + StallType.TEH_TARIK -> FireResult.NewPuddle( + StickyPuddle( + id = UUID.randomUUID().toString(), + position = target.position, + spawnTimeMs = currentTimeMs, + durationMs = stall.effectDurationMs, + sourceStallCoord = stallCoord, + sourceStallId = stall.id + ) + ) + StallType.SATAY -> { + val dq = target.position.q - stallCoord.q + val dr = target.position.r - stallCoord.r + val angle = Math.atan2(dr.toDouble(), dq.toDouble()).toFloat() + FireResult.NewProjectile( + projectile = Projectile( + id = UUID.randomUUID().toString(), + position = stallPos, + targetEnemyId = null, + targetPosition = target.position, + damage = stall.damage, + color = Color.White, + speed = projectileSpeed, + aoeRadius = stall.aoeRadius, + isArc = true, + startPosition = stallPos, + sourceStallType = StallType.SATAY, + sourceStallCoord = stallCoord, + sourceStallId = stall.id + ), + updatedStall = stall.copy(rotation = angle) + ) + } + else -> FireResult.NewProjectile( + projectile = Projectile( + id = UUID.randomUUID().toString(), + position = stallPos, + targetEnemyId = target.id, + targetPosition = target.position, + damage = stall.damage, + color = stall.color, + isFreeze = type == StallType.ICE_KACHANG, + freezeDurationMs = stall.freezeDurationMs, + aoeRadius = stall.aoeRadius, + sourceStallType = type, + sourceStallCoord = stallCoord, + sourceStallId = stall.id + ) + ) + } + } + + fun toStall(id: String = UUID.randomUUID().toString()): Stall { + return Stall( + id = id, + name = name, + baseName = name, + cost = cost, + color = color, + range = range, + damage = damage, + fireRateMs = fireRateMs, + stallType = type, + description = description, + aoeRadius = aoeRadius, + effectDurationMs = effectDurationMs, + freezeDurationMs = freezeDurationMs + ) + } + + fun getUpgradeBenefit(category: String, level: Int, baseStall: StallDefinition): String { + if (level <= 0) return "" + + return when (category) { + "Damage" -> { + var currentDamage = baseStall.damage + val increasePerLevel = getUpgradeDamageIncrease(baseStall.damage) + for (l in 1..level) { + currentDamage += increasePerLevel + if (l % 10 == 0) { + currentDamage = Math.round(currentDamage * 1.25f) + } + } + val percentage = Math.round(((currentDamage - baseStall.damage).toFloat() / baseStall.damage) * 100) + "+$percentage%" + } + "Rate" -> { + var currentRate = baseStall.fireRateMs + val rateReduction = (baseStall.fireRateMs * 0.1f).toLong() + for (l in 1..level) { + currentRate = Math.max(50L, currentRate - rateReduction) + if (l % 10 == 0) { + currentRate = Math.max(50L, Math.round(currentRate * 0.75)) + } + } + val percentage = Math.round(((baseStall.fireRateMs - currentRate).toFloat() / baseStall.fireRateMs) * 100) + "+$percentage%" + } + "Range" -> { + var currentRange = baseStall.range + for (l in 1..level) { + currentRange += 0.5f + if (l % 10 == 0) { + currentRange *= 1.25f + } + } + "+${String.format("%.1f", currentRange - baseStall.range)}" + } + "Radius" -> { + var currentRadius = baseStall.aoeRadius + for (l in 1..level) { + currentRadius += 0.2f + if (l % 10 == 0) { + currentRadius *= 1.25f + } + } + "+${String.format("%.1f", currentRadius - baseStall.aoeRadius)}" + } + "Duration" -> { + var currentDuration = baseStall.effectDurationMs + for (l in 1..level) { + currentDuration += 500 + if (l % 10 == 0) { + currentDuration = Math.round(currentDuration * 1.25f).toLong() + } + } + "+${currentDuration - baseStall.effectDurationMs}ms" + } + "Effect" -> { + var currentEffect = baseStall.freezeDurationMs + for (l in 1..level) { + currentEffect += 100 + if (l % 10 == 0) { + currentEffect = Math.round(currentEffect * 1.25f).toLong() + } + } + "+${currentEffect - baseStall.freezeDurationMs}ms" + } + else -> "" + } + } +} + +data class EnemyDefinition( + val type: EnemyType, + val name: String, + val description: String, + val baseHp: Int, + val baseSpeed: Float, + val reward: Int, + val spriteRow: Int +) { + fun getPuddleSlowMultiplier(enemyType: EnemyType): Float { + return when (enemyType) { + EnemyType.DELIVERY_RIDER -> 0.2f + EnemyType.AUNTIE -> 0.8f + else -> 0.6f + } + } + + fun updateSpecialBehavior(enemy: Enemy, currentTimeMs: Long): Enemy { + if (type == EnemyType.TOURIST) { + var isStopped = enemy.isStopped + var stopDurationMs = enemy.stopDurationMs + var lastStopMs = enemy.lastStopMs + + if (isStopped) { + stopDurationMs -= 32 + if (stopDurationMs <= 0) { + isStopped = false + lastStopMs = currentTimeMs + } + } else if (currentTimeMs - lastStopMs > 8000) { + isStopped = true + stopDurationMs = 2000L + } + return enemy.copy(isStopped = isStopped, stopDurationMs = stopDurationMs, lastStopMs = lastStopMs) + } + return enemy + } + + fun getHp(wave: Int): Int { + return (baseHp * Math.pow(1.1, (wave - 1).toDouble())).toInt() + } + + fun toEnemy(id: String = UUID.randomUUID().toString(), wave: Int, position: PreciseAxialCoordinate, path: List, isFacingLeft: Boolean): Enemy { + val hp = getHp(wave) + return Enemy( + id = id, + type = type, + health = hp, + maxHealth = hp, + position = position, + baseSpeed = baseSpeed, + currentSpeed = baseSpeed, + path = path, + currentPathIndex = 0, + reward = reward, + isFacingLeft = isFacingLeft + ) + } +} + +object StallRegistry { + private val definitions = mapOf( + StallType.TEH_TARIK to StallDefinition( + type = StallType.TEH_TARIK, + name = "Teh Tarik", + cost = 150, + color = Color.Blue, + range = 3f, + damage = 10, + fireRateMs = 1000L, + description = "Creates slowing puddles", + tutorialTitle = "Teh Tarik Maestro (Movement Slow)", + signatureMove = "The Perpetual Tarik Puddle", + tutorialDescription = "Welcome to the Teh Tarik Maestro, where the art of 'pulling' tea is a high-level tactical maneuver. This Maestro doesn't just make your enemies slower; he makes the very ground they walk on sticky. Utilizing a massive pair of custom cups, he performs a continuous, mesmerizing 'tarik' high in the air. Each 'pull' perfectly places a wide, frothy Perpetual Tarik Puddle of viscous, sweet milk tea. The tea is so thick and syrupy that enemies stepping into it are immediately bogged down, their speed cut in half as they struggle through the delicious, sticky mess. A crowd favorite for slowing the rush.", + spriteRect = IntRect(22, 41, 330, 451), + effectDurationMs = 3000L + ), + StallType.SATAY to StallDefinition( + type = StallType.SATAY, + name = "Satay", + cost = 200, + color = Color.Red, + range = 2.5f, + damage = 20, + fireRateMs = 1500, + description = "Area chili sauce damage", + tutorialTitle = "Uncle's Satay Stall (AoE Damage)", + signatureMove = "The Chili Conflagration", + tutorialDescription = "Wah, smells so shiok! Behind this unassuming grill, the Satay Uncle is fanning a fiery revolution. Watch out for his signature Chili Conflagration—the chili isn't just spicy; it's explosive. He loads up a massive spoon and, with a precision usually reserved for satay-counting, launches a gigantic splash of his secret, explosive chili sauce. When it hits, it covers a wide circle, dousing groups of enemies in a sticky, burning chili storm that eats away at their health (and their willpower). If you need a crowd-control burn, this Uncle is the OG.", + spriteRect = IntRect(358, 41, 666, 451), + aoeRadius = 1.0f, + projectileSpeed = 0.3f, + isArc = true, + projectileColor = Color.White, + visualEffectType = VisualEffectType.GAS_CLOUD, + visualEffectColor = Color.Red.copy(alpha = 0.3f), + visualEffectDuration = 500L + ), + StallType.CHICKEN_RICE to StallDefinition( + type = StallType.CHICKEN_RICE, + name = "Chicken Rice", + cost = 100, + color = Color.Yellow, + range = 4f, + damage = 15, + fireRateMs = 700, + description = "High single-target damage", + tutorialTitle = "Ah Hock’s Chicken Rice Stand (Single-Target DPS)", + signatureMove = "The Garlic-Ginger Gatling Gun", + tutorialDescription = "Ah Hock’s Chicken Rice is famous for two things: the tenderest steamed chicken and the single-minded focus of his attacks. Don’t be fooled by the simple setup; this stand is your base single-target workhorse. When an enemy is targeted, Ah Hock deploys his Garlic-Ginger Gatling Gun. Instead of bullets, he’s launching high-velocity, precision-aimed balls of marinated meat, dousing targets in flavor-infused damage. It’s consistent, it’s powerful, and it never runs out of stock. A classic choice that never fails.", + spriteRect = IntRect(22, 500, 330, 930) + ), + StallType.DURIAN to StallDefinition( + type = StallType.DURIAN, + name = "Durian", + cost = 300, + color = Color(0xFF4CAF50), + range = 3f, + damage = 120, + fireRateMs = 2000, + description = "Massive damage, slow fire", + tutorialTitle = "The King Durian Bunker (High Damage/Slight AoE)", + signatureMove = "The Spiky Cataclysm", + tutorialDescription = "They call the Durian the King of Fruits, and this stall is the King of Damage. The King Durian Bunker is fortified with armor-plating and smells… well, like a durian. When the King’s crew makes a sale, they aren't selling just fruit; they are deploying a localized explosive. Using a heavy-duty pneumatic launcher, they fire an overripe, spikey Durian bomb into the largest cluster of enemies. Upon impact, it delivers a high-damage, single-target blow, followed immediately by a Spiky Cataclysm AoE explosion as the potent, heavy aroma bursts outward. It’s high-cost and slow-reloading, but the raw damage (and the scent) is devastating.", + spriteRect = IntRect(33, 961, 341, 1318), + aoeRadius = 1.0f, + visualEffectColor = Color(0xFFCDDC39).copy(alpha = 0.5f) + ), + StallType.ICE_KACHANG to StallDefinition( + type = StallType.ICE_KACHANG, + name = "Ice Kachang", + cost = 250, + color = Color.Cyan, + range = 3.5f, + damage = 2, + fireRateMs = 1500, + description = "Freezes enemies in place", + tutorialTitle = "Auntie's Ice Kachang Cart (Stun/Freezer)", + signatureMove = "The Absolute Zero Brain Freeze", + tutorialDescription = "Want something to really chill out the enemies? Then you need the Auntie at the Ice Kachang Cart! She’s taken traditional dessert techniques to the cryo-level. Her specialized ice shaver can launch a massive, compacted ball of shaved ice, syrup, and cold, cold, red beans, aimed precisely at the lead enemy. Upon impact, it doesn't just damage; it delivers an Absolute Zero Brain Freeze. The target is frozen solid, encased in a giant colorful ice cube, completely immobilized for several precious seconds. A perfect stall for controlling boss units.", + spriteRect = IntRect(358, 500, 666, 930), + freezeDurationMs = 500L + ) + ) + + fun get(type: StallType): StallDefinition = definitions[type]!! + fun all(): List = definitions.values.toList() +} + +object EnemyRegistry { + private val definitions = mapOf( + EnemyType.SALARYMAN to EnemyDefinition( + type = EnemyType.SALARYMAN, + name = "Salaryman", + description = "The fast-paced office worker. They move quickly across the grid, eager to reach their destination. Their high speed makes them difficult to hit, but they don't have much health.", + baseHp = 50, + baseSpeed = 0.08f, + reward = 20, + spriteRow = 2 + ), + EnemyType.TOURIST to EnemyDefinition( + type = EnemyType.TOURIST, + name = "Tourist", + description = "A curious visitor who frequently stops to take pictures of the local sights. While stationary, they are easy targets for your stalls, but they have more health than a Salaryman.", + baseHp = 100, + baseSpeed = 0.04f, + reward = 20, + spriteRow = 1 + ), + EnemyType.AUNTIE to EnemyDefinition( + type = EnemyType.AUNTIE, + name = "Auntie", + description = "A veteran of the hawker scene. She moves slowly and deliberately, but possesses high health. It takes sustained fire from multiple stalls to stop her progress.", + baseHp = 150, + baseSpeed = 0.03f, + reward = 20, + spriteRow = 0 + ), + EnemyType.DELIVERY_RIDER to EnemyDefinition( + type = EnemyType.DELIVERY_RIDER, + name = "Delivery Rider", + description = "A formidable boss on two wheels. He has massive health and moves at a significant speed. He is particularly cautious on wet surfaces, slowing down considerably when passing through sticky puddles.", + baseHp = 500, + baseSpeed = 0.06f, + reward = 100, + spriteRow = 3 + ) + ) + + fun get(type: EnemyType): EnemyDefinition = definitions[type]!! +} diff --git a/app/src/main/java/com/messark/hawkerrush/ui/components/GameBoard.kt b/app/src/main/java/com/messark/hawkerrush/ui/components/GameBoard.kt index 5821b4f..a6b4031 100644 --- a/app/src/main/java/com/messark/hawkerrush/ui/components/GameBoard.kt +++ b/app/src/main/java/com/messark/hawkerrush/ui/components/GameBoard.kt @@ -26,6 +26,8 @@ import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.dp import com.messark.hawkerrush.R import com.messark.hawkerrush.model.* +import com.messark.hawkerrush.registry.EnemyRegistry +import com.messark.hawkerrush.registry.StallRegistry import com.messark.hawkerrush.ui.constants.SpriteConstants import com.messark.hawkerrush.utils.GridUtils import java.util.Comparator @@ -256,7 +258,7 @@ fun GameBoard( // 3. Stalls tile.stall?.let { stall -> - val stallSrcRect = SpriteConstants.STALL_RECTS[stall.stallType] ?: SpriteConstants.STALL_RECTS[StallType.CHICKEN_RICE]!! + val stallSrcRect = StallRegistry.get(stall.stallType).spriteRect drawables.add(DrawableEntity( q = coord.q.toFloat(), @@ -414,7 +416,8 @@ fun GameBoard( enemies.forEach { enemy -> val screenPos = toScreenPrecise(enemy.position.q, enemy.position.r) - val rowIndex = SpriteConstants.ENEMY_ROW_INDICES[enemy.type] ?: 0 + val enemyDef = EnemyRegistry.get(enemy.type) + val rowIndex = enemyDef.spriteRow val frameIndex = ((enemy.animationTimeMs / 500) % SpriteConstants.ENEMY_SPRITE_FRAMES).toInt() val srcRect = IntRect( diff --git a/app/src/main/java/com/messark/hawkerrush/ui/components/GameControlPanel.kt b/app/src/main/java/com/messark/hawkerrush/ui/components/GameControlPanel.kt index 5d5a5a6..fe9cdd3 100644 --- a/app/src/main/java/com/messark/hawkerrush/ui/components/GameControlPanel.kt +++ b/app/src/main/java/com/messark/hawkerrush/ui/components/GameControlPanel.kt @@ -100,7 +100,7 @@ fun GameControlPanel( modifier = Modifier .size(24.dp) .border(1.dp, Color.White, androidx.compose.foundation.shape.CircleShape) - .clickable { + .clickable { onShowStallTutorial(selectedStall.stallType) onTriggerHaptic() }, 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 cbf4938..2de76cd 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( Text(text = "No upgrades", color = Color.Gray, fontSize = 10.sp) } else { val upgradeText = stall.upgrades.entries.joinToString(", ") { (key, value) -> - val benefit = stall.getUpgradeBenefit(key, value, baseStall) + val benefit = stall.getUpgradeBenefit(key, value) if (benefit.isNotEmpty()) "$key: $value ($benefit)" else "$key: $value" } Text(text = upgradeText, color = Color.Gray, fontSize = 10.sp) @@ -81,7 +81,7 @@ fun StallConsole( modifier = Modifier.padding(bottom = 2.dp) ) } - + Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) { SpriteButton( normalRect = SpriteConstants.BTN_UPGRADE_RECT, diff --git a/app/src/main/java/com/messark/hawkerrush/ui/components/StallSlot.kt b/app/src/main/java/com/messark/hawkerrush/ui/components/StallSlot.kt index e2eb0cf..5345da1 100644 --- a/app/src/main/java/com/messark/hawkerrush/ui/components/StallSlot.kt +++ b/app/src/main/java/com/messark/hawkerrush/ui/components/StallSlot.kt @@ -18,8 +18,7 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.messark.hawkerrush.model.Stall -import com.messark.hawkerrush.model.StallType -import com.messark.hawkerrush.ui.constants.SpriteConstants +import com.messark.hawkerrush.registry.StallRegistry @Composable fun StallSlot( @@ -29,7 +28,7 @@ fun StallSlot( onClick: () -> Unit, stallsSheet: ImageBitmap ) { - val spriteRect = SpriteConstants.STALL_RECTS[stall.stallType] ?: SpriteConstants.STALL_RECTS[StallType.CHICKEN_RICE]!! + val spriteRect = StallRegistry.get(stall.stallType).spriteRect Box( modifier = Modifier diff --git a/app/src/main/java/com/messark/hawkerrush/ui/components/TutorialOverlay.kt b/app/src/main/java/com/messark/hawkerrush/ui/components/TutorialOverlay.kt index e68d4f9..103f94c 100644 --- a/app/src/main/java/com/messark/hawkerrush/ui/components/TutorialOverlay.kt +++ b/app/src/main/java/com/messark/hawkerrush/ui/components/TutorialOverlay.kt @@ -28,6 +28,8 @@ import com.messark.hawkerrush.R import com.messark.hawkerrush.SpriteButton import com.messark.hawkerrush.model.TutorialData import com.messark.hawkerrush.model.TutorialType +import com.messark.hawkerrush.registry.EnemyRegistry +import com.messark.hawkerrush.registry.StallRegistry import com.messark.hawkerrush.ui.constants.SpriteConstants import kotlinx.coroutines.delay @@ -90,7 +92,8 @@ fun TutorialOverlay( contentAlignment = Alignment.Center ) { if (tutorialData.enemyType != null) { - val rowIndex = SpriteConstants.ENEMY_ROW_INDICES[tutorialData.enemyType] ?: 0 + val enemyDef = EnemyRegistry.get(tutorialData.enemyType) + val rowIndex = enemyDef.spriteRow val srcRect = IntRect( left = frameIndex * SpriteConstants.ENEMY_SPRITE_WIDTH, top = rowIndex * SpriteConstants.ENEMY_SPRITE_HEIGHT, @@ -118,7 +121,7 @@ fun TutorialOverlay( } } } else if (tutorialData.stallType != null) { - val srcRect = SpriteConstants.STALL_RECTS[tutorialData.stallType] ?: IntRect(0, 0, 100, 100) + val srcRect = StallRegistry.get(tutorialData.stallType).spriteRect Canvas(modifier = Modifier.fillMaxSize(0.8f)) { val scale = Math.min(size.width / srcRect.width, size.height / srcRect.height) val drawWidth = srcRect.width * scale diff --git a/app/src/main/java/com/messark/hawkerrush/ui/constants/SpriteConstants.kt b/app/src/main/java/com/messark/hawkerrush/ui/constants/SpriteConstants.kt index 5f004e7..c0ccf2d 100644 --- a/app/src/main/java/com/messark/hawkerrush/ui/constants/SpriteConstants.kt +++ b/app/src/main/java/com/messark/hawkerrush/ui/constants/SpriteConstants.kt @@ -22,26 +22,11 @@ object SpriteConstants { val PILLAR_RECT = IntRect(31, 501, 101, 627) val GOAL_TABLE_RECT = IntRect(1100, 430, 1363, 628) - val STALL_RECTS = mapOf( - StallType.TEH_TARIK to IntRect(22, 41, 330, 451), - StallType.SATAY to IntRect(358, 41, 666, 451), - StallType.CHICKEN_RICE to IntRect(22, 500, 330, 930), - StallType.ICE_KACHANG to IntRect(358, 500, 666, 930), - StallType.DURIAN to IntRect(33, 961, 341, 1318) - ) - // Enemies (from drawable-nodpi/enemies.png) const val ENEMY_SPRITE_WIDTH = 100 const val ENEMY_SPRITE_HEIGHT = 125 const val ENEMY_SPRITE_FRAMES = 3 - val ENEMY_ROW_INDICES = mapOf( - com.messark.hawkerrush.model.EnemyType.AUNTIE to 0, - com.messark.hawkerrush.model.EnemyType.TOURIST to 1, - com.messark.hawkerrush.model.EnemyType.SALARYMAN to 2, - com.messark.hawkerrush.model.EnemyType.DELIVERY_RIDER to 3 - ) - val FX_PUDDLE_RECT = IntRect(1078, 679, 1142, 741) // Buttons (from drawable-nodpi/buttons.png) diff --git a/app/src/test/java/com/messark/hawkerrush/MilestoneBoostTest.kt b/app/src/test/java/com/messark/hawkerrush/MilestoneBoostTest.kt index eb93509..f4ce3b8 100644 --- a/app/src/test/java/com/messark/hawkerrush/MilestoneBoostTest.kt +++ b/app/src/test/java/com/messark/hawkerrush/MilestoneBoostTest.kt @@ -45,12 +45,12 @@ class MilestoneBoostTest { // Level 9: 15 + 9 * ( (15*0.3).toInt() + 2 ) = 15 + 9 * 6 = 69 // Increase is 54. 54/15 = 3.6 -> 360% - val benefit9 = baseStall.getUpgradeBenefit("Damage", 9, baseStall) + val benefit9 = baseStall.getUpgradeBenefit("Damage", 9) assertEquals("+360%", benefit9) // Level 10: (15 + 10 * 6) * 1.25 = 75 * 1.25 = 93.75 -> 94 // Increase is 94 - 15 = 79. 79/15 = 5.266... -> 527% - val benefit10 = baseStall.getUpgradeBenefit("Damage", 10, baseStall) + val benefit10 = baseStall.getUpgradeBenefit("Damage", 10) assertEquals("+527%", benefit10) }