Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
282 changes: 135 additions & 147 deletions app/src/main/java/com/messark/hawker/utils/StallUpgradeManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment thread
candour marked this conversation as resolved.

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()
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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"
Expand All @@ -134,6 +178,7 @@ object StallUpgradeManager {
}
val newName = LegendaryNames.constructName(stall.baseName, newPrefix, newSuffix)

var disabledWaves = stall.disabledWaves
if (isSpecific && upgradeCost > 0) {
disabledWaves += 1
}
Expand All @@ -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)}"
Comment thread
candour marked this conversation as resolved.
"Cleaning Time", "Duration" -> "+${(finalValue - baseStall.effectDurationMs).roundToLong()}ms"
"Effect" -> "+${(finalValue - baseStall.freezeDurationMs).roundToLong()}ms"
"Boost" -> "+${(finalValue - baseStall.damage).roundToInt()}%"
else -> ""
}
}
Expand Down
43 changes: 43 additions & 0 deletions app/src/test/java/com/messark/hawker/AliasNormalizationTest.kt
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading