client/client.tstypescript
import { EventEmitter } from "node:events";
import { BlockGetter, BlockManager, BlockState } from "../world/block";
import { EntitiesManager } from "../world/entity";
import { Input, Player } from "../physics/player";
import { TickLoop } from "../world/tick";
import { Message, TCPClient, TCPClientOption } from "../net/tcp";
import { TypedEmmiter as TypedEmitter } from "../base/event";
import { BlockRegistry, EffectRegistry, EntityRegistry, PacketRegistry, ProtocolVersionMapping } from "../version/registry";
import { BaseVec3, Vec3 } from "../physics/direction";
import { RegistryData, TextComponent } from "../base/typing";
import { ClientStatus, ConnectionState, EmittedEvent as EmittedEvent, Entity, Health, SharedState } from "../world/state";
import { Listener } from "../packet/listener";
import { VersionCodec } from "../version/codec";
import { AuthClient } from "./auth";
import { computeUUID, getFrac } from "../base/math";
import { HaveSignatureButNotIndex, UnexpectedValue } from "../base/error";
import { Sender } from "../packet/sender";
import { CacheImplementation } from "../base/cache";
import { Container, Slot } from "../world/container";
export type ClientOption = Omit<TCPClientOption, "protocolVersion"> & {
/**
* The client version. Supports 26.2 and 26.3
*/
version: string,
/**
* Whenever should the client verify the message against the signature, if any.
*
* In order to set this option to true, you HAVE TO set `shouldVerifyMessageOrder` to true
*/
shouldVerifyMessageSignature?: boolean,
/**
* Should verify the message orders to keep the sync with server.
*/
shouldVerifyMessageOrder?: boolean,
/**
* Whether to load the whole chunk section (16x16x16) and keep them in the cache or just load the needed block.
*
* Please consider this carefully since this can consume a lot of resource to calculate the whole chunk section
* and store it while you may not need that much.
*
* This option is useful when you have to constantly access to the world chunk, e.g pathfinding.
*/
loadAndCacheChunk?: boolean,
/**
* Custom cache class. Default to `LRUCache`
*/
cacheImplementation?: () => CacheImplementation<Uint16Array>,
/**
* The number of tick will be stored in the buffer to calculate the TPS.
*
* I would suggest 30. That would be enough sample.
*
* But if you want a longer time window for more detail, you could specify a larger number. In that scenario,
* I suggest you this equation:
* ```text
* tickBufferLength = <minute> * 60
* ```
* which `minute` is the time window in minute
*
* **Note** Server sent tick update every seconds, so the `tickBufferLength` will be the number of the seconds stored,
* not ticks.
*
* But be careful, the tick and the timestamp are stored in the BigInt format, meaning that it could cost
* a lot of storage to store for precise calculation.
*/
tickBufferLength?: number
/**
* Respawn automatically after timeout.
*
* The time is in seconds
*/
autoRespawnTimeout?: number
}
export interface ClientEvents {
// TCP Events
/** When the TCP socket connect is initted sucessfully */
connect: [],
/** When the TCP socket connection is destroyed */
destroy: [],
/**
* When the client is ready.
*
* Specifically, when the packet `Login (play)` is received.
*
* Most of the client method require this event to be fired in order to work.
*/
ready: [],
/**
* When the client got disconnected from server.
*
* This event only provide the parsed string. To get the unparsed, or the raw one with decoration, use `disconnectRaw`.
* But, the `disconnectRaw` is only fired when the disconnection come from the server, or the server disconnect the client.
* Sometime, the disconnect will be fired within the client, meaning that there will be no `disconnectRaw`
*/
disconnect: [reason: string],
/**
* When the disconnection come with the raw Text Component.
*/
disconnectRaw: [textComponent: TextComponent],
/** The registry_data packet */
registryData: [data: RegistryData],
/**
* When server send `Player Position Synchronization` packet.
*
* **Note:** this event is not fired when the client is moved by holding the input.
*
* To get the player current position, use `getPosition` method
*/
playerPosition: [position: BaseVec3],
/**
* When the chunk is loaded
*
* Explaining the `chunkX`, `sectionY`, `chunkZ`: A chunk in Minecraft is a region which length and width is 16 and the height extends to the limit.
* But when sending the packet, the chunk is divided into smaller section which volume is 16x16x16.
* So the `chunkX` and `chunkZ` is the chunk position and `sectionY` is the section within that chunk,
* counting from the bedrock up to the sky
*/
loadChunk: [chunkX: number, sectionY: number, chunkZ: number],
/**
* When the chunk got deleted by the server
*
* See the `chunkX` and `chunkZ` explaination in `loadChunk` events
*/
unloadChunk: [chunkX: number, chunkZ: number],
/**
* When a block is updated
*
* When you place a block in the world, instead of resending a whole chunk, which is very costly, the server will send only newly placed block and its
* information.
*/
blockUpdate: [x: number, y: number, z: number, newBlockId: number],
/**
* Like `blockUpdate` but its chunk is not in the memory.
*/
orphanBlockUpdate: [x: number, y: number, z: number, blockId: number],
/** When an entity is spawned or appeared in the client view distance */
spawnEntity: [entity: Entity],
/** When an entity is updated, including position, rotation, etc... */
updateEntity: [entityId: number],
/** When an entity go out of client view distance or despawned */
removeEntity: [entityId: number],
// Chat
/** When a chat message got broadcasted */
message: [message: Message],
/** When a message is failed to be verified against the signature sent with it */
failedMessage: [message: Message],
/**
* The system message
*
* This event only provide the parsed string. To get the unparsed, or the raw one with decoration, use `systemMessageRaw`
*/
systemMessage: [message: string],
/**
* The system message with the Text Component for decorations
*/
systemMessageRaw: [textComponent: TextComponent],
/**
* The action bar
*
* This event only provide the parsed string. To get the unparsed, or the raw one with decoration, use `actionBarRaw`
*/
actionBar: [message: string],
/** The raw action bar */
actionBarRaw: [textComponent: TextComponent],
// Container
/**
* When a container is opened
*
* This event will be fired when client click on a block (with `openBlock` method) that is a container
* or when server believe that you just open a container (with open_screen packet).
*/
openContainer: [container: Container],
/** When the opening container is closed */
closeContainer: [],
/** When the packet contain the content of the container is received */
containerContent: [container: Container, carryingItem: Slot],
/** When the packet contain the property of the container is received */
containerProperty: [container: Container],
/**
* When tab list is updated
*
* `{"text":""}` mean remove header or footer.
*/
tablist: [newTablist: { header: { text: string, raw: TextComponent }, footer: { text: string, raw: TextComponent } }],
/**
* The response of the ping request.
*
* The latency is in ms.
*/
pong: [latency: number],
/**
* The death message shown on the death screen.
*
* This event only provide the parsed string. To get the unparsed, or the raw one with decoration, use `deathRaw`
*/
death: [message: string],
/**
* The death raw message.
*/
deathRaw: [rawMessage: TextComponent],
/**
* When server update player health
*/
health: [health: Health]
}
/**
* High-level client.
*
* @example
* ```ts
* const client = new Client({
* playerName: "bot", // Put your name here
* host: "localhost", // Put the server address
* port: 25565, // 25565 is the default port, change to your server's port if needed
* version: "26.2", // The client version
* loadRegistry: false, //
* // auth: { // This things is not working yet
* // client_id: "d86254d8-edf7-4640-90eb-643c99af188e",
* // method: "loopback",
* // openBrowser: true,
* // port: 12345
* // },
* // shouldVerifyMessageOrder: true,
* // shouldVerifyMessageSignature: true
* });
*
* // MineJs is a event driven library, the same way DiscordJS work (if you know them).
* // One thing to note is most of method in this client require client to be ready,
* // meaning that unless the "ready" event is fired, you can't do, mostly, anything :D
* client.on("ready", () => console.log("Client is ready :yay:"));
*
* // The init step before is just init the data and does not create the connection yet.
* // This method will create that connection.
* client.connect();
* ```
*/
export class Client extends (EventEmitter as new () => TypedEmitter<ClientEvents>) {
private static loadRegistry: boolean = false;
private tickLoop: TickLoop;
private blocks: BlockManager;
private entities: EntitiesManager;
private player: Player;
private tcp: TCPClient;
private state: SharedState;
private packetListener: Listener;
private packetSender: Sender;
constructor(private options: ClientOption) {
if (options.shouldVerifyMessageSignature === true && options.shouldVerifyMessageOrder !== true)
throw new HaveSignatureButNotIndex();
super();
this.loadRegistries();
// For managing shared data
this.state = new SharedState(options);
// For handling packet
this.packetSender = new Sender(this.state, this.emit.bind(this));
this.packetListener = new Listener(this.state, this.emit.bind(this), this.packetSender);
// For reading packet
const versionCodec = new VersionCodec(this.state);
// For throwing packet through internet
this.tcp = new TCPClient(
this.state,
{
...options,
protocolVersion: ProtocolVersionMapping[options.version]!
}
);
this.tcp.forwardPacket = versionCodec.handlePacket.bind(versionCodec);
this.tcp.parsePacket = versionCodec.encodePacket.bind(versionCodec);
this.tcp.sendInitPacket = () => {
this.packetSender.sendHandshake();
this.packetSender.sendLoginStart();
};
this.packetSender.sendPacket = this.tcp.sendPacket.bind(this.tcp);
this.packetListener.disconnect = this.disconnect.bind(this);
this.packetListener.sendChunkBatchReceived = this.packetSender.sendChunkBatchReceived.bind(this.packetSender);
versionCodec.consumePacket = this.packetListener.handlePacket.bind(this.packetListener);
Container.sendClickContainer = this.packetSender.sendClickContainer.bind(this.packetSender);
// For querying data
this.blocks = new BlockManager(this.state);
this.entities = new EntitiesManager(this.state);
// For physics things
this.player = new Player(
this.state,
this.on,
this.entities,
this.blocks,
);
this.player.sendPlayerPos = this.packetSender.sendPlayerPos;
this.player.sendPlayerPosRot = this.packetSender.sendPlayerPosRot;
this.player.sendPlayerRot = this.packetSender.sendPlayerRot;
this.player.sendPlayerStatus = this.packetSender.sendPlayerStatus;
// For tick-tock tick-tock
this.tickLoop = new TickLoop(() => {
this.state.drainMutation();
this.state.drainEvent(event => this.emitEvent(event));
this.state.drainPacket((packet) => this.tcp.sendPacket(packet.id, packet.data));
this.player.tick();
this.packetSender.sendClientTickEnd();
});
// Forward TCP events
this.tcp.on("connect", () => this.emit("connect"));
this.tcp.on("destroy", () => this.emit("destroy"));
this.on("loadChunk", (sx, sy, sz) => this.blocks.deleteChunkCache(sx, sy, sz));
this.on("unloadChunk", (sx, sz) => {
for (let sy = 0; sy < 16; sy++)
this.blocks.deleteChunkCache(sx, sy, sz);
});
// Auto respawn
if (options.autoRespawnTimeout && options.autoRespawnTimeout > 0)
this.on("death", () => this.respawn());
}
private emitEvent(event: EmittedEvent) {
this.emit(event.event, ...event.args);
}
private loadRegistries() {
if (Client.loadRegistry) return;
Client.loadRegistry = true;
const { version } = this.options;
BlockRegistry.load(version);
EntityRegistry.load(version);
PacketRegistry.load(version);
EffectRegistry.load(version);
}
// Start / stop
/**
* Create a connection to the server
*/
public async connect() {
if (!this.state.playerUUID) {
if (this.options.auth) {
this.state.authClient = new AuthClient(this.options.auth);
const { uuid } = await this.state.authClient.getUUID();
this.state.playerUUID = uuid.replaceAll("-", "");
await this.state.startSignatureLoop();
} else
this.state.playerUUID = computeUUID(this.options.playerName);
}
this.tcp.connect();
this.once("ready", () => {
this.player.pruneInitialVal();
this.player.setInitialVal();
this.tickLoop.start();
});
this.tcp.once("destroy", () => {
if (this.state.state === ConnectionState.Disconnected) return;
this.disconnect("socket close");
});
}
/**
* Destroy the connection.
*
* Calling this method will also fire the "disconnect" event.
*/
public disconnect(reason: string = "Disconnect", raw?: TextComponent) {
this.state.state = ConnectionState.Disconnected;
this.state.status = ClientStatus.Disconnected;
this.tickLoop.stop();
this.player.pruneInitialVal();
this.state.pruneStates();
this.emit("disconnect", reason);
if (raw)
this.emit("disconnectRaw", raw);
this.tcp.disconnect();
if (this.options.auth)
this.state.stopSignatureLoop();
}
// Inputs
/**
* Press and hold a key.
*
* This is pretty the same idea as when you play Minecraft.
* When you need to go forward, you have to hold the key `W`, `S` for backward, etc...
*
* @throws {ClientNotReady} When the client is not ready
*/
public hold(input: Input) {
this.state.checkReady();
this.player.input(input);
}
/**
* Release a key.
*
* This is also pretty the same idea as when you play Minecraft.
* Need to stop, stop holding `W`, `S` key, etc...
*
* @throws {ClientNotReady} When the client is not ready
*/
public release(input: Input) {
this.state.checkReady();
this.player.releaseInput(input);
}
/**
* Release all key.
*
* This is the shortcut for release all keys.
* Instead of calling `release` method for each keys, just call this
*
* @throws {ClientNotReady} When the client is not ready
*/
public stopMoving() {
this.state.checkReady();
this.player.releaseAllInputs();
}
/**
* Send a message
*
* @throws {ClientNotReady} When the client is not ready
*/
public chat(message: string) {
this.state.checkReady();
this.packetSender.sendMessage(message);
}
// World
/**
* Get the block state at position
*
* @throws {ClientNotReady} When the client is not ready
*/
public getBlock(x: number, y: number, z: number): BlockState | null;
public getBlock(position: BaseVec3): BlockState | null;
public getBlock(a: BaseVec3 | number, b?: number, c?: number): BlockState | null {
const vec3 = Vec3.loadArgs(a, b, c);
return this.blocks.at(vec3);
}
/**
* Get player position
*
* @throws {ClientNotReady} When the client is not ready
*/
public getPosition() {
this.state.checkReady();
return structuredClone(this.state.player!.position);
}
/**
* Rotate your face
*
* "Yaw is measured in degrees and does not follow classical trigonometry rules.
* The unit circle of yaw on the XZ-plane starts at (0, 1) and turns counterclockwise, with 90 at (-1, 0), 180 at (0, -1) and 270 at (1, 0).
*
* Pitch is measured in degrees, where 0 is looking straight ahead, -90 is looking straight up, and 90 is looking straight down."
*
* @see https://minecraft.wiki/w/Java_Edition_protocol/Packets#Synchronize_Player_Position
*
* @param yaw In degree
* @param pitch In degree
*
* @throws {ClientNotReady} When the client is not ready
*/
public lookAt(yaw: number, pitch: number) {
this.state.checkReady();
this.player.setAngle(yaw, pitch);
}
/**
* Get opening container.
*
* Return `undefined` if nothing is opening
*
* @throws {ClientNotReady} When the client is not ready
*/
public getOpeningContainer() {
this.state.checkReady();
return this.state.openingContainer;
}
/**
* Get carrying item.
*
* Return `undefined` if nothing is carried
*
* @throws {ClientNotReady} When the client is not ready
*/
public getCarryingItem() {
this.state.checkReady();
return this.state.player?.carryingItem;
}
/**
* Get player's inventory
*
* @throws {ClientNotReady} When the client is not ready
*/
public getInventory() {
this.state.checkReady();
return this.state.player?.inventory;
}
/**
* Open a block.
*
* @throws {ClientNotReady} When the client is not ready
* @returns `true` if the target block is a solid block, or else, return `false`
*/
public openBlock(x: number, y: number, z: number): boolean;
public openBlock(position: BaseVec3): boolean;
public openBlock(a: BaseVec3 | number, b?: number, c?: number): boolean {
this.state.checkReady();
const vec3 = Vec3.loadArgs(a, b, c);
const eyePos = new Vec3(this.state.player!.position).add(0, 1.62, 0);
const target = new Vec3(vec3).add(0.5, 0.5, 0.5); // aim at block center, or use look direction
const reach = 4.5; // survival reach, adjust as needed
const dir = target.subtract(eyePos).normalize();
const to = eyePos.add(dir.scale(reach));
const clipResult = BlockGetter.clip(
eyePos,
to,
(pos) => this.blocks.at(pos)!.shape
)!;
if (clipResult.miss) return false;
const cursorX = Math.abs(clipResult.location.x - clipResult.pos.x),
cursorY = Math.abs(clipResult.location.y - clipResult.pos.y),
cursorZ = Math.abs(clipResult.location.z - clipResult.pos.z);
if (cursorX > 1 || cursorY > 1 || cursorZ > 1)
return false;
this.packetSender.sendUseItemOn(
"main",
vec3,
clipResult.direction,
{ x: cursorX, y: cursorY, z: cursorZ },
false,
false
);
return true;
}
/**
* Close the opening container, for example, close opening chest.
*
* **Note** The `getOpeningContainer()` will return undefined after call this method.
*
* @throws {ClientNotReady} When the client is not ready
*/
public closeContainer() {
this.state.checkReady();
this.packetSender.sendCloseContainer();
this.state.openingContainer = undefined;
}
/**
* Changes the slot selection in the hotbar.
*
* @param slot slot which the player has selected (0–8)
*
* @throws {ClientNotReady} When the client is not ready
* @throws {UnexpectedValue} when submit invalid number (a float, a number out of 0-8 range)
*/
public setCarriedSlot(slot: number) {
this.state.checkReady();
if (getFrac(slot) != 0)
throw new UnexpectedValue("a number (0-8)", "a float");
if (slot < 0 || slot > 8)
throw new UnexpectedValue("a number (0-8)", `${slot}`);
this.packetSender.sendSetCarriedSlot(slot);
this.state.enqueueMutation(state => state.player!.holdingSlot = slot);
}
/**
* Get the server TPS or tick per second.
*
* Normally, server runs at 20 TPS.
*
* If you get suspiciously low TPS or -1 TPS when you just start the client, let it has sometime to
* populate the tick calculator.
*
* The difference between this method and `getTpsWithWindow` is this method is depend on how large the
* `tickBufferLength` option be, while the `getTpsWithWindow` depend on the `window` parameter.
*
* @throws {ClientNotReady} When the client is not ready
*
* @returns -1 if there isn't any tick stored
* @returns the TPS
*/
public getTps() {
this.state.checkReady();
return this.state.tickCalculator.getTps();
}
/**
* Return the TPS in a time window (eg. 5 minutes)
*
* This could be useful when you just need the TPS in certain window of time.
*
* **Note** The window time must be smaller than the `tickBufferLength` option, for detail:
* ```text
* tickBufferLength => <windows time>
* ```
*
* @param window In second
*
* @throws {ClientNotReady} When the client is not ready
*
* @returns `-1` if there isn't any tick stored
* @returns `null` if the window is larger than the ticks stored
* @returns or else, return the TPS
*/
public getTpsWithWindow(window: number): number | null {
this.state.checkReady();
return this.state.tickCalculator.getTpsWithWindow(window);
}
/**
* Get player latency
*
* @throws {ClientNotReady} When the client is not ready
* @returns latency, in ms
*/
public ping() {
this.state.checkReady();
this.packetSender.sendPingRequest();
return new Promise<number>((res) => this.once("pong", res));
}
/**
* Send the respawn request.
*
* It is unclear what happen when you send this packet when you are still alive.
*
* @throws {ClientNotReady} When the client is not ready
*/
public respawn() {
this.state.checkReady();
this.packetSender.sendRespawn();
}
/**
* Get the player's health
*
* @throws {ClientNotReady} When the client is not ready
*/
public getHealth() {
this.state.checkReady();
return structuredClone(this.state.player!.health);
}
}