Implemented both attack and defence, now comes refactoring

This commit is contained in:
Loïc Prieto 2025-06-16 16:53:03 +02:00
parent fa2f37b813
commit 5a19f29e3b
8 changed files with 204 additions and 22 deletions

View file

@ -84,12 +84,13 @@ public class AttackScenario {
} }
public AttackScenarioResolution resolveAttack() { public AttackScenarioResolution resolveAttack() {
var resolution = new AttackScenarioResolution();
// Filter out weapons that cannot participate in this attack // Filter out weapons that cannot participate in this attack
var validWeapons = weapons.stream().filter( w -> hasLineOfSight || w._1.hasIndirectFire()) var validWeapons = weapons.stream().filter( w -> hasLineOfSight || w._1.hasIndirectFire())
.toList(); .toList();
// Attack step // Attack step
var totalDamage = 0d;
var totalPreventedDamage = 0d;
for(var weaponData : validWeapons) { for(var weaponData : validWeapons) {
Weapon weapon = weaponData._1; Weapon weapon = weaponData._1;
int amountOfWeapons = weaponData._2; int amountOfWeapons = weaponData._2;
@ -101,17 +102,18 @@ public class AttackScenario {
var mortalWounds = passingWounds._2; var mortalWounds = passingWounds._2;
// Defence funnel // 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; return results;
}
private void executeDefenceFunnel(Weapon weapon, double normalWounds, double mortalWounds) {
// Save normal wounds
//
} }
/** /**

View file

@ -1,6 +1,7 @@
package ninja.thefirearchmage.games.fourtykcalculator; package ninja.thefirearchmage.games.fourtykcalculator;
import lombok.Getter; import lombok.Getter;
import lombok.Setter;
import ninja.thefirearchmage.games.fourtykcalculator.utils.datastructures.Tuple; import ninja.thefirearchmage.games.fourtykcalculator.utils.datastructures.Tuple;
import ninja.thefirearchmage.games.fourtykcalculator.utils.datastructures.Tuple2; import ninja.thefirearchmage.games.fourtykcalculator.utils.datastructures.Tuple2;
@ -14,9 +15,15 @@ import static java.lang.String.format;
@Getter @Getter
public class AttackScenarioResolution { public class AttackScenarioResolution {
private Map<String, Map<WeaponStat, Object>> weaponStats; private Map<String, Map<WeaponStat, Object>> weaponStats;
@Setter
private double totalInflictedDamage;
@Setter
private double totalPreventedDamage;
public AttackScenarioResolution() { public AttackScenarioResolution() {
this.weaponStats = new HashMap<>(); this.weaponStats = new HashMap<>();
totalInflictedDamage = 0;
totalPreventedDamage = 0;
} }
public void setWeaponStat(Weapon weapon, WeaponStat statName, Object statValue) { public void setWeaponStat(Weapon weapon, WeaponStat statName, Object statValue) {

View file

@ -12,26 +12,163 @@ public class DefensiveProfile {
private final int normalSave; private final int normalSave;
private final int wounds; private final int wounds;
private final int bodies; private final int bodies;
private final Optional<InvulnerableSave> invulnerableSave; private Optional<InvulnerableSave> invulnerableSave;
private final Optional<FeelNoPainEffect> feelNoPain; private Optional<FeelNoPainEffect> feelNoPain;
private final Set<UnitTypeKeyword> unitTypeKeywords; private Set<UnitTypeKeyword> 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) { 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> invulnerableSave, Optional<FeelNoPainEffect> feelNoPain) { public DefensiveProfile(int toughness, int wounds, int bodies, int normalSave, Optional<InvulnerableSave> invulnerableSave,
Optional<FeelNoPainEffect> feelNoPain, Set<UnitTypeKeyword> unitTypeKeywords,
boolean isInCover, boolean hasRerollOneNormalSaves, boolean hasRerollFailedNormalSaves,
boolean hasRerollAnyNormalSaves, boolean isVisible) {
this.toughness = toughness; this.toughness = toughness;
this.wounds = wounds;
this.normalSave = normalSave; this.normalSave = normalSave;
this.wounds = wounds;
this.bodies = bodies;
this.invulnerableSave = invulnerableSave; this.invulnerableSave = invulnerableSave;
this.feelNoPain = feelNoPain; this.feelNoPain = feelNoPain;
this.bodies = bodies; this.unitTypeKeywords = unitTypeKeywords;
this.unitTypeKeywords = new HashSet<>(); this.isInCover = isInCover;
this.hasRerollOneNormalSaves = hasRerollOneNormalSaves;
this.hasRerollFailedNormalSaves = hasRerollFailedNormalSaves;
this.hasRerollAnyNormalSaves = hasRerollAnyNormalSaves;
this.isVisible = isVisible;
} }
public DefensiveProfile withUnitTypes(Set<UnitTypeKeyword> types) { public DefensiveProfile withUnitTypes(Set<UnitTypeKeyword> types) {
this.unitTypeKeywords.addAll(types); this.unitTypeKeywords.addAll(types);
return this; 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<Reroll> 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;
}
} }

View file

@ -1,6 +1,11 @@
package ninja.thefirearchmage.games.fourtykcalculator; package ninja.thefirearchmage.games.fourtykcalculator;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public class FeelNoPainEffect { public class FeelNoPainEffect {
private int rollValue; private int rollValue;
private FeelNoPainType type; private FeelNoPainType type;

View file

@ -15,4 +15,14 @@ public class InvulnerableSave {
this.rollValue = rollValue; this.rollValue = rollValue;
this.type = type; 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;
};
}
} }

View file

@ -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;
}

View file

@ -12,7 +12,9 @@ public enum WeaponStat {
WOUNDS_COUNT_WITHOUT_REROLL(Double.class), WOUNDS_COUNT_WITHOUT_REROLL(Double.class),
WOUNDS_COUNT_FROM_REROLL(Double.class), WOUNDS_COUNT_FROM_REROLL(Double.class),
MORTAL_WOUNDS_COUNT(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), LETHAL_HITS_COUNT(Double.class),
NON_LETHAL_HITS_COUNT(Double.class), NON_LETHAL_HITS_COUNT(Double.class),
SUSTAINED_HITS_COUNT(Double.class); SUSTAINED_HITS_COUNT(Double.class);

View file

@ -6,7 +6,7 @@ eradicator_melta_rifle:
dice_type: 6 dice_type: 6
number_of_dices: 1 number_of_dices: 1
melta: 2 melta: 2
ap: -4 ap: 4
toHit: 3 toHit: 3
strength: 9 strength: 9
isHeavy: true isHeavy: true
@ -18,7 +18,7 @@ eradicator_multi_melta:
attacks: 2 attacks: 2
range_type: RANGED range_type: RANGED
melta: 2 melta: 2
ap: -4 ap: 4
toHit: 4 toHit: 4
strength: 9 strength: 9
isHeavy: true isHeavy: true
@ -26,7 +26,7 @@ intercessor_bolt_rifle:
name: Bolt Rifle (Intercessor) name: Bolt Rifle (Intercessor)
attacks: 2 attacks: 2
damage: 1 damage: 1
ap: -1 ap: 1
toHit: 3 toHit: 3
strength: 4 strength: 4
isHeavy: true isHeavy: true