UNPKG

haxjs

Version:

haxball headless api written in typescript

877 lines (758 loc) 25.2 kB
/* Copyright (C) 2022 <alpheratz99@protonmail.com> This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License version 2 as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA */ import { Page } from 'puppeteer'; import { AnnounceStyle } from './announce-style'; import { CollisionFlags } from './collision-flags'; import { DiscProperties } from './disc-properties'; import { Logger } from './logger'; import { Player } from './player'; import { Position } from './position'; import { RoomConfig } from './room-config'; import { Scores } from './scores'; import { DefaultStadium } from './default-stadium'; import { TeamID } from './team-id'; import { BigBrowser } from './big-browser'; import { Stadium } from './stadium'; declare global { export interface Window { /* current running room */ _room: Room; /* function to start a haxball headless room */ HBInit: (roomConfig: RoomConfig) => Room; /* exposed callback functions */ onPlayerJoin: (player: Player) => void; onPlayerLeave: (player: Player) => void; onTeamVictory: (scores: Scores) => void; onPlayerChat: (player: Player, message: string) => void; onPlayerBallKick: (player: Player) => void; onTeamGoal: (team: TeamID) => void; onGameStart: (byPlayer: Player | null) => void; onGameStop: (byPlayer: Player | null) => void; onPlayerAdminChange: ( changedPlayer: Player, byPlayer: Player | null ) => void; onPlayerTeamChange: ( changedPlayer: Player, byPlayer: Player | null ) => void; onPlayerKicked: ( kickedPlayer: Player, reason: string, ban: boolean, byPlayer: Player | null ) => void; onGameTick: () => void; onGamePause: (byPlayer: Player | null) => void; onGameUnpause: (byPlayer: Player | null) => void; onPositionsReset: () => void; onPlayerActivity: (player: Player) => void; onStadiumChange: ( newStadiumName: string, byPlayer: Player | null ) => void; onKickRateLimitSet: ( min: number, rate: number, burst: number, byPlayer: Player | null ) => void; } } export class Room { private page: Page; private config: RoomConfig; constructor(config: RoomConfig) { this.config = config; } private async openNewTab(): Promise<void> { Logger.begin('opening new tab'); this.page = await BigBrowser.getNewPage(); Logger.end(); } private async exposeFunctions(): Promise<void> { Logger.begin('exposing callback functions'); await this.page.exposeFunction('onPlayerJoin', (player: Player) => { this.onPlayerJoin && this.onPlayerJoin(player); }); await this.page.exposeFunction('onPlayerLeave', (player: Player) => { this.onPlayerLeave && this.onPlayerLeave(player); }); await this.page.exposeFunction('onTeamVictory', (scores: Scores) => { this.onTeamVictory && this.onTeamVictory(scores); }); await this.page.exposeFunction( 'onPlayerChat', (player: Player, message: string) => { this.onPlayerChat && this.onPlayerChat(player, message); } ); await this.page.exposeFunction('onPlayerBallKick', (player: Player) => { this.onPlayerBallKick && this.onPlayerBallKick(player); }); await this.page.exposeFunction('onTeamGoal', (team: TeamID) => { this.onTeamGoal && this.onTeamGoal(team); }); await this.page.exposeFunction('onGameStart', (byPlayer: Player) => { this.onGameStart && this.onGameStart(byPlayer); }); await this.page.exposeFunction('onGameStop', (byPlayer: Player) => { this.onGameStop && this.onGameStop(byPlayer); }); await this.page.exposeFunction( 'onPlayerAdminChange', (changedPlayer: Player, byPlayer: Player) => { this.onPlayerAdminChange && this.onPlayerAdminChange(changedPlayer, byPlayer); } ); await this.page.exposeFunction( 'onPlayerTeamChange', (changedPlayer: Player, byPlayer: Player) => { this.onPlayerTeamChange && this.onPlayerTeamChange(changedPlayer, byPlayer); } ); await this.page.exposeFunction( 'onPlayerKicked', ( kickedPlayer: Player, reason: string, ban: boolean, byPlayer: Player ) => { this.onPlayerKicked && this.onPlayerKicked(kickedPlayer, reason, ban, byPlayer); } ); await this.page.exposeFunction('onGameTick', () => { this.onGameTick && this.onGameTick(); }); await this.page.exposeFunction('onGamePause', (byPlayer: Player) => { this.onGamePause && this.onGamePause(byPlayer); }); await this.page.exposeFunction('onGameUnpause', (byPlayer: Player) => { this.onGameUnpause && this.onGameUnpause(byPlayer); }); await this.page.exposeFunction('onPositionsReset', () => { this.onPositionsReset && this.onPositionsReset(); }); await this.page.exposeFunction('onPlayerActivity', (player: Player) => { this.onPlayerActivity && this.onPlayerActivity(player); }); await this.page.exposeFunction( 'onStadiumChange', (newStadiumName: string, byPlayer: Player) => { this.onStadiumChange && this.onStadiumChange(newStadiumName, byPlayer); } ); await this.page.exposeFunction( 'onKickRateLimitSet', (min: number, rate: number, burst: number, byPlayer: Player) => { this.onKickRateLimitSet && this.onKickRateLimitSet(min, rate, burst, byPlayer); } ); Logger.end(); } private async createRoom(): Promise<void> { Logger.begin('creating room'); await this.page.goto('https://www.haxball.com/headless', { waitUntil: 'networkidle2' }); await this.page.evaluate((serializedConfig: string) => { window._room = window.HBInit(JSON.parse(serializedConfig)); }, JSON.stringify(this.config)); await this.page.evaluate(() => { window._room.onPlayerJoin = (player) => { window.onPlayerJoin(player); }; window._room.onPlayerLeave = (player) => { window.onPlayerLeave(player); }; window._room.onTeamVictory = (scores) => { window.onTeamVictory(scores); }; window._room.onPlayerChat = (player, message) => { window.onPlayerChat(player, message); return false; }; window._room.onPlayerBallKick = (player) => { window.onPlayerBallKick(player); }; window._room.onTeamGoal = (team) => { window.onTeamGoal(team); }; window._room.onGameStart = (byPlayer) => { window.onGameStart(byPlayer); }; window._room.onGameStop = (byPlayer) => { window.onGameStop(byPlayer); }; window._room.onPlayerAdminChange = (changedPlayer, byPlayer) => { window.onPlayerAdminChange(changedPlayer, byPlayer); }; window._room.onPlayerTeamChange = (changedPlayer, byPlayer) => { window.onPlayerTeamChange(changedPlayer, byPlayer); }; window._room.onPlayerKicked = ( kickedPlayer, reason, ban, byPlayer ) => { window.onPlayerKicked(kickedPlayer, reason, ban, byPlayer); }; window._room.onGameTick = () => { window.onGameTick(); }; window._room.onGamePause = (byPlayer) => { window.onGamePause(byPlayer); }; window._room.onGameUnpause = (byPlayer) => { window.onGameUnpause(byPlayer); }; window._room.onPositionsReset = () => { window.onPositionsReset(); }; window._room.onPlayerActivity = (player) => { window.onPlayerActivity(player); }; window._room.onStadiumChange = (newStadiumName, byPlayer) => { window.onStadiumChange(newStadiumName, byPlayer); }; window._room.onKickRateLimitSet = (min, rate, burst, byPlayer) => { window.onKickRateLimitSet(min, rate, burst, byPlayer); }; }); this.CollisionFlags = await this.page.evaluate(() => { return window._room.CollisionFlags; }); const iframehandle = await this.page.$('iframe'); const iframe = await iframehandle?.contentFrame(); await iframe?.waitForFunction(() => { var recaptcha = document.querySelector('#recaptcha'); var link = document.querySelector('#roomlink'); if (recaptcha == null || link == null) return false; return recaptcha.clientHeight + link.clientHeight > 0; }); const failed = await iframe?.evaluate(() => { var recaptcha = document.querySelector('#recaptcha'); if (recaptcha == null) return true; return recaptcha.clientHeight > 0; }); if (failed) { Logger.end(true); process.exit(1); } Logger.end(); const link = await iframe?.evaluate(() => { let roomLink = document.querySelector('#roomlink a'); if (roomLink != null) return roomLink.getAttribute('href'); return ''; }); this.onRoomLink && this.onRoomLink(link || ''); } async start(): Promise<void> { await this.openNewTab(); await this.exposeFunctions(); await this.createRoom(); } /** Sends a chat message using the host player. * If targetId is null or undefined the message is sent to all players. * @param message The message content. * @param targetId The target id of the player to send the message. * If null or undefined the message is sent to all players. */ async sendChat(message: string, targetId?: number | null): Promise<void> { if (targetId == undefined) targetId = null; await this.page.evaluate( ({ message, targetId }: any) => { window._room.sendChat(message, targetId); }, { message, targetId } ); } /** Changes the admin status of the specified player. * @param playerId The player id whose admin status will change. * @param admin The new admin status of the player. */ async setPlayerAdmin(playerId: number, admin: boolean): Promise<void> { await this.page.evaluate( ({ playerId, admin }: any) => { window._room.setPlayerAdmin(playerId, admin); }, { playerId, admin } ); } /** Moves the specified player to a team. * @param playerId The player id whose team will change. * @param team The new team id of the player. */ async setPlayerTeam(playerId: number, team: number): Promise<void> { await this.page.evaluate( ({ playerId, team }: any) => { window._room.setPlayerTeam(playerId, team); }, { playerId, team } ); } /** Kicks the specified player from the room. * @param playerId The player id to kick from the room. * @param reason The reason of the kick. * @param ban True if the player is being banned from the room otherwise false. */ async kickPlayer( playerId: number, reason: string, ban: boolean ): Promise<void> { await this.page.evaluate( ({ playerId, reason, ban }: any) => { window._room.kickPlayer(playerId, reason, ban); }, { playerId, reason, ban } ); } /** Clears the ban for a playerId that belonged * to a player that was previously banned. * @param playerId The player id that previously has been banned. */ async clearBan(playerId: number): Promise<void> { await this.page.evaluate( ({ playerId }: any) => { window._room.clearBan(playerId); }, { playerId } ); } /** Clears the list of banned players. */ async clearBans(): Promise<void> { await this.page.evaluate(() => { window._room.clearBans(); }); } /** Sets the score limit of the room. * @param limit The score limit. */ async setScoreLimit(limit: number): Promise<void> { await this.page.evaluate( ({ limit }: any) => { window._room.setScoreLimit(limit); }, { limit } ); } /** Sets the time limit of the room. The limit must * be specified in number of minutes. * @param limitInMinutes The time limit in minutes. */ async setTimeLimit(limitInMinutes: number): Promise<void> { await this.page.evaluate( ({ limitInMinutes }: any) => { window._room.setTimeLimit(limitInMinutes); }, { limitInMinutes } ); } /** Parses the stadiumFileContents as a .hbs stadium * file and sets it as the selected stadium. * Returns false if it fails to set the stadium. * @param stadiumFileContents The content of the .hbs stadium file. */ async setCustomStadium(stadiumFileContents: string): Promise<boolean> { if (Stadium.isValid(stadiumFileContents)) { return await this.page.evaluate( ({ stadiumFileContents }: any) => { try { window._room.setCustomStadium(stadiumFileContents); return true; } catch (_) { /* invalid map format */ return false; } }, { stadiumFileContents } ); } else { return false; } } /** Sets the selected stadium to one of the default stadiums. * The name must match exactly (case sensitive). * @param stadiumName The default stadium name. */ async setDefaultStadium(stadiumName: DefaultStadium): Promise<void> { await this.page.evaluate( ({ stadiumName }: any) => { window._room.setDefaultStadium(stadiumName); }, { stadiumName } ); } /** Sets the teams lock. When teams are locked players are * not able to change team unless they are moved by an admin. * @param locked True to prevent players from changing its current team. */ async setTeamsLock(locked: boolean): Promise<void> { await this.page.evaluate( ({ locked }: any) => { window._room.setTeamsLock(locked); }, { locked } ); } /** Sets the colors of a team. * @param team The team id to change its colors. * @param angle The rotation. * @param textColor The color of the avatars. * @param colors The background colors. */ async setTeamColors( team: TeamID, angle: number, textColor: number, colors: number[] ): Promise<void> { await this.page.evaluate( ({ team, angle, textColor, colors }: any) => { window._room.setTeamColors(team, angle, textColor, colors); }, { team, angle, textColor, colors } ); } /** Starts the game, if a game is already in progress * this method does nothing. */ async startGame(): Promise<void> { await this.page.evaluate(() => { window._room.startGame(); }); } /** Stops the game, if no game is in progress * this method does nothing. */ async stopGame(): Promise<void> { await this.page.evaluate(() => { window._room.stopGame(); }); } /** Sets the pause state of the game. true = paused and false = unpaused. * @param pauseState True to pause the game, false to unpause it. */ async pauseGame(pauseState: boolean): Promise<void> { await this.page.evaluate( ({ pauseState }: any) => { window._room.pauseGame(pauseState); }, { pauseState } ); } /** Returns the player with the specified id. * Returns null if the player doesn't exist. * @param playerId The id of the player to search * in the list of players connected. */ async getPlayer(playerId: number): Promise<Player | null> { return await this.page.evaluate( ({ playerId }: any) => { return window._room.getPlayer(playerId); }, { playerId } ); } /** Returns the current list of players. */ async getPlayerList(): Promise<Player[]> { return await this.page.evaluate(() => { return window._room.getPlayerList(); }); } /** If a game is in progress it returns the * current score information. */ async getScores(): Promise<Scores | null> { return await this.page.evaluate(() => { return window._room.getScores(); }); } /** Returns the ball's position in the field * or null if no game is in progress. */ async getBallPosition(): Promise<Position | null> { return await this.page.evaluate(() => { return window._room.getBallPosition(); }); } /** Starts recording of a haxball replay. */ async startRecording(): Promise<void> { await this.page.evaluate(() => { window._room.startRecording(); }); } /** Stops the recording previously started with startRecording * and returns the replay file contents as a Uint8Array. */ async stopRecording(): Promise<Uint8Array | null> { return await this.page.evaluate(() => { return window._room.stopRecording(); }); } /** Changes the password of the room. * @param password The new password of the room, * if null the password will be cleared. */ async setPassword(password: string | null): Promise<void> { await this.page.evaluate( ({ password }: any) => { window._room.setPassword(password); }, { password } ); } /** Activates or deactivates the recaptcha requirement to join the room. * @param required True if every player must complete a * recaptcha before joining the room. */ async setRequireRecaptcha(required: boolean): Promise<void> { await this.page.evaluate( ({ required }: any) => { window._room.setRequireRecaptcha(required); }, { required } ); } /** First all players listed are removed, then they are * reinserted in the same order they appear in the playerIdList. * @param playerIdList The order in witch the players will be reinserted. * @param moveToTop If true players are inserted at the top of the list, * otherwise they are inserted at the bottom of the list. */ async reorderPlayers( playerIdList: number[], moveToTop: boolean ): Promise<void> { await this.page.evaluate( ({ playerIdList, moveToTop }: any) => { window._room.reorderPlayers(playerIdList, moveToTop); }, { playerIdList, moveToTop } ); } /** Sends a host announcement with msg as contents. Unlike sendChat, * announcements will work without a host player and has a larger * limit on the number of characters. * @param msg The content of the message. * @param targetId If null or undefined the message is sent to all players, * otherwise it's sent only to the player with matching targetId. * @param color The message color. * @param style The style of the announcement text, it must be one * of the following strings: "normal", "bold", "italic", "small", * "small-bold", "small-italic". * @param sound If 0 the announcement will produce no sound. * If 1 the announcement will produce a normal chat sound. * If 2 it will produce a notification sound. */ async sendAnnouncement( msg: string, targetId?: number | null, color?: number | null, style?: AnnounceStyle | null, sound?: number | null ): Promise<void> { if (targetId == undefined) targetId = null; if (color == undefined) color = null; if (style == undefined) style = null; if (sound == undefined) sound = null; await this.page.evaluate( ({ msg, targetId, color, style, sound }: any) => { window._room.sendAnnouncement( msg, targetId, color, style, sound ); }, { msg, targetId, color, style, sound } ); } /** Sets the room's kick rate limits. * @param min The minimum number of logic-frames between two kicks. * It is impossible to kick faster than this. * @param rate Works like min but lets players save up extra * kicks to use them later depending on the value of burst. * @param burst Determines how many extra kicks the player is able to save up. */ async setKickRateLimit( min: number, rate: number, burst: number ): Promise<void> { await this.page.evaluate( ({ min, rate, burst }: any) => { window._room.setKickRateLimit(min, rate, burst); }, { min, rate, burst } ); } /** Overrides the avatar of the target player. ** If avatar is set to null the override is cleared and the player will be able to use his own avatar again. */ async setPlayerAvatar( playerId: number, avatar: string | null ): Promise<void> { await this.page.evaluate( ({ playerId, avatar }: any) => { window._room.setPlayerAvatar(playerId, avatar); }, { playerId, avatar } ); } /** Sets properties of the target disc. ** Properties that are null or undefined will not be set and therefor will preserve whatever value the disc already had. */ async setDiscProperties( discIndex: number, properties: Partial<DiscProperties> ): Promise<void> { await this.page.evaluate( ({ discIndex, properties }: any) => { window._room.setDiscProperties(discIndex, properties); }, { discIndex, properties } ); } /** Gets the properties of the disc at discIndex. * Returns null if discIndex is out of bounds. */ async getDiscProperties(discIndex: number): Promise<DiscProperties | null> { return await this.page.evaluate( ({ discIndex }: any) => { return window._room.getDiscProperties(discIndex); }, { discIndex } ); } /** Same as setDiscProperties but targets the disc * belonging to a player with the given Id. */ async setPlayerDiscProperties( playerId: number, properties: Partial<DiscProperties> ): Promise<void> { await this.page.evaluate( ({ playerId, properties }: any) => { window._room.setPlayerDiscProperties(playerId, properties); }, { playerId, properties } ); } /** Same as getDiscProperties but targets the disc * belonging to a player with the given Id. */ async getPlayerDiscProperties( playerId: number ): Promise<DiscProperties | null> { return await this.page.evaluate( ({ playerId }: any) => { return window._room.getPlayerDiscProperties(playerId); }, { playerId } ); } /** Gets the number of discs in the game including * the ball and player discs. */ async getDiscCount(): Promise<number> { return await this.page.evaluate(() => { return window._room.getDiscCount(); }); } /** Object filled with the collision flags constants that * compose the cMask and cGroup disc properties. */ public CollisionFlags: CollisionFlags; /** Event called when a new player joins the room. */ public onPlayerJoin?(player: Player): void; /** Event called when a player leaves the room. */ public onPlayerLeave?(player: Player): void; /** Event called when a team wins. */ public onTeamVictory?(scores: Scores): void; /** Event called when a player sends a chat message. */ public onPlayerChat?(player: Player, message: string): void; /** Event called when a player kicks the ball. */ public onPlayerBallKick?(player: Player): void; /** Event called when a team scores a goal. */ public onTeamGoal?(team: TeamID): void; /** Event called when a game starts. * @param byPlayer is the player which caused the * event (can be null if the event wasn't caused by a player). */ public onGameStart?(byPlayer: Player | null): void; /** Event called when a game stops. * @param byPlayer is the player which caused the * event (can be null if the event wasn't caused by a player). */ public onGameStop?(byPlayer: Player | null): void; /** Event called when a player's admin rights are changed. * @param byPlayer is the player which caused the * event (can be null if the event wasn't caused by a player). */ public onPlayerAdminChange?( changedPlayer: Player, byPlayer: Player | null ): void; /** Event called when a player team is changed. * @param byPlayer is the player which caused the * event (can be null if the event wasn't caused by a player). */ public onPlayerTeamChange?( changedPlayer: Player, byPlayer: Player | null ): void; /** Event called when a player has been kicked from the room. * This is always called after the onPlayerLeave event. * @param byPlayer is the player which caused the * event (can be null if the event wasn't caused by a player). */ public onPlayerKicked?( kickedPlayer: Player, reason: string, ban: boolean, byPlayer: Player | null ): void; /** Event called once for every game tick (happens 60 times per second). * This is useful if you want to monitor the player and ball * positions without missing any ticks. ** This event is not called if the game is paused or stopped. */ public onGameTick?(): void; /** Event called when the game is paused. * @param byPlayer is the player which caused the * event (can be null if the event wasn't caused by a player). */ public onGamePause?(byPlayer: Player | null): void; /** Event called when the game is unpaused. * @param byPlayer is the player which caused the * event (can be null if the event wasn't caused by a player). */ public onGameUnpause?(byPlayer: Player | null): void; /** Event called when the players and ball positions are * reset after a goal happens. */ public onPositionsReset?(): void; /** Event called when a player gives signs of activity, * such as pressing a key. This is useful for detecting * inactive players. */ public onPlayerActivity?(player: Player): void; /** Event called when the stadium is changed. * @param byPlayer is the player which caused the * event (can be null if the event wasn't caused by a player). */ public onStadiumChange?( newStadiumName: string, byPlayer: Player | null ): void; /** Event called when the room link is obtained. */ public onRoomLink?(url: string): void; /** Event called when the kick rate is set. * @param byPlayer is the player which caused the * event (can be null if the event wasn't caused by a player). */ public onKickRateLimitSet?( min: number, rate: number, burst: number, byPlayer: Player | null ): void; }