Start refactoring of wounds calculation

This commit is contained in:
Loïc Prieto 2025-06-17 22:18:30 +02:00
parent 96a5e91e0b
commit 2f2c5dfabd
4 changed files with 68 additions and 86 deletions

View file

@ -3,10 +3,7 @@ package ninja.thefirearchmage.games.fourtykcalculator;
import lombok.Getter; import lombok.Getter;
import ninja.thefirearchmage.games.fourtykcalculator.utils.datastructures.Tuple3; import ninja.thefirearchmage.games.fourtykcalculator.utils.datastructures.Tuple3;
import java.util.HashSet; import java.util.*;
import java.util.List;
import java.util.Optional;
import java.util.Set;
@Getter @Getter
public class DefensiveProfile { public class DefensiveProfile {
@ -25,31 +22,33 @@ public class DefensiveProfile {
private boolean fishFor6NormalSaves; private boolean fishFor6NormalSaves;
private boolean hasStealth; private boolean hasStealth;
private boolean isInStealthRange; private boolean isInStealthRange;
private boolean hasWoundMalusIfStrengthHigherThanToughness;
private Tuple3<Boolean, DamageReductionType, Integer> damageReductionModifier; private Tuple3<Boolean, DamageReductionType, Integer> damageReductionModifier;
private Tuple3<Boolean, WeaponRangeType, Integer> apReduction; private Tuple3<Boolean, WeaponRangeType, Integer> apReduction;
public DefensiveProfile(int toughness, int wounds, int bodies, int normalSave) { 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(), new ArrayList<>(),
new HashSet<>(), false, false, false, new HashSet<>(), false, false, false,
false, true); false, true, false);
} }
public DefensiveProfile(int toughness, int wounds, int bodies, int normalSave, Optional<InvulnerableSave> invulnerableSave, public DefensiveProfile(int toughness, int wounds, int bodies, int normalSave, Optional<InvulnerableSave> invulnerableSave,
Optional<FeelNoPainEffect> feelNoPain, Set<UnitTypeKeyword> unitTypeKeywords, List<FeelNoPainEffect> feelNoPain, Set<UnitTypeKeyword> unitTypeKeywords,
boolean isInCover, boolean hasRerollOneNormalSaves, boolean hasRerollFailedNormalSaves, boolean isInCover, boolean hasRerollOneNormalSaves, boolean hasRerollFailedNormalSaves,
boolean hasRerollAnyNormalSaves, boolean isVisible) { boolean hasRerollAnyNormalSaves, boolean isVisible, boolean hasWoundMalusIfStrengthHigherThanToughness) {
this.toughness = toughness; this.toughness = toughness;
this.normalSave = normalSave; this.normalSave = normalSave;
this.wounds = wounds; this.wounds = wounds;
this.bodies = bodies; this.bodies = bodies;
this.invulnerableSave = invulnerableSave; this.invulnerableSave = invulnerableSave;
this.feelNoPain = feelNoPain; this.feelNoPainEffects = feelNoPain;
this.unitTypeKeywords = unitTypeKeywords; this.unitTypeKeywords = unitTypeKeywords;
this.isInCover = isInCover; this.isInCover = isInCover;
this.hasRerollOneNormalSaves = hasRerollOneNormalSaves; this.hasRerollOneNormalSaves = hasRerollOneNormalSaves;
this.hasRerollFailedNormalSaves = hasRerollFailedNormalSaves; this.hasRerollFailedNormalSaves = hasRerollFailedNormalSaves;
this.hasRerollAnyNormalSaves = hasRerollAnyNormalSaves; this.hasRerollAnyNormalSaves = hasRerollAnyNormalSaves;
this.isVisible = isVisible; this.isVisible = isVisible;
this.hasWoundMalusIfStrengthHigherThanToughness = hasWoundMalusIfStrengthHigherThanToughness;
} }
public DefensiveProfile withUnitTypes(Set<UnitTypeKeyword> types) { public DefensiveProfile withUnitTypes(Set<UnitTypeKeyword> types) {
@ -68,7 +67,7 @@ public class DefensiveProfile {
return this; return this;
} }
public DefensiveProfile withFeelNoPainConfig(FeelNoPainEffect feelNoPainConfig) { public DefensiveProfile withFeelNoPainConfig(FeelNoPainEffect feelNoPainConfig) {
this.feelNoPain = Optional.of(feelNoPainConfig); this.feelNoPainEffects.add(feelNoPainConfig);
return this; return this;
} }
public DefensiveProfile setInCover(boolean inCover) { public DefensiveProfile setInCover(boolean inCover) {

View file

@ -71,4 +71,8 @@ public class UnitAttackModifiers {
private static <T> boolean setContainsAnyOf(Set<T> oneSet, Set<T> otherSet, T otherValue) { private static <T> boolean setContainsAnyOf(Set<T> oneSet, Set<T> otherSet, T otherValue) {
return oneSet.stream().anyMatch(i -> i.equals(otherValue) || otherSet.contains(i)); return oneSet.stream().anyMatch(i -> i.equals(otherValue) || otherSet.contains(i));
} }
public boolean lethalHitsAppliesTo(Weapon weapon) {
return lethalHitsModifier._1 && lethalHitsModifier._2.appliesTo(weapon);
}
} }

View file

@ -5,6 +5,7 @@ import ninja.thefirearchmage.games.fourtykcalculator.utils.datastructures.Tuple;
import ninja.thefirearchmage.games.fourtykcalculator.utils.datastructures.Tuple2; import ninja.thefirearchmage.games.fourtykcalculator.utils.datastructures.Tuple2;
import java.util.*; import java.util.*;
import java.util.stream.Collectors;
public class Weapon { public class Weapon {
@Getter @Getter
@ -73,7 +74,7 @@ public class Weapon {
var weaponAttackResolution = new WeaponAttackResolution(); var weaponAttackResolution = new WeaponAttackResolution();
var passingHits = calculatePassingHits(target, unitModifiers, weaponAmount, weaponAttackResolution); var passingHits = calculatePassingHits(target, unitModifiers, weaponAmount, weaponAttackResolution);
var passingWounds = calculatePassingWounds(weaponAmount, passingHits); var passingWounds = calculatePassingWounds(target, unitModifiers, weaponAmount, passingHits, weaponAttackResolution);
var normalWounds = passingWounds._1; var normalWounds = passingWounds._1;
var mortalWounds = passingWounds._2; var mortalWounds = passingWounds._2;
@ -93,7 +94,7 @@ public class Weapon {
weaponAttackResolution.setHitsWithoutRerolls(passingHits); weaponAttackResolution.setHitsWithoutRerolls(passingHits);
// Add hits from rerolls // Add hits from rerolls
var hitRerolls = prepareHitRerolls(hitRollGoal, unitAttackModifiers, target); var hitRerolls = prepareRerolls(RerollStageType.HIT, hitRollGoal, unitAttackModifiers, target);
if(hitRerolls.isPresent()) { if(hitRerolls.isPresent()) {
var rerolls = hitRerolls.get(); var rerolls = hitRerolls.get();
var successfulHitFromRerolls = weaponAttacks * hitFailureProbability * rerolls.getSuccessProbability(); var successfulHitFromRerolls = weaponAttacks * hitFailureProbability * rerolls.getSuccessProbability();
@ -140,22 +141,24 @@ public class Weapon {
/** /**
* Returns a tuple of normal passing wounds and mortal wounds so that the caller can handle them differently. * Returns a tuple of normal passing wounds and mortal wounds so that the caller can handle them differently.
*/ */
private Tuple2<Double, Double> calculatePassingWounds(Weapon weapon, int amountOfWeapons, double passingHits) { private Tuple2<Double, Double> calculatePassingWounds(DefensiveProfile target, UnitAttackModifiers unitAttackModifiers,
double lethalHitsCount = calculateLethalHits(weapon, amountOfWeapons); int amountOfWeapons, double passingHits,
WeaponAttackResolution weaponAttackResolution) {
double lethalHitsCount = calculateLethalHits(amountOfWeapons, unitAttackModifiers, target, weaponAttackResolution);
var normalHits = passingHits - lethalHitsCount; var normalHits = passingHits - lethalHitsCount;
results.setWeaponStat(weapon, WeaponStat.NON_LETHAL_HITS_COUNT, normalHits);
int woundRollGoal = calculateWoundRollGoal(weapon); int woundRollGoal = calculateWoundRollGoal(target, unitAttackModifiers);
int woundSuccessProbabilityNumerator = calculateProbabilityNumeratorFrom(woundRollGoal); int woundSuccessProbabilityNumerator = calculateProbabilityNumeratorFrom(woundRollGoal);
var passingNormalWounds = normalHits * ( (double) woundSuccessProbabilityNumerator / PROBABILITY_DENOMINATOR_D6); var passingNormalWounds = normalHits * woundSuccessProbabilityNumerator / 6;
results.setWeaponStat(weapon, WeaponStat.WOUNDS_COUNT_WITHOUT_REROLL, passingNormalWounds+lethalHitsCount); weaponAttackResolution.setNormalWoundsWithoutRerolls(passingNormalWounds + lethalHitsCount);
var woundsRerolls = prepareWoundRerolls(woundRollGoal);
var woundsRerolls = prepareRerolls(RerollStageType.WOUND, woundRollGoal, unitAttackModifiers, target);
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 * (6 - woundSuccessProbabilityNumerator) / 6;
var extraWoundsFromRerolls = failedWounds * rerolls.getSuccessProbability() / PROBABILITY_DENOMINATOR_D6; var extraWoundsFromRerolls = failedWounds * rerolls.getSuccessProbability() / 6;
results.setWeaponStat(weapon, WeaponStat.WOUNDS_COUNT_FROM_REROLL, extraWoundsFromRerolls); weaponAttackResolution.setNormalWoundsFromRerolls(extraWoundsFromRerolls);
passingNormalWounds += extraWoundsFromRerolls; passingNormalWounds += extraWoundsFromRerolls;
} }
@ -182,50 +185,42 @@ public class Weapon {
return Tuple.of(passingNormalWounds, passingMortalWounds); return Tuple.of(passingNormalWounds, passingMortalWounds);
} }
private double calculateLethalHits(Weapon weapon, int amountOfWeapons) { private double calculateLethalHits(int amountOfWeapons, UnitAttackModifiers unitAttackModifiers,
DefensiveProfile target, WeaponAttackResolution weaponAttackResolution) {
var lethalHitsCount = 0d; var lethalHitsCount = 0d;
var hasLethalHits = this.hasLethalHits || unitAttackModifiers.lethalHitsAppliesTo(this);
if(hasLethalHits) {
double weaponAttacks = calculateWeaponAttacks(amountOfWeapons, unitAttackModifiers, target, weaponAttackResolution);
var lethalHitsProbability = calculateProbabilityNumeratorFrom(unitAttackModifiers.getCriticalHitValue()) / 6;
if(weapon.hasLethalHits()) { lethalHitsCount = weaponAttacks * lethalHitsProbability;
double weaponAttacks = calculateBasicWeaponAttacks(weapon, amountOfWeapons); // If we're fishing for hit criticals, the lethal hits will be higher
var lethalHitsProbabilityNumerator = calculateProbabilityNumeratorFrom(criticalHitValue); if(unitAttackModifiers.isFishForCriticalHits()) {
var lethalHitsFailureProbability = (6 - (lethalHitsProbability * 6)) / 6;
lethalHitsCount = weaponAttacks * ((double) lethalHitsProbabilityNumerator / PROBABILITY_DENOMINATOR_D6); var extraLethalHitsFromFishingCriticals = weaponAttacks * lethalHitsFailureProbability * lethalHitsProbability;
results.setWeaponStat(weapon, WeaponStat.LETHAL_HITS_COUNT, lethalHitsCount); weaponAttackResolution.setLethalHitsFromFishingCriticals(extraLethalHitsFromFishingCriticals);
lethalHitsCount += extraLethalHitsFromFishingCriticals;
}
weaponAttackResolution.setNormalWoundsFromLethalHits(lethalHitsCount);
} }
return lethalHitsCount; return lethalHitsCount;
} }
private Optional<Reroll> prepareHitRerolls(int hitRollGoal, UnitAttackModifiers unitAttackModifiers, private Optional<Reroll> prepareRerolls(RerollStageType stageType, int rollGoal,
DefensiveProfile target) { UnitAttackModifiers unitAttackModifiers, DefensiveProfile target) {
var hasAnyHitReroll = unitAttackModifiers.hasRerollFor(RerollStageType.HIT, RerollType.ANY, var hasAnyReroll = unitAttackModifiers.hasRerollFor(stageType, RerollType.ANY, target.getUnitTypeKeywords(), this.rangeType);
target.getUnitTypeKeywords(), this.rangeType); var hasOnesReroll = unitAttackModifiers.hasRerollFor(stageType, RerollType.ON_ONES, target.getUnitTypeKeywords(), this.rangeType);
var hasOnesHitsReroll = unitAttackModifiers.hasRerollFor(RerollStageType.HIT, RerollType.ON_ONES, var hasFailedReroll = unitAttackModifiers.hasRerollFor(stageType, RerollType.ON_FAILURE, target.getUnitTypeKeywords(), this.rangeType);
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 (hasAnyReroll || hasFailedReroll) {
if(hasAnyHitReroll || hasFailedHitsReroll) { return hasAnyReroll ?
return hasAnyHitReroll ? unitAttackModifiers.getRerollFor(RerollStageType.HIT, RerollType.ANY, unitAttackModifiers.getRerollFor(stageType, RerollType.ANY, target.getUnitTypeKeywords(), this.rangeType, rollGoal)
target.getUnitTypeKeywords(), this.rangeType, hitRollGoal) : : unitAttackModifiers.getRerollFor(stageType, RerollType.ON_FAILURE, target.getUnitTypeKeywords(), this.rangeType, rollGoal);
unitAttackModifiers.getRerollFor(RerollStageType.HIT, RerollType.ON_FAILURE,
target.getUnitTypeKeywords(), this.rangeType, hitRollGoal);
}
if(hasOnesHitsReroll) {
return unitAttackModifiers.getRerollFor(RerollStageType.HIT, RerollType.ON_ONES,
target.getUnitTypeKeywords(), this.rangeType, hitRollGoal);
} }
return Optional.empty(); if (hasOnesReroll) {
} return unitAttackModifiers.getRerollFor(stageType, RerollType.ON_ONES, target.getUnitTypeKeywords(), this.rangeType, rollGoal);
private Optional<Reroll> prepareWoundRerolls(int woundRollGoal) {
if(hasRerollWoundsOnes) {
return Optional.of(Reroll.rerollOnOnes());
}
if(hasRerollWoundsFailures || hasRerollWoundsAny) {
return Optional.of(Reroll.rerollFailures(woundRollGoal));
} }
return Optional.empty(); return Optional.empty();
@ -323,21 +318,21 @@ public class Weapon {
} }
private int calculateWoundRollGoal(Weapon weapon) { private int calculateWoundRollGoal(DefensiveProfile target, UnitAttackModifiers unitAttackModifiers) {
var woundRollGoal = 4; var woundRollGoal = 4;
var finalWoundPenalty = hasWoundRollPenalty ? 1 : 0; var finalWoundPenalty = unitAttackModifiers.getWoundMalusModifier()._1 ? 1 : 0;
var toughness = defensiveProfile.getToughness(); var toughness = target.getToughness();
var strength = weapon.getStrength();
if(strength > toughness) { if(this.strength > toughness) {
finalWoundPenalty += woundRollPenaltyIfStrengthIsHigher ? 1 : 0; finalWoundPenalty += target.isHasWoundMalusIfStrengthHigherThanToughness() ? 1 : 0;
woundRollGoal = strength >= toughness*2 ? 2 : 3; woundRollGoal = strength >= toughness*2 ? 2 : 3;
} else if(strength < toughness) { } else if(strength < toughness) {
woundRollGoal = toughness*2 >= strength ? 6 : 5; woundRollGoal = toughness*2 >= strength ? 6 : 5;
} }
var finalWoundBonus = hasWoundRollBonus ? 1 : 0; var finalWoundBonus = unitAttackModifiers.getWoundBonusModifier()._1 ? 1 : 0;
if(attackerCharged && weapon.hasLance() && weapon.getRangeType() == WeaponRangeType.MELEE) { var hasLance = (this.hasLance || unitAttackModifiers.isLanceModifier()) && rangeType == WeaponRangeType.MELEE;
if(unitAttackModifiers.isUnitCharged() && hasLance) {
finalWoundBonus++; finalWoundBonus++;
} }
@ -348,32 +343,15 @@ public class Weapon {
woundRollGoal -= finalWoundRollModifier; woundRollGoal -= finalWoundRollModifier;
// Now apply any anti-X if it is better than the current wound roll // Now apply any anti-X if it is better than the current wound roll
var antiWoundRoll = weapon.getBestAntiFor(defensiveProfile); var antiWoundRoll = antiUnitEffects.stream()
.filter(effect -> target.getUnitTypeKeywords().contains(effect.getUnitType()))
.mapToInt(AntiUnitEffect::getWoundRollValue)
.min()
.stream().boxed().findFirst(); // Necessary ugliness
if(antiWoundRoll.isPresent()) { if(antiWoundRoll.isPresent()) {
woundRollGoal = Math.min(woundRollGoal, antiWoundRoll.get()); woundRollGoal = Math.min(woundRollGoal, antiWoundRoll.get());
} }
return woundRollGoal; return woundRollGoal;
} }
/**
* Returns whether this weapon is anti for any of the given types
*/
public boolean isAntiFor(Set<UnitTypeKeyword> unitTypes) {
var antiUnitTypes = antiStats.keySet();
return unitTypes.stream().anyMatch(antiUnitTypes::contains);
}
public Optional<Integer> getBestAntiFor(DefensiveProfile target) {
var bestAnti = Optional.<Integer>empty();
if(isAntiFor(target.getUnitTypeKeywords())) {
var antiUnitTypes = antiStats.keySet();
var minAnti = target.getUnitTypeKeywords().stream()
.filter(antiUnitTypes::contains)
.mapToInt(type -> antiStats.get(type))
.min().getAsInt();
bestAnti = Optional.of(minAnti);
}
return bestAnti;
}
} }

View file

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