pokemon-showdown/server/chat-plugins/calculator.ts
Guangcong Luo 78439b4a02
Update to ESLint 9 (#10926)
ESLint has a whole new config format, so I figure it's a good time to
make the config system saner.

- First, we no longer have separate eslint-no-types configs. Lint
  performance shouldn't be enough of a problem to justify the
  relevant maintenance complexity.

- Second, our base config should work out-of-the-box now. `npx eslint`
  will work as expected, without any CLI flags. You should still use
  `npm run lint` which adds the `--cached` flag for performance.

- Third, whatever updates I did fixed style linting, which apparently
  has been bugged for quite some time, considering all the obvious
  mixed-tabs-and-spaces issues I found in the upgrade.

Also here are some changes to our style rules. In particular:

- Curly brackets (for objects etc) now have spaces inside them. Sorry
  for the huge change. ESLint doesn't support our old style, and most
  projects use Prettier style, so we might as well match them in this way.
  See https://github.com/eslint-stylistic/eslint-stylistic/issues/415

- String + number concatenation is no longer allowed. We now
  consistently use template strings for this.
2025-02-25 20:03:46 -08:00

210 lines
6.5 KiB
TypeScript

import { Utils } from '../../lib';
type Operator = '^' | 'negative' | '%' | '/' | '*' | '+' | '-' | '(';
interface Operators {
precedence: number;
associativity: "Left" | "Right";
}
const OPERATORS: { [k in Operator]: Operators } = {
"^": {
precedence: 5,
associativity: "Right",
},
"negative": {
precedence: 4,
associativity: "Right",
},
"%": {
precedence: 3,
associativity: "Left",
},
"/": {
precedence: 3,
associativity: "Left",
},
"*": {
precedence: 3,
associativity: "Left",
},
"+": {
precedence: 2,
associativity: "Left",
},
"-": {
precedence: 2,
associativity: "Left",
},
"(": {
precedence: 1,
associativity: "Right",
},
};
const BASE_PREFIXES: { [base: number]: string } = {
2: "0b",
8: "0o",
10: "",
16: "0x",
};
function parseMathematicalExpression(infix: string) {
// Shunting-yard Algorithm -- https://en.wikipedia.org/wiki/Shunting-yard_algorithm
const outputQueue: string[] = [];
const operatorStack: Operator[] = [];
infix = infix.replace(/\s+/g, "");
const infixArray = infix.split(/([+\-*/%^()])/).filter(token => token);
let isExprExpected = true;
for (const token of infixArray) {
if (isExprExpected && "+-".includes(token)) {
if (token === '-') operatorStack.push('negative');
} else if ("^%*/+-".includes(token)) {
if (isExprExpected) throw new SyntaxError(`Got "${token}" where an expression should be`);
const op = OPERATORS[token as Operator];
let prevToken = operatorStack[operatorStack.length - 1] || '(';
let prevOp = OPERATORS[prevToken];
while (op.associativity === "Left" ? op.precedence <= prevOp.precedence : op.precedence < prevOp.precedence) {
outputQueue.push(operatorStack.pop()!);
prevToken = operatorStack[operatorStack.length - 1] || '(';
prevOp = OPERATORS[prevToken];
}
operatorStack.push(token as Operator);
isExprExpected = true;
} else if (token === "(") {
if (!isExprExpected) throw new SyntaxError(`Got "(" where an operator should be`);
operatorStack.push(token as Operator);
isExprExpected = true;
} else if (token === ")") {
if (isExprExpected) throw new SyntaxError(`Got ")" where an expression should be`);
while (operatorStack.length && operatorStack[operatorStack.length - 1] !== "(") {
outputQueue.push(operatorStack.pop()!);
}
operatorStack.pop();
isExprExpected = false;
} else {
if (!isExprExpected) throw new SyntaxError(`Got "${token}" where an operator should be`);
outputQueue.push(token);
isExprExpected = false;
}
}
if (isExprExpected) throw new SyntaxError(`Input ended where an expression should be`);
while (operatorStack.length > 0) {
const token = operatorStack.pop()!;
if (token === '(') continue;
outputQueue.push(token);
}
return outputQueue;
}
function solveRPN(rpn: string[]): [number, number] {
let base = 10;
const resultStack: number[] = [];
for (let token of rpn) {
if (token === 'negative') {
if (!resultStack.length) throw new SyntaxError(`Unknown syntax error`);
resultStack.push(-resultStack.pop()!);
} else if (!"^%*/+-".includes(token)) {
if (token.endsWith('h')) {
// Convert h suffix for hexadecimal to 0x prefix
token = `0x${token.slice(0, -1)}`;
} else if (token.endsWith('o')) {
// Convert o suffix for octal to 0o prefix
token = `0o${token.slice(0, -1)}`;
} else if (token.endsWith('b')) {
// Convert b suffix for binary to 0b prefix
token = `0b${token.slice(0, -1)}`;
}
if (token.startsWith('0x')) base = 16;
if (token.startsWith('0b')) base = 2;
if (token.startsWith('0o')) base = 8;
let num = Number(token);
if (isNaN(num) && token.toUpperCase() in Math) {
// @ts-expect-error Math consts should be safe
num = Math[token.toUpperCase()];
}
if (isNaN(num) && token !== 'NaN') {
throw new SyntaxError(`Unrecognized token ${token}`);
}
resultStack.push(num);
} else {
if (resultStack.length < 2) throw new SyntaxError(`Unknown syntax error`);
const a = resultStack.pop()!;
const b = resultStack.pop()!;
switch (token) {
case "+":
resultStack.push(a + b);
break;
case "-":
resultStack.push(b - a);
break;
case "*":
resultStack.push(a * b);
break;
case "/":
resultStack.push(b / a);
break;
case "%":
resultStack.push(b % a);
break;
case "^":
resultStack.push(b ** a);
break;
}
}
}
if (resultStack.length !== 1) throw new SyntaxError(`Unknown syntax error`);
return [resultStack.pop()!, base];
}
export const commands: Chat.ChatCommands = {
math: "calculate",
calculate(target, room, user) {
if (!target) return this.parse('/help calculate');
let base = 0;
const baseMatchResult = (/\b(?:in|to)\s+([a-zA-Z]+)\b/).exec(target);
if (baseMatchResult) {
switch (toID(baseMatchResult[1])) {
case 'decimal': case 'dec': base = 10; break;
case 'hexadecimal': case 'hex': base = 16; break;
case 'octal': case 'oct': base = 8; break;
case 'binary': case 'bin': base = 2; break;
default:
return this.errorReply(`Unrecognized base "${baseMatchResult[1]}". Valid options are binary or bin, octal or oct, decimal or dec, and hexadecimal or hex.`);
}
}
const expression = target.replace(/\b(in|to)\s+([a-zA-Z]+)\b/g, '').trim();
if (!this.runBroadcast()) return;
try {
const [result, inferredBase] = solveRPN(parseMathematicalExpression(expression));
if (!base) base = inferredBase;
let baseResult = '';
if (Number.isFinite(result) && base !== 10) {
baseResult = `${BASE_PREFIXES[base]}${result.toString(base).toUpperCase()}`;
if (baseResult === expression) baseResult = '';
}
let resultStr = '';
const resultTruncated = parseFloat(result.toPrecision(15));
let resultDisplay = resultTruncated.toString();
if (resultTruncated > 10 ** 15) {
resultDisplay = resultTruncated.toExponential();
}
if (baseResult) {
resultStr = `<strong>${baseResult}</strong> = ${resultDisplay}`;
} else {
resultStr = `<strong>${resultDisplay}</strong>`;
}
this.sendReplyBox(`${expression}<br />= ${resultStr}`);
} catch (e: any) {
this.sendReplyBox(
Utils.html`${expression}<br />= <span class="message-error"><strong>Invalid input:</strong> ${e.message}</span>`
);
}
},
calculatehelp: [
`/calculate [arithmetic question] - Calculates an arithmetical question. Supports PEMDAS (Parenthesis, Exponents, Multiplication, Division, Addition and Subtraction), pi and e.`,
`/calculate [arithmetic question] in [base] - Returns the result in a specific base. [base] can be bin, oct, dec or hex.`,
],
};