Inventory
continuous-integration/drone/push Build is passing Details

master
May B. 2020-04-23 00:08:52 +02:00
parent 2dc150c38b
commit 095c5c8ce2
13 changed files with 478 additions and 100 deletions

View File

@ -35,7 +35,7 @@ yarn run test
```
### Note
`minecraft-data` package isn't updated for 1.15+. As a workaround, data will be downloader for `https://raw.githubusercontent.com/Pokechu22/Burger/gh-pages/{version}.json` and cached in `data` folder
`minecraft-data` package isn't updated for 1.15+. As a workaround, data will be downloader from `https://pokechu22.github.io/Burger/{version}.json` and cached in `data` folder
## Tests
```

View File

@ -15,6 +15,7 @@
},
"Combat": {},
"Chat": {},
"Meal": {},
"Time": {
"log": "debug"
}

View File

@ -4,7 +4,8 @@ import Module from '../utils/Module'
import { IDelta } from '../utils/types'
import Connection from './Connection'
import Entities, { IAlive, ILiving, IPlayer } from './Entities'
import Life from './Life'
import Inventory from './Inventory'
import Spawn from './Spawn'
import State from './State'
interface IConf {
@ -28,6 +29,10 @@ interface IConf {
mobs: string[]
/** Targeted entities ids (override mobs) */
mobsIds?: number[]
/** Weapons names by priority */
weapons: string[]
/** Weapons ids by priority (override weapons) */
weaponsIds?: number[]
}
interface IEnemy extends IAlive {
@ -40,9 +45,8 @@ const SAFE_DELAY = SERVER_TICK_RATE * 2
export default class Combat extends Module<IConf> {
private state!: State
private life!: Life
private spawn!: Spawn
private entities!: Entities
private connection!: Connection
public umount() {
this.conf.fight = false
@ -51,13 +55,27 @@ export default class Combat extends Module<IConf> {
protected mount() {
this.state = this.load<State>(State)
this.life = this.load<Life>(Life)
this.spawn = this.load<Spawn>(Spawn)
this.entities = this.load<Entities>(Entities)
this.connection = this.load<Connection>(Connection)
this.fight()
this.arrows()
if (this.conf.mobsIds && (this.conf.weaponsIds || this.conf.weapon === 'none')) {
this.fight()
} else {
this.load<Connection>(Connection).data.onReady(data => {
if (!this.conf.mobsIds) {
this.conf.mobsIds = this.conf.mobs
.map(name => data.entities![name].id)
}
if (!this.conf.weaponsIds) {
this.conf.weaponsIds = this.conf.weapons
.map(name => data.items![name].numeric_id)
}
this.fight()
})
}
this.client.on('combat_event', packet => {
switch (packet.event) {
case 0:
@ -72,7 +90,7 @@ export default class Combat extends Module<IConf> {
})
// MAYBE: chat packet.message
if (this.conf.respawn) {
this.life.respawn()
this.spawn.respawn()
}
} else {
this.logger.info({ msg: 'Fight end', packet })
@ -85,7 +103,7 @@ export default class Combat extends Module<IConf> {
return {
respawn: true,
fight: true,
weapon: 'always',
weapon: 'needed',
arrows: 'dodge',
delay: 'safe',
multiaura: false,
@ -98,22 +116,18 @@ export default class Combat extends Module<IConf> {
/*'snow_golem',*/ 'spider', 'stray', 'vex', 'vindicator', 'witch', 'wither', 'wither_skeleton',
/*'wolf',*/ 'zombie', /*'zombie_pigman',*/ 'zombie_villager',
],
weapons: [
'diamond_sword', 'diamond_axe', 'iron_sword', 'iron_axe', 'stone_sword', 'stone_axe',
],
}
}
private fight() {
if (this.conf.fight) {
//TODO: pick weapon if change wait
//TODO: pick shield
if (!this.conf.mobsIds) {
if (this.connection.data?.ready) {
this.conf.mobsIds = this.conf.mobs
.map(name => this.connection.data!.entities![name].id)
} else {
this.logger.warn('Waiting data')
setTimeout(this.fight.bind(this), SAFE_DELAY)
}
if (this.conf.weapon == 'always') {
this.equipWeapon()
}
//TODO: pick shield
// Find dangerous entities
const dangers: IEnemy[] = ([...this.entities.players, ...this.entities.livings] as IAlive[])
@ -139,6 +153,10 @@ export default class Combat extends Module<IConf> {
if (dangers.length > 0) {
this.logger.info({ msg: 'Fighting', type: 'count', value: dangers.length });
if (this.conf.weapon === 'needed') {
this.equipWeapon()
}
(this.conf.multiaura ? dangers : dangers.slice(0, 1)).forEach(({ entityId }) => {
this.logger.debug({ msg: 'Attack', type: 'entityId', value: entityId })
this.entities.attack(entityId)
@ -155,6 +173,14 @@ export default class Combat extends Module<IConf> {
}
}
private equipWeapon() {
const inv = this.load<Inventory>(Inventory)
const weapon = inv.findFirst(this.conf.weaponsIds!)
if (weapon.slot >= 0) {
inv.selectItem(weapon.slot, false)
}
}
private arrows() {
if (this.conf.arrows !== 'ignore') {
const arrows = [...this.entities.objects]
@ -176,3 +202,6 @@ export default class Combat extends Module<IConf> {
//TODO: respawn or disconnect
}
// TODO: look and swing
// TODO: critical

View File

@ -3,6 +3,7 @@ import Cubbot from '../Cubbot'
import Data from '../utils/Data'
import Module from '../utils/Module'
import Chat, { IMessage } from './Chat'
import Spawn from './Spawn'
type DisconnectReason = 'authservers_down' | 'banned' | 'banned.reason' | 'banned.expiration' | 'banned_ip.reason' |
'banned_ip.expiration' | 'duplicate_login' | 'flying' | 'generic' | 'idling' | 'illegal_characters' |
@ -37,7 +38,7 @@ export default class Connection extends Module<IConfig> {
return this._oldData
}
private _data?: Data
private _data = new Data()
public get data() {
return this._data
}
@ -48,9 +49,13 @@ export default class Connection extends Module<IConfig> {
}
protected mount() {
const spawn = this.load<Spawn>(Spawn)
spawn.addCheck('data')
this.client.on('connect', () => {
this._connected = true
this.logger.trace('Connected')
this._data.onReady(() => spawn.validateCheck('data'))
})
this.client.on('disconnect', ({ reason }) => {
this._connected = false
@ -66,7 +71,7 @@ export default class Connection extends Module<IConfig> {
})
this.client.on('login', () => {
this.logger.trace('Logged')
this._data = new Data(this.conf.dataSource, this.conf.dataDir, (this.client as any).version,
this._data.load(this.conf.dataSource, this.conf.dataDir, (this.client as any).version,
this.logger.child({ name: 'Data', level: 'info' }))
// FIXME: use 'minecraft-data' when it include 1.15+
})

View File

@ -254,8 +254,6 @@ export default class Entities extends Module<{}> {
}
// TODO: player info
// MAYBE: animation
// MAYBE: attach_entity
// MAYBE: entity_sound_effect

View File

@ -1,9 +1,200 @@
//TODO: window_items
//TODO: set_slot
import { HOTBAR_END_SLOT, HOTBAR_START_SLOT, OFF_HAND_SLOT } from '../utils/constants'
import Module from '../utils/Module'
import { ISlot } from '../utils/types'
//TODO: declare_recipes
type Ingredient = ISlot[]
type IRecipe = IRecipeCraftingShapeless | IRecipeCraftingShaped | IRecipeSpecial | IRecipeSmelting | IRecipeStoneCutting
interface IRecipeCraftingShapeless {
type: 'minecraft:crafting_shapeless'
recipeId: string
data: {
group: string,
ingredients: Ingredient[],
result: ISlot,
}
}
interface IRecipeCraftingShaped {
type: 'minecraft:crafting_shaped'
recipeId: string
data: {
group: string,
width: number,
height: number,
ingredients: Ingredient[][],
result: ISlot,
}
}
interface IRecipeSpecial {
type: string
recipeId: string
}
interface IRecipeSmelting {
type: 'minecraft:smelting' | 'minecraft:blasting' | 'minecraft:smoking' | 'minecraft:campfire_cooking'
recipeId: string
data: {
group: string,
ingredient: Ingredient,
result: ISlot,
experience: number,
cookTime: number,
}
}
interface IRecipeStoneCutting {
type: 'minecraft:stonecutting'
recipeId: string
data: {
group: string,
ingredient: Ingredient,
result: ISlot,
}
}
interface IWindow {
items?: ISlot[]
closed?: true
properties?: number[]
}
const NULL_SLOT: ISlot = { present: false, itemId: -1, itemCount: 0, nbtData: [] }
export default class Inventory extends Module<{}> {
private _recipes!: IRecipe[]
public get recipes() {
return this._recipes
}
private _windows = new Map<number, IWindow>()
public get inventory() {
return this._windows.get(0)?.items || []
}
public get armor() {
return {
head: this.inventory[5],
chest: this.inventory[6],
legs: this.inventory[7],
feet: this.inventory[8],
}
}
private get hotbar() {
return this.inventory.slice(HOTBAR_START_SLOT, HOTBAR_END_SLOT)
}
private get offItem() {
return this.inventory[OFF_HAND_SLOT]
}
private _held = 0
public get mainHand() {
return this._held
}
public get mainItem() {
return this.hotbar[this.mainHand]
}
private _actionCounter = 0
public click(windowId: number, slot: number, mouseButton: number, mode: number, item: ISlot) {
this.client.write('window_click', { windowId, slot, mouseButton, action: this._actionCounter++, mode, item })
}
public toHotbar(to: number, from: number, windowId = 0) {
const inv = this._windows.get(windowId)?.items
const item = inv ? inv[from] : NULL_SLOT
this.click(windowId, from, to, 2, item)
}
public drop(from: number, windowId = 0, stack = false) {
this.click(windowId, from, stack ? 1 : 0, 4, NULL_SLOT)
}
public setHeld(slotId: number) {
this.client.write('held_item_slot', { slotId })
this._held = slotId
}
public swapOffItem() {
this.client.write('block_dig', { status: 6, location: { x: 0, y: 0, z: 0 }, face: 0 })
}
public selectItem(slot: number, allowOff: boolean) {
let hand = -1
if (slot === OFF_HAND_SLOT && allowOff) {
hand = 1
} else {
let hotSlot = slot
if (hotSlot < HOTBAR_START_SLOT || hotSlot > HOTBAR_END_SLOT) {
hotSlot = this.hotbar.findIndex(s => !s.present)
if (hotSlot < 0) {
hotSlot = this.mainHand + 1 % (HOTBAR_END_SLOT - HOTBAR_START_SLOT)
}
this.toHotbar(hotSlot, slot)
} else {
hotSlot -= HOTBAR_START_SLOT
}
if (hotSlot !== this.mainHand || Math.random() > 0.9) { // Force held randomlys
this.setHeld(hotSlot)
}
hand = 0
}
return hand
}
public useItem(slot: number, allowOff = true) {
this.client.write('use_item', { hand: this.selectItem(slot, allowOff) })
}
public findFirst(order: number[]) {
return this.inventory.reduce((prev, slot, pos) => {
const idx = order.indexOf(slot.itemId)
return idx < 0 || (prev.value > -1 && prev.value <= idx) ? prev : { slot: pos, value: idx }
}, { slot: -1, value: -1 })
}
protected mount(): void {
this.client.on('declare_recipes', packet => this._recipes = packet.recipes)
this.client.on('window_items', packet => this.updateWindow(packet.windowId, { items: packet.items}))
this.client.on('close_window', packet => this.updateWindow(packet.windowId, { closed: true }))
this.client.on('craft_progress_bar', packet => {
const window = this._windows.get(packet.windowId) || {}
if (!window.properties) {
window.properties = []
}
window.properties[packet.property] = packet.value
this._windows.set(packet.windowId, window)
})
this.client.on('transaction', packet => {
// TODO: action list
if (!packet.accepted) {
this.logger.warn('Conflict')
this.client.write('transaction', packet)
}
})
this.client.on('set_slot', packet => {
const window = this._windows.get(packet.windowId) || {}
if (!window.items) {
window.items = []
}
window.items[packet.slot] = packet.item
this._windows.set(packet.windowId, window)
})
this.client.on('held_item_slot', packet => this._held = packet.slot)
}
protected getConf() {
return {}
}
private updateWindow(windowId: number, data: IWindow) {
const window = this._windows.get(windowId) || {}
this._windows.set(windowId, {...window, ...data})
}
// TODO: autodrop
}
// MAYBE: horse_window
/* Action */
//TODO: Pick Item
//TDOO: craft
//TODO: Held Item Change
// TODO: Pick Item
// TODO: craft
// TODO: Log actions
// TODO: Move inventory (equip armor)

View File

@ -1,36 +1,34 @@
import { LOW_HEALTH, MAX_HEALTH, REGENERATE_FOOD, SPRINT_FOOD } from '../utils/constants'
import { EventEmitter } from 'events'
import { LOW_HEALTH, MAX_FOOD, MAX_HEALTH, MAX_SATURATION, SPRINT_FOOD } from '../utils/constants'
import { round } from '../utils/func'
import Module from '../utils/Module'
import Spawn from './Spawn'
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 */
/** Warn if food level is lower */
lowFood: number,
/** Food level needed to regenerate */
regenerateFood: number,
/** Log food level info */
showFood: boolean,
}
/** Monitor health, food and xp */
export default class Life extends Module<IConf> {
private _health = MAX_HEALTH
public get health() {
return this._health
}
private _food = MAX_FOOD
public get food() {
return this._food
}
private _saturation = MAX_SATURATION
public get saturation() {
return this._saturation
}
@ -39,49 +37,37 @@ export default class Life extends Module<IConf> {
return this.health > 0
}
public get mustEat() {
return this.food <= this.conf.lowFood ||
(this.health <= this.conf.targetHealth && this.food <= this.conf.regenerateFood)
}
private _experienceBar = 0
public get experienceBar() {
return this._experienceBar
}
private _level = 0
public get level() {
return this._level
}
private _totalExperience = 0
public get totalExperience() {
return this._totalExperience
}
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 })
private _events = new EventEmitter()
public get events() {
return this._events
}
protected mount() {
//TODO: const inventory = this.conf.eat ? this.load('inventory') : undefined
const spawn = this.load<Spawn>(Spawn)
spawn.addCheck('health')
this.client.on('update_health', data => {
if (this.conf.spawn && this._health == null) {
setTimeout(this.respawn.bind(this), 500)
}
this._health = round(data.health)
this._food = data.food
this._saturation = data.foodSaturation
spawn.validateCheck('health')
if (this.health <= this.conf.lowHealth) {
this.logger.warn({ msg: this.alive ? 'Low health' : 'Dead', type: 'health', value: this.health })
} else if (this.conf.showHealth) {
@ -94,10 +80,7 @@ export default class Life extends Module<IConf> {
this.logger.info({ msg: 'Replete', type: 'food', value: this.food })
}
if (this.conf.eat && this.mustEat) {
this.logger.warn({ msg: 'TODO: Must eat' })
// TODO: inventory.items.filter(food).orderBy(this.conf.preferSaturation)
}
this._events.emit('update')
})
this.client.on('experience', data => {
@ -107,18 +90,13 @@ export default class Life extends Module<IConf> {
})
}
//TODO: send client settings
// MAYBE: 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,
}
}

96
src/modules/Meal.ts Normal file
View File

@ -0,0 +1,96 @@
import { MAX_HEALTH, REGENERATE_FOOD, SPRINT_FOOD } from '../utils/constants'
import Module from '../utils/Module'
import Connection from './Connection'
import Inventory from './Inventory'
import Life from './Life'
import State from './State'
interface IConf {
/** Life to regenerate */
targetHealth: number,
/** Minimal food level */
minFood: number,
/** Food level needed to regenerate */
regenerateFood: number,
/** Food names by priority */
foods: string[]
/** Food ids by priority (override foods) */
foodsIds?: number[]
}
export default class Meal extends Module<IConf> {
private life!: Life
private inv!: Inventory
private state!: State
private _eating?: NodeJS.Timeout
public get mustEat() {
return this.life.food <= this.conf.minFood ||
(this.life.health <= this.conf.targetHealth && this.life.food <= this.conf.regenerateFood)
}
protected mount() {
this.life = this.load<Life>(Life)
this.inv = this.load<Inventory>(Inventory)
this.state = this.load<State>(State)
const ready = () => {
this.life.events.on('update', this.eat.bind(this))
this.eat()
}
if (this.conf.foodsIds) {
ready()
} else {
this.load<Connection>(Connection).data.onReady(data => {
this.conf.foodsIds = this.conf.foods
.map(name => data.items![name].numeric_id)
ready()
})
}
}
protected getConf() {
return {
minFood: SPRINT_FOOD,
targetHealth: MAX_HEALTH,
regenerateFood: REGENERATE_FOOD,
foods: [
'golden_carrot', 'cooked_porkchop', 'cooked_beef', 'rabbit_stew', 'cooked_mutton', 'cooked_salmon',
// 'golden_apple', 'enchanted_golden_apple',
'beetroot_soup', 'cooked_chicken', 'mushroom_stem', 'baked_potato', 'bread', 'cooked_cod',
'cooked_rabbit', 'pumpkin_pie', 'carrot', 'apple', 'beef', 'porkchop', 'rabbit', 'honey_bottle',
'melon_slice', 'mutton', 'beetroot', 'dried_kelp', 'potato', 'cookie', 'cod', 'salmon', 'tropical_fish',
'chicken', 'rotten_flesh',
],
}
}
private eat() {
if (this.mustEat && !this._eating) {
const food = this.inv.findFirst(this.conf.foodsIds!)
if (food.slot >= 0) {
this.client.on('entity_status', packet => {
if (this._eating && packet.entityId === this.state.state.entityId && packet.entityStatus === 9) {
clearTimeout(this._eating)
this._eating = undefined
}
})
this.logger.info('Eating %s', this.conf.foods[food.value])
this.inv.useItem(food.slot)
this._eating = setTimeout(() => {
this.logger.error('Fail eating')
this._eating = undefined
this.eat()
}, 3000)
} else {
this.logger.warn('No food available')
}
}
}
}

34
src/modules/Spawn.ts Normal file
View File

@ -0,0 +1,34 @@
import Module from '../utils/Module'
export default class Spawn extends Module<{ spawn: boolean }> {
private _checkList: string[] = []
public addCheck(name: string) {
this._checkList.push(name)
}
public validateCheck(name: string) {
const idx = this._checkList.indexOf(name)
if (idx > -1) {
this._checkList.splice(this._checkList.indexOf(name), 1)
this.logger.trace({ msg: 'Spawn ready', value: name, remain: this._checkList.length })
if (this._checkList.length === 0 && this.conf.spawn) {
this.respawn()
}
}
}
public respawn() {
this.logger.info('Respawn')
this.client.write('client_command', { action: 1 })
}
protected mount() {
// passive
}
protected getConf() {
return { spawn: true }
}
}

View File

@ -1,5 +1,6 @@
import Module from '../utils/Module'
import { IDelta, IPosition, IState, gameMode } from '../utils/types'
import Spawn from './Spawn'
// MAYBE: split
@ -65,11 +66,22 @@ export default class State extends Module<{ positionConfirm: boolean }> {
}
protected mount() {
this.client.on('login', data => this._state = data)
const spawn = this.load<Spawn>(Spawn)
spawn.addCheck('state')
spawn.addCheck('abilities')
spawn.addCheck('position')
this.client.on('login', data => {
this._state = data
spawn.validateCheck('state')
})
this.client.on('difficulty', data => this._difficulty = {
level: data.difficulty, locked: data.difficultyLocked,
})
this.client.on('abilities', data => this._abilities = data)
this.client.on('abilities', data => {
this._abilities = data
spawn.validateCheck('abilities')
})
this.client.on('position', (data: IPositionPacket) => {
if (this._position == null) {
@ -94,6 +106,7 @@ export default class State extends Module<{ positionConfirm: boolean }> {
this.client.write('teleport_confirm', { teleportId: data.teleportId })
}
this.logger.debug({ msg: 'Teleported', type: 'position', value: this.position })
spawn.validateCheck('position')
})
this.client.on('player_info', (packet: { action: number, data: IPlayerInfo[] }) => {
@ -103,13 +116,22 @@ export default class State extends Module<{ positionConfirm: boolean }> {
this._players.set(player.UUID, player)
break
case 1:
this._players.get(player.UUID)!.gamemode = player.gamemode
const p1 = this._players.get(player.UUID)
if (p1) {
p1.gamemode = player.gamemode
}
break
case 2:
this._players.get(player.UUID)!.ping = player.ping
const p2 = this._players.get(player.UUID)
if (p2) {
p2.ping = player.ping
}
break
case 3:
this._players.get(player.UUID)!.name = player.name
const p3 = this._players.get(player.UUID)
if (p3) {
p3.name = player.name
}
break
case 4:
this._players.delete(player.UUID)

View File

@ -1,9 +1,12 @@
import { IModuleType } from '../utils/Module'
import Chat from './Chat'
import Client from './Connection'
import Combat from './Combat'
import Client from './Connection'
import Entities from './Entities'
import Inventory from './Inventory'
import Life from './Life'
import Meal from './Meal'
import Spawn from './Spawn'
import State from './State'
import Time from './Time'
@ -14,6 +17,9 @@ export default [
Combat,
Entities,
Life,
Meal,
State,
Time,
Inventory,
Spawn,
] as IModuleType[]

View File

@ -11,20 +11,42 @@ export interface IEntityData {
metadata: Array<{ entity?: string }>
}
export interface IItemData {
display_name: string
max_stack_size: number
numeric_id: number
}
/** Load game data */
export default class Data {
private _ready = false
public get ready() {
return this._ready
}
private _onReady: Array<(data: Data) => void> = []
private _entities?: { [name: string]: IEntityData }
public get entities() {
return this._entities
}
constructor(source: string, directory: string, version: string, logger: Logger) {
private _items?: { [name: string]: IItemData }
public get items() {
return this._items
}
public onReady(cb: ((data: Data) => void)) {
this._onReady.push(cb)
}
public getEntityById(entityId: number) {
if (this._entities) {
for (const name in (this._entities as object)) {
if (this._entities[name].id === entityId) {
return this._entities[name]
}
}
}
}
public load(source: string, directory: string, version: string, logger: Logger) {
logger.debug('Loading data file')
const path = directory + version + '.json'
if (!fs.existsSync(directory)) {
@ -39,29 +61,20 @@ export default class Data {
res.on('error', error => console.error({ msg: 'Downloading error', error }))
res.on('end', () => {
out.close()
setTimeout(() => this.load(path), 500)
setTimeout(() => this.read(path), 500)
})
}).on('error', error => console.error({ msg: 'Downloading error', error }))
} else {
this.load(path)
this.read(path)
}
}
public getEntityById(entityId: number) {
if (this._entities) {
for (const name in (this._entities as object)) {
if (this._entities[name].id === entityId) {
return this._entities[name]
}
}
}
}
private load(path: string) {
private read(path: string) {
try {
const data = JSON.parse(readFileSync(path).toString())[0] // NOTE: memory ???
this._entities = data.entities.entity
this._ready = true
this._items = data.items.item
this._onReady.forEach(cb => cb(this))
} catch (error) {
console.error('/!\\ =( Please remove ' + path, error)
process.exit(1)

View File

@ -1,6 +1,7 @@
export const SPRINT_FOOD = 6
export const REGENERATE_FOOD = 18
export const MAX_FOOD = 20
export const MAX_SATURATION = 20
export const MAX_HEALTH = 20
export const LOW_HEALTH = 4
@ -15,3 +16,7 @@ export const DELTA_RATIO = 128 * 32
export const SERVER_TICK_RATE = 50
export const SERVER_TPS = 1 / SERVER_TICK_RATE * 1000
export const OFF_HAND_SLOT = 45
export const HOTBAR_START_SLOT = 36
export const HOTBAR_END_SLOT = 44