From eb431145eebb1bda1d06e3346a1a92a5f92aacbc Mon Sep 17 00:00:00 2001 From: catloversg <152669316+catloversg@users.noreply.github.com> Date: Thu, 7 May 2026 05:22:43 +0700 Subject: [PATCH] UI: Show hints of Gang mechanic in pre-endgame (#2723) --- src/Faction/FactionInfo.tsx | 6 ++ src/Faction/ui/CreateGangModal.tsx | 7 ++ src/Faction/ui/FactionRoot.tsx | 2 - src/Faction/ui/GangButton.tsx | 80 --------------------- src/Faction/ui/GangCampaign.tsx | 110 +++++++++++++++++++++++++++++ src/Gang/data/Constants.ts | 2 +- src/Gang/helpers.ts | 26 +++++++ src/NetscriptFunctions/Gang.ts | 19 +---- test/jest/Netscript/Gang.test.ts | 70 ++++++++++++++++-- 9 files changed, 216 insertions(+), 106 deletions(-) delete mode 100644 src/Faction/ui/GangButton.tsx create mode 100644 src/Faction/ui/GangCampaign.tsx create mode 100644 src/Gang/helpers.ts diff --git a/src/Faction/FactionInfo.tsx b/src/Faction/FactionInfo.tsx index 75372fb5b..d45ed3ab2 100644 --- a/src/Faction/FactionInfo.tsx +++ b/src/Faction/FactionInfo.tsx @@ -40,6 +40,8 @@ import { CONSTANTS } from "../Constants"; import { BladeburnerConstants } from "../Bladeburner/data/Constants"; import type { PlayerObject } from "../PersonObjects/Player/PlayerObject"; import { CovenantCampaign } from "./ui/CovenantCampaign"; +import { GangCampaign } from "./ui/GangCampaign"; +import { GangConstants } from "../Gang/data/Constants"; interface FactionInfoParams { infoText?: JSX.Element; @@ -812,3 +814,7 @@ export const FactionInfos: Record = { }, }), }; + +for (const factionName of GangConstants.Names) { + FactionInfos[factionName].campaign = () => ; +} diff --git a/src/Faction/ui/CreateGangModal.tsx b/src/Faction/ui/CreateGangModal.tsx index 36c1d3ed7..0d87aa5fd 100644 --- a/src/Faction/ui/CreateGangModal.tsx +++ b/src/Faction/ui/CreateGangModal.tsx @@ -7,6 +7,8 @@ import Typography from "@mui/material/Typography"; import Button from "@mui/material/Button"; import { KEY } from "../../utils/KeyboardEventKey"; import { FactionName } from "@enums"; +import { canCreateGang } from "../../Gang/helpers"; +import { dialogBoxCreate } from "../../ui/React/DialogBox"; interface IProps { open: boolean; @@ -33,6 +35,11 @@ export function CreateGangModal(props: IProps): React.ReactElement { } function createGang(): void { + const checkResult = canCreateGang(props.facName); + if (!checkResult.success) { + dialogBoxCreate(checkResult.message); + return; + } Player.startGang(props.facName, isHacking()); props.onClose(); Router.toPage(Page.Gang); diff --git a/src/Faction/ui/FactionRoot.tsx b/src/Faction/ui/FactionRoot.tsx index bcb2a607a..56b6827f1 100644 --- a/src/Faction/ui/FactionRoot.tsx +++ b/src/Faction/ui/FactionRoot.tsx @@ -17,7 +17,6 @@ import { Player } from "@player"; import { Typography, Button } from "@mui/material"; import { FactionWorkType } from "@enums"; -import { GangButton } from "./GangButton"; import { FactionWork } from "../../Work/FactionWork"; import { useCycleRerender } from "../../ui/React/hooks"; import { favorNeededToDonate } from "../formulas/donation"; @@ -111,7 +110,6 @@ function MainPage({ faction, rerender, onAugmentations }: IMainProps): React.Rea {faction.name} - {!isPlayersGang && ( <> {factionInfo.offersWork() && ( diff --git a/src/Faction/ui/GangButton.tsx b/src/Faction/ui/GangButton.tsx deleted file mode 100644 index 0f08a5df8..000000000 --- a/src/Faction/ui/GangButton.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { Button, Typography, Box, Paper, Tooltip } from "@mui/material"; -import React, { useState } from "react"; -import { GangConstants } from "../../Gang/data/Constants"; -import { Router } from "../../ui/GameRoot"; -import { Page } from "../../ui/Router"; -import { Player } from "@player"; -import { Faction } from "../Faction"; -import { CreateGangModal } from "./CreateGangModal"; - -interface IProps { - faction: Faction; -} - -export function GangButton({ faction }: IProps): React.ReactElement { - const [gangOpen, setGangOpen] = useState(false); - - if ( - !GangConstants.Names.includes(faction.name) || // not even a gang - !Player.isAwareOfGang() || // doesn't know about gang - (Player.gang && Player.getGangName() !== faction.name) // already in another gang - ) { - return <>; - } - - let data = { - enabled: false, - title: "", - tooltip: "" as string | React.ReactElement, - description: "", - }; - - if (Player.gang) { - data = { - enabled: true, - title: "Manage Gang", - tooltip: "", - description: "Manage a gang for this Faction. Gangs will earn you money and faction reputation", - }; - } else { - const checkResult = Player.canAccessGang(); - data = { - enabled: checkResult.success, - title: "Create Gang", - tooltip: !checkResult.success ? ( - Unlocked when reaching {GangConstants.GangKarmaRequirement} karma - ) : ( - "" - ), - description: "Create a gang for this Faction. Gangs will earn you money and faction reputation", - }; - } - - const manageGang = (): void => { - // If player already has a gang, just go to the gang UI - if (Player.inGang()) { - return Router.toPage(Page.Gang); - } - - setGangOpen(true); - }; - - return ( - <> - - - - - - - - {data.description} - - - - setGangOpen(false)} /> - - ); -} diff --git a/src/Faction/ui/GangCampaign.tsx b/src/Faction/ui/GangCampaign.tsx new file mode 100644 index 000000000..236fcd3c3 --- /dev/null +++ b/src/Faction/ui/GangCampaign.tsx @@ -0,0 +1,110 @@ +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Paper from "@mui/material/Paper"; +import Tooltip from "@mui/material/Tooltip"; +import Typography from "@mui/material/Typography"; +import { Player } from "@player"; +import React, { useState } from "react"; +import { knowAboutBitverse } from "../../BitNode/BitNodeUtils"; +import { GangConstants } from "../../Gang/data/Constants"; +import { Router } from "../../ui/GameRoot"; +import { Modal } from "../../ui/React/Modal"; +import { Page } from "../../ui/Router"; +import { FactionName } from "../Enums"; +import { CreateGangModal } from "./CreateGangModal"; +import { Option } from "./Option"; + +function GangIncompleteCampaign() { + const [open, setOpen] = useState(false); + return ( + <> + + setOpen(false)}> + + Each time you attempt to execute the plan, it is abruptly interrupted for reasons no one can explain. You + receive the same distorted message every time: +
+
+ #@)($*&@__Y0U__^%$#@&*()__HAV3__(&@#*$%(@ +
+ ()@#*$%(__N0T__@&$#)@*(__S33N__)(*@#&$)( +
+ @&*($#@&__TH3__#@A&#@*)(@$#@)* +
+ %$#@&()@__TRU1H__()*@#$&()@#$ +
+
+ + ); +} + +export function GangCampaign({ factionName }: { factionName: FactionName }) { + const [gangOpen, setGangOpen] = useState(false); + + if (!GangConstants.Names.includes(factionName)) { + throw new Error(`Cannot create gang with ${factionName}`); + } + if (!knowAboutBitverse()) { + return ; + } + + const data = { + enabled: false, + title: "", + tooltip: "" as string | React.ReactElement, + description: "", + }; + + if (Player.gang) { + if (Player.getGangName() !== factionName) { + data.enabled = false; + data.title = "Create Gang"; + data.tooltip = "You already created a gang with another faction"; + } else { + data.enabled = true; + data.title = "Manage Gang"; + data.description = "Manage a gang for this Faction. Gangs will earn you money and faction reputation"; + } + } else { + const checkResult = Player.canAccessGang(); + data.enabled = checkResult.success; + data.title = "Create Gang"; + data.tooltip = !checkResult.success ? checkResult.message : ""; + data.description = "Create a gang for this Faction. Gangs will earn you money and faction reputation"; + } + + const manageGang = (): void => { + // If player already has a gang, just go to the gang UI + if (Player.inGang()) { + return Router.toPage(Page.Gang); + } + + setGangOpen(true); + }; + + return ( + <> + + + + + + + + {data.description} + + + + setGangOpen(false)} /> + + ); +} diff --git a/src/Gang/data/Constants.ts b/src/Gang/data/Constants.ts index 6c9e7e9cc..3e97db7c8 100644 --- a/src/Gang/data/Constants.ts +++ b/src/Gang/data/Constants.ts @@ -23,7 +23,7 @@ export const GangConstants = { FactionName.SpeakersForTheDead, FactionName.NiteSec, FactionName.TheBlackHand, - ] as string[], + ] as FactionName[], GangKarmaRequirement: -54000, /** Normal number of game cycles processed at once (2 seconds) */ minCyclesToProcess: 2000 / CONSTANTS.MilliPerCycle, diff --git a/src/Gang/helpers.ts b/src/Gang/helpers.ts new file mode 100644 index 000000000..769c5e73b --- /dev/null +++ b/src/Gang/helpers.ts @@ -0,0 +1,26 @@ +import { Result } from "@nsdefs"; +import { Player } from "@player"; +import { FactionName } from "../Enums"; +import { GangConstants } from "./data/Constants"; + +export function canCreateGang(faction: FactionName): Result { + if (Player.gang) { + return { success: false, message: "You already have a gang." }; + } + const checkResult = Player.canAccessGang(); + if (!checkResult.success) { + return { success: false, message: checkResult.message }; + } + if (!GangConstants.Names.includes(faction)) { + return { + success: false, + message: `${faction} does not allow creating a gang. You can only do that with ${GangConstants.Names.join( + ", ", + )}.`, + }; + } + if (!Player.factions.includes(faction)) { + return { success: false, message: `You are not a member of ${faction}.` }; + } + return { success: true }; +} diff --git a/src/NetscriptFunctions/Gang.ts b/src/NetscriptFunctions/Gang.ts index 935a498eb..79c3bba0c 100644 --- a/src/NetscriptFunctions/Gang.ts +++ b/src/NetscriptFunctions/Gang.ts @@ -7,13 +7,13 @@ import { type InternalAPI, type NetscriptContext, setRemovedFunctions } from ".. import { GangPromise, RecruitmentResult } from "../Gang/Gang"; import { Player } from "@player"; import { FactionName } from "@enums"; -import { GangConstants } from "../Gang/data/Constants"; import { AllGangs } from "../Gang/AllGangs"; import { GangMemberTasks } from "../Gang/GangMemberTasks"; import { GangMemberUpgrades } from "../Gang/GangMemberUpgrades"; import { helpers } from "../Netscript/NetscriptHelpers"; import { getEnumHelper } from "../utils/EnumHelper"; import { CONSTANTS } from "../Constants"; +import { canCreateGang } from "../Gang/helpers"; export function NetscriptGang(): InternalAPI { /** Functions as an API check and also returns the gang object */ @@ -40,26 +40,11 @@ export function NetscriptGang(): InternalAPI { const gangFunctions: InternalAPI = { createGang: (ctx) => (_faction) => { const faction = getEnumHelper("FactionName").nsGetMember(ctx, _faction); - if (Player.gang) { - return false; - } - const checkResult = Player.canAccessGang(); + const checkResult = canCreateGang(faction); if (!checkResult.success) { helpers.log(ctx, () => checkResult.message); return false; } - if (!GangConstants.Names.includes(faction)) { - helpers.log( - ctx, - () => - `${faction} does not allow creating a gang. You can only do that with ${GangConstants.Names.join(", ")}.`, - ); - return false; - } - if (!Player.factions.includes(faction)) { - helpers.log(ctx, () => `You are not a member of ${faction}.`); - return false; - } const isHacking = faction === FactionName.NiteSec || faction === FactionName.TheBlackHand; Player.startGang(faction, isHacking); diff --git a/test/jest/Netscript/Gang.test.ts b/test/jest/Netscript/Gang.test.ts index 667bdf362..e2467295f 100644 --- a/test/jest/Netscript/Gang.test.ts +++ b/test/jest/Netscript/Gang.test.ts @@ -1,8 +1,10 @@ import { FactionName } from "@enums"; import { Player } from "@player"; -import { Gang } from "../../../src/Gang/Gang"; import { AllGangs } from "../../../src/Gang/AllGangs"; -import { getNS, initGameEnvironment, setupBasicTestingEnvironment } from "../Utilities"; +import { getNS, getWorkerScriptAndNS, initGameEnvironment, setupBasicTestingEnvironment } from "../Utilities"; +import { GangConstants } from "../../../src/Gang/data/Constants"; +import { joinFaction } from "../../../src/Faction/FactionHelpers"; +import { Factions } from "../../../src/Faction/Factions"; beforeAll(() => { initGameEnvironment(); @@ -10,12 +12,16 @@ beforeAll(() => { beforeEach(() => { setupBasicTestingEnvironment(); - // Give the player a gang so gang API is accessible - Player.gang = new Gang(FactionName.SlumSnakes, false); + Player.sourceFiles.set(2, 3); }); describe("ns.gang.getAllGangInformation", () => { - it("should return territory and power info for all gangs including the player's", () => { + beforeEach(() => { + // Give the player a gang so gang API is accessible + Player.startGang(FactionName.SlumSnakes, false); + }); + + test("should return territory and power info for all gangs including the player's", () => { const ns = getNS(); const info = ns.gang.getAllGangInformation(); const gangNames = Object.keys(info); @@ -35,7 +41,7 @@ describe("ns.gang.getAllGangInformation", () => { } }); - it("should return copies, not references to the original AllGangs data", () => { + test("should return copies, not references to the original AllGangs data", () => { const ns = getNS(); const info = ns.gang.getAllGangInformation(); @@ -44,3 +50,55 @@ describe("ns.gang.getAllGangInformation", () => { expect(AllGangs[FactionName.SlumSnakes].power).not.toBe(999999); }); }); + +describe("createGang", () => { + test("Success", () => { + const ns = getNS(); + Player.karma = GangConstants.GangKarmaRequirement; + joinFaction(Factions[FactionName.SlumSnakes]); + expect(ns.gang.createGang(FactionName.SlumSnakes)).toBe(true); + }); + describe("Failure", () => { + test("Already have a gang", () => { + const { ws, ns } = getWorkerScriptAndNS(); + Player.karma = GangConstants.GangKarmaRequirement; + joinFaction(Factions[FactionName.SlumSnakes]); + expect(ns.gang.createGang(FactionName.SlumSnakes)).toBe(true); + + expect(ns.gang.createGang(FactionName.SlumSnakes)).toBe(false); + expect(ws.scriptRef.logs[0]).toMatch("You already have a gang"); + expect(ns.gang.createGang(FactionName.Tetrads)).toBe(false); + expect(ws.scriptRef.logs[1]).toMatch("You already have a gang"); + }); + test("Disabled by advanced options", () => { + const { ws, ns } = getWorkerScriptAndNS(); + Player.bitNodeOptions.disableGang = true; + expect(ns.gang.createGang(FactionName.SlumSnakes)).toBe(false); + expect(ws.scriptRef.logs[0]).toMatch("Gang is disabled by advanced options"); + }); + test("Not have Source-File 2", () => { + const { ws, ns } = getWorkerScriptAndNS(); + Player.sourceFiles.set(2, 0); + expect(ns.gang.createGang(FactionName.SlumSnakes)).toBe(false); + expect(ws.scriptRef.logs[0]).toMatch("You do not have Source-File 2"); + }); + test("Not enough karma", () => { + const { ws, ns } = getWorkerScriptAndNS(); + expect(ns.gang.createGang(FactionName.SlumSnakes)).toBe(false); + expect(ws.scriptRef.logs[0]).toMatch("Your karma must be less than or equal to"); + }); + test("Invalid gang faction", () => { + const { ws, ns } = getWorkerScriptAndNS(); + Player.karma = GangConstants.GangKarmaRequirement; + joinFaction(Factions[FactionName.SlumSnakes]); + expect(ns.gang.createGang(FactionName.Illuminati)).toBe(false); + expect(ws.scriptRef.logs[0]).toMatch("does not allow creating a gang"); + }); + test("Not a faction member", () => { + const { ws, ns } = getWorkerScriptAndNS(); + Player.karma = GangConstants.GangKarmaRequirement; + expect(ns.gang.createGang(FactionName.SlumSnakes)).toBe(false); + expect(ws.scriptRef.logs[0]).toMatch("You are not a member of"); + }); + }); +});