From 3ae3f947ac0f74f79db83aac8583ad34f43d3804 Mon Sep 17 00:00:00 2001 From: Jesse Clark Date: Tue, 19 Sep 2023 21:36:48 -0700 Subject: [PATCH] CORPORATION: Rework share price calculation + UI improvements (#782) --- .../bitburner.corporation.buybackshares.md | 2 +- markdown/bitburner.corporation.md | 4 +- markdown/bitburner.corporation.sellshares.md | 2 +- ...itburner.corporationinfo.investorshares.md | 13 ++ .../bitburner.corporationinfo.issuedshares.md | 2 +- markdown/bitburner.corporationinfo.md | 7 +- .../bitburner.corporationinfo.numshares.md | 2 +- .../bitburner.corporationinfo.totalshares.md | 2 +- src/Corporation/Actions.ts | 96 +++++++---- src/Corporation/Corporation.ts | 82 ++++++--- src/Corporation/data/Constants.ts | 6 +- src/Corporation/helpers.ts | 44 ++++- src/Corporation/ui/MaterialElem.tsx | 6 +- src/Corporation/ui/Overview.tsx | 60 ++++--- src/Corporation/ui/ProductElem.tsx | 8 +- .../ui/modals/BuybackSharesModal.tsx | 102 +++++------ .../ui/modals/CreateCorporationModal.tsx | 17 +- .../ui/modals/FindInvestorsModal.tsx | 62 ++++--- src/Corporation/ui/modals/GoPublicModal.tsx | 79 ++++++--- .../ui/modals/IssueDividendsModal.tsx | 19 ++- .../ui/modals/IssueNewSharesModal.tsx | 158 ++++++++++-------- .../ui/modals/PurchaseMaterialModal.tsx | 5 +- .../ui/modals/SellCorporationModal.tsx | 1 + .../ui/modals/SellDivisionModal.tsx | 15 +- src/Corporation/ui/modals/SellSharesModal.tsx | 102 ++++++----- .../ui/modals/SmartSupplyModal.tsx | 2 +- src/Corporation/ui/modals/ThrowPartyModal.tsx | 4 +- src/DevMenu/ui/CorporationDev.tsx | 11 ++ src/Faction/ui/FactionsRoot.tsx | 2 +- src/NetscriptFunctions/Corporation.ts | 81 ++------- .../Player/PlayerObjectCorporationMethods.ts | 5 +- src/ScriptEditor/NetscriptDefinitions.d.ts | 14 +- test/jest/Corporation.test.ts | 55 ++++++ test/jest/__snapshots__/FullSave.test.ts.snap | 1 + 34 files changed, 654 insertions(+), 417 deletions(-) create mode 100644 markdown/bitburner.corporationinfo.investorshares.md diff --git a/markdown/bitburner.corporation.buybackshares.md b/markdown/bitburner.corporation.buybackshares.md index 0e16a29ca..6faa0f583 100644 --- a/markdown/bitburner.corporation.buybackshares.md +++ b/markdown/bitburner.corporation.buybackshares.md @@ -4,7 +4,7 @@ ## Corporation.buyBackShares() method -Buyback Shares +Buyback Shares. Spend money from the player's wallet to transfer shares from public traders to the CEO. **Signature:** diff --git a/markdown/bitburner.corporation.md b/markdown/bitburner.corporation.md index 42afe0a27..36c5d6a25 100644 --- a/markdown/bitburner.corporation.md +++ b/markdown/bitburner.corporation.md @@ -19,7 +19,7 @@ export interface Corporation extends WarehouseAPI, OfficeAPI | --- | --- | | [acceptInvestmentOffer()](./bitburner.corporation.acceptinvestmentoffer.md) | Accept investment based on you companies current valuation | | [bribe(factionName, amountCash)](./bitburner.corporation.bribe.md) | Bribe a faction | -| [buyBackShares(amount)](./bitburner.corporation.buybackshares.md) | Buyback Shares | +| [buyBackShares(amount)](./bitburner.corporation.buybackshares.md) | Buyback Shares. Spend money from the player's wallet to transfer shares from public traders to the CEO. | | [createCorporation(corporationName, selfFund)](./bitburner.corporation.createcorporation.md) | Create a Corporation | | [expandCity(divisionName, city)](./bitburner.corporation.expandcity.md) | Expand to a new city | | [expandIndustry(industryType, divisionName)](./bitburner.corporation.expandindustry.md) | Expand to a new industry | @@ -40,5 +40,5 @@ export interface Corporation extends WarehouseAPI, OfficeAPI | [issueNewShares(amount)](./bitburner.corporation.issuenewshares.md) | Issue new shares | | [levelUpgrade(upgradeName)](./bitburner.corporation.levelupgrade.md) | Level an upgrade. | | [purchaseUnlock(upgradeName)](./bitburner.corporation.purchaseunlock.md) | Unlock an upgrade | -| [sellShares(amount)](./bitburner.corporation.sellshares.md) | Sell Shares | +| [sellShares(amount)](./bitburner.corporation.sellshares.md) | Sell Shares. Transfer shares from the CEO to public traders to receive money in the player's wallet. | diff --git a/markdown/bitburner.corporation.sellshares.md b/markdown/bitburner.corporation.sellshares.md index 803e0931a..ccd2ac609 100644 --- a/markdown/bitburner.corporation.sellshares.md +++ b/markdown/bitburner.corporation.sellshares.md @@ -4,7 +4,7 @@ ## Corporation.sellShares() method -Sell Shares +Sell Shares. Transfer shares from the CEO to public traders to receive money in the player's wallet. **Signature:** diff --git a/markdown/bitburner.corporationinfo.investorshares.md b/markdown/bitburner.corporationinfo.investorshares.md new file mode 100644 index 000000000..e4edf1ee7 --- /dev/null +++ b/markdown/bitburner.corporationinfo.investorshares.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [bitburner](./bitburner.md) > [CorporationInfo](./bitburner.corporationinfo.md) > [investorShares](./bitburner.corporationinfo.investorshares.md) + +## CorporationInfo.investorShares property + +Amount of shares owned by private investors. Not available for public sale or CEO buyback. + +**Signature:** + +```typescript +investorShares: number; +``` diff --git a/markdown/bitburner.corporationinfo.issuedshares.md b/markdown/bitburner.corporationinfo.issuedshares.md index 673228d79..40b5f8f61 100644 --- a/markdown/bitburner.corporationinfo.issuedshares.md +++ b/markdown/bitburner.corporationinfo.issuedshares.md @@ -4,7 +4,7 @@ ## CorporationInfo.issuedShares property -Amount of acquirable shares. +Amount of shares owned by public traders. Available for CEO buyback. **Signature:** diff --git a/markdown/bitburner.corporationinfo.md b/markdown/bitburner.corporationinfo.md index e648131c2..13c50301b 100644 --- a/markdown/bitburner.corporationinfo.md +++ b/markdown/bitburner.corporationinfo.md @@ -22,14 +22,15 @@ interface CorporationInfo | [divisions](./bitburner.corporationinfo.divisions.md) | | string\[\] | Array of all division names | | [expenses](./bitburner.corporationinfo.expenses.md) | | number | Expenses per second this cycle | | [funds](./bitburner.corporationinfo.funds.md) | | number | Funds available | -| [issuedShares](./bitburner.corporationinfo.issuedshares.md) | | number | Amount of acquirable shares. | +| [investorShares](./bitburner.corporationinfo.investorshares.md) | | number | Amount of shares owned by private investors. Not available for public sale or CEO buyback. | +| [issuedShares](./bitburner.corporationinfo.issuedshares.md) | | number | Amount of shares owned by public traders. Available for CEO buyback. | | [issueNewSharesCooldown](./bitburner.corporationinfo.issuenewsharescooldown.md) | | number | Cooldown until new shares can be issued | | [name](./bitburner.corporationinfo.name.md) | | string | Name of the corporation | -| [numShares](./bitburner.corporationinfo.numshares.md) | | number | Amount of share owned | +| [numShares](./bitburner.corporationinfo.numshares.md) | | number | Amount of shares owned by the CEO. | | [public](./bitburner.corporationinfo.public.md) | | boolean | Indicating if the company is public | | [revenue](./bitburner.corporationinfo.revenue.md) | | number | Revenue per second this cycle | | [sharePrice](./bitburner.corporationinfo.shareprice.md) | | number | Price of the shares | | [shareSaleCooldown](./bitburner.corporationinfo.sharesalecooldown.md) | | number | Cooldown until shares can be sold again | | [state](./bitburner.corporationinfo.state.md) | | string |

The next state to be processed.

I.e. when the state is PURCHASE, it means purchasing will occur during the next state transition.

Possible states are START, PURCHASE, PRODUCTION, EXPORT, SALE.

| -| [totalShares](./bitburner.corporationinfo.totalshares.md) | | number | Total number of shares issues by this corporation | +| [totalShares](./bitburner.corporationinfo.totalshares.md) | | number | Total number of shares issued by this corporation. | diff --git a/markdown/bitburner.corporationinfo.numshares.md b/markdown/bitburner.corporationinfo.numshares.md index d85f3fec2..6ac749ed7 100644 --- a/markdown/bitburner.corporationinfo.numshares.md +++ b/markdown/bitburner.corporationinfo.numshares.md @@ -4,7 +4,7 @@ ## CorporationInfo.numShares property -Amount of share owned +Amount of shares owned by the CEO. **Signature:** diff --git a/markdown/bitburner.corporationinfo.totalshares.md b/markdown/bitburner.corporationinfo.totalshares.md index 2a87395d3..009bf0234 100644 --- a/markdown/bitburner.corporationinfo.totalshares.md +++ b/markdown/bitburner.corporationinfo.totalshares.md @@ -4,7 +4,7 @@ ## CorporationInfo.totalShares property -Total number of shares issues by this corporation +Total number of shares issued by this corporation. **Signature:** diff --git a/src/Corporation/Actions.ts b/src/Corporation/Actions.ts index afcb0c888..73bedfd95 100644 --- a/src/Corporation/Actions.ts +++ b/src/Corporation/Actions.ts @@ -1,5 +1,3 @@ -import { isInteger } from "lodash"; - import { Player } from "@player"; import { CorpResearchName, CorpSmartSupplyOption } from "@nsdefs"; @@ -18,6 +16,7 @@ import { isRelevantMaterial } from "./ui/Helpers"; import { CityName } from "@enums"; import { getRandomInt } from "../utils/helpers/getRandomInt"; import { getRecordValues } from "../Types/Record"; +import { sellSharesFailureReason, buybackSharesFailureReason, issueNewSharesFailureReason } from "./helpers"; export function NewDivision(corporation: Corporation, industry: IndustryType, name: string): void { if (corporation.divisions.size >= corporation.maxDivisions) @@ -85,33 +84,72 @@ export function IssueDividends(corporation: Corporation, rate: number): void { corporation.dividendRate = rate; } -export function IssueNewShares(corporation: Corporation, amount: number): [number, number, number] { - const max = corporation.calculateMaxNewShares(); +export function GoPublic(corporation: Corporation, numShares: number): void { + const ceoOwnership = (corporation.numShares - numShares) / corporation.totalShares; + const initialSharePrice = corporation.getTargetSharePrice(ceoOwnership); - // Round to nearest ten-millionth - amount = Math.round(amount / 10e6) * 10e6; - - if (isNaN(amount) || amount < 10e6 || amount > max) { - throw new Error(`Invalid value. Must be an number between 10m and ${max} (20% of total shares)`); + if (isNaN(numShares) || numShares < 0) { + throw new Error("Invalid value for number of issued shares"); } + if (numShares > corporation.numShares) { + throw new Error("You don't have that many shares to issue!"); + } + corporation.public = true; + corporation.sharePrice = initialSharePrice; + corporation.issuedShares += numShares; + corporation.numShares -= numShares; + corporation.addNonIncomeFunds(numShares * initialSharePrice); +} - const newSharePrice = Math.round(corporation.sharePrice * 0.8); +export function IssueNewShares( + corporation: Corporation, + amount: number, +): [profit: number, amount: number, privateShares: number] { + const failureReason = issueNewSharesFailureReason(corporation, amount); + if (failureReason) throw new Error(failureReason); - const profit = amount * newSharePrice; - corporation.issueNewSharesCooldown = corpConstants.issueNewSharesCooldown; + const ceoOwnership = corporation.numShares / (corporation.totalShares + amount); + const newSharePrice = corporation.getTargetSharePrice(ceoOwnership); - const privateOwnedRatio = 1 - (corporation.numShares + corporation.issuedShares) / corporation.totalShares; + const profit = (amount * (corporation.sharePrice + newSharePrice)) / 2; + + const cooldownMultiplier = corporation.totalShares / corpConstants.initialShares; + corporation.issueNewSharesCooldown = corpConstants.issueNewSharesCooldown * cooldownMultiplier; + + const privateOwnedRatio = corporation.investorShares / corporation.totalShares; const maxPrivateShares = Math.round((amount / 2) * privateOwnedRatio); const privateShares = Math.round(getRandomInt(0, maxPrivateShares) / 10e6) * 10e6; corporation.issuedShares += amount - privateShares; + corporation.investorShares += privateShares; corporation.totalShares += amount; corporation.addNonIncomeFunds(profit); - corporation.immediatelyUpdateSharePrice(); + // Set sharePrice directly because all formulas will be based on stale cycleValuation data + corporation.sharePrice = newSharePrice; return [profit, amount, privateShares]; } +export function AcceptInvestmentOffer(corporation: Corporation): void { + if ( + corporation.fundingRound >= corpConstants.fundingRoundShares.length || + corporation.fundingRound >= corpConstants.fundingRoundMultiplier.length || + corporation.public + ) { + throw new Error("No more investment offers are available."); + } + const val = corporation.valuation; + const percShares = corpConstants.fundingRoundShares[corporation.fundingRound]; + const roundMultiplier = corpConstants.fundingRoundMultiplier[corporation.fundingRound]; + const funding = val * percShares * roundMultiplier; + const investShares = Math.floor(corpConstants.initialShares * percShares); + corporation.fundingRound++; + corporation.addNonIncomeFunds(funding); + + corporation.numShares -= investShares; + corporation.investorShares += investShares; +} + export function SellMaterial(material: Material, amount: string, price: string): void { if (price === "") price = "0"; if (amount === "") amount = "0"; @@ -280,17 +318,10 @@ export function BulkPurchase( } export function SellShares(corporation: Corporation, numShares: number): number { - if (isNaN(numShares) || !isInteger(numShares)) throw new Error("Invalid value for number of shares"); - if (numShares <= 0) throw new Error("Invalid value for number of shares"); - if (numShares > corporation.numShares) throw new Error("You don't have that many shares to sell!"); - if (numShares === corporation.numShares) throw new Error("You cant't sell all your shares!"); - if (numShares > 1e14) throw new Error("Invalid value for number of shares"); - if (!corporation.public) throw new Error("You haven't gone public!"); - if (corporation.shareSaleCooldown) throw new Error("Share sale on cooldown!"); - const stockSaleResults = corporation.calculateShareSale(numShares); - const profit = stockSaleResults[0]; - const newSharePrice = stockSaleResults[1]; - const newSharesUntilUpdate = stockSaleResults[2]; + const failureReason = sellSharesFailureReason(corporation, numShares); + if (failureReason) throw new Error(failureReason); + + const [profit, newSharePrice, newSharesUntilUpdate] = corporation.calculateShareSale(numShares); corporation.numShares -= numShares; corporation.issuedShares += numShares; @@ -302,15 +333,16 @@ export function SellShares(corporation: Corporation, numShares: number): number } export function BuyBackShares(corporation: Corporation, numShares: number): boolean { - if (isNaN(numShares) || !isInteger(numShares)) throw new Error("Invalid value for number of shares"); - if (numShares <= 0) throw new Error("Invalid value for number of shares"); - if (numShares > corporation.issuedShares) throw new Error("You don't have that many shares to buy!"); - if (!corporation.public) throw new Error("You haven't gone public!"); - const buybackPrice = corporation.sharePrice * 1.1; - if (Player.money < numShares * buybackPrice) throw new Error("You cant afford that many shares!"); + const failureReason = buybackSharesFailureReason(corporation, numShares); + if (failureReason) throw new Error(failureReason); + + const [cost, newSharePrice, newSharesUntilUpdate] = corporation.calculateShareBuyback(numShares); + corporation.numShares += numShares; corporation.issuedShares -= numShares; - Player.loseMoney(numShares * buybackPrice, "corporation"); + corporation.sharePrice = newSharePrice; + corporation.shareSalesUntilPriceUpdate = newSharesUntilUpdate; + Player.loseMoney(cost, "corporation"); return true; } diff --git a/src/Corporation/Corporation.ts b/src/Corporation/Corporation.ts index 5156f0b10..653c1f21d 100644 --- a/src/Corporation/Corporation.ts +++ b/src/Corporation/Corporation.ts @@ -12,7 +12,7 @@ import { showLiterature } from "../Literature/LiteratureHelpers"; import { dialogBoxCreate } from "../ui/React/DialogBox"; import { constructorsForReviver, Generic_toJSON, Generic_fromJSON, IReviverValue } from "../utils/JSONReviver"; -import { CorpStateName } from "@nsdefs"; +import { CorpStateName, InvestmentOffer } from "@nsdefs"; import { calculateUpgradeCost } from "./helpers"; import { JSONMap, JSONSet } from "../Types/Jsonable"; import { formatMoney } from "../ui/formatNumber"; @@ -46,6 +46,7 @@ export class Corporation { issueNewSharesCooldown = 0; // Game cycles until player can issue shares again dividendRate = 0; dividendTax = 1 - currentNodeMults.CorporationSoftcap + 0.15; + investorShares = 0; issuedShares = 0; sharePrice = 0; storedCycles = 0; @@ -232,10 +233,17 @@ export class Corporation { this.totalAssets = assets; } - getTargetSharePrice(): number { - // Note: totalShares - numShares is not the same as issuedShares because - // issuedShares does not account for private investors - return this.valuation / (2 * (this.totalShares - this.numShares) + 1); + getTargetSharePrice(ceoOwnership: number | null = null): number { + // Share price is proportional to total corporation valuation. + // When the CEO owns 0% of the company, market cap is 0.5x valuation. + // When the CEO owns 25% of the company, market cap is 1.0x valuation. + // When the CEO owns 100% of shares, market cap is 1.5x valuation. + if (ceoOwnership === null) { + ceoOwnership = this.numShares / this.totalShares; + } + const ceoConfidence = 0.5 + Math.sqrt(Math.max(0, ceoOwnership)); + const marketCap = this.valuation * ceoConfidence; + return marketCap / this.totalShares; } updateSharePrice(): void { @@ -250,10 +258,6 @@ export class Corporation { } } - immediatelyUpdateSharePrice(): void { - this.sharePrice = this.getTargetSharePrice(); - } - calculateMaxNewShares(): number { const maxNewSharesUnrounded = Math.round(this.totalShares * 0.2); const maxNewShares = maxNewSharesUnrounded - (maxNewSharesUnrounded % 10e6); @@ -261,17 +265,18 @@ export class Corporation { } // Calculates how much money will be made and what the resulting stock price - // will be when the player sells his/her shares + // will be when the player sells their shares // @return - [Player profit, final stock price, end shareSalesUntilPriceUpdate property] - calculateShareSale(numShares: number): [number, number, number] { - let sharesTracker = numShares; + calculateShareSale(numShares: number): [profit: number, sharePrice: number, sharesUntilUpdate: number] { + let sharesRemaining = numShares; let sharesUntilUpdate = this.shareSalesUntilPriceUpdate; let sharePrice = this.sharePrice; let sharesSold = 0; let profit = 0; - let targetPrice = this.getTargetSharePrice(); - const maxIterations = Math.ceil(numShares / corpConstants.sharesPerPriceUpdate); + const sharesPerStep = Math.sign(numShares || 1) * corpConstants.sharesPerPriceUpdate; + const maxIterations = Math.ceil(numShares / sharesPerStep); + if (isNaN(maxIterations) || maxIterations > 10e6) { console.error( `Something went wrong or unexpected when calculating share sale. Max iterations calculated to be ${maxIterations}`, @@ -280,28 +285,59 @@ export class Corporation { } for (let i = 0; i < maxIterations; ++i) { - if (sharesTracker < sharesUntilUpdate) { - profit += sharePrice * sharesTracker; - sharesUntilUpdate -= sharesTracker; + if (Math.abs(sharesRemaining) < Math.abs(sharesUntilUpdate)) { + profit += sharePrice * sharesRemaining; + sharesUntilUpdate -= sharesRemaining; break; } else { - profit += sharePrice * sharesUntilUpdate; - sharesUntilUpdate = corpConstants.sharesPerPriceUpdate; - sharesTracker -= sharesUntilUpdate; - sharesSold += sharesUntilUpdate; - targetPrice = this.valuation / (2 * (this.totalShares + sharesSold - this.numShares)); - // Calculate what new share price would be + profit += sharePrice * sharesPerStep; + sharesRemaining -= sharesPerStep; + sharesSold += sharesPerStep; + + // Update the share price + const ceoOwnership = (this.numShares - sharesSold) / this.totalShares; + const targetPrice = this.getTargetSharePrice(ceoOwnership); if (sharePrice <= targetPrice) { sharePrice *= 1 + 0.5 * 0.01; } else { sharePrice *= 1 - 0.5 * 0.01; } + sharesUntilUpdate = corpConstants.sharesPerPriceUpdate; } } return [profit, sharePrice, sharesUntilUpdate]; } + calculateShareBuyback(numShares: number): [cost: number, sharePrice: number, sharesUntilUpdate: number] { + const [profit, sharePrice, sharesUntilUpdate] = this.calculateShareSale(-numShares); + const cost = -1.1 * profit; + return [cost, sharePrice, sharesUntilUpdate]; + } + + getInvestmentOffer(): InvestmentOffer { + if ( + this.fundingRound >= corpConstants.fundingRoundShares.length || + this.fundingRound >= corpConstants.fundingRoundMultiplier.length || + this.public + ) + return { + funds: 0, + shares: 0, + round: this.fundingRound + 1, // Make more readable + }; // Don't throw an error here, no reason to have a second function to check if you can get investment. + const val = this.valuation; + const percShares = corpConstants.fundingRoundShares[this.fundingRound]; + const roundMultiplier = corpConstants.fundingRoundMultiplier[this.fundingRound]; + const funding = val * percShares * roundMultiplier; + const investShares = Math.floor(corpConstants.initialShares * percShares); + return { + funds: funding, + shares: investShares, + round: this.fundingRound + 1, // Make more readable + }; + } + convertCooldownToString(cd: number): string { // The cooldown value is based on game cycles. Convert to a simple string const seconds = cd / 5; diff --git a/src/Corporation/data/Constants.ts b/src/Corporation/data/Constants.ts index 2dc986b74..3c31970fc 100644 --- a/src/Corporation/data/Constants.ts +++ b/src/Corporation/data/Constants.ts @@ -33,7 +33,7 @@ export const stateNames: CorpStateName[] = ["START", "PURCHASE", "PRODUCTION", " /** Names of all one-time corporation-wide unlocks */ unlockNames: APIUnlockName[] = Object.values(CorpUnlockName), upgradeNames: APIUpgradeName[] = Object.values(CorpUpgradeName), - /** Names of all reasearches common to all industries */ + /** Names of all researches common to all industries */ researchNamesBase: CorpResearchName[] = Object.values(CorpBaseResearchName), /** Names of all researches only available to product industries */ researchNamesProductOnly: CorpResearchName[] = Object.values(CorpProductResearchName), @@ -42,8 +42,8 @@ export const stateNames: CorpStateName[] = ["START", "PURCHASE", "PRODUCTION", " initialShares = 1e9, /** When selling large number of shares, price is dynamically updated for every batch of this amount */ sharesPerPriceUpdate = 1e6, - /** Cooldown for issue new shares cooldown in game cycles. 12 hours. */ - issueNewSharesCooldown = 216e3, + /** Cooldown for issue new shares cooldown in game cycles. Initially 4 hours. */ + issueNewSharesCooldown = 72e3, /** Cooldown for selling shares in game cycles. 1 hour. */ sellSharesCooldown = 18e3, teaCostPerEmployee = 500e3, diff --git a/src/Corporation/helpers.ts b/src/Corporation/helpers.ts index ec3085cd0..2f0511f2e 100644 --- a/src/Corporation/helpers.ts +++ b/src/Corporation/helpers.ts @@ -1,4 +1,6 @@ -import { PositiveInteger } from "../types"; +import { Player } from "@player"; +import { PositiveInteger, isPositiveInteger } from "../types"; +import { formatShares } from "../ui/formatNumber"; import { Corporation } from "./Corporation"; import { CorpUpgrade } from "./data/CorporationUpgrades"; @@ -29,3 +31,43 @@ export function calculateMaxAffordableUpgrade(corp: Corporation, upgrade: CorpUp const sanitizedValue = maxAffordableUpgrades >= 0 ? maxAffordableUpgrades : 0; return sanitizedValue as PositiveInteger | 0; } + +/** Returns a string representing the reason a share sale should fail, or empty string if there is no issue. */ +export function sellSharesFailureReason(corp: Corporation, numShares: number): string { + if (!isPositiveInteger(numShares)) return "Number of shares must be a positive integer."; + else if (numShares > corp.numShares) return "You do not have that many shares to sell."; + else if (numShares === corp.numShares) return "You cannot sell all your shares."; + else if (numShares > 1e14) return `Cannot sell more than ${formatShares(1e14)} shares at a time.`; + else if (!corp.public) return "Cannot sell shares before going public."; + else if (corp.shareSaleCooldown) + return `Cannot sell shares for another ${corp.convertCooldownToString(corp.shareSaleCooldown)}.`; + return ""; +} + +/** Returns a string representing the reason a share buyback should fail, or empty string if there is no issue. */ +export function buybackSharesFailureReason(corp: Corporation, numShares: number): string { + if (!isPositiveInteger(numShares)) return "Number of shares must be a positive integer."; + if (numShares > corp.issuedShares) return "Not enough shares are available for buyback."; + if (numShares > 1e14) return `Cannot buy more than ${formatShares(1e14)} shares at a time.`; + if (!corp.public) return "Cannot buy back shares before going public."; + + const [cost] = corp.calculateShareBuyback(numShares); + if (Player.money < cost) return "You cannot afford that many shares."; + + return ""; +} + +/** Returns a string representing the reason issuing new shares should fail, or empty string if there is no issue. */ +export function issueNewSharesFailureReason(corp: Corporation, numShares: number): string { + if (!isPositiveInteger(numShares)) return "Number of shares must be a positive integer."; + if (numShares % 10e6 !== 0) return "Number of shares must be a multiple of 10 million."; + if (!corp.public) return "Cannot issue new shares before going public."; + + const maxNewShares = corp.calculateMaxNewShares(); + if (numShares > maxNewShares) return `Number of shares cannot exceed ${maxNewShares} (20% of total shares).`; + + const cooldown = corp.issueNewSharesCooldown; + if (cooldown > 0) return `Cannot issue new shares for another ${corp.convertCooldownToString(cooldown)}.`; + + return ""; +} diff --git a/src/Corporation/ui/MaterialElem.tsx b/src/Corporation/ui/MaterialElem.tsx index ab21e69dc..46a377e71 100644 --- a/src/Corporation/ui/MaterialElem.tsx +++ b/src/Corporation/ui/MaterialElem.tsx @@ -8,7 +8,7 @@ import { Warehouse } from "../Warehouse"; import { ExportModal } from "./modals/ExportModal"; import { SellMaterialModal } from "./modals/SellMaterialModal"; import { PurchaseMaterialModal } from "./modals/PurchaseMaterialModal"; -import { formatBigNumber, formatCorpStat, formatMoney, formatQuality } from "../../ui/formatNumber"; +import { formatBigNumber, formatCorpStat, formatQuality } from "../../ui/formatNumber"; import { isString } from "../../utils/helpers/string"; import { Money } from "../../ui/React/Money"; import { useCorporation, useDivision } from "./Context"; @@ -118,7 +118,9 @@ export function MaterialElem(props: IMaterialProps): React.ReactElement { } > - MP: {formatMoney(mat.marketPrice)} + + MP: + The quality of your material. Higher quality will lead to more sales} diff --git a/src/Corporation/ui/Overview.tsx b/src/Corporation/ui/Overview.tsx index 9df0ae8a3..6ba08887c 100644 --- a/src/Corporation/ui/Overview.tsx +++ b/src/Corporation/ui/Overview.tsx @@ -77,8 +77,21 @@ export function Overview({ rerender }: IProps): React.ReactElement { title={  {formatShares(corp.numShares)} , + <>({formatPercent(corp.numShares / corp.totalShares)}), + ], + [ + "Outstanding Shares:", + <> {formatShares(corp.issuedShares)} , + <>({formatPercent(corp.issuedShares / corp.totalShares)}), + ], + [ + "Private Shares:", + <> {formatShares(corp.investorShares)} , + <>({formatPercent(corp.investorShares / corp.totalShares)}), + ], ]} /> } @@ -96,8 +109,8 @@ export function Overview({ rerender }: IProps): React.ReactElement { - Get a copy of and read 'The Complete Handbook for Creating a Successful Corporation.' This is a .lit file - that guides you through the beginning of setting up a Corporation and provides some tips/pointers for + Get a copy of and read The Complete Handbook for Creating a Successful Corporation. This is a .lit + file that guides you through the beginning of setting up a Corporation and provides some tips/pointers for helping you get started with managing it. } @@ -125,7 +138,7 @@ function PrivateButtons({ rerender }: IPrivateButtonsProps): React.ReactElement const [findInvestorsopen, setFindInvestorsopen] = useState(false); const [goPublicopen, setGoPublicopen] = useState(false); - const fundingAvailable = corp.fundingRound < 4; + const fundingAvailable = corp.fundingRound < corpConstants.fundingRoundShares.length; const findInvestorsTooltip = fundingAvailable ? "Search for private investors who will give you startup funding in exchange for equity (stock shares) in your company" : ""; @@ -213,29 +226,27 @@ function PublicButtons({ rerender }: IPublicButtonsProps): React.ReactElement { const [issueDividendsOpen, setIssueDividendsOpen] = useState(false); const sellSharesOnCd = corp.shareSaleCooldown > 0; - const sellSharesTooltip = sellSharesOnCd - ? "Cannot sell shares for " + corp.convertCooldownToString(corp.shareSaleCooldown) - : "Sell your shares in the company. The money earned from selling your " + - "shares goes into your personal account, not the Corporation's. " + - "This is one of the only ways to profit from your business venture."; + const sellSharesTooltip = + "Sell your shares in the company. The money earned from selling your " + + "shares goes into your personal account, not the Corporation's. " + + "This is one of the only ways to profit from your business venture."; const issueNewSharesOnCd = corp.issueNewSharesCooldown > 0; - const issueNewSharesTooltip = issueNewSharesOnCd - ? "Cannot issue new shares for " + corp.convertCooldownToString(corp.issueNewSharesCooldown) - : "Issue new equity shares to raise capital."; return ( <> setSellSharesOpen(true)} > Sell Shares setSellSharesOpen(false)} rerender={rerender} /> setBuybackSharesOpen(true)} > @@ -243,13 +254,15 @@ function PublicButtons({ rerender }: IPublicButtonsProps): React.ReactElement { setBuybackSharesOpen(false)} rerender={rerender} /> setIssueNewSharesOpen(true)} > Issue New Shares - setIssueNewSharesOpen(false)} /> + setIssueNewSharesOpen(false)} rerender={rerender} /> setIssueDividendsOpen(true)} @@ -306,13 +319,22 @@ function SellDivisionButton(): React.ReactElement { function RestartButton(): React.ReactElement { const [open, setOpen] = useState(false); + const corp = useCorporation(); + const sellSharesOnCd = corp.shareSaleCooldown > 0; + function restart(): void { setOpen(true); } return ( <> - + Sell CEO position setOpen(false)} /> diff --git a/src/Corporation/ui/ProductElem.tsx b/src/Corporation/ui/ProductElem.tsx index 66990c03b..a5fa62833 100644 --- a/src/Corporation/ui/ProductElem.tsx +++ b/src/Corporation/ui/ProductElem.tsx @@ -8,7 +8,7 @@ import { LimitProductProductionModal } from "./modals/LimitProductProductionModa import { SellProductModal } from "./modals/SellProductModal"; import { CancelProductModal } from "./modals/CancelProductModal"; -import { formatBigNumber, formatMoney, formatPercent } from "../../ui/formatNumber"; +import { formatBigNumber, formatPercent } from "../../ui/formatNumber"; import { isString } from "../../utils/helpers/string"; import { Money } from "../../ui/React/Money"; @@ -135,7 +135,7 @@ export function ProductElem(props: IProductProps): React.ReactElement { An estimate of the material cost it takes to create this Product.}> - Est. Production Cost: {formatMoney(product.productionCost / corpConstants.baseProductProfitMult)} + Est. Production Cost: @@ -148,7 +148,9 @@ export function ProductElem(props: IProductProps): React.ReactElement { } > - Est. Market Price: {formatMoney(product.productionCost)} + + Est. Market Price: + diff --git a/src/Corporation/ui/modals/BuybackSharesModal.tsx b/src/Corporation/ui/modals/BuybackSharesModal.tsx index d1e70356f..427e8d404 100644 --- a/src/Corporation/ui/modals/BuybackSharesModal.tsx +++ b/src/Corporation/ui/modals/BuybackSharesModal.tsx @@ -1,15 +1,15 @@ import React, { useState } from "react"; +import { dialogBoxCreate } from "../../../ui/React/DialogBox"; import { Modal } from "../../../ui/React/Modal"; -import { formatBigNumber, formatMoney } from "../../../ui/formatNumber"; -import { Player } from "@player"; +import { Money } from "../../../ui/React/Money"; +import { formatShares } from "../../../ui/formatNumber"; import { useCorporation } from "../Context"; import Typography from "@mui/material/Typography"; import { ButtonWithTooltip } from "../../../ui/Components/ButtonWithTooltip"; import { NumberInput } from "../../../ui/React/NumberInput"; import { BuyBackShares } from "../../Actions"; -import { dialogBoxCreate } from "../../../ui/React/DialogBox"; import { KEY } from "../../../utils/helpers/keyCodes"; -import { isPositiveInteger } from "../../../types"; +import { buybackSharesFailureReason } from "../../helpers"; interface IProps { open: boolean; @@ -23,44 +23,28 @@ export function BuybackSharesModal(props: IProps): React.ReactElement { const corp = useCorporation(); const [shares, setShares] = useState(NaN); - const currentStockPrice = corp.sharePrice; - const buybackPrice = currentStockPrice * 1.1; - const disabledText = !isPositiveInteger(shares) - ? "Number of shares must be a positive integer" - : shares > corp.issuedShares - ? "There are not enough shares available to buyback this many" - : shares * buybackPrice > Player.money - ? "Insufficient player funds" - : ""; + const [cost, sharePrice] = corp.calculateShareBuyback((props.open && shares) || 0); + const disabledText = buybackSharesFailureReason(corp, shares); function buy(): void { if (disabledText) return; try { BuyBackShares(corp, shares); + dialogBoxCreate( + <> + + You bought {formatShares(shares)} shares for . + + + {corp.name}'s stock price rose to per share. + + , + ); + props.onClose(); + props.rerender(); + setShares(NaN); } catch (err) { - dialogBoxCreate(err + ""); - } - props.onClose(); - props.rerender(); - } - - function CostIndicator(): React.ReactElement { - if (shares === null) return <>; - if (isNaN(shares) || shares <= 0) { - return <>ERROR: Invalid value entered for number of shares to buyback; - } else if (shares > corp.issuedShares) { - return ( - <> - There are not this many shares available to buy back. There are only {formatBigNumber(corp.issuedShares)}{" "} - outstanding shares. - - ); - } else { - return ( - <> - Purchase {shares} shares for a total of {formatMoney(shares * buybackPrice)} - - ); + dialogBoxCreate(`${err}`); } } @@ -70,23 +54,45 @@ export function BuybackSharesModal(props: IProps): React.ReactElement { return ( - - Enter the number of outstanding shares you would like to buy back. These shares must be bought at a 10% premium. - However, repurchasing shares from the market tends to lead to an increase in stock price. -
-
- To purchase these shares, you must use your own money (NOT your Corporation's funds). -
-
- The current buyback price of your company's stock is {formatMoney(buybackPrice)}. Your company currently has{" "} - {formatBigNumber(corp.issuedShares)} outstanding stock shares. + + Enter the number of outstanding shares you would like to buy back. +
    +
  • Buying back shares will cause the stock price to rise due to market forces.
  • +
  • These shares must be bought at a 10% premium over the market price.
  • +
  • You purchase these shares with your own money (NOT your Corporation's funds).
  • +
+ {corp.name} currently has {formatShares(corp.issuedShares)} outstanding stock shares, valued at{" "} + per share.
-
- + Buy shares + {cost > 0 ? ( + <> +  -  + {" "} + + ) : ( + <> + )} +
+ + {!shares ? null : disabledText ? ( + disabledText + ) : ( + <> + {corp.name}'s stock price will rise to per share. + + )} +
); } diff --git a/src/Corporation/ui/modals/CreateCorporationModal.tsx b/src/Corporation/ui/modals/CreateCorporationModal.tsx index 756c137b0..023f2e0e6 100644 --- a/src/Corporation/ui/modals/CreateCorporationModal.tsx +++ b/src/Corporation/ui/modals/CreateCorporationModal.tsx @@ -4,6 +4,7 @@ import { Money } from "../../../ui/React/Money"; import { Modal } from "../../../ui/React/Modal"; import { Router } from "../../../ui/GameRoot"; import { Page } from "../../../ui/Router"; +import { formatShares } from "../../../ui/formatNumber"; import { Player } from "@player"; import Typography from "@mui/material/Typography"; import { ButtonWithTooltip } from "../../../ui/Components/ButtonWithTooltip"; @@ -54,15 +55,19 @@ export function CreateCorporationModal(props: IProps): React.ReactElement { return ( - Would you like to start a corporation? This will require $150b for registration and initial funding.{" "} - {Player.bitNodeN === 3 && - `This $150b - can either be self-funded, or you can obtain the seed money from the government in exchange for 500 million - shares`} + Would you like to start a corporation? This will require for + registration and initial funding.{" "} + {Player.bitNodeN === 3 && ( + <> + This can either be self-funded, or you can obtain the seed money from the government + in exchange for {formatShares(500e6)} shares (a 33.3% stake in the company). + + )}

If you would like to start one, please enter a name for your corporation below:
+
{Player.bitNodeN === 3 && ( @@ -71,7 +76,7 @@ export function CreateCorporationModal(props: IProps): React.ReactElement { )} Self-Fund () diff --git a/src/Corporation/ui/modals/FindInvestorsModal.tsx b/src/Corporation/ui/modals/FindInvestorsModal.tsx index b7657e32e..9d3504289 100644 --- a/src/Corporation/ui/modals/FindInvestorsModal.tsx +++ b/src/Corporation/ui/modals/FindInvestorsModal.tsx @@ -1,8 +1,10 @@ import React from "react"; -import { formatMoney, formatPercent, formatShares } from "../../../ui/formatNumber"; -import * as corpConstants from "../../data/Constants"; +import { dialogBoxCreate } from "../../../ui/React/DialogBox"; +import { formatPercent, formatShares } from "../../../ui/formatNumber"; import { Modal } from "../../../ui/React/Modal"; +import { Money } from "../../../ui/React/Money"; import { useCorporation } from "../Context"; +import { AcceptInvestmentOffer } from "../../Actions"; import Typography from "@mui/material/Typography"; import Button from "@mui/material/Button"; @@ -13,40 +15,52 @@ interface IProps { rerender: () => void; } -// Create a popup that lets the player manage exports +// Create a popup that lets the player manage investment offers export function FindInvestorsModal(props: IProps): React.ReactElement { - const corporation = useCorporation(); - const val = corporation.valuation; - if ( - corporation.fundingRound >= corpConstants.fundingRoundShares.length || - corporation.fundingRound >= corpConstants.fundingRoundMultiplier.length - ) - return <>; - const percShares = corpConstants.fundingRoundShares[corporation.fundingRound]; - const roundMultiplier = corpConstants.fundingRoundMultiplier[corporation.fundingRound]; - const funding = val * percShares * roundMultiplier; - const investShares = Math.floor(corpConstants.initialShares * percShares); + const corp = useCorporation(); + const { funds, shares } = corp.getInvestmentOffer(); function findInvestors(): void { - corporation.fundingRound++; - corporation.addNonIncomeFunds(funding); - corporation.numShares -= investShares; - props.rerender(); - props.onClose(); + if (shares === 0) return; + try { + AcceptInvestmentOffer(corp); + dialogBoxCreate( + <> + You accepted the investment offer. + + {corp.name} received . + + + Your remaining equity is {formatPercent(corp.numShares / corp.totalShares, 1)}. + + , + ); + props.onClose(); + props.rerender(); + } catch (err) { + dialogBoxCreate(`${err}`); + } } + return ( - An investment firm has offered you {formatMoney(funding)} in funding in exchange for a{" "} - {formatPercent(percShares, 3)} stake in the company ({formatShares(investShares)} shares). + An investment firm has offered to buy {formatShares(shares)} shares of stock (a{" "} + {formatPercent(shares / corp.totalShares, 1)} stake in the company).

- Do you accept or reject this offer? + {corp.name} will receive . +
+ Your equity will fall to {formatPercent((corp.numShares - shares) / corp.totalShares, 1)}.

- Hint: Investment firms will offer more money if your corporation is turning a profit + Hint: Investment firms will offer more money if your Corporation is turning a profit. +
+
+ Do you accept this offer?
- +
+
); } diff --git a/src/Corporation/ui/modals/GoPublicModal.tsx b/src/Corporation/ui/modals/GoPublicModal.tsx index b3872c1a2..2b02b241e 100644 --- a/src/Corporation/ui/modals/GoPublicModal.tsx +++ b/src/Corporation/ui/modals/GoPublicModal.tsx @@ -1,7 +1,8 @@ import React, { useState } from "react"; import { dialogBoxCreate } from "../../../ui/React/DialogBox"; import { Modal } from "../../../ui/React/Modal"; -import { formatMoney, formatShares } from "../../../ui/formatNumber"; +import { Money } from "../../../ui/React/Money"; +import { formatShares } from "../../../ui/formatNumber"; import { useCorporation } from "../Context"; import Typography from "@mui/material/Typography"; import { ButtonWithTooltip } from "../../../ui/Components/ButtonWithTooltip"; @@ -9,6 +10,7 @@ import { NumberInput } from "../../../ui/React/NumberInput"; import Box from "@mui/material/Box"; import { KEY } from "../../../utils/helpers/keyCodes"; import { isPositiveInteger } from "../../../types"; +import { GoPublic } from "../../Actions"; interface IProps { open: boolean; @@ -20,26 +22,9 @@ interface IProps { export function GoPublicModal(props: IProps): React.ReactElement { const corp = useCorporation(); const [shares, setShares] = useState(NaN); - const initialSharePrice = corp.valuation / corp.totalShares; - function goPublic(): void { - const initialSharePrice = corp.valuation / corp.totalShares; - if (shares >= corp.numShares || (shares !== 0 && !isPositiveInteger(shares))) return; - corp.public = true; - corp.sharePrice = initialSharePrice; - corp.issuedShares = shares; - corp.numShares -= shares; - corp.addFunds(shares * initialSharePrice); - props.rerender(); - dialogBoxCreate( - `You took your ${corp.name} public and earned ` + `${formatMoney(shares * initialSharePrice)} in your IPO`, - ); - props.onClose(); - } - - function onKeyDown(event: React.KeyboardEvent): void { - if (event.key === KEY.ENTER) goPublic(); - } + const ceoOwnership = (corp.numShares - (shares || 0)) / corp.totalShares; + const initialSharePrice = corp.getTargetSharePrice(ceoOwnership); const disabledText = shares >= corp.numShares @@ -48,22 +33,62 @@ export function GoPublicModal(props: IProps): React.ReactElement { ? "Must issue an non-negative integer number of shares" : ""; + function goPublic(): void { + if (disabledText) return; + try { + GoPublic(corp, shares); + dialogBoxCreate( + + {corp.name} went public and earned in its IPO. + , + ); + props.onClose(); + props.rerender(); + setShares(NaN); + } catch (err) { + dialogBoxCreate(`${err}`); + } + } + + function onKeyDown(event: React.KeyboardEvent): void { + if (event.key === KEY.ENTER) goPublic(); + } + return ( - - Enter the number of shares you would like to issue for your IPO. These shares will be publicly sold and you will - no longer own them. Your Corporation will receive {formatMoney(initialSharePrice)} per share (the IPO money will - be deposited directly into your Corporation's funds). -
-
+ + Enter the number of shares you would like to issue for your IPO. +
    +
  • These shares will be publicly sold and you will no longer own them.
  • +
  • The IPO money will be deposited directly into your Corporation's funds.
  • +
You can issue some, but not all, of your {formatShares(corp.numShares)} shares.
+
- + Go Public +
+ + {isNaN(shares) ? null : disabledText ? ( + disabledText + ) : ( + <> + Go public at per share? +
+ {corp.name} will receive . + + )} +
); } diff --git a/src/Corporation/ui/modals/IssueDividendsModal.tsx b/src/Corporation/ui/modals/IssueDividendsModal.tsx index 43e1dd616..3b60b44dd 100644 --- a/src/Corporation/ui/modals/IssueDividendsModal.tsx +++ b/src/Corporation/ui/modals/IssueDividendsModal.tsx @@ -1,6 +1,8 @@ import React, { useState } from "react"; import { dialogBoxCreate } from "../../../ui/React/DialogBox"; import { Modal } from "../../../ui/React/Modal"; +import { Money } from "../../../ui/React/Money"; +import { MoneyRate } from "../../../ui/React/MoneyRate"; import * as corpConstants from "../../data/Constants"; import { IssueDividends } from "../../Actions"; import { useCorporation } from "../Context"; @@ -53,20 +55,19 @@ export function IssueDividendsModal(props: IProps): React.ReactElement { yourself, as well.

- In order to issue dividends, simply allocate some percentage of your corporation's profits to dividends. This - percentage must be an integer between 0 and 100. (A percentage of 0 means no dividends will be issued) + Note that issuing dividends will negatively affect {corp.name}'s stock price.

- Two important things to note: -
- * Issuing dividends will negatively affect your corporation's stock price + In order to issue dividends, simply allocate some percentage of your Corporation's profits to dividends. This + percentage must be an integer between 0 and 100. (A percentage of 0 means no dividends will be issued.)

- Example: Assume your corporation makes $100m / sec in profit and you allocate 40% of that towards dividends. - That means your corporation will gain $60m / sec in funds and the remaining $40m / sec will be paid as - dividends. Since your corporation starts with 1 billion shares, every shareholder will be paid $0.04 per share - per second before taxes. + Example: Assume your corporation makes in profit and you allocate 40% of that + towards dividends. That means your corporation will gain in funds and the remaining{" "} + will be paid as dividends. Since your corporation starts with 1 billion shares, every + shareholder will be paid per share per second before taxes. +
; - const newSharePrice = Math.round(corp.sharePrice * 0.9); - const maxNewShares = corp.calculateMaxNewShares(); - let newShares = props.shares; - if (isNaN(newShares)) { - return Invalid input; - } - - // Round to nearest ten-millionth - newShares /= 10e6; - newShares = Math.round(newShares) * 10e6; - - if (newShares < 10e6) { - return Must issue at least 10 million new shares; - } - - if (newShares > maxNewShares) { - return You cannot issue that many shares; - } - - return ( - - Issue {formatShares(newShares)} new shares for {formatMoney(newShares * newSharePrice)}? - - ); -} +import * as corpConstants from "../../data/Constants"; +import { issueNewSharesFailureReason } from "../../helpers"; interface IProps { open: boolean; onClose: () => void; + rerender: () => void; } // Create a popup that lets the player issue new shares @@ -52,56 +23,103 @@ interface IProps { export function IssueNewSharesModal(props: IProps): React.ReactElement { const corp = useCorporation(); const [shares, setShares] = useState(NaN); - const maxNewShares = corp.calculateMaxNewShares(); + const maxNewShares = corp.calculateMaxNewShares(); const newShares = Math.round((shares || 0) / 10e6) * 10e6; - const disabled = isNaN(shares) || isNaN(newShares) || newShares < 10e6 || newShares > maxNewShares; + + const ceoOwnership = corp.numShares / (corp.totalShares + (newShares || 0)); + const newSharePrice = corp.getTargetSharePrice(ceoOwnership); + const profit = ((shares || 0) * (corp.sharePrice + newSharePrice)) / 2; + + const privateOwnedRatio = corp.investorShares / corp.totalShares; + const maxPrivateShares = Math.round(((newShares / 2) * privateOwnedRatio) / 10e6) * 10e6; + + const disabledText = issueNewSharesFailureReason(corp, shares); function issueNewShares(): void { - if (isNaN(shares)) return; - if (disabled) return; - const [profit, newShares, privateShares] = IssueNewShares(corp, shares); - - props.onClose(); - - let dialogContents = - `Issued ${formatShares(newShares)} new shares` + ` and raised ${formatMoney(profit)}.` + (privateShares > 0) - ? "\n" + formatShares(privateShares) + " of these shares were bought by private investors." - : ""; - dialogContents += `\n\nStock price decreased to ${formatMoney(corp.sharePrice)}`; - dialogBoxCreate(dialogContents); + if (disabledText) return; + try { + const [profit, newShares, privateShares] = IssueNewShares(corp, shares); + dialogBoxCreate( + <> + + Issued {formatShares(newShares)} new shares and raised . + + {privateShares > 0 ? ( + {formatShares(privateShares)} of these shares were bought by private investors. + ) : null} + + {corp.name}'s stock price fell to . + + , + ); + props.onClose(); + props.rerender(); + } catch (err) { + dialogBoxCreate(`${err}`); + } } function onKeyDown(event: React.KeyboardEvent): void { if (event.key === KEY.ENTER) issueNewShares(); } + const nextCooldown = corpConstants.issueNewSharesCooldown * (corp.totalShares / corpConstants.initialShares); + return ( - - You can issue new equity shares (i.e. stocks) in order to raise capital for your corporation. + + You can issue new equity shares (i.e. stocks) in order to raise capital. +
    +
  • Issuing new shares will cause dilution, lowering stock price and reducing dividends per share.
  • +
  • New shares are sold between the current price and the updated price.
  • +
  • The money from issuing new shares will be deposited directly into your Corporation's funds.
  • +
  • + Private shareholders have first priority for buying new shares, up to half of their existing stake in the + company ({formatPercent(privateOwnedRatio / 2, 1)}). +
    + If they choose to exercise this option, these newly issued shares become private, restricted shares, which + means you cannot buy them back. +
  • +
  • + You will not be able to issue new shares again for {corp.convertCooldownToString(nextCooldown)}. +
  • +
+ You can issue at most {formatShares(maxNewShares)} new shares.
-
-  * You can issue at most {formatShares(maxNewShares)} new shares -
-  * New shares are sold at a 10% discount -
-  * You can only issue new shares once every 12 hours -
-  * Issuing new shares causes dilution, resulting in a decrease in stock price and lower dividends per share -
-  * Number of new shares issued must be a multiple of 10 million -
-
- When you choose to issue new equity, private shareholders have first priority for up to 0.5n% of the new shares, - where n is the percentage of the company currently owned by private shareholders. If they choose to exercise - this option, these newly issued shares become private, restricted shares, which means you cannot buy them back. + The number of new shares issued must be a multiple of 10 million.
- - - +
+
+ + {disabledText ? ( + disabledText + ) : ( + <> + Issue {formatShares(newShares)} new shares? +
+ {maxPrivateShares > 0 + ? `Private investors may buy up to ${formatShares( + maxPrivateShares, + )} of these shares and keep them off the market.` + : null} +
+ {corp.name} will receive . +
+ {corp.name}'s stock price will fall to per share. + + )} +
); } diff --git a/src/Corporation/ui/modals/PurchaseMaterialModal.tsx b/src/Corporation/ui/modals/PurchaseMaterialModal.tsx index f206d1185..a1249125e 100644 --- a/src/Corporation/ui/modals/PurchaseMaterialModal.tsx +++ b/src/Corporation/ui/modals/PurchaseMaterialModal.tsx @@ -3,9 +3,10 @@ import { dialogBoxCreate } from "../../../ui/React/DialogBox"; import { MaterialInfo } from "../../MaterialInfo"; import { Warehouse } from "../../Warehouse"; import { Material } from "../../Material"; -import { formatMatPurchaseAmount, formatMoney } from "../../../ui/formatNumber"; +import { formatMatPurchaseAmount } from "../../../ui/formatNumber"; import { BulkPurchase, BuyMaterial } from "../../Actions"; import { Modal } from "../../../ui/React/Modal"; +import { Money } from "../../../ui/React/Money"; import { useCorporation, useDivision } from "../Context"; import Typography from "@mui/material/Typography"; import TextField from "@mui/material/TextField"; @@ -56,7 +57,7 @@ function BulkPurchaseSection(props: IBPProps): React.ReactElement { return ( <> - Purchasing {formatMatPurchaseAmount(parsedAmt)} of {props.mat.name} will cost {formatMoney(cost)} + Purchasing {formatMatPurchaseAmount(parsedAmt)} of {props.mat.name} will cost ); diff --git a/src/Corporation/ui/modals/SellCorporationModal.tsx b/src/Corporation/ui/modals/SellCorporationModal.tsx index 5145a42d8..d6d701164 100644 --- a/src/Corporation/ui/modals/SellCorporationModal.tsx +++ b/src/Corporation/ui/modals/SellCorporationModal.tsx @@ -57,6 +57,7 @@ export function SellCorporationModal(props: IProps): React.ReactElement {
If you would like to start new one, please enter a name for your corporation below: +
{Player.bitNodeN === 3 && ( diff --git a/src/Corporation/ui/modals/SellSharesModal.tsx b/src/Corporation/ui/modals/SellSharesModal.tsx index 80ad43fb7..c17f39b93 100644 --- a/src/Corporation/ui/modals/SellSharesModal.tsx +++ b/src/Corporation/ui/modals/SellSharesModal.tsx @@ -1,16 +1,17 @@ import React, { useState } from "react"; -import { formatMoney } from "../../../ui/formatNumber"; import { dialogBoxCreate } from "../../../ui/React/DialogBox"; +import { formatShares } from "../../../ui/formatNumber"; import { Modal } from "../../../ui/React/Modal"; -import { useCorporation } from "../Context"; -import { Corporation } from "../../Corporation"; -import Typography from "@mui/material/Typography"; -import Button from "@mui/material/Button"; import { Money } from "../../../ui/React/Money"; +import { useCorporation } from "../Context"; +import * as corpConstants from "../../data/Constants"; +import Typography from "@mui/material/Typography"; +import { ButtonWithTooltip } from "../../../ui/Components/ButtonWithTooltip"; import { SellShares } from "../../Actions"; import { KEY } from "../../../utils/helpers/keyCodes"; import { NumberInput } from "../../../ui/React/NumberInput"; -import { isInteger } from "lodash"; +import { sellSharesFailureReason } from "../../helpers"; + interface IProps { open: boolean; onClose: () => void; @@ -23,48 +24,28 @@ export function SellSharesModal(props: IProps): React.ReactElement { const corp = useCorporation(); const [shares, setShares] = useState(NaN); - const disabled = isNaN(shares) || shares <= 0 || shares >= corp.numShares; - - function ProfitIndicator(props: { shares: number | null; corp: Corporation }): React.ReactElement { - if (props.shares === null) return <>; - let text = ""; - if (isNaN(props.shares) || props.shares <= 0 || !isInteger(props.shares)) { - text = `ERROR: Invalid value entered for number of shares to sell`; - } else if (props.shares > corp.numShares) { - text = `You don't have this many shares to sell!`; - } else if (props.shares === corp.numShares) { - text = `You can not sell all your shares!`; - } else if (props.shares > 1e14) { - text = `You can't sell more than 100t shares at once!`; - } else { - const stockSaleResults = corp.calculateShareSale(props.shares); - const profit = stockSaleResults[0]; - text = `Sell ${props.shares} shares for a total of ${formatMoney(profit)}`; - } - - return ( - - {text} - - ); - } + const [profit, sharePrice] = corp.calculateShareSale((props.open && shares) || 0); + const disabledText = sellSharesFailureReason(corp, shares); function sell(): void { - if (disabled) return; + if (disabledText) return; try { - const profit = SellShares(corp, shares); - props.onClose(); + SellShares(corp, shares); dialogBoxCreate( <> - Sold {formatMoney(shares)} shares for - . The corporation's stock price fell to  - as a result of dilution. + + You sold {formatShares(shares)} shares for . + + + {corp.name}'s stock price fell to per share. + , ); - + props.onClose(); props.rerender(); + setShares(NaN); } catch (err) { - dialogBoxCreate(err + ""); + dialogBoxCreate(`${err as Error}`); } } @@ -74,32 +55,43 @@ export function SellSharesModal(props: IProps): React.ReactElement { return ( - - Enter the number of shares you would like to sell. The money from selling your shares will go directly to you - (NOT your Corporation). -
-
- The amount sold must be an integer between 1 and 100t. -
-
- Selling your shares will cause your corporation's stock price to fall due to dilution. Furthermore, selling a - large number of shares all at once will have an immediate effect in reducing your stock price. -
-
- The current price of your company's stock is {formatMoney(corp.sharePrice)} + + Enter the number of shares you would like to sell. +
    +
  • Selling shares will cause stock price to fall due to market forces.
  • +
  • The money from selling your shares will go directly to you (NOT your Corporation).
  • +
  • + You will not be able to sell shares again (or dissolve the corporation) for{" "} + {corp.convertCooldownToString(corpConstants.sellSharesCooldown)}. +
  • +
+ You currently have {formatShares(corp.numShares)} shares of {corp.name} stock, valued at{" "} + per share.

- - + +
+ + {!shares ? null : disabledText ? ( + disabledText + ) : ( + <> + You will receive . +
+ {corp.name}'s stock price will fall to per share. + + )} +
); } diff --git a/src/Corporation/ui/modals/SmartSupplyModal.tsx b/src/Corporation/ui/modals/SmartSupplyModal.tsx index 0c7869ce4..01f6b89c6 100644 --- a/src/Corporation/ui/modals/SmartSupplyModal.tsx +++ b/src/Corporation/ui/modals/SmartSupplyModal.tsx @@ -95,7 +95,7 @@ export function SmartSupplyModal(props: IProps): React.ReactElement { label={Enable Smart Supply} />
- + Options:
  • diff --git a/src/Corporation/ui/modals/ThrowPartyModal.tsx b/src/Corporation/ui/modals/ThrowPartyModal.tsx index 529f97140..ffa216ec8 100644 --- a/src/Corporation/ui/modals/ThrowPartyModal.tsx +++ b/src/Corporation/ui/modals/ThrowPartyModal.tsx @@ -3,7 +3,7 @@ import { formatMultiplier, formatPercent } from "../../../ui/formatNumber"; import { dialogBoxCreate } from "../../../ui/React/DialogBox"; import { OfficeSpace } from "../../OfficeSpace"; import { ThrowParty } from "../../Actions"; -import { Money } from "../../../ui/React/Money"; +import { MoneyCost } from "../MoneyCost"; import { Modal } from "../../../ui/React/Modal"; import { useCorporation } from "../Context"; import Typography from "@mui/material/Typography"; @@ -59,7 +59,7 @@ export function ThrowPartyModal(props: IProps): React.ReactElement { if (isNaN(cost) || cost < 0) return Invalid value entered!; return ( - Throwing this party will cost a total of + Throwing this party will cost a total of ); } diff --git a/src/DevMenu/ui/CorporationDev.tsx b/src/DevMenu/ui/CorporationDev.tsx index 0dd99f245..0245642d0 100644 --- a/src/DevMenu/ui/CorporationDev.tsx +++ b/src/DevMenu/ui/CorporationDev.tsx @@ -69,6 +69,12 @@ export function CorporationDev(): React.ReactElement { }); } + function resetCorporationCooldowns(): void { + if (!Player.corporation) return; + Player.corporation.shareSaleCooldown = 0; + Player.corporation.issueNewSharesCooldown = 0; + } + return ( }> @@ -117,6 +123,11 @@ export function CorporationDev(): React.ReactElement { + + + + + diff --git a/src/Faction/ui/FactionsRoot.tsx b/src/Faction/ui/FactionsRoot.tsx index 80aa8face..04e7dae76 100644 --- a/src/Faction/ui/FactionsRoot.tsx +++ b/src/Faction/ui/FactionsRoot.tsx @@ -130,7 +130,7 @@ const FactionElement = (props: FactionElementProps): React.ReactElement => { {!props.joined && facInfo.enemies.length > 0 && ( + This Faction is enemies with:
      {facInfo.enemies.map((enemy) => ( diff --git a/src/NetscriptFunctions/Corporation.ts b/src/NetscriptFunctions/Corporation.ts index 189c23026..8e509f4b2 100644 --- a/src/NetscriptFunctions/Corporation.ts +++ b/src/NetscriptFunctions/Corporation.ts @@ -13,7 +13,6 @@ import { Division as NSDivision, WarehouseAPI, OfficeAPI, - InvestmentOffer, CorpResearchName, CorpMaterialName, } from "@nsdefs"; @@ -22,7 +21,9 @@ import { NewDivision, purchaseOffice, IssueDividends, + GoPublic, IssueNewShares, + AcceptInvestmentOffer, SellMaterial, SellProduct, SetSmartSupply, @@ -104,63 +105,6 @@ export function NetscriptCorporation(): InternalAPI { return cost; } - function getInvestmentOffer(): InvestmentOffer { - const corporation = getCorporation(); - if ( - corporation.fundingRound >= corpConstants.fundingRoundShares.length || - corporation.fundingRound >= corpConstants.fundingRoundMultiplier.length || - corporation.public - ) - return { - funds: 0, - shares: 0, - round: corporation.fundingRound + 1, // Make more readable - }; // Don't throw an error here, no reason to have a second function to check if you can get investment. - const val = corporation.valuation; - const percShares = corpConstants.fundingRoundShares[corporation.fundingRound]; - const roundMultiplier = corpConstants.fundingRoundMultiplier[corporation.fundingRound]; - const funding = val * percShares * roundMultiplier; - const investShares = Math.floor(corpConstants.initialShares * percShares); - return { - funds: funding, - shares: investShares, - round: corporation.fundingRound + 1, // Make more readable - }; - } - - function acceptInvestmentOffer(): boolean { - const corporation = getCorporation(); - if ( - corporation.fundingRound >= corpConstants.fundingRoundShares.length || - corporation.fundingRound >= corpConstants.fundingRoundMultiplier.length || - corporation.public - ) - return false; - const val = corporation.valuation; - const percShares = corpConstants.fundingRoundShares[corporation.fundingRound]; - const roundMultiplier = corpConstants.fundingRoundMultiplier[corporation.fundingRound]; - const funding = val * percShares * roundMultiplier; - const investShares = Math.floor(corpConstants.initialShares * percShares); - corporation.fundingRound++; - corporation.addNonIncomeFunds(funding); - corporation.numShares -= investShares; - return true; - } - - function goPublic(numShares: number): boolean { - const corporation = getCorporation(); - const initialSharePrice = corporation.valuation / corporation.totalShares; - if (isNaN(numShares)) throw new Error("Invalid value for number of issued shares"); - if (numShares < 0) throw new Error("Invalid value for number of issued shares"); - if (numShares > corporation.numShares) throw new Error("You don't have that many shares to issue!"); - corporation.public = true; - corporation.sharePrice = initialSharePrice; - corporation.issuedShares = numShares; - corporation.numShares -= numShares; - corporation.addNonIncomeFunds(numShares * initialSharePrice); - return true; - } - function getResearchCost(division: Division, researchName: CorpResearchName): number { const researchTree = IndustryResearchTrees[division.type]; if (researchTree === undefined) throw new Error(`No research tree for industry '${division.type}'`); @@ -741,12 +685,6 @@ export function NetscriptCorporation(): InternalAPI { const maxNewShares = corporation.calculateMaxNewShares(); if (_amount == undefined) _amount = maxNewShares; const amount = helpers.number(ctx, "amount", _amount); - if (corporation.issueNewSharesCooldown > 0) throw new Error(`Can't issue new shares, action on cooldown.`); - if (amount < 10e6 || amount > maxNewShares) - throw new Error( - `Invalid value for amount field! Must be numeric, greater than 10m, and less than ${maxNewShares} (20% of total shares)`, - ); - if (!corporation.public) throw helpers.makeRuntimeErrorMsg(ctx, `Your company has not gone public!`); const [funds] = IssueNewShares(corporation, amount); return funds; }, @@ -768,6 +706,7 @@ export function NetscriptCorporation(): InternalAPI { totalShares: corporation.totalShares, numShares: corporation.numShares, shareSaleCooldown: corporation.shareSaleCooldown, + investorShares: corporation.investorShares, issuedShares: corporation.issuedShares, issueNewSharesCooldown: corporation.issueNewSharesCooldown, sharePrice: corporation.sharePrice, @@ -807,18 +746,26 @@ export function NetscriptCorporation(): InternalAPI { }, getInvestmentOffer: (ctx) => () => { checkAccess(ctx); - return getInvestmentOffer(); + const corporation = getCorporation(); + return corporation.getInvestmentOffer(); }, acceptInvestmentOffer: (ctx) => () => { checkAccess(ctx); - return acceptInvestmentOffer(); + const corporation = getCorporation(); + try { + AcceptInvestmentOffer(corporation); + return true; + } catch (err) { + return false; + } }, goPublic: (ctx) => (_numShares) => { checkAccess(ctx); const corporation = getCorporation(); if (corporation.public) throw helpers.makeRuntimeErrorMsg(ctx, "corporation is already public"); const numShares = helpers.number(ctx, "numShares", _numShares); - return goPublic(numShares); + GoPublic(corporation, numShares); + return true; }, sellShares: (ctx) => (_numShares) => { checkAccess(ctx); diff --git a/src/PersonObjects/Player/PlayerObjectCorporationMethods.ts b/src/PersonObjects/Player/PlayerObjectCorporationMethods.ts index 80fce4d98..a92c3f98c 100644 --- a/src/PersonObjects/Player/PlayerObjectCorporationMethods.ts +++ b/src/PersonObjects/Player/PlayerObjectCorporationMethods.ts @@ -21,5 +21,8 @@ export function startCorporation(this: PlayerObject, corpName: string, seedFunde this.corporation.unlocks.add(CorpUnlockName.OfficeAPI); } - this.corporation.totalShares += seedFunded ? 500_000_000 : 0; + if (seedFunded) { + this.corporation.investorShares += 500e6; + this.corporation.totalShares += 500e6; + } } diff --git a/src/ScriptEditor/NetscriptDefinitions.d.ts b/src/ScriptEditor/NetscriptDefinitions.d.ts index e3be7de4e..213c7033c 100644 --- a/src/ScriptEditor/NetscriptDefinitions.d.ts +++ b/src/ScriptEditor/NetscriptDefinitions.d.ts @@ -7437,11 +7437,13 @@ export interface Corporation extends WarehouseAPI, OfficeAPI { * @returns Amount of funds generated for the corporation. */ issueNewShares(amount?: number): number; - /** Buyback Shares + /** Buyback Shares. + * Spend money from the player's wallet to transfer shares from public traders to the CEO. * @param amount - Amount of shares to buy back, must be integer and larger than 0 */ buyBackShares(amount: number): void; - /** Sell Shares + /** Sell Shares. + * Transfer shares from the CEO to public traders to receive money in the player's wallet. * @param amount - Amount of shares to sell, must be integer between 1 and 100t */ sellShares(amount: number): void; @@ -7515,13 +7517,15 @@ interface CorporationInfo { expenses: number; /** Indicating if the company is public */ public: boolean; - /** Total number of shares issues by this corporation */ + /** Total number of shares issued by this corporation. */ totalShares: number; - /** Amount of share owned */ + /** Amount of shares owned by the CEO. */ numShares: number; /** Cooldown until shares can be sold again */ shareSaleCooldown: number; - /** Amount of acquirable shares. */ + /** Amount of shares owned by private investors. Not available for public sale or CEO buyback. */ + investorShares: number; + /** Amount of shares owned by public traders. Available for CEO buyback. */ issuedShares: number; /** Cooldown until new shares can be issued */ issueNewSharesCooldown: number; diff --git a/test/jest/Corporation.test.ts b/test/jest/Corporation.test.ts index 999d31eb0..66cc9369a 100644 --- a/test/jest/Corporation.test.ts +++ b/test/jest/Corporation.test.ts @@ -2,11 +2,22 @@ import { PositiveInteger } from "../../src/types"; import { Corporation } from "../../src/Corporation/Corporation"; import { CorpUpgrades } from "../../src/Corporation/data/CorporationUpgrades"; import { calculateMaxAffordableUpgrade, calculateUpgradeCost } from "../../src/Corporation/helpers"; +import { Player, setPlayer } from "../../src/Player"; +import { PlayerObject } from "../../src/PersonObjects/Player/PlayerObject"; +import { + AcceptInvestmentOffer, + BuyBackShares, + GoPublic, + IssueNewShares, + SellShares, +} from "../../src/Corporation/Actions"; describe("Corporation", () => { let corporation: Corporation; beforeEach(() => { + setPlayer(new PlayerObject()); + Player.init(); corporation = new Corporation({ name: "Test" }); }); @@ -57,4 +68,48 @@ describe("Corporation", () => { } }); }); + + describe("Corporation totalShares", () => { + function expectSharesToAddUp(corp: Corporation) { + expect(corp.totalShares).toEqual(corp.numShares + corp.investorShares + corp.issuedShares); + } + + it("should equal the sum of each kind of shares", () => { + expectSharesToAddUp(corporation); + }); + it("should be preserved by seed funding", () => { + const seedFunded = true; + Player.startCorporation("TestCorp", seedFunded); + expectSharesToAddUp(Player.corporation!); + }); + it("should be preserved by acceptInvestmentOffer", () => { + AcceptInvestmentOffer(corporation); + expectSharesToAddUp(corporation); + }); + it("should be preserved by goPublic", () => { + const numShares = 1e8; + GoPublic(corporation, numShares); + expectSharesToAddUp(corporation); + }); + it("should be preserved by IssueNewShares", () => { + const numShares = 1e8; + GoPublic(corporation, numShares); + corporation.issueNewSharesCooldown = 0; + IssueNewShares(corporation, numShares); + expectSharesToAddUp(corporation); + }); + it("should be preserved by BuyBackShares", () => { + const numShares = 1e8; + GoPublic(corporation, numShares); + BuyBackShares(corporation, numShares); + expectSharesToAddUp(corporation); + }); + it("should be preserved by SellShares", () => { + const numShares = 1e8; + GoPublic(corporation, numShares); + corporation.shareSaleCooldown = 0; + SellShares(corporation, numShares); + expectSharesToAddUp(corporation); + }); + }); }); diff --git a/test/jest/__snapshots__/FullSave.test.ts.snap b/test/jest/__snapshots__/FullSave.test.ts.snap index d58fcf253..8e4c78a7f 100644 --- a/test/jest/__snapshots__/FullSave.test.ts.snap +++ b/test/jest/__snapshots__/FullSave.test.ts.snap @@ -1150,6 +1150,7 @@ exports[`Check Save File Continuity PlayerSave continuity 1`] = ` "expenses": 0, "fundingRound": 0, "funds": 150000000000, + "investorShares": 0, "issueNewSharesCooldown": 0, "issuedShares": 0, "maxDivisions": 20,