mirror of
https://github.com/bitburner-official/bitburner-src.git
synced 2025-03-07 11:04:36 +01:00
Bug fixes for v0.47.0. Fixed the BUY MAX feature for new stock market. Added collapse/expand tickers buttons for new stock market UI
This commit is contained in:
@ -42,6 +42,7 @@
|
||||
}
|
||||
|
||||
#stock-market-watchlist-filter {
|
||||
display: block;
|
||||
margin: 5px 5px 5px 10px;
|
||||
padding: 4px;
|
||||
width: 50%;
|
||||
|
@ -51,7 +51,7 @@ bid price. Note that this is reversed for a short position. Purchasing a stock
|
||||
in the short position will occur at the stock's bid price, and selling a stock
|
||||
in the short position will occur at the stock's ask price.
|
||||
|
||||
.. _gameplay_stock_spread_price_movement:
|
||||
.. _gameplay_stock_market_spread_price_movement:
|
||||
|
||||
Transactions Influencing Stock Price
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
@ -73,6 +73,8 @@ be sold at $98.01, and so on.
|
||||
This is an important concept to keep in mind if you are trying to purchase/sell a
|
||||
large number of shares, as **it can negatively affect your profits**.
|
||||
|
||||
.. _gameplay_stock_market_order_types:
|
||||
|
||||
Order Types
|
||||
^^^^^^^^^^^
|
||||
There are three different types of orders you can make to buy or sell stocks on the exchange:
|
||||
|
@ -61,5 +61,5 @@ getInformation() Netscript Function
|
||||
workChaExpGain: charisma exp gained from work,
|
||||
workMoneyGain: money gained from work,
|
||||
},
|
||||
workRepGain: sl.getRepGain(),
|
||||
workRepGain: Reputation gain rate when working for factions or companies
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
getSleeveAugmentations() Netscript Function
|
||||
=======================================
|
||||
===========================================
|
||||
|
||||
.. js:function:: getSleeveAugmentations(sleeveNumber)
|
||||
|
||||
|
@ -6,7 +6,11 @@ getOrders() Netscript Function
|
||||
:RAM cost: 2.5 GB
|
||||
|
||||
Returns your order book for the stock market. This is an object containing information
|
||||
for all the Limit and Stop Orders you have in the stock market.
|
||||
for all the :ref:`Limit and Stop Orders <gameplay_stock_market_order_types>`
|
||||
you have in the stock market.
|
||||
|
||||
.. note:: This function isn't accessible until you have unlocked the ability to use
|
||||
Limit and Stop Orders.
|
||||
|
||||
The object has the following structure::
|
||||
|
||||
|
@ -11,5 +11,5 @@ getStockPurchaseCost() Netscript Function
|
||||
|
||||
Calculates and returns how much it would cost to buy a given number of
|
||||
shares of a stock. This takes into account :ref:`spread <gameplay_stock_market_spread>`,
|
||||
:ref:`large transactions influencing the price of the stock <gameplay_stock_spread_price_movement>`
|
||||
:ref:`large transactions influencing the price of the stock <gameplay_stock_market_spread_price_movement>`
|
||||
and commission fees.
|
||||
|
@ -11,5 +11,5 @@ getStockSaleGain() Netscript Function
|
||||
|
||||
Calculates and returns how much you would gain from selling a given number of
|
||||
shares of a stock. This takes into account :ref:`spread <gameplay_stock_market_spread>`,
|
||||
:ref:`large transactions influencing the price of the stock <gameplay_stock_spread_price_movement>`
|
||||
:ref:`large transactions influencing the price of the stock <gameplay_stock_market_spread_price_movement>`
|
||||
and commission fees.
|
||||
|
@ -19,8 +19,10 @@ placeOrder() Netscript Function
|
||||
NOT case-sensitive.
|
||||
:RAM cost: 2.5 GB
|
||||
|
||||
Places an order on the stock market. This function only works for `Limit and Stop Orders <http://bitburner.wikia.com/wiki/Stock_Market#Order_Types>`_.
|
||||
|
||||
The ability to place limit and stop orders is **not** immediately available to the player and must be unlocked later on in the game.
|
||||
Places an order on the stock market. This function only works
|
||||
for :ref:`Limit and Stop Orders <gameplay_stock_market_order_types>`.
|
||||
|
||||
Returns true if the order is successfully placed, and false otherwise.
|
||||
|
||||
.. note:: The ability to place limit and stop orders is **not** immediately available to
|
||||
the player and must be unlocked later on in the game.
|
||||
|
@ -7,6 +7,7 @@ import { Factions } from "./Faction/Factions";
|
||||
import { Player } from "./Player";
|
||||
import { AllServers } from "./Server/AllServers";
|
||||
import { GetServerByHostname } from "./Server/ServerHelpers";
|
||||
import { SpecialServerNames } from "./Server/SpecialServerIps";
|
||||
|
||||
import { getRandomInt } from "../utils/helpers/getRandomInt";
|
||||
|
||||
@ -162,7 +163,9 @@ function getRandomServer() {
|
||||
// An infinite loop shouldn't ever happen, but to be safe we'll use
|
||||
// a for loop with a limited number of tries
|
||||
for (let i = 0; i < 200; ++i) {
|
||||
if (randServer.purchasedByPlayer === false) { break; }
|
||||
if (!randServer.purchasedByPlayer && randServer.hostname !== SpecialServerNames.WorldDaemon) {
|
||||
break;
|
||||
}
|
||||
randIndex = getRandomInt(0, servers.length - 1);
|
||||
randServer = AllServers[servers[randIndex]];
|
||||
}
|
||||
|
@ -284,6 +284,11 @@ export let CONSTANTS: IMap<any> = {
|
||||
** Added getStockAskPrice(), getStockBidPrice() Netscript functions to the TIX API
|
||||
** Added getStockPurchaseCost(), getStockSaleGain() Netscript functions to the TIX API
|
||||
|
||||
|
||||
* Bug Fix: Fixed sleeve.getInformation() throwing error in certain scenarios
|
||||
* Bug Fix: Coding contracts should no longer generate on the w0r1d_d43m0n server
|
||||
* Bug Fix: Duplicate Sleeves now properly have access to all Augmentations if you have a gang
|
||||
* Bug Fix: Fixed issue that caused messages (.msg) to be sent when refreshing/reloading the game
|
||||
* Bug Fix: Purchasing hash upgrades for Bladeburner/Corporation when you don't actually have access to those mechanics no longer gives hashes
|
||||
* Bug Fix: run(), exec(), and spawn() Netscript functions now throw if called with 0 threads
|
||||
`
|
||||
}
|
||||
|
@ -99,14 +99,16 @@ export class HashManager {
|
||||
*/
|
||||
refundUpgrade(upgName: string): void {
|
||||
const upg = HashUpgrades[upgName];
|
||||
|
||||
// Reduce the level first, so we get the right cost
|
||||
--this.upgrades[upgName];
|
||||
|
||||
const currLevel = this.upgrades[upgName];
|
||||
if (upg == null || currLevel == null || currLevel === 0) {
|
||||
if (upg == null || currLevel == null || currLevel < 0) {
|
||||
console.error(`Invalid Upgrade Name given to HashManager.upgrade(): ${upgName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reduce the level first, so we get the right cost
|
||||
--this.upgrades[upgName];
|
||||
const cost = upg.getCost(currLevel);
|
||||
this.hashes += cost;
|
||||
}
|
||||
|
@ -1,15 +1,15 @@
|
||||
import { Message } from "./Message";
|
||||
import { Augmentatation } from "../Augmentation/Augmentation";
|
||||
import { Augmentations } from "../Augmentation/Augmentations";
|
||||
import { AugmentationNames } from "../Augmentation/data/AugmentationNames";
|
||||
import { Programs } from "../Programs/Programs";
|
||||
import { inMission } from "../Missions";
|
||||
import { Player } from "../Player";
|
||||
import { redPillFlag } from "../RedPill";
|
||||
import { GetServerByHostname } from "../Server/ServerHelpers";
|
||||
import { Settings } from "../Settings/Settings";
|
||||
import { dialogBoxCreate,
|
||||
dialogBoxOpened} from "../../utils/DialogBox";
|
||||
import { Message } from "./Message";
|
||||
import { Augmentatation } from "../Augmentation/Augmentation";
|
||||
import { Augmentations } from "../Augmentation/Augmentations";
|
||||
import { AugmentationNames } from "../Augmentation/data/AugmentationNames";
|
||||
import { Programs } from "../Programs/Programs";
|
||||
import { inMission } from "../Missions";
|
||||
import { Player } from "../Player";
|
||||
import { redPillFlag } from "../RedPill";
|
||||
import { GetServerByHostname } from "../Server/ServerHelpers";
|
||||
import { Settings } from "../Settings/Settings";
|
||||
import { dialogBoxCreate, dialogBoxOpened} from "../../utils/DialogBox";
|
||||
import { Reviver } from "../../utils/JSONReviver";
|
||||
|
||||
//Sends message to player, including a pop up
|
||||
function sendMessage(msg, forced=false) {
|
||||
@ -31,7 +31,7 @@ function showMessage(msg) {
|
||||
function addMessageToServer(msg, serverHostname) {
|
||||
var server = GetServerByHostname(serverHostname);
|
||||
if (server == null) {
|
||||
console.log("WARNING: Did not locate " + serverHostname);
|
||||
console.warn(`Could not find server ${serverHostname}`);
|
||||
return;
|
||||
}
|
||||
for (var i = 0; i < server.messages.length; ++i) {
|
||||
|
@ -117,7 +117,7 @@ import {
|
||||
} from "./NetscriptEvaluator";
|
||||
import { NetscriptPort } from "./NetscriptPort";
|
||||
import { SleeveTaskType } from "./PersonObjects/Sleeve/SleeveTaskTypesEnum";
|
||||
import { findSleevePurchasableAugs } from "./PersonObjects/Sleeve/Sleeve";
|
||||
import { findSleevePurchasableAugs } from "./PersonObjects/Sleeve/SleeveHelpers";
|
||||
|
||||
import { Page, routing } from "./ui/navigationTracking";
|
||||
import { numeralWrapper } from "./ui/numeralFormat";
|
||||
@ -288,6 +288,9 @@ function NetscriptFunctions(workerScript) {
|
||||
* Checks if the player has TIX API access. Throws an error if the player does not
|
||||
*/
|
||||
const checkTixApiAccess = function(callingFn="") {
|
||||
if (!Player.hasWseAccount) {
|
||||
throw makeRuntimeRejectMsg(workerScript, `You don't have WSE Access! Cannot use ${callingFn}()`);
|
||||
}
|
||||
if (!Player.hasTixApiAccess) {
|
||||
throw makeRuntimeRejectMsg(workerScript, `You don't have TIX API Access! Cannot use ${callingFn}()`);
|
||||
}
|
||||
@ -977,7 +980,7 @@ function NetscriptFunctions(workerScript) {
|
||||
if (scriptname === undefined || ip === undefined) {
|
||||
throw makeRuntimeRejectMsg(workerScript, "exec() call has incorrect number of arguments. Usage: exec(scriptname, server, [numThreads], [arg1], [arg2]...)");
|
||||
}
|
||||
if (isNaN(threads) || threads < 0) {
|
||||
if (isNaN(threads) || threads <= 0) {
|
||||
throw makeRuntimeRejectMsg(workerScript, "Invalid argument for thread count passed into exec(). Must be numeric and greater than 0");
|
||||
}
|
||||
var argsForNewScript = [];
|
||||
@ -1002,7 +1005,7 @@ function NetscriptFunctions(workerScript) {
|
||||
if (scriptname === undefined) {
|
||||
throw makeRuntimeRejectMsg(workerScript, "spawn() call has incorrect number of arguments. Usage: spawn(scriptname, numThreads, [arg1], [arg2]...)");
|
||||
}
|
||||
if (isNaN(threads) || threads < 0) {
|
||||
if (isNaN(threads) || threads <= 0) {
|
||||
throw makeRuntimeRejectMsg(workerScript, "Invalid argument for thread count passed into run(). Must be numeric and greater than 0");
|
||||
}
|
||||
var argsForNewScript = [];
|
||||
@ -1858,9 +1861,7 @@ function NetscriptFunctions(workerScript) {
|
||||
return updateStaticRam("getOrders", CONSTANTS.ScriptBuySellStockRamCost);
|
||||
}
|
||||
updateDynamicRam("getOrders", CONSTANTS.ScriptBuySellStockRamCost);
|
||||
if (!Player.hasTixApiAccess) {
|
||||
throw makeRuntimeRejectMsg(workerScript, "You don't have TIX API Access! Cannot use getOrders()");
|
||||
}
|
||||
checkTixApiAccess("getOrders");
|
||||
if (Player.bitNodeN !== 8) {
|
||||
if (!(hasWallStreetSF && wallStreetSFLvl >= 3)) {
|
||||
throw makeRuntimeRejectMsg(workerScript, "ERROR: Cannot use getOrders(). You must either be in BitNode-8 or have Level 3 of Source-File 8");
|
||||
@ -5078,7 +5079,7 @@ function NetscriptFunctions(workerScript) {
|
||||
workChaExpGain: sl.earningsForTask.cha,
|
||||
workMoneyGain: sl.earningsForTask.money,
|
||||
},
|
||||
workRepGain: sl.getRepGain(),
|
||||
workRepGain: sl.getRepGain(Player),
|
||||
}
|
||||
},
|
||||
getSleeveAugmentations : function(sleeveNumber=0) {
|
||||
|
@ -129,6 +129,7 @@ export interface IPlayer {
|
||||
gainCharismaExp(exp: number): void;
|
||||
gainMoney(money: number): void;
|
||||
getCurrentServer(): Server;
|
||||
getGangFaction(): Faction;
|
||||
getGangName(): string;
|
||||
getHomeComputer(): Server;
|
||||
getNextCompanyPosition(company: Company, entryPosType: CompanyPosition): CompanyPosition;
|
||||
|
@ -13,6 +13,15 @@ export function canAccessGang() {
|
||||
return (this.karma <= GangKarmaRequirement);
|
||||
}
|
||||
|
||||
export function getGangFaction() {
|
||||
const fac = Factions[this.gang.facName];
|
||||
if (fac == null) {
|
||||
throw new Error(`Gang has invalid faction name: ${this.gang.facName}`);
|
||||
}
|
||||
|
||||
return fac;
|
||||
}
|
||||
|
||||
export function getGangName() {
|
||||
return this.gang.facName;
|
||||
}
|
||||
|
@ -884,32 +884,4 @@ export class Sleeve extends Person {
|
||||
}
|
||||
}
|
||||
|
||||
export function findSleevePurchasableAugs(sleeve: Sleeve, p: IPlayer): Augmentation[] {
|
||||
// You can only purchase Augmentations that are actually available from
|
||||
// your factions. I.e. you must be in a faction that has the Augmentation
|
||||
// and you must also have enough rep in that faction in order to purchase it.
|
||||
|
||||
const ownedAugNames: string[] = sleeve.augmentations.map((e) => {return e.name});
|
||||
const availableAugs: Augmentation[] = [];
|
||||
|
||||
for (const facName of p.factions) {
|
||||
if (facName === "Bladeburners") { continue; }
|
||||
if (facName === "Netburners") { continue; }
|
||||
const fac: Faction | null = Factions[facName];
|
||||
if (fac == null) { continue; }
|
||||
|
||||
for (const augName of fac.augmentations) {
|
||||
if (augName === AugmentationNames.NeuroFluxGovernor) { continue; }
|
||||
if (ownedAugNames.includes(augName)) { continue; }
|
||||
const aug: Augmentation | null = Augmentations[augName];
|
||||
|
||||
if (fac.playerReputation > aug.baseRepRequirement && !availableAugs.includes(aug)) {
|
||||
availableAugs.push(aug);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return availableAugs;
|
||||
}
|
||||
|
||||
Reviver.constructors.Sleeve = Sleeve;
|
||||
|
@ -2,16 +2,13 @@
|
||||
* Module for handling the UI for purchasing Sleeve Augmentations
|
||||
* This UI is a popup, not a full page
|
||||
*/
|
||||
import { Sleeve, findSleevePurchasableAugs } from "./Sleeve";
|
||||
import { Sleeve } from "./Sleeve";
|
||||
import { findSleevePurchasableAugs } from "./SleeveHelpers";
|
||||
|
||||
import { IPlayer } from "../IPlayer";
|
||||
|
||||
import { Augmentation } from "../../Augmentation/Augmentation";
|
||||
import { Augmentations } from "../../Augmentation/Augmentations";
|
||||
import { AugmentationNames } from "../../Augmentation/data/AugmentationNames";
|
||||
|
||||
import { Faction } from "../../Faction/Faction";
|
||||
import { Factions } from "../../Faction/Factions";
|
||||
|
||||
import { numeralWrapper } from "../../ui/numeralFormat";
|
||||
|
||||
|
64
src/PersonObjects/Sleeve/SleeveHelpers.ts
Normal file
64
src/PersonObjects/Sleeve/SleeveHelpers.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { Sleeve } from "./Sleeve";
|
||||
|
||||
import { IPlayer } from "../IPlayer";
|
||||
|
||||
import { Augmentation } from "../../Augmentation/Augmentation";
|
||||
import { Augmentations } from "../../Augmentation/Augmentations";
|
||||
import { AugmentationNames } from "../../Augmentation/data/AugmentationNames";
|
||||
import { Faction } from "../../Faction/Faction";
|
||||
import { Factions } from "../../Faction/Factions";
|
||||
|
||||
export function findSleevePurchasableAugs(sleeve: Sleeve, p: IPlayer): Augmentation[] {
|
||||
// You can only purchase Augmentations that are actually available from
|
||||
// your factions. I.e. you must be in a faction that has the Augmentation
|
||||
// and you must also have enough rep in that faction in order to purchase it.
|
||||
|
||||
const ownedAugNames: string[] = sleeve.augmentations.map((e) => {return e.name});
|
||||
const availableAugs: Augmentation[] = [];
|
||||
|
||||
// Helper function that helps filter out augs that are already owned
|
||||
// and augs that aren't allowed for sleeves
|
||||
function isAvailableForSleeve(aug: Augmentation): boolean {
|
||||
if (aug.name === AugmentationNames.NeuroFluxGovernor) { return false; }
|
||||
if (ownedAugNames.includes(aug.name)) { return false; }
|
||||
if (availableAugs.includes(aug)) { return false; }
|
||||
if (aug.isSpecial) { return false; }
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// If player is in a gang, then we return all augs that the player
|
||||
// has enough reputation for (since that gang offers all augs)
|
||||
if (p.inGang()) {
|
||||
const fac = p.getGangFaction();
|
||||
|
||||
for (const augName in Augmentations) {
|
||||
const aug = Augmentations[augName];
|
||||
if (!isAvailableForSleeve(aug)) { continue; }
|
||||
|
||||
if (fac.playerReputation > aug.baseRepRequirement) {
|
||||
availableAugs.push(aug);
|
||||
}
|
||||
}
|
||||
|
||||
return availableAugs;
|
||||
}
|
||||
|
||||
for (const facName of p.factions) {
|
||||
if (facName === "Bladeburners") { continue; }
|
||||
if (facName === "Netburners") { continue; }
|
||||
const fac: Faction | null = Factions[facName];
|
||||
if (fac == null) { continue; }
|
||||
|
||||
for (const augName of fac.augmentations) {
|
||||
const aug: Augmentation = Augmentations[augName];
|
||||
if (!isAvailableForSleeve(aug)) { continue; }
|
||||
|
||||
if (fac.playerReputation > aug.baseRepRequirement) {
|
||||
availableAugs.push(aug);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return availableAugs;
|
||||
}
|
@ -45,6 +45,7 @@ import {
|
||||
SpecialServerNames
|
||||
} from "./Server/SpecialServerIps";
|
||||
import {
|
||||
deleteStockMarket,
|
||||
initStockMarket,
|
||||
initSymbolToStockMap,
|
||||
} from "./StockMarket/StockMarket";
|
||||
@ -326,6 +327,8 @@ function prestigeSourceFile() {
|
||||
if (Player.hasWseAccount) {
|
||||
initStockMarket();
|
||||
initSymbolToStockMap();
|
||||
} else {
|
||||
deleteStockMarket();
|
||||
}
|
||||
|
||||
if (Player.inGang()) { Player.gang.clearUI(); }
|
||||
|
@ -204,27 +204,33 @@ function loadGame(saveString) {
|
||||
try {
|
||||
loadAliases(saveObj.AliasesSave);
|
||||
} catch(e) {
|
||||
console.warn(`Could not load Aliases from save`);
|
||||
loadAliases("");
|
||||
}
|
||||
} else {
|
||||
console.warn(`Save file did not contain an Aliases property`);
|
||||
loadAliases("");
|
||||
}
|
||||
if (saveObj.hasOwnProperty("GlobalAliasesSave")) {
|
||||
try {
|
||||
loadGlobalAliases(saveObj.GlobalAliasesSave);
|
||||
} catch(e) {
|
||||
console.warn(`Could not load GlobalAliases from save`);
|
||||
loadGlobalAliases("");
|
||||
}
|
||||
} else {
|
||||
console.warn(`Save file did not contain a GlobalAliases property`);
|
||||
loadGlobalAliases("");
|
||||
}
|
||||
if (saveObj.hasOwnProperty("MessagesSave")) {
|
||||
try {
|
||||
loadMessages(saveObj.MessagesSave);
|
||||
} catch(e) {
|
||||
console.warn(`Could not load Messages from save`);
|
||||
initMessages();
|
||||
}
|
||||
} else {
|
||||
console.warn(`Save file did not contain a Messages property`);
|
||||
initMessages();
|
||||
}
|
||||
if (saveObj.hasOwnProperty("StockMarketSave")) {
|
||||
|
@ -95,6 +95,7 @@ let NetscriptFunctions =
|
||||
|
||||
// TIX API
|
||||
"getStockPrice|getStockPosition|getStockSymbols|getStockMaxShares|" +
|
||||
"getStockAskPrice|getStockBidPrice|getStockPurchaseCost|getStockSaleGain|" +
|
||||
"buyStock|sellStock|shortStock|sellShort|" +
|
||||
"placeOrder|cancelOrder|getOrders|getStockVolatility|getStockForecast|" +
|
||||
"purchase4SMarketData|purchase4SMarketDataTixApi|" +
|
||||
|
@ -153,9 +153,13 @@ CodeMirror.defineMode("netscript", function(config, parserConfig) {
|
||||
|
||||
// Netscript TIX API
|
||||
"getStockPrice": atom,
|
||||
"getStockAskPrice": atom,
|
||||
"getStockBidPrice": atom,
|
||||
"getStockPosition": atom,
|
||||
"getStockSymbols": atom,
|
||||
"getStockMaxShares": atom,
|
||||
"getStockPurchaseCost": atom,
|
||||
"getStockSaleGain": atom,
|
||||
"buyStock": atom,
|
||||
"sellStock": atom,
|
||||
"shortStock": atom,
|
||||
|
@ -154,6 +154,10 @@ export function loadStockMarket(saveString) {
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteStockMarket() {
|
||||
StockMarket = {};
|
||||
}
|
||||
|
||||
export function initStockMarket() {
|
||||
for (const stk in StockMarket) {
|
||||
if (StockMarket.hasOwnProperty(stk)) {
|
||||
@ -195,15 +199,13 @@ export function initSymbolToStockMap() {
|
||||
}
|
||||
|
||||
export function stockMarketCycle() {
|
||||
for (var name in StockMarket) {
|
||||
if (StockMarket.hasOwnProperty(name)) {
|
||||
var stock = StockMarket[name];
|
||||
if (!(stock instanceof Stock)) {continue;}
|
||||
var thresh = 0.6;
|
||||
if (stock.b) {thresh = 0.4;}
|
||||
if (Math.random() < thresh) {
|
||||
stock.b = !stock.b;
|
||||
}
|
||||
for (const name in StockMarket) {
|
||||
const stock = StockMarket[name];
|
||||
if (!(stock instanceof Stock)) { continue; }
|
||||
let thresh = 0.6;
|
||||
if (stock.b) { thresh = 0.4; }
|
||||
if (Math.random() < thresh) {
|
||||
stock.b = !stock.b;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -235,9 +237,9 @@ export function buyStock(stock, shares, workerScript=null) {
|
||||
const totalPrice = getBuyTransactionCost(stock, shares, PositionTypes.Long);
|
||||
if (Player.money.lt(totalPrice)) {
|
||||
if (tixApi) {
|
||||
workerScript.log(`ERROR: buyStock() failed because you do not have enough money to purchase this potiion. You need ${numeralWrapper.formatMoney(totalPrice + CONSTANTS.StockMarketCommission)}`);
|
||||
workerScript.log(`ERROR: buyStock() failed because you do not have enough money to purchase this potiion. You need ${numeralWrapper.formatMoney(totalPrice)}`);
|
||||
} else {
|
||||
dialogBoxCreate(`You do not have enough money to purchase this. You need ${numeralWrapper.formatMoney(totalPrice + CONSTANTS.StockMarketCommission)}`);
|
||||
dialogBoxCreate(`You do not have enough money to purchase this. You need ${numeralWrapper.formatMoney(totalPrice)}`);
|
||||
}
|
||||
|
||||
return false;
|
||||
@ -256,7 +258,7 @@ export function buyStock(stock, shares, workerScript=null) {
|
||||
|
||||
const origTotal = stock.playerShares * stock.playerAvgPx;
|
||||
Player.loseMoney(totalPrice);
|
||||
const newTotal = origTotal + totalPrice;
|
||||
const newTotal = origTotal + totalPrice - CONSTANTS.StockMarketCommission;
|
||||
stock.playerShares = Math.round(stock.playerShares + shares);
|
||||
stock.playerAvgPx = newTotal / stock.playerShares;
|
||||
processBuyTransactionPriceMovement(stock, shares, PositionTypes.Long);
|
||||
@ -317,7 +319,7 @@ export function sellStock(stock, shares, workerScript=null) {
|
||||
const resultTxt = `Sold ${numeralWrapper.format(shares, '0,0')} shares of ${stock.symbol}. ` +
|
||||
`After commissions, you gained a total of ${numeralWrapper.formatMoney(gains)}.`;
|
||||
if (tixApi) {
|
||||
if (workerScript.shouldLog("sellStock")) { workerScript.log(resultTxt); }
|
||||
if (workerScript.shouldLog("sellStock")) { workerScript.log(resultTxt); }
|
||||
} else {
|
||||
dialogBoxCreate(resultTxt);
|
||||
}
|
||||
@ -350,14 +352,14 @@ export function shortStock(stock, shares, workerScript=null) {
|
||||
|
||||
// Does the player have enough money?
|
||||
const totalPrice = getBuyTransactionCost(stock, shares, PositionTypes.Short);
|
||||
if (Player.money.lt(totalPrice + CONSTANTS.StockMarketCommission)) {
|
||||
if (Player.money.lt(totalPrice)) {
|
||||
if (tixApi) {
|
||||
workerScript.log("ERROR: shortStock() failed because you do not have enough " +
|
||||
"money to purchase this short position. You need " +
|
||||
numeralWrapper.formatMoney(totalPrice + CONSTANTS.StockMarketCommission));
|
||||
numeralWrapper.formatMoney(totalPrice));
|
||||
} else {
|
||||
dialogBoxCreate("You do not have enough money to purchase this short position. You need " +
|
||||
numeralWrapper.formatMoney(totalPrice + CONSTANTS.StockMarketCommission));
|
||||
numeralWrapper.formatMoney(totalPrice));
|
||||
}
|
||||
|
||||
return false;
|
||||
@ -375,8 +377,8 @@ export function shortStock(stock, shares, workerScript=null) {
|
||||
}
|
||||
|
||||
const origTotal = stock.playerShortShares * stock.playerAvgShortPx;
|
||||
Player.loseMoney(totalPrice + CONSTANTS.StockMarketCommission);
|
||||
const newTotal = origTotal + totalPrice;
|
||||
Player.loseMoney(totalPrice);
|
||||
const newTotal = origTotal + totalPrice - CONSTANTS.StockMarketCommission;
|
||||
stock.playerShortShares = Math.round(stock.playerShortShares + shares);
|
||||
stock.playerAvgShortPx = newTotal / stock.playerShortShares;
|
||||
processBuyTransactionPriceMovement(stock, shares, PositionTypes.Short);
|
||||
@ -454,16 +456,17 @@ export function sellShort(stock, shares, workerScript=null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Stock prices updated every 6 seconds
|
||||
const msPerStockUpdate = 6e3;
|
||||
const cyclesPerStockUpdate = msPerStockUpdate / CONSTANTS.MilliPerCycle;
|
||||
export function processStockPrices(numCycles=1) {
|
||||
if (StockMarket.storedCycles == null || isNaN(StockMarket.storedCycles)) { StockMarket.storedCycles = 0; }
|
||||
StockMarket.storedCycles += numCycles;
|
||||
|
||||
// Stock Prices updated every 6 seconds on average. But if there are stored
|
||||
// cycles they update 50% faster, so every 4 seconds
|
||||
const msPerStockUpdate = 6e3;
|
||||
const cyclesPerStockUpdate = msPerStockUpdate / CONSTANTS.MilliPerCycle;
|
||||
if (StockMarket.storedCycles < cyclesPerStockUpdate) { return; }
|
||||
|
||||
// We can process the update every 4 seconds as long as there are enough
|
||||
// stored cycles. This lets us account for offline time
|
||||
const timeNow = new Date().getTime();
|
||||
if (timeNow - StockMarket.lastUpdate < 4e3) { return; }
|
||||
|
||||
@ -471,62 +474,62 @@ export function processStockPrices(numCycles=1) {
|
||||
StockMarket.storedCycles -= cyclesPerStockUpdate;
|
||||
|
||||
var v = Math.random();
|
||||
for (var name in StockMarket) {
|
||||
if (StockMarket.hasOwnProperty(name)) {
|
||||
var stock = StockMarket[name];
|
||||
if (!(stock instanceof Stock)) { continue; }
|
||||
var av = (v * stock.mv) / 100;
|
||||
if (isNaN(av)) {av = .02;}
|
||||
for (const name in StockMarket) {
|
||||
const stock = StockMarket[name];
|
||||
if (!(stock instanceof Stock)) { continue; }
|
||||
let av = (v * stock.mv) / 100;
|
||||
if (isNaN(av)) { av = .02; }
|
||||
|
||||
var chc = 50;
|
||||
if (stock.b) {
|
||||
chc = (chc + stock.otlkMag) / 100;
|
||||
if (isNaN(chc)) {chc = 0.5;}
|
||||
} else {
|
||||
chc = (chc - stock.otlkMag) / 100;
|
||||
if (isNaN(chc)) {chc = 0.5;}
|
||||
}
|
||||
if (stock.price >= stock.cap) {
|
||||
chc = 0.1; // "Soft Limit" on stock price. It could still go up but its unlikely
|
||||
stock.b = false;
|
||||
}
|
||||
|
||||
var c = Math.random();
|
||||
if (c < chc) {
|
||||
stock.price *= (1 + av);
|
||||
processOrders(stock, OrderTypes.LimitBuy, PositionTypes.Short);
|
||||
processOrders(stock, OrderTypes.LimitSell, PositionTypes.Long);
|
||||
processOrders(stock, OrderTypes.StopBuy, PositionTypes.Long);
|
||||
processOrders(stock, OrderTypes.StopSell, PositionTypes.Short);
|
||||
} else {
|
||||
stock.price /= (1 + av);
|
||||
processOrders(stock, OrderTypes.LimitBuy, PositionTypes.Long);
|
||||
processOrders(stock, OrderTypes.LimitSell, PositionTypes.Short);
|
||||
processOrders(stock, OrderTypes.StopBuy, PositionTypes.Short);
|
||||
processOrders(stock, OrderTypes.StopSell, PositionTypes.Long);
|
||||
}
|
||||
|
||||
var otlkMagChange = stock.otlkMag * av;
|
||||
if (stock.otlkMag <= 0.1) {
|
||||
otlkMagChange = 1;
|
||||
}
|
||||
if (c < 0.5) {
|
||||
stock.otlkMag += otlkMagChange;
|
||||
} else {
|
||||
stock.otlkMag -= otlkMagChange;
|
||||
}
|
||||
if (stock.otlkMag > 50) { stock.otlkMag = 50; } // Cap so the "forecast" is between 0 and 100
|
||||
if (stock.otlkMag < 0) {
|
||||
stock.otlkMag *= -1;
|
||||
stock.b = !stock.b;
|
||||
}
|
||||
let chc = 50;
|
||||
if (stock.b) {
|
||||
chc = (chc + stock.otlkMag) / 100;
|
||||
} else {
|
||||
chc = (chc - stock.otlkMag) / 100;
|
||||
}
|
||||
if (stock.price >= stock.cap) {
|
||||
chc = 0.1; // "Soft Limit" on stock price. It could still go up but its unlikely
|
||||
stock.b = false;
|
||||
}
|
||||
if (isNaN(chc)) { chc = 0.5; }
|
||||
|
||||
const c = Math.random();
|
||||
if (c < chc) {
|
||||
stock.price *= (1 + av);
|
||||
processOrders(stock, OrderTypes.LimitBuy, PositionTypes.Short);
|
||||
processOrders(stock, OrderTypes.LimitSell, PositionTypes.Long);
|
||||
processOrders(stock, OrderTypes.StopBuy, PositionTypes.Long);
|
||||
processOrders(stock, OrderTypes.StopSell, PositionTypes.Short);
|
||||
} else {
|
||||
stock.price /= (1 + av);
|
||||
processOrders(stock, OrderTypes.LimitBuy, PositionTypes.Long);
|
||||
processOrders(stock, OrderTypes.LimitSell, PositionTypes.Short);
|
||||
processOrders(stock, OrderTypes.StopBuy, PositionTypes.Short);
|
||||
processOrders(stock, OrderTypes.StopSell, PositionTypes.Long);
|
||||
}
|
||||
|
||||
let otlkMagChange = stock.otlkMag * av;
|
||||
if (stock.otlkMag <= 0.1) {
|
||||
otlkMagChange = 1;
|
||||
}
|
||||
if (c < 0.5) {
|
||||
stock.otlkMag += otlkMagChange;
|
||||
} else {
|
||||
stock.otlkMag -= otlkMagChange;
|
||||
}
|
||||
if (stock.otlkMag > 50) { stock.otlkMag = 50; } // Cap so the "forecast" is between 0 and 100
|
||||
if (stock.otlkMag < 0) {
|
||||
stock.otlkMag *= -1;
|
||||
stock.b = !stock.b;
|
||||
}
|
||||
|
||||
// Shares required for price movement gradually approaches max over time
|
||||
stock.shareTxUntilMovement = Math.min(stock.shareTxUntilMovement + 5, stock.shareTxForMovement);
|
||||
}
|
||||
|
||||
displayStockMarketContent();
|
||||
}
|
||||
|
||||
//Checks and triggers any orders for the specified stock
|
||||
// Checks and triggers any orders for the specified stock
|
||||
function processOrders(stock, orderType, posType) {
|
||||
var orderBook = StockMarket["Orders"];
|
||||
if (orderBook == null) {
|
||||
|
@ -59,17 +59,18 @@ export function getBuyTransactionCost(stock: Stock, shares: number, posType: Pos
|
||||
let remainingShares = shares - stock.shareTxUntilMovement;
|
||||
let numIterations = 1 + Math.ceil(remainingShares / stock.shareTxForMovement);
|
||||
|
||||
|
||||
|
||||
// The initial cost calculation takes care of the first "iteration"
|
||||
let currPrice = isLong ? stock.getAskPrice() : stock.getBidPrice();
|
||||
let totalCost = (stock.shareTxUntilMovement * currPrice);
|
||||
|
||||
const increasingMvmt = calculateIncreasingPriceMovement(stock)!;
|
||||
const decreasingMvmt = calculateDecreasingPriceMovement(stock)!;
|
||||
|
||||
function processPriceMovement() {
|
||||
if (isLong) {
|
||||
currPrice *= calculateIncreasingPriceMovement(stock)!;
|
||||
currPrice *= increasingMvmt;
|
||||
} else {
|
||||
currPrice *= calculateDecreasingPriceMovement(stock)!;
|
||||
currPrice *= decreasingMvmt;
|
||||
}
|
||||
}
|
||||
|
||||
@ -228,3 +229,58 @@ export function processSellTransactionPriceMovement(stock: Stock, shares: number
|
||||
stock.price = currPrice;
|
||||
stock.shareTxUntilMovement = stock.shareTxForMovement - ((shares - stock.shareTxUntilMovement) % stock.shareTxForMovement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the maximum number of shares of a stock that can be purchased.
|
||||
* Handles mid-transaction price movements, both L and S positions, etc.
|
||||
* Used for the "Buy Max" button in the UI
|
||||
* @param {Stock} stock - Stock being purchased
|
||||
* @param {PositionTypes} posType - Long or short position
|
||||
* @param {number} money - Amount of money player has
|
||||
* @returns maximum number of shares that the player can purchase
|
||||
*/
|
||||
export function calculateBuyMaxAmount(stock: Stock, posType: PositionTypes, money: number): number {
|
||||
if (!(stock instanceof Stock)) { return 0; }
|
||||
|
||||
const isLong = (posType === PositionTypes.Long);
|
||||
|
||||
const increasingMvmt = calculateIncreasingPriceMovement(stock);
|
||||
const decreasingMvmt = calculateDecreasingPriceMovement(stock);
|
||||
if (increasingMvmt == null || decreasingMvmt == null) { return 0; }
|
||||
|
||||
let remainingMoney = money - CONSTANTS.StockMarketCommission;
|
||||
let currPrice = isLong ? stock.getAskPrice() : stock.getBidPrice();
|
||||
|
||||
// No price movement
|
||||
const firstIterationCost = stock.shareTxUntilMovement * currPrice;
|
||||
if (remainingMoney < firstIterationCost) {
|
||||
return Math.floor(remainingMoney / currPrice);
|
||||
}
|
||||
|
||||
// We'll avoid any accidental infinite loops by having a hardcoded maximum number of
|
||||
// iterations
|
||||
let numShares = stock.shareTxUntilMovement;
|
||||
remainingMoney -= firstIterationCost;
|
||||
for (let i = 0; i < 10e3; ++i) {
|
||||
if (isLong) {
|
||||
currPrice *= increasingMvmt;
|
||||
} else {
|
||||
currPrice *= decreasingMvmt;
|
||||
}
|
||||
|
||||
const affordableShares = Math.floor(remainingMoney / currPrice);
|
||||
const actualShares = Math.min(stock.shareTxForMovement, affordableShares);
|
||||
|
||||
// Can't afford any more, so we're done
|
||||
if (actualShares <= 0) { break; }
|
||||
|
||||
numShares += actualShares;
|
||||
|
||||
let cost = actualShares * currPrice;
|
||||
remainingMoney -= cost;
|
||||
|
||||
if (remainingMoney <= 0) { break; }
|
||||
}
|
||||
|
||||
return Math.floor(numShares);
|
||||
}
|
||||
|
@ -65,6 +65,11 @@ export class InfoAndPurchases extends React.Component<IProps, any> {
|
||||
this.props.initStockMarket();
|
||||
this.props.p.loseMoney(CONSTANTS.WSEAccountCost);
|
||||
this.props.rerender();
|
||||
|
||||
const worldHeader = document.getElementById("world-menu-header");
|
||||
if (worldHeader instanceof HTMLElement) {
|
||||
worldHeader.click(); worldHeader.click();
|
||||
}
|
||||
}
|
||||
|
||||
purchaseTixApiAccess() {
|
||||
@ -117,7 +122,7 @@ export class InfoAndPurchases extends React.Component<IProps, any> {
|
||||
const cost = CONSTANTS.TIXAPICost;
|
||||
return (
|
||||
<StdButton
|
||||
disabled={!this.props.p.canAfford(cost)}
|
||||
disabled={!this.props.p.canAfford(cost) || !this.props.p.hasWseAccount}
|
||||
onClick={this.purchaseTixApiAccess}
|
||||
style={blockStyleMarkup}
|
||||
text={`Buy Trade Information eXchange (TIX) API Access - ${numeralWrapper.formatMoney(cost)}`}
|
||||
@ -138,7 +143,7 @@ export class InfoAndPurchases extends React.Component<IProps, any> {
|
||||
const cost = getStockMarket4SDataCost();
|
||||
return (
|
||||
<StdButton
|
||||
disabled={!this.props.p.canAfford(cost)}
|
||||
disabled={!this.props.p.canAfford(cost) || !this.props.p.hasWseAccount}
|
||||
onClick={this.purchase4SMarketData}
|
||||
text={`Buy 4S Market Data Access - ${numeralWrapper.formatMoney(cost)}`}
|
||||
tooltip={"Lets you view additional pricing and volatility information about stocks"}
|
||||
|
@ -59,16 +59,19 @@ export class StockMarketRoot extends React.Component<IProps, IState> {
|
||||
p={this.props.p}
|
||||
rerender={this.rerender}
|
||||
/>
|
||||
<StockTickers
|
||||
buyStockLong={this.props.buyStockLong}
|
||||
buyStockShort={this.props.buyStockShort}
|
||||
cancelOrder={this.props.cancelOrder}
|
||||
p={this.props.p}
|
||||
placeOrder={this.props.placeOrder}
|
||||
sellStockLong={this.props.sellStockLong}
|
||||
sellStockShort={this.props.sellStockShort}
|
||||
stockMarket={this.props.stockMarket}
|
||||
/>
|
||||
{
|
||||
this.props.p.hasWseAccount &&
|
||||
<StockTickers
|
||||
buyStockLong={this.props.buyStockLong}
|
||||
buyStockShort={this.props.buyStockShort}
|
||||
cancelOrder={this.props.cancelOrder}
|
||||
p={this.props.p}
|
||||
placeOrder={this.props.placeOrder}
|
||||
sellStockLong={this.props.sellStockLong}
|
||||
sellStockShort={this.props.sellStockShort}
|
||||
stockMarket={this.props.stockMarket}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -13,6 +13,7 @@ import { Stock } from "../Stock";
|
||||
import {
|
||||
getBuyTransactionCost,
|
||||
getSellTransactionGain,
|
||||
calculateBuyMaxAmount,
|
||||
} from "../StockMarketHelpers";
|
||||
import { OrderTypes } from "../data/OrderTypes";
|
||||
import { PositionTypes } from "../data/PositionTypes";
|
||||
@ -142,7 +143,7 @@ export class StockTicker extends React.Component<IProps, IState> {
|
||||
return `You do not have this many shares in the Short position`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const cost = getSellTransactionGain(stock, qty, this.state.position);
|
||||
if (cost == null) { return ""; }
|
||||
|
||||
@ -202,7 +203,7 @@ export class StockTicker extends React.Component<IProps, IState> {
|
||||
const playerMoney: number = this.props.p.money.toNumber();
|
||||
|
||||
const stock = this.props.stock;
|
||||
let maxShares = Math.floor((playerMoney - CONSTANTS.StockMarketCommission) / this.props.stock.price);
|
||||
let maxShares = calculateBuyMaxAmount(stock, this.state.position, playerMoney);
|
||||
maxShares = Math.min(maxShares, Math.round(stock.maxShares - stock.playerShares - stock.playerShortShares));
|
||||
|
||||
switch (this.state.orderType) {
|
||||
|
@ -37,6 +37,8 @@ type IState = {
|
||||
}
|
||||
|
||||
export class StockTickers extends React.Component<IProps, IState> {
|
||||
listRef: React.RefObject<HTMLUListElement>;
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
@ -49,6 +51,10 @@ export class StockTickers extends React.Component<IProps, IState> {
|
||||
|
||||
this.changeDisplayMode = this.changeDisplayMode.bind(this);
|
||||
this.changeWatchlistFilter = this.changeWatchlistFilter.bind(this);
|
||||
this.collapseAllTickers = this.collapseAllTickers.bind(this);
|
||||
this.expandAllTickers = this.expandAllTickers.bind(this);
|
||||
|
||||
this.listRef = React.createRef();
|
||||
}
|
||||
|
||||
changeDisplayMode() {
|
||||
@ -82,6 +88,38 @@ export class StockTickers extends React.Component<IProps, IState> {
|
||||
}
|
||||
}
|
||||
|
||||
collapseAllTickers() {
|
||||
const ul = this.listRef.current;
|
||||
if (ul == null) { return; }
|
||||
const tickers = ul.getElementsByClassName("accordion-header");
|
||||
for (let i = 0; i < tickers.length; ++i) {
|
||||
const ticker = tickers[i];
|
||||
if (!(ticker instanceof HTMLButtonElement)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ticker.classList.contains("active")) {
|
||||
ticker.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expandAllTickers() {
|
||||
const ul = this.listRef.current;
|
||||
if (ul == null) { return; }
|
||||
const tickers = ul.getElementsByClassName("accordion-header");
|
||||
for (let i = 0; i < tickers.length; ++i) {
|
||||
const ticker = tickers[i];
|
||||
if (!(ticker instanceof HTMLButtonElement)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ticker.classList.contains("active")) {
|
||||
ticker.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rerender() {
|
||||
this.setState((prevState) => {
|
||||
return {
|
||||
@ -134,10 +172,12 @@ export class StockTickers extends React.Component<IProps, IState> {
|
||||
<StockTickersConfig
|
||||
changeDisplayMode={this.changeDisplayMode}
|
||||
changeWatchlistFilter={this.changeWatchlistFilter}
|
||||
collapseAllTickers={this.collapseAllTickers}
|
||||
expandAllTickers={this.expandAllTickers}
|
||||
tickerDisplayMode={this.state.tickerDisplayMode}
|
||||
/>
|
||||
|
||||
<ul id="stock-market-list">
|
||||
<ul id="stock-market-list" ref={this.listRef}>
|
||||
{tickers}
|
||||
</ul>
|
||||
</div>
|
||||
|
@ -15,6 +15,8 @@ export enum TickerDisplayMode {
|
||||
type IProps = {
|
||||
changeDisplayMode: () => void;
|
||||
changeWatchlistFilter: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
collapseAllTickers: () => void;
|
||||
expandAllTickers: () => void;
|
||||
tickerDisplayMode: TickerDisplayMode;
|
||||
}
|
||||
|
||||
@ -47,6 +49,14 @@ export class StockTickersConfig extends React.Component<IProps, any> {
|
||||
return (
|
||||
<div>
|
||||
{this.renderDisplayModeButton()}
|
||||
<StdButton
|
||||
onClick={this.props.expandAllTickers}
|
||||
text="Expand Tickers"
|
||||
/>
|
||||
<StdButton
|
||||
onClick={this.props.collapseAllTickers}
|
||||
text="Collapse Tickers"
|
||||
/>
|
||||
|
||||
<input
|
||||
className="text-input"
|
||||
|
@ -6,6 +6,7 @@ import * as React from "react";
|
||||
type IProps = {
|
||||
headerContent: React.ReactElement;
|
||||
panelContent: React.ReactElement;
|
||||
panelInitiallyOpened?: boolean;
|
||||
}
|
||||
|
||||
type IState = {
|
||||
@ -19,7 +20,7 @@ export class Accordion extends React.Component<IProps, IState> {
|
||||
this.handleHeaderClick = this.handleHeaderClick.bind(this);
|
||||
|
||||
this.state = {
|
||||
panelOpened: false,
|
||||
panelOpened: props.panelInitiallyOpened ? true : false,
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,16 +29,17 @@ export class Accordion extends React.Component<IProps, IState> {
|
||||
elem.classList.toggle("active");
|
||||
|
||||
const panel: HTMLElement = elem.nextElementSibling as HTMLElement;
|
||||
if (panel!.style.display === "block") {
|
||||
panel!.style.display = "none";
|
||||
this.setState({
|
||||
panelOpened: false,
|
||||
});
|
||||
} else {
|
||||
const active = elem.classList.contains("active");
|
||||
if (active) {
|
||||
panel!.style.display = "block";
|
||||
this.setState({
|
||||
panelOpened: true,
|
||||
});
|
||||
} else {
|
||||
panel!.style.display = "none";
|
||||
this.setState({
|
||||
panelOpened: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user