TLDR: Enabling multithreading in Optaplanner is supposed to be a one-liner, but it throws an exception
I'm trying to optimize a damage calculation using configurable loadouts in a videogame. For context, a player may configure each item they own with a "reforge," which adds stats for strength or crit damage. The final damage calculation must be maximized as a combination of strength and crit damage. For this reason, I am using Optaplanner to allocate reforges to items.
However, enabling multithreading through <moveThreadCount>AUTO</moveThreadCount>
in the XML solver config throws an exception (that does not occur in single-threaded execution):
Caused by: java.lang.IllegalStateException: The externalObject (ReforgeProblemFact(id=897f4bab-80e0-4eb9-a1d7-974f7cddfd9e, name=Fierce, rarity=COMMON, strength=4, critDamage=0)) with planningId ((class net.javaman.optaplanner_reproducible.ReforgeProblemFact,897f4bab-80e0-4eb9-a1d7-974f7cddfd9e)) has no known workingObject (null).
Maybe the workingObject was never added because the planning solution doesn't have a @ProblemFactCollectionProperty annotation on a member with instances of the externalObject's class (class net.javaman.optaplanner_reproducible.ReforgeProblemFact).
This SO question is similar, but its answer doesn't fix the exception in this example.
I removed packages and imports in the code below. Full GitHub repository link
Project structure:
src/main/
kotlin/
net/javaman/optaplanner_reproducible/
Rarity.kt
ReforgeProblemFact.kt
ItemPlanningEntity.kt
ReforgePlanningSolution.kt
MaximizeDamageConstraintProvider.kt
Main.kt
resources/
reforgeSolverConfig.xml
Rarity.kt:
enum class Rarity {
COMMON,
RARE,
LEGENDARY
}
ReforgeProblemFact.kt:
data class ReforgeProblemFact(
@PlanningId
val id: UUID,
val name: String,
val rarity: Rarity,
val strength: Int,
val critDamage: Int
)
ItemPlanningEntity.kt:
@PlanningEntity
data class ItemPlanningEntity @JvmOverloads constructor(
@PlanningId
val id: UUID? = null,
val rarity: Rarity? = null,
@PlanningVariable(valueRangeProviderRefs = ["reforgeRange"])
var reforge: ReforgeProblemFact? = null,
@ValueRangeProvider(id = "reforgeRange")
@ProblemFactCollectionProperty
val availableReforges: List<ReforgeProblemFact>? = null
)
ReforgePlanningSolution.kt:
@PlanningSolution
class ReforgePlanningSolution @JvmOverloads constructor(
@PlanningEntityCollectionProperty
val availableItems: List<ItemPlanningEntity>? = null,
@PlanningScore
val score: HardSoftScore? = null,
)
MaximizeDamageConstraintProvider.kt:
class MaximizeDamageConstraintProvider : ConstraintProvider {
override fun defineConstraints(factory: ConstraintFactory): Array<Constraint> = arrayOf(maximizeDamage(factory))
// This approach does not take full advantage of incremental solving,
// but it is necessary to compute strength and critDamage together in the same equation
private fun maximizeDamage(factory: ConstraintFactory) = factory.from(ItemPlanningEntity::class.java)
.map(ItemPlanningEntity::reforge) // Get each item's reforge
.groupBy({ 0 }, toList { reforge: ReforgeProblemFact? -> reforge }) // Compile into one List<ReforgeProblemFact>
.reward("damage", HardSoftScore.ONE_SOFT) { _, reforges: List<ReforgeProblemFact?> ->
val strengthSum = reforges.stream().collect(Collectors.summingInt { reforge -> reforge?.strength ?: 0 })
val critDamageSum = reforges.stream().collect(Collectors.summingInt { reforge -> reforge?.critDamage ?: 0 })
(100 + strengthSum) * (100 + critDamageSum)
}
}
Main.kt:
class Main {
companion object {
private val allReforges = listOf(
ReforgeProblemFact(UUID.randomUUID(), "Clean", Rarity.COMMON, 0, 3),
ReforgeProblemFact(UUID.randomUUID(), "Fierce", Rarity.COMMON, 4, 0),
ReforgeProblemFact(UUID.randomUUID(), "Shiny", Rarity.COMMON, 2, 1),
ReforgeProblemFact(UUID.randomUUID(), "Clean", Rarity.RARE, 1, 3),
ReforgeProblemFact(UUID.randomUUID(), "Fierce", Rarity.RARE, 5, 0),
ReforgeProblemFact(UUID.randomUUID(), "Shiny", Rarity.RARE, 3, 2),
ReforgeProblemFact(UUID.randomUUID(), "Clean", Rarity.LEGENDARY, 1, 4),
ReforgeProblemFact(UUID.randomUUID(), "Fierce", Rarity.LEGENDARY, 6, 0),
ReforgeProblemFact(UUID.randomUUID(), "Shiny", Rarity.LEGENDARY, 4, 2),
)
private val solverManager: SolverManager<ReforgePlanningSolution, UUID> = SolverManager.create(
SolverConfig.createFromXmlResource("reforgeSolverConfig.xml")
)
@JvmStatic
fun main(args: Array<String>) {
val availableItems = generateAvailableItems(
mapOf(
Rarity.COMMON to 4,
Rarity.RARE to 3,
Rarity.LEGENDARY to 1
)
)
val solverJob = solverManager.solve(UUID.randomUUID(), ReforgePlanningSolution(availableItems))
val solution = solverJob.finalBestSolution
solution.availableItems!!
.map { it.reforge!! }
.forEach { println(it.rarity.name + " " + it.name) }
}
private fun generateAvailableItems(itemCounts: Map<Rarity, Int>): MutableList<ItemPlanningEntity> {
val availableItems = mutableListOf<ItemPlanningEntity>()
for (itemCount in itemCounts) {
for (count in 0 until itemCount.value) {
val rarity = itemCount.key
availableItems.add(
ItemPlanningEntity(
UUID.randomUUID(),
rarity,
null,
allReforges.filter { it.rarity == rarity }
)
)
}
}
return availableItems
}
}
}
I revisited the similar SO question. After trying a few different versions of the answer, it finally worked. Here's a more detailed explanation than the other post:
Each PlanningEntity
's ProblemFactCollectionProperty
must be part of the main ProblemFactCollectionProperty
in the PlanningSolution
. This means that both the entity and solution should have their problem facts defined. Here's what fixed it for me:
Keep ItemPlanningEntity.kt the same.
Include a global ProblemFactCollectionProperty
in ReforgePlanningSolution.kt:
@PlanningSolution
class ReforgePlanningSolution @JvmOverloads constructor(
@PlanningEntityCollectionProperty
val availableItems: List<ItemPlanningEntity>? = null,
@ProblemFactCollectionProperty
val allReforges: List<ReforgeProblemFact>? = null,
@PlanningScore
val score: HardSoftScore? = null
)
Define the global collection when instantiating the solution in Main.kt:
val solverJob = solverManager.solve(UUID.randomUUID(), ReforgePlanningSolution(availableItems, allReforges))