From 5a19f29e3b30c42896dc288ab03bf48dbc49054a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Prieto?= Date: Mon, 16 Jun 2025 16:53:03 +0200 Subject: [PATCH] Implemented both attack and defence, now comes refactoring --- .../fourtykcalculator/AttackScenario.java | 22 +-- .../AttackScenarioResolution.java | 7 + .../fourtykcalculator/DefensiveProfile.java | 153 +++++++++++++++++- .../fourtykcalculator/FeelNoPainEffect.java | 5 + .../fourtykcalculator/InvulnerableSave.java | 10 ++ .../fourtykcalculator/SaveResolution.java | 19 +++ .../games/fourtykcalculator/WeaponStat.java | 4 +- src/main/resources/weapons.yaml | 6 +- 8 files changed, 204 insertions(+), 22 deletions(-) create mode 100644 src/main/java/ninja/thefirearchmage/games/fourtykcalculator/SaveResolution.java diff --git a/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/AttackScenario.java b/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/AttackScenario.java index acd01a3..31e0f35 100644 --- a/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/AttackScenario.java +++ b/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/AttackScenario.java @@ -84,12 +84,13 @@ public class AttackScenario { } public AttackScenarioResolution resolveAttack() { - var resolution = new AttackScenarioResolution(); // Filter out weapons that cannot participate in this attack var validWeapons = weapons.stream().filter( w -> hasLineOfSight || w._1.hasIndirectFire()) .toList(); // Attack step + var totalDamage = 0d; + var totalPreventedDamage = 0d; for(var weaponData : validWeapons) { Weapon weapon = weaponData._1; int amountOfWeapons = weaponData._2; @@ -101,17 +102,18 @@ public class AttackScenario { var mortalWounds = passingWounds._2; // Defence funnel - executeDefenceFunnel(weapon, normalWounds, mortalWounds); + var savesResolution = defensiveProfile.saveWounds(weapon, normalWounds, mortalWounds); + results.setWeaponStat(weapon, WeaponStat.DAMAGE_NORMAL, normalWounds - savesResolution.getPreventedNormalWounds()); + results.setWeaponStat(weapon, WeaponStat.DAMAGE_MORTAL, mortalWounds - savesResolution.getPreventedMortalWoundsFromFNP()); + results.setWeaponStat(weapon, WeaponStat.DAMAGE_TOTAL, normalWounds + mortalWounds - savesResolution.getPreventedWounds()); + + totalDamage += normalWounds + mortalWounds - savesResolution.getPreventedWounds(); + totalPreventedDamage += savesResolution.getPreventedWounds(); } + results.setTotalPreventedDamage(totalDamage); + results.setTotalPreventedDamage(totalPreventedDamage); - return resolution; - } - - - private void executeDefenceFunnel(Weapon weapon, double normalWounds, double mortalWounds) { - // Save normal wounds - - // + return results; } /** diff --git a/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/AttackScenarioResolution.java b/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/AttackScenarioResolution.java index f074a36..7f8d51f 100644 --- a/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/AttackScenarioResolution.java +++ b/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/AttackScenarioResolution.java @@ -1,6 +1,7 @@ package ninja.thefirearchmage.games.fourtykcalculator; import lombok.Getter; +import lombok.Setter; import ninja.thefirearchmage.games.fourtykcalculator.utils.datastructures.Tuple; import ninja.thefirearchmage.games.fourtykcalculator.utils.datastructures.Tuple2; @@ -14,9 +15,15 @@ import static java.lang.String.format; @Getter public class AttackScenarioResolution { private Map> weaponStats; + @Setter + private double totalInflictedDamage; + @Setter + private double totalPreventedDamage; public AttackScenarioResolution() { this.weaponStats = new HashMap<>(); + totalInflictedDamage = 0; + totalPreventedDamage = 0; } public void setWeaponStat(Weapon weapon, WeaponStat statName, Object statValue) { diff --git a/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/DefensiveProfile.java b/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/DefensiveProfile.java index 7b4a258..b12a413 100644 --- a/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/DefensiveProfile.java +++ b/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/DefensiveProfile.java @@ -12,26 +12,163 @@ public class DefensiveProfile { private final int normalSave; private final int wounds; private final int bodies; - private final Optional invulnerableSave; - private final Optional feelNoPain; - private final Set unitTypeKeywords; + private Optional invulnerableSave; + private Optional feelNoPain; + private Set unitTypeKeywords; + private boolean isInCover; + private boolean isVisible; + private boolean hasRerollOneNormalSaves; + private boolean hasRerollFailedNormalSaves; + private boolean hasRerollAnyNormalSaves; + private boolean fishFor6NormalSaves; public DefensiveProfile(int toughness, int wounds, int bodies, int normalSave) { - this(toughness, wounds, bodies, normalSave, Optional.empty(), Optional.empty()); + this(toughness, wounds, bodies, normalSave, Optional.empty(), Optional.empty(), + new HashSet<>(), false, false, false, + false, true); } - public DefensiveProfile(int toughness, int wounds, int bodies, int normalSave, Optional invulnerableSave, Optional feelNoPain) { + public DefensiveProfile(int toughness, int wounds, int bodies, int normalSave, Optional invulnerableSave, + Optional feelNoPain, Set unitTypeKeywords, + boolean isInCover, boolean hasRerollOneNormalSaves, boolean hasRerollFailedNormalSaves, + boolean hasRerollAnyNormalSaves, boolean isVisible) { this.toughness = toughness; - this.wounds = wounds; this.normalSave = normalSave; + this.wounds = wounds; + this.bodies = bodies; this.invulnerableSave = invulnerableSave; this.feelNoPain = feelNoPain; - this.bodies = bodies; - this.unitTypeKeywords = new HashSet<>(); + this.unitTypeKeywords = unitTypeKeywords; + this.isInCover = isInCover; + this.hasRerollOneNormalSaves = hasRerollOneNormalSaves; + this.hasRerollFailedNormalSaves = hasRerollFailedNormalSaves; + this.hasRerollAnyNormalSaves = hasRerollAnyNormalSaves; + this.isVisible = isVisible; } public DefensiveProfile withUnitTypes(Set types) { this.unitTypeKeywords.addAll(types); return this; } + public DefensiveProfile withRerollsConfig(boolean rerollOneNormalSaves, boolean rerollFailedNormalSaves, + boolean rerollAnyNormalSaves) { + this.hasRerollOneNormalSaves = rerollOneNormalSaves; + this.hasRerollFailedNormalSaves = rerollFailedNormalSaves; + this.hasRerollAnyNormalSaves = rerollAnyNormalSaves; + return this; + } + public DefensiveProfile withInvulnerabilitySave(InvulnerableSave invulnerabilitySave) { + this.invulnerableSave = Optional.of(invulnerabilitySave); + return this; + } + public DefensiveProfile withFeelNoPainConfig(FeelNoPainEffect feelNoPainConfig) { + this.feelNoPain = Optional.of(feelNoPainConfig); + return this; + } + public DefensiveProfile setInCover(boolean inCover) { + this.isInCover = inCover; + return this; + } + public DefensiveProfile setIsVisible(boolean isVisible) { + this.isVisible = isVisible; + return this; + } + + /** + * Returns a detailed description of how the wounds applied to this defensive profile were prevented + */ + public SaveResolution saveWounds(Weapon weapon, double normalWounds, double mortalWounds) { + var resolution = new SaveResolution(); + + // Get the best save for this weapon + var saveRollGoal = calculateBestSaveFor(weapon); + var saveProbabilityNumerator = 7 - saveRollGoal; + + // Normal wounds + var savedNormalWounds = normalWounds * saveProbabilityNumerator/6; + resolution.setPreventedNormalWoundsFromSavesWithoutRerolls(savedNormalWounds); + var saveRerolls = calculateBestReroll(saveRollGoal); + if(saveRerolls.isPresent()) { + var rerollProbability = saveRerolls.get().getProbabilityNumerator(); + var savedNormalWoundsFromRerolls = normalWounds*(1 - saveProbabilityNumerator)/6 * rerollProbability/6; + resolution.setPreventedNormalWoundsFromSavesRerolls(savedNormalWoundsFromRerolls); + savedNormalWounds += savedNormalWoundsFromRerolls; + } + resolution.setPreventedNormalWoundsFromSaves(savedNormalWounds); + + // Feel no pain saves + var preventedWoundsFromFNP = 0d; + if(feelNoPain.isPresent()) { + var fnp = feelNoPain.get(); + var fnpRollGoalProbability = fnp.getRollValue() / 6; + // Normal + var remainingNormalWounds = normalWounds - savedNormalWounds; + var fnpedNormalWounds = 0d; + if(fnp.getType() == FeelNoPainType.ALL) { + fnpedNormalWounds += remainingNormalWounds * fnpRollGoalProbability; + resolution.setPreventedNormalWoundsFromFNP(fnpedNormalWounds); + savedNormalWounds -= fnpedNormalWounds; + preventedWoundsFromFNP += fnpedNormalWounds; + } + + // Mortal wounds + var fnpedMortalWounds = 0d; + if(fnp.getType() == FeelNoPainType.ALL || fnp.getType() == FeelNoPainType.MORTAL_WOUNDS) { + fnpedMortalWounds += mortalWounds * fnpRollGoalProbability; + resolution.setPreventedMortalWoundsFromFNP(fnpedMortalWounds); + mortalWounds -= fnpedMortalWounds; + preventedWoundsFromFNP += fnpedMortalWounds; + } + + resolution.setPreventedWoundsFromFNP(fnpedNormalWounds + fnpedMortalWounds); + } + + resolution.setPreventedNormalWounds(savedNormalWounds); + resolution.setPreventedWounds(savedNormalWounds + preventedWoundsFromFNP); + + return resolution; + } + + private Optional calculateBestReroll(int saveTarget) { + if(hasRerollOneNormalSaves) { + return Optional.of(Reroll.rerollOnOnes()); + } else if (hasRerollFailedNormalSaves || hasRerollAnyNormalSaves) { + return Optional.of(fishFor6NormalSaves && hasRerollAnyNormalSaves ? Reroll.rerollOnNot(Set.of(6)) : + Reroll.rerollFailures(saveTarget)); + } else { + return Optional.empty(); + } + } + + private int calculateBestSaveFor(Weapon weapon) { + var ap = weapon.getAp(); + // Augment basic save with potential modifiers + var finalSave = normalSave; + var finalIsInCover = isInCover; + // Indirect fire save bonus + if(weapon.hasIndirectFire() && !isVisible) { + finalIsInCover = true; + } + // Cover save bonus + if(finalIsInCover && !weapon.ignoresCover()) { + // Saves better than +3 get no cover benefits from ap 0 weapons + if(normalSave > 3 || ap > 0) { + finalSave++; + } + } + // Subtract AP (a higher save is worse) + finalSave += weapon.getAp(); + finalSave = Math.min(finalSave, 6); + + if(invulnerableSave.isPresent()) { + if(invulnerableSave.get().appliesTo(weapon)) { + var invulnerabilitySave = invulnerableSave.get().getRollValue(); + if(invulnerabilitySave < finalSave) { + finalSave = invulnerabilitySave; + } + } + } + + return finalSave; + } } diff --git a/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/FeelNoPainEffect.java b/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/FeelNoPainEffect.java index af2e8c3..519d45d 100644 --- a/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/FeelNoPainEffect.java +++ b/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/FeelNoPainEffect.java @@ -1,6 +1,11 @@ package ninja.thefirearchmage.games.fourtykcalculator; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor public class FeelNoPainEffect { private int rollValue; private FeelNoPainType type; diff --git a/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/InvulnerableSave.java b/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/InvulnerableSave.java index 642db1a..b6123e7 100644 --- a/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/InvulnerableSave.java +++ b/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/InvulnerableSave.java @@ -15,4 +15,14 @@ public class InvulnerableSave { this.rollValue = rollValue; this.type = type; } + + /** + * Checks whether this invulnerable save applies to the given weapon attack + */ + public boolean appliesTo(Weapon weapon) { + return switch(weapon.getRangeType()) { + case MELEE -> type == InvulnerableType.MELEE || type == InvulnerableType.ALL; + case RANGED -> type == InvulnerableType.RANGED || type == InvulnerableType.ALL; + }; + } } diff --git a/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/SaveResolution.java b/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/SaveResolution.java new file mode 100644 index 0000000..157659a --- /dev/null +++ b/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/SaveResolution.java @@ -0,0 +1,19 @@ +package ninja.thefirearchmage.games.fourtykcalculator; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter @Setter +@AllArgsConstructor @NoArgsConstructor +public class SaveResolution { + private double preventedWounds; + private double preventedNormalWounds; + private double preventedNormalWoundsFromSaves; + private double preventedNormalWoundsFromSavesRerolls; + private double preventedNormalWoundsFromSavesWithoutRerolls; + private double preventedNormalWoundsFromFNP; + private double preventedMortalWoundsFromFNP; + private double preventedWoundsFromFNP; +} \ No newline at end of file diff --git a/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/WeaponStat.java b/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/WeaponStat.java index b80f92c..4e0a6a1 100644 --- a/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/WeaponStat.java +++ b/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/WeaponStat.java @@ -12,7 +12,9 @@ public enum WeaponStat { WOUNDS_COUNT_WITHOUT_REROLL(Double.class), WOUNDS_COUNT_FROM_REROLL(Double.class), MORTAL_WOUNDS_COUNT(Double.class), - TOTAL_DAMAGE(Double.class), + DAMAGE_NORMAL(Double.class), + DAMAGE_MORTAL(Double.class), + DAMAGE_TOTAL(Double.class), LETHAL_HITS_COUNT(Double.class), NON_LETHAL_HITS_COUNT(Double.class), SUSTAINED_HITS_COUNT(Double.class); diff --git a/src/main/resources/weapons.yaml b/src/main/resources/weapons.yaml index 41f6eae..f5bfddb 100644 --- a/src/main/resources/weapons.yaml +++ b/src/main/resources/weapons.yaml @@ -6,7 +6,7 @@ eradicator_melta_rifle: dice_type: 6 number_of_dices: 1 melta: 2 - ap: -4 + ap: 4 toHit: 3 strength: 9 isHeavy: true @@ -18,7 +18,7 @@ eradicator_multi_melta: attacks: 2 range_type: RANGED melta: 2 - ap: -4 + ap: 4 toHit: 4 strength: 9 isHeavy: true @@ -26,7 +26,7 @@ intercessor_bolt_rifle: name: Bolt Rifle (Intercessor) attacks: 2 damage: 1 - ap: -1 + ap: 1 toHit: 3 strength: 4 isHeavy: true \ No newline at end of file