CORPORATION: Rewrite validation code for strings of price and quantity (#1753)

This commit is contained in:
catloversg 2024-11-12 22:39:21 +07:00 committed by GitHub
parent 9bba6a0a41
commit 246d668951
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 240 additions and 177 deletions

@ -198,125 +198,89 @@ export function acceptInvestmentOffer(corporation: Corporation): void {
corporation.investorShares += investShares;
}
export function sellMaterial(material: Material, amount: string, price: string): void {
if (price === "") price = "0";
if (amount === "") amount = "0";
let cost = price.replace(/\s+/g, "");
cost = cost.replace(/[^-()\d/*+.MPe]/g, ""); //Sanitize cost
let temp = cost.replace(/MP/, "1.234e5");
try {
if (temp.includes("MP")) throw "Only one reference to MP is allowed in sell price.";
temp = eval?.(temp);
} catch (error) {
throw new Error("Invalid value or expression for sell price field", { cause: error });
}
export function convertPriceString(price: string): string {
/**
* Replace invalid characters. Only accepts:
* - Digit characters
* - 4 most basic algebraic operations (+ - * /)
* - Parentheses
* - Dot character
* - Any characters in this list: [e, E, M, P]
*/
const sanitizedPrice = price.replace(/[^\d+\-*/().eEMP]/g, "");
if (temp == null || isNaN(parseFloat(temp))) {
throw new Error("Invalid value or expression for sell price field");
}
if (cost.includes("MP")) {
material.desiredSellPrice = cost; //Dynamically evaluated
} else {
material.desiredSellPrice = temp;
}
//Parse quantity
amount = amount.toUpperCase();
if (amount.includes("MAX") || amount.includes("PROD") || amount.includes("INV")) {
let q = amount.replace(/\s+/g, "");
q = q.replace(/[^-()\d/*+.MAXPRODINV]/g, "");
let tempQty = q.replace(/MAX/g, material.maxSellPerCycle.toString());
tempQty = tempQty.replace(/PROD/g, material.productionAmount.toString());
tempQty = tempQty.replace(/INV/g, material.productionAmount.toString());
// Replace MP with test numbers.
for (const testNumber of [-1.2e123, -123456, 123456, 1.2e123]) {
const temp = sanitizedPrice.replace(/MP/g, testNumber.toString());
let evaluatedTemp: unknown;
try {
tempQty = eval?.(tempQty);
evaluatedTemp = eval?.(temp);
if (typeof evaluatedTemp !== "number" || !Number.isFinite(evaluatedTemp)) {
throw new Error(
`Evaluated value is not a valid number: ${evaluatedTemp}. Price: ${price}. sanitizedPrice: ${sanitizedPrice}. testNumber: ${testNumber}.`,
);
}
} catch (error) {
throw new Error("Invalid value or expression for sell quantity field", { cause: error });
throw new Error(`Invalid value or expression for sell price field: ${error}`, { cause: error });
}
if (tempQty == null || isNaN(parseFloat(tempQty))) {
throw new Error("Invalid value or expression for sell quantity field");
}
material.desiredSellAmount = q; //Use sanitized input
} else if (isNaN(parseFloat(amount)) || parseFloat(amount) < 0) {
throw new Error("Invalid value for sell quantity field! Must be numeric or 'PROD' or 'MAX'");
} else {
let q = parseFloat(amount);
if (isNaN(q)) {
q = 0;
}
material.desiredSellAmount = q;
}
// Use sanitized price.
return sanitizedPrice;
}
export function convertAmountString(amount: string): string {
/**
* Replace invalid characters. Only accepts:
* - Digit characters
* - 4 most basic algebraic operations (+ - * /)
* - Parentheses
* - Dot character
* - Any characters in this list: [e, E, M, A, X, P, R, O, D, I, N, V]
*/
const sanitizedAmount = amount.replace(/[^\d+\-*/().eEMAXPRODINV]/g, "");
for (const testNumber of [-1.2e123, -123456, 123456, 1.2e123]) {
let temp = sanitizedAmount.replace(/MAX/g, testNumber.toString());
temp = temp.replace(/PROD/g, testNumber.toString());
temp = temp.replace(/INV/g, testNumber.toString());
let evaluatedTemp: unknown;
try {
evaluatedTemp = eval?.(temp);
if (typeof evaluatedTemp !== "number" || !Number.isFinite(evaluatedTemp)) {
throw new Error(
`Evaluated value is not a valid number: ${evaluatedTemp}. Amount: ${amount}. sanitizedAmount: ${sanitizedAmount}. testNumber: ${testNumber}.`,
);
}
} catch (error) {
throw new Error(`Invalid value or expression for sell quantity field: ${error}`, { cause: error });
}
}
// Use sanitized amount.
return sanitizedAmount;
}
export function sellMaterial(material: Material, amount: string, price: string): void {
const convertedPrice = convertPriceString(price.toUpperCase());
const convertedAmount = convertAmountString(amount.toUpperCase());
material.desiredSellPrice = convertedPrice;
material.desiredSellAmount = convertedAmount;
}
export function sellProduct(product: Product, city: CityName, amt: string, price: string, all: boolean): void {
//Parse price
// initliaze newPrice with oldPrice as default
let newPrice = product.cityData[city].desiredSellPrice;
if (price.includes("MP")) {
//Dynamically evaluated quantity. First test to make sure its valid
//Sanitize input, then replace dynamic variables with arbitrary numbers
price = price.replace(/\s+/g, "");
price = price.replace(/[^-()\d/*+.MPe]/g, "");
let temp = price.replace(/MP/, "1.234e5");
try {
if (temp.includes("MP")) throw "Only one reference to MP is allowed in sell price.";
temp = eval?.(temp);
} catch (error) {
throw new Error("Invalid value or expression for sell price field.", { cause: error });
}
if (temp == null || isNaN(parseFloat(temp))) {
throw new Error("Invalid value or expression for sell price field.");
}
newPrice = price; //Use sanitized price
} else {
const cost = parseFloat(price);
if (isNaN(cost)) {
throw new Error("Invalid value for sell price field");
}
newPrice = cost;
}
const convertedPrice = convertPriceString(price.toUpperCase());
const convertedAmount = convertAmountString(amt.toUpperCase());
// Parse quantity
amt = amt.toUpperCase();
//initialize newAmount with old as default
let newAmount = product.cityData[city].desiredSellAmount;
if (amt.includes("MAX") || amt.includes("PROD") || amt.includes("INV")) {
//Dynamically evaluated quantity. First test to make sure its valid
let qty = amt.replace(/\s+/g, "");
qty = qty.replace(/[^-()\d/*+.MAXPRODINV]/g, "");
let temp = qty.replace(/MAX/g, product.maxSellAmount.toString());
temp = temp.replace(/PROD/g, product.cityData[city].productionAmount.toString());
temp = temp.replace(/INV/g, product.cityData[city].stored.toString());
try {
temp = eval?.(temp);
} catch (error) {
throw new Error("Invalid value or expression for sell quantity field", { cause: error });
}
if (temp == null || isNaN(parseFloat(temp))) {
throw new Error("Invalid value or expression for sell quantity field");
}
newAmount = qty; //Use sanitized input
} else if (isNaN(parseFloat(amt)) || parseFloat(amt) < 0) {
throw new Error("Invalid value for sell quantity field! Must be numeric or 'PROD' or 'MAX'");
} else {
let qty = parseFloat(amt);
if (isNaN(qty)) {
qty = 0;
}
newAmount = qty;
}
//apply new price and amount to all or just current
if (all) {
for (const cityName of Object.values(CityName)) {
product.cityData[cityName].desiredSellAmount = newAmount;
product.cityData[cityName].desiredSellPrice = newPrice;
product.cityData[cityName].desiredSellAmount = convertedAmount;
product.cityData[cityName].desiredSellPrice = convertedPrice;
}
} else {
product.cityData[city].desiredSellAmount = newAmount;
product.cityData[city].desiredSellPrice = newPrice;
product.cityData[city].desiredSellAmount = convertedAmount;
product.cityData[city].desiredSellPrice = convertedPrice;
}
}
@ -577,23 +541,21 @@ Attempted export amount: ${amount}`);
sanitizedAmt = sanitizedAmt.replace(/[^-()\d/*+.MAXEPRODINV]/g, "");
for (const testReplacement of ["(1.23)", "(-1.23)"]) {
const replaced = sanitizedAmt.replace(/(MAX|IPROD|EPROD|IINV|EINV)/g, testReplacement);
let evaluated, error;
let evaluated: unknown;
try {
evaluated = eval?.(replaced);
} catch (e) {
error = e;
}
if (!error && isNaN(evaluated)) error = "evaluated value is NaN";
if (error) {
if (typeof evaluated !== "number" || !Number.isFinite(evaluated)) {
throw new Error(`Evaluated value is not a valid number: ${evaluated}`);
}
} catch (error) {
throw new Error(
`Error while trying to set the exported amount of ${material.name}.
Error occurred while testing keyword replacement with ${testReplacement}.
Your input: ${amount}
Sanitized input: ${sanitizedAmt}
Input after replacement: ${replaced}
Evaluated value: ${evaluated}` +
// eslint-disable-next-line @typescript-eslint/no-base-to-string
`Error encountered: ${error}`,
Evaluated value: ${evaluated}
Error encountered: ${error}`,
);
}
}

@ -16,6 +16,7 @@ import { PartialRecord, getRecordEntries, getRecordKeys, getRecordValues } from
import { Material } from "./Material";
import { getKeyList } from "../utils/helpers/getKeyList";
import { calculateMarkupMultiplier } from "./helpers";
import { exceptionAlert } from "../utils/helpers/exceptionAlert";
import { throwIfReachable } from "../utils/helpers/throwIfReachable";
interface DivisionParams {
@ -538,25 +539,30 @@ export class Division {
// The amount gets re-multiplied later, so this is the correct
// amount to calculate with for "MAX".
const adjustedQty = mat.stored / (corpConstants.secondsPerMarketCycle * marketCycles);
if (typeof mat.desiredSellAmount === "string") {
//Dynamically evaluated
let tmp = mat.desiredSellAmount.replace(/MAX/g, adjustedQty.toString());
tmp = tmp.replace(/PROD/g, mat.productionAmount.toString());
try {
sellAmt = eval?.(tmp);
} catch (e) {
dialogBoxCreate(
`Error evaluating your sell amount for material ${mat.name} in ${this.name}'s ${city} office. The sell amount is being set to zero`,
);
sellAmt = 0;
/**
* desiredSellAmount is usually a string, but it also can be a number in old versions. eval requires a
* string, so we convert it to a string here, replace placeholders, and then pass it to eval.
*/
let temp = String(mat.desiredSellAmount);
temp = temp.replace(/MAX/g, adjustedQty.toString());
temp = temp.replace(/PROD/g, mat.productionAmount.toString());
temp = temp.replace(/INV/g, mat.stored.toString());
try {
// Typecasting here is fine. We will validate the result immediately after this line.
sellAmt = eval?.(temp) as number;
if (typeof sellAmt !== "number" || !Number.isFinite(sellAmt)) {
throw new Error(`Evaluated value is not a valid number: ${sellAmt}`);
}
} else {
sellAmt = mat.desiredSellAmount;
} catch (error) {
dialogBoxCreate(
`Error evaluating your sell amount for material ${mat.name} in ${this.name}'s ${city} office. Error: ${error}.`,
);
continue;
}
// Determine the cost that the material will be sold at
const markupLimit = mat.getMarkupLimit();
let sCost;
let sCost: number;
if (mat.marketTa2) {
// Reverse engineer the 'maxSell' formula
// 1. Set 'maxSell' = sellAmt
@ -590,11 +596,29 @@ export class Division {
sCost = optimalPrice;
} else if (mat.marketTa1) {
sCost = mat.marketPrice + markupLimit;
} else if (typeof mat.desiredSellPrice === "string") {
sCost = mat.desiredSellPrice.replace(/MP/g, mat.marketPrice.toString());
sCost = eval?.(sCost);
} else {
sCost = mat.desiredSellPrice;
// If the player does not set the price, desiredSellPrice will be an empty string.
if (mat.desiredSellPrice === "") {
continue;
}
/**
* desiredSellPrice is usually a string, but it also can be a number in old versions. eval requires a
* string, so we convert it to a string here, replace the placeholder MP, and then pass it to eval later.
*/
const temp = String(mat.desiredSellPrice).replace(/MP/g, mat.marketPrice.toString());
try {
// Typecasting here is fine. We will validate the result immediately after this line.
sCost = eval?.(temp) as number;
if (typeof sCost !== "number" || !Number.isFinite(sCost)) {
throw new Error(`Evaluated value is not a valid number: ${sCost}`);
}
} catch (error) {
dialogBoxCreate(
`Error evaluating your sell price for material ${mat.name} in ${this.name}'s ${city} office. ` +
`The sell amount is being set to zero. Error: ${error}`,
);
continue;
}
}
mat.uiMarketPrice = sCost;
@ -658,19 +682,17 @@ export class Division {
amtStr = amtStr.replace(/IINV/g, `(${tempMaterial.stored})`);
let amt = 0;
try {
amt = eval?.(amtStr);
// Typecasting here is fine. We will validate the result immediately after this line.
amt = eval?.(amtStr) as number;
if (typeof amt !== "number" || !Number.isFinite(amt)) {
throw new Error(`Evaluated value is not a valid number: ${amt}`);
}
} catch (e) {
dialogBoxCreate(
`Calculating export for ${mat.name} in ${this.name}'s ${city} division failed with error: ${e}`,
);
continue;
}
if (isNaN(amt)) {
dialogBoxCreate(
`Error calculating export amount for ${mat.name} in ${this.name}'s ${city} division.`,
);
continue;
}
amt = amt * corpConstants.secondsPerMarketCycle * marketCycles;
if (mat.stored < amt) {
@ -869,35 +891,43 @@ export class Division {
const marketFactor = this.getMarketFactor(product); //Competition + demand
// Parse player sell-amount input (needed for TA.II and selling)
let sellAmt: number | string;
let sellAmt: number;
// The amount gets re-multiplied later, so this is the correct
// amount to calculate with for "MAX".
const adjustedQty = product.cityData[city].stored / (corpConstants.secondsPerMarketCycle * marketCycles);
const desiredSellAmount = product.cityData[city].desiredSellAmount;
if (typeof desiredSellAmount === "string") {
//Sell amount is dynamically evaluated
let tmp: number | string = desiredSellAmount.replace(/MAX/g, adjustedQty.toString());
tmp = tmp.replace(/PROD/g, product.cityData[city].productionAmount.toString());
try {
tmp = eval?.(tmp);
if (typeof tmp !== "number") throw "";
} catch (e) {
dialogBoxCreate(
`Error evaluating your sell price expression for ${product.name} in ${this.name}'s ${city} office. Sell price is being set to MAX`,
);
tmp = product.maxSellAmount;
/**
* desiredSellAmount is usually a string, but it also can be a number in old versions. eval requires a
* string, so we convert it to a string here, replace placeholders, and then pass it to eval.
*/
const desiredSellAmount = String(product.cityData[city].desiredSellAmount);
let temp = desiredSellAmount.replace(/MAX/g, adjustedQty.toString());
temp = temp.replace(/PROD/g, product.cityData[city].productionAmount.toString());
temp = temp.replace(/INV/g, product.cityData[city].stored.toString());
try {
// Typecasting here is fine. We will validate the result immediately after this line.
sellAmt = eval?.(temp) as number;
if (typeof sellAmt !== "number" || !Number.isFinite(sellAmt)) {
throw new Error(`Evaluated value is not a valid number: ${sellAmt}`);
}
sellAmt = tmp;
} else if (desiredSellAmount && desiredSellAmount > 0) {
sellAmt = desiredSellAmount;
} else sellAmt = adjustedQty;
} catch (error) {
dialogBoxCreate(
`Error evaluating your sell amount for ${product.name} in ${this.name}'s ${city} office. Error: ${error}.`,
);
// break the case "SALE"
break;
}
if (sellAmt < 0) sellAmt = 0;
if (sellAmt < 0) {
sellAmt = 0;
}
if (product.markup === 0) {
exceptionAlert(new Error("product.markup is 0"));
product.markup = 1;
}
// Calculate Sale Cost (sCost), which could be dynamically evaluated
const markupLimit = Math.max(product.cityData[city].effectiveRating, 0.001) / product.markup;
let sCost: number;
const sellPrice = product.cityData[city].desiredSellPrice;
if (product.marketTa2) {
// Reverse engineer the 'maxSell' formula
// 1. Set 'maxSell' = sellAmt
@ -930,16 +960,29 @@ export class Division {
sCost = optimalPrice;
} else if (product.marketTa1) {
sCost = product.cityData[city].productionCost + markupLimit;
} else if (typeof sellPrice === "string") {
let sCostString = sellPrice;
if (product.markup === 0) {
console.error(`mku is zero, reverting to 1 to avoid Infinity`);
product.markup = 1;
}
sCostString = sCostString.replace(/MP/g, product.cityData[city].productionCost.toString());
sCost = eval?.(sCostString);
} else {
sCost = sellPrice;
// If the player does not set the price, desiredSellPrice will be an empty string.
if (product.cityData[city].desiredSellPrice === "") {
// break the case "SALE"
break;
}
const temp = String(product.cityData[city].desiredSellPrice).replace(
/MP/g,
product.cityData[city].productionCost.toString(),
);
try {
// Typecasting here is fine. We will validate the result immediately after this line.
sCost = eval?.(temp) as number;
if (typeof sCost !== "number" || !Number.isFinite(sCost)) {
throw new Error(`Evaluated value is not a valid number: ${sCost}`);
}
} catch (error) {
dialogBoxCreate(
`Error evaluating your sell price for product ${product.name} in ${this.name}'s ${city} office. Error: ${error}.`,
);
// break the case "SALE"
break;
}
}
product.uiMarketPrice[city] = sCost;

@ -31,10 +31,16 @@ export function SellMaterialModal(props: IProps): React.ReactElement {
}
function onAmtChange(event: React.ChangeEvent<HTMLInputElement>): void {
if (event.target.value === "") {
return;
}
setAmt(event.target.value);
}
function onPriceChange(event: React.ChangeEvent<HTMLInputElement>): void {
if (event.target.value === "") {
return;
}
setPrice(event.target.value);
}

@ -21,8 +21,8 @@ interface IProps {
// Create a popup that let the player manage sales of a material
export function SellProductModal(props: IProps): React.ReactElement {
const [checked, setChecked] = useState(true);
const [iQty, setQty] = useState<string>(String(props.product.cityData[props.city].desiredSellAmount));
const [px, setPx] = useState<string>(String(props.product.cityData[props.city].desiredSellPrice));
const [amt, setAmt] = useState<string>(String(props.product.cityData[props.city].desiredSellAmount));
const [price, setPrice] = useState<string>(String(props.product.cityData[props.city].desiredSellPrice));
function onCheckedChange(event: React.ChangeEvent<HTMLInputElement>): void {
setChecked(event.target.checked);
@ -30,7 +30,7 @@ export function SellProductModal(props: IProps): React.ReactElement {
function sellProduct(): void {
try {
actions.sellProduct(props.product, props.city, iQty, px, checked);
actions.sellProduct(props.product, props.city, amt, price, checked);
} catch (error) {
dialogBoxCreate(String(error));
}
@ -39,11 +39,17 @@ export function SellProductModal(props: IProps): React.ReactElement {
}
function onAmtChange(event: React.ChangeEvent<HTMLInputElement>): void {
setQty(event.target.value);
if (event.target.value === "") {
return;
}
setAmt(event.target.value);
}
function onPriceChange(event: React.ChangeEvent<HTMLInputElement>): void {
setPx(event.target.value);
if (event.target.value === "") {
return;
}
setPrice(event.target.value);
}
function onKeyDown(event: React.KeyboardEvent<HTMLInputElement>): void {
@ -75,14 +81,14 @@ export function SellProductModal(props: IProps): React.ReactElement {
</Typography>
<br />
<TextField
value={iQty}
value={amt}
autoFocus={true}
type="text"
placeholder="Sell amount"
onChange={onAmtChange}
onKeyDown={onKeyDown}
/>
<TextField value={px} type="text" placeholder="Sell price" onChange={onPriceChange} onKeyDown={onKeyDown} />
<TextField value={price} type="text" placeholder="Sell price" onChange={onPriceChange} onKeyDown={onKeyDown} />
<Button onClick={sellProduct} style={{ marginLeft: ".5rem", marginRight: ".5rem" }}>
Confirm
</Button>

@ -11,6 +11,8 @@ import { PlayerObject } from "../../src/PersonObjects/Player/PlayerObject";
import {
acceptInvestmentOffer,
buyBackShares,
convertAmountString,
convertPriceString,
goPublic,
issueNewShares,
sellShares,
@ -84,7 +86,10 @@ describe("Corporation", () => {
it("should be preserved by seed funding", () => {
const seedFunded = true;
Player.startCorporation("TestCorp", seedFunded);
expectSharesToAddUp(Player.corporation!);
if (!Player.corporation) {
throw new Error("Player.startCorporation failed to create a corporation.");
}
expectSharesToAddUp(Player.corporation);
});
it("should be preserved by acceptInvestmentOffer", () => {
acceptInvestmentOffer(corporation);
@ -137,4 +142,45 @@ describe("Corporation", () => {
},
);
});
describe("convertPriceString", () => {
it("should pass normally", () => {
expect(convertPriceString("MP")).toStrictEqual("MP");
expect(convertPriceString("MP+1")).toStrictEqual("MP+1");
expect(convertPriceString("MP+MP")).toStrictEqual("MP+MP");
expect(convertPriceString("123")).toStrictEqual("123");
expect(convertPriceString("123+456")).toStrictEqual("123+456");
expect(convertPriceString("1e10")).toStrictEqual("1e10");
expect(convertPriceString("1E10")).toStrictEqual("1E10");
});
it("should throw errors", () => {
expect(() => convertPriceString("")).toThrow();
expect(() => convertPriceString("null")).toThrow();
expect(() => convertPriceString("undefined")).toThrow();
expect(() => convertPriceString("Infinity")).toThrow();
expect(() => convertPriceString("abc")).toThrow();
});
});
describe("convertAmountString", () => {
it("should pass normally", () => {
expect(convertAmountString("MAX")).toStrictEqual("MAX");
expect(convertAmountString("PROD")).toStrictEqual("PROD");
expect(convertAmountString("INV")).toStrictEqual("INV");
expect(convertAmountString("MAX+1")).toStrictEqual("MAX+1");
expect(convertAmountString("MAX+MAX")).toStrictEqual("MAX+MAX");
expect(convertAmountString("MAX+PROD+INV")).toStrictEqual("MAX+PROD+INV");
expect(convertAmountString("123")).toStrictEqual("123");
expect(convertAmountString("123+456")).toStrictEqual("123+456");
expect(convertAmountString("1e10")).toStrictEqual("1e10");
expect(convertAmountString("1E10")).toStrictEqual("1E10");
});
it("should throw errors", () => {
expect(() => convertAmountString("")).toThrow();
expect(() => convertAmountString("null")).toThrow();
expect(() => convertAmountString("undefined")).toThrow();
expect(() => convertAmountString("Infinity")).toThrow();
expect(() => convertAmountString("abc")).toThrow();
});
});
});