mirror of
https://github.com/bitburner-official/bitburner-src.git
synced 2024-12-24 07:02:26 +01:00
BLADEBURNER: Move bladeburner team losses to Casualties (#1654)
This commit is contained in:
parent
cf2d9b8335
commit
b86044bcc1
@ -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;
|
||||
|
@ -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) {
|
||||
|
47
src/Bladeburner/Actions/TeamCasualties.ts
Normal file
47
src/Bladeburner/Actions/TeamCasualties.ts
Normal file
@ -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;
|
||||
}
|
@ -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<number> = { 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;
|
||||
}
|
||||
|
@ -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];
|
||||
|
@ -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);
|
||||
|
@ -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}`);
|
||||
|
171
test/jest/Bladeburner/TeamCasualties.test.ts
Normal file
171
test/jest/Bladeburner/TeamCasualties.test.ts
Normal file
@ -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
|
||||
* <code>
|
||||
* afterEach(() => {
|
||||
* console.error(inst.consoleLogs);
|
||||
* });
|
||||
* </code>
|
||||
*/
|
||||
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<BlackOperation> | ActionIdFor<Operation>) => {
|
||||
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<BlackOperation> | ActionIdFor<Operation>) {
|
||||
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);
|
||||
}
|
||||
});
|
Loading…
Reference in New Issue
Block a user