@sarahisweird/hmoog
Version:
Out-of-game automation for Hackmud
335 lines (334 loc) • 12.2 kB
JavaScript
import * as native from '@sarahisweird/hmoog-native';
import FileWatcher from './fileWatcher.js';
import { randomUUID } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { getShellPath, popAssert, removeColors, waitMs } from './utils.js';
import { OogExecutionError, OogInitializationError, OogNotInitializedError } from './errors.js';
import { FlushReason } from './types.js';
import { ACTIVATING_HARDLINE_MESSAGE, FAILURE_MESSAGE, FLUSH_MESSAGE, GREATER_THAN_ENCODED, HARDLINE_ACTIVE_MESSAGE, HARDLINE_DISCONNECTED_MESSAGE, HARDLINE_RECALIBRATING_MESSAGE, LESS_THAN_ENCODED, NO_HARDLINES_AVAILABLE_MESSAGE, SUCCESS_MESSAGE } from './constants.js';
/** The class that manages OOG activity. */
export class HmOog {
shellPath;
shouldFocusShell;
fileWatcher;
didInit = false;
lastShellFlag;
unprocessedLines = [];
isInHardline = false;
/**
* @param options Initialisation options - see {@link OogOptions}
*/
constructor(options) {
const defaultedOptions = {
shellPath: getShellPath(),
shouldFocusShell: true,
...(options || {}),
};
if (!native.init()) {
throw new OogInitializationError('Could not initialize native module!');
}
this.shellPath = defaultedOptions.shellPath;
this.shouldFocusShell = defaultedOptions.shouldFocusShell;
this.fileWatcher = new FileWatcher(defaultedOptions.shellPath);
}
/**
* Initialize the OOG. Must be called before any other methods!
*/
async init() {
if (this.shouldFocusShell) {
native.sendMouseClick(100, 100, false);
}
await this.updateShell();
this.consumeLines(); // Discard old output
this.didInit = true;
}
/**
* Wait until the shell is flushed.
*
* @returns why the shell was flushed
*/
async waitForFlush() {
this.assertDidInit();
await this.fileWatcher.waitForChange();
return await this.updateShell();
}
;
/**
* Wait until the shell is flushed by a `flush` command.
*/
async waitForCommandFlush() {
while (await this.waitForFlush() !== FlushReason.COMMAND) { }
}
/**
* Runs the `flush` command and waits until it successfully flushed the shell.
*
* @param timeout=0 the maximum number of milliseconds to try flushing.
* A value below 1 means no waiting.
*
* @returns whether or not a timeout happened.
*
* @remarks
* The flush operation may be delayed by another program that's currently being executed,
* in which case this function only returns when both the program and the flush command ran.
*/
async flush(timeout = 0) {
this.assertDidInit();
let didCommandFlush = false;
let didTimeout = false;
this.waitForCommandFlush().then(() => { didCommandFlush = true; });
if (timeout > 0) {
setTimeout(() => didTimeout = true, timeout);
}
await this.sendRaw('flush');
while (!didCommandFlush && !didTimeout) {
native.sendKeystrokes('\n');
await waitMs(50);
}
// For some reason, the delay sometimes isn't big enough. This should fix it.
await waitMs(500);
return !didCommandFlush && didTimeout;
}
/**
* Sends a command to Hackmud and processes the result.
*
* @param command The command to send
* @returns The result of the command
* @throws TypeError if the command contains newlines
* @throws OogExecutionError if the command couldn't be typed into Hackmud
*/
async runCommand(command) {
if (!await this.sendRaw(command))
throw new OogExecutionError('Could not send command to Hackmud!');
await this.flush();
return this.processOutput(command);
}
processOutput(ofCommand) {
const lines = this.consumeLines();
let echoedCommand = undefined;
while (echoedCommand !== ofCommand) {
echoedCommand = lines.length > 0
? removeColors(lines.shift()).slice(2)
: undefined;
if (echoedCommand === undefined)
throw new OogExecutionError('Could not find the command that was sent!');
}
let success;
if ([SUCCESS_MESSAGE, FAILURE_MESSAGE].includes(lines[0])) {
success = lines.shift() == SUCCESS_MESSAGE;
}
const rawText = lines.join('\n');
const rawUncoloredText = removeColors(rawText)
.replaceAll(LESS_THAN_ENCODED, '<')
.replaceAll(GREATER_THAN_ENCODED, '>');
const uncoloredLines = rawUncoloredText.split('\n');
return {
command: ofCommand,
success: success,
output: {
colored: {
raw: rawText,
lines: lines,
},
uncolored: {
raw: rawUncoloredText,
lines: uncoloredLines,
},
},
};
}
/**
* Sends a command to Hackmud.
*
* @param command The command to send
* @returns whether the command could be typed into Hackmud
* @throws TypeError if the command contains newlines
*/
async sendRaw(command) {
if (command.trim() === '')
throw new TypeError('Command cannot be empty!');
if (command.includes('\n'))
throw new TypeError('Commands cannot contain newlines!');
if (!native.sendKeystrokes(command + '\n'))
return false;
await waitMs(50);
return true;
}
/**
* Removes and returns all shell lines yet to be processed by other methods.
*
* @returns a list of the raw shell output
*/
consumeLines() {
const lines = this.unprocessedLines;
this.unprocessedLines = [];
return lines;
}
/**
* Get the current hardline activation status.
*/
isHardlineActive() {
return this.isInHardline;
}
/**
* Enters hardline.
*
* @remarks
*
* Due to the hardline GUI, it takes *at least* 15 seconds to enter hardline!
* If entering hardline fails, the call takes ~2s.
* If it succeeds, it will take over 20s!
*
* This delay can **not** be reduced meaningfully due to the
* `-hardline active-` message, as well as all the animations being pretty slow.
*
* @returns 0 if successful, otherwise how many milliseconds are left until the next hardline.
*/
async enterHardline() {
this.consumeLines();
await this.sendRaw('kernel.hardline');
// If we can flush, we can't be in the hardline!
const didTimeout = await this.flush(3000);
if (!didTimeout) {
// Apparently, a flush happens before entering hardline.
// Unsure if it's only sometimes or always,
// but we can just check the response if we got one.
const cooldown = this.getHardlineCooldown();
if (cooldown > 0)
return cooldown;
}
await waitMs(8000);
for (let _i = 0; _i < 12; _i++) {
native.sendKeystrokes('0123456789');
await waitMs(10);
}
await waitMs(11000);
await this.flush();
this.consumeLines();
return 0;
}
/**
* Exits hardline.
*
* @remarks
*
* Wait five seconds after calling `kernel.hardline {dc: true}`, due to the
* `-hardline disconnected-` message breaking parsing.
*
* While it could be reduced, it would also show up unexpectedly in results
* from {@link runCommand}!
*/
async exitHardline() {
await this.sendRaw('kernel.hardline {dc: true}');
await waitMs(5000);
await this.flush();
this.consumeLines();
}
/**
* Helper method to get the number of milliseconds left until the next hardline is available.
*
* @private
*/
getHardlineCooldown() {
const result = this.processOutput('kernel.hardline');
const response = result.output.colored.lines[0];
if (response && response === ACTIVATING_HARDLINE_MESSAGE)
return 0;
let secondsString;
if (response && response.startsWith(NO_HARDLINES_AVAILABLE_MESSAGE)) {
secondsString = response.substring(NO_HARDLINES_AVAILABLE_MESSAGE.length + 1);
}
else if (response && response.startsWith(HARDLINE_RECALIBRATING_MESSAGE)) {
secondsString = response.substring(HARDLINE_RECALIBRATING_MESSAGE.length + 1);
}
else {
const errorMessage = 'Could not enter hardline, and yet there is no recalibration message!\n'
+ 'Response from kernel.hardline:\n'
+ response;
throw new OogExecutionError(errorMessage);
}
secondsString = secondsString
.split(' ')[0]
.replace('s', '');
// Add one second, just to be sure. "0s" remaining is a thing.
return (parseInt(secondsString) + 1) * 1000;
}
/**
* Small helper method to check if {@link init} has been run.
*
* @private
* @throws OogNotInitializedError if not initialized
*/
assertDidInit() {
if (!this.didInit)
throw new OogNotInitializedError();
}
/**
* Sends a special flag, so we can find where we last left off.
*
* @remarks
*
* The reason we need to do this is that we can't tell apart old stuff from new stuff
* in every case. If the outputs have been exactly the same, we'd need to guesswork.
* This completely negates the need for guessing!
*
* @private
*/
async placeShellFlag() {
this.lastShellFlag = randomUUID().toString();
await waitMs(500);
await this.sendRaw(`# ${this.lastShellFlag}`);
}
/**
* Helper method that searches for the previous {@link placeShellFlag shell flag} and removes everything
* before it, including the flag itself and its error output.
*
* @private
* @param lines The lines to process
*/
removeOldLines(lines) {
if (!this.lastShellFlag)
return lines;
const lastLineIndex = lines.findIndex(line => line.includes(this.lastShellFlag));
if (this.didInit && lastLineIndex === -1)
throw new Error('Couldn\'t merge shell histories!');
return lines.slice(lastLineIndex + 3);
}
/**
* Reads the actual shell.txt file and puts yet unprocessed lines into {@link unprocessedLines}.
*
* Removes *some* junk output like any `flush` executions.
*
* @private
* @returns The reason for the last shell flush
*/
async updateShell() {
const newContents = await readFile(this.shellPath, { encoding: 'utf8' });
const lines = newContents.split('\n');
let newLines = this.removeOldLines(lines);
if (newLines.length === 0)
return FlushReason.AUTO;
const flushReason = newLines[newLines.length - 1] === FLUSH_MESSAGE
? FlushReason.COMMAND
: FlushReason.AUTO;
if (newLines.indexOf(HARDLINE_ACTIVE_MESSAGE) !== -1) {
this.isInHardline = true;
}
if (newLines.indexOf(HARDLINE_DISCONNECTED_MESSAGE) !== -1) {
this.isInHardline = false;
}
if (flushReason === FlushReason.AUTO)
return FlushReason.AUTO;
// :(
newLines = newLines.filter(line => (line !== HARDLINE_ACTIVE_MESSAGE) && (line !== HARDLINE_DISCONNECTED_MESSAGE));
if (flushReason === FlushReason.COMMAND) {
popAssert(newLines, FLUSH_MESSAGE);
// If the last command also was a flush, this will be empty.
if (newLines.length > 0) {
popAssert(newLines, '');
}
}
this.unprocessedLines.push(...newLines);
await this.placeShellFlag();
return flushReason;
}
}