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() {
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;
}
/**

View file

@ -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<String, Map<WeaponStat, Object>> 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) {

View file

@ -12,26 +12,163 @@ public class DefensiveProfile {
private final int normalSave;
private final int wounds;
private final int bodies;
private final Optional<InvulnerableSave> invulnerableSave;
private final Optional<FeelNoPainEffect> feelNoPain;
private final Set<UnitTypeKeyword> unitTypeKeywords;
private Optional<InvulnerableSave> invulnerableSave;
private Optional<FeelNoPainEffect> feelNoPain;
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) {
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.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<UnitTypeKeyword> 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<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;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public class FeelNoPainEffect {
private int rollValue;
private FeelNoPainType type;

View file

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

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_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);

View file

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