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 ninja.thefirearchmage.games.fourtykcalculator.utils.datastructures.Tuple3;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.*;
@Getter
public class DefensiveProfile {
@ -25,31 +22,33 @@ public class DefensiveProfile {
private boolean fishFor6NormalSaves;
private boolean hasStealth;
private boolean isInStealthRange;
private boolean hasWoundMalusIfStrengthHigherThanToughness;
private Tuple3<Boolean, DamageReductionType, Integer> damageReductionModifier;
private Tuple3<Boolean, WeaponRangeType, Integer> apReduction;
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,
false, true);
false, true, false);
}
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 hasRerollAnyNormalSaves, boolean isVisible) {
boolean hasRerollAnyNormalSaves, boolean isVisible, boolean hasWoundMalusIfStrengthHigherThanToughness) {
this.toughness = toughness;
this.normalSave = normalSave;
this.wounds = wounds;
this.bodies = bodies;
this.invulnerableSave = invulnerableSave;
this.feelNoPain = feelNoPain;
this.feelNoPainEffects = feelNoPain;
this.unitTypeKeywords = unitTypeKeywords;
this.isInCover = isInCover;
this.hasRerollOneNormalSaves = hasRerollOneNormalSaves;
this.hasRerollFailedNormalSaves = hasRerollFailedNormalSaves;
this.hasRerollAnyNormalSaves = hasRerollAnyNormalSaves;
this.isVisible = isVisible;
this.hasWoundMalusIfStrengthHigherThanToughness = hasWoundMalusIfStrengthHigherThanToughness;
}
public DefensiveProfile withUnitTypes(Set<UnitTypeKeyword> types) {
@ -68,7 +67,7 @@ public class DefensiveProfile {
return this;
}
public DefensiveProfile withFeelNoPainConfig(FeelNoPainEffect feelNoPainConfig) {
this.feelNoPain = Optional.of(feelNoPainConfig);
this.feelNoPainEffects.add(feelNoPainConfig);
return this;
}
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) {
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 java.util.*;
import java.util.stream.Collectors;
public class Weapon {
@Getter
@ -73,7 +74,7 @@ public class Weapon {
var weaponAttackResolution = new 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 mortalWounds = passingWounds._2;
@ -93,7 +94,7 @@ public class Weapon {
weaponAttackResolution.setHitsWithoutRerolls(passingHits);
// Add hits from rerolls
var hitRerolls = prepareHitRerolls(hitRollGoal, unitAttackModifiers, target);
var hitRerolls = prepareRerolls(RerollStageType.HIT, hitRollGoal, unitAttackModifiers, target);
if(hitRerolls.isPresent()) {
var rerolls = hitRerolls.get();
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.
*/
private Tuple2<Double, Double> calculatePassingWounds(Weapon weapon, int amountOfWeapons, double passingHits) {
double lethalHitsCount = calculateLethalHits(weapon, amountOfWeapons);
private Tuple2<Double, Double> calculatePassingWounds(DefensiveProfile target, UnitAttackModifiers unitAttackModifiers,
int amountOfWeapons, double passingHits,
WeaponAttackResolution weaponAttackResolution) {
double lethalHitsCount = calculateLethalHits(amountOfWeapons, unitAttackModifiers, target, weaponAttackResolution);
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);
var passingNormalWounds = normalHits * ( (double) woundSuccessProbabilityNumerator / PROBABILITY_DENOMINATOR_D6);
results.setWeaponStat(weapon, WeaponStat.WOUNDS_COUNT_WITHOUT_REROLL, passingNormalWounds+lethalHitsCount);
var woundsRerolls = prepareWoundRerolls(woundRollGoal);
var passingNormalWounds = normalHits * woundSuccessProbabilityNumerator / 6;
weaponAttackResolution.setNormalWoundsWithoutRerolls(passingNormalWounds + lethalHitsCount);
var woundsRerolls = prepareRerolls(RerollStageType.WOUND, woundRollGoal, unitAttackModifiers, target);
if(woundsRerolls.isPresent()) {
var rerolls = woundsRerolls.get();
var failedWounds = normalHits * ((double) (1 - woundSuccessProbabilityNumerator) / PROBABILITY_DENOMINATOR_D6);
var extraWoundsFromRerolls = failedWounds * rerolls.getSuccessProbability() / PROBABILITY_DENOMINATOR_D6;
results.setWeaponStat(weapon, WeaponStat.WOUNDS_COUNT_FROM_REROLL, extraWoundsFromRerolls);
var failedWounds = normalHits * (6 - woundSuccessProbabilityNumerator) / 6;
var extraWoundsFromRerolls = failedWounds * rerolls.getSuccessProbability() / 6;
weaponAttackResolution.setNormalWoundsFromRerolls(extraWoundsFromRerolls);
passingNormalWounds += extraWoundsFromRerolls;
}
@ -182,50 +185,42 @@ public class Weapon {
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 hasLethalHits = this.hasLethalHits || unitAttackModifiers.lethalHitsAppliesTo(this);
if(hasLethalHits) {
double weaponAttacks = calculateWeaponAttacks(amountOfWeapons, unitAttackModifiers, target, weaponAttackResolution);
var lethalHitsProbability = calculateProbabilityNumeratorFrom(unitAttackModifiers.getCriticalHitValue()) / 6;
if(weapon.hasLethalHits()) {
double weaponAttacks = calculateBasicWeaponAttacks(weapon, amountOfWeapons);
var lethalHitsProbabilityNumerator = calculateProbabilityNumeratorFrom(criticalHitValue);
lethalHitsCount = weaponAttacks * ((double) lethalHitsProbabilityNumerator / PROBABILITY_DENOMINATOR_D6);
results.setWeaponStat(weapon, WeaponStat.LETHAL_HITS_COUNT, lethalHitsCount);
lethalHitsCount = weaponAttacks * lethalHitsProbability;
// If we're fishing for hit criticals, the lethal hits will be higher
if(unitAttackModifiers.isFishForCriticalHits()) {
var lethalHitsFailureProbability = (6 - (lethalHitsProbability * 6)) / 6;
var extraLethalHitsFromFishingCriticals = weaponAttacks * lethalHitsFailureProbability * lethalHitsProbability;
weaponAttackResolution.setLethalHitsFromFishingCriticals(extraLethalHitsFromFishingCriticals);
lethalHitsCount += extraLethalHitsFromFishingCriticals;
}
weaponAttackResolution.setNormalWoundsFromLethalHits(lethalHitsCount);
}
return lethalHitsCount;
}
private Optional<Reroll> prepareHitRerolls(int hitRollGoal, UnitAttackModifiers unitAttackModifiers,
DefensiveProfile target) {
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);
private Optional<Reroll> prepareRerolls(RerollStageType stageType, int rollGoal,
UnitAttackModifiers unitAttackModifiers, DefensiveProfile target) {
var hasAnyReroll = unitAttackModifiers.hasRerollFor(stageType, RerollType.ANY, target.getUnitTypeKeywords(), this.rangeType);
var hasOnesReroll = unitAttackModifiers.hasRerollFor(stageType, RerollType.ON_ONES, target.getUnitTypeKeywords(), this.rangeType);
var hasFailedReroll = unitAttackModifiers.hasRerollFor(stageType, 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(hasOnesHitsReroll) {
return unitAttackModifiers.getRerollFor(RerollStageType.HIT, RerollType.ON_ONES,
target.getUnitTypeKeywords(), this.rangeType, hitRollGoal);
if (hasAnyReroll || hasFailedReroll) {
return hasAnyReroll ?
unitAttackModifiers.getRerollFor(stageType, RerollType.ANY, target.getUnitTypeKeywords(), this.rangeType, rollGoal)
: unitAttackModifiers.getRerollFor(stageType, RerollType.ON_FAILURE, target.getUnitTypeKeywords(), this.rangeType, rollGoal);
}
return Optional.empty();
}
private Optional<Reroll> prepareWoundRerolls(int woundRollGoal) {
if(hasRerollWoundsOnes) {
return Optional.of(Reroll.rerollOnOnes());
}
if(hasRerollWoundsFailures || hasRerollWoundsAny) {
return Optional.of(Reroll.rerollFailures(woundRollGoal));
if (hasOnesReroll) {
return unitAttackModifiers.getRerollFor(stageType, RerollType.ON_ONES, target.getUnitTypeKeywords(), this.rangeType, rollGoal);
}
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 finalWoundPenalty = hasWoundRollPenalty ? 1 : 0;
var toughness = defensiveProfile.getToughness();
var strength = weapon.getStrength();
var finalWoundPenalty = unitAttackModifiers.getWoundMalusModifier()._1 ? 1 : 0;
var toughness = target.getToughness();
if(strength > toughness) {
finalWoundPenalty += woundRollPenaltyIfStrengthIsHigher ? 1 : 0;
if(this.strength > toughness) {
finalWoundPenalty += target.isHasWoundMalusIfStrengthHigherThanToughness() ? 1 : 0;
woundRollGoal = strength >= toughness*2 ? 2 : 3;
} else if(strength < toughness) {
woundRollGoal = toughness*2 >= strength ? 6 : 5;
}
var finalWoundBonus = hasWoundRollBonus ? 1 : 0;
if(attackerCharged && weapon.hasLance() && weapon.getRangeType() == WeaponRangeType.MELEE) {
var finalWoundBonus = unitAttackModifiers.getWoundBonusModifier()._1 ? 1 : 0;
var hasLance = (this.hasLance || unitAttackModifiers.isLanceModifier()) && rangeType == WeaponRangeType.MELEE;
if(unitAttackModifiers.isUnitCharged() && hasLance) {
finalWoundBonus++;
}
@ -348,32 +343,15 @@ public class Weapon {
woundRollGoal -= finalWoundRollModifier;
// 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()) {
woundRollGoal = Math.min(woundRollGoal, antiWoundRoll.get());
}
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 attacksFromModifiers;
private int attacksFromBlast;
private double lethalHitsFromFishingCriticals;
private SaveResolution saveResolution;
}