diff --git a/package-lock.json b/package-lock.json index 034b3ce..4583be2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pixelwalker.js", - "version": "1.3.2", + "version": "1.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pixelwalker.js", - "version": "1.3.2", + "version": "1.3.3", "hasInstallScript": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index 77cc094..d97d72c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pixelwalker.js", - "version": "1.3.2", + "version": "1.3.3", "description": "PixelWalker.JS API Bindings", "main": "dist/index.js", "type": "module", diff --git a/src/game.connection.ts b/src/game.connection.ts index c184a62..9479502 100644 --- a/src/game.connection.ts +++ b/src/game.connection.ts @@ -9,6 +9,13 @@ type WorldEventNames = Protocol.WorldPacket["packet"]["case"]; type WorldEventData = Protocol.WorldPacket["packet"] & { name: Name }; export type Events = { [K in WorldEventNames & string]: [(WorldEventData & { case: K })["value"]] }; +export type JoinData = Partial<{ + world_title: string; + world_width: 636 | 400 | 375 | 350 | 325 | 300 | 275 | 250 | 225 | 200 | 175 | 150 | 125 | 100 | 75 | 50; + world_height: 400 | 375 | 350 | 325 | 300 | 275 | 250 | 225 | 200 | 175 | 150 | 125 | 100 | 75 | 50; + spawnId: number; +}>; + /** * The GameConnection is a connection to the game server at the * {@link https://game.pixelwalker.net/ PixelWalker Game Server}. @@ -29,7 +36,7 @@ export type Events = { [K in WorldEventNames & string]: [(WorldEventData & { * game.listen('playerInitPacket', () => { * game.send('playerInitReceived'); * }); - * + * * this.connection.listen("ping", () => { * this.connection.send("ping"); * }); @@ -42,7 +49,7 @@ export default class GameConnection { * The protocol is a collection of all the possible messages that can be * sent and received from the game server. It is used to serialize and * deserialize messages to and from the game server. - * + * * @kind file */ public static Protocol = Protocol; @@ -60,24 +67,24 @@ export default class GameConnection { #receiver: EventEmitter = new EventEmitter(); /** - * + * * @param joinkey The joinkey retrieved from the API server. - * + * * ```ts * import { LobbyClient } from "pixelwalker.js/localhost" - * + * * const client = LobbyClient.withToken(process.env.token) * const joinkey = await client.getJoinKey(process.env.world_id); * ``` - * + * * @returns {GameConnection} A new instance of the GameConnection. - * + * * ```ts * const connection = GameConnection.withJoinKey(joinkey); * ``` */ - public static withJoinKey(joinkey: string) { - return new this(joinkey); + public static withJoinKey(joinkey: string, joinData?: JoinData) { + return new this(joinkey, joinData); } /** @@ -85,7 +92,7 @@ export default class GameConnection { * You need to manually call the `bind` method to establish a connection, after * registering event handlersand managing the state of your program. */ - protected constructor(private joinkey: string) {} + protected constructor(private joinkey: string, private joinData?: JoinData) {} // // @@ -99,7 +106,7 @@ export default class GameConnection { * already been added. Multiple calls passing the same combination of * `eventNameand` listener will result in the listener being added, and * called, multiple times. - * + * * | Event Name | Description | * |--------------------|-------------| * | `playerInitPacket` | The message event is received when the client opens the connection. @@ -112,9 +119,9 @@ export default class GameConnection { /** * Sends a message to the game server without any body. Only two events, `ping` and * `playerInitReceived` can be sent without any body. - * + * * ### Events - * + * * | Event Name | Description | * |----------------------|-------------| * | `ping` | The message has to be sent for every `ping` received from the server. @@ -125,9 +132,9 @@ export default class GameConnection { /** * Sends a message to the game server, evaluating the header bytes and argument * format based on `eventName`. - * + * * ### Events - * + * * | Event Name | Description | * |----------------------|-------------| * | `playerInitReceived` | The message has to be sent when the client receives `playerInitPacket`. @@ -138,14 +145,14 @@ export default class GameConnection { * Sends a message to the game server, evaluating the header bytes and argument * format based on `eventName`. *You can optionally omit the `$typeName` and `playerId` * fields from the message.* - * + * * ### Events - * + * * | Event Name | Description | * |----------------------|-------------| * | `playerInitReceived` | The message has to be sent when the client receives `playerInitPacket`. */ - public send(eventName: Event, value: Omit): void; + public send(eventName: Event, value: Omit): void; public send(eventName: Event, value: Events[Event][0] = {}) { const message = create(Protocol.WorldPacketSchema, { packet: { case: eventName, value } } as any); @@ -164,10 +171,16 @@ export default class GameConnection { * creates a socket in the connection class and appends core event listeners. */ public bind(): this { + let url = `${Config.GameServerSocketLink}/room/${this.joinkey}`; + + if (this.joinData) { + url += `?joinData=${btoa(JSON.stringify(this.joinData))}`; + } + if (process.env.LOCALHOST) { - this.socket = new WebSocket(`${Config.GameServerSocketLink}/room/${this.joinkey}`, { port: 5148 }) as any; + this.socket = new WebSocket(url, { port: 5148 }); } else { - this.socket = new WebSocket(`${Config.GameServerSocketLink}/room/${this.joinkey}`) as any; + this.socket = new WebSocket(url); } this.socket.binaryType = "arraybuffer"; diff --git a/src/game.ts b/src/game.ts index 6f722d5..8a01b7f 100644 --- a/src/game.ts +++ b/src/game.ts @@ -1,8 +1,11 @@ -import GameConnection from "./game.connection.js"; +import EventEmitter from "events"; +import GameConnection, { JoinData } from "./game.connection.js"; import LobbyClient from "./lobby.js"; import PlayerMap from "./players/map.js"; import World from "./world/world.js"; +import Player from "./types/player.js"; +import BlockScheduler from "./scheduler/block.js"; /** * The GameClient is a connection interface with the game server. It is used to @@ -52,6 +55,56 @@ export default class GameClient extends GameConnection { */ // #receiver: EventEmitter = new EventEmitter(); + /** + * The command prefix is a list of prefixes that are used to identify + * bot commands in the chat. + */ + public commandPrefix: string[] = ["!", "."]; + + /** + * The command event is called to handle chat commands from players. + * You can listen for commands like in the example below. + * + * ```ts + * game.commands.on('giveedit', ([player, username]) => { + * // `player` is the command caller, `username` is the argument. + * // You can do permission checking here. + * + * game.send('playerChatPacket', { + * message: `/giveedit ${username}`, + * }) + * }) + */ + private commands = new EventEmitter<{ [commandName: string]: [Player, ...string[]] }>(); + + /** + * This static variable is used for command argument parsing. You + * can test it at a website like [Regex101](https://regex101.com/) + * + * The regular expression consists of three components: a double + * quoted string, a single quoted string and a word. The string + * components consists of a bracket structure to match for beginning + * and end of a string. The center part `(\\"|\\.|.)*?` matches for + * string escapes non-greedy. The word component `\S+` matches for + * a word (any combination of non-whitespace characters.) + * + * @example + * + * Here is an example of a command and the resulting matches. + * + * ``` + * !test "Hello, \"World!\"" 256 wonderful-evening! --help + * ^^^^ ^^^^^^^^^^^^^^^^^^^ ^^^ ^^^^^^^^^^^^^^^^^^ ^^^^^^ + * ``` + */ + private static CommandLineParser = /"(\\"|\\.|.)*?"|'(\\'|\\.|.)*?'|\S+/mg; + + /** + * The block scheduler is a utility to manage block updates in the game and + * efficiently place blocks in accordance to the rate limit. + */ + private blockScheduler = new BlockScheduler(this); + /** * * @param joinkey The joinkey retrieved from the API server. @@ -88,13 +141,12 @@ export default class GameClient extends GameConnection { * You need to manually call the `bind` method to establish a connection, after * registering event handlersand managing the state of your program. */ - constructor(joinkey: string) { - super(joinkey); + constructor(joinkey: string, joinData?: JoinData) { + super(joinkey, joinData); - // this.connection = GameConnection.withJoinKey(joinkey); // this.chat = new Chat(this.connection); this.players = new PlayerMap(this); - this.world = new World(this); + this.world = new World(this, this.blockScheduler); } // @@ -109,6 +161,7 @@ export default class GameClient extends GameConnection { */ public override bind(): this { super.bind(); + this.blockScheduler.start(); /** * @event Ping @@ -132,6 +185,56 @@ export default class GameClient extends GameConnection { super.send("playerInitReceived"); }); + /** + * @event PlayerChat + * + * The `PlayerChat` event is emitted when a player sends a chat message. + * This event handler will only listen for chat commands and emit the + * command manager. + */ + super.listen("playerChatPacket", ({ playerId, message }) => { + let idx = this.commandPrefix.findIndex((prefix) => message.startsWith(prefix)); + if (idx === -1) return; + + const [command, ...args] = message.substring(this.commandPrefix[idx].length).match(GameClient.CommandLineParser) ?? []; + if (!command) return; + + const player = this.players[playerId]; + if (!player) return; + + this.commands.emit(command, player, ...args); + }); + + return this; + } + + /** + * Closes the connection to the game server. This method is used to + * close the connection to the game server and stop the schedulers and + * other running entities. + */ + public override close(): void { + super.close(); + this.blockScheduler.stop(); + } + + /** + * Register a command handler. The command handler is called when a player + * sends a chat message that starts with the command prefix. The command + * arguments are then parsed with a regular expression and passes the results + * to the callback function. + * + * @param commandName The name of the command to listen for. This is the first + * argument of the command message. + * + * @param player The player who sent the command. This is the first argument + * of the callback function, you do permission checking on this instance to + * determine if the player is allowed to execute the command. + */ + public listenCommand(commandName: string, callback: (player: Player, ...args: string[]) => void): this; + + public listenCommand(commandName: string, callback: (player: Player, ...args: string[]) => void): this { + this.commands.on(commandName, callback); return this; } } diff --git a/src/index.ts b/src/index.ts index 3d00c7b..1489c35 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,7 +22,7 @@ export { default as GameClient } from "./game.js"; export { default as PlayerMap } from "./players/map.js"; -export { default as Block } from "./world/block.js"; +export { default as Block, BlockId, BlockName } from "./world/block.js"; export { default as Layer } from "./world/layer.js"; export { default as Structure } from "./world/structure.js"; // export { default as World } from "./world/world.js"; diff --git a/src/lobby.ts b/src/lobby.ts index 40d65c2..84faa18 100644 --- a/src/lobby.ts +++ b/src/lobby.ts @@ -5,9 +5,12 @@ import Config from "./data/config.js"; import RoomTypes from "./data/room-types.js"; import GameClient from "./game.js"; -import PublicProfile from "./types/public-profile.js"; +import OnlineWorlds from "./types/online-worlds.js"; import PublicWorld from "./types/public-world.js"; +import PrivateWorld from "./types/private-world.js"; +import PublicProfile from "./types/public-profile.js"; import Friend, { FriendRequest } from "./types/friends.js"; +import { JoinData } from "./game.connection.js"; /** * The LobbyClient connects with the @@ -90,7 +93,7 @@ export default class LobbyClient { public static async withUsernamePassword(username: string, password: string) { const client = new this(); try { - const auth = await client.pocketbase.collection('users').authWithPassword(username, password); + const auth = await client.pocketbase.collection("users").authWithPassword(username, password); if (!auth) return null; return client; } catch (_) { @@ -151,6 +154,11 @@ export default class LobbyClient { * See usage at the [PocketBase](https://pocketbase.io/) website for [searching records](https://pocketbase.io/docs/api-records#listsearch-records). * This method returns a collection handler that allows you to search through all public worlds. * + * @note + * + * If you want to get a full list of worlds that you own and are not set to + * public, refer to the {@link LobbyClient.my_worlds} method. + * * @example * * ```json @@ -169,24 +177,101 @@ export default class LobbyClient { * "width": 200 * } * ``` + * + * @example + * + * If you want to get only **public** worlds that you yourself own, you can + * use the `owner='selfId'` filter (with selfId being your own connect user id). + * + * ```ts + * LobbyClient.withToken(process.env.token); + * .worlds() + * .getFullList({ filter: `owner='${client.selfId}'` }) + * .then(console.log) + * ``` */ public worlds(): RecordService { return this.pocketbase.collection("public_worlds"); } /** + * Returns a Pocketbase [RecordService](https://github.com/pocketbase/js-sdk/blob/master/src/services/RecordService.ts). + * See usage at the [PocketBase](https://pocketbase.io/) website for [searching records](https://pocketbase.io/docs/api-records#listsearch-records). + * This method returns a collection handler that allows you to search through + * all worlds owned by you. + * + * @example + * + * ```ts + * LobbyClient.withToken(process.env.token). + * .my_worlds() + * .getFullList() + * .then(console.log) + * ``` + * * @note To use this function you have to be logged in. (Login with * an authorized constructor, i.e. not {@link LobbyClient.guest}) - * + */ + public my_worlds(this: LobbyClient): RecordService { + return this.pocketbase.collection("worlds"); + } + + /** + * Returns an array of all online, visible worlds. + * * @example - * + * + * ```ts + * // Example response: + * + * { + * "visibleRooms": [ + * { + * "id": "mknckr7oqxq24xa", + * "players": 1, + * "max_players": 50, + * "data": { + * "title": "[Realms] Lobby", + * "description": "Visit https://realms.martenm.nl to browse worlds.", + * "plays": 1119, + * "minimapEnabled": true, + * "type": 0 + * } + * } + * ], + * "onlineRoomCount": 5, + * "onlinePlayerCount": 10 + * } + * ``` + * + * The `data` segment contains the `type` field, which is one of the following: + * + * - `0`: Saved World (Worlds that players own, e.g. Public Worlds) + * - `1`: Unsaved World (Non-persistant Worlds, e.g. Martens' Realms) + * - `2`: Legacy World (Worlds from the EE archive) + */ + public async online_worlds(roomType?: (typeof RoomTypes)[0]): Promise { + roomType = roomType ?? (process.env.LOCALHOST ? "pixelwalker_dev" : RoomTypes[0]); + + const response = await fetch(`${Config.GameServerLink}/room/list/${roomType}`); + if (!response.ok) throw new Error("Failed to fetch online worlds."); + + return response.json(); + } + + /** + * @note To use this function you have to be logged in. (Login with + * an authorized constructor, i.e. not {@link LobbyClient.guest}) + * + * @example + * * ```json * // To test it, you will need to fetch the resource with your auth token. * // When you have the token, you can use the link below to find the * // resource linked to you. - * // + * // * // https://api.pixelwalker.net/api/collections/users/records?perPage=500&page=1 - * + * * { * "admin": false, * "banned": false, @@ -217,16 +302,16 @@ export default class LobbyClient { /** * @note To use this function you have to be logged in. (Login with * an authorized constructor, i.e. not {@link LobbyClient.guest}) - * + * * @example - * + * * ```json * // To test it, you will need to fetch the resource with your auth token. * // When you have the token, you can use the link below to find the * // resource linked to you. - * // + * // * // https://api.pixelwalker.net/api/friends - * + * * { * "id": "5cy5r7za1r3splc", * "username": "ANATOLY", @@ -246,16 +331,16 @@ export default class LobbyClient { /** * @note To use this function you have to be logged in. (Login with * an authorized constructor, i.e. not {@link LobbyClient.guest}) - * + * * @example - * + * * ```json * // To test it, you will need to fetch the resource with your auth token. * // When you have the token, you can use the link below to find the * // resource linked to you. - * // + * // * // https://api.pixelwalker.net/api/friends - * + * * { * "id": "...", // The id of the friend request entry in the database * "sender": "5cy5r7za1r3splc", @@ -309,9 +394,9 @@ export default class LobbyClient { * * @this LobbyClient */ - public async getJoinKey(this: LobbyClient, world_id: string, room_type?: (typeof RoomTypes)[0]): Promise { - room_type = room_type ?? (process.env.LOCALHOST ? "pixelwalker_dev" : RoomTypes[0]); - const { token } = await this.pocketbase.send(`/api/joinkey/${room_type}/${world_id}`, {}); + public async getJoinKey(this: LobbyClient, world_id: string, roomType?: (typeof RoomTypes)[0]): Promise { + roomType = roomType ?? (process.env.LOCALHOST ? "pixelwalker_dev" : RoomTypes[0]); + const { token } = await this.pocketbase.send(`/api/joinkey/${roomType}/${world_id}`, {}); return token; } @@ -325,8 +410,25 @@ export default class LobbyClient { * * @this LobbyClient */ - public async connection(this: LobbyClient, world_id: string, room_type?: (typeof RoomTypes)[0]): Promise { - const token = await this.getJoinKey(world_id, room_type); + public async connection(this: LobbyClient, world_id: string, roomType?: (typeof RoomTypes)[0]): Promise { + const token = await this.getJoinKey(world_id, roomType); return new GameClient(token); } + + /** + * Create a new world on the game server. This method requires a title, + * + * The join data is a collection of all the possible messages that can be + * sent to create or join a world. + * + * Width and Height are restricted to values between 25 and 400 and it has + * to be in multiples of 25, with the exception of `636` assigned to width. + * + * @since 1.3.3 + */ + public async createUnsavedWorld(this: LobbyClient, joinData: JoinData, roomType?: (typeof RoomTypes)[0]): Promise { + const generatedId = "pw-js-" + Math.random().toString(36).substring(2, 9); + const token = await this.getJoinKey(generatedId); + return new GameClient(token, joinData); + } } diff --git a/src/scheduler/base.ts b/src/scheduler/base.ts new file mode 100644 index 0000000..d9bb96a --- /dev/null +++ b/src/scheduler/base.ts @@ -0,0 +1,275 @@ +import GameConnection from "../game.connection.js"; + +const DEFAULT_PRIORITY = 10; + +class Entry { + public priority: number = DEFAULT_PRIORITY; + public timeSince: number = performance.now(); + public ignoreThisLoop = false; + + constructor(public value: V) {} +} + +export default abstract class Scheduler { + /** + * The running state of the scheduler. + */ + #running = false; + + /** + * The time control of the scheduler. + */ + #timer: NodeJS.Timeout; + + /** + * The last time the scheduler loop was active. + */ + #lastTimeBusy = 0; + + /** + * The busy state of the scheduler. + */ + #busy = false; + + /** + * The queue of tickets to be processed. + */ + #queue = new Map>(); + + /** + * The promises of the tickets. + */ + #promises = new Map>(); + + /** + * The promises of the tickets. + */ + #promiseAnswers = new Map void, (v: any) => void]>(); + + public LOOP_FREQUENCY = 100; + public ELEMENTS_PER_TICK = 200; + public INBETWEEN_DELAY = 5; + public RETRY_FREQUENCY = 400; + + /** + * Return if the scheduler is running or inactive. + */ + public get isRunning() { + return this.connection.connected() && this.#running; + } + + /** + * Return if the scheduler is busy or inactive. + */ + public get isBusy() { + return this.isRunning && this.#busy; + } + + /** + * Create a new schedular instance. + */ + constructor(protected connection: GameConnection) { + clearTimeout((this.#timer = setTimeout(() => {}, 1000))); + } + + // + // + // METHODS + // + // + + /** + * Start the scheduler and its' loop. + */ + public start(): void { + this.#running = true; + } + + /** + * Stop the scheduler and its' loop. Rejects all pending tickets. + */ + public stop(): void { + this.#running = false; + this.#promiseAnswers.forEach(([, reject]) => reject("Scheduler was stopped.")); + } + + /** + * Sets the loop to busy. + */ + public busy(): void { + this.#busy = true; + this.tryLoop(); + } + + /** + * Sets the loop to not busy. + */ + public unbusy(): void { + this.#busy = false; + } + + /** + * Send a ticket to the scheduler. + */ + public send(value: V, priority?: number): Promise { + const ticket = this.createKey(value); + return this.add(ticket, value, priority); + } + + /** + * Create a key identifying a ticket. + */ + protected abstract createKey(value: V): string; + + /** + * Send a ticket to the scheduler. + */ + protected abstract trySend(value: V): void; + + /** + * Gets the queue of tickets to be processed. + */ + private nextEntries() { + const time = performance.now(); + + return ( + Array.from(this.#queue.entries()) + /** + * Wait Time exceeds or first time placing block + */ + .filter((v) => time - v[1].timeSince > this.RETRY_FREQUENCY || v[1].priority == 0) + /** + * Sort by priority + */ + .sort((a, b) => b[1].priority - a[1].priority) + /** + * Only take first N elements + */ + .slice(0, this.ELEMENTS_PER_TICK) + ); + } + + /** + * Returns a list of all entries in the queue. + */ + protected entries() { + return Array.from(this.#queue.entries()); + } + + /** + * Sets the timer to activate the main loop. + */ + private tryLoop() { + clearTimeout(this.#timer); + const nextLoopTime = Math.max(this.LOOP_FREQUENCY - (performance.now() - this.#lastTimeBusy), 0); + this.#timer = setTimeout(this.loop.bind(this), nextLoopTime); + } + + /** + * The main loop of the scheduler. + */ + private loop() { + if (!this.isRunning) return this.stop(); + if (this.#queue.size == 0) return this.unbusy(); + if (performance.now() - this.#lastTimeBusy < this.LOOP_FREQUENCY) return this.tryLoop(); + + // Set the busy time to the current time. + this.#lastTimeBusy = performance.now(); + + for (const [key, entry] of this.nextEntries()) { + if (entry.ignoreThisLoop) { + entry.ignoreThisLoop = false; + continue; + } + + this.trySend(entry.value); + entry.priority += 1; + + // TODO inbetween delay + } + + return this.tryLoop(); + } + + /** + * Add a ticket to the queue. + */ + protected add(key: string, value: V, priority: number = DEFAULT_PRIORITY) { + if (!this.isRunning) return Promise.reject("Scheduler running state was set to false."); + + // If thekey already exists, store the old value. + const [previousResolve, previousReject] = this.#promiseAnswers.get(key) ?? [undefined, undefined]; + + // If the key already exists, update the value and reset the priority. + const entry = new Entry(value); + entry.priority = priority; + + this.#queue.set(key, entry); + + // Update the busy state of the scheduler. + if (!this.#busy) this.busy(); + + // Create the promise for the ticket and link it with the old promise. + const promise = new Promise((res: (v: boolean) => void, rej: (v: any) => void) => { + this.#promiseAnswers.set(key, [res, rej]); + }) + .then(previousResolve) + .catch(previousReject); + + // Link the promise internally. + this.#promises.set(key, promise); + + return promise; + } + + /** + * @todo + */ + protected map(key: string, cb: (value: V) => V) { + throw new Error("Unimplemented!"); + } + + /** + * Check if a ticket exists in the queue. + */ + protected has(key: string) { + return this.#queue.has(key); + } + + /** + * Remove a ticket from the queue, and reject the original promise. + */ + protected reject(k: string) { + this.#queue.delete(k); + const answer = this.#promiseAnswers.get(k); + + if (answer) { + const [previousResolve, previousReject] = this.#promiseAnswers.get(k)!; + previousReject("Ticket was removed from the queue:\n" + new Error().stack); + } + + if (this.#queue.size == 0) this.unbusy(); + } + + /** + * Remove a ticket from the queue, and accept the original promise. + */ + protected receive(k: string) { + this.#queue.delete(k); + const answer = this.#promiseAnswers.get(k); + + if (answer) { + const [previousResolve, previousReject] = this.#promiseAnswers.get(k)!; + previousResolve(true); + } + + if (this.#queue.size == 0) this.unbusy(); + } + + /** + * Await till the entire queue is empty. + */ + public async awaitEmpty() { + return Promise.all([...this.#promises.values()]); + } +} diff --git a/src/scheduler/block.ts b/src/scheduler/block.ts new file mode 100644 index 0000000..5ed4e1f --- /dev/null +++ b/src/scheduler/block.ts @@ -0,0 +1,96 @@ +import Scheduler from "./base"; + +import GameConnection from "../game.connection"; +import { WorldBlockPlacedPacket } from "../network/pixelwalker_pb"; + +type Change = { + x: number; + y: number; + layer: number; + blockId: number; + extraFields: Uint8Array; +}; + +export default class BlockScheduler extends Scheduler { + override LOOP_FREQUENCY = 25; + override ELEMENTS_PER_TICK = 200; + override INBETWEEN_DELAY = 2; + override RETRY_FREQUENCY = 500; + public BLOCKS_PER_TICK = 400; + + constructor(connection: GameConnection) { + super(connection); + + connection.listen("worldBlockPlacedPacket", ({ positions, layer, blockId, extraFields }) => { + for (const { x, y } of positions) { + this.receive( + this.createKey({ + x, + y, + layer, + blockId, + extraFields, + }) + ); + } + + // this.receive(this.createKey(message)); + }); + } + + protected override createKey({ layer, x, y, blockId, extraFields }: Change): string { + return `${layer}-${x}-${y}-${blockId}-${extraFields}`; + } + + public override trySend({ blockId, layer, extraFields, x, y }: Change): void { + const args: WorldBlockPlacedPacket = { + $typeName: "WorldPackets.WorldBlockPlacedPacket", + isFillOperation: false, + blockId, + layer, + extraFields, + positions: [ + { + $typeName: "WorldPackets.PointInteger", + x, + y, + }, + ], + }; + + this.entries() + .filter(([key, entry]) => { + if (entry.value.blockId !== blockId || entry.value.layer !== layer) return false; + if (entry.value.extraFields.length !== extraFields.length) return false; + + for (let i = 0; i < extraFields.length; i++) { + if (entry.value.extraFields[i] !== extraFields[i]) return false; + } + + return true; + }) + .slice(0, this.BLOCKS_PER_TICK) + .forEach(([_, entry]) => { + entry.ignoreThisLoop = true; + args.positions.push({ + $typeName: "WorldPackets.PointInteger", + x: entry.value.x, + y: entry.value.y, + }); + }); + + this.connection.send("worldBlockPlacedPacket", args); + + // console.log("trySend", message, this.createKey(message)); + + // this.connection.send('worldBlockPlacedPacket', message); + + // { + // isFillOperation: false, + // extraFields: block.serialize_args(), + // positions: [{ $typeName: 'WorldPackets.PointInteger', x: x + xt, y:y + yt }], + // layer, + // blockId: blockId, + // } + } +} diff --git a/src/types/online-worlds.ts b/src/types/online-worlds.ts new file mode 100644 index 0000000..908d44b --- /dev/null +++ b/src/types/online-worlds.ts @@ -0,0 +1,21 @@ +/** + * An online world as retrieved by the Game Server. + */ +export type OnlineWorlds = { + visibleRooms: { + id: string; + players: number; + max_players: number; + data: { + title: string; + description: string; + plays: number; + minimapEnabled: boolean; + type: 0 | 1 | 2; + }; + }[]; + onlineRoomCount: number; + onlinePlayerCount: number; +}; + +export default OnlineWorlds; diff --git a/src/types/private-world.ts b/src/types/private-world.ts new file mode 100644 index 0000000..3ed68ea --- /dev/null +++ b/src/types/private-world.ts @@ -0,0 +1,24 @@ +import { RecordModel } from "pocketbase"; + +/** + * A public world as retrieved by the Pocketbase API. + */ +export type PrivateWorld = RecordModel & { + collectionId: "omma7comumpv34j"; + collectionName: "worlds"; + id: string; + minimap: string; + minimapEnabled: boolean; + created: string; + updated: string; + visibility: 'public' | 'unlisted' | 'friends' | 'private'; + data: string; + owner: string; + title: string; + description: string; + plays: number; + width: number; + height: number; +}; + +export default PrivateWorld; diff --git a/src/util/buffer-reader.ts b/src/util/buffer-reader.ts index 93ac78c..4e96650 100644 --- a/src/util/buffer-reader.ts +++ b/src/util/buffer-reader.ts @@ -233,14 +233,14 @@ export default class BufferReader { * */ public get length() { - return this.#buffer.length; + return this.#buffer.length - this.#offset; } /** * */ public subarray(start: number = this.#offset, end: number = this.length): BufferReader { - return new BufferReader(this.#buffer.subarray(start, end)); + return new BufferReader(this.#buffer.subarray(start, this.#offset + end)); } /** @@ -422,6 +422,16 @@ export default class BufferReader { // return tmp; // } + /** + * + */ + public expectUInt8(value: number) { + const tmp = this.#buffer.readUInt8(this.#offset); + this.#offset += 1; + if (tmp !== value) throw new Error(`Expected ${value} but got ${tmp}`); + return tmp; + } + /** * */ @@ -548,33 +558,33 @@ export default class BufferReader { return tmp; } - public read(tt: ComponentTypeHeader): string | number | bigint | boolean | Buffer; - public read(tt: ComponentTypeHeader.String): string; - public read(tt: ComponentTypeHeader.Byte): number; - public read(tt: ComponentTypeHeader.Int16): number; - public read(tt: ComponentTypeHeader.Int32): number; - public read(tt: ComponentTypeHeader.Int64): bigint; - public read(tt: ComponentTypeHeader.Float): number; - public read(tt: ComponentTypeHeader.Double): number; - public read(tt: ComponentTypeHeader.Boolean): boolean; - public read(tt: ComponentTypeHeader.ByteArray): Buffer; + public read(tt: ComponentTypeHeader, littleEndian?: boolean): string | number | bigint | boolean | Buffer; + public read(tt: ComponentTypeHeader.String, littleEndian?: boolean): string; + public read(tt: ComponentTypeHeader.Byte, littleEndian?: boolean): number; + public read(tt: ComponentTypeHeader.Int16, littleEndian?: boolean): number; + public read(tt: ComponentTypeHeader.Int32, littleEndian?: boolean): number; + public read(tt: ComponentTypeHeader.Int64, littleEndian?: boolean): bigint; + public read(tt: ComponentTypeHeader.Float, littleEndian?: boolean): number; + public read(tt: ComponentTypeHeader.Double, littleEndian?: boolean): number; + public read(tt: ComponentTypeHeader.Boolean, littleEndian?: boolean): boolean; + public read(tt: ComponentTypeHeader.ByteArray, littleEndian?: boolean): Buffer; - public read(tt: ComponentTypeHeader): string | number | bigint | boolean | Buffer { + public read(tt: ComponentTypeHeader, little = true): string | number | bigint | boolean | Buffer { switch (tt) { case ComponentTypeHeader.String: return this.readDynamicBuffer().toString("ascii"); case ComponentTypeHeader.Byte: return this.readUInt8(); case ComponentTypeHeader.Int16: - return this.readInt16LE(); + return little ? this.readInt16LE() : this.readInt16BE(); case ComponentTypeHeader.Int32: - return this.readInt32LE(); + return little ? this.readInt32LE() : this.readInt32BE(); case ComponentTypeHeader.Int64: - return this.readBigInt64LE(); + return little ? this.readBigInt64LE() : this.readBigInt64BE(); case ComponentTypeHeader.Float: - return this.readFloatLE(); + return little ? this.readFloatLE() : this.readFloatBE(); case ComponentTypeHeader.Double: - return this.readDoubleLE(); + return little ? this.readDoubleLE() : this.readDoubleBE(); case ComponentTypeHeader.Boolean: return !!this.readUInt8(); case ComponentTypeHeader.ByteArray: @@ -741,4 +751,16 @@ export default class BufferReader { return arr; } + + [Symbol.for("nodejs.util.inspect.custom")]() { + let s = ''; + } } diff --git a/src/world/block.ts b/src/world/block.ts index edd38db..76569a2 100644 --- a/src/world/block.ts +++ b/src/world/block.ts @@ -10,7 +10,7 @@ import BufferReader, { ComponentTypeHeader } from "../util/buffer-reader.js"; * block.name; // 'empty' * ``` */ -export type BlockId = keyof typeof BlockMappingsReverse; +export type BlockId = (typeof BlockMappings)[string]; /** * This type represents the block mapping name that can be used in the game. @@ -32,7 +32,12 @@ export type BlockName = keyof typeof BlockMappings; * console.log(Block['EMPTY']); // Block[empty] * ``` */ -export class Block { +export class Block { + /** + * @todo + */ + public static Mappings = BlockMappings; + /** * Retrieve the block argument based on the argument number. * @@ -48,7 +53,7 @@ export class Block { * The unique id of a block. Block ID changes accross updates. * If you want to save persistant data, refer the block mapping. */ - public readonly id: Index; + public readonly id: BlockId; /** * Block arguments are additional data that is sent with the block. @@ -64,14 +69,14 @@ export class Block { /** * Create a new block instance based on its' block id or block mapping. */ - public constructor(id: Index | (typeof BlockMappingsReverse)[Index]) { + public constructor(id: BlockId | BlockName) { switch (true) { case id === undefined: throw new Error("Block id is undefined"); // this.id = 0 as Index; // break; case typeof id === "string" && BlockMappings[id] !== undefined: - this.id = BlockMappings[id] as Index; + this.id = BlockMappings[id]; break; case typeof id === "number": this.id = id; @@ -98,14 +103,14 @@ export class Block { /** * Retrieves the mapping name of the block based on its' id. */ - public get name(): (typeof BlockMappingsReverse)[Index] { + public get name(): BlockName { return BlockMappingsReverse[this.id]; } /** * Returns if two blocks are equal based on their id and arguments. */ - public equals(other: Block): other is Block { + public equals(other: Block): boolean { if ((this.id as number) !== other.id) return false; if (this.data.length !== other.data.length) return false; @@ -142,6 +147,11 @@ export class Block { public static deserialize(buffer: BufferReader): Block { const blockId = buffer.readUInt32LE(); const block = new Block(blockId); + + if (blockId == 72) { + console.log(buffer.subarray(undefined, 15)) + } + block.deserialize_args(buffer); return block; } @@ -149,11 +159,15 @@ export class Block { /** * Deserialize a block arguments from the buffer reader. */ - public deserialize_args(buffer: BufferReader): Block { + public deserialize_args(buffer: BufferReader, flag = false): this { const format: ComponentTypeHeader[] = (BlockArgs as any)[this.name]; for (let i = 0; i < (format?.length ?? 0); i++) { - this.data[i] = buffer.read(format[i]); + if (flag) { + buffer.expectUInt8(format[i]); + } + + this.data[i] = buffer.read(format[i], !flag); } return this; @@ -162,31 +176,31 @@ export class Block { /** * Deserialize the block from the string. */ - public static fromString(value: string): Block { - const parts = value.split("."); - const blockId = parseInt(parts[0], 16); - const block = new Block(blockId); - const format: ComponentTypeHeader[] = (BlockArgs as any)[block.name]; - - if (parts.length > 1) { - const args = parts[1]; - const buffer = BufferReader.from(Buffer.from(args, "hex")); - - for (let i = 0; i < (format?.length ?? 0); i++) { - block.data[i] = buffer.read(format[i]); - } - - // if ((BlockArgs as any)[block.name]) { - // block.args_t = (BlockArgs as any)[block.name]; - - // for (const type of block.args_t) { - // block.args.push(buffer.read(type)); - // } - // } - } - - return block; - } + // public static fromString(value: string): Block { + // const parts = value.split("."); + // const blockId = parseInt(parts[0], 16); + // const block = new Block(blockId); + // const format: ComponentTypeHeader[] = (BlockArgs as any)[block.name]; + + // if (parts.length > 1) { + // const args = parts[1]; + // const buffer = BufferReader.from(Buffer.from(args, "hex")); + + // for (let i = 0; i < (format?.length ?? 0); i++) { + // block.data[i] = buffer.read(format[i]); + // } + + // // if ((BlockArgs as any)[block.name]) { + // // block.args_t = (BlockArgs as any)[block.name]; + + // // for (const type of block.args_t) { + // // block.args.push(buffer.read(type)); + // // } + // // } + // } + + // return block; + // } /** * Provides a custom string representation of the block which diff --git a/src/world/structure.ts b/src/world/structure.ts index 14af689..de827f1 100644 --- a/src/world/structure.ts +++ b/src/world/structure.ts @@ -1,7 +1,6 @@ -import YAML from "yaml"; import BufferReader from "../util/buffer-reader.js"; import Layer from "./layer.js"; -import Block from "./block.js"; +import Block, { BlockArgs, BlockName } from "./block.js"; import structureMigrations from "./structure.migrations.js"; @@ -82,20 +81,27 @@ export default class Structure= this.width || y >= this.height) - continue - world.background[x - x1][y - y1] = this.background[x][y] - world.foreground[x - x1][y - y1] = this.foreground[x][y] + if (x < 0 || y < 0 || x >= this.width || y >= this.height) continue; + world.background[x - x1][y - y1] = this.background[x][y]; + world.foreground[x - x1][y - y1] = this.foreground[x][y]; } - return world + return world; } /** @@ -114,22 +120,49 @@ export default class Structure Structure.LATEST_VERSION_ENCODING) { throw new Error(`Unsupported file version: ${data["file-version"]}`); @@ -139,20 +172,39 @@ export default class Structure; + public structure!: Structure; + + /** + * The width of the world. + */ + public get width(): number { + return this.structure.width; + } + + /** + * The height of the world. + */ + public get height(): number { + return this.structure.width; + } /** * The event attributes are the internal event emitters for the @@ -34,7 +49,7 @@ export default class World { */ // private events: EventEmitter = new EventEmitter(); - public constructor(connection: GameConnection) { + public constructor(private connection: GameConnection, private scheduler: BlockScheduler) { /** * @event PlayerInit * @@ -49,13 +64,34 @@ export default class World { if (buffer.subarray().length) { console.error(`WorldSerializationFault: World data buffer has ${buffer.subarray().length} remaining bytes.`); - // connection.close(); + connection.close(); return; } // this.events.emit("Init", this.structure); }); + /** + * @event worldReloadedPacket + * + * The `worldReloadedPacket` event is emitted when the world is reloaded. + */ + connection.listen('worldReloadedPacket', message => { + const buffer = BufferReader.from(message.worldData); + const width = this.structure.width; + const height = this.structure.height; + const oldMeta = this.structure.meta; + + this.structure = new Structure(width, height).deserialize(buffer) as Structure; + this.structure.meta = oldMeta; + + if (buffer.subarray().length) { + console.error(`WorldSerializationFault: World data buffer has ${buffer.subarray().length} remaining bytes.`); + connection.close(); + return; + } + }) + /** * @event WorldBlockPlaced * @@ -63,7 +99,7 @@ export default class World { */ connection.listen("worldBlockPlacedPacket", ({ playerId, isFillOperation, blockId, layer, extraFields, positions }) => { const block = new Block(blockId); - block.deserialize_args(BufferReader.from(extraFields.buffer)); + block.deserialize_args(BufferReader.from(extraFields), true); for (const { x, y } of positions) { this.structure![layer][x][y] = block; @@ -95,15 +131,38 @@ export default class World { // // - // public isInitialized(): this is World { - // return this.structure !== undefined; - // } + public async paste(xt: number, yt: number, fragment: Structure) { + const promises: Promise[] = []; + + for (let x = 0; x < fragment.width; x++) { + if (x + xt < 0 && x + xt >= this.width) continue; + + for (let y = 0; y < fragment.height; y++) { + if (y + yt < 0 || y + yt >= this.height) continue; + + for (let layer: any = 0; layer < Structure.LAYER_COUNT; layer++) { + const block = fragment[layer][x][y] ?? new Block('empty'); + // if (block.id === 0 && !args.write_empty) continue; + // to_be_placed.push([[layer, x + xt, y + yt], block]); + + this.scheduler.send({ + // $typeName: 'WorldPackets.WorldBlockPlacedPacket', + // playerId: 0, + // isFillOperation: false, + extraFields: block.serialize_args(), + x: x + xt, y:y + yt, + // positions: [{ $typeName: 'WorldPackets.PointInteger', x: x + xt, y:y + yt }], + layer, + blockId: block.id, + }) + } + } + } - // public get width(): Init extends true ? number : undefined { - // return this.structure.width as any; - // } + return Promise.all(promises); + } - // public get height(): Init extends true ? number : undefined { - // return this.structure.width as any; + // public isInitialized(): this is World { + // return this.structure !== undefined; // } }