haxjs
Version:
haxball headless api written in typescript
877 lines (758 loc) • 25.2 kB
text/typescript
/*
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;
}