dimensions-ai
Version:
A generalized AI Competition framework that allows you to create any competition you want in any language you want with no hassle.
700 lines • 32.7 kB
JavaScript
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
import { deepMerge } from '../utils/DeepMerge';
import { MatchEngine } from '../MatchEngine';
import { Agent } from '../Agent';
import { Logger } from '../Logger';
import { FatalError, MatchDestroyedError, MatchWarn, MatchError, NotSupportedError, MatchReplayFileError, AgentError, AgentCompileError, AgentInstallError, } from '../DimensionError';
var COMMAND_STREAM_TYPE = MatchEngine.COMMAND_STREAM_TYPE;
import { genID } from '../utils';
import { deepCopy } from '../utils/DeepCopy';
import path from 'path';
import extract from 'extract-zip';
import { removeDirectory, removeFile } from '../utils/System';
import { BOT_DIR } from '../Station';
import { mkdirSync, existsSync, statSync } from 'fs';
/**
* An match created using a {@link Design} and a list of Agents. The match can be stopped and resumed with
* {@link stop}, {@link resume}, and state and configurations can be retrieved at any point in time with the
* {@link state} and {@link configs} fields
*
* @see {@link Design} for Design information
* @see {@link Agent} for Agent information
*/
export class Match {
/**
* Match Constructor
* @param design - The {@link Design} used
* @param agents - List of agents used to create Match.
* @param configs - Configurations that are passed to every run through {@link Design.initialize}, {@link Design.update}, and {@link Design.getResults} functioon in the
* given {@link Design}
*/
constructor(design,
/**
* agent meta data regarding files, ids, etc.
*/
agentFiles, configs = {}, dimension) {
this.design = design;
this.agentFiles = agentFiles;
this.dimension = dimension;
/**
* List of the agents currently involved in the match.
* @See {@link Agent} for details on the agents.
*/
this.agents = [];
/**
* Map from an {@link Agent.ID} ID to the {@link Agent}
*/
this.idToAgentsMap = new Map();
/**
* The current time step of the Match. This time step is independent of any {@link Design} and agents are coordianted
* against this timeStep
*/
this.timeStep = 0;
/**
* The match logger.
* @see {@link Logger} for details on how to use this
*/
this.log = new Logger();
/**
* The results field meant to store any results retrieved with {@link Design.getResults}
* @default `null`
*/
this.results = null;
/**
* The current match status
*/
this.matchStatus = Match.Status.UNINITIALIZED;
/**
* A mapping from {@link Agent} IDs to the tournament id of the {@link Player} in a tournament that generated the
* {@link Agent}
*/
this.mapAgentIDtoTournamentID = new Map();
/**
* Match Configurations. See {@link Match.Configs} for configuration options
*/
this.configs = {
name: '',
loggingLevel: Logger.LEVEL.INFO,
engineOptions: {},
secureMode: false,
agentOptions: deepCopy(Agent.OptionDefaults),
languageSpecificAgentOptions: {},
storeReplay: true,
storeReplayDirectory: 'replays',
storeErrorLogs: true,
storeErrorDirectory: 'errorlogs',
agentSpecificOptions: [],
storeMatchErrorLogs: false,
detached: false,
};
/** Signal to stop at next time step */
this.shouldStop = false;
/**
* Non local files that should be removed as they are stored somewhere else. Typically bot files are non local if
* using a backing storage service
*/
this.nonLocalFiles = [];
// override configs with provided configs argument
this.configs = deepMerge(deepCopy(this.configs), deepCopy(configs));
// agent runs in securemode if parent match is in securemode
this.configs.agentOptions.secureMode = this.configs.secureMode;
// agent logging level is inherited from parent match.
this.configs.agentOptions.loggingLevel = this.configs.loggingLevel;
this.id = Match.genMatchID();
this.creationDate = new Date();
if (this.configs.name) {
this.name = this.configs.name;
}
else {
this.name = `match_${this.id}`;
}
// set logging level to what was given
this.log.level = this.configs.loggingLevel;
this.log.identifier = this.name;
// store reference to the matchEngine used and override any options
this.matchEngine = new MatchEngine(this.design, this.log.level);
this.matchEngine.setEngineOptions(configs.engineOptions);
}
/**
* Initializes this match using its configurations and using the {@link Design.initialize} function. This can
* throw error with agent generation, design initialization, or with engine initialization. In engine initialization,
* errors that can be thrown can be {@link AgentCompileError | AgentCompileErrors},
* {@link AgentInstallError | AgentInstallErrors}, etc.
*
*
* @returns a promise that resolves true if initialized correctly
*/
initialize() {
return __awaiter(this, void 0, void 0, function* () {
try {
this.log.infobar();
this.log.info(`Design: ${this.design.name} | Initializing match - ID: ${this.id}, Name: ${this.name}`);
const overrideOptions = this.design.getDesignOptions().override;
this.log.detail('Match Configs', this.configs);
this.timeStep = 0;
if (this.configs.storeErrorLogs) {
// create error log folder if it does not exist
if (!existsSync(this.configs.storeErrorDirectory)) {
mkdirSync(this.configs.storeErrorDirectory);
}
const matchErrorLogDirectory = this.getMatchErrorLogDirectory();
if (!existsSync(matchErrorLogDirectory)) {
mkdirSync(matchErrorLogDirectory);
}
}
// this allows engine to be reused after it ran once
this.matchEngine.killOffSignal = false;
// copy over any agent bot files if dimension has a backing storage service and the agent has botkey specified
// copy them over the agent's specified file location to use
const retrieveBotFilePromises = [];
const retrieveBotFileIndexes = [];
if (this.dimension.hasStorage()) {
this.agentFiles.forEach((agentFile, index) => {
if (agentFile.botkey && agentFile.file) {
let useCachedBotFile = false;
if (this.configs.agentSpecificOptions[index] &&
this.configs.agentSpecificOptions[index].useCachedBotFile) {
useCachedBotFile = true;
}
retrieveBotFilePromises.push(this.retrieveBot(agentFile.botkey, agentFile.file, useCachedBotFile));
retrieveBotFileIndexes.push(index);
}
});
}
const retrievedBotFiles = yield Promise.all(retrieveBotFilePromises);
retrieveBotFileIndexes.forEach((val, index) => {
if (!(typeof this.agentFiles[val] === 'string')) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this.agentFiles[val].file = retrievedBotFiles[index];
// push them as non local files so they can be removed when match is done
this.nonLocalFiles.push(path.dirname(retrievedBotFiles[index]));
}
});
// Initialize agents with agent files
this.agents = Agent.generateAgents(this.agentFiles, this.configs.agentOptions, this.configs.languageSpecificAgentOptions);
this.agents.forEach((agent) => {
this.idToAgentsMap.set(agent.id, agent);
if (agent.tournamentID !== null) {
this.mapAgentIDtoTournamentID.set(agent.id, agent.tournamentID);
}
});
// use the matchengine to initialize agents if not in detached mode
if (!this.configs.detached) {
// if overriding with custom design, log some other info and use a different engine initialization function
if (overrideOptions.active) {
this.log.detail('Match Arguments', overrideOptions.arguments);
yield this.matchEngine.initializeCustom();
}
else {
// Initialize the matchEngine and get it ready to run and process I/O for agents
yield this.matchEngine.initialize(this.agents, this);
}
}
// by now all agents should up and running, all compiled and ready
// Initialize match according to `design` by delegating intialization task to the enforced `design`
yield this.design.initialize(this);
// remove initialized status and set as READY
this.matchStatus = Match.Status.READY;
return true;
}
catch (err) {
yield this.handleLogFiles();
// kill processes and clean up and then throw the error
yield this.killAndCleanUp();
if (err instanceof AgentError) {
if (err instanceof AgentCompileError ||
err instanceof AgentInstallError) {
// mark agents with compile or install error as having crashed
// console.log(err, err.agentID);
this.agents[err.agentID].status = Agent.Status.CRASHED;
}
}
throw err;
}
});
}
/**
* Retrieves a bot through its key and downloads it to a random generated folder. Returns the new file's path
* @param botkey
* @param file
* @param useCached - if true, storage plugin will avoid redownloading data. If false, storage plugin will always
* redownload data
*/
retrieveBot(botkey, file, useCached) {
return __awaiter(this, void 0, void 0, function* () {
const dir = BOT_DIR + '/anon-' + genID(18);
mkdirSync(dir);
const zipFile = path.join(dir, 'bot.zip');
// if useCached is true, actualZipFileLocation will likely be different than zipFile, and we directly re-extract
// the bot from that zip file. It can be argued that it would be better to cache the unzipped bot instead but this
// could potentially be a security concern by repeatedly copying over unzipped bot files instead of the submitted
// zip file; and zip is smaller to cache
const actualZipFileLocation = yield this.dimension.storagePlugin.download(botkey, zipFile, useCached);
yield extract(actualZipFileLocation, {
dir: dir,
});
return path.join(dir, path.basename(file));
});
}
/**
* Runs this match to completion. Sets this.results to match results and resolves with the match results when done
*/
run() {
// returning new promise explicitly here because we need to store reject
// eslint-disable-next-line no-async-promise-executor
return new Promise((resolve, reject) => __awaiter(this, void 0, void 0, function* () {
try {
this.runReject = reject;
let status;
this.matchStatus = Match.Status.RUNNING;
// check if our design is a javascript/typescript based design or custom and to be executed with a
// provided command
const overrideOptions = this.design.getDesignOptions().override;
if (overrideOptions.active) {
this.log.system('Running custom');
yield this.matchEngine.runCustom(this);
this.results = yield this.getResults();
// process results with result handler if necessary
if (overrideOptions.resultHandler) {
this.results = overrideOptions.resultHandler(this.results);
}
}
else {
// otherwise run the match using the design with calls to this.next()
do {
status = yield this.next();
} while (status != Match.Status.FINISHED);
this.agents.forEach((agent) => {
agent._clearTimer();
});
this.results = yield this.getResults();
}
// kill processes and clean up
yield this.killAndCleanUp();
// upload replayfile if given and using storage plugin
if (this.results.replayFile) {
// verify file exists and its a file
if (existsSync(this.results.replayFile)) {
if (!statSync(this.results.replayFile).isDirectory()) {
if (this.configs.storeReplay) {
this.replayFile = this.results.replayFile;
if (this.dimension.hasStorage()) {
const fileName = path.basename(this.results.replayFile);
// store to storage and get key
const key = yield this.dimension.storagePlugin.upload(this.results.replayFile, `${path.join(this.configs.storeReplayDirectory, fileName)}`);
this.replayFileKey = key;
// once uploaded and meta data stored, remove old file
removeFile(this.replayFile);
}
}
else {
removeFile(this.results.replayFile);
}
}
else {
reject(new MatchReplayFileError(`Replay file provided ${this.results.replayFile} is not a file`));
}
}
else {
reject(new MatchReplayFileError(`Replay file provided ${this.results.replayFile} does not exist`));
}
}
yield this.handleLogFiles();
this.finishDate = new Date();
resolve(this.results);
}
catch (error) {
reject(error);
}
}));
}
/**
* Handles log files and stores / uploads / deletes them as necessary
*/
handleLogFiles() {
return __awaiter(this, void 0, void 0, function* () {
const uploadLogPromises = [];
const fileLogsToRemove = [];
// upload error logs if stored
if (this.configs.storeErrorLogs) {
// upload each agent error log
for (const agent of this.agents) {
const filepath = path.join(this.getMatchErrorLogDirectory(), agent.getAgentErrorLogFilename());
if (existsSync(filepath)) {
// check if replay file is empty
if (agent._logsize === 0) {
fileLogsToRemove.push(filepath);
}
else if (this.dimension.hasStorage()) {
const uploadKeyPromise = this.dimension.storagePlugin
.upload(filepath, filepath)
.then((key) => {
return { key: key, agentID: agent.id };
});
uploadLogPromises.push(uploadKeyPromise);
fileLogsToRemove.push(filepath);
}
}
else {
// this shouldn't happen
this.log.error(`Agent ${this.id} log file at ${filepath} does not exist`);
}
}
}
const logkeys = yield Promise.all(uploadLogPromises);
logkeys.forEach((val) => {
this.idToAgentsMap.get(val.agentID).logkey = val.key;
});
if (fileLogsToRemove.length === this.agents.length) {
removeDirectory(this.getMatchErrorLogDirectory());
}
else {
fileLogsToRemove.forEach((logPath) => {
removeFile(logPath);
});
}
});
}
/**
* Step forward the match by one timestep by sending commands individually.
*/
step(commands) {
var _a;
return __awaiter(this, void 0, void 0, function* () {
const engineOptions = this.matchEngine.getEngineOptions();
if (engineOptions.commandStreamType === COMMAND_STREAM_TYPE.SEQUENTIAL) {
const status = (_a = (yield this.design.update(this, commands))) !== null && _a !== void 0 ? _a : Match.Status.RUNNING;
return status;
}
else {
throw new NotSupportedError('Only sequential streaming is allowed');
}
});
}
/**
* Next function. Moves match forward by one timestep. Resolves with the match status
* This function should always used to advance forward a match unless a custom design is provided
*
* Gathers commands from agents via the {@link MatchEngine}
*
* Should not be called by user
*/
next() {
return __awaiter(this, void 0, void 0, function* () {
const engineOptions = this.matchEngine.getEngineOptions();
if (engineOptions.commandStreamType === COMMAND_STREAM_TYPE.SEQUENTIAL) {
// if this.shouldStop is set to true, await for the resume promise to resolve
if (this.shouldStop == true) {
// set status and stop the engine
this.matchStatus = Match.Status.STOPPED;
this.matchEngine.stop(this);
this.log.info('Stopped match');
this.resolveStopPromise();
yield this.resumePromise;
this.matchEngine.resume(this);
this.log.info('Resumed match');
this.shouldStop = false;
}
// we reset each Agent for the next move
this.agents.forEach((agent) => {
// continue agents again
agent.resume();
// setup the agent and its promises and get it ready for the next move
agent._setupMove();
// if timeout is set active and agent not already terminated
if (engineOptions.timeout.active && !agent.isTerminated()) {
agent._setTimeout(() => {
// if agent times out, emit the timeout event
agent.timeout();
}, engineOptions.timeout.max + MatchEngine.timeoutBuffer);
}
// each of these steps can take ~2 ms
});
// after agebts are reset, we are ready to receive commands from them
// Updates sent by agents on previous timestep can be obtained with MatchEngine.getCommands
// This is also the COORDINATION step, where we essentially wait for all commands from all agents to be
// delivered out or until one of them fails
const commands = yield this.matchEngine.getCommands(this);
this.log.system(`Retrieved ${commands.length} commands`);
// Updates the match state and sends appropriate signals to all Agents based on the stored `Design`
const status = yield this.design.update(this, commands);
// default status is running if no status returned
if (!status) {
this.matchStatus = Match.Status.RUNNING;
}
else {
this.matchStatus = status;
}
// update timestep now
this.timeStep += 1;
return status;
}
// TODO: implement this
else if (engineOptions.commandStreamType === COMMAND_STREAM_TYPE.PARALLEL) {
// with a parallel structure, the `Design` updates the match after each command sequence, delimited by \n
// this means agents end up sending commands using out of sync state information, so the `Design` would need to
// adhere to this. Possibilities include stateless designs, or heavily localized designs where out of
// sync states wouldn't matter much
throw new NotSupportedError('PARALLEL command streaming has not been implemented yet');
}
});
}
/**
* Stops the match. For non-custom designs, stops at the next nearest timestep possible. Otherwise attempts to stop
* the match using the {@link MatchEngine} stopCustom function.
*
* Notes:
* - If design uses a PARALLEL match engine, stopping behavior can be a little unpredictable
* - If design uses a SEQUENTIAL match engine, a stop will result in ensuring all agents complete all their actions up
* to a coordinated stopping `timeStep`
*/
stop() {
return new Promise((resolve, reject) => {
if (this.matchStatus != Match.Status.RUNNING) {
this.log.warn("You can't stop a match that is not running");
reject(new MatchWarn('You can\t stop a match that is not running'));
return;
}
// if override is on, we stop using the matchEngine stop function
if (this.design.getDesignOptions().override.active) {
this.matchEngine
.stopCustom(this)
.then(() => {
this.matchStatus = Match.Status.STOPPED;
resolve();
})
.catch(reject);
return;
}
else {
this.resolveStopPromise = resolve;
this.log.info('Stopping match...');
this.resumePromise = new Promise((resolve) => {
this.resumeResolve = resolve;
});
this.shouldStop = true;
}
});
}
/**
* Resume the match if it was in the stopped state
* @returns true if succesfully resumed
*/
resume() {
return new Promise((resolve, reject) => {
if (this.matchStatus != Match.Status.STOPPED) {
this.log.warn("You can't resume a match that is not stopped");
reject(new MatchWarn("You can't resume a match that is not stopped"));
return;
}
this.log.info('Resuming match...');
// if override is on, we resume using the matchEngine resume function
if (this.design.getDesignOptions().override.active) {
this.matchEngine
.resumeCustom(this)
.then(() => {
this.matchStatus = Match.Status.RUNNING;
resolve();
})
.catch(reject);
}
else {
// set back to running and resolve
this.matchStatus = Match.Status.RUNNING;
this.resumeResolve();
resolve();
}
});
}
/**
* Stop all agents through the match engine and clean up any other files and processes
*
* Used by custom and dimensions based designs
*/
killAndCleanUp() {
return __awaiter(this, void 0, void 0, function* () {
yield this.matchEngine.killAndClean(this);
const removeNonLocalFilesPromises = [];
this.nonLocalFiles.forEach((nonLocalFile) => {
removeNonLocalFilesPromises.push(removeDirectory(nonLocalFile));
});
yield Promise.all(removeNonLocalFilesPromises);
});
}
/**
* Terminate an {@link Agent}, kill the process. Note, the agent is still stored in the Match, but you can't send or
* receive messages from it anymore
*
* @param agent - id of agent or the Agent object to kill
* @param reason - an optional reason string to provide for logging purposes
*/
kill(agent, reason) {
return __awaiter(this, void 0, void 0, function* () {
if (agent instanceof Agent) {
this.matchEngine.kill(agent, reason);
}
else {
this.matchEngine.kill(this.idToAgentsMap.get(agent), reason);
}
});
}
/**
* Retrieve results through delegating the task to {@link Design.getResults}
*/
getResults() {
return __awaiter(this, void 0, void 0, function* () {
// Retrieve match results according to `design` by delegating storeResult task to the enforced `design`
return yield this.design.getResults(this);
});
}
/**
* Sends a message to the standard input of all agents in this match
* @param message - the message to send to all agents available
* @returns a promise resolving true/false if it was succesfully sent
*/
sendAll(message) {
return new Promise((resolve) => {
const sendPromises = [];
this.agents.forEach((agent) => {
sendPromises.push(this.send(message, agent));
});
Promise.all(sendPromises).then((sendStatus) => {
// if all promises resolve, we sent all messages
resolve(sendStatus.every((v) => v === true));
});
});
}
/**
* Functional method for sending a message string to a particular {@link Agent}. Returns a promise that resolves true
* if succesfully sent. Returns false if could not send message, meaning agent was also killed.
* @param message - the string message to send
* @param receiver - receiver of message can be specified by the {@link Agent} or it's {@link Agent.ID} (a number)
*/
send(message, receiver) {
return __awaiter(this, void 0, void 0, function* () {
if (receiver instanceof Agent) {
try {
yield this.matchEngine.send(this, message, receiver.id);
}
catch (err) {
this.log.error(err);
yield this.kill(receiver, 'could not send message anymore');
return false;
}
}
else {
try {
yield this.matchEngine.send(this, message, receiver);
}
catch (err) {
this.log.error(err);
yield this.kill(receiver, 'could not send message anymore');
return false;
}
}
return true;
});
}
/**
* Throw an {@link FatalError}, {@link MatchError}, or {@link MatchWarn} within the Match. Indicates that the
* {@link Agent} with id agentID caused this error/warning.
*
* Throwing MatchWarn will just log a warning level message and throwing a MatchError will just log it as an error
* level message.
*
* Throwing FatalError will cause the match to automatically be destroyed. This is highly not recommended and it is
* suggested to have some internal logic to handle moments when the match cannot continue.
*
*
* Examples are misuse of an existing command or using incorrect commands or sending too many commands
* @param agentID - the misbehaving agent's ID
* @param error - The error
*/
throw(agentID, error) {
return __awaiter(this, void 0, void 0, function* () {
// Fatal errors are logged and should end the whole match
const agent = this.idToAgentsMap.get(agentID);
if (error instanceof FatalError) {
yield this.destroy();
const msg = `FatalError: ${agent.name} | ${error.message}`;
this.log.error(msg);
if (this.configs.storeMatchErrorLogs)
agent.writeToErrorLog(msg);
}
else if (error instanceof MatchWarn) {
const msg = `ID: ${agentID}, ${this.idToAgentsMap.get(agentID).name} | ${error.message}`;
this.log.warn(msg);
if (this.configs.storeMatchErrorLogs)
agent.writeToErrorLog(msg);
}
else if (error instanceof MatchError) {
const msg = `ID: ${agentID}, ${this.idToAgentsMap.get(agentID).name} | ${error.message}`;
this.log.error(msg);
if (this.configs.storeMatchErrorLogs)
agent.writeToErrorLog(msg);
}
else {
this.log.error('User tried throwing an error of type other than FatalError, MatchWarn, or MatchError');
}
});
}
/**
* Destroys this match and makes sure to remove any leftover processes
*/
destroy() {
return __awaiter(this, void 0, void 0, function* () {
// reject the run promise first if it exists
if (this.runReject)
this.runReject(new MatchDestroyedError('Match was destroyed'));
// now actually stop and clean up
yield this.killAndCleanUp(); // Theoretically this line is not needed for custom matches, but in here in case
yield this.matchEngine.killAndCleanCustom(this);
});
}
/**
* Generates a 12 character nanoID string for identifying matches
*/
static genMatchID() {
return genID(12);
}
getMatchErrorLogDirectory() {
return path.join(this.configs.storeErrorDirectory, `match_${this.id}`);
}
}
(function (Match) {
let Status;
(function (Status) {
/** Match was created with new but initialize was not called */
Status["UNINITIALIZED"] = "uninitialized";
/**
* If the match has been initialized and checks have been passed, the match is ready to run using {@link Match.run}
*/
Status["READY"] = "ready";
/**
* If the match is running at the moment
*/
Status["RUNNING"] = "running";
/**
* If the match is stopped
*/
Status["STOPPED"] = "stopped";
/**
* If the match is completed
*/
Status["FINISHED"] = "finished";
/**
* If fatal error occurs in Match, appears when match stops itself
*/
Status["ERROR"] = "error";
})(Status = Match.Status || (Match.Status = {}));
})(Match || (Match = {}));
//# sourceMappingURL=index.js.map