MineJSv0.7.0
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);
    }
}