Basic networking (#9)
Added PeerJS-powered client/server architecture. Clients can join, leave, chat rooms, Server can take parts as players too via a local client shim. Closes #4 Signed-off-by: Hamcha <hamcha@crunchy.rocks>
This commit is contained in:
parent
f0b8e98327
commit
6f6b00fd05
23 changed files with 1070 additions and 91 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,6 +1,7 @@
|
|||
.DS_Store
|
||||
node_modules
|
||||
/dist
|
||||
coverage
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
|
|
|
@ -5,3 +5,5 @@ Work in progress name, work in progress game
|
|||
## License
|
||||
|
||||
Code is ISC, Assets "depends", some stuff is taken from FreeSound and DeviantArt so your best bet is to just praise the copyright gods.
|
||||
|
||||
PeerJS mocking is based on works by Rolf Erik Lekang, which is licensed under MIT. Check [peerjs-mock](https://github.com/relekang/peerjs-mock) for more details.
|
||||
|
|
|
@ -1,37 +1,27 @@
|
|||
module.exports = {
|
||||
moduleFileExtensions: [
|
||||
'js',
|
||||
'jsx',
|
||||
'json',
|
||||
'vue',
|
||||
'ts',
|
||||
'tsx'
|
||||
],
|
||||
moduleFileExtensions: ["js", "jsx", "json", "vue", "ts", "tsx"],
|
||||
transform: {
|
||||
'^.+\\.vue$': 'vue-jest',
|
||||
'.+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$': 'jest-transform-stub',
|
||||
'^.+\\.tsx?$': 'ts-jest'
|
||||
"^.+\\.vue$": "vue-jest",
|
||||
".+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$":
|
||||
"jest-transform-stub",
|
||||
"^.+\\.tsx?$": "ts-jest"
|
||||
},
|
||||
transformIgnorePatterns: [
|
||||
'/node_modules/'
|
||||
],
|
||||
transformIgnorePatterns: ["/node_modules/"],
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1'
|
||||
"^@/(.*)$": "<rootDir>/src/$1"
|
||||
},
|
||||
snapshotSerializers: [
|
||||
'jest-serializer-vue'
|
||||
],
|
||||
snapshotSerializers: ["jest-serializer-vue"],
|
||||
testMatch: [
|
||||
'**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)'
|
||||
"**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)"
|
||||
],
|
||||
testURL: 'http://localhost/',
|
||||
testURL: "http://localhost/",
|
||||
watchPlugins: [
|
||||
'jest-watch-typeahead/filename',
|
||||
'jest-watch-typeahead/testname'
|
||||
"jest-watch-typeahead/filename",
|
||||
"jest-watch-typeahead/testname"
|
||||
],
|
||||
globals: {
|
||||
'ts-jest': {
|
||||
"ts-jest": {
|
||||
babelConfig: true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
"buefy": "^0.8.2",
|
||||
"core-js": "^2.6.5",
|
||||
"dexie": "^2.0.4",
|
||||
"eventemitter3": "^4.0.0",
|
||||
"peerjs": "^1.0.4",
|
||||
"register-service-worker": "^1.6.2",
|
||||
"vue": "^2.6.10",
|
||||
|
|
98
src/network/Client.ts
Normal file
98
src/network/Client.ts
Normal file
|
@ -0,0 +1,98 @@
|
|||
import {
|
||||
PeerMetadata,
|
||||
NetworkMessage,
|
||||
PasswordResponse,
|
||||
RoomInfo
|
||||
} from "./types";
|
||||
import EventEmitter from "eventemitter3";
|
||||
|
||||
export abstract class Client extends EventEmitter {
|
||||
public metadata: PeerMetadata;
|
||||
public players!: string[];
|
||||
public roomInfo!: RoomInfo;
|
||||
|
||||
public constructor(metadata: PeerMetadata) {
|
||||
super();
|
||||
this.metadata = metadata;
|
||||
}
|
||||
|
||||
protected _received(data: NetworkMessage) {
|
||||
this.emit("data", data);
|
||||
switch (data.kind) {
|
||||
// Server is sending over player list and room info
|
||||
case "room-info":
|
||||
this.roomInfo = data.room;
|
||||
this.players = data.players;
|
||||
break;
|
||||
// Someone changed name (or was forced to)
|
||||
case "rename":
|
||||
if (data.oldname == this.metadata.name) {
|
||||
// We got a name change!
|
||||
this.metadata.name = data.newname;
|
||||
} else {
|
||||
let idx = this.players.indexOf(data.oldname);
|
||||
if (idx < 0) {
|
||||
// Weird
|
||||
console.warn(
|
||||
`Someone (${data.oldname}) changed name but wasn't on the player list`
|
||||
);
|
||||
this.players.push(data.newname);
|
||||
break;
|
||||
}
|
||||
this.players[idx] = data.newname;
|
||||
}
|
||||
this.emit("rename", data.oldname, data.newname);
|
||||
break;
|
||||
// A new player joined the room (this includes us)
|
||||
case "player-joined":
|
||||
this.players.push(data.name);
|
||||
this.emit("player-joined", data.name);
|
||||
break;
|
||||
case "player-left":
|
||||
let idx = this.players.indexOf(data.name);
|
||||
if (idx < 0) {
|
||||
// Weird
|
||||
console.warn(
|
||||
`Someone (${data.name}) left but wasn't on the player list`
|
||||
);
|
||||
break;
|
||||
}
|
||||
this.players.splice(idx, 1);
|
||||
case "password-req":
|
||||
this.emit("password-required");
|
||||
default:
|
||||
// For most cases, we can just use the kind as event type
|
||||
this.emit(data.kind, data);
|
||||
}
|
||||
}
|
||||
|
||||
public password(password: string) {
|
||||
this.send<PasswordResponse>({
|
||||
kind: "password-resp",
|
||||
password
|
||||
});
|
||||
}
|
||||
|
||||
public get name(): string {
|
||||
return this.metadata.name;
|
||||
}
|
||||
|
||||
public abstract send<T extends NetworkMessage>(data: T): void;
|
||||
|
||||
public leave(): void {
|
||||
this.send({
|
||||
kind: "leave-req"
|
||||
});
|
||||
}
|
||||
|
||||
public say(to: string | null, message: string) {
|
||||
this.send({
|
||||
kind: "chat",
|
||||
from: this.name,
|
||||
to: to ? to : "",
|
||||
message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default Client;
|
20
src/network/LocalClient.ts
Normal file
20
src/network/LocalClient.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { PeerMetadata, NetworkMessage } from "./types";
|
||||
import Client from "./Client";
|
||||
|
||||
export class LocalClient extends Client {
|
||||
public receiver!: (data: NetworkMessage) => void;
|
||||
|
||||
public constructor(metadata: PeerMetadata) {
|
||||
super(metadata);
|
||||
}
|
||||
|
||||
public receive(data: NetworkMessage) {
|
||||
this._received(data);
|
||||
}
|
||||
|
||||
public send<T extends NetworkMessage>(data: T) {
|
||||
this.receiver(data);
|
||||
}
|
||||
}
|
||||
|
||||
export default LocalClient;
|
45
src/network/PeerClient.ts
Normal file
45
src/network/PeerClient.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
import Peer, { DataConnection } from "peerjs";
|
||||
import { PeerMetadata, NetworkMessage } from "./types";
|
||||
import Client from "./Client";
|
||||
|
||||
export class PeerClient extends Client {
|
||||
protected peer: Peer;
|
||||
private connection?: DataConnection;
|
||||
|
||||
public constructor(metadata: PeerMetadata, customPeer?: Peer) {
|
||||
super(metadata);
|
||||
this.peer = customPeer ? customPeer : new Peer();
|
||||
this.peer.on("open", function(id) {
|
||||
console.info("Peer ID assigned: %s", id);
|
||||
});
|
||||
}
|
||||
|
||||
public connect(peerid: string) {
|
||||
this.connection = this.peer.connect(peerid, {
|
||||
metadata: this.metadata,
|
||||
reliable: true
|
||||
});
|
||||
if (!this.connection) {
|
||||
throw new Error("Could not connect");
|
||||
}
|
||||
this.connection.on("open", () => {
|
||||
this.emit("connected");
|
||||
});
|
||||
this.connection.on("data", data => {
|
||||
this._received(data);
|
||||
});
|
||||
}
|
||||
|
||||
public get name(): string {
|
||||
return this.metadata.name;
|
||||
}
|
||||
|
||||
public send<T extends NetworkMessage>(data: T) {
|
||||
if (!this.connection) {
|
||||
throw new Error("Client is not connected to a server");
|
||||
}
|
||||
this.connection.send(data);
|
||||
}
|
||||
}
|
||||
|
||||
export default PeerClient;
|
239
src/network/PeerServer.ts
Normal file
239
src/network/PeerServer.ts
Normal file
|
@ -0,0 +1,239 @@
|
|||
import Peer, { DataConnection } from "peerjs";
|
||||
import {
|
||||
RoomInfo,
|
||||
PasswordRequest,
|
||||
Room,
|
||||
ErrorMessage,
|
||||
PasswordResponse,
|
||||
PeerMetadata,
|
||||
JoinMessage,
|
||||
RoomInfoMessage,
|
||||
Player,
|
||||
NetworkMessage,
|
||||
RenameMessage,
|
||||
LeaveMessage,
|
||||
NetworkPlayer,
|
||||
AckMessage,
|
||||
ChatMessage
|
||||
} from "./types";
|
||||
import { LocalClient } from ".";
|
||||
import EventEmitter from "eventemitter3";
|
||||
|
||||
// Increment name, add number at the end if not present
|
||||
// Examples:
|
||||
// Guest -> Guest1
|
||||
// Guest1 -> Guest2
|
||||
// Guest9 -> Guest10
|
||||
function nextName(name: string): string {
|
||||
let i = 1;
|
||||
for (; i < name.length; i++) {
|
||||
if (!isNaN(Number(name.slice(i - name.length)))) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return name.substr(0, i) + (Number(name.slice(i)) + 1);
|
||||
}
|
||||
|
||||
export class PeerServer extends EventEmitter {
|
||||
protected peer: Peer;
|
||||
private room: Room;
|
||||
|
||||
public constructor(
|
||||
roomInfo: RoomInfo,
|
||||
local: LocalClient,
|
||||
customPeer?: Peer
|
||||
) {
|
||||
super();
|
||||
let players: Record<string, Player> = {};
|
||||
|
||||
// Add local player to server
|
||||
players[local.name] = {
|
||||
kind: "local",
|
||||
name: local.name,
|
||||
client: local
|
||||
};
|
||||
local.receiver = this._received.bind(this, players[local.name]);
|
||||
|
||||
this.room = {
|
||||
info: roomInfo,
|
||||
players
|
||||
};
|
||||
|
||||
local.players = Object.keys(this.players);
|
||||
local.roomInfo = this.room.info;
|
||||
|
||||
// Setup peer
|
||||
this.peer = customPeer ? customPeer : new Peer();
|
||||
this.peer.on("open", function(id) {
|
||||
console.info("Peer ID assigned: %s", id);
|
||||
});
|
||||
this.peer.on("connection", conn => {
|
||||
this._connection(conn);
|
||||
});
|
||||
}
|
||||
|
||||
private _connection(conn: DataConnection) {
|
||||
const metadata = conn.metadata as PeerMetadata;
|
||||
|
||||
let player: NetworkPlayer = {
|
||||
kind: "remote",
|
||||
name: metadata.name,
|
||||
conn: conn
|
||||
};
|
||||
|
||||
//
|
||||
// Check if this connection should be allowed
|
||||
//
|
||||
|
||||
// Check if room is full
|
||||
if (this.playerCount >= this.room.info.max_players) {
|
||||
this.send<ErrorMessage>(player, { kind: "error", error: "room is full" });
|
||||
conn.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if there is already a player called that way
|
||||
if (this.room.players[metadata.name]) {
|
||||
// Force rename
|
||||
let newname = metadata.name;
|
||||
while (this.room.players[newname]) {
|
||||
newname = nextName(metadata.name);
|
||||
}
|
||||
this.send<RenameMessage>(player, {
|
||||
kind: "rename",
|
||||
oldname: metadata.name,
|
||||
newname: newname
|
||||
});
|
||||
player.name = newname;
|
||||
player.conn.metadata.name = newname;
|
||||
}
|
||||
|
||||
// Check for password
|
||||
if (this.room.info.password != "") {
|
||||
const checkPasswordResponse = (data: any) => {
|
||||
try {
|
||||
let resp = data as PasswordResponse;
|
||||
if (resp.password != this.room.info.password) {
|
||||
this.send<ErrorMessage>(player, {
|
||||
kind: "error",
|
||||
error: "invalid password"
|
||||
});
|
||||
return;
|
||||
}
|
||||
conn.off("data", checkPasswordResponse);
|
||||
this.addPlayer(player);
|
||||
} catch (e) {
|
||||
this.send<ErrorMessage>(player, {
|
||||
kind: "error",
|
||||
error: "not a password"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
this.send<PasswordRequest>(player, { kind: "password-req" });
|
||||
conn.on("data", checkPasswordResponse.bind(this));
|
||||
return;
|
||||
}
|
||||
|
||||
this.addPlayer(player);
|
||||
}
|
||||
|
||||
private addPlayer(player: NetworkPlayer) {
|
||||
const playerName = player.name;
|
||||
this.room.players[playerName] = player;
|
||||
|
||||
// Start listening for new messages
|
||||
player.conn.on(
|
||||
"data",
|
||||
this._received.bind(this, this.room.players[playerName])
|
||||
);
|
||||
|
||||
// Send the player info about the room
|
||||
this.send<RoomInfoMessage>(player, {
|
||||
kind: "room-info",
|
||||
room: {
|
||||
...this.room.info,
|
||||
password: ""
|
||||
},
|
||||
players: Object.keys(this.room.players)
|
||||
});
|
||||
|
||||
// Notify other players
|
||||
this.broadcast<JoinMessage>({
|
||||
kind: "player-joined",
|
||||
name: playerName
|
||||
});
|
||||
}
|
||||
|
||||
private removePlayer(player: NetworkPlayer) {
|
||||
// Tell the player everything's fine
|
||||
this.send<AckMessage>(player, { kind: "ok", what: "leave-req" });
|
||||
|
||||
// Close connection with player
|
||||
player.conn.close();
|
||||
|
||||
// Notify other players
|
||||
this.broadcast<LeaveMessage>({
|
||||
kind: "player-left",
|
||||
name: player.name
|
||||
});
|
||||
}
|
||||
|
||||
private _received(player: Player, data: NetworkMessage) {
|
||||
switch (data.kind) {
|
||||
// Player wants to say something in chat
|
||||
case "chat":
|
||||
data.from = player.name;
|
||||
if (data.to == "") {
|
||||
// Players is saying that out loud
|
||||
this.broadcast(data);
|
||||
} else {
|
||||
// Player is telling someone specifically
|
||||
if (data.to in this.players) {
|
||||
this.send<ChatMessage>(this.players[data.to], data);
|
||||
} else {
|
||||
this.send<ErrorMessage>(player, {
|
||||
kind: "error",
|
||||
error: `player not found: ${data.to}`
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
// Player is leaving!
|
||||
case "leave-req":
|
||||
// If we're leaving, end the server
|
||||
if (player.kind == "local") {
|
||||
//TODO
|
||||
} else {
|
||||
// Remove and disconnect player
|
||||
this.removePlayer(player);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public get playerCount(): number {
|
||||
return Object.keys(this.room.players).length;
|
||||
}
|
||||
|
||||
public get players() {
|
||||
return this.room.players;
|
||||
}
|
||||
|
||||
protected send<T extends NetworkMessage>(player: Player, message: T) {
|
||||
if (player.kind == "remote") {
|
||||
player.conn.send(message);
|
||||
} else {
|
||||
player.client.receive(message);
|
||||
}
|
||||
}
|
||||
|
||||
private broadcast<T extends NetworkMessage>(message: T) {
|
||||
for (const playerName in this.room.players) {
|
||||
const player = this.room.players[playerName];
|
||||
this.send<T>(player, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default PeerServer;
|
|
@ -1,14 +0,0 @@
|
|||
import NetworkPeer from "./peer";
|
||||
import { DataConnection } from "peerjs";
|
||||
|
||||
export default class PeerClient extends NetworkPeer {
|
||||
private connection: DataConnection;
|
||||
public constructor(peerid: string, metadata: Object) {
|
||||
super();
|
||||
this.connection = this.peer.connect(peerid, {
|
||||
label: "server",
|
||||
metadata,
|
||||
reliable: true
|
||||
});
|
||||
}
|
||||
}
|
5
src/network/index.ts
Normal file
5
src/network/index.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export * from "./PeerClient";
|
||||
export * from "./LocalClient";
|
||||
export * from "./Client";
|
||||
export * from "./PeerServer";
|
||||
export * from "./types";
|
|
@ -1,16 +0,0 @@
|
|||
import Peer, { DataConnection } from "peerjs";
|
||||
|
||||
export default class NetworkPeer {
|
||||
protected peer: Peer;
|
||||
protected constructor() {
|
||||
this.peer = new Peer();
|
||||
this.peer.on("open", function(id) {
|
||||
console.info("Peer ID assigned: %s", id);
|
||||
});
|
||||
}
|
||||
|
||||
protected send<T>(conn: DataConnection, data: T) {
|
||||
//TODO Debugging support?
|
||||
conn.send(data);
|
||||
}
|
||||
}
|
|
@ -1,34 +0,0 @@
|
|||
import NetworkPeer from "./peer";
|
||||
import { DataConnection } from "peerjs";
|
||||
import { RoomInfo, PasswordRequest, Room } from "./types";
|
||||
|
||||
export default class PeerServer extends NetworkPeer {
|
||||
private room: Room;
|
||||
public constructor(roomInfo: RoomInfo) {
|
||||
super();
|
||||
this.room = {
|
||||
info: roomInfo,
|
||||
players: {}
|
||||
};
|
||||
this.peer.on("connection", this._connection);
|
||||
}
|
||||
private async _connection(conn: DataConnection) {
|
||||
// Check if this connection should be allowed
|
||||
console.info("%s (%s) connected!", conn.metadata.name, conn.label);
|
||||
|
||||
// Check if room is full
|
||||
if (this.playerCount >= this.room.info.max_players) {
|
||||
//TODO Reject
|
||||
}
|
||||
|
||||
if (this.room.info.password != "") {
|
||||
this.send<PasswordRequest>(conn, { kind: "password-req" });
|
||||
} else {
|
||||
//TODO Add player
|
||||
}
|
||||
}
|
||||
|
||||
private get playerCount(): number {
|
||||
return Object.keys(this.room.players).length;
|
||||
}
|
||||
}
|
|
@ -1,19 +1,33 @@
|
|||
import { DataConnection } from "peerjs";
|
||||
import LocalClient from "./LocalClient";
|
||||
|
||||
export interface LocalPlayer {
|
||||
kind: "local";
|
||||
name: string;
|
||||
client: LocalClient;
|
||||
}
|
||||
|
||||
export interface NetworkPlayer {
|
||||
kind: "remote";
|
||||
name: string;
|
||||
conn: DataConnection;
|
||||
}
|
||||
|
||||
export type Player = NetworkPlayer | LocalPlayer;
|
||||
|
||||
export interface PeerMetadata {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Room {
|
||||
info: RoomInfo;
|
||||
players: Record<string, NetworkPlayer>;
|
||||
players: Record<string, Player>;
|
||||
}
|
||||
|
||||
export interface RoomInfo {
|
||||
max_players: number;
|
||||
password: string;
|
||||
game: GameInfo;
|
||||
game?: GameInfo;
|
||||
}
|
||||
|
||||
type GameInfo = MatchInfo | DraftInfo;
|
||||
|
@ -46,7 +60,17 @@ type DraftInfo = {
|
|||
|
||||
// Message schemas
|
||||
|
||||
export type NetworkMessage = PasswordRequest | PasswordResponse;
|
||||
export type NetworkMessage =
|
||||
| PasswordRequest
|
||||
| PasswordResponse
|
||||
| LeaveRequest
|
||||
| ErrorMessage
|
||||
| RoomInfoMessage
|
||||
| JoinMessage
|
||||
| LeaveMessage
|
||||
| RenameMessage
|
||||
| ChatMessage
|
||||
| AckMessage;
|
||||
|
||||
export interface PasswordRequest {
|
||||
kind: "password-req";
|
||||
|
@ -56,3 +80,46 @@ export interface PasswordResponse {
|
|||
kind: "password-resp";
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RoomInfoMessage {
|
||||
kind: "room-info";
|
||||
room: RoomInfo;
|
||||
players: string[];
|
||||
}
|
||||
|
||||
export interface LeaveRequest {
|
||||
kind: "leave-req";
|
||||
}
|
||||
|
||||
export interface JoinMessage {
|
||||
kind: "player-joined";
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface LeaveMessage {
|
||||
kind: "player-left";
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface RenameMessage {
|
||||
kind: "rename";
|
||||
oldname: string;
|
||||
newname: string;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
kind: "chat";
|
||||
from: string;
|
||||
to: string; // "" means everyone
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface AckMessage {
|
||||
kind: "ok";
|
||||
what: string;
|
||||
}
|
||||
|
||||
export interface ErrorMessage {
|
||||
kind: "error";
|
||||
error: string;
|
||||
}
|
||||
|
|
63
src/testing/EventHook.ts
Normal file
63
src/testing/EventHook.ts
Normal file
|
@ -0,0 +1,63 @@
|
|||
import EventEmitter from "eventemitter3";
|
||||
import { withTimeout } from "@/utils";
|
||||
|
||||
interface HookedEvent {
|
||||
date: Date;
|
||||
arguments: any[];
|
||||
}
|
||||
|
||||
const defaultCmp = () => {};
|
||||
|
||||
export class EventHook extends EventEmitter {
|
||||
public backlog: Record<string, HookedEvent[]> = {};
|
||||
|
||||
public hookEmitter(emitter: EventEmitter, event: string, id: string) {
|
||||
emitter.on(event, this._receivedEmitter.bind(this, id));
|
||||
}
|
||||
|
||||
public expect(
|
||||
id: string,
|
||||
timeout: number = 1000,
|
||||
cmp: (...args: any) => void = defaultCmp
|
||||
): Promise<void> {
|
||||
// Build promise
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
const getLastEvent = () => {
|
||||
// Take first element in the backlog
|
||||
let event = this.backlog[id].shift();
|
||||
if (!event) {
|
||||
// Should never happen
|
||||
return reject(`undefined event in list (??)`);
|
||||
}
|
||||
// Send to check function, then resolve
|
||||
resolve(cmp(...event.arguments));
|
||||
return;
|
||||
};
|
||||
if (this.backlog[id] && this.backlog[id].length > 0) {
|
||||
// Event is already in the backlog
|
||||
getLastEvent();
|
||||
} else {
|
||||
// If the event hasn't fired yet, subscribe to the next time it will
|
||||
this.once(id, getLastEvent);
|
||||
}
|
||||
});
|
||||
|
||||
// Return promise with added timeout
|
||||
return withTimeout(promise, timeout);
|
||||
}
|
||||
|
||||
// When events are received
|
||||
private _receivedEmitter(id: string, ...args: any) {
|
||||
const event: HookedEvent = {
|
||||
date: new Date(),
|
||||
arguments: args
|
||||
};
|
||||
// Create backlog if it doesn't exist
|
||||
if (!this.backlog[id]) {
|
||||
this.backlog[id] = [];
|
||||
}
|
||||
// Push to backlog and emit event (for pending expects)
|
||||
this.backlog[id].push(event);
|
||||
this.emit(id, event);
|
||||
}
|
||||
}
|
221
src/testing/MockDataConnection.ts
Normal file
221
src/testing/MockDataConnection.ts
Normal file
|
@ -0,0 +1,221 @@
|
|||
import EventEmitter from "eventemitter3";
|
||||
import MockPeer from "./MockPeer";
|
||||
import { PeerConnectOption } from "peerjs";
|
||||
|
||||
export class MockDataConnection extends EventEmitter {
|
||||
public options: PeerConnectOption;
|
||||
public peer: string;
|
||||
public provider: MockPeer;
|
||||
public open: boolean = false;
|
||||
private _connection?: MockDataConnection;
|
||||
public id: string;
|
||||
public label: string;
|
||||
|
||||
public constructor(
|
||||
peer: string,
|
||||
provider: MockPeer,
|
||||
options?: PeerConnectOption
|
||||
) {
|
||||
super();
|
||||
this.options = {
|
||||
serialization: "binary",
|
||||
reliable: false
|
||||
};
|
||||
if (options) {
|
||||
this.options = Object.assign(this.options, options);
|
||||
}
|
||||
this.id = "fake_" + Math.random().toString(32);
|
||||
this.label = this.options.label ? this.options.label : this.id;
|
||||
this.provider = provider;
|
||||
this.peer = peer;
|
||||
}
|
||||
|
||||
public send(message: any) {
|
||||
if (!this.open) {
|
||||
this.emit(
|
||||
"error",
|
||||
new Error(
|
||||
"Connection is not open. You should listen for the `open` event before sending messages."
|
||||
)
|
||||
);
|
||||
}
|
||||
const connection = this._connection;
|
||||
if (connection) {
|
||||
connection.receive(message);
|
||||
}
|
||||
}
|
||||
|
||||
public receive(message: any) {
|
||||
this.emit("data", message);
|
||||
}
|
||||
|
||||
public close() {
|
||||
if (!this.open) {
|
||||
return;
|
||||
}
|
||||
this.open = false;
|
||||
this.emit("close");
|
||||
}
|
||||
|
||||
public _setRemote(connection: MockDataConnection) {
|
||||
this._connection = connection;
|
||||
this._connection.on("open", () => {
|
||||
if (!this.open) {
|
||||
this.open = true;
|
||||
this.emit("open");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public _configureDataChannel(
|
||||
mocks: Record<string, MockPeer>,
|
||||
options?: PeerConnectOption
|
||||
) {
|
||||
if (!this._connection && this.peer in mocks) {
|
||||
this._setRemote(
|
||||
mocks[this.peer]._negotiate(this.provider.id, this, options)
|
||||
);
|
||||
this.open = true;
|
||||
this.emit("open");
|
||||
} else {
|
||||
throw new Error(`Peer(${this.peer}) not found`);
|
||||
}
|
||||
}
|
||||
|
||||
public get metadata() {
|
||||
return this.options.metadata;
|
||||
}
|
||||
|
||||
public get reliable() {
|
||||
return this.options.reliable ? this.options.reliable : false;
|
||||
}
|
||||
|
||||
public get serialization() {
|
||||
return this.options.serialization ? this.options.serialization : "binary";
|
||||
}
|
||||
|
||||
public get peerConnection() {
|
||||
return this._connection;
|
||||
}
|
||||
|
||||
/*
|
||||
UNIMPLEMENTED STUFF
|
||||
*/
|
||||
|
||||
public dataChannel: unknown;
|
||||
public bufferSize: unknown;
|
||||
public type: unknown;
|
||||
public canTrickleIceCandidates: unknown;
|
||||
public connectionState: unknown;
|
||||
public currentLocalDescription: unknown;
|
||||
public currentRemoteDescription: unknown;
|
||||
public iceConnectionState: unknown;
|
||||
public iceGatheringState: unknown;
|
||||
public idpErrorInfo: unknown;
|
||||
public idpLoginUrl: unknown;
|
||||
public localDescription: unknown;
|
||||
public onconnectionstatechange: unknown;
|
||||
public ondatachannel: unknown;
|
||||
public onicecandidate: unknown;
|
||||
public onicecandidateerror: unknown;
|
||||
public oniceconnectionstatechange: unknown;
|
||||
public onicegatheringstatechange: unknown;
|
||||
public onnegotiationneeded: unknown;
|
||||
public onsignalingstatechange: unknown;
|
||||
public onstatsended: unknown;
|
||||
public ontrack: unknown;
|
||||
public peerIdentity: unknown;
|
||||
public pendingLocalDescription: unknown;
|
||||
public pendingRemoteDescription: unknown;
|
||||
public remoteDescription: unknown;
|
||||
public sctp: unknown;
|
||||
public signalingState: unknown;
|
||||
|
||||
public addIceCandidate(
|
||||
candidate: RTCIceCandidateInit | RTCIceCandidate
|
||||
): Promise<void> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
public addTrack(track: MediaStreamTrack, ...streams: MediaStream[]): unknown {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
public addTransceiver(
|
||||
trackOrKind: MediaStreamTrack | string,
|
||||
init?: RTCRtpTransceiverInit
|
||||
): unknown {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
public createAnswer(options?: RTCOfferOptions): Promise<unknown> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
public createDataChannel(
|
||||
label: string,
|
||||
dataChannelDict?: RTCDataChannelInit
|
||||
): unknown {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
public createOffer(options?: RTCOfferOptions): Promise<unknown> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
public getConfiguration(): unknown {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
public getIdentityAssertion(): Promise<string> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
public getReceivers(): unknown[] {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
public getSenders(): unknown[] {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
public getStats(selector?: MediaStreamTrack | null): Promise<RTCStatsReport> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
public getTransceivers(): unknown[] {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
public removeTrack(sender: RTCRtpSender): void {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
public setConfiguration(configuration: RTCConfiguration): void {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
public setIdentityProvider(
|
||||
provider: string,
|
||||
options?: RTCIdentityProviderOptions
|
||||
) {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
public setLocalDescription(
|
||||
description: RTCSessionDescriptionInit
|
||||
): Promise<void> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
public setRemoteDescription(
|
||||
description: RTCSessionDescriptionInit
|
||||
): Promise<void> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
addEventListener(
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject,
|
||||
options?: boolean | AddEventListenerOptions
|
||||
): void {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
removeEventListener(
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject,
|
||||
options?: boolean | EventListenerOptions
|
||||
): void {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
dispatchEvent(event: Event): boolean {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
}
|
||||
|
||||
export default MockDataConnection;
|
27
src/testing/MockHelper.ts
Normal file
27
src/testing/MockHelper.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import Peer from "peerjs";
|
||||
import { MockPeer } from ".";
|
||||
import { LocalClient, PeerServer, PeerClient, RoomInfo } from "@/network";
|
||||
|
||||
export class MockHelper {
|
||||
private mocks: Record<string, MockPeer> = {};
|
||||
|
||||
createServer(
|
||||
room: RoomInfo,
|
||||
id: string = "test-server",
|
||||
player?: LocalClient
|
||||
) {
|
||||
const serverPeer = new MockPeer(this.mocks, id, {});
|
||||
return new PeerServer(
|
||||
room,
|
||||
player ? player : new LocalClient({ name: "server-player" }),
|
||||
serverPeer as Peer
|
||||
);
|
||||
}
|
||||
|
||||
createClient(id: string = "test-client", name: string = "client-peer") {
|
||||
const clientPeer = new MockPeer(this.mocks, id, {});
|
||||
return new PeerClient({ name }, clientPeer as Peer);
|
||||
}
|
||||
}
|
||||
|
||||
export default MockHelper;
|
95
src/testing/MockPeer.ts
Normal file
95
src/testing/MockPeer.ts
Normal file
|
@ -0,0 +1,95 @@
|
|||
import EventEmitter from "eventemitter3";
|
||||
import Peer, { PeerConnectOption, PeerJSOption } from "peerjs";
|
||||
import MockDataConnection from "./MockDataConnection";
|
||||
|
||||
export class MockPeer extends EventEmitter {
|
||||
public connections: Record<string, MockDataConnection[]>;
|
||||
public id: string;
|
||||
public options: PeerJSOption;
|
||||
public disconnected: boolean;
|
||||
public destroyed: boolean;
|
||||
public prototype: unknown;
|
||||
private mocks: Record<string, MockPeer>;
|
||||
|
||||
public constructor(
|
||||
mockSource: Record<string, MockPeer>,
|
||||
id: string,
|
||||
options?: PeerJSOption
|
||||
) {
|
||||
super();
|
||||
this.id = id;
|
||||
this.options = options ? options : {};
|
||||
this.connections = {};
|
||||
this.disconnected = false;
|
||||
this.destroyed = false;
|
||||
this.mocks = mockSource;
|
||||
this.mocks[id] = this;
|
||||
}
|
||||
|
||||
public connect(id: string, options?: PeerConnectOption): MockDataConnection {
|
||||
let connection = new MockDataConnection(id, this, options);
|
||||
// hacky way to avoid race conditions
|
||||
setTimeout(() => connection._configureDataChannel(this.mocks, options), 1);
|
||||
this._addConnection(id, connection);
|
||||
return connection;
|
||||
}
|
||||
|
||||
public disconnect() {
|
||||
this.disconnected = true;
|
||||
this.emit("disconnected", this.id);
|
||||
this.id = "";
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
this.destroyed = true;
|
||||
this.emit("destroyed", this.id);
|
||||
this.disconnect();
|
||||
}
|
||||
|
||||
private _addConnection(peer: string, connection: MockDataConnection) {
|
||||
if (!this.connections[peer]) {
|
||||
this.connections[peer] = [];
|
||||
}
|
||||
this.connections[peer].push(connection);
|
||||
}
|
||||
|
||||
public _negotiate(
|
||||
peer: string,
|
||||
remoteConnection: MockDataConnection,
|
||||
options?: PeerConnectOption
|
||||
) {
|
||||
let localConnection = new MockDataConnection(peer, this, options);
|
||||
localConnection._setRemote(remoteConnection);
|
||||
this._addConnection(peer, localConnection);
|
||||
this.emit("connection", localConnection);
|
||||
return localConnection;
|
||||
}
|
||||
|
||||
public call(
|
||||
id: string,
|
||||
stream: MediaStream,
|
||||
options?: Peer.CallOption
|
||||
): Peer.MediaConnection {
|
||||
throw new Error("unimplemented");
|
||||
}
|
||||
|
||||
public reconnect() {}
|
||||
|
||||
public getConnection(
|
||||
peerId: string,
|
||||
connectionId: string
|
||||
): MockDataConnection | null {
|
||||
if (peerId in this.mocks) {
|
||||
let map = this.mocks[peerId].connections;
|
||||
if (connectionId in map) {
|
||||
return map[connectionId][0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public listAllPeers() {}
|
||||
}
|
||||
|
||||
export default MockPeer;
|
3
src/testing/index.ts
Normal file
3
src/testing/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export * from "./MockDataConnection";
|
||||
export * from "./MockPeer";
|
||||
export * from "./MockHelper";
|
151
src/tests/unit/network.spec.ts
Normal file
151
src/tests/unit/network.spec.ts
Normal file
|
@ -0,0 +1,151 @@
|
|||
import { MockHelper } from "@/testing";
|
||||
import { NetworkMessage, LocalClient, ChatMessage } from "@/network";
|
||||
import { EventHook } from "@/testing/EventHook";
|
||||
|
||||
const sampleRoom = () => ({
|
||||
max_players: 3,
|
||||
password: ""
|
||||
});
|
||||
|
||||
function messageKind(kind: string): (msg: NetworkMessage) => void {
|
||||
return msg => {
|
||||
expect(msg.kind).toEqual(kind);
|
||||
};
|
||||
}
|
||||
|
||||
describe("network/PeerServer", () => {
|
||||
test("A server can be created", () => {
|
||||
const mox = new MockHelper();
|
||||
mox.createServer(sampleRoom());
|
||||
});
|
||||
|
||||
test("Clients can join servers and receive data", async () => {
|
||||
const mox = new MockHelper();
|
||||
const hook = new EventHook();
|
||||
mox.createServer(sampleRoom());
|
||||
const client = mox.createClient();
|
||||
hook.hookEmitter(client, "data", "client-data");
|
||||
client.connect("test-server");
|
||||
await hook.expect("client-data", 1000, messageKind("room-info"));
|
||||
await hook.expect("client-data", 1000, messageKind("player-joined"));
|
||||
});
|
||||
|
||||
test("Servers handle multiple clients and resolve name collisions", async () => {
|
||||
const mox = new MockHelper();
|
||||
const hook = new EventHook();
|
||||
mox.createServer(sampleRoom());
|
||||
const client1 = mox.createClient();
|
||||
const client2 = mox.createClient();
|
||||
hook.hookEmitter(client1, "data", "client1-data");
|
||||
hook.hookEmitter(client2, "data", "client2-data");
|
||||
client1.connect("test-server");
|
||||
await hook.expect("client1-data", 1000, messageKind("room-info"));
|
||||
await hook.expect("client1-data", 1000, messageKind("player-joined"));
|
||||
client2.connect("test-server");
|
||||
await hook.expect("client2-data", 1000, messageKind("rename"));
|
||||
await hook.expect("client2-data", 1000, messageKind("room-info"));
|
||||
await hook.expect("client2-data", 1000, messageKind("player-joined"));
|
||||
await hook.expect("client1-data", 1000, messageKind("player-joined"));
|
||||
});
|
||||
|
||||
test("Local server clients receives client events", async () => {
|
||||
const mox = new MockHelper();
|
||||
const hook = new EventHook();
|
||||
const local = new LocalClient({ name: "server-player" });
|
||||
const server = mox.createServer(sampleRoom(), "test-server", local);
|
||||
const client = mox.createClient();
|
||||
hook.hookEmitter(local, "player-joined", "local-joined");
|
||||
client.connect("test-server");
|
||||
await hook.expect("local-joined", 1000);
|
||||
});
|
||||
|
||||
test("Server enforces password protection", async () => {
|
||||
const mox = new MockHelper();
|
||||
const hook = new EventHook();
|
||||
const room = sampleRoom();
|
||||
const roomPassword = "test-pwd";
|
||||
room.password = roomPassword;
|
||||
const server = mox.createServer(room, "test-server");
|
||||
|
||||
// This client will try the right password
|
||||
const client1 = mox.createClient();
|
||||
hook.hookEmitter(client1, "data", "client1-data");
|
||||
client1.connect("test-server");
|
||||
await hook.expect("client1-data", 1000, messageKind("password-req"));
|
||||
client1.password(roomPassword);
|
||||
await hook.expect("client1-data", 1000, messageKind("room-info"));
|
||||
await hook.expect("client1-data", 1000, messageKind("player-joined"));
|
||||
|
||||
// This client will try an incorrect password
|
||||
const client2 = mox.createClient("test-client2", "another-client");
|
||||
hook.hookEmitter(client2, "data", "client2-data");
|
||||
client2.connect("test-server");
|
||||
await hook.expect("client2-data", 1000, messageKind("password-req"));
|
||||
client2.password("not the right password");
|
||||
await hook.expect("client2-data", 1000, messageKind("error"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("network/PeerClient", () => {
|
||||
test("Client handles forced name change", async () => {
|
||||
const mox = new MockHelper();
|
||||
const hook = new EventHook();
|
||||
mox.createServer(sampleRoom());
|
||||
const client1 = mox.createClient();
|
||||
const client2 = mox.createClient();
|
||||
hook.hookEmitter(client2, "rename", "client-rename");
|
||||
client1.connect("test-server");
|
||||
client2.connect("test-server");
|
||||
await hook.expect("client-rename", 1000);
|
||||
// Client must have changed its internal data to match the new name
|
||||
expect(client1.name).not.toEqual(client2.name);
|
||||
});
|
||||
|
||||
test("Client can leave the room", async () => {
|
||||
const mox = new MockHelper();
|
||||
const hook = new EventHook();
|
||||
const local = new LocalClient({ name: "server-player" });
|
||||
const server = mox.createServer(sampleRoom(), "test-server", local);
|
||||
const client = mox.createClient();
|
||||
hook.hookEmitter(local, "player-joined", "client-joined");
|
||||
hook.hookEmitter(local, "player-left", "client-left");
|
||||
client.connect("test-server");
|
||||
await hook.expect("client-joined", 1000);
|
||||
client.leave();
|
||||
await hook.expect("client-left", 1000);
|
||||
});
|
||||
|
||||
test("Clients can chat", async () => {
|
||||
const mox = new MockHelper();
|
||||
const hook = new EventHook();
|
||||
const local = new LocalClient({ name: "server-player" });
|
||||
const server = mox.createServer(sampleRoom(), "test-server", local);
|
||||
const client = mox.createClient();
|
||||
hook.hookEmitter(local, "player-joined", "client-joined");
|
||||
hook.hookEmitter(local, "chat", "client-chat");
|
||||
client.connect("test-server");
|
||||
await hook.expect("client-joined", 1000);
|
||||
const hellomsg = "Hello everyone";
|
||||
client.say("", hellomsg);
|
||||
await hook.expect("client-chat", 1000, (msg: ChatMessage) => {
|
||||
expect(msg.from).toEqual("client-peer");
|
||||
expect(msg.message).toEqual(hellomsg);
|
||||
});
|
||||
});
|
||||
|
||||
test("Client internal player list gets correctly updated", async () => {
|
||||
const mox = new MockHelper();
|
||||
const hook = new EventHook();
|
||||
const local = new LocalClient({ name: "server-player" });
|
||||
const server = mox.createServer(sampleRoom(), "test-server", local);
|
||||
const client = mox.createClient();
|
||||
hook.hookEmitter(local, "player-joined", "client-joined");
|
||||
hook.hookEmitter(local, "player-left", "client-left");
|
||||
client.connect("test-server");
|
||||
await hook.expect("client-joined", 1000);
|
||||
expect(local.players).toHaveLength(2);
|
||||
client.leave();
|
||||
await hook.expect("client-left", 1000);
|
||||
expect(local.players).toHaveLength(1);
|
||||
});
|
||||
});
|
1
src/utils/index.ts
Normal file
1
src/utils/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from "./timeout";
|
9
src/utils/timeout.ts
Normal file
9
src/utils/timeout.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||
const timeout = new Promise<T>((resolve, reject) => {
|
||||
let id = setTimeout(() => {
|
||||
clearTimeout(id);
|
||||
reject("Timed out after " + ms + "ms.");
|
||||
}, ms);
|
||||
});
|
||||
return Promise.race<T>([promise, timeout]);
|
||||
}
|
|
@ -3889,6 +3889,11 @@ eventemitter3@^3.0.0, eventemitter3@^3.1.2:
|
|||
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.2.tgz#2d3d48f9c346698fce83a85d7d664e98535df6e7"
|
||||
integrity sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==
|
||||
|
||||
eventemitter3@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.0.tgz#d65176163887ee59f386d64c82610b696a4a74eb"
|
||||
integrity sha512-qerSRB0p+UDEssxTtm6EDKcE7W4OaoisfIMl4CngyEhjpYglocpNg6UEqCvemdGhosAsg4sO2dXJOdyBifPGCg==
|
||||
|
||||
events@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/events/-/events-3.0.0.tgz#9a0a0dfaf62893d92b875b8f2698ca4114973e88"
|
||||
|
|
Loading…
Reference in a new issue