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:
Hamcha 2019-09-06 12:36:11 +00:00 committed by Hamcha
parent f0b8e98327
commit 6f6b00fd05
Signed by: hamcha
GPG Key ID: 44AD3571EB09A39E
23 changed files with 1070 additions and 91 deletions

1
.gitignore vendored
View File

@ -1,6 +1,7 @@
.DS_Store
node_modules
/dist
coverage
# local env files
.env.local

View File

@ -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.

View File

@ -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
}
}
}
};

View File

@ -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
View 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;

View 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
View 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
View 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;

View File

@ -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
View File

@ -0,0 +1,5 @@
export * from "./PeerClient";
export * from "./LocalClient";
export * from "./Client";
export * from "./PeerServer";
export * from "./types";

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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
View 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);
}
}

View 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
View 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
View 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
View File

@ -0,0 +1,3 @@
export * from "./MockDataConnection";
export * from "./MockPeer";
export * from "./MockHelper";

View 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
View File

@ -0,0 +1 @@
export * from "./timeout";

9
src/utils/timeout.ts Normal file
View 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]);
}

View File

@ -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"