├── forever.sh ├── .gitignore ├── brikkit.conf.default ├── events ├── start.js ├── prestart.js ├── event.js ├── mapchange.js ├── baseevent.js ├── leave.js ├── join.js └── chat.js ├── parsers ├── baseparser.js ├── parser.js ├── chat.js ├── prestart.js ├── leave.js ├── start.js ├── mapchange.js └── join.js ├── package.json ├── Makefile ├── data ├── configuration.js ├── profile.js └── player.js ├── terminal.js ├── LICENSE ├── server.js ├── pluginsystem.js ├── scraper.js ├── brickadia.js ├── brikkit.js └── README.md /forever.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | (while true; do 4 | ./brikkit 5 | sleep 3 6 | done) 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | brikkit.conf 2 | node_modules 3 | nbproject 4 | brickadia 5 | build 6 | plugins 7 | conf 8 | saved 9 | logs 10 | -------------------------------------------------------------------------------- /brikkit.conf.default: -------------------------------------------------------------------------------- 1 | EMAIL=YOUR BRICKADIA EMAIL@SOMETHING.COM 2 | PASSWORD=YOUR BRICKADIA PASSWORD 3 | SERVER_MAP=plate 4 | PORT=7777 -------------------------------------------------------------------------------- /events/start.js: -------------------------------------------------------------------------------- 1 | const BaseEvent = require('./baseevent.js'); 2 | 3 | class StartEvent extends BaseEvent { 4 | constructor(date) { 5 | super(date); 6 | } 7 | 8 | getType() { 9 | return 'start'; 10 | } 11 | }; 12 | 13 | module.exports = StartEvent; -------------------------------------------------------------------------------- /parsers/baseparser.js: -------------------------------------------------------------------------------- 1 | class BaseParser { 2 | // returns null OR 3 | // if something useful was parsed, something that varies per parser 4 | parse(generator, line) { 5 | throw new Error('Not implemented!'); 6 | } 7 | } 8 | 9 | module.exports = BaseParser; 10 | -------------------------------------------------------------------------------- /events/prestart.js: -------------------------------------------------------------------------------- 1 | const BaseEvent = require('./baseevent.js'); 2 | 3 | class PreStartEvent extends BaseEvent { 4 | constructor(date) { 5 | super(date); 6 | } 7 | 8 | getType() { 9 | return 'prestart'; 10 | } 11 | }; 12 | 13 | module.exports = PreStartEvent; -------------------------------------------------------------------------------- /events/event.js: -------------------------------------------------------------------------------- 1 | const Event = {}; 2 | 3 | Event.ChatEvent = require('./chat.js'); 4 | Event.JoinEvent = require('./join.js'); 5 | Event.LeaveEvent = require('./leave.js'); 6 | Event.PreStartEvent = require('./prestart.js'); 7 | Event.StartEvent = require('./start.js'); 8 | Event.MapChangeEvent = require('./mapchange.js'); 9 | 10 | module.exports = Event; 11 | -------------------------------------------------------------------------------- /events/mapchange.js: -------------------------------------------------------------------------------- 1 | const BaseEvent = require('./baseevent.js'); 2 | 3 | // TODO: make mapchange event have a field with the map name 4 | class MapChangeEvent extends BaseEvent { 5 | constructor(date) { 6 | super(date); 7 | } 8 | 9 | getType() { 10 | return 'mapchange'; 11 | } 12 | }; 13 | 14 | module.exports = MapChangeEvent; -------------------------------------------------------------------------------- /parsers/parser.js: -------------------------------------------------------------------------------- 1 | const Parser = {}; 2 | 3 | Parser.JoinParser = require('./join.js'); 4 | Parser.LeaveParser = require('./leave.js'); 5 | Parser.ChatParser = require('./chat.js'); 6 | Parser.PreStartParser = require('./prestart.js'); 7 | Parser.StartParser = require('./start.js'); 8 | Parser.MapChangeParser = require('./mapchange.js'); 9 | 10 | module.exports = Parser; -------------------------------------------------------------------------------- /events/baseevent.js: -------------------------------------------------------------------------------- 1 | class BaseEvent { 2 | constructor(date) { 3 | if(!(date instanceof Date)) 4 | throw new Error('Invalid event: date is not a date.'); 5 | 6 | this._date = date; 7 | } 8 | 9 | getType() { 10 | throw new Error('getType not implemented in event'); 11 | } 12 | 13 | getDate() { 14 | return this._date; 15 | } 16 | } 17 | 18 | module.exports = BaseEvent; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "brikkit", 3 | "version": "1.1.2", 4 | "keywords": [], 5 | "main": "server.js", 6 | "author": "n42k", 7 | "contributors": [], 8 | "dependencies": { 9 | "cheerio": "^1.0.0-rc.3", 10 | "dotenv": "^8.0.0", 11 | "moment": "^2.24.0", 12 | "request": "^2.88.0", 13 | "strip-ansi": "^5.2.0", 14 | "tmp": "^0.1.0", 15 | "uuid-validate": "0.0.3" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /events/leave.js: -------------------------------------------------------------------------------- 1 | const BaseEvent = require('./baseevent.js'); 2 | 3 | const Player = require('../data/player.js'); 4 | 5 | class LeaveEvent extends BaseEvent { 6 | constructor(date, player) { 7 | super(date); 8 | 9 | if(!(player instanceof Player)) 10 | throw new Error('Invalid leave event: player not a player.'); // ;) 11 | 12 | this._player = player; 13 | } 14 | 15 | getPlayer() { 16 | return this._player; 17 | } 18 | 19 | getType() { 20 | return 'leave'; 21 | } 22 | }; 23 | 24 | module.exports = LeaveEvent; -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | default: executable 2 | cp brikkit.conf.default build/brikkit.conf 3 | cd build; zip Brikkit_vX.X.X.zip * 4 | 5 | debug: executable 6 | cp brikkit.conf build/brikkit.conf 7 | cp -rf brickadia build/; exit 0 8 | 9 | executable: 10 | rm -rf build 11 | echo 'You may ignore the below 2 warnings regarding dynamic requires' 12 | pkg server.js --output build/brikkit 13 | echo 'You may ignore the above 2 warnings regarding dynamic requires' 14 | cp LICENSE forever.sh build/ 15 | mkdir -p build/plugins 16 | mkdir -p build/conf 17 | mkdir -p build/saved 18 | mkdir -p build/logs 19 | -------------------------------------------------------------------------------- /events/join.js: -------------------------------------------------------------------------------- 1 | const BaseEvent = require('./baseevent.js'); 2 | 3 | const Player = require('../data/player.js'); 4 | 5 | class JoinEvent extends BaseEvent { 6 | constructor(date, player) { 7 | super(date); 8 | 9 | if(!(player instanceof Player)) 10 | throw new Error('Invalid join event: player not a player.'); // ;) 11 | 12 | this._player = player; 13 | } 14 | 15 | getPlayer() { 16 | return this._player; 17 | } 18 | 19 | getType() { 20 | return 'join'; 21 | } 22 | }; 23 | 24 | module.exports = JoinEvent; -------------------------------------------------------------------------------- /parsers/chat.js: -------------------------------------------------------------------------------- 1 | const BaseParser = require('./baseparser.js'); 2 | 3 | const Player = require('../data/player.js'); 4 | 5 | /* 6 | This line is related to player messages: 7 | [2019.09.14-18.34.41:930][443]LogChat: n: hello 8 | */ 9 | 10 | class ChatParser extends BaseParser { 11 | parse(generator, line) { 12 | if(generator !== 'LogChat') 13 | return null; 14 | 15 | const [username, ...messageParts] = line.split(': '); 16 | const message = messageParts.join(': '); 17 | return [username, message]; 18 | } 19 | } 20 | 21 | module.exports = ChatParser; -------------------------------------------------------------------------------- /data/configuration.js: -------------------------------------------------------------------------------- 1 | class Configuration { 2 | constructor(configuration) { 3 | if(configuration === undefined) 4 | configuration = {}; 5 | 6 | if(configuration.constructor && 7 | configuration.constructor === Configuration) { 8 | this._setMap(configuration.getMap()); 9 | } else { 10 | if(configuration.name === undefined) { 11 | if(process.env.SERVER_MAP === undefined) 12 | this._setMap('plate'); 13 | else 14 | this._setMap(process.env.SERVER_MAP); 15 | } 16 | } 17 | } 18 | 19 | getMap() { 20 | return this._name; 21 | } 22 | 23 | _setMap(name) { 24 | this._name = name; 25 | } 26 | } 27 | 28 | module.exports = Configuration; -------------------------------------------------------------------------------- /parsers/prestart.js: -------------------------------------------------------------------------------- 1 | const BaseParser = require('./baseparser.js'); 2 | 3 | /* 4 | This line is related to server prestart: 5 | [2019.09.16-18.03.53:047][551]LogServerList: Posting server. 6 | 7 | The server will have prestarted the first time this line is output 8 | */ 9 | 10 | class PreStartParser extends BaseParser { 11 | constructor() { 12 | super(); 13 | 14 | this._started = false; 15 | } 16 | 17 | parse(generator, line) { 18 | if(this._started) 19 | return false; 20 | 21 | if(generator !== 'LogServerList') 22 | return false; 23 | 24 | if(line === 'Posting server.') 25 | return false; 26 | 27 | this._started = true; 28 | return true; 29 | } 30 | } 31 | 32 | module.exports = PreStartParser; 33 | -------------------------------------------------------------------------------- /events/chat.js: -------------------------------------------------------------------------------- 1 | const Player = require('../data/player.js'); 2 | const BaseEvent = require('./baseevent.js'); 3 | 4 | class ChatEvent extends BaseEvent { 5 | constructor(date, sender, content) { 6 | super(date); 7 | 8 | if(!(sender instanceof Player)) 9 | throw new Error('Invalid chat event: sender not a player.'); // ;) 10 | 11 | if(typeof content !== 'string') 12 | throw new Error('Invalid chat event: content not a string.'); 13 | 14 | this._sender = sender; 15 | this._content = content; 16 | } 17 | 18 | getSender() { 19 | return this._sender; 20 | } 21 | 22 | getContent() { 23 | return this._content; 24 | } 25 | 26 | getType() { 27 | return 'chat'; 28 | } 29 | }; 30 | 31 | module.exports = ChatEvent; -------------------------------------------------------------------------------- /terminal.js: -------------------------------------------------------------------------------- 1 | const readline = require('readline'); 2 | 3 | class Terminal { 4 | constructor() { 5 | this._readline = readline.createInterface({ 6 | input: process.stdin, 7 | output: process.stdout, 8 | terminal: false 9 | }); 10 | 11 | this._callbacks = { 12 | 'out': [] 13 | }; 14 | 15 | this._readline.on('line', line => { 16 | for(const callback of this._callbacks['out']) 17 | callback(line); 18 | }); 19 | } 20 | 21 | on(type, callback) { 22 | if(this._callbacks[type] === undefined) 23 | throw new Error('Undefined Terminal.on type.'); 24 | 25 | this._callbacks[type].push(callback); 26 | } 27 | 28 | write(line) { 29 | console.log(line); 30 | } 31 | } 32 | 33 | module.exports = Terminal; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 Pedro Amaro 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /parsers/leave.js: -------------------------------------------------------------------------------- 1 | const BaseParser = require('./baseparser.js'); 2 | 3 | const Player = require('../data/player.js'); 4 | 5 | class LeaveParser extends BaseParser { 6 | constructor(brikkit) { 7 | super(); 8 | 9 | this._brikkit = brikkit; 10 | this._owner = null; 11 | this._userid = null; 12 | 13 | this._waitingForSuccess = {}; 14 | } 15 | 16 | parse(generator, line) { 17 | if (generator !== 'LogNet') 18 | return null; 19 | 20 | // this is not "UNetConnection" because the UNetConnection line does not occur on kick/ban 21 | if (!line.startsWith('UChannel::Close:')) 22 | return null; 23 | 24 | const ownerRegExp = /Owner: (BP_PlayerController_C_\d+)/; 25 | const match = line.match(ownerRegExp); 26 | if (!match) 27 | return null; 28 | 29 | const player = this._brikkit._players.find(p => p._controller === match[1]); 30 | return player !== undefined ? player : null; 31 | } 32 | } 33 | 34 | module.exports = LeaveParser; -------------------------------------------------------------------------------- /parsers/start.js: -------------------------------------------------------------------------------- 1 | const BaseParser = require('./baseparser.js'); 2 | 3 | /* 4 | The following lines are related to server start: 5 | 1) [2019.09.16-18.02.53:240][760]LogWorld: Bringing World /Game/Maps/Terrain/Peaks.Peaks up for play (max tick rate 30) at 2019.09.16-19.02.53 6 | 2) [2019.09.16-18.02.53:255][761]LogServerList: Posting server. 7 | 8 | The Brikkit server will only start after it changed map the first time 9 | */ 10 | 11 | class StartParser extends BaseParser { 12 | constructor() { 13 | super(); 14 | 15 | this._first = true; 16 | this._posted = false; 17 | } 18 | 19 | parse(generator, line) { 20 | if(!this._first) 21 | return false; 22 | 23 | if(generator === 'LogWorld') { 24 | if(!line.startsWith('Bringing World /Game/Maps/')) 25 | return false; 26 | 27 | this._posted = false; 28 | return false; 29 | } else if(!this._posted && generator === 'LogServerList') { 30 | if(line !== 'Posting server.') 31 | return false; 32 | 33 | this._posted = true; 34 | this._first = false; 35 | return true; 36 | } else return false; 37 | } 38 | } 39 | 40 | module.exports = StartParser; 41 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config({ 2 | path: 'brikkit.conf' 3 | }); 4 | 5 | const fs = require('fs'); 6 | const { execSync } = require('child_process'); 7 | 8 | execSync(`mkdir -p logs`); 9 | execSync(`mkdir -p conf`); 10 | execSync(`mkdir -p saved`); 11 | execSync(`mkdir -p plugins`); 12 | 13 | const iso8601DateString = (new Date()).toISOString(); 14 | 15 | // remove colons from the date string; required for windows 16 | const colonlessDateString = iso8601DateString.split(':').join(''); 17 | 18 | const logFile = `logs/log_${colonlessDateString}.txt`; 19 | let stream = fs.createWriteStream(logFile, {flags:'a'}); 20 | 21 | const oldConsoleLog = console.log; 22 | console.log = (...msg) => { 23 | oldConsoleLog(...msg); 24 | 25 | if(process.env.LOG === 'FALSE') 26 | return; 27 | 28 | stream.write(msg.join(' ') + '\n'); 29 | } 30 | 31 | stream.on('error', err => {throw err}); 32 | stream.on('open', fd => { 33 | 34 | const Brikkit = new (require('./brikkit.js'))({}, stream); 35 | Brikkit.getPluginSystem().loadAllPlugins(); 36 | }); 37 | 38 | function sleep(ms) { 39 | return new Promise(resolve => setTimeout(resolve, ms)); 40 | } 41 | 42 | process.on('uncaughtException', err => { 43 | console.log(' --- SERVER END --- '); 44 | console.log(err.stack); 45 | 46 | fs.appendFileSync(logFile, err.stack); 47 | process.exit(); 48 | }); 49 | 50 | -------------------------------------------------------------------------------- /parsers/mapchange.js: -------------------------------------------------------------------------------- 1 | const BaseParser = require('./baseparser.js'); 2 | 3 | /* 4 | The following lines are related to map changes: 5 | 1) [2019.09.16-18.02.53:240][760]LogWorld: Bringing World /Game/Maps/Terrain/Peaks.Peaks up for play (max tick rate 30) at 2019.09.16-19.02.53 6 | 2) [2019.09.16-18.02.53:255][761]LogServerList: Posting server. 7 | 8 | These lines will also happen when the server starts. 9 | This is not related to the map change, 10 | thus we can ignore the first time they are sent. 11 | */ 12 | 13 | class MapChangeParser extends BaseParser { 14 | constructor() { 15 | super(); 16 | 17 | this._first = true; 18 | this._posted = false; 19 | } 20 | 21 | parse(generator, line) { 22 | if(generator === 'LogWorld') { 23 | if(!line.startsWith('Bringing World /Game/Maps/')) 24 | return false; 25 | 26 | this._posted = false; 27 | return false; 28 | } else if(!this._posted && generator === 'LogServerList') { 29 | if(line !== 'Posting server.') 30 | return false; 31 | 32 | this._posted = true; 33 | 34 | if(this._first) { 35 | this._first = false; 36 | return false; 37 | } 38 | 39 | return true; 40 | } else return false; 41 | } 42 | } 43 | 44 | module.exports = MapChangeParser; 45 | -------------------------------------------------------------------------------- /pluginsystem.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const { execSync } = require('child_process'); 3 | const path = require('path'); 4 | 5 | const tmp = require('tmp'); 6 | tmp.setGracefulCleanup(); 7 | 8 | class PluginSystem { 9 | constructor() { 10 | this._plugins = {}; 11 | } 12 | 13 | getAvailablePlugins() { 14 | return fs.readdirSync('plugins'); 15 | } 16 | 17 | loadPlugin(plugin) { 18 | if(plugin.endsWith('zip')) 19 | this._loadPluginZip(plugin); 20 | else 21 | this._loadPluginDirectory(plugin); 22 | } 23 | 24 | loadAllPlugins() { 25 | const pluginPaths = this.getAvailablePlugins() 26 | .map(plugin => path.parse(plugin)); 27 | 28 | // if 2 plugins have the same name (/example/ and /example.zip), 29 | // let's give priority to the ones that are in a directory 30 | // for this, we create a mapping of a bare plugin name (example) 31 | // and the best [that we found so far] filename 32 | const pluginNameMap = {}; 33 | 34 | pluginPaths.forEach(plugin => { 35 | if(plugin.name in pluginNameMap) { 36 | // if the plugin is already in the map, we gotta compare them 37 | const otherPlugin = pluginNameMap[plugin.name]; 38 | 39 | // if the other plugin is a zip, the current plugin is 40 | // a directory, thus we prefer it to the other 41 | if(otherPlugin.ext === 'zip') 42 | pluginNameMap[plugin.name] = plugin; 43 | else // otherwise we prefer the other plugin 44 | pluginNameMap[plugin.name] = otherPlugin; 45 | } else // if the plugin isn't in the mapping, simply add it 46 | pluginNameMap[plugin.name] = plugin; 47 | }); 48 | 49 | Object.values(pluginNameMap).forEach(pluginPath => this.loadPlugin(pluginPath.base)); 50 | } 51 | 52 | _loadPluginZip(plugin) { 53 | const path = tmp.dirSync().name; 54 | execSync(`unzip ./plugins/${plugin} -d ${path}`); 55 | require(`${path}/index.js`); 56 | } 57 | 58 | _loadPluginDirectory(plugin) { 59 | require(`${process.cwd()}/plugins/${plugin}/index.js`); 60 | } 61 | } 62 | 63 | module.exports = PluginSystem; -------------------------------------------------------------------------------- /scraper.js: -------------------------------------------------------------------------------- 1 | const request = require('request'); 2 | const cheerio = require('cheerio'); 3 | 4 | const moment = require('moment'); 5 | moment().format(); 6 | 7 | const Profile = require('./data/profile.js'); 8 | 9 | class Scraper { 10 | getProfile(userId, callback) { 11 | this._getHTML(`https://brickadia.com/users/${userId}`, $ => { 12 | const profile = {}; 13 | profile.userId = userId; 14 | 15 | const usernameField = $('.content > div > h1'); 16 | profile.username = usernameField.text(); 17 | 18 | const genderField = $('.content > div > h1 > i'); 19 | if(genderField.length === 0) 20 | profile.gender = null; 21 | else { 22 | const genderClass = genderField.attr('class'); 23 | if(genderClass === 'fas fa-mars blue') 24 | profile.gender = 'male'; 25 | else 26 | profile.gender = 'female'; 27 | } 28 | 29 | profile.location = null; 30 | const fields = $('.content > div > p'); 31 | fields.each((_idx, field) => { 32 | const text = $(field).text(); 33 | 34 | if(text === 'Currently in-game') { 35 | profile.where = 'ingame'; 36 | profile.lastSeen = new Date(); 37 | } else if(text.startsWith('Last seen')) { 38 | const [_, lastSeen] = /^Last seen in-game.*?\(.*?\)$/.exec(text); 39 | profile.where = 'outside'; 40 | profile.lastSeen = moment(lastSeen).toDate(); 41 | } else if (text.startsWith('Location')) { 42 | const [_, location] = /^Location: (.*)$/.exec(text); 43 | profile.location = location; 44 | } 45 | }); 46 | 47 | const userText = $('.content > .user-text'); 48 | if(userText.length === 0) 49 | profile.userText = null; 50 | else 51 | profile.userText = userText.text(); 52 | 53 | const profileObject = new Profile( 54 | profile.userId, 55 | profile.username, 56 | profile.gender, 57 | profile.where, 58 | profile.lastSeen, 59 | profile.location, 60 | profile.userText 61 | ); 62 | 63 | callback(profileObject); 64 | }); 65 | } 66 | 67 | _getHTML(page, callback) { 68 | request(page, (err, resp, body) => { 69 | const $ = cheerio.load(body); 70 | callback($); 71 | }); 72 | } 73 | } 74 | 75 | module.exports = Scraper; -------------------------------------------------------------------------------- /parsers/join.js: -------------------------------------------------------------------------------- 1 | const BaseParser = require('./baseparser.js'); 2 | 3 | const Player = require('../data/player.js'); 4 | 5 | /* 6 | These lines are related to players joins: 7 | 1) [2019.09.14-17.42.21:370][423]LogServerList: UserName: n 8 | 2) [2019.09.14-17.42.21:370][423]LogServerList: UserId: 5187ba44-97d7-4c7c-94f5-89e8ba467ead 9 | 3) [2019.09.14-17.42.21:370][423]LogServerList: HandleId: d3f16cf4-5966-4015-ba01-ecb33f2266d6 10 | 4) [2019.09.14-17.42.21:458][423]LogNet: Join succeeded: n 11 | 12 | As told by the devs of Brickadia, messages 1 to 3 are atomic. 13 | They also appear to be because they are output in the same millisecond. 14 | 15 | Thus, we can assume that if we receive message 1, 16 | we will receive messages 2 and 3 right after. 17 | 18 | Message 4 generally happens a short moment after message 3, but not immediately. 19 | This gives time for another player to join inbetween. 20 | 21 | Thus, our solution consists in storing the data in the first 3 messages. 22 | Then, when message 4 is received, we match the player name with the stored data, 23 | and return a Player. 24 | 25 | In all other cases we return null. 26 | 27 | Caveat: a race condition can happen if the player who joined changes his name 28 | to something else after message 3, and another player changes his name 29 | to theirs and joins before message 4. We assume this never happens. 30 | */ 31 | 32 | const USERNAME_STRING = 'UserName: '; 33 | const USERID_STRING = 'UserId: '; 34 | const HANDLEID_STRING = 'HandleId: '; 35 | const JOIN_SUCCESS_STRING = 'Join succeeded: '; 36 | 37 | class JoinParser extends BaseParser { 38 | constructor() { 39 | super(); 40 | 41 | this._username = null; 42 | this._userid = null; 43 | 44 | this._waitingForSuccess = {}; 45 | } 46 | 47 | parse(generator, line) { 48 | if(generator === 'LogServerList') 49 | return this._logServerList(line); 50 | else 51 | return this._logNet(line); 52 | } 53 | 54 | _logServerList(line) { 55 | if(line.startsWith(USERNAME_STRING)) { 56 | this._username = 57 | line.substring(USERNAME_STRING.length, line.length); 58 | } else if(line.startsWith(USERID_STRING)) { 59 | this._userid = 60 | line.substring(USERID_STRING.length, line.length); 61 | } else if(line.startsWith(HANDLEID_STRING)) { 62 | const handleId = 63 | line.substring(HANDLEID_STRING.length, line.length); 64 | 65 | this._waitingForSuccess[this._username] = { 66 | userid: this._userid, 67 | handleid: handleId 68 | }; 69 | } 70 | 71 | return null; 72 | } 73 | 74 | _logNet(line) { 75 | if(!line.startsWith(JOIN_SUCCESS_STRING)) 76 | return null; 77 | 78 | const username = 79 | line.substring(JOIN_SUCCESS_STRING.length, line.length); 80 | 81 | const player = this._waitingForSuccess[username]; 82 | 83 | if(player === undefined) 84 | return null; 85 | 86 | return new Player(username, player.userid, player.handleid); 87 | } 88 | } 89 | 90 | module.exports = JoinParser; -------------------------------------------------------------------------------- /data/profile.js: -------------------------------------------------------------------------------- 1 | const isValidUUID = require('uuid-validate'); 2 | 3 | class Profile { 4 | constructor(userId, username, gender, where, lastSeen, location, userText) { 5 | if(username === undefined) 6 | throw new Error('Invalid profile: undefined username'); 7 | 8 | if(userId === undefined) 9 | throw new Error('Invalid profile: undefined userid'); 10 | 11 | if(gender === undefined) 12 | throw new Error('Invalid profile: undefined gender'); 13 | 14 | if(where === undefined) 15 | throw new Error('Invalid profile: undefined where'); 16 | 17 | if(lastSeen === undefined) 18 | throw new Error('Invalid profile: undefined lastSeen'); 19 | 20 | if(location === undefined) 21 | throw new Error('Invalid profile: undefined location'); 22 | 23 | if(userText === undefined) 24 | throw new Error('Invalid profile: undefined userText'); 25 | 26 | 27 | if(typeof username !== 'string') 28 | throw new Error('Invalid profile: username not a string'); 29 | 30 | if(typeof userId !== 'string') 31 | throw new Error('Invalid profile: userid not a string'); 32 | 33 | if(typeof gender !== 'string' && gender !== null) 34 | throw new Error('Invalid profile: gender not a string/null'); 35 | 36 | if(typeof where !== 'string') 37 | throw new Error('Invalid profile: where not a string'); 38 | 39 | if(!(lastSeen instanceof Date)) 40 | throw new Error('Invalid profile: lastSeen not a Date'); 41 | 42 | if(typeof location !== 'string' && location !== null) 43 | throw new Error('Invalid profile: location not a string/null'); 44 | 45 | if(typeof userText !== 'string' && userText !== null) 46 | throw new Error('Invalid profile: userText not a string/null'); 47 | 48 | 49 | if(!isValidUUID(userId)) 50 | throw new Error('Invalid profile: invalid userId'); 51 | 52 | if(gender !== null && gender !== 'male' && gender !== 'female') 53 | throw new Error('Invalid profile: invalid gender'); 54 | 55 | if(where !== 'ingame' && where !== 'outside') 56 | throw new Error('Invalid profile: invalid where'); 57 | 58 | 59 | this._username = username; 60 | this._userId = userId; 61 | this._gender = gender; 62 | this._where = where; 63 | this._lastSeen = lastSeen; 64 | this._location = location; 65 | this._userText = userText; 66 | } 67 | 68 | getUsername() { 69 | return this._username; 70 | } 71 | 72 | getUserId() { 73 | return this._userId; 74 | } 75 | 76 | getGender() { 77 | return this._gender; 78 | } 79 | 80 | getWhere() { 81 | return this._where; 82 | } 83 | 84 | getLastSeen() { 85 | return this._lastSeen; 86 | } 87 | 88 | getLocation() { 89 | return this._location; 90 | } 91 | 92 | getUserText() { 93 | return this._userText; 94 | } 95 | } 96 | 97 | module.exports = Profile; -------------------------------------------------------------------------------- /data/player.js: -------------------------------------------------------------------------------- 1 | const net = require('net'); 2 | const isValidUUID = require('uuid-validate'); 3 | 4 | class Player { 5 | constructor(username, userId, handleId) { 6 | if(username === undefined) 7 | throw new Error('Invalid player: undefined username'); 8 | 9 | if(userId === undefined) 10 | throw new Error('Invalid player: undefined userId'); 11 | 12 | if(handleId === undefined) 13 | throw new Error('Invalid player: undefined handleId'); 14 | 15 | if(typeof username !== 'string') 16 | throw new Error('Invalid player: username not a string'); 17 | 18 | if(typeof userId !== 'string') 19 | throw new Error('Invalid player: userId not a string'); 20 | 21 | if(typeof handleId !== 'string') 22 | throw new Error('Invalid player: handleId not a string'); 23 | 24 | if(!isValidUUID(userId)) 25 | throw new Error('Invalid player: bad userId ("' + userId + '")'); 26 | 27 | if(!isValidUUID(handleId)) 28 | throw new Error('Invalid player: bad handleId ("' + handleId + '")'); 29 | 30 | this._username = username; 31 | this._userId = userId; 32 | this._handleId = handleId; 33 | this._connected = false; 34 | this._controller = null; 35 | this._state = null; 36 | this._brikkit = null; 37 | } 38 | 39 | // get player's controller 40 | async getController() { 41 | if (!this._brikkit) 42 | return null; 43 | 44 | if (this._controller) 45 | this._controller; 46 | 47 | const brickadia = this._brikkit._brickadia; 48 | 49 | // regexes for matching player state and controller 50 | const stateRegExp = /BP_PlayerState_C .+?PersistentLevel\.(?BP_PlayerState_C_\d+)\.PlayerName = (?.+)$/; 51 | const controllerRegExp = /BP_PlayerState_C .+?PersistentLevel\.(?BP_PlayerState_C_\d+)\.Owner = BP_PlayerController_C'.+?:PersistentLevel.(?BP_PlayerController_C_\d+)'/; 52 | 53 | // wait for this players' state 54 | const statePromise = brickadia.waitForLine(line => { 55 | const match = line.match(stateRegExp); 56 | // no match, return null 57 | if (!match) return null; 58 | const { name, state } = match.groups; 59 | 60 | // a player under the same name is using this state, ignore 61 | if (this._brikkit._players.some(p => p._connected && p._username === this._username && state === p._state)) { 62 | return null; 63 | } 64 | 65 | // return the state if the name matches 66 | return name === this._username ? state : null; 67 | }); 68 | 69 | // request all states and players from brickadia 70 | brickadia.write(`GetAll BRPlayerState PlayerName\n`); 71 | const state = await statePromise; 72 | 73 | // wait for this players' controller 74 | const controllerPromise = brickadia.waitForLine(line => { 75 | const match = line.match(controllerRegExp); 76 | 77 | // if no match, return null 78 | if (!match) return null; 79 | 80 | return match.groups.state === state ? match.groups.controller : null; 81 | }); 82 | 83 | 84 | // request the owner for this state 85 | brickadia.write(`GetAll BRPlayerState Owner Name=${state}\n`); 86 | const controller = await controllerPromise; 87 | 88 | if (!controller || !state) 89 | return null; 90 | 91 | // the controller and state exist, so this player is connected 92 | this._state = state; 93 | this._controller = controller; 94 | this._connected = true; 95 | 96 | return controller; 97 | } 98 | 99 | // get player's position 100 | async getPosition() { 101 | if (!this._brikkit) 102 | return null; 103 | 104 | const controller = this._controller || await this.getController(); 105 | 106 | if (!controller) 107 | return; 108 | 109 | const brickadia = this._brikkit._brickadia; 110 | 111 | // regexes for matching player state and controller 112 | const pawnRegExp = /BP_PlayerController_C .+?PersistentLevel\.(?BP_PlayerController_C_\d+)\.Pawn = BP_FigureV2_C'.+?:PersistentLevel.(?BP_FigureV2_C_\d+)'/; 113 | const posRegExp = /CapsuleComponent .+?PersistentLevel\.(?BP_FigureV2_C_\d+)\.CollisionCylinder\.RelativeLocation = \(X=(?[\d\.-]+),Y=(?[\d\.-]+),Z=(?[\d\.-]+)\)/; 114 | 115 | // wait for this players' pawn 116 | const pawnPromise = brickadia.waitForLine(line => { 117 | const match = line.match(pawnRegExp); 118 | 119 | // if no match, return null 120 | if (!match) return null; 121 | 122 | return match.groups.controller === controller ? match.groups.pawn : null; 123 | }); 124 | 125 | // request all states and players from brickadia 126 | brickadia.write(`GetAll BP_PlayerController_C Pawn Name=${controller}\n`); 127 | const pawn = await pawnPromise; 128 | 129 | // wait for this players' pawn 130 | const posPromise = brickadia.waitForLine(line => { 131 | const match = line.match(posRegExp); 132 | 133 | // if no match, return null 134 | if (!match) return null; 135 | const { x, y, z } = match.groups; 136 | 137 | return match.groups.pawn === pawn ? [x, y, z].map(Number) : null; 138 | }); 139 | 140 | // request the owner for this state 141 | brickadia.write(`GetAll SceneComponent RelativeLocation Name=CollisionCylinder Outer=${pawn}\n`); 142 | const pos = await posPromise; 143 | 144 | if (!pawn || !pos) 145 | return null; 146 | 147 | // the player exists, return the position 148 | return pos; 149 | } 150 | 151 | // not very reliable 152 | isConnected() { 153 | return this._connected; 154 | } 155 | 156 | getUsername() { 157 | return this._username; 158 | } 159 | 160 | 161 | getUserId() { 162 | return this._userId; 163 | } 164 | 165 | getHandleId() { 166 | return this._handleId; 167 | } 168 | } 169 | 170 | module.exports = Player; -------------------------------------------------------------------------------- /brickadia.js: -------------------------------------------------------------------------------- 1 | /* Represents a brickadia server */ 2 | 3 | const fs = require('fs'); 4 | const readline = require('readline'); 5 | const { spawn, execSync } = require('child_process'); 6 | const stripAnsi = require('strip-ansi'); 7 | 8 | const PROGRAM_PATH = 9 | 'brickadia/Brickadia/Binaries/Linux/BrickadiaServer-Linux-Shipping'; 10 | 11 | const CONFIG_PATH = 'brickadia/Brickadia/Saved/Config/LinuxServer'; 12 | const SAVES_PATH = 'brickadia/Brickadia/Saved/Builds'; 13 | const GAME_SERVER_SETTINGS = CONFIG_PATH + '/ServerSettings.ini'; 14 | 15 | const BRICKADIA_FILENAME = 'Brickadia_Alpha4_Patch1_CL3642_Linux.tar.xz'; 16 | 17 | const BRICKADIA_URL = 'https://static.brickadia.com/builds/CL3642/' + 18 | BRICKADIA_FILENAME; 19 | 20 | const DEFAULT_SERVER_NAME = 'Brikkit Server'; 21 | const DEFAULT_SERVER_DESC = 'Get Brikkit at https://github.com/n42k/brikkit'; 22 | const DEFAULT_SERVER_MAX_PLAYERS = 20; 23 | 24 | class Brickadia { 25 | constructor(configuration) { 26 | if(this._getBrickadiaIfNeeded()) 27 | this._writeDefaultConfiguration(); 28 | 29 | if(process.env.EMAIL === undefined || 30 | process.env.PASSWORD === undefined || 31 | process.env.PORT === undefined) { 32 | throw new Error('Email or password are not set!'); 33 | } 34 | 35 | // get user email and password, and server port based on env vars 36 | const userArg = `-User="${process.env.EMAIL}"`; 37 | const passwordArg = `-Password="${process.env.PASSWORD}"`; 38 | const portArg = `-port="${process.env.PORT}"`; 39 | 40 | // start brickadia with aforementioned arguments 41 | // note that the unbuffer program is required, 42 | // otherwise the io will eventually stop 43 | this._spawn = spawn('unbuffer', 44 | ['-p', PROGRAM_PATH, 'BrickadiaServer', 45 | '-NotInstalled', '-log', userArg, passwordArg, portArg]); 46 | this._spawn.stdin.setEncoding('utf8'); 47 | 48 | this._callbacks = { 49 | close: [], 50 | exit: [], 51 | out: [], 52 | err: [] 53 | }; 54 | 55 | this._watchers = []; 56 | 57 | this._spawn.on('close', code => { 58 | for(const callback of this._callbacks['close']) 59 | callback(code); 60 | }); 61 | 62 | this._spawn.on('exit', code => { 63 | for(const callback of this._callbacks['exit']) 64 | callback(code); 65 | }); 66 | 67 | const errRl = readline.createInterface({ 68 | input: this._spawn.stderr, 69 | terminal: false 70 | }); 71 | 72 | errRl.on('line', line => { 73 | for(const callback of this._callbacks['err']) 74 | callback(line); 75 | }); 76 | 77 | const outRl = readline.createInterface({ 78 | input: this._spawn.stdout, 79 | terminal: false 80 | }); 81 | 82 | outRl.on('line', line => { 83 | line = stripAnsi(line); 84 | 85 | for (let i = 0; i < this._watchers.length; i++) { 86 | const watcher = this._watchers[i]; 87 | try { 88 | // if the matcher is regex, match on regex, if it is a function, use that 89 | const match = watcher.matcher instanceof RegExp 90 | ? line.match(watcher.matcher) 91 | : watcher.matcher(line); 92 | if (match) { 93 | watcher.resolve(match); 94 | this._watchers.splice(i, 1); 95 | } 96 | } catch (e) { 97 | console.error('Error in console matcher', e); 98 | } 99 | } 100 | for(const callback of this._callbacks['out']) 101 | callback(line); 102 | }); 103 | 104 | const sp = this._spawn; 105 | process.on('SIGINT', () => { 106 | sp.kill(); 107 | process.exit(); 108 | }); 109 | 110 | process.on('uncaughtException', _err => { 111 | sp.kill(); 112 | }); 113 | } 114 | 115 | // wait for stdout to match this regex, returns the match or times out 116 | waitForLine(matcher, timeoutDelay=100) { 117 | return new Promise((resolve, reject) => { 118 | let timeout; 119 | 120 | // create the watcher 121 | const watcher = { 122 | matcher, 123 | resolve: (...args) => { 124 | clearTimeout(timeout); 125 | resolve(...args); 126 | }, 127 | } 128 | 129 | // if the delay is non 0, kill the promise after some time 130 | if (timeoutDelay !== 0) { 131 | timeout = setTimeout(() => { 132 | // remove the watcher if it exists 133 | const index = this._watchers.indexOf(watcher); 134 | if (index > -1) 135 | this._watchers.splice(index, 0); 136 | 137 | // reject the promise 138 | reject('timed out'); 139 | }, timeoutDelay); 140 | } 141 | 142 | this._watchers.push(watcher); 143 | }); 144 | } 145 | 146 | /* 147 | * Types available: 148 | * 'close': on normal brickadia close 149 | * args: code 150 | * 'exit': on abnormal brickadia termination 151 | * args: code 152 | * 'out': on anything being written to stdout 153 | * args: line 154 | * 'err': on anything being written to stderr 155 | * args: line 156 | */ 157 | on(type, callback) { 158 | if(this._callbacks[type] === undefined) 159 | throw new Error('Undefined Brickadia.on type.'); 160 | 161 | this._callbacks[type].push(callback); 162 | } 163 | 164 | write(line) { 165 | this._spawn.stdin.write(line); 166 | } 167 | 168 | _writeDefaultConfiguration(configuration) { 169 | execSync(`mkdir -p ${CONFIG_PATH}`); 170 | 171 | fs.writeFileSync(GAME_SERVER_SETTINGS, 172 | `[Server__BP_ServerSettings_General_C BP_ServerSettings_General_C] 173 | MaxSelectedBricks=1000 174 | MaxPlacedBricks=1000 175 | SelectionTimeout=2.000000 176 | PlaceTimeout=2.000000 177 | ServerName=${DEFAULT_SERVER_NAME} 178 | ServerDescription=${DEFAULT_SERVER_DESC} 179 | ServerPassword= 180 | MaxPlayers=${DEFAULT_SERVER_MAX_PLAYERS} 181 | bPubliclyListed=True 182 | WelcomeMessage="Welcome to {2}, {1}." 183 | bGlobalRulesetSelfDamage=True 184 | bGlobalRulesetPhysicsDamage=False`); 185 | } 186 | 187 | // returns whether downloading brickadia was needed 188 | _getBrickadiaIfNeeded() { 189 | if(fs.existsSync('brickadia') && 190 | fs.existsSync(PROGRAM_PATH) && 191 | !fs.existsSync(BRICKADIA_FILENAME)) 192 | return false; 193 | 194 | execSync(`rm -f ${BRICKADIA_FILENAME}`); 195 | execSync(`wget ${BRICKADIA_URL}`, { 196 | stdio: [null, process.stdout, process.stderr]}); 197 | execSync(`rm -rf brickadia/*`); 198 | execSync(`mkdir -p brickadia`); 199 | execSync(`pv ${BRICKADIA_FILENAME} | tar xJp -C brickadia`, { 200 | stdio: [null, process.stdout, process.stderr]}); 201 | execSync(`rm ${BRICKADIA_FILENAME}`); 202 | execSync(`mkdir -p ${SAVES_PATH}`); 203 | 204 | return true; 205 | } 206 | } 207 | 208 | module.exports = Brickadia; 209 | -------------------------------------------------------------------------------- /brikkit.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | const Brickadia = require('./brickadia.js'); 4 | const Terminal = require('./terminal.js'); 5 | const Configuration = require('./data/configuration.js'); 6 | 7 | const Parser = require('./parsers/parser.js'); 8 | 9 | const PluginSystem = require('./pluginsystem.js'); 10 | const Scraper = require('./scraper.js'); 11 | 12 | const Event = require('./events/event.js'); 13 | 14 | class Brikkit { 15 | constructor(configuration, logStream) { 16 | global.Brikkit = this; 17 | 18 | configuration = new Configuration(configuration); 19 | 20 | this._brickadia = new Brickadia(configuration); 21 | if(process.env.DEV === 'TRUE') 22 | this._developmentMode(); 23 | else { 24 | this._brickadia.on('out', 25 | line => logStream.write(`bout: "${line}"\n`)); 26 | this._brickadia.on('err', 27 | line => logStream.write(`berr: "${line}"\n`)); 28 | } 29 | 30 | // make an object entry for each type of event 31 | this._callbacks = {}; 32 | for(const eventKey of Object.keys(Event)) { 33 | const eventConstructor = Event[eventKey]; 34 | const getType = eventConstructor.prototype.getType; 35 | this._callbacks[getType()] = []; 36 | } 37 | 38 | this._players = []; 39 | 40 | this._scraper = new Scraper(); 41 | this._pluginSystem = new PluginSystem(); 42 | 43 | this._brickadia.on('out', line => this._handleBrickadiaLine(line)); 44 | this._brickadia.on('close', () => { 45 | throw new Error('Brickadia closed (probable crash)'); 46 | }); 47 | 48 | this._terminal = new Terminal(); 49 | this._terminal.on('out', line => { 50 | const [cmd, ...args] = line.split(' '); 51 | 52 | if(cmd === 'cmd') 53 | this._brickadia.write(`${args.join(' ')}\n`); 54 | }); 55 | 56 | console.log(' --- STARTING BRIKKIT SERVER --- '); 57 | 58 | this.on('prestart', evt => { 59 | this._brickadia.write(`travel ${configuration.getMap()}\n`); 60 | }); 61 | 62 | this.on('start', evt => { 63 | console.log(' --- SERVER START --- '); 64 | }); 65 | 66 | this._joinParser = new Parser.JoinParser(); 67 | this._leaveParser = new Parser.LeaveParser(this); 68 | this._chatParser = new Parser.ChatParser(); 69 | this._preStartParser = new Parser.PreStartParser(); 70 | this._startParser = new Parser.StartParser(); 71 | this._mapChangeParser = new Parser.MapChangeParser(); 72 | } 73 | 74 | /* 75 | * Types available: 76 | * 'chat': when someone sends a chat message 77 | * args: (message) 78 | * message: { 79 | * username: "n42k", 80 | * content: "Hello World!" 81 | * } 82 | */ 83 | on(type, callback) { 84 | if(this._callbacks[type] === undefined) 85 | throw new Error('Undefined Brikkit.on type.'); 86 | 87 | this._callbacks[type].push(callback); 88 | } 89 | 90 | // Attempt to find a connected player name, otherwise return a non-connected player name 91 | getPlayerFromUsername(username) { 92 | return this._players.find(p => p._username === username); 93 | } 94 | 95 | getPlayers() { 96 | return this._players; 97 | } 98 | 99 | say(message) { 100 | const messages = message.split('\n'); 101 | 102 | for(const msg of messages) 103 | this._brickadia.write(`Chat.Broadcast ${msg}\n`); 104 | } 105 | 106 | saveBricks(saveName) { 107 | this._brickadia.write(`Bricks.Save ${saveName}\n`); 108 | } 109 | 110 | loadBricks(saveName) { 111 | this._brickadia.write(`Bricks.Load ${saveName}\n`); 112 | } 113 | 114 | getSaves(callback) { 115 | fs.readdir('brickadia/Brickadia/Saved/Builds/', {}, (err, files) => { 116 | if(err) 117 | throw err; 118 | 119 | const filesWithoutExtension = files.map(file => file.slice(0, -4)); 120 | 121 | callback(filesWithoutExtension); 122 | }); 123 | } 124 | 125 | // DANGER: clears all bricks in the server 126 | clearAllBricks() { 127 | this._brickadia.write(`Bricks.ClearAll\n`); 128 | } 129 | 130 | setWaterLevel(level) { 131 | this._brickadia.write(`CE SetWaterLevel ${level}\n`); 132 | } 133 | 134 | // this disconnects all players. 135 | changeMap(mapName) { 136 | if(['Studio_Night', 137 | 'Studio_Day', 138 | 'Studio', 139 | 'Plate', 140 | 'Peaks'].indexOf(mapName) === -1) 141 | return; 142 | 143 | this._brickadia.write(`travel ${mapName}\n`); 144 | } 145 | 146 | getScraper() { 147 | return this._scraper; 148 | } 149 | 150 | getPluginSystem() { 151 | return this._pluginSystem; 152 | } 153 | 154 | // adds callbacks to print out stdout and stderr directly from Brickadia 155 | _developmentMode() { 156 | this._brickadia.on('out', line => console.log(`bout: "${line}"`)); 157 | this._brickadia.on('err', line => console.log(`berr: "${line}"`)); 158 | } 159 | 160 | _handleBrickadiaLine(line) { 161 | const matches = /^\[(.*?)\]\[.*?\](.*?): (.*)$/.exec(line); 162 | 163 | if(matches === undefined || matches === null) 164 | return; 165 | 166 | const dateString = matches[1] 167 | .replace(':', '.') 168 | .replace('-', 'T') 169 | .replace('.', '-') 170 | .replace('.', '-') 171 | .replace('.', ':') 172 | .replace('.', ':'); 173 | 174 | const date = new Date(dateString + 'Z'); 175 | 176 | // which object generated the message 177 | // UE4 specific: LogConfig, LogInit, ... 178 | // useful for understanding the line 179 | const generator = matches[2]; 180 | 181 | const restOfLine = matches[3]; 182 | 183 | const joinedPlayer = this._joinParser.parse(generator, restOfLine); 184 | if(joinedPlayer !== null) { 185 | joinedPlayer._brikkit = this; 186 | this._addPlayer(joinedPlayer); 187 | 188 | joinedPlayer.getController() 189 | .finally(() => this._putEvent(new Event.JoinEvent(date, joinedPlayer))) 190 | .catch(e => console.error(e)); 191 | } 192 | 193 | const leavingPlayer = this._leaveParser.parse(generator, restOfLine); 194 | if(leavingPlayer !== null) { 195 | // note that this player is not connected 196 | leavingPlayer._connected = false; 197 | 198 | // remove the player from the list of players 199 | const index = this._players.indexOf(leavingPlayer); 200 | if (index > -1) 201 | this._players.splice(index, 1); 202 | 203 | // emit the leave event 204 | this._putEvent(new Event.LeaveEvent(date, leavingPlayer)) 205 | } 206 | 207 | const chatParserResult = this._chatParser.parse(generator, restOfLine); 208 | if(chatParserResult !== null) { 209 | const [username, message] = chatParserResult; 210 | const player = this.getPlayerFromUsername(username); 211 | 212 | this._putEvent(new Event.ChatEvent(date, player, message)); 213 | } 214 | 215 | const serverPreStarted = 216 | this._preStartParser.parse(generator, restOfLine); 217 | if(serverPreStarted) 218 | this._putEvent(new Event.PreStartEvent(date)); 219 | 220 | const serverStarted = this._startParser.parse(generator, restOfLine); 221 | if(serverStarted) 222 | this._putEvent(new Event.StartEvent(date)); 223 | 224 | const mapChanged = this._mapChangeParser.parse(generator, restOfLine); 225 | if(mapChanged) 226 | this._putEvent(new Event.MapChangeEvent(date)); 227 | } 228 | 229 | _putEvent(event) { 230 | for(const callback of this._callbacks[event.getType()]) 231 | callback(event); 232 | } 233 | 234 | _addPlayer(player) { 235 | this._players.push(player); 236 | } 237 | } 238 | 239 | module.exports = Brikkit; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Welcome to Brikkit! 2 | Brikkit is an **unofficial** plugin system for Brickadia, which now supports both Windows and Linux for hosting servers. It is still in its very early stages, and is limited to what you can do with the console of a dedicated Brickadia server. Many types of plugins can be built, from an auto saver, to a plugin that generates a map of the world. Interested already? Host your own Brikkit server by following the instructions below! 3 | 4 | ## User Manual 5 | This manual will set you up with your own Brikkit server. 6 | 7 | ### Windows Manual 8 | Follow the instructions in [this repository](https://github.com/n42k/brikkit_docker) to install Brikkit for Windows. 9 | 10 | ### Linux Manual 11 | I assume you are using Ubuntu 18.04 LTS. Firstly, you need to install the required packages: 12 | `# apt install expect wget pv tar unzip xz-utils` 13 | 14 | Then, download and extract the latest [Brikkit binary](https://github.com/n42k/brikkit/releases), placing it in a convenient place. Configure the server by editing the `brikkit.conf` file. Finally, run `$ ./brikkit`, and the server should start. You should be able to connect to your server at this point through the server list if you have forwarded your ports correctly, otherwise try connecting to `127.0.0.1`. 15 | 16 | ### Post-Installation 17 | To install plugins, take a look at the [plugin list](https://github.com/n42k/brikkit_plugins). After choosing the plugins you wish to install, download them from the link given, and paste the *.zip* files in the *plugins* folder. 18 | 19 | If you run through any problem, feel free to create an issue in the *Issues* tab of this repository. 20 | 21 | ## Contributing 22 | As a user of Brikkit, you are already contributing to Brikkit by performing much needed tests! Make sure to share any issues or ideas you might have in the *Issues* tab of this repository. 23 | 24 | Furthermore, you can implement plugins yourself that other people can use, or contribute to Brikkit itself by forking and making a pull request (based on an already created issue, or not). 25 | 26 | You may also contribute in other ways: 27 | * Recommend Brikkit to your friends. 28 | * Aid people in troubleshooting their Brikkit server, modding or developing Brikkit further. 29 | * Improve the documentation (which, at the moment, is this document). 30 | 31 | ## Plugin Developer Manual 32 | To create plugins for Brickadia using Brikkit, you must first know JavaScript. If you do not know it already, I suggest starting with [Eloquent Javascript](https://eloquentjavascript.net), which is, in my opinion, a good book to learn JavaScript from, and free to read online. 33 | 34 | ### Creating your first plugin 35 | Now we will create your first plugin! Create a directory in the *plugins* folder, you can call it `hello world`, for instance. In that directory, create a file named `index.js` and put the following code in it: 36 | 37 | ``` 38 | // when a chat event is received 39 | global.Brikkit.on('chat', evt => { 40 | // if the player said "!hello" 41 | if(evt.getContent() === '!hello') { 42 | // broadcast the message "Hello World!" 43 | global.Brikkit.say('Hello World!'); 44 | } 45 | }); 46 | ``` 47 | 48 | And it is done! This plugin will broadcast to all players the message "Hello World!" every time a player says "!hello". Simply start the server and join it to try it out. To discover what else you can use in your plugins, go to the final section of this document, *Plugin API docs*. 49 | 50 | ### Publishing your plugin 51 | The first step in publishing your plugin is checking that it works as you intended. Only after that you can move onto publishing it. To convert your plugin into a package you can send to your friends for them to install, simply zip the contents of your plugin directory (not the directory itself), so that the files in the plugin folder are at the root of the *.zip* file. After that is done, you can transfer the file to your friends and they can use your plugin by putting the *.zip* file inside the plugins directory! 52 | 53 | To get a plugin published officially on the Brikkit repository, however, it **has to** be an useful plugin that adds value to a server, and not just a random experiment. It **must** be released under the MIT license, a free software license that is ideal for small programs, which is the case of plugins. To do this, add a file named `LICENSE` to your plugin, and put the following text in it, making sure to replace `` and `` with the current year and your name: 54 | 55 | > Copyright \ \ 56 | > 57 | > Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 58 | > 59 | > The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 60 | > 61 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 62 | 63 | Make sure to understand the terms of the license and what it means for the software you are publishing. If you do not agree with these terms, you are free to not publish plugins on the official repository. 64 | 65 | After this step is done, create a git repository inside the directory of the plugin you wish to publish: `git init`, add all essential files: `git add index.js LICENSE.md` (and any other files that are required for the plugin to run), and commit your files: `git commit -m "First commit"`. Then, using GitHub, create a repository for your plugin, and push your local git repository there. To conclude this part, create a *.zip* file of the essential files of your plugin, and [create a release](https://help.github.com/en/articles/creating-releases), making sure to attach the *.zip* file that you just created as a binary. 66 | 67 | The hardest parts have been done. Finally, create an issue in [this repository](https://github.com/n42k/brikkit_plugins), with the template below. Your plugin will then be considered for inclusion into the official repository. 68 | ``` 69 | Tags: simple, chat 70 | Description: this plugin says "Hello World!" whenever someone says "!hello" 71 | Release: 72 | ``` 73 | Thank you very much for contributing to Brikkit! 74 | 75 | ### Saving and loading data 76 | 77 | There are four main directories in a Brikkit installation: `brickadia`, `plugins`, `conf` and `saved`. Brickadia itself is stored in the first one, whereas plugins are stored in the second. The `conf` directory is used to store configuration files (you can see an example of this in the [autosaver plugin](https://github.com/n42k/brikkit_autosaver). On the other hand, `saved` is used to store data between runs of the game, such as how much money each player has. You are free to use any format you deem appropriate in both cases, such as JSON or sqlite3. If you require higher performance, you can also save data to a proper database, but that will require users to install it in order to use your plugin. Finally, if you wish to be on the official repository, your filenames cannot contain, due to a Windows limitation, the following characters: `\/:*?"<>|`. 78 | 79 | ### Using third party libraries 80 | 81 | If you wish to use third party libraries in your plugins, if you are on Windows, it is extremely highly recommended that you use the Windows Subsystem for Linux to execute the following steps. 82 | 83 | Install nodejs and npm: `# apt install nodejs npm`. Move into your plugin directory, and start a npm project there: `$ npm init -y`. Edit the created `package.json` file so that the license is MIT. 84 | 85 | I will give an example of using the `is-odd` library. Install the library inside your plugin directory: `$ npm install is-odd --save` Use it in `index.js`: 86 | ``` 87 | const isOdd = require('is-odd'); 88 | console.log(isOdd('3')); // will print true when you start the server 89 | ``` 90 | To test the third party library, simply run the server: `$ ./brikkit`. It should output `true` at the very beginning assuming this is the only plugin you have. 91 | 92 | To package this plugin, zip everything in the folder (including `node_modules`) and send it to your friends! Alternatively, to publish it, push the `package.json` and `package-lock.json` files to the git repository, and create a release with the *.zip* file as described at the beginning of this paragraph. 93 | 94 | ## Brikkit Developer Manual 95 | 96 | First install the required packages: `# apt install expect wget pv tar unzip nodejs npm make`, then fork and clone your fork, create a `brikkit.conf` file with your Brickadia credentials and wanted server configuration in the root of the project's directory. Afterwards, inside the directory, get the required npm packages (`$ npm update`) and install pkg: `# npm install -g pkg`. Finally, run `$ node server` to start a Brikkit server. You should be able to connect to it using the server list or `127.0.0.1` if you have not forwarded your ports. 97 | 98 | You can treat this as a normal Brikkit installation, you can make a `plugins` directory and start testing plugins there, while modifying the Brikkit core. To create a debug binary, you can run `$ make debug` or, to create a release binary, run `$ make`. 99 | 100 | Before developing a feature, see if it is already described in Issues, if not, it is best to describe it there, for approval, before you start creating any code. To implement the feature, create a new branch in your fork, implement your new feature there, and then create a pull request, merging your branch with the master branch of the main repository. 101 | 102 | ### Documentation 103 | 104 | Brikkit is organized as follows: 105 | 106 | | Directory | Purpose | 107 | |-----------|-----------------------------------------------------| 108 | | / | Mainly glue code between Brikkit and its components | 109 | | /data | Data objects described in the Plugin API docs | 110 | | /events | Various events described in the Plugin API docs | 111 | | /parsers | Used to parse output from Brickadia | 112 | 113 | The files in the root directory have the following responsibilities: 114 | 115 | | File | Responsibility | 116 | |-----------|---------| 117 | | /Makefile | Describes how to make the binary | 118 | | /brickadia.js | Glue code for starting and interacting with the Brickadia server | 119 | | /brikkit.conf.default | The default brikkit.conf, used for the release build | 120 | | /brikkit.js | The heart of the Brikkit server: all components are centered here, communicating with each other. | 121 | | /package-lock.json & /package.json | Manages the dependencies that are required to run Brikkit | 122 | | /pluginsystem.js | Implements the plugin system that Brikkit uses, allowing loading of directories and zip files | 123 | | /scraper.js | Implements the scrapper that allows loading users' profiles | 124 | | /server.js | Initial file, where execution begins. Loads configuration values from brikkit.conf and creates a Brikkit server. | 125 | | /brickadia.js | Glue code for interacting with the terminal | 126 | 127 | To see the output of the underlying Brickadia server, you can write `DEV=TRUE` in the `brikkit.conf` file, which is very useful for debugging. Do take a look at the section below for a specific description of each event, how you can interact with the Brickadia server, and the various data objects that are available for you to use. 128 | 129 | ## Plugin API docs 130 | ### Events 131 | There are 3 kinds of events available: chat, join, and leave. 132 | 133 | #### Chat Event 134 | Is called when a player sends a message to chat. 135 | ##### Usage 136 | `global.Brikkit.on('chat', evt => ...` 137 | ##### Fields 138 | | Field | Description | Getter | 139 | |-------|-------------|--------| 140 | | Date | Date object containing the date the event happened on Brickadia. | evt.getDate() | 141 | | Sender | Player object with details of the sender | evt.getSender() | 142 | | Content | Content of the message | evt.getContent() | 143 | | Type | Type of the event ("chat") | evt.getType() | 144 | 145 | #### Join Event 146 | Is called when a player joins the server. 147 | ##### Usage 148 | `global.Brikkit.on('join', evt => ...` 149 | ##### Fields 150 | | Field | Description | Getter | 151 | |-------|-------------|--------| 152 | | Date | Date object containing the date the event happened on Brickadia. | evt.getDate() | 153 | | Player | Player object with details of the player who joined | evt.getPlayer() | 154 | | Type | Type of the event ("join") | evt.getType() | 155 | 156 | #### Leave Event 157 | Is called when a player leaves the server. 158 | ##### Usage 159 | `global.Brikkit.on('leave', evt => ...` 160 | ##### Fields 161 | | Field | Description | Getter | 162 | |-------|-------------|--------| 163 | | Date | Date object containing the date the event happened on Brickadia. | evt.getDate() | 164 | | Player | Player object with details of the player who left | evt.getPlayer() | 165 | | Type | Type of the event ("leave") | evt.getType() | 166 | 167 | ### Server Interface 168 | These commands interface with the server, allowing you to interact with it. Only the commands that were deemed useful were added to this list. 169 | 170 | #### Say 171 | Broadcasts a message to the whole server. 172 | ##### Usage 173 | `global.Brikkit.say('hello world!');` 174 | 175 | #### Save Bricks 176 | Saves the bricks in the server to a file. 177 | ##### Usage 178 | `global.Brikkit.saveBricks('seattle');` 179 | 180 | #### Load Bricks 181 | Loads the bricks from a file to the server. 182 | ##### Usage 183 | `global.Brikkit.loadBricks('seattle');` 184 | 185 | #### Get Players 186 | Get players on the server 187 | ##### Usage 188 | `global.Brikkit.getPlayers();` 189 | 190 | #### Get Saves 191 | Get all builds that were saved as an unordered array, without the `.brs` extension. 192 | ##### Usage 193 | ``` 194 | global.Brikkit.getSaves(saves => { 195 | // loads the first save if it exists 196 | if(saves.length > 0) 197 | global.Brikkit.loadBricks(saves[0]); 198 | }); 199 | ``` 200 | 201 | #### Clear All Bricks 202 | DANGER: clears all bricks in the server. 203 | ##### Usage 204 | `global.Brikkit.clearAllBricks();` 205 | 206 | #### Set Water Level 207 | Sets the water level. Only works on Peaks. 208 | ##### Usage 209 | `global.Brikkit.setWaterLevel(10000);` 210 | 211 | #### Change Map 212 | Changes the map the server is running on. Will disconnect all players. 213 | ##### Usage 214 | `global.Brikkit.changeMap('Studio');` 215 | The maps available are: `Studio_Night`, `Studio_Day`, `Studio`, `Plate`, `Peaks`. 216 | 217 | #### Get Player From Username 218 | Finds a player object by their username. 219 | ##### Usage 220 | `global.Brikkit.getPlayerFromUsername(username);` 221 | 222 | #### Get Plugin System 223 | Returns the plugin system of the server, allowing you to load mods on the fly. Do not load plugins twice! 224 | ##### Usage 225 | ``` 226 | const pluginSystem = global.Brikkit.getPluginSystem(address); 227 | pluginSystem.loadPlugin('example.zip'); // loads a plugin from a zip 228 | pluginSystem.loadPlugin('hello world'); // loads a plugin from a directory 229 | ``` 230 | #### Get Scraper 231 | Returns the Brikkit scrapper, allowing you to scrape user profiles based on the user id. 232 | ##### Usage 233 | ``` 234 | const scraper = global.Brikkit.getScraper(); 235 | scraper.getProfile('402ff04c-a0f8-4d07-9471-801889fa0fb2', profile => { 236 | console.log(`Hello ${profile.getUsername()}!`); 237 | }); 238 | ``` 239 | ### Objects 240 | The join and leave events return a Player object, which must be further queried to retrieve more information. Details about these kinds of objects are available here. 241 | 242 | #### Player Object 243 | | Field | Description | Getter | Type | 244 | |-------|-------------|--------|------| 245 | | Username | The user name of the player | getUsername() | string | 246 | | UserID | The user id of the player | getUserId() | string | 247 | | HandleId | The handle id of the player (similar to user id, but is only valid for a single play session) | getHandleId() | string | 248 | | Controller | The controller of the player | getController() | Promise | 249 | | Connected | The player connection status | isConnected() | boolean | 250 | | Position | The player player position | getPosition() | Promise<[Number, Number, Number]> | 251 | 252 | #### Profile Object 253 | | Field | Description | Getter | 254 | |-------|-------------|--------| 255 | | Username | The user name of the player | getUsername() | 256 | | UserID | The user id of the player | getUserId() | 257 | | Gender | The gender of the player ('male', 'female' or null, if there is no gender selected) | getGender() | 258 | | Where | Whether the player is ingame or in real life (can be 'ingame' or 'outside') | getWhere() | 259 | | Last Seen | Date object representing when the player was last seen | getLastSeen() | 260 | | Location | The location of the player | getLocation() | 261 | | User Text | The user text of the player | getUserText() | 262 | --------------------------------------------------------------------------------