Refactor hit calculation, moving on to wound calculation

This commit is contained in:
Loic Prieto 2025-06-16 23:56:13 +02:00
parent ec880e119b
commit 96a5e91e0b
6 changed files with 103 additions and 57 deletions

View file

@ -95,7 +95,7 @@ public class DefensiveProfile {
resolution.setPreventedNormalWoundsFromSavesWithoutRerolls(savedNormalWounds); resolution.setPreventedNormalWoundsFromSavesWithoutRerolls(savedNormalWounds);
var saveRerolls = calculateBestReroll(saveRollGoal); var saveRerolls = calculateBestReroll(saveRollGoal);
if(saveRerolls.isPresent()) { if(saveRerolls.isPresent()) {
var rerollProbability = saveRerolls.get().getProbabilityNumerator(); var rerollProbability = saveRerolls.get().getSuccessProbability();
var savedNormalWoundsFromRerolls = normalWounds*(1 - saveProbabilityNumerator)/6 * rerollProbability/6; var savedNormalWoundsFromRerolls = normalWounds*(1 - saveProbabilityNumerator)/6 * rerollProbability/6;
resolution.setPreventedNormalWoundsFromSavesRerolls(savedNormalWoundsFromRerolls); resolution.setPreventedNormalWoundsFromSavesRerolls(savedNormalWoundsFromRerolls);
savedNormalWounds += savedNormalWoundsFromRerolls; savedNormalWounds += savedNormalWoundsFromRerolls;

View file

@ -9,41 +9,38 @@ import java.util.stream.IntStream;
/** /**
* Assumes a D6 * Assumes a D6
*/ */
@Getter @AllArgsConstructor @Getter @Setter @AllArgsConstructor @NoArgsConstructor
public class Reroll { public class Reroll {
private RerollStageType rerollStageType; private RerollStageType rerollStageType;
private RerollType type; private RerollType type;
private final Set<Integer> rerollableValues; private Set<Integer> rerollableValues;
private Set<UnitTypeKeyword> validTargetTypes; private Set<UnitTypeKeyword> validTargetTypes;
private Set<WeaponRangeType> validWeaponRanges;
public static Reroll rerollOnOnes(RerollStageType rerollStageType, Set<UnitTypeKeyword> validTargetTypes) { public static Reroll rerollOnOnes(RerollStageType rerollStageType, Set<UnitTypeKeyword> validTargetTypes, Set<WeaponRangeType> validWeaponRanges) {
return new Reroll(rerollStageType, RerollType.ON_ONES, Set.of(1), validTargetTypes); return new Reroll(rerollStageType, RerollType.ON_ONES, Set.of(1), validTargetTypes, validWeaponRanges);
} }
public static Reroll rerollFailures(RerollStageType rerollStageType, int targetHit, public static Reroll rerollFailures(RerollStageType rerollStageType, int targetHit,
Set<UnitTypeKeyword> validTargetTypes) { Set<UnitTypeKeyword> validTargetTypes, Set<WeaponRangeType> validWeaponRanges) {
var rerollableValues = IntStream.range(1, targetHit).boxed() var rerollableValues = IntStream.range(1, targetHit).boxed()
.collect(Collectors.toSet()); .collect(Collectors.toSet());
return new Reroll(rerollStageType, RerollType.ON_FAILURE, rerollableValues, validTargetTypes); return new Reroll(rerollStageType, RerollType.ON_FAILURE, rerollableValues, validTargetTypes, validWeaponRanges);
} }
public static Reroll rerollOnNot(RerollStageType rerollStageType, Set<Integer> allowedValues, public static Reroll rerollOnNot(RerollStageType rerollStageType, Set<Integer> allowedValues,
Set<UnitTypeKeyword> validTargetTypes) { Set<UnitTypeKeyword> validTargetTypes, Set<WeaponRangeType> validWeaponRanges) {
var rerollableValues = IntStream.range(1,7).filter(i-> !allowedValues.contains(i)) var rerollableValues = IntStream.range(1,7).filter(i-> !allowedValues.contains(i))
.boxed().collect(Collectors.toSet()); .boxed().collect(Collectors.toSet());
return new Reroll(rerollStageType, RerollType.ANY, rerollableValues, validTargetTypes); return new Reroll(rerollStageType, RerollType.ANY, rerollableValues, validTargetTypes, validWeaponRanges);
} }
/** /**
* From a D6 returns what proportion of rerollable values this reroll is. * From a D6 returns what proportion of rerollable values this reroll is.
*/ */
public double getProbabilityNumerator() { public double getSuccessProbability() {
return (double) rerollableValues.size() / 6; return (double) rerollableValues.size() / 6;
} }
public boolean appliesTo(UnitTypeKeyword target) {
return this.validTargetTypes.contains(target);
}
} }

View file

@ -1,6 +1,7 @@
package ninja.thefirearchmage.games.fourtykcalculator; package ninja.thefirearchmage.games.fourtykcalculator;
public enum RerollStageType { public enum RerollStageType {
ATTACKS,
HIT, HIT,
WOUND, WOUND,
DAMAGE; DAMAGE;

View file

@ -7,6 +7,10 @@ import ninja.thefirearchmage.games.fourtykcalculator.utils.datastructures.Tuple2
import ninja.thefirearchmage.games.fourtykcalculator.utils.datastructures.Tuple3; import ninja.thefirearchmage.games.fourtykcalculator.utils.datastructures.Tuple3;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
/** /**
* Models what modifiers a unit that is performing the attacking provides to its weapons. * Models what modifiers a unit that is performing the attacking provides to its weapons.
@ -18,9 +22,8 @@ public class UnitAttackModifiers {
private Tuple2<Boolean, WeaponRangeType> lethalHitsModifier; private Tuple2<Boolean, WeaponRangeType> lethalHitsModifier;
private int criticalHitValue = 6; private int criticalHitValue = 6;
private boolean lanceModifier; private boolean lanceModifier;
private List<Reroll> hitRerollsModifier; // These rerolls do not contain actual values, they must be generated from the method getRerollFor
private List<Reroll> woundRerollsModifier; private List<Reroll> rerollsModifier;
private List<Reroll> damageRerollsModifier;
private Tuple3<Boolean, WeaponRangeType, Integer> apModifier; private Tuple3<Boolean, WeaponRangeType, Integer> apModifier;
private Tuple2<Boolean, WeaponRangeType> woundBonusModifier; private Tuple2<Boolean, WeaponRangeType> woundBonusModifier;
private Tuple2<Boolean, WeaponRangeType> woundMalusModifier; private Tuple2<Boolean, WeaponRangeType> woundMalusModifier;
@ -43,5 +46,29 @@ public class UnitAttackModifiers {
private boolean isPsychic; private boolean isPsychic;
private boolean canRerollHazardous; private boolean canRerollHazardous;
public boolean hasReroll public boolean hasRerollFor(RerollStageType stageType, RerollType type,
Set<UnitTypeKeyword> targetsType, WeaponRangeType weaponRangeType) {
return rerollsModifier.stream()
.filter( r -> setContainsAnyOf(r.getValidTargetTypes(), targetsType, UnitTypeKeyword.ALL))
.filter( r -> setContainsAnyOf(r.getValidWeaponRanges(), Set.of(weaponRangeType), WeaponRangeType.ALL))
.filter(r -> r.getRerollStageType() == stageType)
.anyMatch(r -> r.getType() == type);
}
public Optional<Reroll> getRerollFor(RerollStageType stageType, RerollType type,
Set<UnitTypeKeyword> targetsType, WeaponRangeType weaponRangeType, int rollGoal) {
if(hasRerollFor(stageType, type, targetsType, weaponRangeType)){
return Optional.of(switch(type) {
case ON_ONES -> Reroll.rerollOnOnes(stageType, targetsType, Set.of(weaponRangeType));
case ON_FAILURE -> Reroll.rerollFailures(stageType, rollGoal, targetsType, Set.of(weaponRangeType));
case ANY -> Reroll.rerollOnNot(stageType, IntStream.range(rollGoal,7).boxed().collect(Collectors.toSet()), targetsType, Set.of(weaponRangeType));
});
}
return Optional.empty();
}
private static <T> boolean setContainsAnyOf(Set<T> oneSet, Set<T> otherSet, T otherValue) {
return oneSet.stream().anyMatch(i -> i.equals(otherValue) || otherSet.contains(i));
}
} }

View file

@ -84,33 +84,35 @@ public class Weapon {
private double calculatePassingHits(DefensiveProfile target, UnitAttackModifiers unitAttackModifiers, private double calculatePassingHits(DefensiveProfile target, UnitAttackModifiers unitAttackModifiers,
int amountOfWeapons, WeaponAttackResolution weaponAttackResolution) { int amountOfWeapons, WeaponAttackResolution weaponAttackResolution) {
int hitRollGoal = calculateHitRollGoal(target, unitAttackModifiers); int hitRollGoal = calculateHitRollGoal(target, unitAttackModifiers);
int hitSuccessProbabilityNumerator = calculateProbabilityNumeratorFrom(hitRollGoal); double hitSuccessProbability = (double) calculateProbabilityNumeratorFrom(hitRollGoal) / 6;
int weaponAttacks = calculateWeaponAttacks(amountOfWeapons, unitAttackModifiers, weaponAttackResolution); double hitFailureProbability = (double) (6 - calculateProbabilityNumeratorFrom(hitRollGoal)) / 6;
int weaponAttacks = calculateWeaponAttacks(amountOfWeapons, unitAttackModifiers, target, weaponAttackResolution);
// calculate sustained hits
var sustainedHits = calculateSustainedHits(weaponAttacks, unitAttackModifiers, weaponAttackResolution);
weaponAttackResolution.setHitsFromSustainedHits(sustainedHits);
weaponAttackResolution.setTotalAttacks(weaponAttacks+sustainedHits);
// Base passing hits without rerolls // Base passing hits without rerolls
double passingHits = (weaponAttacks*amountOfWeapons) * ((double) hitSuccessProbabilityNumerator / PROBABILITY_DENOMINATOR_D6); double passingHits = weaponAttacks * hitSuccessProbability;
results.setWeaponStat(weapon, WeaponStat.HITS_COUNT_WITHOUT_REROLL, passingHits); weaponAttackResolution.setHitsWithoutRerolls(passingHits);
// Add hits from rerolls // Add hits from rerolls
var hitRerolls = prepareHitRerolls(hitRollGoal); var hitRerolls = prepareHitRerolls(hitRollGoal, unitAttackModifiers, target);
if(hitRerolls.isPresent()) { if(hitRerolls.isPresent()) {
var rerolls = hitRerolls.get(); var rerolls = hitRerolls.get();
var successfulHitFromRerolls = rerolls.getProbabilityNumerator() * ((double) hitSuccessProbabilityNumerator / PROBABILITY_DENOMINATOR_D6); var successfulHitFromRerolls = weaponAttacks * hitFailureProbability * rerolls.getSuccessProbability();
results.setWeaponStat(weapon, WeaponStat.HITS_COUNT_FROM_REROLL, successfulHitFromRerolls); weaponAttackResolution.setHitsFromRerolls(successfulHitFromRerolls);
passingHits += successfulHitFromRerolls; passingHits += successfulHitFromRerolls;
} }
results.setWeaponStat(weapon, WeaponStat.HITS_TOTAL_COUNT, passingHits);
// calculate sustained hits
var sustainedHits = calculateSustainedHits(weaponAttacks, unitAttackModifiers, target);
weaponAttackResolution.setHitsFromSustainedHits(sustainedHits);
passingHits += sustainedHits;
weaponAttackResolution.setHits(passingHits);
return passingHits; return passingHits;
} }
private double calculateSustainedHits(int weaponAttacks, UnitAttackModifiers unitAttackModifiers, private double calculateSustainedHits(int weaponAttacks, UnitAttackModifiers unitAttackModifiers,
WeaponAttackResolution weaponAttackResolution) { DefensiveProfile target) {
var sustainedHitsAmounts = 0d; var sustainedHitsAmounts = 0d;
var hasSustainedHits = this.sustainedHits.isPresent() || var hasSustainedHits = this.sustainedHits.isPresent() ||
@ -124,7 +126,9 @@ public class Weapon {
double nonSustainedProbability = (double) (6 - (7 - criticalHitValue)) / 6; double nonSustainedProbability = (double) (6 - (7 - criticalHitValue)) / 6;
double sustainedProbability = (double) (7 - criticalHitValue) / 6; double sustainedProbability = (double) (7 - criticalHitValue) / 6;
if(unitAttackModifiers.isFishForCriticalHits() && hasRerollAny(unitAttackModifiers.getHitRerollsModifier())) { var hasHitRerollAny = unitAttackModifiers.hasRerollFor(RerollStageType.HIT, RerollType.ANY,
target.getUnitTypeKeywords(), this.rangeType);
if(unitAttackModifiers.isFishForCriticalHits() && hasHitRerollAny) {
sustainedProbability += nonSustainedProbability*sustainedProbability; sustainedProbability += nonSustainedProbability*sustainedProbability;
} }
sustainedHitsAmounts = weaponAttacks * sustainedProbability * sustainedHitsValue; sustainedHitsAmounts = weaponAttacks * sustainedProbability * sustainedHitsValue;
@ -150,7 +154,7 @@ public class Weapon {
if(woundsRerolls.isPresent()) { if(woundsRerolls.isPresent()) {
var rerolls = woundsRerolls.get(); var rerolls = woundsRerolls.get();
var failedWounds = normalHits * ((double) (1 - woundSuccessProbabilityNumerator) / PROBABILITY_DENOMINATOR_D6); var failedWounds = normalHits * ((double) (1 - woundSuccessProbabilityNumerator) / PROBABILITY_DENOMINATOR_D6);
var extraWoundsFromRerolls = failedWounds * rerolls.getProbabilityNumerator() / PROBABILITY_DENOMINATOR_D6; var extraWoundsFromRerolls = failedWounds * rerolls.getSuccessProbability() / PROBABILITY_DENOMINATOR_D6;
results.setWeaponStat(weapon, WeaponStat.WOUNDS_COUNT_FROM_REROLL, extraWoundsFromRerolls); results.setWeaponStat(weapon, WeaponStat.WOUNDS_COUNT_FROM_REROLL, extraWoundsFromRerolls);
passingNormalWounds += extraWoundsFromRerolls; passingNormalWounds += extraWoundsFromRerolls;
} }
@ -192,12 +196,25 @@ public class Weapon {
return lethalHitsCount; return lethalHitsCount;
} }
private Optional<Reroll> prepareHitRerolls(int hitRollGoal) { private Optional<Reroll> prepareHitRerolls(int hitRollGoal, UnitAttackModifiers unitAttackModifiers,
if(hasRerollHitsOnes) { DefensiveProfile target) {
return Optional.of(Reroll.rerollOnOnes()); var hasAnyHitReroll = unitAttackModifiers.hasRerollFor(RerollStageType.HIT, RerollType.ANY,
target.getUnitTypeKeywords(), this.rangeType);
var hasOnesHitsReroll = unitAttackModifiers.hasRerollFor(RerollStageType.HIT, RerollType.ON_ONES,
target.getUnitTypeKeywords(), this.rangeType);
var hasFailedHitsReroll = unitAttackModifiers.hasRerollFor(RerollStageType.HIT, RerollType.ON_FAILURE,
target.getUnitTypeKeywords(), this.rangeType);
// Priorize rerolling failed/any hits over ones
if(hasAnyHitReroll || hasFailedHitsReroll) {
return hasAnyHitReroll ? unitAttackModifiers.getRerollFor(RerollStageType.HIT, RerollType.ANY,
target.getUnitTypeKeywords(), this.rangeType, hitRollGoal) :
unitAttackModifiers.getRerollFor(RerollStageType.HIT, RerollType.ON_FAILURE,
target.getUnitTypeKeywords(), this.rangeType, hitRollGoal);
} }
if(hasRerollHitsFailures || hasRerollHitsAny) { if(hasOnesHitsReroll) {
return Optional.of(Reroll.rerollFailures(hitRollGoal)); return unitAttackModifiers.getRerollFor(RerollStageType.HIT, RerollType.ON_ONES,
target.getUnitTypeKeywords(), this.rangeType, hitRollGoal);
} }
return Optional.empty(); return Optional.empty();
@ -215,30 +232,33 @@ public class Weapon {
} }
private int calculateWeaponAttacks(int amountOfWeapons, UnitAttackModifiers unitAttackModifiers, private int calculateWeaponAttacks(int amountOfWeapons, UnitAttackModifiers unitAttackModifiers,
WeaponAttackResolution weaponAttackResolution) { DefensiveProfile target, WeaponAttackResolution weaponAttackResolution) {
double weaponAttacks = calculateBasicWeaponAttacks(amountOfWeapons, unitAttackModifiers, weaponAttackResolution); var weaponAttacks = calculateBasicWeaponAttacks(amountOfWeapons, unitAttackModifiers, weaponAttackResolution);
return weaponAttacks;
}
private static boolean hasRerollAny(List<Reroll> rerolls) {
return rerolls.stream().anyMatch(r -> r.getType() == RerollType.ANY);
}
private double calculateBasicWeaponAttacks(int amountOfWeapons, UnitAttackModifiers unitAttackModifiers,
WeaponAttackResolution weaponAttackResolution) {
var weaponAttacks = this.attacks;
// Rapid fire // Rapid fire
var hasRapidFire = this.getRapidFire().isPresent() || unitAttackModifiers.getRapidFireModifier()._1; var hasRapidFire = this.getRapidFire().isPresent() || unitAttackModifiers.getRapidFireModifier()._1;
if(hasRapidFire && unitAttackModifiers.isTargetInRapidFireRange()) { if(hasRapidFire && unitAttackModifiers.isTargetInRapidFireRange()) {
var higherRapidFireValue = Math.max(this.rapidFire.get(), unitAttackModifiers.getRapidFireModifier()._3); var higherRapidFireValue = Math.max(this.rapidFire.get(), unitAttackModifiers.getRapidFireModifier()._3);
weaponAttacks += higherRapidFireValue; weaponAttacks += higherRapidFireValue*amountOfWeapons;
weaponAttackResolution.setAttacksFromRapidFire(higherRapidFireValue*amountOfWeapons); weaponAttackResolution.setAttacksFromRapidFire(higherRapidFireValue);
} }
// Blast
if(this.hasBlast) {
var blastBonus = (int)Math.floor((double)target.getBodies() / 5);
weaponAttacks += blastBonus*amountOfWeapons;
weaponAttackResolution.setAttacksFromBlast(blastBonus);
}
weaponAttackResolution.setTotalAttacks(weaponAttacks);
return weaponAttacks;
}
private int calculateBasicWeaponAttacks(int amountOfWeapons, UnitAttackModifiers unitAttackModifiers,
WeaponAttackResolution weaponAttackResolution) {
var weaponAttacks = this.attacks;
// Attacks modifiers // Attacks modifiers
var attackModifier = 0; var attackModifier = 0;
var attackBonus = unitAttackModifiers.getAttacksBonusModifier(); var attackBonus = unitAttackModifiers.getAttacksBonusModifier();
@ -253,7 +273,6 @@ public class Weapon {
weaponAttacks += attackModifier; weaponAttacks += attackModifier;
weaponAttacks *= amountOfWeapons; weaponAttacks *= amountOfWeapons;
weaponAttackResolution.setTotalAttacks(weaponAttacks);
return weaponAttacks; return weaponAttacks;
} }
@ -298,6 +317,7 @@ public class Weapon {
// Because a roll of 1 always fails and a roll of 6 always succeeds, we modify the roll goal to be between // Because a roll of 1 always fails and a roll of 6 always succeeds, we modify the roll goal to be between
// values 2-5 // values 2-5
var modifiedRollGoal = Math.max(rollGoal, 2); var modifiedRollGoal = Math.max(rollGoal, 2);
modifiedRollGoal = Math.min(modifiedRollGoal, 5);
return 7 - modifiedRollGoal; return 7 - modifiedRollGoal;

View file

@ -23,5 +23,6 @@ public class WeaponAttackResolution {
private double totalAttacks; private double totalAttacks;
private int attacksFromRapidFire; private int attacksFromRapidFire;
private int attacksFromModifiers; private int attacksFromModifiers;
private int attacksFromBlast;
private SaveResolution saveResolution; private SaveResolution saveResolution;
} }