diff --git a/src/Bladeburner/Actions/BlackOperation.ts b/src/Bladeburner/Actions/BlackOperation.ts index 2780f3831..fd75cd469 100644 --- a/src/Bladeburner/Actions/BlackOperation.ts +++ b/src/Bladeburner/Actions/BlackOperation.ts @@ -5,6 +5,7 @@ import { BladeburnerActionType, BladeburnerBlackOpName } from "@enums"; import { ActionClass, ActionParams } from "./Action"; import { operationSkillSuccessBonus, operationTeamSuccessBonus } from "./Operation"; import { getEnumHelper } from "../../utils/EnumHelper"; +import type { TeamActionWithCasualties } from "./TeamCasualties"; interface BlackOpParams { name: BladeburnerBlackOpName; @@ -12,7 +13,7 @@ interface BlackOpParams { n: number; } -export class BlackOperation extends ActionClass { +export class BlackOperation extends ActionClass implements TeamActionWithCasualties { readonly type: BladeburnerActionType.BlackOp = BladeburnerActionType.BlackOp; readonly name: BladeburnerBlackOpName; n: number; @@ -57,6 +58,10 @@ export class BlackOperation extends ActionClass { return 1; } + getMinimumCasualties(): number { + return 1; + } + getTeamSuccessBonus = operationTeamSuccessBonus; getActionTypeSkillSuccessBonus = operationSkillSuccessBonus; diff --git a/src/Bladeburner/Actions/Operation.ts b/src/Bladeburner/Actions/Operation.ts index b619d0643..614839d09 100644 --- a/src/Bladeburner/Actions/Operation.ts +++ b/src/Bladeburner/Actions/Operation.ts @@ -6,17 +6,18 @@ import type { ActionIdFor, Availability, SuccessChanceParams } from "../Types"; import { BladeburnerActionType, BladeburnerMultName, BladeburnerOperationName } from "@enums"; import { BladeburnerConstants } from "../data/Constants"; import { ActionClass } from "./Action"; -import { Generic_fromJSON, IReviverValue, constructorsForReviver } from "../../utils/JSONReviver"; +import { constructorsForReviver, Generic_fromJSON, IReviverValue } from "../../utils/JSONReviver"; import { LevelableActionClass, LevelableActionParams } from "./LevelableAction"; import { clampInteger } from "../../utils/helpers/clampNumber"; import { getEnumHelper } from "../../utils/EnumHelper"; +import type { TeamActionWithCasualties } from "./TeamCasualties"; export interface OperationParams extends LevelableActionParams { name: BladeburnerOperationName; getAvailability?: (bladeburner: Bladeburner) => Availability; } -export class Operation extends LevelableActionClass { +export class Operation extends LevelableActionClass implements TeamActionWithCasualties { readonly type: BladeburnerActionType.Operation = BladeburnerActionType.Operation; readonly name: BladeburnerOperationName; teamCount = 0; @@ -44,6 +45,10 @@ export class Operation extends LevelableActionClass { getActionTypeSkillSuccessBonus = operationSkillSuccessBonus; + getMinimumCasualties(): number { + return 0; + } + getChaosSuccessFactor(inst: Bladeburner /*, params: ISuccessChanceParams*/): number { const city = inst.getCurrentCity(); if (city.chaos > BladeburnerConstants.ChaosThreshold) { diff --git a/src/Bladeburner/Actions/TeamCasualties.ts b/src/Bladeburner/Actions/TeamCasualties.ts new file mode 100644 index 000000000..1f9724f74 --- /dev/null +++ b/src/Bladeburner/Actions/TeamCasualties.ts @@ -0,0 +1,47 @@ +export enum CasualtyFactor { + LOW_CASUALTIES = 0.5, // 50% + HIGH_CASUALTIES = 1, // 100% +} + +export interface OperationTeam { + /** teamSize = Human Team + Supporting Sleeves */ + teamSize: number; + teamLost: number; + /** number of supporting sleeves at time of action completion */ + sleeveSize: number; + + getTeamCasualtiesRoll(low: number, high: number): number; + + killRandomSupportingSleeves(sleeveDeaths: number): void; +} + +export interface TeamActionWithCasualties { + teamCount: number; + + getMinimumCasualties(): number; +} + +/** + * Some actions (Operations and Black Operations) use teams for success bonus + * and may result in casualties, reducing the player's hp, killing team members + * and killing sleeves (to shock them, sleeves are immortal) * + */ +export function resolveTeamCasualties(action: TeamActionWithCasualties, team: OperationTeam, success: boolean) { + const severity = success ? CasualtyFactor.LOW_CASUALTIES : CasualtyFactor.HIGH_CASUALTIES; + const radius = action.teamCount * severity; + const worstCase = severity < 1 ? Math.ceil(radius) : Math.floor(radius); + /** Best case is always no deaths */ + const deaths = team.getTeamCasualtiesRoll(action.getMinimumCasualties(), worstCase); + const humans = action.teamCount - team.sleeveSize; + const humanDeaths = Math.min(humans, deaths); + /** Supporting Sleeves take damage when they are part of losses, + * e.g. 8 sleeves + 3 team members with 4 losses -> 1 sleeve takes damage */ + team.killRandomSupportingSleeves(deaths - humanDeaths); + + /** Clamped, bugfix for PR#1659 + * "BUGFIX: Wrong team size when all team members die in Bladeburner's action" */ + team.teamSize = Math.max(team.teamSize - humanDeaths, team.sleeveSize); + team.teamLost += deaths; + + return deaths; +} diff --git a/src/Bladeburner/Bladeburner.ts b/src/Bladeburner/Bladeburner.ts index e3190ec8e..b55b833f1 100644 --- a/src/Bladeburner/Bladeburner.ts +++ b/src/Bladeburner/Bladeburner.ts @@ -37,7 +37,6 @@ import { Settings } from "../Settings/Settings"; import { formatTime } from "../utils/helpers/formatTime"; import { joinFaction } from "../Faction/FactionHelpers"; import { isSleeveInfiltrateWork } from "../PersonObjects/Sleeve/Work/SleeveInfiltrateWork"; -import { isSleeveSupportWork } from "../PersonObjects/Sleeve/Work/SleeveSupportWork"; import { WorkStats, newWorkStats } from "../Work/WorkStats"; import { getEnumHelper } from "../utils/EnumHelper"; import { PartialRecord, createEnumKeyedRecord, getRecordEntries } from "../Types/Record"; @@ -50,10 +49,12 @@ import { GeneralActions } from "./data/GeneralActions"; import { PlayerObject } from "../PersonObjects/Player/PlayerObject"; import { Sleeve } from "../PersonObjects/Sleeve/Sleeve"; import { autoCompleteTypeShorthand } from "./utils/terminalShorthands"; +import { resolveTeamCasualties, type OperationTeam } from "./Actions/TeamCasualties"; +import { shuffleArray } from "../Infiltration/ui/BribeGame"; export const BladeburnerPromise: PromisePair = { promise: null, resolve: null }; -export class Bladeburner { +export class Bladeburner implements OperationTeam { numHosp = 0; moneyLost = 0; rank = 0; @@ -102,6 +103,7 @@ export class Bladeburner { automateThreshLow = 0; consoleHistory: string[] = []; consoleLogs: string[] = ["Bladeburner Console", "Type 'help' to see console commands"]; + getTeamCasualtiesRoll = getRandomIntInclusive; constructor() { this.contracts = createContracts(); @@ -750,32 +752,20 @@ export class Bladeburner { } } + killRandomSupportingSleeves(n: number) { + const sup = [...Player.sleevesSupportingBladeburner()]; // Explicit shallow copy + shuffleArray(sup); + sup.slice(0, Math.min(sup.length, n)).forEach((sleeve) => sleeve.kill()); + } + completeOperation(success: boolean): void { if (this.action?.type !== BladeburnerActionType.Operation) { throw new Error("completeOperation() called even though current action is not an Operation"); } const action = this.getActionObject(this.action); - - // Calculate team losses - const teamCount = action.teamCount; - if (teamCount >= 1) { - const maxLosses = success ? Math.ceil(teamCount / 2) : Math.floor(teamCount); - const losses = getRandomIntInclusive(0, maxLosses); - this.teamSize -= losses; - if (this.teamSize < this.sleeveSize) { - const sup = Player.sleeves.filter((x) => isSleeveSupportWork(x.currentWork)); - for (let i = 0; i > this.teamSize - this.sleeveSize; i--) { - const r = Math.floor(Math.random() * sup.length); - sup[r].takeDamage(sup[r].hp.max); - sup.splice(r, 1); - } - // If this happens, all team members died and some sleeves took damage. In this case, teamSize = sleeveSize. - this.teamSize = this.sleeveSize; - } - this.teamLost += losses; - if (this.logging.ops && losses > 0) { - this.log("Lost " + formatNumberNoSuffix(losses, 0) + " team members during this " + action.name); - } + const deaths = resolveTeamCasualties(action, this, success); + if (this.logging.ops && deaths > 0) { + this.log("Lost " + formatNumberNoSuffix(deaths, 0) + " team members during this " + action.name); } const city = this.getCurrentCity(); @@ -992,9 +982,7 @@ export class Bladeburner { this.stamina = 0; } - // Team loss variables - const teamCount = action.teamCount; - let teamLossMax; + let deaths; if (action.attempt(this, person)) { retValue = this.getActionStats(action, person, true); @@ -1004,7 +992,8 @@ export class Bladeburner { rankGain = addOffset(action.rankGain * currentNodeMults.BladeburnerRank, 10); this.changeRank(person, rankGain); } - teamLossMax = Math.ceil(teamCount / 2); + + deaths = resolveTeamCasualties(action, this, true); if (this.logging.blackops) { this.log( @@ -1028,7 +1017,8 @@ export class Bladeburner { this.moneyLost += cost; } } - teamLossMax = Math.floor(teamCount); + + deaths = resolveTeamCasualties(action, this, false); if (this.logging.blackops) { this.log( @@ -1042,26 +1032,10 @@ export class Bladeburner { this.resetAction(); // Stop regardless of success or fail - // Calculate team losses - if (teamCount >= 1) { - const losses = getRandomIntInclusive(1, teamLossMax); - this.teamSize -= losses; - if (this.teamSize < this.sleeveSize) { - const sup = Player.sleeves.filter((x) => isSleeveSupportWork(x.currentWork)); - for (let i = 0; i > this.teamSize - this.sleeveSize; i--) { - const r = Math.floor(Math.random() * sup.length); - sup[r].takeDamage(sup[r].hp.max); - sup.splice(r, 1); - } - // If this happens, all team members died and some sleeves took damage. In this case, teamSize = sleeveSize. - this.teamSize = this.sleeveSize; - } - this.teamLost += losses; - if (this.logging.blackops) { - this.log( - `${person.whoAmI()}: You lost ${formatNumberNoSuffix(losses, 0)} team members during ${action.name}.`, - ); - } + if (this.logging.blackops && deaths > 0) { + this.log( + `${person.whoAmI()}: You lost ${formatNumberNoSuffix(deaths, 0)} team members during ${action.name}.`, + ); } break; } diff --git a/src/Infiltration/ui/BribeGame.tsx b/src/Infiltration/ui/BribeGame.tsx index 7c4ffe5bf..8c737648d 100644 --- a/src/Infiltration/ui/BribeGame.tsx +++ b/src/Infiltration/ui/BribeGame.tsx @@ -12,6 +12,7 @@ import { KeyHandler } from "./KeyHandler"; interface Difficulty { [key: string]: number; + timer: number; size: number; } @@ -106,7 +107,7 @@ export function BribeGame(props: IMinigameProps): React.ReactElement { ); } -function shuffleArray(array: string[]): void { +export function shuffleArray(array: unknown[]): void { for (let i = array.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); const temp = array[i]; diff --git a/src/PersonObjects/Player/PlayerObject.ts b/src/PersonObjects/Player/PlayerObject.ts index 5e1eb07c4..818b714c0 100644 --- a/src/PersonObjects/Player/PlayerObject.ts +++ b/src/PersonObjects/Player/PlayerObject.ts @@ -28,6 +28,7 @@ import { CONSTANTS } from "../../Constants"; import { Person } from "../Person"; import { isMember } from "../../utils/EnumHelper"; import { PartialRecord } from "../../Types/Record"; +import { isSleeveSupportWork } from "../Sleeve/Work/SleeveSupportWork"; export class PlayerObject extends Person implements IPlayer { // Player-specific properties @@ -171,6 +172,10 @@ export class PlayerObject extends Person implements IPlayer { return "Player"; } + sleevesSupportingBladeburner(): Sleeve[] { + return this.sleeves.filter((s) => isSleeveSupportWork(s.currentWork)); + } + /** Serialize the current object to a JSON save state. */ toJSON(): IReviverValue { return Generic_toJSON("PlayerObject", this); diff --git a/src/PersonObjects/Sleeve/Sleeve.ts b/src/PersonObjects/Sleeve/Sleeve.ts index 696d0f3bb..13d6d5d2a 100644 --- a/src/PersonObjects/Sleeve/Sleeve.ts +++ b/src/PersonObjects/Sleeve/Sleeve.ts @@ -536,6 +536,11 @@ export class Sleeve extends Person implements SleevePerson { return "sleeves"; } + /** Sleeves are immortal, but we damage them for max hp so they get shocked */ + kill() { + return this.takeDamage(this.hp.max); + } + takeDamage(amt: number): boolean { if (typeof amt !== "number") { console.warn(`Player.takeDamage() called without a numeric argument: ${amt}`); diff --git a/test/jest/Bladeburner/TeamCasualties.test.ts b/test/jest/Bladeburner/TeamCasualties.test.ts new file mode 100644 index 000000000..45c3c17c7 --- /dev/null +++ b/test/jest/Bladeburner/TeamCasualties.test.ts @@ -0,0 +1,171 @@ +import { Player, setPlayer } from "@player"; +import { FormatsNeedToChange } from "../../../src/ui/formatNumber"; +import type { ActionIdFor } from "../../../src/Bladeburner/Types"; +import type { Bladeburner } from "../../../src/Bladeburner/Bladeburner"; +import { BlackOperation, Contract, Operation } from "../../../src/Bladeburner/Actions"; +import { Sleeve } from "../../../src/PersonObjects/Sleeve/Sleeve"; +import { SleeveSupportWork } from "../../../src/PersonObjects/Sleeve/Work/SleeveSupportWork"; +import { BladeburnerBlackOpName, BladeburnerContractName, BladeburnerOperationName } from "@enums"; +import { PlayerObject } from "../../../src/PersonObjects/Player/PlayerObject"; + +/** + * You may want to use hook to help with debugging + * + * afterEach(() => { + * console.error(inst.consoleLogs); + * }); + * + */ +describe("Bladeburner Team", () => { + const MAX_ROLL = (_: number, high: number) => high; + const MIN_ROLL = (low: number, _: number) => low; + const BLACK_OP = BlackOperation.createId(BladeburnerBlackOpName.OperationAnnihilus); + const OP = Operation.createId(BladeburnerOperationName.Assassination); + + let inst: Bladeburner; + let action: BlackOperation | Operation; + + beforeAll(() => { + /* Initialise Formatters. Dependency of Bladeburner */ + FormatsNeedToChange.emit(); + }); + + beforeEach(() => { + setPlayer(new PlayerObject()); + Player.init(); + Player.startBladeburner(); + + if (!Player.bladeburner) throw new Error(); + inst = Player.bladeburner; + + Player.sourceFiles.set(10, 3); + Player.sleevesFromCovenant = 5; + Sleeve.recalculateNumOwned(); + Player.sleeves.forEach((s) => (s.shock = 0)); + }); + + describe("Operations", () => { + it("hav a chance of zero deaths for Operations", () => { + teamSize(10), startAction(OP), teamUsed(10), forceMinCasualties(); + actionFails(); + expect(inst.teamSize).toBe(10); + }); + }); + + describe("Black Operations", () => { + it("always have at least 1 death", () => { + teamSize(10), startAction(BLACK_OP), teamUsed(10), forceMinCasualties(); + actionFails(); + expect(inst.teamSize).toBe(9); + }); + }); + + describe("Solo: with no members or sleeves", () => { + it.each([ + ["success", actionSucceeds], + ["fail", actionFails], + ])("remains unchanged at all rates: %s", (_: string, attempt: CallableFunction) => { + teamSize(1000), startAction(OP), teamUsed(0); + attempt(); + expect(inst.teamSize).toBe(1000); + }); + }); + + describe("Human members", () => { + it("get killed according to roll", () => { + teamSize(15), startAction(OP), teamUsed(15), forceMaxCasualties(), actionSucceeds(); + expect(inst).toMatchObject({ teamSize: 7, teamLost: 8 }); + }); + }); + + describe("Assigned team members", () => { + it("get killed with human casualties before sleeves", () => { + /** At most 10 + 8 -> 9 casualties occur at worst, + * killing human team members before sleeves */ + teamSize(10), startAction(BLACK_OP), supportingSleeves(8), teamUsed(18); + actionSucceeds(); + expect(inst.teamSize).toBeLessThanOrEqual(18); + assertNoShockIncrease(); + }); + + it("shocks sleeves when deaths exceed humans", () => { + teamSize(0), startAction(OP), supportingSleeves(8), forceMaxCasualties(), teamUsed(8); + actionFails(); + assertSleevesHaveBeenShocked(); + }); + }); + + describe("Casualties", () => { + it("do not affect contracts", () => { + teamSize(3); + inst.action = Contract.createId(BladeburnerContractName.Tracking); + actionFails(); + expect(inst.teamSize).toBe(3); + }); + + it.each([[OP], [BLACK_OP]])( + "will occur on actions that support teams: %s", + (op: ActionIdFor | ActionIdFor) => { + teamSize(5), startAction(op), forceMaxCasualties(), teamUsed(5), actionFails(); + expect(inst.teamSize).toBe(0); + }, + ); + + it("are potentially entire team when failing", () => { + teamSize(5), startAction(OP), forceMaxCasualties(), teamUsed(5), actionFails(); + expect(inst).toMatchObject({ teamSize: 0, teamLost: 5 }); + }); + + it("at worst half the team when succeeding (rounding up)", () => { + teamSize(5), startAction(OP), forceMaxCasualties(), teamUsed(5), actionSucceeds(); + expect(inst).toMatchObject({ teamSize: 2, teamLost: 3 }); + }); + }); + + function teamSize(n: number) { + inst.teamSize = n; + } + + function teamUsed(n: number) { + action.teamCount = n; + } + + function startAction(type: ActionIdFor | ActionIdFor) { + inst.action = type; + action = inst.getActionObject(type) as BlackOperation | Operation; + } + + function forceMaxCasualties() { + inst.getTeamCasualtiesRoll = MAX_ROLL; + } + + function forceMinCasualties() { + inst.getTeamCasualtiesRoll = MIN_ROLL; + } + + function actionSucceeds() { + action.baseDifficulty = 0; + Player.skills.strength = 1e12; + Player.skills.agility = 1e12; + inst.action && inst.completeAction(Player, inst.action); + } + + function actionFails() { + action.baseDifficulty = 1e15; + inst.action && inst.completeAction(Player, inst.action); + } + + function supportingSleeves(n: number) { + for (let i = 0; i < n; i++) Player.sleeves[i].startWork(new SleeveSupportWork()); + } + + function assertNoShockIncrease() { + const shockIncrease = Player.sleeves.reduce((sum, s) => sum + s.shock, 0); + expect(shockIncrease).toBe(0); + } + + function assertSleevesHaveBeenShocked() { + const shockIncrease = Player.sleeves.reduce((sum, s) => sum + s.shock, 0); + expect(shockIncrease).toBeGreaterThan(0); + } +});