diff --git a/src/Ability.ts b/src/Ability.ts index f14d7c5..f1a6280 100644 --- a/src/Ability.ts +++ b/src/Ability.ts @@ -19,6 +19,7 @@ export enum Ability { EliteModerate = "EliteModerate", // promote miss to norm or norm to crit EliteExtreme = "EliteExtreme", // promote miss to crit JustAScratch = "JustAScratch", // cancel one attack die just before damage; both shoot and fight + Durable = "Durable", // one crit hit does 1 less damage, to minimun of 3 // fight stuff Brutal = "Brutal", // opponent can only parry with crit diff --git a/src/CalcEngineFight.test.ts b/src/CalcEngineFight.test.ts index 4d853b3..b338f99 100644 --- a/src/CalcEngineFight.test.ts +++ b/src/CalcEngineFight.test.ts @@ -152,7 +152,7 @@ describe(calcDieChoice.name + ', common & strike/parry', () => { }); it('#2b: if enemy already stunned, then cannot stun again', () => { const chooser = newFighterState(99, 99, 99, FightStrategy.Parry, new Set([Ability.Stun])); - chooser.hasDoneStun = true; + chooser.hasCritStruck = true; const enemy = newFighterState(0, 99, 20); expect(calcDieChoice(chooser, enemy)).toBe(FightChoice.NormParry); }); @@ -243,7 +243,7 @@ describe(resolveDieChoice.name + ': basic, stun, storm shield, hammerhand, duell it('CritStrike+stun, already stunned', () => { for(let stormShieldMaybe of [Ability.None, Ability.StormShield]) { // storm shield shouldn't matter const chooser = makeChooser(Ability.Stun, stormShieldMaybe); - chooser.hasDoneStun = true; + chooser.hasCritStruck = true; const enemy = makeEnemy(chooser.profile.critDmg + finalWounds); resolveDieChoice(FightChoice.CritStrike, chooser, enemy); diff --git a/src/CalcEngineFightInternal.ts b/src/CalcEngineFightInternal.ts index 30d3680..8835945 100644 --- a/src/CalcEngineFightInternal.ts +++ b/src/CalcEngineFightInternal.ts @@ -7,6 +7,7 @@ import FightStrategy from 'src/FightStrategy'; import FighterState from "src/FighterState"; import FightChoice from "src/FightChoice"; import Ability from "src/Ability"; +import { MinCritDmgAfterDurable } from "./KtMisc"; export const toWoundPairKey = (guy1Wounds: number, guy2Wounds: number): string => [guy1Wounds, guy2Wounds].toString(); export const fromWoundPairKey = (woundsPairText: string): number[] => woundsPairText.split(',').map(x => parseInt(x)); @@ -107,20 +108,15 @@ export function resolveFight( while(currentGuy.crits + currentGuy.norms + nextGuy.crits + nextGuy.norms > 0 && currentGuy.currentWounds > 0 && nextGuy.currentWounds > 0) { - // if a guy is out of successes, then other guy does all strikes - if(currentGuy.crits + currentGuy.norms <= 0) { - currentGuy.applyDmg(nextGuy.totalDmg()); - break; - } - else if(nextGuy.crits + nextGuy.norms <= 0) { - nextGuy.applyDmg(currentGuy.totalDmg()); - break; - } - else { + // used to have a `if(oneGuy out of successes){ oneGuy.applyDmg(otherGuy.totalDmg())); }` + // but it would be painful to make that handle Durable and other abilities + + if(currentGuy.crits + currentGuy.norms > 0) { const choice = calcDieChoice(currentGuy, nextGuy); resolveDieChoice(choice, currentGuy, nextGuy); - [currentGuy, nextGuy] = [nextGuy, currentGuy]; } + + [currentGuy, nextGuy] = [nextGuy, currentGuy]; } if(guy1State.crits < 0 || guy1State.norms < 0 @@ -135,7 +131,7 @@ export function calcDieChoice(chooser: FighterState, enemy: FighterState): Fight // ALWAYS strike if you can kill enemy with a single strike; // also, if enemy has brutal and you have no crits, then you must strike; - if(chooser.nextDmg() >= enemy.currentWounds + if(chooser.nextDmg(enemy) >= enemy.currentWounds || (enemy.profile.has(Ability.Brutal) && chooser.crits === 0)) { return chooser.nextStrike(); } @@ -143,7 +139,7 @@ export function calcDieChoice(chooser: FighterState, enemy: FighterState): Fight // if can stun enemy (crit strike that also cancels an enemy NORM success), // and enemy doesn't have any crit successes, then there is no downside // to doing a stunning crit strike now - if(chooser.profile.has(Ability.Stun) && !chooser.hasDoneStun && chooser.crits > 0 && enemy.crits === 0) { + if(chooser.profile.has(Ability.Stun) && !chooser.hasCritStruck && chooser.crits > 0 && enemy.crits === 0) { return FightChoice.CritStrike; } @@ -204,7 +200,7 @@ export function resolveDieChoice( chooser: FighterState, enemy: FighterState, ): void { - function applyFirstStrikeDmg(dmg: number) { + function applyDmgWithFirstStrikeHandling(dmg: number) { if(!chooser.hasStruck) { if(enemy.profile.abilities.has(Ability.JustAScratch)) { dmg = 0; @@ -217,38 +213,36 @@ export function resolveDieChoice( } if(choice === FightChoice.CritStrike) { + let critDmgAfterPossibleDurable = chooser.nextCritDmgWithDurableAndWithoutHammerhand(enemy); + applyDmgWithFirstStrikeHandling(critDmgAfterPossibleDurable); chooser.crits--; - applyFirstStrikeDmg(chooser.profile.critDmg); + + if(chooser.profile.has(Ability.Stun) && !chooser.hasCritStruck) { + enemy.norms = Math.max(0, enemy.norms - 1); // stun ability can only cancel an enemy norm success + } if ( chooser.successes() && chooser.profile.has(Ability.MurderousEntrance) - && !chooser.hasDoneMurderousEntrance + && !chooser.hasCritStruck ) { - chooser.hasDoneMurderousEntrance = true; - if(chooser.crits > 0) { - chooser.crits--; enemy.applyDmg(chooser.profile.critDmg); + chooser.crits--; } else { - chooser.norms--; enemy.applyDmg(chooser.profile.normDmg); + chooser.norms--; } } - if(chooser.profile.has(Ability.Stun) && !chooser.hasDoneStun) { - chooser.hasDoneStun = true; - enemy.norms = Math.max(0, enemy.norms - 1); // stun ability can only cancel an enemy norm success - } + chooser.hasCritStruck = true; } else if(choice === FightChoice.NormStrike) { + applyDmgWithFirstStrikeHandling(chooser.profile.normDmg); chooser.norms--; - applyFirstStrikeDmg(chooser.profile.normDmg); } else if(choice === FightChoice.CritParry) { - chooser.crits--; - // Dueller: critical parry can cancel additional normal success if(chooser.profile.abilities.has(Ability.Dueller)) { let numCritsCancelled = 0; @@ -270,13 +264,14 @@ export function resolveDieChoice( } } } + chooser.crits--; } else if(choice === FightChoice.NormParry) { if(enemy.profile.has(Ability.Brutal)) { throw new Error("not allowed to do FightChoice.NormParry when enemy has brutal") } - chooser.norms--; enemy.norms = Math.max(0, enemy.norms - chooser.profile.cancelsPerParry()); + chooser.norms--; } else { throw new Error("invalid DieChoice"); diff --git a/src/CalcEngineShoot.test.ts b/src/CalcEngineShoot.test.ts index 3a7ddca..117fa0e 100644 --- a/src/CalcEngineShoot.test.ts +++ b/src/CalcEngineShoot.test.ts @@ -126,6 +126,21 @@ describe(calcDamage.name + ', smallCrit (crit < norm)', () => { }); }); +describe(calcDmgProbs.name + ', Durable', () => { + const dn = 10; // normal damage + const dc = 100; // critical damage + const atker = new Model(0, 0, dn, dc); + const def = new Model().setAbility(Ability.Durable); + it('Durable, D=10/100', () => { + expect(calcDamage(atker, def, 2, 2, 0, 0)).toBe(2 * dc - 1 + 2 * dn); + }); + it('Durable, D=10/3', () => { + const lowDc = 3; + const lowAtker = new Model(0, 0, dn, lowDc); + expect(calcDamage(lowAtker, def, 2, 2, 0, 0)).toBe(2 * lowDc + 2 * dn); + }); +}); + describe(calcDamage.name + ', Fire Team rules', () => { // test typical situation of normDmg < critDmg < 2*normDmg const dn = 5; // normal damage @@ -616,5 +631,4 @@ describe('q', () => { expect(0).toBe(0); }); }); - -*/ \ No newline at end of file +*/ diff --git a/src/CalcEngineShootInternal.ts b/src/CalcEngineShootInternal.ts index 4a5b650..1a27dd5 100644 --- a/src/CalcEngineShootInternal.ts +++ b/src/CalcEngineShootInternal.ts @@ -3,6 +3,7 @@ import * as Util from 'src/Util'; import FinalDiceProb from 'src/FinalDiceProb'; import * as Common from 'src/CalcEngineCommon'; import Ability from "src/Ability"; +import { MinCritDmgAfterDurable } from "./KtMisc"; class DefenderFinalDiceStuff { public finalDiceProbs: FinalDiceProb[]; @@ -167,5 +168,11 @@ export function calcDamage( } damage += critHits * attacker.critDmg + normHits * attacker.normDmg; + + // TODO: make the above decisions take Durable into account + if(defender.has(Ability.Durable) && attacker.critDmg > MinCritDmgAfterDurable && critHits > 0) { + damage -= 1; + } + return damage; } diff --git a/src/FighterState.ts b/src/FighterState.ts index 41de922..d2bed1d 100644 --- a/src/FighterState.ts +++ b/src/FighterState.ts @@ -3,6 +3,7 @@ import Model from "src/Model"; import FightStrategy from 'src/FightStrategy'; import FightChoice from "src/FightChoice"; import Ability from "./Ability"; +import { MinCritDmgAfterDurable } from "./KtMisc"; export default class FighterState { public profile: Model; @@ -10,9 +11,8 @@ export default class FighterState { public norms: number; public strategy: FightStrategy; public currentWounds: number; - public hasDoneStun: boolean; public hasStruck: boolean; - public hasDoneMurderousEntrance: boolean; + public hasCritStruck: boolean; public constructor( profile: Model, @@ -20,18 +20,16 @@ export default class FighterState { norms: number, strategy: FightStrategy, currentWounds: number = -1, - hasDoneStun: boolean = false, - hasDoneHammerhand: boolean = false, - hasDoneMurderousEntrance: boolean = false, + hasStruck: boolean = false, + hasCritStruck: boolean = false, ) { this.profile = profile; this.crits = crits; this.norms = norms; this.strategy = strategy; this.currentWounds = currentWounds > 0 ? currentWounds : this.profile.wounds; - this.hasDoneStun = hasDoneStun; - this.hasStruck = hasDoneHammerhand; - this.hasDoneMurderousEntrance = hasDoneMurderousEntrance; + this.hasStruck = hasStruck; + this.hasCritStruck = hasCritStruck; } public successes() { @@ -42,6 +40,12 @@ export default class FighterState { this.currentWounds = Math.max(0, this.currentWounds - dmg); } + public applyDmgFromStrike(dmg: number, atker: Model, isCrit: boolean) { + if (isCrit) { + this.hasCritStruck = true; + } + } + public isFullHealth() { return this.currentWounds === this.profile.wounds; } @@ -66,13 +70,26 @@ export default class FighterState { return this.possibleDmg(this.crits, this.norms); } - public nextDmg(): number { - let dmg = this.hammerhandDmg(); + public nextCritDmgWithDurableAndWithoutHammerhand(enemy: FighterState): number { + let critDmg = this.profile.critDmg; + + if(enemy.profile.abilities.has(Ability.Durable) + && !this.hasCritStruck + && this.profile.critDmg > MinCritDmgAfterDurable + ) { + critDmg--; + } + + return critDmg; + } + + public nextDmg(enemy: FighterState): number { + let dmg = 0; if (this.crits > 0) { - dmg += this.profile.critDmg; + dmg += this.nextCritDmgWithDurableAndWithoutHammerhand(enemy); - if (this.profile.has(Ability.MurderousEntrance) && !this.hasDoneMurderousEntrance) { + if (this.profile.has(Ability.MurderousEntrance) && !this.hasCritStruck) { dmg += this.profile.critDmg; } } @@ -80,6 +97,7 @@ export default class FighterState { dmg += this.profile.normDmg; } + dmg += this.hammerhandDmg(); return dmg; } diff --git a/src/KtMisc.ts b/src/KtMisc.ts index 605106b..0a2c6ca 100644 --- a/src/KtMisc.ts +++ b/src/KtMisc.ts @@ -2,4 +2,5 @@ import { range } from "lodash"; export const MaxWounds = 24; export const WoundRange = range(1, MaxWounds + 1); -export const SaveRange = range(2, 7); \ No newline at end of file +export const SaveRange = range(2, 7); +export const MinCritDmgAfterDurable = 3; // not including crit dmg that starts lower than this \ No newline at end of file diff --git a/src/Model.ts b/src/Model.ts index 57ed91b..a72d7e4 100644 --- a/src/Model.ts +++ b/src/Model.ts @@ -122,7 +122,7 @@ export default class Model { return this.abilities.has(ability); } - public setAbility(ability: Ability, addIt: boolean): Model { + public setAbility(ability: Ability, addIt: boolean = true): Model { Util.addOrRemove(this.abilities, ability, addIt); return this; } diff --git a/src/components/DefenderControls.tsx b/src/components/DefenderControls.tsx index 620f44e..a76a8d0 100644 --- a/src/components/DefenderControls.tsx +++ b/src/components/DefenderControls.tsx @@ -66,6 +66,7 @@ const DefenderControls: React.FC = (props: Props) => { new IncProps(N.FeelNoPain, def.fnp + '+', xspan(6, 2, '+'), numHandler('fnp')), new IncProps(N.Reroll, def.reroll, preX(rerolls), textHandler('reroll')), new IncProps(N.JustAScratch, toYN(Ability.JustAScratch), xAndCheck, singleHandler(Ability.JustAScratch)), + new IncProps(N.Durable, toYN(Ability.Durable), xAndCheck, singleHandler(Ability.Durable)), ]; // we actually have 1 column when rendered, and order gets weird if we pretend we have 2 diff --git a/src/components/FighterControls.tsx b/src/components/FighterControls.tsx index a09bf97..1e1a647 100644 --- a/src/components/FighterControls.tsx +++ b/src/components/FighterControls.tsx @@ -84,15 +84,16 @@ const FighterControls: React.FC = (props: Props) => { new IncProps(N.StunMelee, toYN(Ability.Stun), xAndCheck, singleHandler(Ability.Stun)), ]; const advancedParams: IncProps[] = [ - new IncProps(N.NicheAbility, nicheAbility, nicheAbilities, subsetHandler(nicheAbilities)), - new IncProps(N.AutoNorms, atk.autoNorms, xspan(1, 9), numHandler('autoNorms')), - new IncProps(N.AutoCrits, atk.autoCrits, xspan(1, 9), numHandler('autoCrits')), - new IncProps(N.NormsToCrits, atk.normsToCrits, xspan(1, 9), numHandler('normsToCrits')), - new IncProps(N.FailsToNorms, atk.failsToNorms, xspan(1, 9), numHandler('failsToNorms')), - new IncProps(N.FailToNormIfCrit, toYN(Ability.FailToNormIfCrit), xAndCheck, singleHandler(Ability.FailToNormIfCrit)), - new IncProps('ElitePoints*', eliteAbility, eliteAbilities, subsetHandler(eliteAbilities)), - new IncProps(N.Duelist, toYN(Ability.Duelist), xAndCheck, singleHandler(Ability.Duelist)), - new IncProps(N.JustAScratch, toYN(Ability.JustAScratch), xAndCheck, singleHandler(Ability.JustAScratch)), + new IncProps(N.NicheAbility, nicheAbility, nicheAbilities, subsetHandler(nicheAbilities)), + new IncProps(N.AutoNorms, atk.autoNorms, xspan(1, 9), numHandler('autoNorms')), + new IncProps(N.AutoCrits, atk.autoCrits, xspan(1, 9), numHandler('autoCrits')), + new IncProps(N.NormsToCrits, atk.normsToCrits, xspan(1, 9), numHandler('normsToCrits')), + new IncProps(N.FailsToNorms, atk.failsToNorms, xspan(1, 9), numHandler('failsToNorms')), + new IncProps(N.FailToNormIfCrit, toYN(Ability.FailToNormIfCrit), xAndCheck, singleHandler(Ability.FailToNormIfCrit)), + new IncProps('ElitePoints*', eliteAbility, eliteAbilities, subsetHandler(eliteAbilities)), + new IncProps(N.Duelist, toYN(Ability.Duelist), xAndCheck, singleHandler(Ability.Duelist)), + new IncProps(N.JustAScratch, toYN(Ability.JustAScratch), xAndCheck, singleHandler(Ability.JustAScratch)), + new IncProps(N.Durable, toYN(Ability.Durable), xAndCheck, singleHandler(Ability.Durable)), ]; const advancedParamsToShow diff --git a/src/components/ShootSection.tsx b/src/components/ShootSection.tsx index 682174b..a10c268 100644 --- a/src/components/ShootSection.tsx +++ b/src/components/ShootSection.tsx @@ -51,7 +51,7 @@ const ShootSection: React.FC = () => { N.CoverCritSaves, N.NormsToCrits, N.InvulnSave, - //N.Durable, + N.Durable, N.HardyX, N.FeelNoPain, N.EliteModerate,