Skip to content

Commit

Permalink
add Durable to kt chooting and fighting
Browse files Browse the repository at this point in the history
  • Loading branch information
Jacob Egner authored and Jacob Egner committed Jul 4, 2024
1 parent dead8a3 commit 361889c
Show file tree
Hide file tree
Showing 11 changed files with 94 additions and 56 deletions.
1 change: 1 addition & 0 deletions src/Ability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/CalcEngineFight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>([Ability.Stun]));
chooser.hasDoneStun = true;
chooser.hasCritStruck = true;
const enemy = newFighterState(0, 99, 20);
expect(calcDieChoice(chooser, enemy)).toBe(FightChoice.NormParry);
});
Expand Down Expand Up @@ -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);
Expand Down
51 changes: 23 additions & 28 deletions src/CalcEngineFightInternal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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
Expand All @@ -135,15 +131,15 @@ 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();
}

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

Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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");
Expand Down
18 changes: 16 additions & 2 deletions src/CalcEngineShoot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -616,5 +631,4 @@ describe('q', () => {
expect(0).toBe(0);
});
});
*/
*/
7 changes: 7 additions & 0 deletions src/CalcEngineShootInternal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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;
}
42 changes: 30 additions & 12 deletions src/FighterState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,35 +3,33 @@ 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;
public crits: number;
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,
crits: number,
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() {
Expand All @@ -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;
}
Expand All @@ -66,20 +70,34 @@ 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;
}
}
else if (this.norms > 0) {
dmg += this.profile.normDmg;
}

dmg += this.hammerhandDmg();
return dmg;
}

Expand Down
3 changes: 2 additions & 1 deletion src/KtMisc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
export const SaveRange = range(2, 7);
export const MinCritDmgAfterDurable = 3; // not including crit dmg that starts lower than this
2 changes: 1 addition & 1 deletion src/Model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions src/components/DefenderControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ const DefenderControls: React.FC<Props> = (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
Expand Down
19 changes: 10 additions & 9 deletions src/components/FighterControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,15 +84,16 @@ const FighterControls: React.FC<Props> = (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
Expand Down
2 changes: 1 addition & 1 deletion src/components/ShootSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ const ShootSection: React.FC = () => {
N.CoverCritSaves,
N.NormsToCrits,
N.InvulnSave,
//N.Durable,
N.Durable,
N.HardyX,
N.FeelNoPain,
N.EliteModerate,
Expand Down

0 comments on commit 361889c

Please sign in to comment.