diff --git a/.env.sample b/.env.sample deleted file mode 100644 index d681447..0000000 --- a/.env.sample +++ /dev/null @@ -1,8 +0,0 @@ -CORE_LOG=info -CORE_HOST="" -#CORE_PORT= -CORE_USER="" -CORE_PASS="" - -CHAT_LOG=info -COMBAT_LOG=info \ No newline at end of file diff --git a/.gitignore b/.gitignore index 944bc0c..db20760 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ .idea/ .vscode/ .nyc_output/ -.env +env.json coverage/ node_modules/ build/ diff --git a/env.sample.json b/env.sample.json new file mode 100644 index 0000000..badeeb7 --- /dev/null +++ b/env.sample.json @@ -0,0 +1,18 @@ +{ + "client": { + "host": "server.tld", + "username": "Bot", + "password": "root" + }, + "log": { + "level": "info" + }, + "modules": { + "Client": {}, + "Combat": {}, + "Chat": {}, + "Time": { + "log": "debug" + } + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 8801429..2c0b333 100644 --- a/package-lock.json +++ b/package-lock.json @@ -318,15 +318,6 @@ "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==", "dev": true }, - "@types/dotenv": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/@types/dotenv/-/dotenv-8.2.0.tgz", - "integrity": "sha512-ylSC9GhfRH7m1EUXBXofhgx4lUWmFeQDINW5oLuS+gxWdfUeW4zJdeVTYVkexEW+e2VUvlZR2kGnGGipAWR7kw==", - "dev": true, - "requires": { - "dotenv": "*" - } - }, "@types/mocha": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-7.0.2.tgz", @@ -365,6 +356,12 @@ "@types/node": "*" } }, + "@types/sprintf-js": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@types/sprintf-js/-/sprintf-js-1.1.2.tgz", + "integrity": "sha512-hkgzYF+qnIl8uTO8rmUSVSfQ8BIfMXC4yJAF4n8BE758YsKBZvFC4NumnAegj7KmylP0liEZNpb9RRGFMbFejA==", + "dev": true + }, "abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -995,11 +992,6 @@ "is-obj": "^2.0.0" } }, - "dotenv": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz", - "integrity": "sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw==" - }, "duplexer3": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz", @@ -1932,6 +1924,12 @@ "picomatch": "^2.0.4" } }, + "strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", + "dev": true + }, "supports-color": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.0.0.tgz", @@ -2651,6 +2649,14 @@ "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" + }, + "dependencies": { + "strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", + "dev": true + } } }, "read-pkg": { @@ -2999,10 +3005,9 @@ "dev": true }, "strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", - "dev": true + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.0.tgz", + "integrity": "sha512-e6/d0eBu7gHtdCqFt0xJr642LdToM5/cN4Qb9DbHjVx1CP5RyeM+zH7pbecEmDv/lBqb0QH+6Uqq75rxFPkM0w==" }, "supports-color": { "version": "6.1.0", diff --git a/package.json b/package.json index bee5606..b5f3092 100644 --- a/package.json +++ b/package.json @@ -16,16 +16,16 @@ "test:coverage": "nyc -r lcov -e .ts -x \"*Test.ts\" mocha -r ts-node/register test/**/*Test.ts && nyc report" }, "dependencies": { - "dotenv": "^8.2.0", "minecraft-protocol": "^1.11.0", - "pino": "^6.2.0" + "pino": "^6.2.0", + "strip-json-comments": "^3.1.0" }, "devDependencies": { "@types/chai": "^4.2.11", - "@types/dotenv": "^8.2.0", "@types/mocha": "^7.0.2", "@types/node": "^13.11.1", "@types/pino": "^6.0.0", + "@types/sprintf-js": "^1.1.2", "chai": "^4.2.0", "concurrently": "^5.1.0", "mocha": "^7.1.1", diff --git a/src/Cubbot.ts b/src/Cubbot.ts new file mode 100644 index 0000000..38434ab --- /dev/null +++ b/src/Cubbot.ts @@ -0,0 +1,99 @@ +import assert from 'assert' +import * as mc from 'minecraft-protocol' +import { default as pino } from 'pino' +import moduleList from './modules' +import Module, { IModuleType } from './utils/Module' + +interface IConfig { + /** Minecraft protocol options */ + client: mc.ClientOptions, + /** Logger options */ + log: pino.LoggerOptions, + /** Modules to load and options */ + modules: { [name: string]: object } +} + +/** Modules manager */ +export default class Cubbot { + + private logger: pino.Logger + + private _client?: mc.Client + public get client() { + return this._client + } + + private _registeredModules = new Map(moduleList.map(m => [m.name, m])) + + private _modules?: Map> + public get modules() { + return this._modules + } + + constructor(private readonly config: IConfig) { + this.logger = pino(this.config.log) + this.logger.warn('Cubbot') + + assert.ok(this.config.client) + } + + /** Add addition module */ + public registerModule(module: IModuleType, name?: string) { + const n: string = name || module.name + if (this._registeredModules.has(n)) { + this.logger.warn('Overriding module %s', n) + } + this._registeredModules.set(n, module) + } + + public loadModule>(module: IModuleType): T { + return this.loadModuleByName(module.name) as T + } + + public loadModuleByName(name: string) { + if (this.modules!.has(name)) { + return this.modules!.get(name)! + } + + this.logger.debug('Loading module %s', name) + assert.ok(this._registeredModules.has(name), `Unknown module ${name}`) + const mType = this._registeredModules.get(name)! + + const conf: { log?: string } = this.config.modules[name] || {} + const mLogger = this.logger.child({ name, level: conf.log }) + mLogger.trace('Logger created') + + const module = new mType(this.client!, mLogger, conf, this.loadModule.bind(this)) + this._modules!.set(name, module) + return module + } + + /** Start bot */ + public mount() { + if (!this.client) { + this.logger.debug('Creating client') + this._client = mc.createClient(this.config.client) + + this.logger.debug('Loading modules') + this._modules = new Map>() + Object.keys(this.config.modules).forEach(this.loadModuleByName.bind(this)) + } + } + + /** Stop bot */ + public umount() { + if (this.client) { + this.logger.debug('Unloading modules') + this.modules!.forEach(m => m.umount()) + this._modules = undefined + + this.logger.debug('Removing client') + //TODO: disconnect + this._client = undefined + + this.logger.warn('Stopped') + this.logger.flush() + } + } + +} diff --git a/src/app.ts b/src/app.ts deleted file mode 100644 index 29316ef..0000000 --- a/src/app.ts +++ /dev/null @@ -1,26 +0,0 @@ -import * as mc from 'minecraft-protocol' -import Env from './core/Env' -import { logger } from './core/logger' -import modulesTypes from './modules' - -export default () => { - logger.warn('Cubbot start') - const client = mc.createClient({ - host: Env.get('CORE_HOST'), - password: Env.orFail('CORE_PASS'), - port: Env.getAs('CORE_PORT', Number.parseInt), - username: Env.orFail('CORE_USER'), - }) - - client.on('connect', () => { - logger.trace('Connected') - }) - client.on('disconnect', packet => { - logger.warn('Disconnected ' + packet.reason) - }) - client.on('login', () => { - logger.trace('Logged') - }) - - const modules = modulesTypes.map(t => new t(client)) -} diff --git a/src/core/Env.ts b/src/core/Env.ts deleted file mode 100644 index b65e3e6..0000000 --- a/src/core/Env.ts +++ /dev/null @@ -1,33 +0,0 @@ -interface IStringMap { [key: string]: string } - -export default class Env { - - public static get(key: string) { - return process.env[key] - } - public static getAs(key: string, mapper: (val: string) => T): T | undefined { - const val = this.get(key) - return val ? mapper(val) : undefined - } - - public static orElse(key: string, fallback: string) { - return this.get(key) || fallback - } - - public static orFail(key: string) { - const val = this.get(key) - if (val) { - return val - } - throw Error(`Required config key: ${key}`) - } - - public static map(map: IStringMap) { - const res = {} as IStringMap - for (const [key, val] of Object.entries(map)) { - res[key] = this.get(val) - } - return res - } - -} diff --git a/src/core/logger.ts b/src/core/logger.ts deleted file mode 100644 index 86f4883..0000000 --- a/src/core/logger.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { default as pino } from 'pino' -import Env from './Env' - -export const logger = pino({ level: Env.orFail('CORE_LOG') }) -logger.trace('Logger created') - -export function child(name: string, level?: string) { - const l = logger.child({ name, level: level || Env.get(name.toUpperCase() + '_LOG') }) - l.trace('Logger created') - return l -} diff --git a/src/core/utils.ts b/src/core/utils.ts deleted file mode 100644 index 1f5d57a..0000000 --- a/src/core/utils.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function longToNumber(arr: number[]) { - return arr[1] + 4294967296 * arr[0] -} diff --git a/src/index.ts b/src/index.ts index 3111b83..a7d6584 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,13 @@ -import * as dotenv from 'dotenv' -dotenv.config() +import { readFileSync } from 'fs' +import stripJsonComments from 'strip-json-comments' +import Cubbot from './Cubbot' -import app from './app' -app() +const config = JSON.parse(stripJsonComments(readFileSync('./env.json').toString())) + +const app = new Cubbot(config) +app.mount() + +process.on('SIGINT', () => { + app.umount() + setTimeout(process.exit, 100) +}) diff --git a/src/modules/Chat.ts b/src/modules/Chat.ts index b521e97..55a799e 100644 --- a/src/modules/Chat.ts +++ b/src/modules/Chat.ts @@ -1,15 +1,48 @@ -import { Client } from 'minecraft-protocol' -import Module from './Module' +import md from 'minecraft-data' +import { vsprintf } from 'sprintf-js' +import Module from '../utils/Module' -export default class Chat extends Module { +/** Message packet payload */ +interface IMessage { text?: string, extra?: Array<{text: string}>, translate?: string, with?: IMessage[] } + +/** Handle chat display and replies */ +export default class Chat extends Module<{ reply?: string }> { + + /** Translation dictionary (en only) */ + private dict!: { [key: string]: string } + + /** Convert payload to string */ + public parse(msg: IMessage) { + let text = '' + if (msg.text) { + text += msg.text + } + if (msg.translate && msg.translate in this.dict) { + text += vsprintf(this.dict[msg.translate], (msg.with || []).map(this.parse.bind(this))) + } + if (msg.extra) { + text += msg.extra.map(({text: t}) => t).join() + } + return text + } + + protected mount() { + this.client.on('login', () => { + // FIXME: use this.client.version when 'minecraft-data' include 1.15 language + this.dict = md('1.14').language + }) - public mount() { this.client.on('chat', packet => { const message = JSON.parse(packet.message) - this.logger.info(message.text || (message.extra ? message.extra.map((e: any) => e.text).join() : packet)) + this.logger.info(this.parse(message)) }) } //TODO: chat write + //TODO: reply + + protected getConf() { + return { } + } } diff --git a/src/modules/Client.ts b/src/modules/Client.ts new file mode 100644 index 0000000..4d3750d --- /dev/null +++ b/src/modules/Client.ts @@ -0,0 +1,32 @@ +import Module from '../utils/Module' + +/** Conection informations */ +export default class Client extends Module<{ reconnect: boolean }> { + + public umount() { + //TODO: proper disconnect + } + + protected mount() { + this.client.on('connect', () => { + this.logger.trace('Connected') + }) + this.client.on('disconnect', packet => { + this.logger.warn('Disconnected %s' + packet.reason) + }) + this.client.on('kick_disconnect', packet => { + this.logger.warn('Kicked %s', packet.reason) + }) + this.client.on('login', () => { + this.logger.trace('Logged') + }) + this.client.on('error', error => { + this.logger.error('error %o', error) + }) + } + + protected getConf() { + return { reconnect: true } + } + +} diff --git a/src/modules/Combat.ts b/src/modules/Combat.ts index bca5e4b..7f3f414 100644 --- a/src/modules/Combat.ts +++ b/src/modules/Combat.ts @@ -1,36 +1,137 @@ -import { Client } from 'minecraft-protocol' -import Module from './Module' +import { ATTACK_DIST2, SERVER_TICK_RATE } from '../utils/constants' +import { dist2 } from '../utils/func' +import Module from '../utils/Module' +import { IDelta } from '../utils/types' +import Entities, { IAlive } from './Entities' +import Life from './Life' +import State from './State' -export default class Combat extends Module { +interface IConf { + /** Respawn after death */ + respawn: boolean, + /** Attack dangerous entities */ + fight: boolean, + /** Must change weapon */ + weapon: 'none' | 'needed' | 'always', + /** Must react to arrows */ + arrows: 'ignore' | 'dodge' | 'shield' + /** Time between attacks */ + delay: number | 'item' | 'safe', + /** Attacks multiple entities at ones */ + multiaura: boolean, + /** Entity to target first */ + priority: 'nearest' +} - private respawn: boolean = true +interface IEnemy extends IAlive { + reach2: number +} + +const SAFE_DELAY = SERVER_TICK_RATE * 2 + +/** Protect and fight */ +export default class Combat extends Module { + + private state!: State + private entities!: Entities + + public umount() { + this.conf.fight = false + this.conf.arrows = 'ignore' + } + + protected mount() { + this.state = this.load(State) + this.entities = this.load(Entities) + + this.fight() + this.arrows() - public mount() { this.client.on('combat_event', packet => { switch (packet.event) { case 0: this.logger.warn('fighting') break - case 1: - this.logger.info('fighting entity %s', packet.entityId) - break - case 2: - this.logger.info('dead player %s by %s', packet.playerId, packet.entityId) - //TODO: is me ? - //TODO: print chat message - if (this.respawn) { - //TODO: use Life.respawn() - this.client.write('client_command', { action: 1 }) + if (packet.playerId === this.state.state.entityId) { + this.logger.error('%o killed me at %o', + this.entities.getAlive(packet.entityId), this.state.position) + // MAYBE: chat packet.message + if (this.conf.respawn) { + this.load(Life).respawn() + } + } else { + this.logger.info('killed %o', packet) } } }) } - //TODO: respawn or disconnect - //TODO: aura + protected getConf(): IConf { + return { + respawn: true, + fight: true, + weapon: 'always', + arrows: 'dodge', + delay: 'safe', + multiaura: false, + priority: 'nearest', + } + } - //TODO: Interact Entity + private fight() { + if (this.conf.fight) { + //TODO: pick weapon if change wait + //TODO: pick shield + + // Find dangerous entities + const dangers: IEnemy[] = ([...this.entities.players, ...this.entities.livings] as IAlive[]) + .filter(({ entityId }) => entityId !== this.state.state.entityId) + //TODO: filter friends, creative, passives + .map(e => ({ reach2: dist2(this.state.position, e), ...e })) // TODO: include box + .filter(({ reach2 }) => reach2 <= ATTACK_DIST2) // MAYBE: as option + .sort((a, b) => a.reach2 - b.reach2) + // MAYBE: switch this.conf.priority + // MAYBE: include entity speed (for tiny zombie or phantom) + + if (dangers.length > 0) { + this.logger.info('fighting %s target(s)', dangers.length); + + (this.conf.multiaura ? dangers : dangers.slice(0, 1)).forEach(({ entityId }) => { + this.logger.debug('attack %s', entityId) + this.entities.attack(entityId) + }) + } + + const attackSpeed = 1.6 //TODO: get from inventory + + const delay = typeof this.conf.delay === 'number' ? this.conf.delay : ( + this.conf.delay === 'safe' && dangers.length == 0 ? SAFE_DELAY : // TODO: exponential back-off + 1 / attackSpeed * 20 * SERVER_TICK_RATE) + + setTimeout(this.fight.bind(this), delay) + } + } + + private arrows() { + if (this.conf.arrows !== 'ignore') { + const arrows = [...this.entities.objects] + .filter(e => e.type === 2 && // arrow + dist2(this.state.position, e) <= 99) + if (arrows.length > 0) { + this.logger.warn('%s arrows', arrows.length) + + this.state.translate(arrows.reduce((p, arrow) => + Math.abs(arrow.velocityX) < Math.abs(arrow.velocityZ) ? + { ...p, dX: p.dX + Math.sign(arrow.velocityX) } : + { ...p, dZ: p.dZ + Math.sign(arrow.velocityZ) }, + { dX: 0, dY: 0, dZ: 0 } as IDelta)) // MAYBE: do some trigo + } + setTimeout(this.arrows.bind(this), SAFE_DELAY) + } + } + + //TODO: respawn or disconnect } diff --git a/src/modules/Entities.ts b/src/modules/Entities.ts index 8049e4a..924f085 100644 --- a/src/modules/Entities.ts +++ b/src/modules/Entities.ts @@ -1,12 +1,293 @@ -//TODO: spawn_entity_living -//TODO: entity_metadata -//TODO: entity_update_attributes -//TODO: entity_equipment -//TODO: entity_move_look -//TODO: entity_head_rotation -//TODO: rel_entity_move -//TODO: entity_velocity -//TODO: entity_destroy -//TODO: entity_teleport -//TODO: entity_look -//TODO: set_passengers \ No newline at end of file +import { applyDelta, applyVelocity } from '../utils/func' +import Module from '../utils/Module' +import { IDelta, IMovable, IPosition, IRotation, ISlot, IState, IVelocity } from '../utils/types' +import Time from './Time' + +interface IId { + entityId: number +} + +interface IPositioned extends IId, IPosition { } +interface IMovableE extends IId, IMovable { } +interface IMove extends IId, IDelta { } +interface ILook extends IId, IRotation { } +interface IMoveLook extends IMove, ILook { } +interface IHead extends IId { + headPitch: number +} +interface IVelocityE extends IId, IVelocity { } + +interface IObject extends IMovableE, IMetadatable { + objectId: number + type: number + data: number +} + +interface IOrb extends IMovableE, IMetadatable { + count: number +} + +interface IEquipment extends IId { + slot: number, + item: ISlot +} +interface IEquipable { + equipment?: Map +} + +interface IAttribute { + key: string + value: number + modifiers: IModifier[] +} +interface IModifier { + uuid: string + amount: number + operation: number +} +interface IAttributes extends IId { + properties: IAttribute[] +} +interface IAttributable { + attributes?: IAttribute[] +} + +interface IMetadata { + key: number + type: number + value: any +} +interface IMetadatas extends IId { + metadata: IMetadata[] +} +interface IMetadatable { + metadata?: Map +} + +export interface IAlive extends IMovableE, IEquipable, IAttributable, IMetadatable { + headPitch: number +} + +interface ILiving extends IAlive { + entityUUID: string + type: number +} + +interface IPlayerSpawn extends IPositioned { + playerUUID: string +} +interface IPlayer extends IPlayerSpawn, IAlive { + me?: boolean +} + +/** Track entities */ +export default class Entities extends Module<{}> { + + private _objects = new Map() + public get objects() { + return this._objects.values() + } + + private _orbs = new Map() + public get orbs() { + return this._orbs.values() + } + + private _livings = new Map() + public get livings() { + return this._livings.values() + } + + private _players = new Map() + public get players() { + return this._players.values() + } + + public getEntity(entityId: number): IMovableE | undefined { + return this._players.get(entityId) || this._livings.get(entityId) || + this._objects.get(entityId) || this._orbs.get(entityId) + } + + public getAlive(entityId: number): IAlive | undefined { + return this._players.get(entityId) || this._livings.get(entityId) + } + + public getMetadatable(entityId: number): IMetadatable | undefined { + return this._players.get(entityId) || this._livings.get(entityId) || + this._objects.get(entityId) || this._orbs.get(entityId) + } + + public deleteEntity(entityId: number) { + return this._players.delete(entityId) || this._livings.delete(entityId) || + this._objects.delete(entityId) || this._orbs.delete(entityId) + } + + public attack(entityId: number, mainHand: boolean = true) { + this.client.write('use_entity', { target: entityId, mouse: 1, hand: mainHand ? 0 : 1 }) + } + + protected mount() { + + this.load