world/state.tstypescript
import { Cipheriv, Decipheriv } from "node:crypto";
import { Angle, BaseVec3 } from "../physics/direction";
import { AuthRelatedNotFound, ClientNotReady } from "../base/error";
import { ClientEvents, ClientOption } from "../client/client";
import { AuthClient } from "../client/auth";
import { MessageLink } from "../message/link";
import { SignatureCache } from "../message/sigCache";
import { CacheImplementation, LRUCache } from "../base/cache";
import { Container, Slot } from "./container";
import { ProtocolVersionMapping } from "../version/registry";
import { clamp } from "../base/math";
export interface Server {
knownPacks?: ServerKnownPack
}
export interface ServerKnownPack {
namespace: string,
id: string,
version: string
}
/**
* Represent world information
*/
export interface ServerWorld {
/** Is hardcore enabled */
hardcore: boolean,
/** All dimensions */
dimensions: string[],
/** Max players allowed */
maxPlayers: number,
/** View distances */
viewDistance: number,
/** Simulation distance */
simulationDistance: number,
/**
* The record key is a chunk section position packed as `Player Dimension:Chunk X:Chunk Z[Section Y]`
*
* For `Section Y`, a chunk is splitted into several cubic 16x16x16 sections, or in other word, a chunk is a bund of 16x16x16 sections stacked on each other.
*/
chunks: Record<string, ChunkColumn>,
/**
* The record key is entity ID
*/
entities: Record<number, Entity>,
}
/**
* Represent a chunk collumns, which may contains multiple chunks sections
*/
export interface ChunkColumn {
sections: Record<number, ChunkSection>,
blockEntities: Record<number, BlockEntity>
}
export interface ChunkSection {
block: PaletteContainer & { overlay: Map<number, number> },
biome: PaletteContainer,
}
/**
* Used for indirect mapping
*
* @see https://minecraft.wiki/w/Java_Edition_protocol/Chunk_format#Paletted_Container_structure
*/
export interface PaletteContainer {
bpe: number, // Bit per entry
palette: number[],
data: BigInt64Array | null, // null if the whole section contains one 1 type of block
}
/** Represent a block entity */
export interface BlockEntity {
type: number,
data: Record<string, any>
}
export interface Effect {
id: number,
amplifier: number,
duration: number,
flag: {
isAmbient: boolean,
showParticles: boolean,
showIcon: boolean,
blend: boolean
},
/**
* The epoch ms when the effect update packet is recieved, for validating duration
*/
date: number
}
/** Represent an entity */
export interface Entity {
id: number,
type: number,
position: BaseVec3,
velocity: BaseVec3,
angle: Angle,
data: number
}
export enum GameMode {
Survival = 0,
Creative = 1,
Adventure = 2,
Spectator = 3
}
export interface ClientPlayer {
/** Player UUID */
uuid: string,
/** Player username */
username: string,
/** Player entity ID */
entityId: number,
/** Current game mode */
gameMode: GameMode,
position: BaseVec3,
velocity: BaseVec3,
angle: Angle,
/** Current dimension */
dimension: string,
effects: Record<number, Effect>,
inventory: Container,
/** The slot which the player has selected (0–8) */
holdingSlot: number,
/** Item being dragged with the mouse. */
carryingItem: Slot | undefined,
health: Health
}
export interface Health {
/**
* Ranging from 0 to 20 where lesser or equal to 0 is death, 20 is full HP
*/
health: number,
/**
* Ranging from 0 to 20
*/
food: number,
/**
* Ranging from 0 to 5
*
* Minecraft Wiki said:
* ```text
* Food saturation acts as a food “overcharge”. Food values will not decrease while the saturation is over zero.
* New players logging in or respawning automatically get a saturation of 5.0. Eating food increases the saturation
* as well as the food bar.
* ```
*/
saturation: number,
}
export enum ClientStatus {
Disconnected,
Connecting,
Logining,
Ready,
}
export enum ConnectionState {
Disconnected,
Handshake,
Login,
Configure,
Play
}
export interface ServerRegistryEntry {
id: string,
data: object
}
export type MutateState = (state: SharedState) => void;
export type EmittedEvent = {
[K in keyof ClientEvents]: { event: K, args: ClientEvents[K] }
}[keyof ClientEvents]
export type SendingPacket = {
id: string | number,
data: Record<string, any>
}
/**
* For managing shared data between classes
*/
export class SharedState {
private mutationQueue: MutateState[] = [];
private eventQueue: EmittedEvent[] = [];
private packetQueue: SendingPacket[] = [];
public state: ConnectionState = ConnectionState.Disconnected;
public status: ClientStatus = ClientStatus.Disconnected;
public compressionThreshold: number = 0;
// Auth stuffs
public authClient: AuthClient | undefined = undefined;
public playerUUID: string | undefined = undefined;
public publicKey: Buffer | undefined = undefined;
public signature: Buffer | undefined = undefined;
public signatureExpireTime: number | undefined = undefined;
// eslint-disable-next-line no-undef
private signatureTimeoutInstance: NodeJS.Timeout | undefined = undefined;
public sharedSecret: Buffer | undefined = undefined;
public cipher: Cipheriv | undefined = undefined;
public decipher: Decipheriv | undefined = undefined;
public useEncryption: boolean = false;
public sessionID: Buffer | undefined = undefined;
public sequence: number = 0;
public server: Server | undefined = undefined;
public registry: Record<string, ServerRegistryEntry[]> | undefined = undefined;
public world: ServerWorld | null = null;
public player: ClientPlayer | null = null;
public messageCount: number = 0;
public openingContainer: Container | undefined = undefined;
public chunkBatchCalculator: ChunkBatchCalculator = new ChunkBatchCalculator();
public tickCalculator: TickCalculator;
public lastPing: number = -1;
public cache: CacheImplementation<Uint16Array> | undefined = undefined;
/**
* For maintaining message order.
*
* A UUID map to a MessageLink.
*/
public messageLinks: Record<string, MessageLink> | undefined = undefined;
public messageSignatureCache: SignatureCache | undefined = undefined;
constructor(
public clientOptions: ClientOption,
) {
if (this.clientOptions.loadAndCacheChunk) {
if (this.clientOptions.cacheImplementation)
this.cache = this.clientOptions.cacheImplementation();
else
this.cache = new LRUCache(32);
}
this.tickCalculator = new TickCalculator(this.clientOptions.tickBufferLength);
}
public isReady() {
return this.status === ClientStatus.Ready;
}
public checkReady() {
if (!this.isReady())
throw new ClientNotReady();
}
public pruneStates() {
this.mutationQueue = [];
this.eventQueue = [];
this.packetQueue = [];
this.state = ConnectionState.Disconnected;
this.status = ClientStatus.Disconnected;
this.compressionThreshold = 0;
this.authClient = undefined;
this.playerUUID = undefined;
this.publicKey = undefined;
this.signature = undefined;
this.sharedSecret = undefined;
this.cipher = undefined;
this.decipher = undefined;
this.useEncryption = false;
this.sessionID = undefined;
this.signatureTimeoutInstance = undefined;
this.server = undefined;
this.registry = undefined;
this.world = undefined as any;
this.player = undefined as any;
this.messageCount = 0;
this.openingContainer = undefined as any;
this.sequence = 0;
this.chunkBatchCalculator = new ChunkBatchCalculator();
this.tickCalculator = new TickCalculator(this.clientOptions.tickBufferLength);
if (this.clientOptions.shouldVerifyMessageSignature)
this.messageSignatureCache = SignatureCache.default();
else
this.messageSignatureCache = undefined;
if (this.clientOptions.shouldVerifyMessageOrder)
this.messageLinks = {};
else
this.messageLinks = undefined;
if (this.clientOptions.loadAndCacheChunk) {
if (this.clientOptions.cacheImplementation)
this.cache = this.clientOptions.cacheImplementation();
else
this.cache = new LRUCache(32);
}
}
public getProtocolId() {
return ProtocolVersionMapping[this.clientOptions.version]!;
}
public getSignature() {
if (this.publicKey && this.signature)
return {
publicKey: this.publicKey,
signature: this.signature
};
throw new AuthRelatedNotFound("public key and signature");
}
public nextSequence() {
return ++this.sequence;
}
private async fetchSignature() {
if (!this.authClient)
throw new AuthRelatedNotFound("instance of class AuthClient");
const { publicKey, signature, expiresAt } = await this.authClient.getKeyPair();
this.publicKey = Buffer.from(publicKey);
this.signature = Buffer.from(signature);
return expiresAt;
}
public async startSignatureLoop() {
const expiresAt = await this.fetchSignature();
this.signatureTimeoutInstance = setTimeout(
async () => await this.startSignatureLoop(),
expiresAt - Date.now() - 10_000
);
}
public stopSignatureLoop() {
clearTimeout(this.signatureTimeoutInstance);
}
public enqueueMutation(mutation: MutateState) { this.mutationQueue.push(mutation); }
public enqueuePacket(packet: string, data: object): void
public enqueuePacket(packet: SendingPacket): void
public enqueuePacket(packet: SendingPacket | string, data?: object) {
if (typeof packet === "string")
this.packetQueue.push({
id: packet,
data: data!
});
else
this.packetQueue.push(packet);
}
public enqueueEvent<K extends keyof ClientEvents>(event: K, ...args: ClientEvents[K]): void
public enqueueEvent(event: EmittedEvent): void
public enqueueEvent(event: EmittedEvent, ...args: any[]) {
if (typeof event === "string")
this.eventQueue.push({
event,
args: args as any
});
else
this.eventQueue.push(event);
}
public drainMutation() {
while (true) {
const mutation = this.mutationQueue.shift();
if (mutation)
mutation(this);
else
break;
}
}
public drainEvent(consumer: (event: EmittedEvent) => void) {
while (true) {
const event = this.eventQueue.shift();
if (event)
consumer(event);
else
break;
}
}
public drainPacket(consumer: (packet: SendingPacket) => void) {
while (true) {
const packet = this.packetQueue.shift();
if (packet)
consumer(packet);
else
break;
}
}
}
class ChunkBatchCalculator {
private static MAX_SAMPLE_WEIGHT = 50;
private startChunkBatch: bigint = 0n;
private averageNsPerBatch: bigint = 2000000n;
private sampleWeight = 0;
public startBatch() {
this.startChunkBatch = process.hrtime.bigint();
}
public endBatch(size: number) {
if (this.startChunkBatch <= 0n) return;
const batchDuration = process.hrtime.bigint() - this.startChunkBatch;
const nsPerBatch = batchDuration / BigInt(size);
const clampedNsPerBatch = clamp(nsPerBatch, this.averageNsPerBatch / 3n, this.averageNsPerBatch * 3n);
this.averageNsPerBatch = (this.averageNsPerBatch * BigInt(this.sampleWeight) + clampedNsPerBatch) / BigInt(this.sampleWeight + 1);
this.sampleWeight = Math.min(ChunkBatchCalculator.MAX_SAMPLE_WEIGHT - 1, this.sampleWeight + 1);
}
public getChunkPerTick() {
return Number(7000000n / this.averageNsPerBatch);
}
}
interface Tick { age: bigint, timestamp: bigint }
class TickCalculator {
public ticks: Tick[] = [];
constructor(private maxTicks: number = 32) { }
public addTick(tick: Tick) {
this.ticks.push(tick);
this.pruneOldTick();
}
private pruneOldTick() {
while (this.ticks.length > this.maxTicks) {
this.ticks.shift();
}
}
/**
* Get the TPS.
*
* @returns -1 if there isn't any tick stored
* @returns or else, the TPS
*/
public getTps() {
console.log(this.ticks);
if (this.ticks.length == 0) return -1;
const last = this.ticks.at(-1)!, first = this.ticks.at(0)!;
const ageDelta = Number(last.age - first.age);
const seconds = Number(last.timestamp - first.timestamp) / 1e9;
if (seconds <= 0 || ageDelta < 0) return -1;
return Math.min(20, ageDelta / seconds);
}
/**
* 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.
*
* @param window In second
*
* @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 {
if (this.ticks.length == 0) return -1;
const last = this.ticks.at(-1)!,
first = this.ticks.findLast((tick) => tick.timestamp <= last.timestamp - BigInt(Math.round(window * 1e9)));
if (!first) return null;
const ageDelta = Number(last.age - first.age);
const seconds = Number(last.timestamp - first.timestamp) / 1e9;
if (seconds <= 0 || ageDelta < 0) return -1;
return Math.min(20, ageDelta / seconds);
}
}