Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BLADEBURNER: Move bladeburner team losses to Casualties #1654

Merged
merged 14 commits into from
Sep 24, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion src/Bladeburner/Actions/BlackOperation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@ import { BladeburnerActionType, BladeburnerBlackOpName } from "@enums";
import { ActionClass, ActionParams } from "./Action";
import { operationSkillSuccessBonus, operationTeamSuccessBonus } from "./Operation";
import { getEnumHelper } from "../../utils/EnumHelper";
import { getRandomIntInclusive } from "../../utils/helpers/getRandomIntInclusive";
import { resolveTeamCasualties, type TeamActionWithCasualties } from "./TeamCasualties";

interface BlackOpParams {
name: BladeburnerBlackOpName;
reqdRank: number;
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;
Expand Down Expand Up @@ -57,7 +59,15 @@ export class BlackOperation extends ActionClass {
return 1;
}

getMinimumCasualties(): number {
return 1;
}

getTeamSuccessBonus = operationTeamSuccessBonus;

getActionTypeSkillSuccessBonus = operationSkillSuccessBonus;

getTeamCasualtiesRoll = getRandomIntInclusive;

resolveTeamCasualties = resolveTeamCasualties;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is more interface complexity that exists only for testing.

getTeamCasualtiesRoll obfuscates the intent a fair amount (it gets plumbed through a class and an interface to ultimately be a simple call to getRandomIntInclusive. However, this is balanced by the fact that it would be a bit trickier to mock around without it.

AFAICT, resolveTeamCasualties isn't even used as a test injection point, and is thus completely unneeded. If you got rid of both of these variables, you could also completely remove the TeamActionWithCasualties interface and resolveTeamCasualties could be a simple function that takes teamCount as a number instead of semi-awkwardly through this. It would also be just as straightforward to mock, if you ever needed/wanted to in the future.

EDIT: Just saw your change to fix the 0/1 issue. I guess the interface has a stay on execution. XD You could pass that in as another free parameter to the function, but that's getting more ugly... it tips the balance back towards it being worth it to have an interface. I still don't think you need the instance variable for the function here, though. It can just be a free function that takes a TeamActionWithCasualties as first parameter. (Normally you would just put it on the base class, but that doesn't work here when you don't have a base class for just this pair of classes.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAICT, resolveTeamCasualties isn't even used as a test injection point, and is thus completely unneeded. If you got rid of both of these variables, you could also completely remove the TeamActionWithCasualties interface and resolveTeamCasualties could be a simple function that takes teamCount as a number instead of semi-awkwardly through this. It would also be just as straightforward to mock, if you ever needed/wanted to in the future.

It won't work. It needs teamCount, sleeveSize and teamSize (not to be confused with teamCount). I tried it and this is the minimal complexity I've been able to scale it down to. It's so easy to confuse teamCount and teamSize and I wanted it to be handled explicitly. A narrower type (the interface) but generously wide instance (Bladeburner, Action) to make it user-friendly on the developer side who's working on CompleteAction.

I tried mocking it. But ended up with a lot more complexity (and automocking on the module level which made the tests much slower).

However, if you insist I could make it a public module export on TeamCasualties and then mock that, but then the tests get exposed to much more "implementation detail" that isn't captured by the interface. It's a hard balance. Anything touching entropy needs to be controlled.

Okay, a bit more context
I need to be able to control the random range to test it properly. If we don't want this interface shenanigans, then I'd rather throw away the tests that are flaky when random. But that means we lose 2/3rds of the tests. The issue isn't Math.random or something similar. The problem is that our numbers cause the rolls to be exhaustive at random. It's exhaustive if casualties are 0% or 100%, and the ranges can randomly range to 50%-100% or 0%-100% depending on what the attempt roll was. This makes it incredibly difficult to test, even with monte-carlo simulations. I either need control over an non-exhaustive range or a forced scope to the roll (MIN,MAX). I opted for the latter, but it requires one injection point in production scope.

Now, I'm aware you may consider injection scope on an interface more invasive than on a package module, but from the perspective of production code, there will be a public function somewhere to roll the range (either exported, or on a class/interface). There is no way avoiding having it be "mentioned" in the final output. If this is a huge blocker, I'd much rather remove the tests, than try to pretend we're not affecting public scope. As I mentioned in the earlier round of reviews, this is one of those problems that cannot be without compromise. That's the nature of random elements.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Edit: As a compromise if you want I can put the roll on Bladeburner instead (on the OperationTeam interface). That way it requires less care on the Action data side.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, as you said it's a compromise but I like this compromise better.

However, I feel like you didn't see the other part of my comment? Specifically, regarding the resolveTeamCasualties function itself, that it doesn't need to be part of the interface but can be a free function that takes an interface object as a param. And that should be just as easy to mock (you can mock out the free function in the tests, if needed.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I misunderstood. I thought you meant the resolve as being part of the interface, not the function declaration being referenced and obsolete after the 0/1 fixed gave legit purpose for the namespace. Let me re-read.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did comment on it, quoted below your original. You mean to have it be a global function? Doesn't that make the invocations more verbose?

Copy link
Contributor Author

@Alpheus Alpheus Sep 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, you mean it can stay on the class, but not be part of the interface? Yeah it seems we lost that structural need somewhere during refactoring. I'll try it, see how it looks.

Edit: Yeah, that's completely benign. Done!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still see you referring to an object with an interface but I'm not quite sure what that's highlighting. Could you give me a quick example or code pin?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I got what you meant, let me know!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, that was what I was talking about!

}
14 changes: 12 additions & 2 deletions src/Bladeburner/Actions/Operation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,19 @@ 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 { resolveTeamCasualties, type TeamActionWithCasualties } from "./TeamCasualties";
import { getRandomIntInclusive } from "../../utils/helpers/getRandomIntInclusive";

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;
Expand Down Expand Up @@ -44,6 +46,14 @@ export class Operation extends LevelableActionClass {

getActionTypeSkillSuccessBonus = operationSkillSuccessBonus;

getTeamCasualtiesRoll = getRandomIntInclusive;

resolveTeamCasualties = resolveTeamCasualties;

getMinimumCasualties(): number {
return 0;
}

getChaosSuccessFactor(inst: Bladeburner /*, params: ISuccessChanceParams*/): number {
const city = inst.getCurrentCity();
if (city.chaos > BladeburnerConstants.ChaosThreshold) {
Expand Down
51 changes: 51 additions & 0 deletions src/Bladeburner/Actions/TeamCasualties.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
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;

killRandomSupportingSleeves(sleeveDeaths: number): void;
}

export interface TeamActionWithCasualties {
teamCount: number;

getTeamCasualtiesRoll(low: number, high: number): number;

getMinimumCasualties(): number;

resolveTeamCasualties: typeof resolveTeamCasualties;
}

/**
* 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(this: TeamActionWithCasualties, team: OperationTeam, success: boolean) {
const severity = success ? CasualtyFactor.LOW_CASUALTIES : CasualtyFactor.HIGH_CASUALTIES;
const radius = this.teamCount * severity;
const worstCase = severity < 1 ? Math.ceil(radius) : Math.floor(radius);
/** Best case is always no deaths */
const deaths = this.getTeamCasualtiesRoll(this.getMinimumCasualties(), worstCase);
const humans = this.teamCount - team.sleeveSize;
const humanDeaths = Math.min(humans, deaths);
const damagedSleeves = deaths - humanDeaths;

/** 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(damagedSleeves);

/** 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, team, damagedSleeves };
d0sboots marked this conversation as resolved.
Show resolved Hide resolved
}
69 changes: 21 additions & 48 deletions src/Bladeburner/Bladeburner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 type { OperationTeam } from "./Actions/TeamCasualties";
import { shuffleArray } from "../Infiltration/ui/BribeGame";
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This probably should go into its own helper, but that can also wait for later if you want.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. I'd prefer to not touch non-BB refactors at the moment. Next pass?


export const BladeburnerPromise: PromisePair<number> = { promise: null, resolve: null };

export class Bladeburner {
export class Bladeburner implements OperationTeam {
numHosp = 0;
moneyLost = 0;
rank = 0;
Expand Down Expand Up @@ -750,32 +751,20 @@ export class Bladeburner {
}
}

public killRandomSupportingSleeves(n: number) {
Alpheus marked this conversation as resolved.
Show resolved Hide resolved
d0sboots marked this conversation as resolved.
Show resolved Hide resolved
const sup = [...Player.sleevesSupportingBladeburner()]; // Explicit shallow copy
shuffleArray(sup);
sup.slice(0, Math.min(sup.length, n)).forEach((sleeve) => sleeve.kill());
Alpheus marked this conversation as resolved.
Show resolved Hide resolved
}

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 } = action.resolveTeamCasualties(this, success);
if (this.logging.ops && deaths > 0) {
this.log("Lost " + formatNumberNoSuffix(deaths, 0) + " team members during this " + action.name);
}

const city = this.getCurrentCity();
Expand Down Expand Up @@ -992,9 +981,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);
Expand All @@ -1004,7 +991,8 @@ export class Bladeburner {
rankGain = addOffset(action.rankGain * currentNodeMults.BladeburnerRank, 10);
this.changeRank(person, rankGain);
}
teamLossMax = Math.ceil(teamCount / 2);

deaths = action.resolveTeamCasualties(this, true).deaths;

if (this.logging.blackops) {
this.log(
Expand All @@ -1028,7 +1016,8 @@ export class Bladeburner {
this.moneyLost += cost;
}
}
teamLossMax = Math.floor(teamCount);

deaths = action.resolveTeamCasualties(this, false).deaths;

if (this.logging.blackops) {
this.log(
Expand All @@ -1042,26 +1031,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}.`,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I always wondered... is this double-space intentional?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd assume it is, although if it bugs you I wouldn't mind if you changed it.

Honestly, I'd be OK with completely unifying the logging between the two versions and ironing out the minor differences in wording, to help factor out more stuff. But the 0 vs 1 discrepancy that Marvin brought up throws a big wrench into things. (OK, I guess it wasn't that big.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By the looks of it we may have a second pass to go over things after this is merged. I'd rather get this one into dev and then do another smaller pass. The PR is getting too stale.

);
}
break;
}
Expand Down
3 changes: 2 additions & 1 deletion src/Infiltration/ui/BribeGame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { KeyHandler } from "./KeyHandler";

interface Difficulty {
[key: string]: number;

timer: number;
size: number;
}
Expand Down Expand Up @@ -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];
Expand Down
5 changes: 5 additions & 0 deletions src/PersonObjects/Player/PlayerObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions src/PersonObjects/Sleeve/Sleeve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down
Loading