File size: 6,614 Bytes
5c2ed06
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
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.`,
	],
};