BLADEBURNER: Move bladeburner team losses to Casualties (#1654)

This commit is contained in:
Denis Čahuk 2024-09-24 02:03:01 +02:00 committed by GitHub
parent cf2d9b8335
commit b86044bcc1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 265 additions and 52 deletions

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

@ -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,27 +1032,11 @@ 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) {
if (this.logging.blackops && deaths > 0) {
this.log(
`${person.whoAmI()}: You lost ${formatNumberNoSuffix(losses, 0)} team members during ${action.name}.`,
`${person.whoAmI()}: You lost ${formatNumberNoSuffix(deaths, 0)} team members during ${action.name}.`,
);
}
}
break;
}
case BladeburnerActionType.General:

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

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