fix: resolve ModifierCache StackOverflow and enforce modifier equality contract (Closes #81) (#123)

This commit was merged in pull request #123.
This commit is contained in:
2026-04-04 22:51:09 +02:00
parent ca0e17c89d
commit c8a83379e1
7 changed files with 227 additions and 14 deletions
@@ -18,10 +18,11 @@ data class Attributes(
private val intuition: Attribute,
private val charisma: Attribute,
val edge: Int,
@Transient
private val cache: ModifierCache = ModifierCache(),
override val version: String = SchemaVersion.CURRENT
): Versionable {
@Transient
private val cache: ModifierCache = ModifierCache()
private fun applyModifiers(modifiers: List<AttributeModifier>): Attributes {
return cache.applyModifiers(modifiers, this)
}
@@ -33,10 +33,11 @@ data class DamageMonitor(
private val stunCurrent: Int = 0,
private val physicalCurrent: Int = 0,
private val overflowCurrent: Int = 0,
@Transient
private val cache: ModifierCache = ModifierCache(),
override val version: String = SchemaVersion.CURRENT
): Versionable {
@Transient
private val cache: ModifierCache = ModifierCache()
init {
require(stunCurrent >= 0) { "Stun damage cannot be negative" }
require(stunCurrent <= stunMax()) { "Stun damage cannot exceed maximum stun damage of ${stunMax()}" }
@@ -2,4 +2,10 @@ package org.shahondin1624.model.modifier
import org.shahondin1624.model.attributes.Attributes
/**
* Modifier that transforms [Attributes].
*
* Implementations **must** override [equals] and [hashCode] (e.g., by using a `data class`)
* so that [ModifierCache] can correctly identify equivalent modifier lists.
*/
interface AttributeModifier: SRModifier<Attributes>
@@ -2,20 +2,30 @@ package org.shahondin1624.model.modifier
import org.shahondin1624.model.attributes.Attributes
data class ModifierCache(
private val cache: LinkedHashMap<List<AttributeModifier>, Attributes> = linkedMapOf()
/**
* LRU cache for modifier application results, keyed by modifier lists.
*
* This is intentionally **not** a data class to break the circular
* `hashCode()`/`equals()` chain between `ModifierCache` and [Attributes]:
* `Attributes` (data class) contains a `ModifierCache`, and the cache stores
* `Attributes` values. Making `ModifierCache` a data class caused
* `StackOverflowError` when the map computed hash codes of its entries.
*
* Cache lookups rely on [List.equals], which delegates to each element's
* `equals()`. Modifier implementations **must** override `equals()`/`hashCode()`
* (e.g., by using a `data class`) for cache hits to work correctly.
*/
class ModifierCache(
private val cache: LinkedHashMap<List<AttributeModifier>, Attributes> = linkedMapOf(),
private val maxSize: Int = 5
) {
fun applyModifiers(modifiers: List<AttributeModifier> = emptyList(), attributes: Attributes): Attributes {
if (cache.containsKey(modifiers)) {
return cache[modifiers]!!
}
cache[modifiers]?.let { return it }
val modified = modifiers.fold(attributes) { acc, modifier -> modifier.apply(acc) }
cache[modifiers] = modified
while (cache.size > 5) {
while (cache.size > maxSize) {
val oldestKey = cache.keys.firstOrNull()
oldestKey?.let {
cache.remove(oldestKey)
}
oldestKey?.let { cache.remove(it) }
}
return modified
}
@@ -2,6 +2,13 @@ package org.shahondin1624.model.modifier
import org.shahondin1624.lib.functions.DiceRoll
/**
* Base interface for all Shadowrun modifiers.
*
* **Contract:** Implementations **must** provide correct [equals] and [hashCode] overrides
* (e.g., by using a `data class`). [ModifierCache] uses modifier lists as cache keys,
* so cache lookups will miss if two equivalent modifiers are not considered equal.
*/
interface SRModifier<T> {
fun apply(value: T): T
fun getDiceRollModifier(diceRoll: DiceRoll): SRModifier<DiceRoll>
@@ -2,4 +2,10 @@ package org.shahondin1624.model.modifier
import org.shahondin1624.model.talents.TalentDefinition
/**
* Modifier that transforms [TalentDefinition].
*
* Implementations **must** override [equals] and [hashCode] (e.g., by using a `data class`)
* so that modifier-based cache lookups work correctly.
*/
interface TalentModifier: SRModifier<TalentDefinition>
@@ -0,0 +1,182 @@
package org.shahondin1624
import org.shahondin1624.lib.functions.DiceRoll
import org.shahondin1624.model.attributes.Attribute
import org.shahondin1624.model.attributes.AttributeType
import org.shahondin1624.model.attributes.Attributes
import org.shahondin1624.model.modifier.AttributeModifier
import org.shahondin1624.model.modifier.ModifierCache
import org.shahondin1624.model.modifier.SRModifier
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertSame
/**
* Tests that [ModifierCache] correctly identifies equivalent modifier lists
* when modifiers are recreated with the same values (issue #81).
*
* The key concern: if modifier implementations lack proper [equals]/[hashCode],
* the cache will miss on structurally identical modifier lists that use
* different object instances.
*/
class ModifierCacheEqualityTest {
private fun baseAttributes(body: Int = 3, agility: Int = 3) = Attributes(
body = Attribute(AttributeType.Body, body),
agility = Attribute(AttributeType.Agility, agility),
reaction = Attribute(AttributeType.Reaction, 3),
strength = Attribute(AttributeType.Strength, 3),
willpower = Attribute(AttributeType.Willpower, 3),
logic = Attribute(AttributeType.Logic, 3),
intuition = Attribute(AttributeType.Intuition, 3),
charisma = Attribute(AttributeType.Charisma, 3),
edge = 2
)
/**
* A data-class-based modifier that gets correct equals/hashCode automatically.
* This is the recommended pattern for implementing [AttributeModifier].
*/
private data class DataClassBodyModifier(val bonus: Int) : AttributeModifier {
override fun apply(value: Attributes): Attributes {
val currentBody = value.body()
return value.withAttribute(AttributeType.Body, currentBody + bonus)
}
override fun getDiceRollModifier(diceRoll: DiceRoll): SRModifier<DiceRoll> {
return object : SRModifier<DiceRoll> {
override fun apply(value: DiceRoll): DiceRoll =
value.copy(numberOfDice = value.numberOfDice + bonus)
override fun getDiceRollModifier(diceRoll: DiceRoll): SRModifier<DiceRoll> = this
}
}
}
/**
* A modifier with manually overridden equals/hashCode (alternative pattern).
*/
private class ManualEqualityModifier(private val multiplier: Int) : AttributeModifier {
override fun apply(value: Attributes): Attributes {
val currentAgility = value.agility()
return value.withAttribute(AttributeType.Agility, currentAgility * multiplier)
}
override fun getDiceRollModifier(diceRoll: DiceRoll): SRModifier<DiceRoll> {
return object : SRModifier<DiceRoll> {
override fun apply(value: DiceRoll): DiceRoll =
value.copy(numberOfDice = value.numberOfDice * multiplier)
override fun getDiceRollModifier(diceRoll: DiceRoll): SRModifier<DiceRoll> = this
}
}
override fun equals(other: Any?): Boolean =
other is ManualEqualityModifier && other.multiplier == multiplier
override fun hashCode(): Int = multiplier.hashCode()
}
// --- Cache hit tests with recreated modifiers ---
@Test
fun cacheHitsWhenDataClassModifierIsRecreated() {
val cache = ModifierCache()
val attrs = baseAttributes(body = 4)
// First call — populates the cache
val first = cache.applyModifiers(listOf(DataClassBodyModifier(2)), attrs)
// Second call — recreated modifier with same value (different object identity)
val second = cache.applyModifiers(listOf(DataClassBodyModifier(2)), attrs)
// Should be the exact same cached instance
assertSame(first, second, "Data-class modifier with same values should produce a cache hit")
assertEquals(6, first.body(), "Body should be 4 + 2 = 6")
}
@Test
fun cacheHitsWhenManualEqualityModifierIsRecreated() {
val cache = ModifierCache()
val attrs = baseAttributes(agility = 5)
val first = cache.applyModifiers(listOf(ManualEqualityModifier(2)), attrs)
val second = cache.applyModifiers(listOf(ManualEqualityModifier(2)), attrs)
assertSame(first, second, "Manually-equals modifier with same values should produce a cache hit")
assertEquals(10, first.agility(), "Agility should be 5 * 2 = 10")
}
@Test
fun cacheHitsWithRecreatedMultiModifierList() {
val cache = ModifierCache()
val attrs = baseAttributes(body = 3, agility = 4)
val first = cache.applyModifiers(
listOf(DataClassBodyModifier(1), ManualEqualityModifier(3)),
attrs
)
// Recreate both modifiers with same values
val second = cache.applyModifiers(
listOf(DataClassBodyModifier(1), ManualEqualityModifier(3)),
attrs
)
assertSame(first, second, "Recreated multi-modifier list should produce a cache hit")
}
@Test
fun cacheMissesWhenModifierValuesAreDifferent() {
val cache = ModifierCache()
val attrs = baseAttributes(body = 4)
val first = cache.applyModifiers(listOf(DataClassBodyModifier(2)), attrs)
val second = cache.applyModifiers(listOf(DataClassBodyModifier(3)), attrs)
assertEquals(6, first.body(), "First: body should be 4 + 2 = 6")
assertEquals(7, second.body(), "Second: body should be 4 + 3 = 7")
}
@Test
fun cacheMissesWhenModifierListOrderDiffers() {
val cache = ModifierCache()
val attrs = baseAttributes(body = 3, agility = 4)
val first = cache.applyModifiers(
listOf(DataClassBodyModifier(1), ManualEqualityModifier(2)),
attrs
)
val second = cache.applyModifiers(
listOf(ManualEqualityModifier(2), DataClassBodyModifier(1)),
attrs
)
// Different order = different key, so should not be the same object
// (even if results happen to differ due to fold order)
assertEquals(4, first.body(), "First: body should be 3 + 1 = 4")
assertEquals(4, second.body(), "Second: body should still be 3 + 1 = 4")
}
@Test
fun cacheHitAfterEvictionAndReinsertionWithRecreatedModifiers() {
val cache = ModifierCache()
val attrs = baseAttributes(body = 3)
// Fill cache to capacity (5 entries)
cache.applyModifiers(listOf(DataClassBodyModifier(1)), attrs)
cache.applyModifiers(listOf(DataClassBodyModifier(2)), attrs)
cache.applyModifiers(listOf(DataClassBodyModifier(3)), attrs)
cache.applyModifiers(listOf(DataClassBodyModifier(4)), attrs)
cache.applyModifiers(listOf(DataClassBodyModifier(5)), attrs)
// Add 6th — evicts the first entry (bonus=1)
cache.applyModifiers(listOf(DataClassBodyModifier(6)), attrs)
// Re-request the evicted entry with a recreated modifier
val recomputed = cache.applyModifiers(listOf(DataClassBodyModifier(1)), attrs)
assertEquals(4, recomputed.body(), "Body should be 3 + 1 = 4 after eviction and recomputation")
// Now it should be cached again — next call with same recreated modifier should hit
val cachedAgain = cache.applyModifiers(listOf(DataClassBodyModifier(1)), attrs)
assertSame(recomputed, cachedAgain, "Recreated modifier should hit cache after reinsertion")
}
}