Basic networking #9
23 changed files with 1070 additions and 91 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,6 +1,7 @@
|
||||||
.DS_Store
|
.DS_Store
|
||||||
node_modules
|
node_modules
|
||||||
/dist
|
/dist
|
||||||
|
coverage
|
||||||
|
|
||||||
# local env files
|
# local env files
|
||||||
.env.local
|
.env.local
|
||||||
|
|
|
@ -5,3 +5,5 @@ Work in progress name, work in progress game
|
||||||
## License
|
## 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.
|
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 = {
|
module.exports = {
|
||||||
moduleFileExtensions: [
|
moduleFileExtensions: ["js", "jsx", "json", "vue", "ts", "tsx"],
|
||||||
'js',
|
|
||||||
'jsx',
|
|
||||||
'json',
|
|
||||||
'vue',
|
|
||||||
'ts',
|
|
||||||
'tsx'
|
|
||||||
],
|
|
||||||
transform: {
|
transform: {
|
||||||
'^.+\\.vue$': 'vue-jest',
|
"^.+\\.vue$": "vue-jest",
|
||||||
'.+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$': 'jest-transform-stub',
|
".+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$":
|
||||||
'^.+\\.tsx?$': 'ts-jest'
|
"jest-transform-stub",
|
||||||
|
"^.+\\.tsx?$": "ts-jest"
|
||||||
},
|
},
|
||||||
transformIgnorePatterns: [
|
transformIgnorePatterns: ["/node_modules/"],
|
||||||
'/node_modules/'
|
|
||||||
],
|
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'^@/(.*)$': '<rootDir>/src/$1'
|
"^@/(.*)$": "<rootDir>/src/$1"
|
||||||
},
|
},
|
||||||
snapshotSerializers: [
|
snapshotSerializers: ["jest-serializer-vue"],
|
||||||
'jest-serializer-vue'
|
|
||||||
],
|
|
||||||
testMatch: [
|
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: [
|
watchPlugins: [
|
||||||
'jest-watch-typeahead/filename',
|
"jest-watch-typeahead/filename",
|
||||||
'jest-watch-typeahead/testname'
|
"jest-watch-typeahead/testname"
|
||||||
],
|
],
|
||||||
globals: {
|
globals: {
|
||||||
'ts-jest': {
|
"ts-jest": {
|
||||||
babelConfig: true
|
babelConfig: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
|
@ -12,6 +12,7 @@
|
||||||
"buefy": "^0.8.2",
|
"buefy": "^0.8.2",
|
||||||
"core-js": "^2.6.5",
|
"core-js": "^2.6.5",
|
||||||
"dexie": "^2.0.4",
|
"dexie": "^2.0.4",
|
||||||
|
"eventemitter3": "^4.0.0",
|
||||||
"peerjs": "^1.0.4",
|
"peerjs": "^1.0.4",
|
||||||
"register-service-worker": "^1.6.2",
|
"register-service-worker": "^1.6.2",
|
||||||
"vue": "^2.6.10",
|
"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 { DataConnection } from "peerjs";
|
||||||
|
import LocalClient from "./LocalClient";
|
||||||
|
|
||||||
|
export interface LocalPlayer {
|
||||||
|
kind: "local";
|
||||||
|
name: string;
|
||||||
|
client: LocalClient;
|
||||||
|
}
|
||||||
|
|
||||||
export interface NetworkPlayer {
|
export interface NetworkPlayer {
|
||||||
|
kind: "remote";
|
||||||
name: string;
|
name: string;
|
||||||
conn: DataConnection;
|
conn: DataConnection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Player = NetworkPlayer | LocalPlayer;
|
||||||
|
|
||||||
|
export interface PeerMetadata {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Room {
|
export interface Room {
|
||||||
info: RoomInfo;
|
info: RoomInfo;
|
||||||
players: Record<string, NetworkPlayer>;
|
players: Record<string, Player>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoomInfo {
|
export interface RoomInfo {
|
||||||
max_players: number;
|
max_players: number;
|
||||||
password: string;
|
password: string;
|
||||||
game: GameInfo;
|
game?: GameInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
type GameInfo = MatchInfo | DraftInfo;
|
type GameInfo = MatchInfo | DraftInfo;
|
||||||
|
@ -46,7 +60,17 @@ type DraftInfo = {
|
||||||
|
|
||||||
// Message schemas
|
// Message schemas
|
||||||
|
|
||||||
export type NetworkMessage = PasswordRequest | PasswordResponse;
|
export type NetworkMessage =
|
||||||
|
| PasswordRequest
|
||||||
|
| PasswordResponse
|
||||||
|
| LeaveRequest
|
||||||
|
| ErrorMessage
|
||||||
|
| RoomInfoMessage
|
||||||
|
| JoinMessage
|
||||||
|
| LeaveMessage
|
||||||
|
| RenameMessage
|
||||||
|
| ChatMessage
|
||||||
|
| AckMessage;
|
||||||
|
|
||||||
export interface PasswordRequest {
|
export interface PasswordRequest {
|
||||||
kind: "password-req";
|
kind: "password-req";
|
||||||
|
@ -56,3 +80,46 @@ export interface PasswordResponse {
|
||||||
kind: "password-resp";
|
kind: "password-resp";
|
||||||
password: string;
|
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"
|
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.2.tgz#2d3d48f9c346698fce83a85d7d664e98535df6e7"
|
||||||
integrity sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==
|
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:
|
events@^3.0.0:
|
||||||
version "3.0.0"
|
version "3.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/events/-/events-3.0.0.tgz#9a0a0dfaf62893d92b875b8f2698ca4114973e88"
|
resolved "https://registry.yarnpkg.com/events/-/events-3.0.0.tgz#9a0a0dfaf62893d92b875b8f2698ca4114973e88"
|
||||||
|
|
Loading…
Reference in a new issue