Search code examples
constraintsmathematical-optimizationoptaplanner

OptaPlanner: How to read game armor data from JSON and find optimal set of armor based on weight + stats?


Elden Ring is a hit game that has some interesting theorycrafting behind it.

There are hundreds of armor pieces, weapons, and spells. Finding optimal combinations of them based on player & item stats is an interesting practical problem.

I've always wanted to learn how to use Constraint Solvers and it seems a good usecase exists!


Goal:

  • Given a list of all armor in the game in JSON format
  • Find the set of armor (head, chest, legs, arms) that has:
    • The highest POISE and PHYSICAL_DEFENSE
    • For the lowest WEIGHT

Here is the repo:

My attempt so far:

Update

I managed to solve it after advice below

The trick was to change to use PlanningEntityProperty:

@PlanningSolution
public class ArmorSetComboPlanningSolution {

    public List<ArmorPiece> armorPieces;
    public Map<Integer, List<ArmorPiece>> armorByType;

    @ValueRangeProvider(id = "headRange")
    @ProblemFactCollectionProperty
    public List<ArmorPiece> headList;

    @ValueRangeProvider(id = "chestRange")
    @ProblemFactCollectionProperty
    public List<ArmorPiece> chestList;

    @ValueRangeProvider(id = "armsRange")
    @ProblemFactCollectionProperty
    public List<ArmorPiece> armsList;

    @ValueRangeProvider(id = "legsRange")
    @ProblemFactCollectionProperty
    public List<ArmorPiece> legsList;

    @PlanningEntityProperty
    public ArmorSet armorSet;

    @PlanningScore(bendableHardLevelsSize = 1, bendableSoftLevelsSize = 5)
    BendableLongScore score;

    ArmorSetComboPlanningSolution() {
    }

    ArmorSetComboPlanningSolution(List<ArmorPiece> armorPieces) {
        this.armorPieces = armorPieces;
        this.armorByType = armorPieces.stream().collect(groupingBy(ArmorPiece::armorCategoryID));
        this.headList = armorByType.get(0);
        this.chestList = armorByType.get(1);
        this.armsList = armorByType.get(2);
        this.legsList = armorByType.get(3);
        // Need to initialize a starting value
        this.armorSet = new ArmorSet(0L, this.headList.get(0), this.chestList.get(0), this.armsList.get(0), this.legsList.get(0));
    }
}

Then the scorer:

public class ArmorSetEasyOptimizer implements EasyScoreCalculator<ArmorSetComboPlanningSolution, BendableLongScore> {

    private final int TARGE_POISE = 61;
    private final double MAX_WEIGHT = 60.64;

    public ArmorSetEasyOptimizer() {
    }

    @Override
    public BendableLongScore calculateScore(ArmorSetComboPlanningSolution solution) {
        long hardScore = 0L;
        ArmorSet armorSet = solution.armorSet;

        if (armorSet.getTotalPoise() < TARGE_POISE) {
            hardScore--;
        }

        if (armorSet.getTotalWeight() > MAX_WEIGHT) {
            hardScore--;
        }

        long poiseRatio = (long) (armorSet.getTotalPoise() / (double) armorSet.getTotalWeight() * 100);

        long physicalDefenseScaled = (long) (armorSet.getTotalPhysicalDefense() * 100);
        long physicalDefenseToWeightRatio = (long) (physicalDefenseScaled / armorSet.getTotalWeight());

        long magicDefenseScaled = (long) (armorSet.getTotalMagicDefense() * 100);
        long magicDefenseToWeightRatio = (long) (magicDefenseScaled / armorSet.getTotalWeight());

        return BendableLongScore.of(
                new long[]{
                        hardScore
                },
                new long[]{
                        poiseRatio,
                        physicalDefenseScaled,
                        physicalDefenseToWeightRatio,
                        magicDefenseScaled,
                        magicDefenseToWeightRatio
                }
        );
    }
}

Results


19:02:12.707 [main] INFO org.optaplanner.core.impl.localsearch.DefaultLocalSearchPhase - Local Search phase (1) ended: time spent (10000), best score ([0]hard/[179/3540/97/2750/75]soft), score calculation speed (987500/sec), step total (4046).

19:02:12.709 [main] INFO org.optaplanner.core.impl.solver.DefaultSolver - Solving ended: time spent (10000), best score ([0]hard/[179/3540/97/2750/75]soft), score calculation speed (985624/sec), phase total (2), environment mode (REPRODUCIBLE), move thread count (NONE).

[0]hard/[179/3540/97/2750/75]soft

ArmorSet (Weight: 36.3, Poise: 65, Physical: 35.4, Phys/Weight: 0.97, Magic: 27.5, Magic/Weight: 0.75 ) [
    head: Radahn Soldier Helm (Weight: 4.0, Poise: 5),
    chest: Veteran's Armor (Weight: 18.9, Poise: 37),
    arms: Godskin Noble Bracelets (Weight: 1.7, Poise: 1),
    legs: Veteran's Greaves (Weight: 11.7, Poise: 22)
]

Solution

  • This is a bit of a strange problem, because you only need one planning entity instance. There only ever needs to be one ArmorSet object - and the solver will be assigning different armor pieces as it comes closer and closer to an optimal combination.

    Therefore your easy score calculator doesn't ever need to do any looping. It simply takes the single ArmorSet's weight and poise and creates a score out of it.

    However, even though I think that this use case may be useful as a learning path towards constraint solvers, some sort of a brute force algorithm could work as well - your data set isn't too large. More importantly, with an exhaustive algorithm such as brute force, you're eventually guaranteed to reach the optimal solution.

    (That said, if you want to enhance the problem with matching these armor sets to particular character traits, then it perhaps may be complex enough for brute force to become inadequate.)

    On a personal note, I attempted Elden Ring and found it too hardcore for me. :-) I prefer games that guide you a bit more.