Skip to content

Commit

Permalink
use worker threads to ensure that dice evaluations don't block the ev…
Browse files Browse the repository at this point in the history
…ent loop
  • Loading branch information
IRONM00N committed Feb 24, 2024
1 parent 1f5bddd commit 176e4dc
Show file tree
Hide file tree
Showing 4 changed files with 162 additions and 25 deletions.
55 changes: 55 additions & 0 deletions lib/dice/diceExpression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ export const enum Operator {

export interface DiceExpression {
accept<T>(visitor: DiceExpressionVisitor<T>): T;
toData(): DiceExpressionData;
}

export type DiceExpressionData = DiceData | LiteralData | BinaryExpressionData | ParenthesisData;

export interface DiceExpressionVisitor<T> {
visitDice(dice: Dice): T;
visitLiteral(lit: Literal): T;
Expand All @@ -30,6 +33,16 @@ export class Dice implements DiceExpression {
public accept<T>(visitor: DiceExpressionVisitor<T>): T {
return visitor.visitDice(this);
}

public toData(): DiceData {
return { class: 'Dice', count: this.count, sides: this.sides };
}
}

export interface DiceData {
class: 'Dice';
count: bigint;
sides: bigint;
}

export class Literal implements DiceExpression {
Expand All @@ -40,6 +53,15 @@ export class Literal implements DiceExpression {
public accept<T>(visitor: DiceExpressionVisitor<T>): T {
return visitor.visitLiteral(this);
}

public toData(): LiteralData {
return { class: 'Literal', value: this.value };
}
}

export interface LiteralData {
class: 'Literal';
value: bigint;
}

export class BinaryExpression implements DiceExpression {
Expand All @@ -56,6 +78,17 @@ export class BinaryExpression implements DiceExpression {
public accept<T>(visitor: DiceExpressionVisitor<T>): T {
return visitor.visitBinaryExpression(this);
}

public toData(): BinaryExpressionData {
return { class: 'BinaryExpression', left: this.left.toData(), operator: this.operator, right: this.right.toData() };
}
}

export interface BinaryExpressionData {
class: 'BinaryExpression';
left: DiceExpressionData;
operator: Operator;
right: DiceExpressionData;
}

export class Parenthesis implements DiceExpression {
Expand All @@ -66,6 +99,15 @@ export class Parenthesis implements DiceExpression {
public accept<T>(visitor: DiceExpressionVisitor<T>): T {
return visitor.visitParenthesis(this);
}

public toData(): ParenthesisData {
return { class: 'Parenthesis', expression: this.expression.toData() };
}
}

export interface ParenthesisData {
class: 'Parenthesis';
expression: DiceExpressionData;
}

function isOperator(val: unknown): val is Operator {
Expand All @@ -75,3 +117,16 @@ function isOperator(val: unknown): val is Operator {
function isDiceExpression(val: unknown): val is DiceExpression {
return val instanceof Dice || val instanceof Literal || val instanceof BinaryExpression || val instanceof Parenthesis;
}

export function fromData(data: DiceExpressionData): DiceExpression {
switch (data.class) {
case 'Dice':
return new Dice(data.count, data.sides);
case 'Literal':
return new Literal(data.value);
case 'BinaryExpression':
return new BinaryExpression(fromData(data.left), data.operator, fromData(data.right));
case 'Parenthesis':
return new Parenthesis(fromData(data.expression));
}
}
28 changes: 26 additions & 2 deletions lib/dice/evalDice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import diceGrammar from '#lib/dice/dice-grammar.js';
import { generateRandomBigInt } from '#lib/utils/Utils.js';
import nearley from 'nearley';
import assert from 'node:assert';
import { join } from 'node:path';
import { Worker } from 'node:worker_threads';
import {
BinaryExpression,
Dice,
Expand All @@ -19,7 +21,7 @@ type EvaluationResult = [value: bigint, history: string];
const MAX_COUNT_HIST = 1_000;
const MAX_SIDES_HIST = 10_000;
const MAX_DICE_HIST_LEN = 1_000;
const MAX_COUNT = 10_000;
const MAX_COUNT = 100_000;

export class DiceExpressionEvaluator implements DiceExpressionVisitor<EvaluationResult> {
public noHistory = false;
Expand Down Expand Up @@ -102,11 +104,33 @@ export function parseDiceNotation(phrase: string): DiceExpression | null {
return res[0];
}

export function evaluateDiceExpression(expr: DiceExpression, ignoreLimits = false) {
/** **This can be very computationally expensive** */
export function evaluateDiceExpression(expr: DiceExpression, ignoreLimits = false): EvaluationResult {
const evaluator = new DiceExpressionEvaluator(ignoreLimits);
const [result, description] = expr.accept(evaluator);

return [result, description];
}

export function evaluateDiceExpressionWorker(expr: DiceExpression, ignoreLimits = false): Promise<EvaluationResult> {
return new Promise((resolve, reject) => {
const worker = new Worker(join(import.meta.dirname, './evalDiceWorker.js'));

worker.on('message', (result) => {
resolve(result);
void worker.terminate();
});

worker.on('error', reject);

worker.on('exit', (code) => {
if (code !== 0) {
reject(new DiceEvaluationError(`stopped with exit code ${code}`));
}
});

worker.postMessage({ expr: expr.toData(), ignoreLimits });
});
}

class DiceEvaluationError extends Error {}
11 changes: 11 additions & 0 deletions lib/dice/evalDiceWorker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { parentPort } from 'worker_threads';
import { fromData } from './diceExpression.js';
import { evaluateDiceExpression } from './evalDice.js';

parentPort!.on('message', ({ expr, ignoreLimits }) => {
const diceExpr = fromData(expr);
const result = evaluateDiceExpression(diceExpr, ignoreLimits);

// send result to main thread
parentPort!.postMessage(result);
});
93 changes: 70 additions & 23 deletions src/commands/fun/diceRoll.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,97 @@
import { Arg, BotCommand, emojis, type ArgType, type CommandMessage, type SlashMessage } from '#lib';
import type { DiceExpression } from '#lib/dice/diceExpression.js';
import { evaluateDiceExpression } from '#lib/dice/evalDice.js';
import { Flag, FlagType } from '@tanzanite/discord-akairo';
import { evaluateDiceExpressionWorker, parseDiceNotation } from '#lib/dice/evalDice.js';
import { Flag, FlagType, type ArgumentGeneratorReturn } from '@tanzanite/discord-akairo';
import { ApplicationCommandOptionType } from 'discord.js';

const COMMON = [4, 6, 8, 12, 20];
const COMMON = [2, 4, 6, 8, 10, 12, 20, 100];

export default class DiceRollCommand extends BotCommand {
public constructor() {
super('dice-roll', {
aliases: ['roll', 'dice', 'die', 'rd', 'd', ...COMMON.map((n) => `d${n}`)],
aliases: ['roll', 'rd', 'r', 'dice', 'die', 'd', ...COMMON.map((n) => `d${n}`)],
category: 'fun',
description: 'Roll virtual dice.',
usage: ['roll', 'roll [sides]'],
examples: ['roll'],
args: [
usage: ['roll|rd|r <notation>', 'dice|dice|die|d <sides>', `d{${COMMON}}`],
examples: ['roll 2d10 + 5', 'r 3d20 - 4', 'rd 4 * d20', 'dice 6', 'd 36', 'd20'],
helpArgs: [
{
id: 'notation',
name: 'notation',
description: 'The dice notation to evaluate.',
type: 'diceNotation',
match: 'rest',
prompt: 'What dice notation would you like evaluated?',
retry: (_, data) => `{error} Invalid dice notation${data.failure ? `: ${data.failure.value}` : ''}`,
slashType: ApplicationCommandOptionType.String
},
optional: false
}
],
slashOptions: [
{
id: 'override',
description: 'Override limitations.',
flag: '--override',
match: 'flag',
optional: true,
slashType: false,
only: 'text',
ownerOnly: true
name: 'notation',
description: 'What dice notation would you like evaluated?',
type: ApplicationCommandOptionType.String
}
],
clientPermissions: [],
userPermissions: [],
slash: true
slash: true,
flags: ['--override'],
lock: 'user'
});
}

public override *args(message: CommandMessage): ArgumentGeneratorReturn {
const alias = message.util.parsed?.alias ?? '';

let notation;

const match = /^d(\d+)$/.exec(alias);
if (match != null && match[1]) {
notation = parseDiceNotation(`1d${match[1]}`);
} else if (['dice', 'die', 'd'].includes(alias)) {
const num = yield {
type: (_, p) => {
if (!p || isNaN(+p)) return null;
const n = BigInt(p);
if (n <= 1n) return null;
return n;
},
prompt: {
start: 'Choose a number of sides for the dice, greater than 1',
retry: `${emojis.error} A dice must have a number of sides greater than 1`
}
};

notation = parseDiceNotation(`1d${num}`);
} else {
notation = yield {
description: 'The dice notation to evaluate.',
type: 'diceNotation',
match: 'rest',
prompt: {
start: 'What dice notation would you like evaluated?',
retry: (_, data) => `{error} Invalid dice notation${data.failure ? `: ${data.failure.value}` : ''}`,
optional: false
}
};
}

const override = message.author.isOwner()
? yield {
flag: '--override',
match: 'flag',
prompt: {
optional: true
}
}
: undefined;

return { notation, override };
}

public override async exec(
message: CommandMessage | SlashMessage,
args: { notation: ArgType<'diceNotation'> | string; override: ArgType<'flag'> }
) {
if (message.util.isSlashMessage(message)) await message.interaction.deferReply();

if (typeof args.notation === 'string') {
const cast: DiceExpression | null | Flag<FlagType.Fail> = await Arg.cast('diceNotation', message, args.notation);
if (cast == null || cast instanceof Flag) {
Expand All @@ -55,7 +102,7 @@ export default class DiceRollCommand extends BotCommand {

let total, hist;
try {
[total, hist] = evaluateDiceExpression(args.notation, args.override === true && message.author.isOwner());
[total, hist] = await evaluateDiceExpressionWorker(args.notation, args.override === true && message.author.isOwner());
} catch (e) {
return await message.util.reply(`${emojis.error} ${e}`);
}
Expand Down

0 comments on commit 176e4dc

Please sign in to comment.