Atomic commit
continuous-integration/drone/push Build is failing Details

master
May B. 2020-04-19 19:02:07 +02:00
parent 6ca0227fa4
commit de29687a11
26 changed files with 965 additions and 240 deletions

View File

@ -1,8 +0,0 @@
CORE_LOG=info
CORE_HOST=""
#CORE_PORT=
CORE_USER=""
CORE_PASS=""
CHAT_LOG=info
COMBAT_LOG=info

2
.gitignore vendored
View File

@ -1,7 +1,7 @@
.idea/
.vscode/
.nyc_output/
.env
env.json
coverage/
node_modules/
build/

18
env.sample.json Normal file
View File

@ -0,0 +1,18 @@
{
"client": {
"host": "server.tld",
"username": "Bot",
"password": "root"
},
"log": {
"level": "info"
},
"modules": {
"Client": {},
"Combat": {},
"Chat": {},
"Time": {
"log": "debug"
}
}
}

41
package-lock.json generated
View File

@ -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",

View File

@ -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",

99
src/Cubbot.ts Normal file
View File

@ -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<string, Module<{}>>
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<T extends Module<{}>>(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<string, Module<{}>>()
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()
}
}
}

View File

@ -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))
}

View File

@ -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<T>(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
}
}

View File

@ -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
}

View File

@ -1,3 +0,0 @@
export function longToNumber(arr: number[]) {
return arr[1] + 4294967296 * arr[0]
}

View File

@ -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)
})

View File

@ -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 { }
}
}

32
src/modules/Client.ts Normal file
View File

@ -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 }
}
}

View File

@ -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<IConf> {
private state!: State
private entities!: Entities
public umount() {
this.conf.fight = false
this.conf.arrows = 'ignore'
}
protected mount() {
this.state = this.load<State>(State)
this.entities = this.load<Entities>(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>(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
}

View File

@ -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
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<number, ISlot>
}
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<number, IMetadata>
}
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<number, IObject>()
public get objects() {
return this._objects.values()
}
private _orbs = new Map<number, IOrb>()
public get orbs() {
return this._orbs.values()
}
private _livings = new Map<number, ILiving>()
public get livings() {
return this._livings.values()
}
private _players = new Map<number, IPlayer>()
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<Time>(Time).events.on('tick', () => {
this._objects.forEach(applyVelocity)
// MAYBE: apply velocity to others
})
this.client.on('spawn_entity', (packet: IObject) => {
this._objects.set(packet.entityId, packet)
})
this.client.on('spawn_entity_experience_orb', (packet: IOrb) => {
this._orbs.set(packet.entityId, packet)
})
this.client.on('spawn_entity_living', (packet: ILiving) => {
this._livings.set(packet.entityId, packet)
})
this.client.on('named_entity_spawn', (packet: IPlayerSpawn) => {
const player = {
velocityX: 0,
velocityY: 0,
velocityZ: 0,
headPitch: 0,
...packet,
}
this._players.set(player.entityId, player)
})
this.client.on('login', (packet: IState) => {
this._players.set(packet.entityId, { // MAYBE: link with State
me: true,
entityId: packet.entityId,
playerUUID: '',
x: 0,
y: 0,
z: 0,
pitch: 0,
yaw: 0,
velocityX: 0,
velocityY: 0,
velocityZ: 0,
headPitch: 0,
})
})
// MAYBE: this.client.on('animation', packet => { })
// MAYBE: this.client.on('entity_status', packet => { })
this.client.on('rel_entity_move', (packet: IMove) => {
const entity = this.getEntity(packet.entityId)
if (entity) {
applyDelta(entity, packet)
} else {
this.logger.warn('rel_move of unknown entity %s', packet.entityId)
}
})
this.client.on('entity_move_look', (packet: IMoveLook) => {
const entity = this.getEntity(packet.entityId)
if (entity) {
applyDelta(entity, packet)
entity.pitch = packet.pitch
entity.yaw = packet.yaw
} else {
this.logger.warn('move_look of unknown entity %s', packet.entityId)
}
})
this.client.on('entity_look', (packet: ILook) => {
const entity = this.getEntity(packet.entityId)
if (entity) {
entity.pitch = packet.pitch
entity.yaw = packet.yaw
} else {
this.logger.warn('look of unknown entity %s', packet.entityId)
}
})
this.client.on('entity_head_rotation', (packet: IHead) => {
const entity = this.getAlive(packet.entityId)
if (entity) {
entity.headPitch = packet.headPitch
} else {
this.logger.warn('head_rotation of unknown entity %s', packet.entityId)
}
})
this.client.on('entity_velocity', (packet: IVelocityE) => {
const entity = this.getEntity(packet.entityId)
if (entity) {
entity.velocityX = packet.velocityX
entity.velocityY = packet.velocityY
entity.velocityZ = packet.velocityZ
} else {
this.logger.warn('velocity of unknown entity %s', packet.entityId)
}
})
this.client.on('entity_teleport', (packet: IPositioned) => {
const entity = this.getEntity(packet.entityId)
if (entity) {
entity.x = packet.x
entity.y = packet.y
entity.z = packet.z
entity.pitch = packet.pitch
entity.yaw = packet.yaw
} else {
this.logger.warn('teleport of unknown entity %s', packet.entityId)
}
})
this.client.on('entity_destroy', (packet: { entityIds: number[] }) => {
packet.entityIds.forEach(id => {
if (!this.deleteEntity(id)) {
this.logger.debug('can not delete entity %s', id)
}
})
})
// TODO: identify metadatas
this.client.on('entity_metadata', (packet: IMetadatas) => {
const entity = this.getMetadatable(packet.entityId)
if (entity) {
if (!entity.metadata) {
entity.metadata = new Map()
}
for (const data of packet.metadata) {
entity.metadata.set(data.key, data)
}
} else {
this.logger.warn('metadata of unknown entity %s (%s)', packet.entityId)
}
})
// MAYBE: this.client.on('entity', (packet: IId) => { })
// TODO: entity_effect, remove_entity_effect
this.client.on('entity_equipment', (packet: IEquipment) => {
const entity = this.getAlive(packet.entityId)
if (entity) {
entity.equipment = entity.equipment ?
entity.equipment.set(packet.slot, packet.item) :
new Map([[packet.slot, packet.item]])
} else {
this.logger.warn('equipment of unknown entity %s', packet.entityId)
}
})
// TODO: identify (with properties)
this.client.on('entity_update_attributes', (packet: IAttributes) => {
const entity = this.getAlive(packet.entityId)
if (entity) {
entity.attributes = packet.properties
} else {
this.logger.warn('attributes of unknown entity %s', packet.entityId)
}
})
}
protected getConf() {
return {}
}
}
// TODO: player info
// MAYBE: animation
// MAYBE: attach_entity
// MAYBE: entity_sound_effect
// MAYBE: set_passengers

View File

@ -1,19 +1,36 @@
import { Client } from 'minecraft-protocol'
import Module from './Module'
import { LOW_HEALTH, MAX_HEALTH, REGENERATE_FOOD, SPRINT_FOOD } from '../utils/constants'
import { round } from '../utils/func'
import Module from '../utils/Module'
export default class Life extends Module {
interface IConf {
/** Respawn on world join */
spawn: boolean,
/** Warn if health is lower */
lowHealth: number,
/** Life to regenerate */
targetHealth: number,
/** Log health info */
showHealth: boolean,
/** Use food if needed */
eat: boolean,
/** Prefer saturation over food power */
preferSaturation: boolean,
/** Warn and eat if food level is lower */
lowFood: number,
/** Food level needed to regenerate */
regenerateFood: number,
/** Log food level info */
showFood: boolean,
}
private _health!: number
/** Monitor health, food and xp */
export default class Life extends Module<IConf> {
public get health() {
return this._health
}
private _food!: number
public get food() {
return this._food
}
private _saturation!: number
public get saturation() {
return this._saturation
}
@ -22,35 +39,67 @@ export default class Life extends Module {
return this.health > 0
}
private _experienceBar!: number
public get mustEat() {
return this.food <= this.conf.lowFood ||
(this.health <= this.conf.targetHealth && this.food <= this.conf.regenerateFood)
}
public get experienceBar() {
return this._experienceBar
}
private _level!: number
public get level() {
return this._level
}
private _totalExperience!: number
public get totalExperience() {
return this._totalExperience
}
public mount() {
private _health!: number
private _food!: number
private _saturation!: number
private _experienceBar!: number
private _level!: number
private _totalExperience!: number
public respawn() {
this.logger.info('respawn')
this.client.write('client_command', { action: 1 })
}
protected mount() {
//TODO: const inventory = this.conf.eat ? this.load('inventory') : undefined
this.client.on('update_health', data => {
if (this._health == null) {
if (this.conf.spawn && this._health == null) {
setTimeout(this.respawn.bind(this), 500)
}
this._health = data.health
this._health = round(data.health)
this._food = data.food
this._saturation = data.foodSaturation
if (this.health > 5) {
this.logger.info('health: %s', this.health)
} else {
this.logger.warn('low health: %s', this.health)
if (this.health <= this.conf.lowHealth) {
this.logger.warn({ type: 'health', status: this.alive ? 'low' : 'ko', health: this.health })
} else if (this.conf.showHealth) {
this.logger.info({ type: 'health', status: 'ok', health: this.health })
}
if (this.food <= this.conf.lowFood) {
this.logger.warn({ type: 'food', status: 'low', food: this.food, saturation: this.saturation })
} else if (this.conf.showHealth) {
this.logger.info({ type: 'food', status: 'ok', food: this.food, saturation: this.saturation })
}
if (this.conf.eat && this.mustEat) {
this.logger.warn('TODO: must Eat')
// TODO: inventory.items.filter(food).orderBy(this.conf.preferSaturation)
}
})
this.client.on('experience', data => {
this._experienceBar = data.experienceBar
this._level = data.level
@ -58,11 +107,20 @@ export default class Life extends Module {
})
}
public respawn() {
this.logger.info('respawn')
this.client.write('client_command', { action: 1 })
}
//TODO: send client settings
protected getConf(): IConf {
return {
spawn: true,
lowHealth: LOW_HEALTH,
targetHealth: MAX_HEALTH,
showHealth: false,
eat: true,
preferSaturation: true,
lowFood: SPRINT_FOOD,
regenerateFood: REGENERATE_FOOD,
showFood: false,
}
}
}

View File

@ -6,7 +6,8 @@
//TODO: weather_entity aka thunderbolt
//TODO: spawn_weather
//TODO: spawn_painting

View File

@ -1,16 +0,0 @@
import { Client } from 'minecraft-protocol'
import { Logger } from 'pino'
import { child } from '../core/logger'
export default abstract class IModule {
protected logger: Logger
protected client: Client
constructor(client: Client) {
this.client = client
this.logger = child(this.constructor.name)
this.mount()
}
protected abstract mount(): void
}

View File

@ -1,19 +1,7 @@
import { Client } from 'minecraft-protocol'
import Module from './Module'
import Module from '../utils/Module'
import { IDelta, IPosition, IState } from '../utils/types'
//MAYBE: split
interface IState {
entityId: number,
gameMode: 0|1|2|3,
dimension: number,
hashedSeed: number[],
maxPlayers: number,
levelType: string,
viewDistance: number,
reducedDebugInfo: boolean,
enableRespawnScreen: boolean
}
// MAYBE: split
interface IDifficulty {
level: number,
@ -21,28 +9,20 @@ interface IDifficulty {
}
interface IAbilities {
//TODO: Flags Byte Bit mask. 0x08: damage disabled (god mode), 0x04: can fly, 0x02: is flying, 0x01: is Creative
// TODO: Flags Byte Bit mask. 0x08: damage disabled (god mode), 0x04: can fly, 0x02: is flying, 0x01: is Creative
flyingSpeed: number,
walkingSpeed: number
}
interface IPosition {
x: number,
y: number,
z: number,
yaw: number,
pitch: number,
flags: number,
interface IPositionPacket extends IPosition {
flags: number
teleportId: number
}
//TODO: entity_status {entityId, entityStatus} aka animations
// TODO: player_info
//TODO: player_info
export default class State extends Module {
public positionConfirm: boolean = true
/** Handle client state and position */
export default class State extends Module<{ positionConfirm: boolean }> {
private _state!: IState
public get state() {
@ -64,17 +44,26 @@ export default class State extends Module {
return this._position
}
public mount() {
public translate(delta: IDelta, onGround: boolean = true) {
this.client.write('position', {
onGround,
x: this.position.x += delta.dX,
y: this.position.y += delta.dY,
z: this.position.z += delta.dZ,
})
}
protected mount() {
this.client.on('login', data => this._state = data)
this.client.on('difficulty', data => this._difficulty = {
level: data.difficulty, locked: data.difficultyLocked,
})
this.client.on('abilities', data => this._abilities = data)
this.client.on('position', (data: IPosition) => {
this.client.on('position', (data: IPositionPacket) => {
if (this._position == null) {
if (data.flags) {
this.logger.error('first postion is relative')
this.logger.error('first position is relative')
return
} else {
this._position = data
@ -87,16 +76,20 @@ export default class State extends Module {
z: data.z + (data.flags & 0x04 ? this._position.z : 0),
yaw: data.yaw + (data.flags & 0x08 ? this._position.yaw : 0),
pitch: data.pitch + (data.flags & 0x10 ? this._position.pitch : 0),
flags: 0,
teleportId : -1,
}
}
if (this.positionConfirm) {
if (this.conf.positionConfirm) {
this.client.write('teleport_confirm', { teleportId: data.teleportId })
}
this.logger.debug({ type: 'teleported', position: this.position })
})
}
protected getConf() {
return { positionConfirm: true }
}
}
// TODO: vehicule_move

View File

@ -1,57 +1,107 @@
import { Client } from 'minecraft-protocol'
import { longToNumber } from '../core/utils'
import Module from './Module'
import { EventEmitter } from 'events'
import { SERVER_TICK_RATE, SERVER_TPS } from '../utils/constants'
import { longToNumber } from '../utils/func'
import Module from '../utils/Module'
interface IUpdateTime {
age: number[]
time: number[]
}
export default class Time extends Module {
interface IConf {
/** Log if server lost ticks */
minTps: number,
/** Log if server lost tps */
maxSpt: number,
/** Log if client is not in sync */
maxOffset: number
}
interface IEmitter {
on(event: 'tick', listener: (age: number) => void): this
}
/** Monitor time and ticks */
export default class Time extends Module<IConf> {
private _events = new EventEmitter()
public get events(): IEmitter {
return this._events
}
private _age!: number
/** Ticks since world creation */
public get age() {
return this._age
}
private _distAge!: number
private _time!: number
/** Ticks of the day */
public get time() {
return this._time
}
private _tps!: number
/** Processed Tick per seconds */
public get tps() {
return this._tps
}
private _tpsLocal!: number
public get tpsLocal() {
return this._tpsLocal
private _spt!: number
/** Processed Tick per seconds */
public get spt() {
return this._spt
}
private _at: number = Date.now()
public mount() {
private _clock?: NodeJS.Timeout
public umount() {
if (this._clock) {
clearInterval(this._clock)
this._clock = undefined
}
}
protected mount() {
this.client.on('update_time', (data: IUpdateTime) => {
const now = Date.now()
const age = longToNumber(data.age)
if (this._age) {
this._tps = age - this._age
if (this.tps < 20) {
this.logger.debug('Server Lag: %s', this.tps)
if (this._distAge) {
this._tps = age - this._distAge
if (this.tps <= this.conf.minTps) {
this.logger.debug('Server Lag: %s / 20 tps', this.tps, SERVER_TPS)
}
this._tpsLocal = (now - this._at) / 50
if (this._tpsLocal > 20 + .5) {
this.logger.debug('Client Lag: %s', this.tpsLocal)
this._spt = (now - this._at) / this.tps
if (this.spt >= this.conf.maxSpt) {
this.logger.debug('Network Lag: %s / %s spt', this.spt, SERVER_TICK_RATE)
}
const offset = age - this._age
if (Math.abs(offset) > this.conf.maxOffset) {
this.logger.debug('Client Off-sync %s ticks', offset)
}
}
this._age = age
this._distAge = this._age = age
this._at = now
this._time = Math.abs(data.time[1]) % 24000
})
this._clock = setInterval(() => this._events.emit('tick', ++this._age), SERVER_TICK_RATE)
}
protected getConf(): IConf {
return {
minTps: SERVER_TPS - 1,
maxSpt: SERVER_TICK_RATE + 10,
maxOffset: 1,
}
}
}

View File

@ -1,13 +1,19 @@
import { IModuleType } from '../utils/Module'
import Chat from './Chat'
import Client from './Client'
import Combat from './Combat'
import Entities from './Entities'
import Life from './Life'
import State from './State'
import Time from './Time'
/** Building modules */
export default [
Chat,
Client,
Combat,
Entities,
Life,
State,
Time,
Life,
Chat,
Combat,
]
] as IModuleType[]

44
src/utils/Module.ts Normal file
View File

@ -0,0 +1,44 @@
import { Client } from 'minecraft-protocol'
import { Logger } from 'pino'
type ModuleLoader = <T extends Module<{}>>(module: IModuleType) => T
/** Typeof Module */
export interface IModuleType {
name: string
new(client: Client, logger: Logger, conf: object, getModule: ModuleLoader): Module<{}>
}
/** Management unit */
export default abstract class Module<T extends object> {
protected logger: Logger
protected client: Client
protected conf: T
private readonly getModule: ModuleLoader
constructor(client: Client, logger: Logger, conf: object, getModule: ModuleLoader) {
this.client = client
this.logger = logger
this.conf = {...this.getConf(), ...conf}
this.getModule = getModule
this.mount()
}
/** Mostly to clean timers */
public umount() {
// nothing
}
/** Default values of config */
protected abstract getConf(): T
/** Setup listener on client */
protected abstract mount(): void
/** Get reference to another module */
protected load<M extends Module<{}>>(module: IModuleType): M {
return this.getModule<M>(module)
}
}

17
src/utils/constants.ts Normal file
View File

@ -0,0 +1,17 @@
export const SPRINT_FOOD = 6
export const REGENERATE_FOOD = 18
export const MAX_FOOD = 20
export const MAX_HEALTH = 20
export const LOW_HEALTH = 4
export const REACH_DIST = 6
export const REACH_DIST2 = REACH_DIST * REACH_DIST
export const ATTACK_DIST = 4
export const ATTACK_DIST2 = ATTACK_DIST * ATTACK_DIST
export const VELOCITY_RATIO = 8000
export const DELTA_RATIO = 128 * 32
export const SERVER_TICK_RATE = 50
export const SERVER_TPS = 1 / SERVER_TICK_RATE * 1000

30
src/utils/func.ts Normal file
View File

@ -0,0 +1,30 @@
import { DELTA_RATIO, VELOCITY_RATIO } from './constants'
import { IDelta, IMovable, IPosition } from './types'
export function longToNumber(arr: number[]) {
return arr[1] + 4294967296 * arr[0]
}
export function round(val: number, precision: number = 1) {
const pow = Math.pow(10, precision)
return Math.round((val + Number.EPSILON) * pow) / pow
}
export function dist2(from: IPosition, to: IPosition) {
const x = from.x - to.x
const y = from.y - to.y
const z = from.z - to.z
return (x * x) + (y * y) + (z * z)
}
export function applyVelocity(movable: IMovable) {
movable.x += (movable.velocityX / VELOCITY_RATIO)
movable.y += (movable.velocityY / VELOCITY_RATIO)
movable.z += (movable.velocityZ / VELOCITY_RATIO)
}
export function applyDelta(movable: IMovable, delta: IDelta) {
movable.x += (delta.dX / DELTA_RATIO)
movable.y += (delta.dY / DELTA_RATIO)
movable.z += (delta.dZ / DELTA_RATIO)
}

45
src/utils/types.ts Normal file
View File

@ -0,0 +1,45 @@
export interface ICoordinates {
x: number
y: number
z: number
}
export interface IRotation {
yaw: number
pitch: number
}
export interface IVelocity {
velocityX: number
velocityY: number
velocityZ: number
}
export interface IDelta {
dX: number
dY: number
dZ: number
}
export interface IPosition extends ICoordinates, IRotation { }
export interface IMovable extends IPosition, IVelocity { }
export interface ISlot {
present: boolean
itemId: number
itemCount: number
nbtData: object
//TODO:
}
export interface IState {
entityId: number,
gameMode: 0|1|2|3,
dimension: number,
hashedSeed: number[],
maxPlayers: number,
levelType: string,
viewDistance: number,
reducedDebugInfo: boolean,
enableRespawnScreen: boolean
}

View File

@ -13,7 +13,8 @@
"experimentalDecorators": true,
"sourceMap": true,
"noImplicitAny": true,
"esModuleInterop": true
"esModuleInterop": true,
"downlevelIteration": true
},
"include": [
"src/**/*"