diff --git a/AGENTS.md b/AGENTS.md index 97b7813..c9049db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,11 +33,21 @@ This document provides a comprehensive guide for AI agents working on the Hawker - **Sorting Logic:** Within a zOrder group, sort by `r` (row), then `zOrder`, then `q` (column) to ensure correct isometric depth. ### Stalls & Upgrades -- **Stall Types:** Teh Tarik (Slow), Satay (AOE), Chicken Rice (Single Target), Durian (High Damage/Slow Fire), Ice Kachang (Freeze), Bak Kut Teh (Booster). -- **Upgrades:** Additive scaling based on base stats. Costs increase linearly (+10% of base cost per level). Bak Kut Teh only upgrades its Boost percentage. +- **Stall Types:** Teh Tarik (Slow), Satay (AOE), Chicken Rice (Single Target), Durian (High Damage/Slow Fire), Ice Kachang (Freeze), Bak Kut Teh (Booster), ATM (Income). +- **Upgrade Model:** Derivation-based $O(\text{level})$ scaling. All stat values are recalculated from the base definition using the current upgrade levels in the `upgrades` map. This prevents precision drift and ensures consistency between UI previews and the engine. +- **Scaling Rules:** + - **Damage:** Multiplicative (1.15x per level). Chicken Rice (cost $100 variant) uses a flat +6 per level. + - **Range:** Additive (+0.5 per level). + - **Radius:** Additive (+0.2 per level). + - **Fire Rate:** Linear reduction with stall-specific floors (e.g., Chicken Rice -15ms floor 200ms). + - **Duration/Effect:** Additive (Duration +500ms, Effect +100ms, Uncle Duration +100ms). + - **Boost (Bak Kut Teh):** Additive (+20% per level). +- **Milestone Boost:** Every 10th level applies a 1.25x multiplier (or 0.75x for rate) to the value. +- **Stat Aliasing:** Some UI stats are aliased (e.g., "Grab Rate" $\rightarrow$ "Rate"). `StallUpgradeManager` normalizes these mappings upfront to prevent state-resetting bugs. +- **Costs:** Costs increase linearly: $\text{Base} \times (0.2 + \text{next\_level} \times 0.1)$. Specific upgrades cost double and apply a `disabledWaves` penalty unless a Kitchelin Star is used. - **Selling:** Provides a 50% refund of the total investment (base cost + upgrades). - **Targeting:** Supports FIRST, CLOSEST, STRONGEST, and WEAKEST strategies. -- **Legendary Names:** Stalls receive a 'legendary' suffix when their first upgrade category hits Level 10, and a 'legendary' prefix when a second, different category hits Level 10. These names are managed via `LegendaryNames.kt`. +- **Legendary Names:** Stalls receive a 'legendary' suffix when their first upgrade category hits Level 10, and a 'legendary' prefix when a second, different category hits Level 10. ### Kitchelin Stars - **Awards:** Awarded every 10th wave. diff --git a/app/src/main/java/com/messark/hawker/utils/StallUpgradeManager.kt b/app/src/main/java/com/messark/hawker/utils/StallUpgradeManager.kt index 9adbcdf..1fb18bd 100644 --- a/app/src/main/java/com/messark/hawker/utils/StallUpgradeManager.kt +++ b/app/src/main/java/com/messark/hawker/utils/StallUpgradeManager.kt @@ -34,91 +34,135 @@ object StallUpgradeManager { } } - fun applyUpgrade(stall: Stall, statName: String, upgradeCost: Int, isSpecific: Boolean): Stall { - val mutableUpgrades = stall.upgrades.toMutableMap() - val newLevel = mutableUpgrades.getOrDefault(statName, 0) + 1 - mutableUpgrades[statName] = newLevel - - var newDamage = stall.damage - var newRange = stall.range - var newFireRate = stall.fireRateMs - var newAoeRadius = stall.aoeRadius - var newEffectDuration = stall.effectDurationMs - var newFreezeDuration = stall.freezeDurationMs - var disabledWaves = stall.disabledWaves - - val isMilestone = newLevel % 10 == 0 + /** + * Maps UI-friendly or stall-specific stat names to their internal canonical keys. + */ + private fun getCanonicalStat(statName: String): String { + return when (statName) { + "Grab Rate" -> "Rate" + "Cleaning Time" -> "Duration" + else -> statName + } + } - when (statName) { - "Damage" -> { - if (stall.stallType == StallType.CHICKEN_RICE && stall.cost == 100) { - newDamage += 6 - } else { - newDamage = (newDamage * 1.15f).roundToInt() - } - if (isMilestone) newDamage = (newDamage * 1.25f).roundToInt() - } - "Range" -> { - newRange += 0.5f - if (isMilestone) newRange *= 1.25f - } - "Rate", "Grab Rate" -> { - val baseStall = StallRegistry.get(stall.stallType) - val rateReduction = when (stall.stallType) { - StallType.TRAY_RETURN_UNCLE -> 100L - StallType.CHICKEN_RICE -> 15L - StallType.DURIAN -> 50L - StallType.SATAY -> 25L - else -> (baseStall.fireRateMs * 0.1f).toLong() + /** + * Calculates the final value for a given stat based on its level and stall type. + * Note: This recalculates the value from scratch (O(level)) to ensure absolute + * consistency between UI previews and the actual game state, and to prevent + * floating-point drift over many upgrades. + */ + private fun calculateValue( + statName: String, + baseValue: Double, + level: Int, + stallType: StallType + ): Double { + var current = baseValue + val baseStall = StallRegistry.get(stallType) + + for (l in 1..level) { + val isMilestone = l % 10 == 0 + when (statName) { + "Damage" -> { + if (stallType == StallType.CHICKEN_RICE && baseStall.cost == 100) { + current += 6.0 + } else { + current = (current * 1.15).roundToInt().toDouble() + } + if (isMilestone) current = (current * 1.25).roundToInt().toDouble() } - var potentialRate = stall.fireRateMs - rateReduction - if (isMilestone) potentialRate = (potentialRate * 0.75).roundToLong() - - val floor = when (stall.stallType) { - StallType.TRAY_RETURN_UNCLE -> 10000L - StallType.CHICKEN_RICE -> 200L - StallType.DURIAN -> 1000L - StallType.SATAY -> 750L - else -> 50L + "Range" -> { + current += 0.5 + if (isMilestone) current *= 1.25 } + "Rate", "Grab Rate" -> { + val rateReduction = when (stallType) { + StallType.TRAY_RETURN_UNCLE -> 100.0 + StallType.CHICKEN_RICE -> 15.0 + StallType.DURIAN -> 50.0 + StallType.SATAY -> 25.0 + else -> baseValue * 0.1 + } + val floor = when (stallType) { + StallType.TRAY_RETURN_UNCLE -> 10000.0 + StallType.CHICKEN_RICE -> 200.0 + StallType.DURIAN -> 1000.0 + StallType.SATAY -> 750.0 + else -> 50.0 + } - if (stall.fireRateMs <= floor && statName == "Rate") { - newFireRate = stall.fireRateMs - } else { - newFireRate = max(floor, potentialRate) + if (current > floor) { + var potentialRate = current - rateReduction + if (isMilestone) potentialRate = (potentialRate * 0.75).roundToLong().toDouble() + current = max(floor, potentialRate) + } + } + "Radius" -> { + current += 0.2 + if (isMilestone) current *= 1.25 } + "Duration", "Cleaning Time" -> { + val increment = if (stallType == StallType.TRAY_RETURN_UNCLE) 100.0 else 500.0 + val cap = if (stallType == StallType.TRAY_RETURN_UNCLE) 4000.0 else Double.MAX_VALUE - if (stall.stallType == StallType.TRAY_RETURN_UNCLE) mutableUpgrades["Rate"] = newLevel - } - "Radius" -> { - newAoeRadius += 0.2f - if (isMilestone) newAoeRadius *= 1.25f - } - "Duration", "Cleaning Time" -> { - val increment = if (stall.stallType == StallType.TRAY_RETURN_UNCLE) 100L else 500L - var potentialDuration = stall.effectDurationMs + increment - if (isMilestone) potentialDuration = (potentialDuration * 1.25).roundToLong() - - val cap = if (stall.stallType == StallType.TRAY_RETURN_UNCLE) 4000L else Long.MAX_VALUE - newEffectDuration = min(cap, potentialDuration) - mutableUpgrades["Duration"] = newLevel - } - "Effect" -> { - newFreezeDuration += 100L - if (isMilestone) newFreezeDuration = (newFreezeDuration * 1.25).roundToLong() + current = min(cap, current + increment) + if (isMilestone) current = min(cap, (current * 1.25).roundToLong().toDouble()) + } + "Effect" -> { + current += 100.0 + if (isMilestone) current = (current * 1.25).roundToLong().toDouble() + } + "Boost" -> { + current += 20.0 + if (isMilestone) current = (current * 1.25).roundToInt().toDouble() + } } - "Boost" -> { - newDamage += 20 - if (isMilestone) newDamage = (newDamage * 1.25f).roundToInt() + } + return current + } + + fun applyUpgrade(stall: Stall, statName: String, upgradeCost: Int, isSpecific: Boolean): Stall { + val mutableUpgrades = stall.upgrades.toMutableMap() + + // Normalize: Ensure any existing aliased levels are synced to their canonical keys + // before we recalculate. This prevents "stat resetting" when canonical keys are missing. + listOf("Grab Rate", "Cleaning Time").forEach { alias -> + val canonical = getCanonicalStat(alias) + val level = max(mutableUpgrades.getOrDefault(alias, 0), mutableUpgrades.getOrDefault(canonical, 0)) + if (level > 0) { + mutableUpgrades[alias] = level + mutableUpgrades[canonical] = level } } + val canonicalStat = getCanonicalStat(statName) + + // Record the upgrade level + val newLevelForStat = mutableUpgrades.getOrDefault(statName, 0) + 1 + mutableUpgrades[statName] = newLevelForStat + + // Sync to canonical stat key if it differs (e.g., "Grab Rate" -> "Rate") + if (canonicalStat != statName) { + mutableUpgrades[canonicalStat] = newLevelForStat + } + + val baseDef = StallRegistry.get(stall.stallType) + + // Recalculate all fields from the canonical levels in the map + val damageStat = if (stall.stallType == StallType.BAK_KUT_TEH) "Boost" else "Damage" + val newDamage = calculateValue(damageStat, baseDef.damage.toDouble(), mutableUpgrades.getOrDefault(damageStat, 0), stall.stallType).toInt() + val newRange = calculateValue("Range", baseDef.range.toDouble(), mutableUpgrades.getOrDefault("Range", 0), stall.stallType).toFloat() + val newFireRate = calculateValue("Rate", baseDef.fireRateMs.toDouble(), mutableUpgrades.getOrDefault("Rate", 0), stall.stallType).toLong() + val newAoeRadius = calculateValue("Radius", baseDef.aoeRadius.toDouble(), mutableUpgrades.getOrDefault("Radius", 0), stall.stallType).toFloat() + val newEffectDuration = calculateValue("Duration", baseDef.effectDurationMs.toDouble(), mutableUpgrades.getOrDefault("Duration", 0), stall.stallType).toLong() + val newFreezeDuration = calculateValue("Effect", baseDef.freezeDurationMs.toDouble(), mutableUpgrades.getOrDefault("Effect", 0), stall.stallType).toLong() + // Legendary Naming var newPrefix = stall.legendaryPrefix var newSuffix = stall.legendarySuffix val newNamingCategories = stall.namingCategories.toMutableList() - if (newLevel == 10 && !stall.namingCategories.contains(statName)) { + if (newLevelForStat == 10 && !stall.namingCategories.contains(statName)) { val legendaryCat = when (statName) { "Grab Rate" -> "Rate" "Cleaning Time" -> "Duration" @@ -134,6 +178,7 @@ object StallUpgradeManager { } val newName = LegendaryNames.constructName(stall.baseName, newPrefix, newSuffix) + var disabledWaves = stall.disabledWaves if (isSpecific && upgradeCost > 0) { disabledWaves += 1 } @@ -159,95 +204,38 @@ object StallUpgradeManager { fun getBenefitString(category: String, level: Int, baseStall: StallDefinition): String { if (level <= 0) return "" + val canonicalStat = getCanonicalStat(category) + val finalValue = calculateValue(canonicalStat, when(canonicalStat) { + "Damage", "Boost" -> baseStall.damage.toDouble() + "Range" -> baseStall.range.toDouble() + "Rate" -> baseStall.fireRateMs.toDouble() + "Radius" -> baseStall.aoeRadius.toDouble() + "Duration" -> baseStall.effectDurationMs.toDouble() + "Effect" -> baseStall.freezeDurationMs.toDouble() + else -> 0.0 + }, level, baseStall.type) + return when (category) { "Damage" -> { - var currentDamage = baseStall.damage.toFloat() - for (l in 1..level) { - if (baseStall.type == StallType.CHICKEN_RICE && baseStall.cost == 100) { - currentDamage += 6 - } else { - currentDamage *= 1.15f - } - if (l % 10 == 0) currentDamage *= 1.25f - } - val diff = currentDamage.roundToInt() - baseStall.damage + val diff = finalValue.roundToInt() - baseStall.damage val percentage = if (baseStall.damage > 0) { - (diff.toFloat() / baseStall.damage * 100).roundToInt() + (diff.toDouble() / baseStall.damage * 100).roundToInt() } else 0 "+$percentage%" } "Grab Rate", "Rate" -> { - var currentRate = baseStall.fireRateMs - val rateReduction = when (baseStall.type) { - StallType.TRAY_RETURN_UNCLE -> 100L - StallType.CHICKEN_RICE -> 15L - StallType.DURIAN -> 50L - StallType.SATAY -> 25L - else -> (baseStall.fireRateMs * 0.1f).toLong() - } - val floor = when (baseStall.type) { - StallType.TRAY_RETURN_UNCLE -> 10000L - StallType.CHICKEN_RICE -> 200L - StallType.DURIAN -> 1000L - StallType.SATAY -> 750L - else -> 50L - } - - for (l in 1..level) { - var potentialRate = currentRate - rateReduction - if (l % 10 == 0) potentialRate = (potentialRate * 0.75).roundToLong() - currentRate = max(floor, potentialRate) - } if (baseStall.type == StallType.TRAY_RETURN_UNCLE) { - "-${baseStall.fireRateMs - currentRate}ms" + "-${(baseStall.fireRateMs - finalValue).roundToLong()}ms" } else { - val percentage = ((baseStall.fireRateMs - currentRate).toFloat() / baseStall.fireRateMs * 100).roundToInt() + val percentage = ((baseStall.fireRateMs - finalValue) / baseStall.fireRateMs * 100).roundToInt() "+$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)}" - } - "Cleaning Time", "Duration" -> { - var currentDuration = baseStall.effectDurationMs - val increment = if (baseStall.type == StallType.TRAY_RETURN_UNCLE) 100L else 500L - val cap = if (baseStall.type == StallType.TRAY_RETURN_UNCLE) 4000L else Long.MAX_VALUE - - for (l in 1..level) { - currentDuration = min(cap, currentDuration + increment) - if (l % 10 == 0) currentDuration = min(cap, (currentDuration * 1.25).roundToLong()) - } - "+${currentDuration - baseStall.effectDurationMs}ms" - } - "Effect" -> { - var currentEffect = baseStall.freezeDurationMs - for (l in 1..level) { - currentEffect += 100 - if (l % 10 == 0) currentEffect = (currentEffect * 1.25).roundToLong() - } - "+${currentEffect - baseStall.freezeDurationMs}ms" - } - "Boost" -> { - var currentBoost = baseStall.damage.toFloat() - for (l in 1..level) { - currentBoost += 20f - if (l % 10 == 0) currentBoost *= 1.25f - } - "+${(currentBoost - baseStall.damage).roundToInt()}%" - } + "Range" -> "+${String.format("%.1f", finalValue - baseStall.range)}" + "Radius" -> "+${String.format("%.1f", finalValue - baseStall.aoeRadius)}" + "Cleaning Time", "Duration" -> "+${(finalValue - baseStall.effectDurationMs).roundToLong()}ms" + "Effect" -> "+${(finalValue - baseStall.freezeDurationMs).roundToLong()}ms" + "Boost" -> "+${(finalValue - baseStall.damage).roundToInt()}%" else -> "" } } diff --git a/app/src/test/java/com/messark/hawker/AliasNormalizationTest.kt b/app/src/test/java/com/messark/hawker/AliasNormalizationTest.kt new file mode 100644 index 0000000..14613b0 --- /dev/null +++ b/app/src/test/java/com/messark/hawker/AliasNormalizationTest.kt @@ -0,0 +1,43 @@ +package com.messark.hawker + +import com.messark.hawker.model.Stall +import com.messark.hawker.model.StallType +import com.messark.hawker.registry.StallRegistry +import com.messark.hawker.utils.StallUpgradeManager +import org.junit.Assert.assertEquals +import org.junit.Test +import java.util.* + +class AliasNormalizationTest { + + @Test + fun `upgrading Cleaning Time does not reset Grab Rate if canonical key is missing`() { + val baseUncle = StallRegistry.get(StallType.TRAY_RETURN_UNCLE).toStall() + + // Simulate legacy state: Grab Rate has been upgraded to level 5, but canonical "Rate" is missing + val legacyStall = baseUncle.copy( + upgrades = mapOf("Grab Rate" to 5), + fireRateMs = 14500L // level 5 value + ) + + // Act: Upgrade Cleaning Time + val upgradedStall = StallUpgradeManager.applyUpgrade( + stall = legacyStall, + statName = "Cleaning Time", + upgradeCost = 100, + isSpecific = true + ) + + // Assert: + // 1. Grab Rate should still be 5 + // 2. Canonical Rate should now be 5 (normalized) + // 3. FireRateMs should NOT be reset to 15000L + assertEquals(5, upgradedStall.upgrades["Grab Rate"]) + assertEquals(5, upgradedStall.upgrades["Rate"]) + assertEquals(1, upgradedStall.upgrades["Cleaning Time"]) + assertEquals(1, upgradedStall.upgrades["Duration"]) + + assertEquals(14500L, upgradedStall.fireRateMs) + assertEquals(2100L, upgradedStall.effectDurationMs) // base 2000 + 100 + } +}