├── .npmignore ├── .travis.yml ├── src ├── methods │ ├── methodTypes.ts │ ├── MethodHandlerManager.spec.ts │ └── MethodHandlerManager.ts ├── state │ ├── interfaces │ │ ├── index.ts │ │ ├── controls │ │ │ ├── index.ts │ │ │ ├── IMeta.ts │ │ │ ├── ILabel.ts │ │ │ ├── IScreen.ts │ │ │ ├── IJoystick.ts │ │ │ ├── IInput.ts │ │ │ ├── ITextbox.ts │ │ │ ├── IControl.ts │ │ │ └── IButton.ts │ │ ├── IGroup.ts │ │ ├── IParticipant.ts │ │ └── IScene.ts │ ├── controls │ │ ├── index.ts │ │ ├── Screen.ts │ │ ├── Joystick.ts │ │ ├── Label.ts │ │ ├── Control.spec.ts │ │ ├── Textbox.ts │ │ ├── Control.ts │ │ └── Button.ts │ ├── Group.ts │ ├── StateFactory.ts │ ├── IState.ts │ ├── Scene.ts │ └── State.spec.ts ├── interfaces.ts ├── merge.ts ├── constants.ts ├── index.ts ├── wire │ ├── reconnection.spec.ts │ ├── reconnection.ts │ ├── packets.ts │ └── Socket.spec.ts ├── EndpointDiscovery.ts ├── Requester.ts ├── errors.spec.ts ├── EndpointDiscovery.spec.ts ├── ParticipantClient.ts ├── ClockSync.spec.ts ├── IClient.ts ├── util.ts ├── Client.spec.ts ├── ClockSync.ts ├── GameClient.ts └── Client.ts ├── .editorconfig ├── examples ├── tsconfig.json ├── ready.ts ├── dynamicControls.ts ├── basic.ts ├── joystick.ts ├── updateControl.ts ├── keyboardControls.ts ├── util.ts ├── relay.ts ├── groups.ts └── textbox.ts ├── webpack.config.min.js ├── webpack.config.js ├── doc-postprocess.js ├── .gitignore ├── LICENSE.md ├── TODO.md ├── tsconfig.json ├── tslint.json ├── package.json ├── test └── fixtures │ └── testGame.json ├── README.md └── CHANGELOG.md /.npmignore: -------------------------------------------------------------------------------- 1 | /* 2 | !/lib 3 | !/dist 4 | *.spec.d.ts 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 6 4 | git: 5 | depth: 10 6 | -------------------------------------------------------------------------------- /src/methods/methodTypes.ts: -------------------------------------------------------------------------------- 1 | export type onReadyParams = { 2 | isReady: boolean; 3 | }; 4 | -------------------------------------------------------------------------------- /src/state/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export * from './IGroup'; 2 | export * from './IScene'; 3 | export * from './IParticipant'; 4 | export * from './controls'; 5 | -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | export interface IRawValues { 2 | [key: string]: any; 3 | } 4 | export type JSONPrimitive = boolean | string | number | null; 5 | 6 | export interface IJSON { 7 | [prop: string]: (IJSON | JSONPrimitive) | (IJSON | JSONPrimitive)[]; 8 | } 9 | -------------------------------------------------------------------------------- /src/state/controls/index.ts: -------------------------------------------------------------------------------- 1 | export { Control } from './Control'; 2 | 3 | export { Button } from './Button'; 4 | export { Joystick } from './Joystick'; 5 | export { Label } from './Label'; 6 | export { Screen } from './Screen'; 7 | export { Textbox } from './Textbox'; 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | # Unix-style newlines with a newline ending every file 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | charset = utf-8 10 | indent_style = space 11 | indent_size = 4 12 | -------------------------------------------------------------------------------- /src/merge.ts: -------------------------------------------------------------------------------- 1 | import deepmerge = require('deepmerge'); //tslint:disable-line no-require-imports import-name 2 | /** 3 | * Merges the properties of two objects together, mutating the first object. Similar to lodash's merge. 4 | */ 5 | export function merge(x: T, y: T): T { 6 | return Object.assign(x, deepmerge(x, y)); 7 | } 8 | -------------------------------------------------------------------------------- /examples/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends":"../tsconfig.json", 3 | "include": [ 4 | "./*.ts" 5 | ], 6 | "exclude": [ 7 | "../node_modules", 8 | "../test", 9 | "../dist", 10 | "../lib", 11 | "../src" 12 | ], 13 | "compilerOptions": { 14 | "outDir": "./", 15 | "declaration": false, 16 | "sourceMap": false 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/state/interfaces/controls/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @deprecated etags are no longer used, you can always omit/ignore this 3 | */ 4 | export type ETag = string; 5 | 6 | export * from './IButton'; 7 | export * from './IControl'; 8 | export * from './IInput'; 9 | export * from './IJoystick'; 10 | export * from './ILabel'; 11 | export * from './IScreen'; 12 | export * from './ITextbox'; 13 | export * from './IMeta'; 14 | -------------------------------------------------------------------------------- /webpack.config.min.js: -------------------------------------------------------------------------------- 1 | ('use strict'); 2 | 3 | const webpack = require('webpack'); 4 | const config = require('./webpack.config'); 5 | 6 | config.output.filename = 'interactive.min.js'; 7 | config.plugins = config.plugins.concat([ 8 | new webpack.optimize.UglifyJsPlugin({ 9 | compress: { 10 | warnings: false, 11 | }, 12 | output: { 13 | comments: false, 14 | }, 15 | }), 16 | ]); 17 | 18 | module.exports = config; 19 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import { IGridLayout } from './state/interfaces/controls'; 2 | 3 | /** 4 | * Offers constant information values to use in an application. 5 | */ 6 | export const gridLayoutSizes = Object.freeze([ 7 | { 8 | size: 'large', 9 | width: 80, 10 | height: 20, 11 | }, 12 | { 13 | size: 'medium', 14 | width: 45, 15 | height: 25, 16 | }, 17 | { 18 | size: 'small', 19 | width: 30, 20 | height: 40, 21 | }, 22 | ]); 23 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | ('use strict'); 2 | const path = require('path'); 3 | module.exports = { 4 | entry: './src/index.ts', 5 | devtool: 'source-map', 6 | plugins: [], 7 | output: { 8 | path: path.resolve(__dirname, 'dist'), 9 | filename: 'interactive.js', 10 | library: 'interactive', 11 | libraryTarget: 'umd', 12 | }, 13 | resolve: { 14 | extensions: ['.webpack.js', '.web.js', '.ts', '.js'], 15 | }, 16 | module: { 17 | loaders: [{ test: /\.ts$/, loader: 'awesome-typescript-loader' }], 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { InteractiveSocket } from './wire/Socket'; 2 | export * from './state/interfaces'; 3 | export * from './state/Scene'; 4 | export * from './state/Group'; 5 | export * from './IClient'; 6 | export * from './GameClient'; 7 | export * from './ParticipantClient'; 8 | export * from './constants'; 9 | export * from './errors'; 10 | export * from './util'; 11 | 12 | /** 13 | * This allows you to specify which WebSocket implementation your 14 | * environment is using. You do not need to do this in Browser environments. 15 | * 16 | * @example `Interactive.setWebsocket(require('ws'));` 17 | */ 18 | export function setWebSocket(ws: any) { 19 | InteractiveSocket.WebSocket = ws; 20 | } 21 | -------------------------------------------------------------------------------- /doc-postprocess.js: -------------------------------------------------------------------------------- 1 | ('use strict'); 2 | 3 | // typedoc is kinda stupid and inserts local paths when it encounters a package, 4 | // this replaces the paths and redacts local files. 5 | 6 | const { sync } = require('glob'); 7 | const { readFileSync, writeFileSync } = require('fs'); 8 | 9 | function escapeRegExp(str) { 10 | return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&'); 11 | } 12 | 13 | const pwd = process.cwd(); 14 | const regExp = new RegExp(`Defined in ${escapeRegExp(pwd)}.+node_modules/(.+)`, 'gi'); 15 | sync('docs/**/*.html').forEach(path => { 16 | const contents = readFileSync(path, 'utf8'); 17 | writeFileSync(path, contents.replace(regExp, 'Defined in external package $1')); 18 | }); 19 | -------------------------------------------------------------------------------- /src/state/interfaces/controls/IMeta.ts: -------------------------------------------------------------------------------- 1 | import { IJSON, JSONPrimitive } from '../../../interfaces'; 2 | import { ETag } from './'; 3 | 4 | /** 5 | * An IMeta property is one property within an IMeta map. 6 | * It contains a value and an Etag. 7 | */ 8 | export interface IMetaProperty { 9 | value: IJSON | JSONPrimitive; 10 | /** @deprecated etags are no longer used, you can always omit/ignore this */ 11 | etag?: ETag; 12 | } 13 | 14 | /** 15 | * Meta is a map of custom property names. 16 | * The values can be any valid JSON type. 17 | * 18 | * Meta properties allow you to store custom Metadata on their attached interactive 19 | * state element. 20 | */ 21 | export interface IMeta { 22 | [property: string]: IMetaProperty; 23 | } 24 | -------------------------------------------------------------------------------- /src/wire/reconnection.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import { ExponentialReconnectionPolicy } from './reconnection'; 4 | 5 | // Setting these explicity will mean these tests wont break should we change the defaults 6 | const maxDelay = 20 * 1000; 7 | const baseDelay = 500; 8 | 9 | describe('exponential reconnection', () => { 10 | const policy = new ExponentialReconnectionPolicy(maxDelay, baseDelay); 11 | beforeEach(() => { 12 | policy.reset(); 13 | }); 14 | it('starts with the base delay', () => { 15 | expect(policy.next()).to.equal(baseDelay); 16 | }); 17 | it('exponentially grows', () => { 18 | expect(policy.next()).to.equal(baseDelay); 19 | expect(policy.next()).to.equal(1000); 20 | policy.next(); 21 | expect(policy.next()).to.equal(4000); 22 | }); 23 | it('resets', () => { 24 | expect(policy.next()).to.equal(baseDelay); 25 | policy.reset(); 26 | expect(policy.next()).to.equal(baseDelay); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #### joe made this: http://goel.io/joe 2 | #### node #### 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # node-waf configuration 27 | .lock-wscript 28 | 29 | # Compiled binary addons (http://nodejs.org/api/addons.html) 30 | build/Release 31 | 32 | # Dependency directories 33 | node_modules 34 | jspm_packages 35 | 36 | # Optional npm cache directory 37 | .npm 38 | .vscode 39 | .npmrc 40 | wallaby.js 41 | 42 | # Optional eslint cache 43 | .eslintcache 44 | 45 | # Optional REPL history 46 | .node_repl_history 47 | 48 | /dist 49 | /lib 50 | /dist 51 | 52 | # Prefer to compile these 53 | examples/**/*.js 54 | /yarn.lock 55 | 56 | docs 57 | -------------------------------------------------------------------------------- /src/state/Group.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | import { merge } from '../merge'; 4 | import { IMeta } from './interfaces/controls'; 5 | import { IGroup, IGroupData } from './interfaces/IGroup'; 6 | 7 | /** 8 | * A Group is a collection of participants. 9 | */ 10 | export class Group extends EventEmitter implements IGroup { 11 | public groupID: string; 12 | public sceneID: string; 13 | /** @deprecated etags are no longer used, you can always omit/ignore this */ 14 | public etag: string; 15 | public meta: IMeta = {}; 16 | 17 | constructor(group: IGroupData) { 18 | super(); 19 | merge(this, group); 20 | } 21 | 22 | // TODO: group management, rather than read-only views 23 | 24 | /** 25 | * Updates this group with new data from the server. 26 | */ 27 | public update(data: IGroupData) { 28 | merge(this, data); 29 | this.emit('updated', this); 30 | } 31 | 32 | public destroy(): void { 33 | this.emit('deleted', this); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/EndpointDiscovery.ts: -------------------------------------------------------------------------------- 1 | import { NoInteractiveServersAvailable } from './errors'; 2 | import { IRequester } from './Requester'; 3 | 4 | export interface IInteractiveEndpoint { 5 | address: string; 6 | } 7 | 8 | export class EndpointDiscovery { 9 | constructor(private requester: IRequester) {} 10 | /** 11 | * Retrieves available interactive servers from Mixer's REST API. 12 | * Game Clients should connect to the first one in the list and use 13 | * other servers in the list should a connection attempt to the first 14 | * fail. 15 | */ 16 | public retrieveEndpoints( 17 | endpoint: string = 'https://mixer.com/api/v1/interactive/hosts', 18 | ): Promise { 19 | return this.requester.request(endpoint).then(res => { 20 | if (res.length > 0) { 21 | return res; 22 | } 23 | throw new NoInteractiveServersAvailable( 24 | 'No Interactive servers are available, please try again.', 25 | ); 26 | }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/state/controls/Screen.ts: -------------------------------------------------------------------------------- 1 | import { IScreenInput } from '../interfaces/controls/IInput'; 2 | import { IScreen, IScreenData, MoveEventType } from '../interfaces/controls/IScreen'; 3 | import { Control } from './Control'; 4 | 5 | /** 6 | * Screen can be used to get mouse input 7 | */ 8 | export class Screen extends Control implements IScreen { 9 | /** 10 | * How the control will handle move events 11 | */ 12 | public sendMoveEvents: MoveEventType; 13 | /** 14 | * The throttle rate for input sent 15 | */ 16 | public moveThrottle: number; 17 | /** 18 | * Whether the control sends the mouse down event. 19 | */ 20 | public sendMouseDownEvent: boolean; 21 | /** 22 | * Whether the control sends the mouse up event. 23 | */ 24 | public sendMouseUpEvent: boolean; 25 | 26 | /** 27 | * Sends an input event from a participant to the server for consumption. 28 | */ 29 | public giveInput(input: IScreenInput): Promise { 30 | return this.sendInput(input); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) Microsoft Corporation 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /src/state/controls/Joystick.ts: -------------------------------------------------------------------------------- 1 | import { IJoystickInput } from '../interfaces/controls/IInput'; 2 | import { IJoystick, IJoystickData } from '../interfaces/controls/IJoystick'; 3 | import { Control } from './Control'; 4 | 5 | /** 6 | * Joysticks can be moved by participants and will report their coordinates down to GameClients 7 | */ 8 | export class Joystick extends Control implements IJoystick { 9 | public angle: number; 10 | public intensity: number; 11 | public sampleRate: number; 12 | 13 | /** 14 | * Sets the angle of the direction indicator for this joystick. 15 | */ 16 | public setAngle(angle: number): Promise { 17 | return this.updateAttribute('angle', angle); 18 | } 19 | 20 | /** 21 | * Sets the opacity/strength of the direction indicator for this joystick. 22 | */ 23 | public setIntensity(intensity: number): Promise { 24 | return this.updateAttribute('intensity', intensity); 25 | } 26 | 27 | /** 28 | * Sends an input event from a participant to the server for consumption. 29 | */ 30 | public giveInput(input: IJoystickInput): Promise { 31 | return this.sendInput(input); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/state/interfaces/IGroup.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import { ETag } from './'; 3 | import { IMeta } from './controls/IMeta'; 4 | 5 | export interface IGroupDataArray { 6 | groups: IGroupData[]; 7 | } 8 | 9 | export interface IGroupDeletionParams { 10 | groupID: string; 11 | reassignGroupID: string; 12 | } 13 | 14 | export interface IGroupData { 15 | /** 16 | * The ID of the group. 17 | */ 18 | groupID?: string; 19 | 20 | /** 21 | * The scene the group is currently assigned to. 22 | */ 23 | sceneID?: string; 24 | 25 | /** 26 | * Metadata associated with the group. 27 | */ 28 | meta?: IMeta; 29 | 30 | /** 31 | * @deprecated etags are no longer used, you can always omit/ignore this 32 | */ 33 | etag?: ETag; 34 | } 35 | 36 | export interface IGroup extends EventEmitter, IGroupData { 37 | /** 38 | * Fired when the group is updated with new data from the server. 39 | */ 40 | on(event: 'updated', listener: (group: IGroup) => void): this; 41 | 42 | /** 43 | * Fired when this group is deleted. 44 | */ 45 | on(event: 'deleted', listener: (group: IGroup) => void): this; 46 | on(event: string, listener: Function): this; 47 | } 48 | -------------------------------------------------------------------------------- /examples/ready.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-console */ 2 | import * as WebSocket from 'ws'; 3 | 4 | import { 5 | GameClient, 6 | setWebSocket, 7 | } from '../lib'; 8 | 9 | if (process.argv.length < 4) { 10 | console.log('Usage gameClient.exe '); 11 | process.exit(); 12 | } 13 | // We need to tell the interactive client what type of websocket we are using. 14 | setWebSocket(WebSocket); 15 | 16 | // As we're on the Streamer's side we need a "GameClient" instance 17 | const client = new GameClient(); 18 | 19 | // Log when we're connected to interactive 20 | client.on('open', () => console.log('Connected to interactive')); 21 | 22 | // These can be un-commented to see the raw JSON messages under the hood 23 | client.on('message', (err: any) => console.log('<<<', err)); 24 | client.on('send', (err: any) => console.log('>>>', err)); 25 | // client.on('error', (err: any) => console.log(err)); 26 | 27 | // Now we open the connection passing in our authentication details and an experienceId. 28 | client.open({ 29 | authToken: process.argv[2], 30 | versionId: parseInt(process.argv[3], 10), 31 | }).then(() => { 32 | // Controls don't appear unless we tell Interactive that we are ready! 33 | return client.ready(true); 34 | }); 35 | 36 | /* tslint:enable:no-console */ 37 | -------------------------------------------------------------------------------- /src/Requester.ts: -------------------------------------------------------------------------------- 1 | import * as http from 'http'; 2 | import * as https from 'https'; 3 | import { HTTPError, TimeoutError } from './errors'; 4 | 5 | export interface IRequester { 6 | request(url: string): Promise; 7 | } 8 | 9 | export class Requester implements IRequester { 10 | public request(url: string): Promise { 11 | return new Promise((resolve, reject) => { 12 | const req = this.getRequestFn(url)(url, res => { 13 | if (res.statusCode !== 200) { 14 | reject(new HTTPError(res.statusCode, res.statusMessage, res)); 15 | } 16 | let body = ''; 17 | res.on('data', str => (body = body + str.toString())); 18 | res.on('end', () => resolve(JSON.parse(body))); 19 | }); 20 | req.setTimeout(15 * 1000); //tslint:disable-line 21 | req.on('error', err => reject(err)); 22 | req.on('timeout', () => reject(new TimeoutError('Request timed out'))); 23 | }); 24 | } 25 | private getRequestFn(url: string) { 26 | //tslint:disable no-http-string 27 | if (url.startsWith('http:')) { 28 | return http.get; 29 | } 30 | //tslint:enable no-http-string 31 | return https.get; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/errors.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import { InteractiveError } from './errors'; 4 | const message = 'test'; 5 | describe('errors', () => { 6 | it('creates expected error from socket message', () => { 7 | const err = new InteractiveError.InvalidPayload(message); 8 | expect(err).to.be.an.instanceof(InteractiveError.InvalidPayload); 9 | }); 10 | it('performs an error lookup', () => { 11 | const err = InteractiveError.fromSocketMessage({ code: 4000, message }); 12 | expect(err).to.be.an.instanceof(InteractiveError.InvalidPayload); 13 | }); 14 | it('maintains referential integrity', () => { 15 | Object.keys(InteractiveError.errors).forEach(codeStr => { 16 | const code = parseInt(codeStr, 10); 17 | const err = InteractiveError.fromSocketMessage({ code, message }); 18 | expect(err).to.be.an.instanceOf(InteractiveError.errors[code]); 19 | expect(err.code).to.equal(code); 20 | }); 21 | }); 22 | 23 | it('handles unknown error codes', () => { 24 | const code = 5000; 25 | const err = InteractiveError.fromSocketMessage({ code, message }); 26 | expect(err).to.be.an.instanceOf(InteractiveError.Base); 27 | expect(err.code).to.equal(code); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # Planned Features 2 | - [ ] GZIP Compression 3 | - [X] Endpoint discovery 4 | - [ ] Complete group management 5 | - [ ] Dynamic scene creation 6 | - [ ] Server side throttle settings 7 | 8 | # Protocol Support 9 | A list of all protocol level methods and this client's support of them. Used to track protocol level support. 10 | ## Methods 11 | ### GameClient Methods 12 | - [X] ready 13 | - [ ] getMemoryStats 14 | 15 | - [ ] getAllParticipants 16 | - [ ] getActiveParticipants 17 | - [x] updateParticipants 18 | 19 | - [x] createGroups 20 | - [x] updateGroups 21 | 22 | - [x] createScenes 23 | - [ ] deleteScene 24 | - [x] updateScenes 25 | 26 | - [X] createControls 27 | - [X] deleteControls 28 | - [X] updateControls 29 | 30 | - [X] capture 31 | 32 | ### Shared Methods 33 | - [X] getTime - on another branch 34 | - [X] getScenes 35 | - [X] setCompression 36 | 37 | ### Paticipant Methods 38 | - [X] giveInput 39 | - [X] onReady 40 | 41 | ### Events 42 | - [ ] issueMemoryWarning 43 | - [X] onReady 44 | - [X] onParticipantJoin 45 | - [X] onParticipantLeave 46 | - [X] onParticipantUpdate 47 | - [X] onSceneCreate 48 | - [X] onSceneDelete 49 | - [X] onSceneUpdate 50 | - [X] onControlCreate 51 | - [X] onControlDelete 52 | - [X] onControlUpdate 53 | - [X] onGroupCreate 54 | - [X] onGroupUpdate 55 | - [X] onGroupDelete 56 | 57 | -------------------------------------------------------------------------------- /src/wire/reconnection.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A ReconnectionPolicy describes how long to wait before attempting to 3 | * reconnect to the websocket if the connection drops. 4 | */ 5 | export interface IReconnectionPolicy { 6 | /** 7 | * next provides the next reconnect delay, in ms. 8 | */ 9 | next(): number; 10 | 11 | /** 12 | * Resets an internal counter of reconnection attempts, should be called on a successful connection. 13 | */ 14 | reset(): void; 15 | } 16 | 17 | /** 18 | * The ExponentialReconnectionPolicy is a policy which reconnects the socket 19 | * on a delay specified by the equation min(maxDelay, attempts^2 * baseDelay). 20 | */ 21 | export class ExponentialReconnectionPolicy implements IReconnectionPolicy { 22 | private retries: number = 0; 23 | 24 | /** 25 | * @param {Number} maxDelay maximum duration to wait between reconnection attempts 26 | * @param {Number} baseDelay delay, in milliseconds, to use in 27 | */ 28 | constructor(public maxDelay: number = 20 * 1000, public baseDelay: number = 500) {} 29 | 30 | public next(): number { 31 | // tslint:disable-next-line:no-bitwise 32 | return Math.min(this.maxDelay, (1 << this.retries++) * this.baseDelay); 33 | } 34 | 35 | public reset() { 36 | this.retries = 0; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/methods/MethodHandlerManager.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import { InteractiveError } from '../errors'; 4 | import { IRawValues } from '../interfaces'; 5 | import { Method } from '../wire/packets'; 6 | import { MethodHandlerManager } from './MethodHandlerManager'; 7 | 8 | describe('method handler', () => { 9 | let handler: MethodHandlerManager; 10 | beforeEach(() => { 11 | handler = new MethodHandlerManager(); 12 | }); 13 | 14 | it('handles a registered method', () => { 15 | handler.addHandler('hello', (method: Method) => { 16 | return method.reply({ bar: 'foo' }, null); 17 | }); 18 | 19 | const reply = handler.handle(new Method('hello', { foo: 'bar' })); 20 | expect(reply).to.exist; 21 | if (reply) { 22 | expect(reply.result).to.deep.equal({ bar: 'foo' }); 23 | } 24 | }); 25 | 26 | it('throws an error if an undiscardable method has no handler', () => { 27 | expect(() => handler.handle(new Method('hello', { foo: 'bar' }, false))).to.throw( 28 | InteractiveError.UnknownMethodName, 29 | ); 30 | }); 31 | 32 | it('does throws an error if a discardable method has no handler', () => { 33 | handler.handle(new Method('hello', { foo: 'bar' }, true)); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "experimentalDecorators": true, 5 | "emitDecoratorMetadata": true, 6 | "moduleResolution": "node", 7 | "noImplicitAny": true, 8 | "noImplicitReturns": true, 9 | "noFallthroughCasesInSwitch": true, 10 | "noUnusedLocals": true, 11 | "noUnusedParameters": true, 12 | "strictNullChecks": false, 13 | "suppressImplicitAnyIndexErrors": true, 14 | "module": "commonjs", 15 | "sourceMap": true, 16 | "target": "es5", 17 | "outDir": "lib", 18 | "lib": [ 19 | "es5", 20 | "dom", 21 | "es2015.collection", 22 | "es2015.promise", 23 | "es2015.iterable", 24 | "es2015.core" 25 | ], 26 | "types": [ 27 | "node", 28 | "mocha", 29 | "sinon", 30 | "sinon-chai" 31 | ], 32 | "typeRoots": [ 33 | "node_modules/@types" 34 | ], 35 | "baseUrl": ".", 36 | "paths": { 37 | "*": [ 38 | "./types/*" 39 | ] 40 | }, 41 | "skipLibCheck": true 42 | }, 43 | "exclude": [ 44 | "node_modules", 45 | "test", 46 | "dist", 47 | "lib", 48 | "examples", 49 | "**/*.spec.ts" 50 | ] 51 | } 52 | -------------------------------------------------------------------------------- /src/EndpointDiscovery.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, use } from 'chai'; 2 | import * as sinon from 'sinon'; 3 | 4 | // tslint:disable-next-line:no-require-imports import-name 5 | import chaip = require('chai-as-promised'); 6 | use(chaip); 7 | 8 | import { EndpointDiscovery } from './EndpointDiscovery'; 9 | import { NoInteractiveServersAvailable } from './errors'; 10 | import { Requester } from './Requester'; 11 | 12 | const servers = [ 13 | { 14 | address: 'ws://foo.bar1/gameClient', 15 | }, 16 | { 17 | address: 'ws://foo.bar2/gameClient', 18 | }, 19 | { 20 | address: 'ws://foo.bar3/gameClient', 21 | }, 22 | ]; 23 | 24 | describe('endpoint discovery', () => { 25 | const requester = new Requester(); 26 | let stub: sinon.SinonStub; 27 | const discovery = new EndpointDiscovery(requester); 28 | beforeEach(() => { 29 | stub = sinon.stub(requester, 'request'); 30 | }); 31 | afterEach(() => { 32 | stub.restore(); 33 | }); 34 | it('resolves with a list of endpoints', () => { 35 | stub.resolves(servers); 36 | return expect(discovery.retrieveEndpoints()).to.eventually.equal(servers); 37 | }); 38 | it('rejects with a NoInteractiveServersAvailable if the response contains no servers', () => { 39 | stub.resolves([]); 40 | return expect(discovery.retrieveEndpoints()).to.be.rejectedWith( 41 | NoInteractiveServersAvailable, 42 | ); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/state/StateFactory.ts: -------------------------------------------------------------------------------- 1 | import { Button, Control, Joystick, Label, Textbox } from './controls'; 2 | import { IControlData } from './interfaces/controls'; 3 | 4 | import { IClient } from '../IClient'; 5 | import { ISceneData } from './interfaces/IScene'; 6 | import { Scene } from './Scene'; 7 | /** 8 | * The StateFactory creates the apropriate instance of a class for a given socket message. 9 | */ 10 | export class StateFactory { 11 | private client: IClient; 12 | 13 | public setClient(client: IClient) { 14 | this.client = client; 15 | } 16 | 17 | public createControl( 18 | controlKind: string, 19 | values: T, 20 | scene: Scene, 21 | ): Control { 22 | let control: Control; 23 | 24 | switch (controlKind) { 25 | case 'button': 26 | control = new Button(values); 27 | break; 28 | case 'joystick': 29 | control = new Joystick(values); 30 | break; 31 | case 'label': 32 | control = new Label(values); 33 | break; 34 | case 'textbox': 35 | control = new Textbox(values); 36 | break; 37 | default: 38 | control = new Control(values); 39 | } 40 | control.setClient(this.client); 41 | control.setScene(scene); 42 | return control; 43 | } 44 | 45 | public createScene(values: ISceneData): Scene { 46 | const scene = new Scene(values); 47 | scene.setClient(this.client); 48 | return scene; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/ParticipantClient.ts: -------------------------------------------------------------------------------- 1 | import { Client, ClientType } from './Client'; 2 | import { IJSON } from './interfaces'; 3 | import { IInput } from './state/interfaces/controls'; 4 | 5 | export interface IParticipantOptions { 6 | /** 7 | * An access key for the Mixer.com session 8 | */ 9 | key: string; 10 | /** 11 | * A url for the Interactive session you'd like to join. 12 | * This should be retrieved from https://mixer.com/api/v1/interactive/{channelId} 13 | * @example wss://interactive1-dal.mixer.com/participant?channel= 14 | */ 15 | url: string; 16 | /** 17 | * Any extra query parameters you'd like to include on the connection, usually used for debugging. 18 | */ 19 | extraParams?: IJSON; 20 | 21 | /** 22 | * Optional intercept function that can be run before socket reconnections. 23 | */ 24 | reconnectChecker?: () => Promise; 25 | } 26 | 27 | export class ParticipantClient extends Client { 28 | constructor() { 29 | super(ClientType.Participant); 30 | } 31 | 32 | public open(options: IParticipantOptions): Promise { 33 | return super.open({ 34 | urls: [options.url], 35 | reconnectChecker: options.reconnectChecker, 36 | queryParams: { 37 | 'x-protocol-version': '2.0', 38 | key: options.key, 39 | ...options.extraParams, 40 | }, 41 | }); 42 | } 43 | /** 44 | * Sends an input event to the Interactive Server. This should only be called 45 | * by controls. 46 | */ 47 | public giveInput(input: T): Promise { 48 | return this.execute('giveInput', input, false); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/ClockSync.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as sinon from 'sinon'; 3 | 4 | import { ClockSync } from './ClockSync'; 5 | 6 | describe('clock syncer', () => { 7 | let syncer: ClockSync; 8 | let clock: sinon.SinonFakeTimers; 9 | 10 | beforeEach(() => { 11 | clock = sinon.useFakeTimers(); 12 | }); 13 | 14 | it('samples the clock delay the specified number of times before emitting a delta', done => { 15 | const spy = sinon.stub(); 16 | spy.returns(Promise.resolve(1)); 17 | const sampleSize = 3; 18 | syncer = new ClockSync({ 19 | sampleFunc: spy, 20 | sampleDelay: 1, 21 | sampleSize, 22 | }); 23 | syncer.start(); 24 | clock.tick(sampleSize - 1); 25 | syncer.on('delta', () => { 26 | expect(spy).to.have.been.calledThrice; 27 | done(); 28 | }); 29 | }); 30 | 31 | it('emits deltas', done => { 32 | const artificialDifference = 10 * 1000; 33 | const tickInterval = 1; 34 | 35 | const spy = sinon.stub(); 36 | 37 | spy.returns(Promise.resolve(clock.now + artificialDifference)); 38 | const sampleSize = 3; 39 | syncer = new ClockSync({ 40 | sampleFunc: spy, 41 | sampleDelay: 1, 42 | sampleSize, 43 | }); 44 | syncer.on('delta', (delta: number) => { 45 | expect(delta).to.equal(artificialDifference - tickInterval * 2); 46 | done(); 47 | }); 48 | syncer.start(); 49 | clock.tick(sampleSize - 1); 50 | }); 51 | 52 | afterEach(() => { 53 | if (syncer) { 54 | syncer.stop(); 55 | syncer = null; 56 | } 57 | clock.restore(); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/state/interfaces/IParticipant.ts: -------------------------------------------------------------------------------- 1 | import { ETag } from './controls'; 2 | import { IMeta } from './controls/IMeta'; 3 | 4 | export interface IParticipantQuery { 5 | userIDs: number[]; 6 | } 7 | 8 | export interface IParticipantQueryResult { 9 | users: Record; 10 | } 11 | 12 | export interface IParticipantArray { 13 | participants: IParticipant[]; 14 | } 15 | 16 | export interface IParticipant { 17 | /** 18 | * a unique string identifier for the user in this session. It’s 19 | * used for all participant identification internally, and 20 | * should be viewed as an opaque token. 21 | */ 22 | sessionID: string; 23 | /** 24 | * This participant's Mixer UserId. Will be set to 0 if anonymous is true. 25 | */ 26 | userID?: number; 27 | /** 28 | * This participant's Mixer Username. 29 | */ 30 | username?: string; 31 | /** 32 | * This participant's Mixer level. 33 | */ 34 | level?: number; 35 | /** 36 | * The unix milliseconds timestamp when the user last 37 | * interacted with the controls. 38 | */ 39 | lastInputAt?: number; 40 | /** 41 | * The unix milliseconds timestamp when the user connected. 42 | */ 43 | connectedAt?: number; 44 | /** 45 | * The disabled state of this participant, when disabled they cannot provide input. 46 | */ 47 | disabled?: boolean; 48 | /** 49 | * The ID of the Group this user belongs to. 50 | */ 51 | groupID?: string; 52 | /** 53 | * The Channel Groups the participant is in. 54 | */ 55 | channelGroups: string[]; 56 | meta?: IMeta; 57 | 58 | /** 59 | * True if this user has not signed in and is an anonymous user. 60 | */ 61 | anonymous: boolean; 62 | 63 | /** 64 | * @deprecated etags are no longer used, you can always omit/ignore this. 65 | */ 66 | etag?: ETag; 67 | } 68 | -------------------------------------------------------------------------------- /src/IClient.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import { ClientType } from './Client'; 3 | 4 | import { InteractiveError } from './errors'; 5 | import { 6 | IControl, 7 | IGroupDataArray, 8 | IGroupDeletionParams, 9 | IInput, 10 | IParticipantArray, 11 | ISceneControlDeletion, 12 | ISceneData, 13 | ISceneDataArray, 14 | ISceneDeletionParams, 15 | } from './state/interfaces'; 16 | import { IState } from './state/IState'; 17 | 18 | export interface IClient extends EventEmitter { 19 | clientType: ClientType; 20 | state: IState; 21 | execute(method: string, params: T, discard: boolean): Promise; 22 | ready(isReady: boolean): Promise; 23 | 24 | createControls(controls: ISceneData): Promise; 25 | createGroups(groups: IGroupDataArray): Promise; 26 | createScene(scene: ISceneData): Promise; 27 | createScenes(scenes: ISceneDataArray): Promise; 28 | updateControls(controls: ISceneData): Promise; 29 | updateGroups(groups: IGroupDataArray): Promise; 30 | deleteControls(controls: ISceneControlDeletion): Promise; 31 | deleteGroup(data: IGroupDeletionParams): Promise; 32 | deleteScene(data: ISceneDeletionParams): Promise; 33 | updateScenes(scenes: ISceneDataArray): Promise; 34 | updateParticipants(participants: IParticipantArray): Promise; 35 | giveInput(_: T): Promise; 36 | 37 | getTime(): Promise; 38 | 39 | on(event: 'open', listener: () => void): this; 40 | on(event: 'send', listener: (payload: string) => void): this; 41 | on(event: 'message', listener: (payload: string) => void): this; 42 | on(event: 'error', listener: (err: InteractiveError.Base) => void): this; 43 | on(event: 'hello', listener: () => void): this; 44 | on(event: string, listener: Function): this; 45 | } 46 | -------------------------------------------------------------------------------- /src/state/interfaces/controls/ILabel.ts: -------------------------------------------------------------------------------- 1 | import { IControl, IControlData, IControlUpdate } from './IControl'; 2 | 3 | /** 4 | * Extends the regular control data with additional properties for Label 5 | */ 6 | export interface ILabelData extends IControlData { 7 | /** 8 | * The text displayed on the label. 9 | */ 10 | text?: string; 11 | /** 12 | * The color of the text. 13 | */ 14 | textColor?: string; 15 | /** 16 | * The size of the text. 17 | */ 18 | textSize?: string; 19 | /** 20 | * Whether the text is underlined. 21 | */ 22 | underline?: boolean; 23 | /** 24 | * Whether the text is bold. 25 | */ 26 | bold?: boolean; 27 | /** 28 | * Whether the text is italicized. 29 | */ 30 | italic?: boolean; 31 | } 32 | 33 | /** 34 | * Represents updatable components of a label which developers can update 35 | * from game clients. 36 | */ 37 | export interface ILabelUpdate extends IControlUpdate { 38 | /** 39 | * Will update the text of this label. 40 | */ 41 | text?: string; 42 | /** 43 | * Will update the text color. 44 | */ 45 | textColor?: string; 46 | /** 47 | * Will update the text size. 48 | */ 49 | textSize?: string; 50 | /** 51 | * Will update if the text is underlined or not. 52 | */ 53 | underline?: boolean; 54 | /** 55 | * Will update if the text is bold or not. 56 | */ 57 | bold?: boolean; 58 | /** 59 | * Will update if the text is itlaic or not. 60 | */ 61 | italic?: boolean; 62 | } 63 | 64 | export interface ILabel extends IControl, ILabelData { 65 | text: string; 66 | textSize: string; 67 | textColor: string; 68 | underline: boolean; 69 | bold: boolean; 70 | italic: boolean; 71 | // GameClient 72 | setText(text: string): Promise; 73 | setTextSize(textSize: string): Promise; 74 | setTextColor(textColor: string): Promise; 75 | update(changedData: ILabelUpdate): Promise; 76 | } 77 | -------------------------------------------------------------------------------- /src/state/controls/Label.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ILabel, 3 | ILabelData, 4 | ILabelUpdate, 5 | } from '../interfaces/controls/ILabel'; 6 | import { Control } from './Control'; 7 | 8 | /** 9 | * Label can be used to title and group different controls. 10 | */ 11 | export class Label extends Control implements ILabel { 12 | /** 13 | * The text displayed on a label, presented to the participants. 14 | * Set this value using [setText]{@link Button.setText} 15 | */ 16 | public text: string; 17 | 18 | /** 19 | * The size of the text on the label, presented to the participants. 20 | * Set this value using [setTextSize]{@link Button.setTextSize} 21 | */ 22 | public textSize: string; 23 | 24 | /** 25 | * The color of the text on the label, presented to the participants. 26 | * Set this value using [setTextColor]{@link Button.setTextColor} 27 | */ 28 | public textColor: string; 29 | 30 | /** 31 | * Underlines the text on the label, presented to the participants. 32 | */ 33 | public underline: boolean; 34 | 35 | /** 36 | * Bolds the text on the label, presented to the participants. 37 | */ 38 | public bold: boolean; 39 | 40 | /** 41 | * Italicize to text on the label, presented ot the participants. 42 | */ 43 | public italic: boolean; 44 | 45 | /** 46 | * Sets a new text value for this label. 47 | */ 48 | public setText(text: string): Promise { 49 | return this.updateAttribute('text', text); 50 | } 51 | 52 | /** 53 | * Sets a progress value for this label. 54 | * A decimalized percentage (0.0 - 1.0) 55 | */ 56 | public setTextSize(textSize: string): Promise { 57 | return this.updateAttribute('textSize', textSize); 58 | } 59 | 60 | /** 61 | * Sets a new text color for this label. 62 | */ 63 | public setTextColor(textColor: string): Promise { 64 | return this.updateAttribute('textColor', textColor); 65 | } 66 | 67 | /** 68 | * Update this label on the server. 69 | */ 70 | public update(controlUpdate: ILabelUpdate): Promise { 71 | // Clone to prevent mutations 72 | // XXX: Typescript 2.4 is strict, let the compiler be clever. 73 | const changedData = Object.assign({}, controlUpdate ); 74 | return super.update(changedData); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/state/interfaces/controls/IScreen.ts: -------------------------------------------------------------------------------- 1 | import { IParticipant } from '../IParticipant'; 2 | import { IControl, IControlData, IControlUpdate } from './IControl'; 3 | import { IInputEvent, IScreenInput } from './IInput'; 4 | 5 | export declare type MoveEventType = 'always' | 'mousedown' | 'never'; 6 | /** 7 | * Extends the regular control data with additional properties for Textbox 8 | */ 9 | export interface IScreenData extends IControlData { 10 | /** 11 | * How the control will handle move events 12 | */ 13 | sendMoveEvents?: MoveEventType; 14 | /** 15 | * The throttle rate for input sent 16 | */ 17 | moveThrottle?: number; 18 | /** 19 | * Whether the control sends the mouse down event. 20 | */ 21 | sendMouseDownEvent?: boolean; 22 | /** 23 | * Whether the control sends the mouse up event. 24 | */ 25 | sendMouseUpEvent?: boolean; 26 | } 27 | 28 | /** 29 | * Represents updatable components of a scren control which developers can 30 | * update from game clients. 31 | */ 32 | export interface IScreenUpdate extends IControlUpdate { 33 | /** 34 | * How the control will handle move events 35 | */ 36 | sendMoveEvents?: MoveEventType; 37 | /** 38 | * The throttle rate for input sent 39 | */ 40 | moveThrottle?: number; 41 | /** 42 | * Whether the control sends the mouse down event. 43 | */ 44 | sendMouseDownEvent?: boolean; 45 | /** 46 | * Whether the control sends the mouse up event. 47 | */ 48 | sendMouseUpEvent?: boolean; 49 | } 50 | 51 | export interface IScreen extends IControl, IScreenData { 52 | sendMoveEvents: MoveEventType; 53 | moveThrottle: number; 54 | sendMouseDownEvent: boolean; 55 | sendMouseUpEvent: boolean; 56 | // GameClient 57 | update(changedData: IScreenUpdate): Promise; 58 | 59 | giveInput(input: IScreenInput): Promise; 60 | 61 | /** 62 | * Fired when a participant moves cursor. 63 | */ 64 | on( 65 | event: 'move', 66 | listener: (inputEvent: IInputEvent, participant: IParticipant) => void, 67 | ): this; 68 | /** 69 | * Fired when a participant presses this button with their mouse. 70 | */ 71 | on( 72 | event: 'mousedown', 73 | listener: (inputEvent: IInputEvent, participant: IParticipant) => void, 74 | ): this; 75 | on(event: string, listener: Function): this; 76 | } 77 | -------------------------------------------------------------------------------- /src/state/controls/Control.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, use } from 'chai'; 2 | import * as sinon from 'sinon'; 3 | 4 | import { Client, ClientType } from '../../Client'; 5 | import { IButtonUpdate } from '../interfaces/controls'; 6 | import { Scene } from '../Scene'; 7 | import { Button } from './'; 8 | 9 | // tslint:disable-next-line:no-require-imports no-var-requires 10 | use(require('sinon-chai')); 11 | 12 | const buttonData = { 13 | controlID: '0', 14 | }; 15 | 16 | describe('control', () => { 17 | let control: Button; 18 | let mockClient: Client; 19 | let scene; 20 | 21 | before(() => { 22 | mockClient = new Client(ClientType.GameClient); 23 | }); 24 | 25 | beforeEach(() => { 26 | scene = new Scene({ sceneID: 'default', controls: [] }); 27 | control = new Button(buttonData); 28 | control.setScene(scene); 29 | control.setClient(mockClient); 30 | }); 31 | 32 | it('lets you update attributes', () => { 33 | const buttonDiff = { 34 | text: 'bar', 35 | }; 36 | const updatedButton = Object.assign({}, buttonData, buttonDiff); 37 | const stub = sinon.stub(mockClient, 'updateControls'); 38 | control.setText('bar'); 39 | expect(stub).to.be.calledWith({ 40 | sceneID: 'default', 41 | controls: [updatedButton], 42 | }); 43 | stub.restore(); 44 | }); 45 | 46 | it('lets you update cost', () => { 47 | const buttonDiff = { 48 | cost: 200, 49 | }; 50 | const updatedButton = Object.assign({}, buttonData, buttonDiff); 51 | const stub = sinon.stub(mockClient, 'updateControls'); 52 | control.setCost(buttonDiff.cost); 53 | expect(stub).to.be.calledWith({ 54 | sceneID: 'default', 55 | controls: [updatedButton], 56 | }); 57 | stub.restore(); 58 | }); 59 | 60 | it('allows batch updates', () => { 61 | const buttonDiff: IButtonUpdate = { 62 | cost: 200, 63 | text: 'foobar', 64 | }; 65 | const updatedButton = { 66 | controlID: buttonData.controlID, 67 | ...buttonDiff, 68 | }; 69 | const stub = sinon.stub(mockClient, 'updateControls'); 70 | control.update(buttonDiff); 71 | expect(stub).to.be.calledWith({ 72 | sceneID: 'default', 73 | controls: [updatedButton], 74 | }); 75 | stub.restore(); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /src/state/interfaces/controls/IJoystick.ts: -------------------------------------------------------------------------------- 1 | import { IParticipant } from '../'; 2 | import { IControl, IControlData, IControlUpdate } from './IControl'; 3 | import { IInputEvent, IJoystickInput } from './IInput'; 4 | 5 | /** 6 | * Extends the regular control data with additional properties for Joysticks 7 | */ 8 | export interface IJoystickData extends IControlData { 9 | /** 10 | * The angle of the Joysticks direction indicator. 11 | * In radians 0 - 2π. 12 | */ 13 | angle?: number; 14 | /** 15 | * Controls the strength/opacity of the direction indicator. 16 | */ 17 | intensity?: number; 18 | /** 19 | * The requested sample rate for this joystick, the client should send 20 | * coordinate updates at this rate. 21 | * 22 | * In milliseconds. 23 | */ 24 | sampleRate?: number; 25 | } 26 | 27 | /** 28 | * Represents updatable components of a joystick which developers can update 29 | * from game clients. 30 | */ 31 | export interface IJoystickUpdate extends IControlUpdate { 32 | /** 33 | * Updates the angle of the Joysticks direction indicator. 34 | * In radians 0 - 2π. 35 | */ 36 | angle?: number; 37 | /** 38 | * updates the strength/opacity of the direction indicator. 39 | */ 40 | intensity?: number; 41 | /** 42 | * Updates the sampleRate of this joystick 43 | * 44 | * In milliseconds. 45 | */ 46 | sampleRate?: number; 47 | } 48 | 49 | /** 50 | * A joysticks coordinates. 51 | * 52 | * Where 1,1 is the bottom right and -1,-1 is the top left. 53 | */ 54 | export interface IJoystickCoords { 55 | x: number; 56 | y: number; 57 | } 58 | 59 | export interface IJoystick extends IControl, IJoystickData { 60 | angle: number; 61 | intensity: number; 62 | sampleRate: number; 63 | 64 | /** 65 | * Sets the angle of the direction indicator for this joystick. 66 | */ 67 | setAngle(angle: number): Promise; 68 | /** 69 | * Sets the opacity/strength of the direction indicator for this joystick. 70 | */ 71 | setIntensity(intensity: number): Promise; 72 | 73 | /** 74 | * Updates the joystick with the supplied joystick parameters 75 | */ 76 | update(controlUpdate: IJoystickUpdate): Promise; 77 | 78 | giveInput(input: IJoystickInput): Promise; 79 | 80 | /** 81 | * Fired when a participant moves this joystick. 82 | */ 83 | on( 84 | event: 'move', 85 | listener: (inputEvent: IInputEvent, participant: IParticipant) => void, 86 | ): this; 87 | on(event: string, listener: Function): this; 88 | } 89 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint-microsoft-contrib", 3 | "rulesDirectory": [ 4 | "node_modules/tslint-microsoft-contrib" 5 | ], 6 | "rules": { 7 | "align": [true], 8 | "no-multiline-string": false, 9 | "arrow-parens": [true, "ban-single-arg-parens"], 10 | "max-file-line-count": [true, 600], 11 | "jsdoc-format": false, 12 | //"cyclomatic-complexity": [true, 20], 13 | "space-before-function-paren": [true, "never"], 14 | 15 | // Basic w/ types 16 | "no-for-in-array": false, // These are disables as `--type-check` causes a crash with the angular compiler. 17 | "restrict-plus-operands": false, 18 | 19 | // Microsoft 20 | "no-relative-imports": false, 21 | "mocha-no-side-effect-code": false, 22 | "missing-jsdoc": false, 23 | "export-name": false, 24 | "no-any": false, 25 | "no-increment-decrement": false, 26 | "trailing-comma": [true, {"multiline": "always", "singleline": "never"}], 27 | "no-typeof-undefined": false, 28 | "underscore-consistent-invocation": false, 29 | "no-backbone-get-set-outside-model": false, 30 | "no-single-line-block-comment": false, 31 | "function-name": [true, { 32 | "method-regex": "^[a-z][\\w\\d]+$", 33 | "private-method-regex": "^[a-z][\\w\\d]+$", 34 | "static-method-regex": "^[a-z][\\w\\d]+$", 35 | "function-regex": "^[a-z][\\w\\d]+$" 36 | }], 37 | "typedef-whitespace": [ 38 | true, 39 | { 40 | "call-signature": "nospace", 41 | "index-signature": "nospace", 42 | "parameter": "nospace", 43 | "property-declaration": "nospace", 44 | "variable-declaration": "nospace" 45 | }, 46 | { 47 | "call-signature": "onespace", 48 | "index-signature": "onespace", 49 | "parameter": "onespace", 50 | "property-declaration": "onespace", 51 | "variable-declaration": "onespace" 52 | } 53 | ], 54 | "no-stateless-class": false, 55 | "insecure-random": false, 56 | "variable-name": [true, "ban-keywords", "check-format", "allow-leading-underscore"], // for unused params 57 | 58 | "no-suspicious-comment": false, // For the duration of pre-release development these will be fine, rm and fix on before release. 59 | "no-string-literal": false, 60 | "no-string-throw": true, 61 | "no-empty-line-after-opening-brace": true, 62 | "no-function-expression": true 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /examples/dynamicControls.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-console */ 2 | import * as WebSocket from 'ws'; 3 | 4 | import * as faker from 'faker'; 5 | 6 | import { 7 | GameClient, 8 | IControlData, 9 | IParticipant, 10 | setWebSocket, 11 | } from '../lib'; 12 | 13 | import { makeControls, updateControls } from './util'; 14 | 15 | if (process.argv.length < 4) { 16 | console.log('Usage gameClient.exe '); 17 | process.exit(); 18 | } 19 | // We need to tell the interactive client what type of websocket we are using. 20 | setWebSocket(WebSocket); 21 | 22 | // As we're on the Streamer's side we need a "GameClient" instance 23 | const client = new GameClient(); 24 | 25 | // These can be un-commented to see the raw JSON messages under the hood 26 | // client.on('message', (err: any) => console.log('<<<', err)); 27 | // client.on('send', (err: any) => console.log('>>>', err)); 28 | // client.on('error', (err: any) => console.log(err)); 29 | 30 | const delayTime = 1000; 31 | let controls: IControlData[] = []; 32 | 33 | /* Loop creates 5 controls and adds them to the default scene. 34 | * It then waits delayTime milliseconds and then deletes them, 35 | * before calling itself again. 36 | */ 37 | function loop() { 38 | const scene = client.state.getScene('default'); 39 | scene.updateControls(updateControls(controls, () => faker.name.firstName())); 40 | } 41 | 42 | let loopInterval: any; 43 | 44 | // Log when we're connected to interactive and setup your game! 45 | client.on('open', () => { 46 | console.log('Connected to Interactive!'); 47 | /* Pull in the state stored on the server 48 | * then call ready so our controls show up. 49 | * then call loop() to begin our loop. 50 | */ 51 | clearInterval(loopInterval); 52 | client.synchronizeState() 53 | .then(() => { 54 | const scene = client.state.getScene('default'); 55 | scene.deleteAllControls(); 56 | controls = makeControls(8, () => faker.name.firstName()); 57 | scene.createControls(controls); 58 | }) 59 | .then(() => client.ready(true)) 60 | .then(() => { 61 | loopInterval = setInterval(loop, delayTime); 62 | }); 63 | }); 64 | 65 | // Now we open the connection passing in our authentication details and an experienceId. 66 | client.open({ 67 | authToken: process.argv[2], 68 | versionId: parseInt(process.argv[3], 10), 69 | }); 70 | 71 | client.state.on('participantJoin', (participant: IParticipant ) => { 72 | console.log(`${participant.username}(${participant.sessionID}) Joined`); 73 | }); 74 | client.state.on('participantLeave', (participantSessionID: string, participant: IParticipant ) => { 75 | console.log(`${participant.username}(${participantSessionID}) Left`); 76 | }); 77 | /* tslint:enable:no-console */ 78 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import { TimeoutError } from './errors'; 3 | 4 | /** 5 | * Returns a promise that's resolved when an event is emitted on the 6 | * EventEmitter. 7 | * @param {EventEmitter} emitter 8 | * @param {string} event 9 | * @para {number} timeout used to prevent memory leaks 10 | * @return {Promise} 11 | */ 12 | export function resolveOn( 13 | emitter: EventEmitter, 14 | event: string, 15 | timeout: number = 120 * 1000, 16 | ): Promise { 17 | return new Promise((resolve, reject) => { 18 | let resolved = false; 19 | const listener = (data: any) => { 20 | resolved = true; 21 | resolve(data); 22 | }; 23 | 24 | emitter.once(event, listener); 25 | 26 | setTimeout(() => { 27 | if (!resolved) { 28 | emitter.removeListener(event, listener); 29 | reject(new TimeoutError(`Expected to get event ${event}`)); 30 | } 31 | }, timeout); 32 | }); 33 | } 34 | 35 | /** 36 | * Return a promise which is rejected with a TimeoutError after the 37 | * provided delay. 38 | * @param {Number} delay 39 | * @return {Promise} 40 | */ 41 | export function timeout(message: string, delay: number): Promise { 42 | // Capture the stacktrace here, since timeout stacktraces 43 | // often get mangled or dropped. 44 | const err = new TimeoutError(message); 45 | 46 | return new Promise((_, reject) => { 47 | setTimeout(() => reject(err), delay); 48 | }); 49 | } 50 | 51 | /** 52 | * Returns a promise which is resolved with an optional value after the provided delay 53 | * @param delay The time in milliseconds to wait before resolving the promise 54 | * @param value The value to resolve the promise with optional 55 | */ 56 | export function delay(delay: number): Promise; 57 | export function delay(delay: number, value?: T): Promise { 58 | return new Promise(resolve => { 59 | setTimeout(() => resolve(value), delay); 60 | }); 61 | } 62 | /** 63 | * Returns a function that calls the wrapped function with only instances of 64 | * the provided class, and throws them otherwise. This is meant to be used 65 | * inside `.catch` blocks of promises. 66 | * 67 | * Imported from frontend2 68 | * 69 | * @example 70 | * // Suppress an error 71 | * return foo.catch(only(AlreadyExistsError)); 72 | * // Handle a error 73 | * return foo.catch(only(AdapterResponseError, err => alert(err.toLocaleString()))); 74 | */ 75 | export function only( 76 | cls: { new (...args: any[]): T }, 77 | handler: (err: T) => U = () => null, 78 | ): (err: any) => U { 79 | return err => { 80 | if (!(err instanceof cls)) { 81 | throw err; 82 | } 83 | 84 | return handler(err); 85 | }; 86 | } 87 | -------------------------------------------------------------------------------- /src/state/IState.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | import { IClient } from '../IClient'; 4 | import { IRawValues } from '../interfaces'; 5 | import { Method, Reply } from '../wire/packets'; 6 | import { Group } from './Group'; 7 | import { IScene, ISceneData, ISceneDataArray } from './interfaces'; 8 | import { IControl } from './interfaces/controls/IControl'; 9 | import { IGroup, IGroupDataArray } from './interfaces/IGroup'; 10 | import { IParticipant } from './interfaces/IParticipant'; 11 | 12 | export interface IState extends EventEmitter { 13 | setClient(client: IClient): void; 14 | processMethod(method: Method): void | Reply; 15 | synchronizeLocalTime(time?: Date | number): Date; 16 | synchronizeRemoteTime(time?: Date | number): Date; 17 | 18 | reset(): void; 19 | 20 | getGroups(): Map; 21 | getGroup(id: string): IGroup; 22 | getScenes(): Map; 23 | getScene(id: string): IScene; 24 | onSceneCreate(data: ISceneData): IScene; 25 | synchronizeScenes(data: ISceneDataArray): IScene[]; 26 | synchronizeGroups(data: IGroupDataArray): IGroup[]; 27 | 28 | onWorldUpdate(data: IRawValues): void; 29 | 30 | getControl(id: string): IControl; 31 | 32 | getParticipants(): Map; 33 | getParticipantByUserID(id: number): IParticipant; 34 | getParticipantByUsername(name: string): IParticipant; 35 | getParticipantBySessionID(id: string): IParticipant; 36 | 37 | /** 38 | * Fired when the ready state of the interactive session changes. 39 | */ 40 | on(event: 'ready', listener: (ready: boolean) => void): this; 41 | 42 | /** 43 | * Fired when the connected participant's state is updated 44 | */ 45 | on(event: 'selfUpdate', listener: (self: IParticipant) => void): this; 46 | /** 47 | * Fired when a participant joins. 48 | */ 49 | on(event: 'participantJoin', listener: (participant: IParticipant) => void): this; 50 | /** 51 | * Fired when a participant leaves. 52 | */ 53 | on( 54 | event: 'participantLeave', 55 | listener: (participantSessionID: string, participant: IParticipant) => void, 56 | ): this; 57 | 58 | /** 59 | * Fired when a scene is deleted. 60 | */ 61 | on(event: 'sceneDeleted', listener: (sceneID: string, reassignSceneID: string) => void): this; 62 | /** 63 | * Fired when a scene is created. 64 | */ 65 | on(event: 'sceneCreated', listener: (scene: IScene) => void): this; 66 | 67 | /** 68 | * Fired when a group is deleted. 69 | */ 70 | on(event: 'groupDeleted', listener: (groupID: string, reassignGroupID: string) => void): this; 71 | /** 72 | * Fired when a group is created. 73 | */ 74 | on(event: 'groupCreated', listener: (group: Group) => void): this; 75 | /** 76 | * Fired when the world is updated. 77 | */ 78 | on(event: 'worldUpdated', listener: (world: IRawValues) => void): this; 79 | on(event: string, listener: Function): this; 80 | } 81 | -------------------------------------------------------------------------------- /examples/basic.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-console */ 2 | import * as WebSocket from 'ws'; 3 | 4 | import { 5 | GameClient, 6 | IButton, 7 | IParticipant, 8 | setWebSocket, 9 | } from '../lib'; 10 | 11 | import { makeControls } from './util'; 12 | 13 | if (process.argv.length < 4) { 14 | console.log('Usage gameClient.exe '); 15 | process.exit(); 16 | } 17 | // We need to tell the interactive client what type of websocket we are using. 18 | setWebSocket(WebSocket); 19 | 20 | // As we're on the Streamer's side we need a "GameClient" instance 21 | const client = new GameClient(); 22 | 23 | // Log when we're connected to interactive and setup your game! 24 | client.on('open', () => { 25 | console.log('Connected to Interactive!'); 26 | // Now that we've opened the connection we can create the controls, 27 | // We need to add them to a scene though. 28 | // Every Interactive Experience has a "default" scene so we'll add them there. 29 | client.createControls({ 30 | sceneID: 'default', 31 | controls: makeControls(5, i => `Button ${i}`), 32 | }).then(controls => { 33 | 34 | // Now that the controls are created we can add some event listeners to them! 35 | controls.forEach((control: IButton) => { 36 | 37 | // mousedown here means that someone has clicked the button. 38 | control.on('mousedown', (inputEvent, participant) => { 39 | 40 | // Let's tell the user who they are, and what they pushed. 41 | console.log(`${participant.username} pushed, ${inputEvent.input.controlID}`); 42 | 43 | // Did this push involve a spark cost? 44 | if (inputEvent.transactionID) { 45 | 46 | // Unless you capture the transaction the sparks are not deducted. 47 | client.captureTransaction(inputEvent.transactionID) 48 | .then(() => { 49 | console.log(`Charged ${participant.username} ${control.cost} sparks!`); 50 | }); 51 | } 52 | }); 53 | }); 54 | // Controls don't appear unless we tell Interactive that we are ready! 55 | return client.ready(true); 56 | }); 57 | }); 58 | 59 | // These can be un-commented to see the raw JSON messages under the hood 60 | // client.on('message', (err: any) => console.log('<<<', err)); 61 | // client.on('send', (err: any) => console.log('>>>', err)); 62 | // client.on('error', (err: any) => console.log(err)); 63 | 64 | // Now we open the connection passing in our authentication details and an experienceId. 65 | client.open({ 66 | authToken: process.argv[2], 67 | versionId: parseInt(process.argv[3], 10), 68 | }); 69 | 70 | client.state.on('participantJoin', participant => { 71 | console.log(`${participant.username}(${participant.sessionID}) Joined`); 72 | }); 73 | client.state.on('participantLeave', (participantSessionID: string, participant: IParticipant ) => { 74 | console.log(`${participant.username}(${participantSessionID}) Left`); 75 | }); 76 | /* tslint:enable:no-console */ 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mixer/interactive-node", 3 | "version": "3.0.1", 4 | "description": "A NodeJS and Browser compatible client for mixer.com's interactive 2 Protocol", 5 | "contributors": [ 6 | { 7 | "name": "Richard Fox", 8 | "email": "fox@xbox.com" 9 | }, 10 | { 11 | "name": "Connor Peet", 12 | "email": "connor@xbox.com" 13 | } 14 | ], 15 | "main": "lib/index.js", 16 | "typings": "lib/index.d.ts", 17 | "scripts": { 18 | "fmt:javascript": "prettier --single-quote --trailing-comma es5 --print-width 100 --write \"*.js\"", 19 | "fmt:typescript": "prettier --single-quote --trailing-comma all --print-width 100 --parser typescript --write \"src/**/*.ts\"", 20 | "fmt": "npm run fmt:javascript && npm run fmt:typescript", 21 | "build:ts": "rimraf lib dist && tsc", 22 | "build:webpack": "rimraf dist && webpack && webpack --config webpack.config.min.js", 23 | "build:examples": "rimraf examples/*.js && cd examples && tsc", 24 | "build": "npm run build:ts && npm run build:webpack && npm run build:examples", 25 | "test": "npm run lint && npm run test:unit", 26 | "test:unit": "mocha --compilers ts:ts-node/register \"src/**/*.spec.ts\" --exit", 27 | "prepublishOnly": "npm run build", 28 | "postpublish": "npm run docs:publish", 29 | "lint": "tslint -c tslint.json --project tsconfig.json \"src/**/*.ts\" \"test/**/*.ts\"", 30 | "docs:build": "rimraf docs && typedoc --exclude \"{*.spec.ts,**/node_modules/**}\" --out docs/ --mode file --excludeNotExported --excludePrivate --excludeExternals --tsconfig tsconfig.json ./src && node doc-postprocess.js", 31 | "docs:publish": "npm run docs:build && gh-pages -d docs -s **" 32 | }, 33 | "repository": { 34 | "type": "git", 35 | "url": "git+https://github.com/mixer/interactive-node.git" 36 | }, 37 | "keywords": [ 38 | "Interactive", 39 | "Mixer" 40 | ], 41 | "license": "MIT", 42 | "bugs": { 43 | "url": "https://github.com/mixer/interactive-node/issues" 44 | }, 45 | "homepage": "https://github.com/mixer/interactive-node#readme", 46 | "devDependencies": { 47 | "@types/chai": "^3.4.34", 48 | "@types/chai-as-promised": "0.0.30", 49 | "@types/deepmerge": "^1.3.3", 50 | "@types/faker": "^4.1.2", 51 | "@types/mocha": "^2.2.48", 52 | "@types/node": "^6.0.113", 53 | "@types/node-fetch": "^1.6.9", 54 | "@types/sinon": "^2.3.7", 55 | "@types/sinon-chai": "^2.7.33", 56 | "@types/ws": "0.0.38", 57 | "awesome-typescript-loader": "^3.5.0", 58 | "chai": "^3.5.0", 59 | "chai-as-promised": "^6.0.0", 60 | "faker": "^4.1.0", 61 | "fetch-cookie": "^0.4.1", 62 | "gh-pages": "^1.2.0", 63 | "mocha": "^5.2.0", 64 | "node-fetch": "^1.7.3", 65 | "nodemon": "^1.17.5", 66 | "prettier": "^1.13.6", 67 | "rimraf": "^2.6.2", 68 | "sinon": "^2.2.0", 69 | "sinon-chai": "^2.14.0", 70 | "ts-node": "^3.3.0", 71 | "tslint": "^4.3.1", 72 | "tslint-microsoft-contrib": "4.0.0", 73 | "typedoc": "^0.11.1", 74 | "typescript": "^2.9.2", 75 | "webpack": "^2.2.1", 76 | "webpack-bundle-analyzer": "^2.13.1", 77 | "ws": "^1.1.5" 78 | }, 79 | "dependencies": { 80 | "deepmerge": "^1.5.2" 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/state/interfaces/controls/IInput.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The base representation of the input portion of an input event. 3 | */ 4 | export interface IInput { 5 | /** 6 | * The controlId this input event relates to. 7 | */ 8 | controlID?: string; 9 | /** 10 | * The event name of this input event. 11 | */ 12 | event: string; 13 | } 14 | 15 | /** 16 | * Extends the base input to include button specific data. 17 | */ 18 | export interface IButtonMouseInput extends IInput { 19 | /** 20 | * Buttons can emit the mousedown(depressed) or mouseup(released) event. 21 | */ 22 | event: 'mousedown' | 'mouseup'; 23 | /** 24 | * Buttons additionally will report which button was used to trigger this event. 25 | * 26 | * See {@link https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button | MouseEvent.button} 27 | * for more information. 28 | */ 29 | button: number; 30 | } 31 | 32 | /** 33 | * Extends the base input to include button specific data. 34 | */ 35 | export interface IButtonKeyboardInput extends IInput { 36 | /** 37 | * Buttons can emit the keydown(depressed) or keyup(released) event. 38 | */ 39 | event: 'keydown' | 'keyup'; 40 | } 41 | 42 | export type IButtonInput = IButtonMouseInput | IButtonKeyboardInput; 43 | 44 | /** 45 | * Extends the base input to include joystick specific data. 46 | */ 47 | export interface IJoystickInput extends IInput { 48 | /** 49 | * Joysticks can only be moved. 50 | */ 51 | event: 'move'; 52 | /** 53 | * The X coordinate of this joystick. -1 - 1. 54 | */ 55 | x: number; 56 | /** 57 | * The Y coordinate of this joystick. -1 - 1. 58 | */ 59 | y: number; 60 | } 61 | 62 | /** 63 | * Extends the base input to include screen specific data. 64 | */ 65 | export interface IScreenInput extends IInput { 66 | /** 67 | * Joysticks can only be moved. 68 | */ 69 | event: 'move' | 'mousedown' | 'mouseup'; 70 | /** 71 | * The X coordinate of the input. 72 | */ 73 | x: number; 74 | /** 75 | * The Y coordinate of the input. 76 | */ 77 | y: number; 78 | } 79 | 80 | /** 81 | * Extends the base input to include textbox specific data. 82 | */ 83 | export interface ITextboxInput extends IInput { 84 | /** 85 | * Textboxes can emit change (regular keyboard input) or submit event. 86 | */ 87 | event: 'change' | 'submit'; 88 | /** 89 | * The value of the textbox. 90 | */ 91 | value: string; 92 | } 93 | 94 | /** 95 | * An Input event ties input data together with the id of the participant who caused it. 96 | */ 97 | export interface IInputEvent { 98 | /** 99 | * The session id of the participant who caused this input. 100 | */ 101 | participantID: string; 102 | /** 103 | * The input data. 104 | */ 105 | input: T; 106 | /** 107 | * A transaction id if this input event has created a transaction. 108 | */ 109 | transactionID?: string; 110 | } 111 | /** 112 | * Used to describe the structure of a transaction capture attempt. 113 | */ 114 | export interface ITransactionCapture { 115 | transactionID: string; 116 | } 117 | -------------------------------------------------------------------------------- /src/state/controls/Textbox.ts: -------------------------------------------------------------------------------- 1 | import { ITextboxInput } from '../interfaces/controls/IInput'; 2 | import { 3 | ITextbox, 4 | ITextboxData, 5 | ITextboxUpdate, 6 | } from '../interfaces/controls/ITextbox'; 7 | import { Control } from './Control'; 8 | 9 | /** 10 | * Textboxes can be used by participants to send text to the game. 11 | */ 12 | export class Textbox extends Control implements ITextbox { 13 | /** 14 | * The text hint inside the textbox, presented to the participants. 15 | * Set this value using [setPlaceholder]{@link Textbox.setPlaceholder} 16 | */ 17 | public placeholder: string; 18 | /** 19 | * The spark cost of this textbox in sparks. 20 | * Set this value using [setCost]{@link Textbox.setCost} 21 | */ 22 | public cost: number; 23 | /** 24 | * If set this value is the Unix Timestamp at which this textbox's cooldown will expire. 25 | * Set this value using [setCooldown]{@link Textbox.setCooldown} 26 | */ 27 | public cooldown: number; 28 | /** 29 | * The text displayed within the submit button for the textbox. 30 | * Set this value using [setSubmitText]{@link Textbox.setSubmitText} 31 | */ 32 | public submitText: string; 33 | /** 34 | * Shows the submit button for the textbox, presented to the participants. 35 | */ 36 | public hasSubmit: boolean; 37 | /** 38 | * Sets the textbox displayed to the participants to be singleline or multiline. 39 | */ 40 | public multiline: boolean; 41 | 42 | /** 43 | * Sets a new placeholder value for this textbox. 44 | */ 45 | public setPlaceholder(placeholder: string): Promise { 46 | return this.updateAttribute('placeholder', placeholder); 47 | } 48 | 49 | /** 50 | * Sets a new submit button text value for this textbox. 51 | */ 52 | public setSubmitText(submitText: string): Promise { 53 | return this.updateAttribute('submitText', submitText); 54 | } 55 | 56 | /** 57 | * Sets the cooldown for this textbox. Specified in Milliseconds. 58 | * The Client will convert this to a Unix timestamp for you. 59 | */ 60 | public setCooldown(duration: number): Promise { 61 | const target = 62 | this.client.state.synchronizeLocalTime().getTime() + duration; 63 | return this.updateAttribute('cooldown', target); 64 | } 65 | 66 | /** 67 | * Sets the spark cost for this textbox. 68 | * An Integer greater than 0 69 | */ 70 | public setCost(cost: number): Promise { 71 | return this.updateAttribute('cost', cost); 72 | } 73 | 74 | /** 75 | * Sends an input event from a participant to the server for consumption. 76 | */ 77 | public giveInput(input: ITextboxInput): Promise { 78 | return this.sendInput(input); 79 | } 80 | 81 | /** 82 | * Update this textbox on the server. 83 | */ 84 | public update(controlUpdate: ITextboxUpdate): Promise { 85 | // Clone to prevent mutations 86 | // XXX: Typescript 2.4 is strict, let the compiler be clever. 87 | const changedData = Object.assign({}, controlUpdate); 88 | if (changedData.cooldown) { 89 | changedData.cooldown = 90 | this.client.state.synchronizeLocalTime().getTime() + 91 | changedData.cooldown; 92 | } 93 | return super.update(changedData); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/state/interfaces/IScene.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import { ETag } from './controls'; 3 | 4 | import { IJSON, IRawValues } from '../../interfaces'; 5 | import { IControl, IControlData } from './controls/IControl'; 6 | import { IMeta } from './controls/IMeta'; 7 | 8 | /** 9 | * Represents an array of scenes stored within an object. 10 | * Will also contain any global world properties. 11 | */ 12 | export interface ISceneDataArray extends IRawValues { 13 | scenes: ISceneData[]; 14 | } 15 | /** 16 | * Represents a request to update the world properties of an interactive session 17 | */ 18 | export interface IWorldUpdate { 19 | world: IJSON; 20 | } 21 | 22 | /** 23 | * Included in messages when a scene is deleted. 24 | * Provides information on where participants on 25 | * the deleted scene should be redirected to. 26 | */ 27 | export interface ISceneDeletionParams { 28 | /** 29 | * The deleted scene ID. 30 | */ 31 | sceneID: string; 32 | /** 33 | * The scene which 34 | */ 35 | reassignSceneID: string; 36 | } 37 | /** 38 | * Represents the raw data of a scene as it is represented on the wire. 39 | */ 40 | export interface ISceneData { 41 | /** 42 | * A unique ID for this scene. 43 | */ 44 | sceneID: string; 45 | /** 46 | * A collection of controls which are on this scene. 47 | */ 48 | controls: IControlData[]; 49 | /** 50 | * A collection of meta properties which this scene has. 51 | */ 52 | meta?: IMeta; 53 | 54 | /** 55 | * @deprecated etags are no longer used, you can always omit/ignore this 56 | */ 57 | etag?: ETag; 58 | } 59 | 60 | export interface IScene extends EventEmitter { 61 | sceneID: string; 62 | controls: Map; 63 | meta: IMeta; 64 | 65 | /** 66 | * @deprecated etags are no longer used, you can always omit/ignore this 67 | */ 68 | etag: string; 69 | //TODO groups 70 | groups: any; 71 | 72 | getControl(id: string): IControl; 73 | getControls(): IControl[]; 74 | 75 | createControl(controlData: IControlData): Promise; 76 | createControls(controls: IControlData[]): Promise; 77 | updateControls(controls: IControlData[]): Promise; 78 | deleteControls(controlIDs: string[]): Promise; 79 | deleteControl(controlIDs: string): Promise; 80 | deleteAllControls(): Promise; 81 | 82 | onControlsCreated(controls: IControlData[]): IControl[]; 83 | onControlsUpdated(controls: IControlData[]): void; 84 | onControlsDeleted(controls: IControlData[]): void; 85 | 86 | update(scene: ISceneData): void; 87 | 88 | destroy(): void; 89 | 90 | /** 91 | * Fired when a control is added to the scene. 92 | */ 93 | on(event: 'controlAdded', listener: (control: IControl) => void): this; 94 | /** 95 | * Fired when a control is removed from the scene. 96 | */ 97 | on(event: 'controlDeleted', listener: (controlId: string) => void): this; 98 | /** 99 | * Fired when the scene is updated with new data from the server. 100 | */ 101 | on(event: 'update', listener: (controlId: this) => void): this; 102 | on(event: string, listener: Function): this; 103 | } 104 | 105 | export interface ISceneControlDeletion { 106 | sceneID: string; 107 | controlIDs: string[]; 108 | } 109 | -------------------------------------------------------------------------------- /examples/joystick.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-console */ 2 | import * as WebSocket from 'ws'; 3 | 4 | import { 5 | GameClient, 6 | IJoystick, 7 | IJoystickData, 8 | IParticipant, 9 | setWebSocket, 10 | } from '../lib'; 11 | 12 | if (process.argv.length < 4) { 13 | console.log('Usage gameClient.exe '); 14 | process.exit(); 15 | } 16 | // We need to tell the interactive client what type of websocket we are using. 17 | setWebSocket(WebSocket); 18 | 19 | // As we're on the Streamer's side we need a "GameClient" instance 20 | const client = new GameClient(); 21 | 22 | const joystick: IJoystickData = { 23 | sampleRate: 50, 24 | kind: 'joystick', 25 | controlID: 'joystick1', 26 | position: [ 27 | { 28 | size: 'large', 29 | width: 12, 30 | height: 12, 31 | x: 2, 32 | y: 1, 33 | }, 34 | { 35 | size: 'medium', 36 | width: 12, 37 | height: 12, 38 | x: 1, 39 | y: 0, 40 | }, 41 | { 42 | size: 'small', 43 | width: 12, 44 | height: 12, 45 | x: 1, 46 | y: 0, 47 | }, 48 | ], 49 | }; 50 | 51 | // Log when we're connected to interactive and setup your game! 52 | client.on('open', () => { 53 | console.log('Connected to Interactive!'); 54 | // Now we can create the controls, We need to add them to a scene though. 55 | // Every Interactive Experience has a "default" scene so we'll add them there there. 56 | client 57 | .createControls({ 58 | sceneID: 'default', 59 | controls: [joystick], 60 | }) 61 | .then(controls => { 62 | // Now that the controls are created we can add some event listeners to them! 63 | controls.forEach((control: IJoystick) => { 64 | // move here means that someone has clicked the button. 65 | control.on('move', (inputEvent, participant) => { 66 | // Let's tell the user who they are, and where they moved the joystick 67 | console.log( 68 | `${participant.username} moved, ${ 69 | inputEvent.input.controlID 70 | } to ${inputEvent.input.x}, ${inputEvent.input.y}`, 71 | ); 72 | }); 73 | }); 74 | // Controls don't appear unless we tell Interactive that we are ready! 75 | client.ready(true); 76 | }); 77 | }); 78 | 79 | // These can be un-commented to see the raw JSON messages under the hood 80 | client.on('message', (err: any) => console.log('<<<', err)); 81 | client.on('send', (err: any) => console.log('>>>', err)); 82 | // client.on('error', (err: any) => console.log(err)); 83 | 84 | // Now we open the connection passing in our authentication details and an experienceId. 85 | client.open({ 86 | authToken: process.argv[2], 87 | versionId: parseInt(process.argv[3], 10), 88 | }); 89 | 90 | client.state.on('participantJoin', participant => { 91 | console.log(`${participant.username}(${participant.sessionID}) Joined`); 92 | }); 93 | client.state.on( 94 | 'participantLeave', 95 | (participantSessionID: string, participant: IParticipant) => { 96 | console.log(`${participant.username}(${participantSessionID}) Left`); 97 | }, 98 | ); 99 | /* tslint:enable:no-console */ 100 | -------------------------------------------------------------------------------- /examples/updateControl.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-console */ 2 | import * as WebSocket from 'ws'; 3 | 4 | import { GameClient, IButton, IParticipant, setWebSocket } from '../lib'; 5 | 6 | import { makeControls } from './util'; 7 | 8 | if (process.argv.length < 4) { 9 | console.log('Usage gameClient.exe '); 10 | process.exit(); 11 | } 12 | // We need to tell the interactive client what type of websocket we are using. 13 | setWebSocket(WebSocket); 14 | 15 | // As we're on the Streamer's side we need a "GameClient" instance 16 | const client = new GameClient(); 17 | 18 | // Log when we're connected to interactive and setup your game! 19 | client.on('open', () => { 20 | console.log('Connected to Interactive!'); 21 | // Now we can create the controls, We need to add them to a scene though. 22 | // Every Interactive Experience has a "default" scene so we'll add them there there. 23 | client 24 | .createControls({ 25 | sceneID: 'default', 26 | controls: makeControls(5, i => `Button ${i}`), 27 | }) 28 | .then(controls => { 29 | // Now that the controls are created we can add some event listeners to them! 30 | controls.forEach((control: IButton) => { 31 | // mousedown here means that someone has clicked the button. 32 | control.on('mousedown', (inputEvent, participant) => { 33 | // Let's tell the user who they are, and what they pushed. 34 | console.log( 35 | `${participant.username} pushed, ${ 36 | inputEvent.input.controlID 37 | }`, 38 | ); 39 | 40 | // Did this push involve a spark cost? 41 | if (inputEvent.transactionID) { 42 | // Unless you capture the transaction the sparks are not deducted. 43 | client 44 | .captureTransaction(inputEvent.transactionID) 45 | .then(() => { 46 | console.log( 47 | `Charged ${participant.username} ${ 48 | control.cost 49 | } sparks!`, 50 | ); 51 | }); 52 | } 53 | //Five second cooldown 54 | control.setCooldown(5000); 55 | }); 56 | }); 57 | // Controls don't appear unless we tell Interactive that we are ready! 58 | client.ready(true); 59 | }); 60 | }); 61 | 62 | // These can be un-commented to see the raw JSON messages under the hood 63 | client.on('message', (err: any) => console.log('<<<', err)); 64 | client.on('send', (err: any) => console.log('>>>', err)); 65 | // client.on('error', (err: any) => console.log(err)); 66 | 67 | // Now we open the connection passing in our authentication details and an experienceId. 68 | client.open({ 69 | authToken: process.argv[2], 70 | versionId: parseInt(process.argv[3], 10), 71 | }); 72 | 73 | client.state.on('participantJoin', participant => { 74 | console.log(`${participant.username}(${participant.sessionID}) Joined`); 75 | }); 76 | client.state.on( 77 | 'participantLeave', 78 | (participantSessionID: string, participant: IParticipant) => { 79 | console.log(`${participant.username}(${participantSessionID}) Left`); 80 | }, 81 | ); 82 | /* tslint:enable:no-console */ 83 | -------------------------------------------------------------------------------- /src/state/interfaces/controls/ITextbox.ts: -------------------------------------------------------------------------------- 1 | import { IParticipant } from '../'; 2 | import { IControl, IControlData, IControlUpdate } from './IControl'; 3 | import { IInputEvent, ITextboxInput } from './IInput'; 4 | 5 | /** 6 | * Extends the regular control data with additional properties for Textbox 7 | */ 8 | export interface ITextboxData extends IControlData { 9 | /** 10 | * The text hint inside the textbox. 11 | */ 12 | placeholder?: string; 13 | /** 14 | * The optional text to replace "Submit" on the submit button. 15 | */ 16 | submitText?: string; 17 | /** 18 | * The spark cost of this textbox. A cost will force a submit button. 19 | */ 20 | cost?: number; 21 | /** 22 | * In milliseconds, will be converted to a unix timestamp of when this cooldown expires. 23 | */ 24 | cooldown?: number; 25 | /** 26 | * Whether the textbox has a submit button. 27 | */ 28 | hasSubmit?: boolean; 29 | /** 30 | * Whether the textbox supports multiple lines of text. 31 | */ 32 | multiline?: boolean; 33 | } 34 | 35 | /** 36 | * Represents updatable components of a label which developers can update 37 | * from game clients. 38 | */ 39 | export interface ITextboxUpdate extends IControlUpdate { 40 | /** 41 | * Will update the text hint inside the textbox. 42 | */ 43 | placeholder?: string; 44 | /** 45 | * Will update the optional text to replace "Submit" on the submit button. 46 | */ 47 | submitText?: string; 48 | /** 49 | * Will update the spark cost of this textbox. A cost will force a submit button. 50 | */ 51 | cost?: number; 52 | /** 53 | * In milliseconds, will be converted to a unix timestamp of when this cooldown expires. 54 | */ 55 | cooldown?: number; 56 | /** 57 | * Will update Whether the textbox has a submit button. 58 | */ 59 | hasSubmit?: boolean; 60 | /** 61 | * Will update Whether the textbox supports multiple lines of text. 62 | */ 63 | multiline?: boolean; 64 | } 65 | 66 | export interface ITextbox extends IControl, ITextboxData { 67 | placeholder: string; 68 | submitText: string; 69 | cost: number; 70 | cooldown: number; 71 | hasSubmit: boolean; 72 | multiline: boolean; 73 | // GameClient 74 | setPlaceholder(placeholder: string): Promise; 75 | setSubmitText(submitText: string): Promise; 76 | setCooldown(duration: number): Promise; 77 | setCost(cost: number): Promise; 78 | update(changedData: ITextboxUpdate): Promise; 79 | 80 | /** 81 | * Fired when a participant presses a key inside the text input. 82 | * Does not send if there is a spark cost or if control has submit. 83 | */ 84 | on( 85 | event: 'change', 86 | listener: ( 87 | inputEvent: IInputEvent, 88 | participant: IParticipant, 89 | ) => void, 90 | ): this; 91 | /** 92 | * Fired when a participant submits the text. 93 | * Submit can be called via clicking the submit button or via pressing the 94 | * Enter key when the textbox is singleline, or Ctrl + Enter when textbox is multiline. 95 | */ 96 | on( 97 | event: 'submit', 98 | listener: ( 99 | inputEvent: IInputEvent, 100 | participant: IParticipant, 101 | ) => void, 102 | ): this; 103 | 104 | on(event: string, listener: Function): this; 105 | } 106 | -------------------------------------------------------------------------------- /test/fixtures/testGame.json: -------------------------------------------------------------------------------- 1 | { 2 | "scenes": [ 3 | { 4 | "sceneID": "my awesome scene", 5 | "etag": "252185589", 6 | "controls": [ 7 | { 8 | "controlID": "win_the_game_btn", 9 | "etag": "262111379", 10 | "kind": "button", 11 | "text": "Win the Game", 12 | "cost": 0, 13 | "progress": 0.25, 14 | "keyCode": 30, 15 | "disabled": false, 16 | "meta": { 17 | "glow": { 18 | "etag": "254353748", 19 | "value": { 20 | "color": "#f00", 21 | "radius": 10 22 | } 23 | } 24 | }, 25 | "position": [ 26 | { 27 | "size":"large", 28 | "width":2, 29 | "height":2, 30 | "x":2, 31 | "y":3 32 | }, 33 | { 34 | "size":"medium", 35 | "width":2, 36 | "height":2, 37 | "x":2, 38 | "y":3 39 | }, 40 | { 41 | "size":"small", 42 | "width":2, 43 | "height":2, 44 | "x":2, 45 | "y":3 46 | } 47 | ] 48 | } 49 | ] 50 | }, 51 | { 52 | "sceneID": "existing second scene", 53 | "etag": "252143549", 54 | "controls": [ 55 | { 56 | "controlID": "sudo_win_the_game_btn", 57 | "etag": "262543579", 58 | "kind": "button", 59 | "text": "Sudo Win the Game", 60 | "cost": 0, 61 | "progress": 0.75, 62 | "keyCode": 30, 63 | "disabled": false, 64 | "meta": { 65 | "glow": { 66 | "etag": "254344348", 67 | "value": { 68 | "color": "#0f0", 69 | "radius": 10 70 | } 71 | } 72 | }, 73 | "position": [ 74 | { 75 | "size":"large", 76 | "width":2, 77 | "height":2, 78 | "x":2, 79 | "y":3 80 | }, 81 | { 82 | "size":"medium", 83 | "width":2, 84 | "height":2, 85 | "x":2, 86 | "y":3 87 | }, 88 | { 89 | "size":"small", 90 | "width":2, 91 | "height":2, 92 | "x":2, 93 | "y":3 94 | } 95 | ] 96 | } 97 | ] 98 | } 99 | ] 100 | } 101 | -------------------------------------------------------------------------------- /examples/keyboardControls.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-console */ 2 | import * as WebSocket from 'ws'; 3 | import { getGridPlacement } from './util'; 4 | 5 | import { GameClient, IButton, IButtonData, setWebSocket } from '../lib'; 6 | 7 | if (process.argv.length < 3) { 8 | console.log('Usage: node keyboardControls.js '); 9 | process.exit(); 10 | } 11 | 12 | // We need to tell the interactive client what type of websocket we are using. 13 | setWebSocket(WebSocket); 14 | 15 | // As we're on the Streamer's side we need a "GameClient" instance 16 | const client = new GameClient(); 17 | 18 | /** 19 | * These are our controls. The "keyCode" property is the JavaScript key code associated 20 | * with the key that participants will press on their keyboard to trigger the button. 21 | */ 22 | const controls: IButtonData[] = [ 23 | { 24 | controlID: 'up', 25 | kind: 'button', 26 | text: 'W', 27 | keyCode: 87, 28 | position: getGridPlacement(1, 0), 29 | }, 30 | { 31 | controlID: 'left', 32 | kind: 'button', 33 | text: 'A', 34 | keyCode: 65, 35 | position: getGridPlacement(0, 1), 36 | }, 37 | { 38 | controlID: 'down', 39 | kind: 'button', 40 | text: 'S', 41 | keyCode: 83, 42 | position: getGridPlacement(1, 1), 43 | }, 44 | { 45 | controlID: 'right', 46 | kind: 'button', 47 | text: 'D', 48 | keyCode: 68, 49 | position: getGridPlacement(2, 1), 50 | }, 51 | ]; 52 | 53 | // Log when we're connected to interactive and setup your game! 54 | client.on('open', () => { 55 | console.log('Connected to Interactive!'); 56 | // Creates the controls on the default scene, "default". 57 | client 58 | .createControls({ 59 | sceneID: 'default', 60 | controls, 61 | }) 62 | .then(buttons => { 63 | // Now that our controls are created, we can add some event listeners to each. 64 | buttons.forEach((control: IButton) => { 65 | control.on('keydown', (inputEvent, participant) => { 66 | console.log( 67 | `${participant.username} pressed ${ 68 | inputEvent.input.controlID 69 | } with their keyboard.`, 70 | ); 71 | }); 72 | 73 | control.on('keyup', (inputEvent, participant) => { 74 | console.log( 75 | `${participant.username} released ${ 76 | inputEvent.input.controlID 77 | } with their keyboard.`, 78 | ); 79 | }); 80 | 81 | control.on('mousedown', (inputEvent, participant) => { 82 | console.log( 83 | `${participant.username} pressed ${ 84 | inputEvent.input.controlID 85 | } with their mouse.`, 86 | ); 87 | }); 88 | 89 | control.on('mouseup', (inputEvent, participant) => { 90 | console.log( 91 | `${participant.username} released ${ 92 | inputEvent.input.controlID 93 | } with their mouse.`, 94 | ); 95 | }); 96 | }); 97 | 98 | // Controls don't appear unless we tell Interactive that we are ready! 99 | client.ready(true); 100 | }); 101 | }); 102 | 103 | // Opens the connection by passing in our authentication details and a versionId. 104 | client.open({ 105 | authToken: process.argv[2], 106 | versionId: parseInt(process.argv[3], 10), 107 | }); 108 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Interactive Node 2 | 3 | [![Build Status](https://travis-ci.org/mixer/interactive-node.svg?branch=master)](https://travis-ci.org/mixer/interactive-node) 4 | 5 | A TypeScript, Node.js and Browser(JavaScript) compatible client for [Mixer.com's interactive 2 Protocol](https://dev.mixer.com/guides/mixplay/protocol/overview). 6 | 7 | For an introduction to interactive2 checkout the [reference docs](https://dev.mixer.com/guides/mixplay/introduction) on the developers site. 8 | 9 | ## Installation 10 | 11 | You can use npm(recommended) or download a zip from the [releases page](https://github.com/mixer/interactive-node/releases). 12 | 13 | ### Browser 14 | 15 | ```html 16 | 17 | ``` 18 | 19 | ### Node 20 | 21 | ``` 22 | npm i --save @mixer/interactive-node 23 | ``` 24 | 25 | ## Usage 26 | 27 | ### Authentication 28 | 29 | [OAuth 2.0](https://tools.ietf.org/html/rfc6749) is used for authentication. Valid bearer tokens can be passed in the [Client.open](https://mixer.github.io/interactive-node/classes/client.html#open) method. 30 | 31 | For more information about Mixer's OAuth visit the [OAuth reference page](https://dev.mixer.com/reference/oauth/index.html) on our developer site. 32 | 33 | ### Browser 34 | 35 | #### index.html 36 | 37 | ```html 38 | 39 | 40 | 41 | Interactive 2 42 | 43 | 44 | 45 | 46 | 47 | 48 | ``` 49 | 50 | #### app.js 51 | 52 | ```js 53 | const client = new interactive.GameClient(); 54 | 55 | client.open({ 56 | authToken: '', 57 | versionId: 1234, 58 | }); 59 | ``` 60 | 61 | ### Node 62 | 63 | #### JavaScript 64 | 65 | ```js 66 | const interactive = require('@mixer/interactive-node'); 67 | const ws = require('ws'); 68 | 69 | interactive.setWebSocket(ws); 70 | 71 | const client = new interactive.GameClient(); 72 | 73 | client.open({ 74 | authToken: '', 75 | versionId: 1234, 76 | }); 77 | ``` 78 | 79 | #### TypeScript 80 | 81 | ```ts 82 | import { GameClient, setWebSocket } from '@mixer/interactive-node'; 83 | import * as ws from 'ws'; 84 | 85 | setWebSocket(ws); 86 | 87 | const client = new GameClient(); 88 | 89 | client.open({ 90 | authToken: '', 91 | versionId: 1234, 92 | }); 93 | ``` 94 | 95 | ## Examples 96 | 97 | Checkout our [examples](https://github.com/mixer/interactive-node/tree/master/examples/) to get up to speed quickly! 98 | 99 | * [basic](https://github.com/mixer/interactive-node/tree/master/examples/basic.ts) - Connects and sets up 5 buttons, when they are clicked the participant is charged 1 spark. 100 | * [dynamicControls](https://github.com/mixer/interactive-node/tree/master/examples/dynamicControls.ts) - Connects and then creates and destroys 5 buttons with random text. 101 | * [joystick](https://github.com/mixer/interactive-node/tree/master/examples/joystick.ts) - Connects and creates a joystick, logs participant coordinate values. 102 | 103 | Using Node.js? Clone this repository and run `npm run build` and the examples will be converted to JavaScript for you! 104 | 105 | ## Documentation 106 | 107 | Checkout our reference docs [here](https://mixer.github.io/interactive-node/). 108 | 109 | ## Development 110 | 111 | To get a development environment setup: 112 | 113 | 1. [Clone this repository](https://help.github.com/articles/cloning-a-repository/) 114 | 2. `npm install` 115 | 3. `npm run build` 116 | 117 | ### Contributing 118 | 119 | Thanks for your interested in contributing, checkout [TODO.md](https://github.com/mixer/interactive-node/blob/master/TODO.md) for a list of tasks! 120 | 121 | Open a [Pull Request](https://github.com/mixer/interactive-node/pulls) we'd love to see your contributions. 122 | -------------------------------------------------------------------------------- /src/state/controls/Control.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | import { IClient } from '../../IClient'; 4 | import { merge } from '../../merge'; 5 | import { IParticipant } from '../interfaces'; 6 | import { 7 | IControl, 8 | IControlData, 9 | IControlUpdate, 10 | IGridPlacement, 11 | } from '../interfaces/controls/IControl'; 12 | import { IInput, IInputEvent } from '../interfaces/controls/IInput'; 13 | import { IMeta } from '../interfaces/controls/IMeta'; 14 | import { Scene } from '../Scene'; 15 | 16 | /** 17 | * Control is used a base class for all other controls within an interactive session. 18 | * It contains shared logic which all types of controls can utilize. 19 | */ 20 | export class Control extends EventEmitter 21 | implements IControl { 22 | public controlID: string; 23 | public kind: string; 24 | public disabled: boolean; 25 | public position: IGridPlacement[]; 26 | /** @deprecated etags are no longer used, you can always omit/ignore this */ 27 | public etag: string; 28 | public meta: IMeta; 29 | 30 | protected scene: Scene; 31 | public client: IClient; 32 | 33 | /** 34 | * Sets the scene this control belongs to. 35 | */ 36 | public setScene(scene: Scene) { 37 | this.scene = scene; 38 | } 39 | /** 40 | * Sets the client instance this control can use to execute methods. 41 | */ 42 | public setClient(client: IClient) { 43 | this.client = client; 44 | } 45 | 46 | constructor(control: T) { 47 | super(); 48 | merge(this, control); 49 | } 50 | 51 | // A base control class cannot give input 52 | public giveInput?(input: IInput): Promise; 53 | 54 | /** 55 | * Called by client when it receives an input event for this control from the server. 56 | */ 57 | public receiveInput(inputEvent: IInputEvent, participant: IParticipant) { 58 | this.emit(inputEvent.input.event, inputEvent, participant); 59 | } 60 | 61 | protected sendInput(input: K): Promise { 62 | // We add this on behalf of the controls so that they don't have to worry about the 63 | // Protocol side too much 64 | input.controlID = this.controlID; 65 | return this.client.giveInput(input); 66 | } 67 | 68 | /** 69 | * Disables this control, preventing participant interaction. 70 | */ 71 | public disable(): Promise { 72 | return this.updateAttribute('disabled', true); 73 | } 74 | /** 75 | * Enables this control, allowing participant interaction. 76 | */ 77 | public enable(): Promise { 78 | return this.updateAttribute('disabled', false); 79 | } 80 | 81 | protected updateAttribute(attribute: K, value: T[K]): Promise { 82 | const packet: T = {}; 83 | packet.controlID = this.controlID; 84 | 85 | packet[attribute] = value; 86 | 87 | return this.client.updateControls({ 88 | sceneID: this.scene.sceneID, 89 | controls: [packet], 90 | }); 91 | } 92 | 93 | /** 94 | * Merges in values from the server in response to an update operation from the server. 95 | */ 96 | public onUpdate(controlData: IControlData) { 97 | merge(this, controlData); 98 | this.emit('updated', this); 99 | } 100 | 101 | /** 102 | * Update this control on the server. 103 | */ 104 | public update(controlUpdate: T2): Promise { 105 | const changedData = { 106 | ...(controlUpdate), 107 | controlID: this.controlID, 108 | }; 109 | 110 | return this.client.updateControls({ 111 | sceneID: this.scene.sceneID, 112 | controls: [changedData], 113 | }); 114 | } 115 | 116 | public destroy(): void { 117 | this.emit('deleted', this); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/methods/MethodHandlerManager.ts: -------------------------------------------------------------------------------- 1 | import { InteractiveError } from '../errors'; 2 | import { IInput, IInputEvent } from '../state/interfaces/controls/IInput'; 3 | import { IParticipantArray } from '../state/interfaces/IParticipant'; 4 | import { Method, Reply } from '../wire/packets'; 5 | import { onReadyParams } from './methodTypes'; 6 | 7 | import { IGroupDataArray, IGroupDeletionParams } from '../state/interfaces/IGroup'; 8 | import { ISceneData, ISceneDataArray, ISceneDeletionParams } from '../state/interfaces/IScene'; 9 | 10 | /** 11 | * A Method handler takes a given method and handles it, optionally replying with a reply instance. 12 | */ 13 | export interface IMethodHandler { 14 | (method: Method): Reply | void; 15 | } 16 | 17 | /** 18 | * A manager class which allows for methods on the interactive protocol to have handlers registered. 19 | * When the manager is handed a method, it will look up the relevant method handler and call it. 20 | */ 21 | export class MethodHandlerManager { 22 | private handlers: { [key: string]: IMethodHandler } = {}; 23 | 24 | public addHandler(method: 'onWorldUpdate', handler: IMethodHandler): void; 25 | public addHandler( 26 | method: 'onParticipantJoin', 27 | handler: IMethodHandler, 28 | ): void; 29 | public addHandler( 30 | method: 'onParticipantLeave', 31 | handler: IMethodHandler, 32 | ): void; 33 | public addHandler( 34 | method: 'onParticipantUpdate', 35 | handler: IMethodHandler, 36 | ): void; 37 | 38 | public addHandler(method: 'onSceneCreate', handler: IMethodHandler): void; 39 | public addHandler(method: 'onSceneDelete', handler: IMethodHandler): void; 40 | public addHandler(method: 'onSceneUpdate', handler: IMethodHandler): void; 41 | 42 | public addHandler(method: 'onGroupCreate', handler: IMethodHandler): void; 43 | public addHandler(method: 'onGroupDelete', handler: IMethodHandler): void; 44 | public addHandler(method: 'onGroupUpdate', handler: IMethodHandler): void; 45 | 46 | public addHandler(method: 'onControlCreate', handler: IMethodHandler): void; 47 | public addHandler(method: 'onControlDelete', handler: IMethodHandler): void; 48 | public addHandler(method: 'onControlUpdate', handler: IMethodHandler): void; 49 | 50 | public addHandler(method: 'onReady', handler: IMethodHandler): void; 51 | public addHandler(method: 'hello', handler: IMethodHandler): void; 52 | 53 | public addHandler( 54 | method: 'giveInput', 55 | handler: IMethodHandler>, 56 | ): void; 57 | 58 | public addHandler(method: string, handler: IMethodHandler): void; 59 | /** 60 | * Registers a handler for a method name. 61 | */ 62 | public addHandler(method: string, handler: IMethodHandler): void { 63 | this.handlers[method] = handler; 64 | } 65 | 66 | /** 67 | * Removes a handler for a method. 68 | */ 69 | public removeHandler(method: string) { 70 | delete this.handlers[method]; 71 | } 72 | 73 | /** 74 | * Looks up a handler for a given method and calls it. 75 | */ 76 | public handle(method: Method): Reply | void { 77 | if (this.handlers[method.method]) { 78 | return this.handlers[method.method](method); 79 | } 80 | /** 81 | * When Discard is true a reply is not required, 82 | * If an error occurs though, we expect the client to tell 83 | * the server about it. 84 | * 85 | * So in the case of a missing method handler, resolve with no reply 86 | * if discard is true, otherwise throw UnknownMethodName 87 | */ 88 | if (method.discard) { 89 | return null; 90 | } 91 | throw new InteractiveError.UnknownMethodName(`Client cannot process ${method.method}`); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/Client.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as sinon from 'sinon'; 3 | import * as WebSocket from 'ws'; 4 | 5 | import { setWebSocket } from './'; 6 | import { Client, ClientType } from './Client'; 7 | import { IGroupData, ISceneData } from './state/interfaces'; 8 | import { Method } from './wire/packets'; 9 | 10 | setWebSocket(WebSocket); 11 | const port: number = parseInt(process.env.SERVER_PORT, 10) || 1339; 12 | 13 | describe('client', () => { 14 | const urls = [`ws://127.0.0.1:${port}/`]; 15 | let client: Client; 16 | let server: WebSocket.Server; 17 | let ws: WebSocket; 18 | 19 | function awaitConnect(callback: Function) { 20 | server.once('connection', (_ws: WebSocket) => { 21 | ws = _ws; 22 | callback(ws); 23 | }); 24 | } 25 | 26 | const socketOptions = { 27 | urls, 28 | }; 29 | function createClient(): Client { 30 | return new Client(ClientType.GameClient); 31 | } 32 | function tearDown(done: (err?: any) => void) { 33 | if (client) { 34 | client.close(); 35 | client = null; 36 | } 37 | if (server) { 38 | server.close(done); 39 | server = null; 40 | } else { 41 | done(); 42 | } 43 | } 44 | describe('connecting', () => { 45 | it('connects', done => { 46 | client = createClient(); 47 | 48 | server = new WebSocket.Server({ port }); 49 | client.open(socketOptions); 50 | awaitConnect(() => { 51 | ws.close(1000, 'Normal'); 52 | done(); 53 | }); 54 | }); 55 | after(done => tearDown(done)); 56 | }); 57 | 58 | describe('method handling', () => { 59 | it('handles hello', done => { 60 | client = createClient(); 61 | client.on('hello', () => { 62 | done(); 63 | }); 64 | client.processMethod(new Method('hello', {}, true, 0)); 65 | }); 66 | }); 67 | 68 | describe('state synchronization', () => { 69 | let executeStub: sinon.SinonStub; 70 | const scenes: ISceneData[] = [{ sceneID: 'default', controls: [] }]; 71 | const groups: IGroupData[] = [{ groupID: 'default' }]; 72 | 73 | beforeEach(() => { 74 | client = createClient(); 75 | executeStub = sinon.stub(client, 'execute'); 76 | }); 77 | afterEach(() => { 78 | executeStub.restore(); 79 | }); 80 | 81 | it('synchronizes scenes', () => { 82 | executeStub.onCall(0).resolves(scenes); 83 | const syncScenesStub = sinon.stub(client.state, 'synchronizeScenes'); 84 | return client.synchronizeScenes().then(() => { 85 | expect(syncScenesStub).to.have.been.calledWith(scenes); 86 | 87 | syncScenesStub.restore(); 88 | }); 89 | }); 90 | 91 | it('synchronizes groups', () => { 92 | executeStub.onCall(0).resolves(groups); 93 | const syncGroupsStub = sinon.stub(client.state, 'synchronizeGroups'); 94 | return client.synchronizeGroups().then(() => { 95 | expect(syncGroupsStub).to.have.been.calledWith(groups); 96 | 97 | syncGroupsStub.restore(); 98 | }); 99 | }); 100 | 101 | it('synchronizes state', () => { 102 | executeStub.withArgs('getGroups', null, false).resolves(groups); 103 | executeStub.withArgs('getScenes', null, false).resolves(scenes); 104 | const syncGroupsStub = sinon.stub(client.state, 'synchronizeGroups'); 105 | const syncScenesStub = sinon.stub(client.state, 'synchronizeScenes'); 106 | return client.synchronizeState().then(() => { 107 | expect(syncScenesStub).to.have.been.calledWith(scenes); 108 | expect(syncGroupsStub).to.have.been.calledWith(groups); 109 | 110 | syncGroupsStub.restore(); 111 | syncScenesStub.restore(); 112 | }); 113 | }); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /src/state/interfaces/controls/IControl.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | import { ETag, IParticipant } from '../'; 4 | import { IClient } from '../../../IClient'; 5 | import { IInput, IInputEvent } from './IInput'; 6 | import { IMeta } from './IMeta'; 7 | 8 | export type ControlKind = string; 9 | export type GridSize = 'large' | 'medium' | 'small'; 10 | 11 | export interface IGridLayout { 12 | readonly size: GridSize; 13 | readonly width: number; 14 | readonly height: number; 15 | } 16 | 17 | /** 18 | * Represents the raw data a control has when transmitted 19 | * and received over a socket connection. 20 | */ 21 | export interface IControlData { 22 | /** 23 | * An id, unique to the session. 24 | */ 25 | controlID?: string; 26 | /** 27 | * The type of control. 28 | */ 29 | kind?: string; 30 | /** 31 | * Wether or not this control is disabled. 32 | */ 33 | disabled?: boolean; 34 | /** 35 | * The collection of Meta properties for this control. 36 | */ 37 | meta?: IMeta; 38 | /** 39 | * A collection of grid placements controlling where the control 40 | * is positioned on screen. 41 | */ 42 | position?: IGridPlacement[]; 43 | /** 44 | * @deprecated etags are no longer used, you can always omit/ignore this 45 | */ 46 | etag?: ETag; 47 | } 48 | 49 | /** 50 | * Represents updatable components of a control which developers can update 51 | * from game clients. 52 | */ 53 | export interface IControlUpdate { 54 | /** 55 | * When set to true this will disable the control. 56 | * When set to false this will enable the control. 57 | */ 58 | disabled?: boolean; 59 | } 60 | 61 | /** 62 | * Control is used a base class for all other controls within an interactive session. 63 | * It contains shared logic which all types of controls can utilize. 64 | */ 65 | export interface IControl extends IControlData, EventEmitter { 66 | client: IClient; 67 | 68 | // Frontend 69 | /** 70 | * Give input causes the control to give input to the mediator status in response to a 71 | * control event. For example a mousedown on a button would end up here. 72 | */ 73 | giveInput?(input: IInput): Promise; 74 | 75 | receiveInput(input: IInputEvent, participant: IParticipant): void; 76 | 77 | // GameClient 78 | /** 79 | * Disables this control, preventing all participants from providing input to this control 80 | */ 81 | disable(): Promise; 82 | /** 83 | * Enables this control. 84 | */ 85 | enable(): Promise; 86 | 87 | /** 88 | * Merges in updated control data from the mediator 89 | */ 90 | onUpdate(controlData: IControlData): void; 91 | 92 | /** 93 | * Updates the control with the supplied update parameters 94 | */ 95 | update(controlUpdate: IControlUpdate): Promise; 96 | 97 | /** 98 | * Fired when the control is deleted. 99 | */ 100 | on(event: 'deleted', listener: (control: IControl) => void): this; 101 | /** 102 | * Fired when the control is updated with new data. 103 | */ 104 | on(event: 'updated', listener: (control: IControl) => void): this; 105 | on(event: string, listener: Function): this; 106 | 107 | destroy(): void; 108 | } 109 | 110 | /** 111 | * A grid placement represents a placement of a control within a scene. 112 | * It controls how the control is rendered. 113 | * 114 | * A control can have many grid placements where each placement is used within 115 | * a different interactive grid. 116 | */ 117 | export interface IGridPlacement { 118 | /** 119 | * The Size of the grid this placement is for. 120 | */ 121 | size: GridSize; 122 | /** 123 | * The width of this control within the grid. 124 | */ 125 | width: number; 126 | /** 127 | * The height of this control within the grid. 128 | */ 129 | height: number; 130 | /** 131 | * The X position of this control within the grid. 132 | */ 133 | x: number; 134 | /** 135 | * The Y position of this control within the grid. 136 | */ 137 | y: number; 138 | } 139 | -------------------------------------------------------------------------------- /examples/util.ts: -------------------------------------------------------------------------------- 1 | import { IButtonData, IControlData, IGridPlacement } from '../lib'; 2 | 3 | // tslint:disable-next-line 4 | const CSS_COLOR_NAMES = ["AliceBlue","AntiqueWhite","Aqua","Aquamarine","Azure","Beige","Bisque","Black","BlanchedAlmond","Blue","BlueViolet","Brown","BurlyWood","CadetBlue","Chartreuse","Chocolate","Coral","CornflowerBlue","Cornsilk","Crimson","Cyan","DarkBlue","DarkCyan","DarkGoldenRod","DarkGray","DarkGrey","DarkGreen","DarkKhaki","DarkMagenta","DarkOliveGreen","Darkorange","DarkOrchid","DarkRed","DarkSalmon","DarkSeaGreen","DarkSlateBlue","DarkSlateGray","DarkSlateGrey","DarkTurquoise","DarkViolet","DeepPink","DeepSkyBlue","DimGray","DimGrey","DodgerBlue","FireBrick","FloralWhite","ForestGreen","Fuchsia","Gainsboro","GhostWhite","Gold","GoldenRod","Gray","Grey","Green","GreenYellow","HoneyDew","HotPink","IndianRed","Indigo","Ivory","Khaki","Lavender","LavenderBlush","LawnGreen","LemonChiffon","LightBlue","LightCoral","LightCyan","LightGoldenRodYellow","LightGray","LightGrey","LightGreen","LightPink","LightSalmon","LightSeaGreen","LightSkyBlue","LightSlateGray","LightSlateGrey","LightSteelBlue","LightYellow","Lime","LimeGreen","Linen","Magenta","Maroon","MediumAquaMarine","MediumBlue","MediumOrchid","MediumPurple","MediumSeaGreen","MediumSlateBlue","MediumSpringGreen","MediumTurquoise","MediumVioletRed","MidnightBlue","MintCream","MistyRose","Moccasin","NavajoWhite","Navy","OldLace","Olive","OliveDrab","Orange","OrangeRed","Orchid","PaleGoldenRod","PaleGreen","PaleTurquoise","PaleVioletRed","PapayaWhip","PeachPuff","Peru","Pink","Plum","PowderBlue","Purple","Red","RosyBrown","RoyalBlue","SaddleBrown","Salmon","SandyBrown","SeaGreen","SeaShell","Sienna","Silver","SkyBlue","SlateBlue","SlateGray","SlateGrey","Snow","SpringGreen","SteelBlue","Tan","Teal","Thistle","Tomato","Turquoise","Violet","Wheat","White","WhiteSmoke","Yellow","YellowGreen"]; 5 | 6 | export function makeControls(amount: number = 5, textGenerator: (i: number) => string): IControlData[] { 7 | const controls: IButtonData[] = []; 8 | const size = 10; 9 | for (let i = 0; i < amount; i++) { 10 | controls.push({ 11 | controlID: `${i}`, 12 | kind: 'button', 13 | text: textGenerator(i), 14 | tooltip: textGenerator(i), 15 | cost: Math.floor(Math.random() * Math.floor(100)), 16 | progress: Math.random(), 17 | backgroundColor: CSS_COLOR_NAMES[Math.floor(Math.random() * CSS_COLOR_NAMES.length)], 18 | textColor: CSS_COLOR_NAMES[Math.floor(Math.random() * CSS_COLOR_NAMES.length)], 19 | accentColor: CSS_COLOR_NAMES[Math.floor(Math.random() * CSS_COLOR_NAMES.length)], 20 | borderColor: CSS_COLOR_NAMES[Math.floor(Math.random() * CSS_COLOR_NAMES.length)], 21 | position: getGridPlacement(i, 0, size), 22 | }); 23 | } 24 | return controls; 25 | } 26 | 27 | export function updateControls(controls: IControlData[], textGenerator: (i: number) => string): IControlData[] { 28 | controls.forEach((control: IButtonData, i) => { 29 | control.text = textGenerator(i); 30 | control.tooltip = textGenerator(i); 31 | control.cost = Math.floor(Math.random() * Math.floor(100)); 32 | control.progress = Math.random(); 33 | control.backgroundColor = CSS_COLOR_NAMES[Math.floor(Math.random() * CSS_COLOR_NAMES.length)]; 34 | control.textColor = CSS_COLOR_NAMES[Math.floor(Math.random() * CSS_COLOR_NAMES.length)]; 35 | control.accentColor = CSS_COLOR_NAMES[Math.floor(Math.random() * CSS_COLOR_NAMES.length)]; 36 | control.borderColor = CSS_COLOR_NAMES[Math.floor(Math.random() * CSS_COLOR_NAMES.length)]; 37 | }); 38 | return controls; 39 | } 40 | 41 | // This makes grid placement objects dynamically, to be used for our controls below. 42 | export function getGridPlacement(x: number, y: number, size: number = 10): IGridPlacement[] { 43 | return [ 44 | { 45 | size: 'large', 46 | width: size, 47 | height: size, 48 | x: x * size, 49 | y: y * size, 50 | }, 51 | { 52 | size: 'medium', 53 | width: size, 54 | height: size, 55 | x: x * size, 56 | y: y * size, 57 | }, 58 | { 59 | size: 'small', 60 | width: size, 61 | height: size, 62 | x: x * size, 63 | y: y * size, 64 | }, 65 | ]; 66 | } 67 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 3.x 4 | 5 | ### 3.0 6 | - Renamed to @mixer/interactive-node 7 | - Added 4023 -> 4027 Error Code Classes 8 | - Disable reconnection logic on 4027 9 | 10 | ## 2.x 11 | 12 | ### 2.9 13 | - Added channelGroups to participants (#100) 14 | - Added broadcast event support (#101) 15 | - Added anonymous property to participant (#102) 16 | - Added participant queries (#102) 17 | - Added screen control properties for sending mouse up and mouse down events (#103) 18 | 19 | ### 2.8.4 20 | - Update screen control properties for move events and move throttle. 21 | 22 | ### 2.8.2 23 | - Added move and mousedown events to screen control (#99) 24 | 25 | ### 2.8.1 26 | - Added screen control (#98) 27 | 28 | ### 2.8 29 | - Added textbox control (#96) 30 | - Added label control (#96) 31 | - Added additional properties to button control (#96) 32 | 33 | ### 2.7.2 34 | - Fix example building 35 | 36 | ### 2.7.1 37 | - Fix a small typo in one of the examples (#93) 38 | - Fix websocket authentication when used in browsers (#95) 39 | 40 | ### 2.7 41 | - Add world update methods (#94) 42 | - Adjust prettier config to Mixer standard and did a pass 43 | 44 | ### 2.6 45 | - Fixed a couple of housekeeping issues 46 | 47 | ### 2.5 48 | - Client will now use a round robin strategy when it encounters issues with an interactive server it is trying to connect to (#92) 49 | - Add sharecode support (#86). Thanks @JohnyCilohokla. 50 | - Update package.json links (#90). Thanks @metaa 51 | - Add keyboard events to buttons (#88) 52 | 53 | ### 2.4 54 | - Added tooltip properties to buttons. 55 | - Removed Etags, these are no longer needed see https://github.com/mixer/developers/issues/160 for more information. 56 | - Authentication changes, we now use stream access keys to connect to interactive for Mixer.com 57 | 58 | ### 2.3 59 | - Added a list of frontend grid sizes `gridLayoutSizes` (#71) 60 | - Improved typings for control metadata (#82) 61 | 62 | ### 2.2 63 | - Added `State.getGroups()`, `State.getParticipants()` and `State.getScenes()` (#78) 64 | - Internal project cleanup 65 | 66 | ### 2.1.0 Bug Fixes and Utility Methods 67 | 68 | - Fixed some re-branding issues (#51 #52 #55) 69 | - Thanks @alfw, @kateract ! 70 | - Add an initial state to the socket (#56) 71 | - Add a singular form of `createScenes` called `createScene` which can be used for tidier use cases (#62) 72 | - Thanks @metaa ! 73 | - Added `synchronizeState` which will retrieve `Scenes` and `Groups` from the server (#57) 74 | - Can be used in the place of two calls to `synchronizeScenes` and `synchronizeGroups` 75 | - Added an `update` method to `Button` and `Joystick` which allows batch updates (#65) 76 | - Added the ability to specify a custom discovery url for internal Mixer developments (#69) 77 | 78 | ### 2.0.0 Groups and Scenes **Breaking** 79 | 80 | With some awesome community contributions we've now added the following features: 81 | 82 | - Added `setCost` to Buttons thanks @kateract 83 | - Added methods to manipulate scenes and groups 84 | - `createGroups` 85 | - `updateGroups` 86 | - `createScenes` 87 | - `updateParticipants` 88 | - `synchronizeGroups` 89 | - `getGroups` 90 | 91 | #### Breaking Changes 92 | 93 | This release includes some minor breaking changes: 94 | 95 | - Minor refactor of IGroup* interfaces to align with I, IData, IDataArray pattern used elsewhere. 96 | 97 | ## 1.x 98 | 99 | ### 1.0.0 Endpoint Discovery **Breaking** 100 | 101 | For interactive 2 its important to always retrieve a list of servers from our API before connecting. This used to be up to the implementer. With 1.0.0 we're placing this responsibility inside the client. This should make getting up and running easier. 102 | 103 | `client.open` now returns a Promise, which resolves when the connection is open. You should move all logic that previously assumed the connection would open immediately into a promise chain attached to `client.open`. 104 | 105 | All of the [examples](examples/) have been updated to reflect this change. 106 | 107 | ## 0.x 108 | 109 | ### 0.11.0 Protocol Fixes 110 | 111 | This fixes several protocol issues with our initial implementation. 112 | 113 | - Correct updateControls structure to allow cool downs and text setting. 114 | - Added ETags to participants 115 | - Fixes State.getControl breaking when there are multiple scenes 116 | - Make .gitignore to ignore all .js files in /examples/ 117 | - correct disabled in participants. 118 | -------------------------------------------------------------------------------- /examples/relay.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This example is for custom bundles. In this script, this assumes 3 | * that your bundle will giveInput on a control with the controlID "setup", 4 | * with the event being "create", that passes a property controls that is 5 | * and array of strings. 6 | * 7 | * These controls will have a kind of generic, as we don't actually care 8 | * what they are, as the event will be broadcasted back to everyone, where 9 | * the bundle will handle these events. 10 | * 11 | * Example: 12 | * mixer.socket.call('giveInput', { 13 | * controlID: 'setup', 14 | * event: 'create', 15 | * controls: ['btn-one', 'hello-world'], 16 | * }); 17 | * 18 | */ 19 | 20 | /* tslint:disable:no-console */ 21 | import * as WebSocket from 'ws'; 22 | 23 | import { 24 | GameClient, 25 | IControl, 26 | IControlData, 27 | IInputEvent, 28 | IParticipant, 29 | setWebSocket, 30 | } from '../lib'; 31 | 32 | // import { makeControls } from './util'; 33 | 34 | if (process.argv.length < 4) { 35 | console.log('Usage gameClient.exe '); 36 | process.exit(); 37 | } 38 | // We need to tell the interactive client what type of websocket we are using. 39 | setWebSocket(WebSocket); 40 | 41 | // As we're on the Streamer's side we need a "GameClient" instance 42 | const client = new GameClient(); 43 | 44 | // Log when we're connected to interactive and setup your game! 45 | client.on('open', () => { 46 | console.log('Connected to Interactive!'); 47 | client 48 | .createControls({ 49 | sceneID: 'default', 50 | controls: [ 51 | { 52 | controlID: 'setup', 53 | kind: 'generic', 54 | }, 55 | ], 56 | }) 57 | .then(() => client.synchronizeState()) 58 | .then(() => client.ready(true)) 59 | .then(() => client.state.getControl('setup')) 60 | .then((control: IControl) => { 61 | return new Promise((resolve, reject) => { 62 | // The Setup controls listens for the create event onces 63 | // to go off and create the controls that will be used during 64 | // communication from the participant to this game client. 65 | control.once( 66 | 'create', 67 | ( 68 | event: IInputEvent, 69 | _participant: IParticipant, 70 | ) => { 71 | const controls: IControlData[] = []; 72 | if (event.input.controls) { 73 | event.input.controls.forEach(id => { 74 | controls.push({ 75 | controlID: id, 76 | kind: 'generic', 77 | }); 78 | }); 79 | } 80 | client 81 | .createControls({ 82 | sceneID: 'default', 83 | controls, 84 | }) 85 | .then(() => resolve()) 86 | .catch((_err: Error) => reject()); 87 | }, 88 | ); 89 | }); 90 | }); 91 | }); 92 | 93 | // These can be un-commented to see the raw JSON messages under the hood 94 | // client.on('send', (err: any) => console.log('>>>', err)); 95 | // client.on('error', (err: any) => console.log(err)); 96 | 97 | // Now we open the connection passing in our authentication details and an experienceId. 98 | client.open({ 99 | authToken: process.argv[2], 100 | versionId: parseInt(process.argv[3], 10), 101 | }); 102 | 103 | client.state.on('participantJoin', (participant: IParticipant) => { 104 | console.log(`${participant.username}(${participant.sessionID}) Joined`); 105 | }); 106 | client.state.on('participantLeave', (participantSessionID: string, participant: IParticipant) => { 107 | console.log(`${participant.username}(${participantSessionID}) Left`); 108 | }); 109 | 110 | client.on('message', (str: string) => { 111 | const blob = JSON.parse(str); 112 | if ( 113 | blob.type === 'method' && 114 | blob.method === 'giveInput' && 115 | blob.params.input.controlID !== 'setup' 116 | ) { 117 | broadcast(blob); 118 | } 119 | }); 120 | 121 | function broadcast(params: any) { 122 | client.broadcastEvent({ 123 | scope: ['everyone'], 124 | data: params, 125 | }); 126 | } 127 | /* tslint:enable:no-console */ 128 | -------------------------------------------------------------------------------- /src/state/interfaces/controls/IButton.ts: -------------------------------------------------------------------------------- 1 | import { IParticipant } from '../'; 2 | import { IControl, IControlData, IControlUpdate } from './IControl'; 3 | import { IButtonKeyboardInput, IButtonMouseInput, IInputEvent } from './IInput'; 4 | 5 | /** 6 | * Extends the regular control data with additional properties for Buttons 7 | */ 8 | export interface IButtonData extends IControlData { 9 | /** 10 | * The text displayed on the button. 11 | */ 12 | text?: string; 13 | /** 14 | * The tooltip text displayed when the participant hovers over the button. 15 | */ 16 | tooltip?: string; 17 | /** 18 | * The spark cost of this button. 19 | */ 20 | cost?: number; 21 | /** 22 | * The progress bar value of this button. 0 - 1. 23 | */ 24 | progress?: number; 25 | /** 26 | * A unix timestamp of when this button's cooldown will expire. 27 | */ 28 | cooldown?: number; 29 | /** 30 | * A JavaScript keycode which participants can use to activate this button. 31 | */ 32 | keyCode?: number; 33 | /** 34 | * The color of the text. 35 | */ 36 | textColor?: string; 37 | /** 38 | * The size of the text. 39 | */ 40 | textSize?: string; 41 | /** 42 | * The color of the border. 43 | */ 44 | borderColor?: string; 45 | /** 46 | * The background color of the button. 47 | */ 48 | backgroundColor?: string; 49 | /** 50 | * The background image of the button. 51 | */ 52 | backgroundImage?: string; 53 | /** 54 | * The hover & Focus border color of the button. 55 | */ 56 | focusColor?: string; 57 | /** 58 | * The progress bar & cooldown spinner color for the button. 59 | */ 60 | accentColor?: string; 61 | } 62 | 63 | /** 64 | * Represents updatable components of a button which developers can update 65 | * from game clients. 66 | */ 67 | export interface IButtonUpdate extends IControlUpdate { 68 | /** 69 | * Will update the text of this button. 70 | */ 71 | text?: string; 72 | /** 73 | * Will update the tooltip of this button. 74 | */ 75 | tooltip?: string; 76 | /** 77 | * In milliseconds, will be converted to a unix timestamp of when this cooldown expires. 78 | */ 79 | cooldown?: number; 80 | /** 81 | * Will update the spark cost of this button. 82 | */ 83 | cost?: number; 84 | /** 85 | * Will update the progress bar underneath the button. 0 - 1. 86 | */ 87 | progress?: number; 88 | /** 89 | * Will update the keycode used by participants for keyboard control. 90 | */ 91 | keyCode?: number; 92 | /** 93 | * The color of the text. 94 | */ 95 | textColor?: string; 96 | /** 97 | * The size of the text. 98 | */ 99 | textSize?: string; 100 | /** 101 | * The color of the border. 102 | */ 103 | borderColor?: string; 104 | /** 105 | * Background color of the button. 106 | */ 107 | backgroundColor?: string; 108 | /** 109 | * Background image of the button. 110 | */ 111 | backgroundImage?: string; 112 | /** 113 | * Hover & Focus border color of the button. 114 | */ 115 | focusColor?: string; 116 | /** 117 | * Progress bar color for the button. 118 | */ 119 | accentColor?: string; 120 | } 121 | 122 | export interface IButton extends IControl, IButtonData { 123 | text: string; 124 | cost: number; 125 | progress: number; 126 | cooldown: number; 127 | keyCode: number; 128 | textColor: string; 129 | textSize: string; 130 | borderColor: string; 131 | backgroundColor: string; 132 | backgroundImage: string; 133 | focusColor: string; 134 | accentColor: string; 135 | // GameClient 136 | setText(text: string): Promise; 137 | setProgress(progress: number): Promise; 138 | setCooldown(duration: number): Promise; 139 | setCost(cost: number): Promise; 140 | setTextSize(textSize: string): Promise; 141 | setBorderColor(borderColor: string): Promise; 142 | setBackgroundColor(backgroundColor: string): Promise; 143 | setBackgroundImage(backgroundImage: string): Promise; 144 | setFocusColor(focusColor: string): Promise; 145 | setAccentColor(accentColor: string): Promise; 146 | setTextColor(textColor: string): Promise; 147 | update(changedData: IButtonUpdate): Promise; 148 | 149 | /** 150 | * Fired when a participant presses this button with their mouse. 151 | */ 152 | on( 153 | event: 'mousedown', 154 | listener: (inputEvent: IInputEvent, participant: IParticipant) => void, 155 | ): this; 156 | /** 157 | * Fired when a participant releases this button with their mouse. 158 | */ 159 | on( 160 | event: 'mouseup', 161 | listener: (inputEvent: IInputEvent, participant: IParticipant) => void, 162 | ): this; 163 | /** 164 | * Fired when a participant presses the key associated with this button. 165 | */ 166 | on( 167 | event: 'keydown', 168 | listener: ( 169 | inputEvent: IInputEvent, 170 | participant: IParticipant, 171 | ) => void, 172 | ): this; 173 | /** 174 | * Fired when a participant releases the key associated with this button. 175 | */ 176 | on( 177 | event: 'keyup', 178 | listener: ( 179 | inputEvent: IInputEvent, 180 | participant: IParticipant, 181 | ) => void, 182 | ): this; 183 | on(event: string, listener: Function): this; 184 | } 185 | -------------------------------------------------------------------------------- /examples/groups.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-console */ 2 | import * as WebSocket from 'ws'; 3 | 4 | import { 5 | GameClient, 6 | Group, 7 | IParticipant, 8 | ISceneDataArray, 9 | setWebSocket, 10 | } from '../lib'; 11 | 12 | import { makeControls } from './util'; 13 | 14 | if (process.argv.length < 4) { 15 | console.log('Usage gameClient.exe '); 16 | console.log(process.argv); 17 | process.exit(); 18 | } 19 | 20 | // We need to tell the interactive client what type of websocket we are using. 21 | setWebSocket(WebSocket); 22 | 23 | // As we're on the Streamer's side we need a "GameClient" instance 24 | const client = new GameClient(); 25 | 26 | // Log when we're connected to interactive and setup your game! 27 | client.on('open', () => { 28 | console.log('Connected to Interactive!'); 29 | // Pull the scenes and groups from the interactive server 30 | client 31 | .synchronizeState() 32 | 33 | // Set the client as ready so that interactive controls show up 34 | .then(() => client.ready(true)) 35 | 36 | // Create the scenes for our application 37 | .then(createScenes) 38 | 39 | // Create the groups for our application 40 | .then(createGroups) 41 | 42 | // Catch any errors 43 | .catch((reason: Error) => console.error('Promise rejected', reason)); 44 | }); 45 | 46 | // A collection of interval timers, one for each participant 47 | const participantTimers: Map = new Map(); 48 | 49 | // The time between when we switch groups for each participant 50 | const delayTime = 2000; 51 | 52 | // These can be un-commented to see the raw JSON messages under the hood 53 | // client.on('message', (err: any) => console.log('<<<', err)); 54 | // client.on('send', (err: any) => console.log('>>>', err)); 55 | // client.on('error', (err: any) => console.log(err)); 56 | 57 | /** 58 | * Swaps the group the current participant is in between secondGroup and default. 59 | */ 60 | function swapParticipantGroup(participant: IParticipant): Promise { 61 | if (participant.groupID === 'default') { 62 | participant.groupID = 'secondGroup'; 63 | } else { 64 | participant.groupID = 'default'; 65 | } 66 | 67 | return client.updateParticipants({ 68 | participants: [participant], 69 | }); 70 | } 71 | 72 | /** 73 | * Removes the participant info from the participantTimers map and stops their timer. 74 | */ 75 | function removeParticipant(participantSessionId: string): void { 76 | if (participantTimers.has(participantSessionId)) { 77 | clearInterval(participantTimers[participantSessionId]); 78 | delete participantTimers[participantSessionId]; 79 | } 80 | } 81 | 82 | /** 83 | * Create the scenes used by the application. 84 | */ 85 | function createScenes(): Promise { 86 | const defaultScene = client.state.getScene('default'); 87 | defaultScene.createControls(makeControls(1, () => 'Scene: default')); 88 | 89 | const secondScene = { 90 | sceneID: 'secondScene', 91 | controls: makeControls(1, () => 'Scene: second'), 92 | }; 93 | 94 | return client.createScenes({ 95 | scenes: [secondScene], 96 | }); 97 | } 98 | 99 | /** 100 | * Create and setup the groups used by the application. 101 | */ 102 | function createGroups(): Promise { 103 | const defaultGroup = client.state.getGroup('default'); 104 | defaultGroup.sceneID = 'default'; 105 | 106 | const secondGroup = new Group({ 107 | groupID: 'secondGroup', 108 | sceneID: 'secondScene', 109 | }); 110 | 111 | const thirdGroup = new Group({ 112 | groupID: 'thirdGroup', 113 | sceneID: 'default', 114 | }); 115 | 116 | return ( 117 | client 118 | // First update the default group 119 | .updateGroups({ 120 | groups: [defaultGroup], 121 | }) 122 | 123 | // Then create the new groups 124 | .then(() => 125 | client.createGroups({ 126 | groups: [secondGroup, thirdGroup], 127 | }), 128 | ) 129 | 130 | // Then delete the third group 131 | .then(() => 132 | client.deleteGroup({ 133 | groupID: thirdGroup.groupID, 134 | reassignGroupID: defaultGroup.groupID, 135 | }), 136 | ) 137 | ); 138 | } 139 | 140 | // Now we open the connection passing in our authentication details and an experienceId. 141 | client 142 | // Open the Mixer interactive client with command line args 143 | .open({ 144 | authToken: process.argv[2], 145 | versionId: parseInt(process.argv[3], 10), 146 | }) 147 | 148 | // Catch any errors 149 | .catch((reason: Error) => console.error('Promise rejected', reason)); 150 | 151 | client.state.on('participantJoin', (participant: IParticipant) => { 152 | console.log(`${participant.username}(${participant.sessionID}) Joined`); 153 | 154 | if (!participantTimers.has(participant.sessionID)) { 155 | participantTimers[participant.sessionID] = setInterval(() => { 156 | const p = client.state.getParticipantBySessionID( 157 | participant.sessionID, 158 | ); 159 | 160 | if (p) { 161 | swapParticipantGroup(p); 162 | } else { 163 | removeParticipant(participant.sessionID); 164 | } 165 | }, delayTime); 166 | } 167 | }); 168 | 169 | client.state.on( 170 | 'participantLeave', 171 | (participantSessionID: string, participant: IParticipant) => { 172 | console.log(`${participant.username}(${participantSessionID}) Left`); 173 | removeParticipant(participantSessionID); 174 | }, 175 | ); 176 | /* tslint:enable:no-console */ 177 | -------------------------------------------------------------------------------- /src/state/Scene.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | import { IClient } from '../IClient'; 4 | import { merge } from '../merge'; 5 | import { IControl, IControlData } from './interfaces/controls/IControl'; 6 | import { IMeta } from './interfaces/controls/IMeta'; 7 | import { IScene, ISceneData } from './interfaces/IScene'; 8 | import { StateFactory } from './StateFactory'; 9 | 10 | /** 11 | * A Scene is a collection of controls within an interactive experience. Groups can be 12 | * set to a scene. Which Scene a group is set to determine which controls they see. 13 | * 14 | * You can use scenes to logically group together controls for some meaning. 15 | */ 16 | export class Scene extends EventEmitter implements IScene { 17 | public sceneID: string; 18 | public controls = new Map(); 19 | public groups: any; 20 | /** 21 | * @deprecated etags are no longer used, you can always omit/ignore this 22 | */ 23 | public etag: string; 24 | public meta: IMeta = {}; 25 | 26 | private client: IClient; 27 | 28 | public setClient(client: IClient) { 29 | this.client = client; 30 | this.stateFactory.setClient(client); 31 | } 32 | 33 | private stateFactory = new StateFactory(); 34 | 35 | constructor(data: ISceneData) { 36 | super(); 37 | this.sceneID = data.sceneID; 38 | this.meta = data.meta || {}; 39 | } 40 | 41 | /** 42 | * Called when controls are added to this scene. 43 | */ 44 | public onControlsCreated(controls: IControlData[]): IControl[] { 45 | return controls.map(control => this.onControlCreated(control)); 46 | } 47 | 48 | /** 49 | * Called when a control is added to this scene. 50 | */ 51 | private onControlCreated(controlData: IControlData): IControl { 52 | let control = this.controls.get(controlData.controlID); 53 | if (control) { 54 | this.onControlUpdated(controlData); 55 | return control; 56 | } 57 | control = this.stateFactory.createControl(controlData.kind, controlData, this); 58 | this.controls.set(control.controlID, control); 59 | this.emit('controlAdded', control); 60 | return control; 61 | } 62 | 63 | /** 64 | * Called when controls are deleted from this scene. 65 | */ 66 | public onControlsDeleted(controls: IControlData[]) { 67 | controls.forEach(control => this.onControlDeleted(control)); 68 | } 69 | 70 | /** 71 | * Called when a control is deleted from this scene. 72 | */ 73 | private onControlDeleted(control: IControlData) { 74 | this.controls.delete(control.controlID); 75 | this.emit('controlDeleted', control.controlID); 76 | } 77 | 78 | /** 79 | * Called when a control in this scene is updated 80 | */ 81 | private onControlUpdated(controlData: IControlData) { 82 | const control = this.getControl(controlData.controlID); 83 | if (control) { 84 | control.onUpdate(controlData); 85 | } 86 | } 87 | /** 88 | * Called when the controls in this scene are updated. 89 | */ 90 | public onControlsUpdated(controls: IControlData[]) { 91 | controls.forEach(control => this.onControlUpdated(control)); 92 | } 93 | 94 | /** 95 | * Retrieve a control in this scene by its id. 96 | */ 97 | public getControl(id: string): IControl { 98 | return this.controls.get(id); 99 | } 100 | 101 | /** 102 | * Retrieves all the controls in this scene. 103 | */ 104 | public getControls(): IControl[] { 105 | return Array.from(this.controls.values()); 106 | } 107 | 108 | /** 109 | * Creates a control in this scene, sending it to the server. 110 | */ 111 | public createControl(control: IControlData): Promise { 112 | return this.createControls([control]).then(res => res[0]); 113 | } 114 | 115 | /** 116 | * Creates a collection of controls in this scene, sending it to the server. 117 | */ 118 | public createControls(controls: IControlData[]): Promise { 119 | return this.client.createControls({ sceneID: this.sceneID, controls }); 120 | } 121 | 122 | /** 123 | * Updates a collection of controls in this scene, sending it to the server. 124 | */ 125 | public updateControls(controls: IControlData[]): Promise { 126 | return this.client.updateControls({ sceneID: this.sceneID, controls }); 127 | } 128 | 129 | /** 130 | * Deletes controls in this scene from the server. 131 | */ 132 | public deleteControls(controlIDs: string[]): Promise { 133 | return this.client.deleteControls({ 134 | sceneID: this.sceneID, 135 | controlIDs: controlIDs, 136 | }); 137 | } 138 | 139 | /** 140 | * Deletes a single control in this scene from the server. 141 | */ 142 | public deleteControl(controlId: string) { 143 | return this.deleteControls([controlId]); 144 | } 145 | 146 | /** 147 | * Fires destruction events for each control in this scene. 148 | */ 149 | public destroy() { 150 | //TODO find the group they should now be on 151 | this.controls.forEach(control => { 152 | this.emit('controlDeleted', control.controlID); 153 | }); 154 | } 155 | 156 | /** 157 | * Merges new data from the server into this scene. 158 | */ 159 | public update(scene: ISceneData) { 160 | if (scene.meta) { 161 | merge(this.meta, scene.meta); 162 | this.emit('update', this); 163 | } 164 | } 165 | 166 | /** 167 | * Deletes all controls in this scene from the server. 168 | */ 169 | public deleteAllControls(): Promise { 170 | const ids: string[] = []; 171 | this.controls.forEach((_, key) => { 172 | ids.push(key); 173 | }); 174 | return this.deleteControls(ids); 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/wire/packets.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import { IInteractiveError, InteractiveError } from '../errors'; 3 | import { IRawValues } from '../interfaces'; 4 | 5 | export enum PacketState { 6 | /** 7 | * The packet has not been sent yet, it may be queued for later sending. 8 | */ 9 | Pending = 1, 10 | /** 11 | * The packet has been sent over the websocket successfully and we are 12 | * waiting for a reply. 13 | */ 14 | Sending, 15 | /** 16 | * The packet was replied to, and has now been complete. 17 | */ 18 | Replied, 19 | /** 20 | * The caller has indicated they no longer wish to be notified about this event. 21 | */ 22 | Cancelled, 23 | } 24 | 25 | const maxInt32 = 0xffffffff; 26 | 27 | /** 28 | * A Packet is a wrapped Method that can be timed-out or canceled whilst it travels over the wire. 29 | */ 30 | export class Packet extends EventEmitter { 31 | private state: PacketState = PacketState.Pending; 32 | private timeout: number; 33 | private method: Method; 34 | 35 | constructor(method: Method) { 36 | super(); 37 | this.method = method; 38 | } 39 | 40 | /** 41 | * Returns the randomly-assigned numeric ID of the packet. 42 | * @return {number} 43 | */ 44 | public id(): number { 45 | return this.method.id; 46 | } 47 | 48 | /** 49 | * Aborts sending the message, if it has not been sent yet. 50 | */ 51 | public cancel() { 52 | this.emit('cancel'); 53 | this.setState(PacketState.Cancelled); 54 | } 55 | 56 | /** 57 | * toJSON implements is called in JSON.stringify. 58 | */ 59 | public toJSON(): IRawValues { 60 | return this.method; 61 | } 62 | 63 | /** 64 | * Sets the timeout duration on the packet. It defaults to the socket's 65 | * timeout duration. 66 | */ 67 | public setTimeout(duration: number) { 68 | this.timeout = duration; 69 | } 70 | 71 | /** 72 | * Returns the packet's timeout duration, or the default if undefined. 73 | */ 74 | public getTimeout(defaultTimeout: number): number { 75 | return this.timeout || defaultTimeout; 76 | } 77 | 78 | /** 79 | * Returns the current state of the packet. 80 | * @return {PacketState} 81 | */ 82 | public getState(): PacketState { 83 | return this.state; 84 | } 85 | 86 | /** 87 | * Sets the sequence number on the outgoing packet. 88 | */ 89 | public setSequenceNumber(x: number): this { 90 | this.method.seq = x; 91 | return this; 92 | } 93 | 94 | public setState(state: PacketState) { 95 | if (state === this.state) { 96 | return; 97 | } 98 | 99 | this.state = state; 100 | } 101 | } 102 | 103 | /** 104 | * A method represents a request from a client to call a method on the recipient. 105 | * They can contain arguments which the recipient will use as arguments for the method. 106 | * 107 | * The Recipient can then reply with a result or an error indicating the method failed. 108 | */ 109 | export class Method { 110 | public readonly type = 'method'; //tslint:disable-line 111 | public seq: number; 112 | 113 | constructor( 114 | /** 115 | * The name of this method 116 | */ 117 | public method: string, 118 | /** 119 | * Params to be used as arguments for this method. 120 | */ 121 | public params: T, 122 | /** 123 | * If discard is set to true it indicates that this method is not expecting a reply. 124 | * 125 | * Recipients should however reply with an error if one is caused by this method. 126 | */ 127 | public discard: boolean = false, 128 | /** 129 | * A Unique id for each method sent. 130 | */ 131 | public id: number = Math.floor(Math.random() * maxInt32), 132 | ) {} 133 | 134 | /** 135 | * Creates a method instance from a JSON decoded socket message. 136 | * @memberOf Method 137 | */ 138 | public static fromSocket(message: any): Method { 139 | return new Method(message.method, message.params, message.discard, message.id); 140 | } 141 | 142 | /** 143 | * Creates a reply for this method. 144 | */ 145 | public reply(result: IRawValues, error: InteractiveError.Base = null): Reply { 146 | return new Reply(this.id, result, error); 147 | } 148 | } 149 | 150 | /** 151 | * A reply represents a recipients response to a corresponding method with the same id. 152 | * It can contain a result or an error indicating that the method failed. 153 | */ 154 | export class Reply { 155 | public readonly type = 'reply'; //tslint:disable-line 156 | constructor( 157 | /** 158 | * A unique id for this reply, which must match the id of the method it is a reply for. 159 | */ 160 | public id: number, 161 | /** 162 | * The result of this method call. 163 | */ 164 | public result: IRawValues = null, 165 | /** 166 | * An error which if present indicates that a method call failed. 167 | */ 168 | public error: IInteractiveError = null, 169 | ) {} 170 | 171 | /** 172 | * Constructs a reply packet from raw values coming in from a socket. 173 | */ 174 | public static fromSocket(message: any): Reply { 175 | const err: InteractiveError.Base = message.error 176 | ? InteractiveError.fromSocketMessage(message.error) 177 | : null; 178 | return new Reply(message.id, message.result, err); 179 | } 180 | 181 | /** 182 | * Construct a reply packet that indicates an error. 183 | */ 184 | public static fromError(id: number, error: InteractiveError.Base): Reply { 185 | return new Reply(id, null, { 186 | message: error.message, 187 | code: error.code, 188 | }); 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /examples/textbox.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-console */ 2 | import * as WebSocket from 'ws'; 3 | 4 | import { 5 | GameClient, 6 | IParticipant, 7 | ITextbox, 8 | ITextboxData, 9 | setWebSocket, 10 | } from '../lib'; 11 | 12 | if (process.argv.length < 4) { 13 | console.log('Usage gameClient.exe '); 14 | process.exit(); 15 | } 16 | // We need to tell the interactive client what type of websocket we are using. 17 | setWebSocket(WebSocket); 18 | 19 | // As we're on the Streamer's side we need a "GameClient" instance 20 | const client = new GameClient(); 21 | 22 | const txtChange: ITextboxData = { 23 | kind: 'textbox', 24 | controlID: 'txtChange', 25 | placeholder: 'Text sends on change!', 26 | position: [ 27 | { 28 | size: 'large', 29 | width: 30, 30 | height: 4, 31 | x: 2, 32 | y: 1, 33 | }, 34 | { 35 | size: 'medium', 36 | width: 15, 37 | height: 4, 38 | x: 1, 39 | y: 0, 40 | }, 41 | { 42 | size: 'small', 43 | width: 15, 44 | height: 4, 45 | x: 1, 46 | y: 0, 47 | }, 48 | ], 49 | }; 50 | 51 | const txtSubmit: ITextboxData = { 52 | kind: 'textbox', 53 | controlID: 'txtSubmit', 54 | placeholder: 'Text sends on submit!', 55 | hasSubmit: true, 56 | position: [ 57 | { 58 | size: 'large', 59 | width: 30, 60 | height: 4, 61 | x: 2, 62 | y: 5, 63 | }, 64 | { 65 | size: 'medium', 66 | width: 15, 67 | height: 4, 68 | x: 0, 69 | y: 4, 70 | }, 71 | { 72 | size: 'small', 73 | width: 15, 74 | height: 4, 75 | x: 0, 76 | y: 4, 77 | }, 78 | ], 79 | }; 80 | 81 | const txtCost: ITextboxData = { 82 | kind: 'textbox', 83 | controlID: 'txtCost', 84 | placeholder: 'Forced Submit because cost!', 85 | cost: 200, 86 | position: [ 87 | { 88 | size: 'large', 89 | width: 30, 90 | height: 4, 91 | x: 2, 92 | y: 9, 93 | }, 94 | { 95 | size: 'medium', 96 | width: 15, 97 | height: 4, 98 | x: 0, 99 | y: 8, 100 | }, 101 | { 102 | size: 'small', 103 | width: 15, 104 | height: 4, 105 | x: 0, 106 | y: 8, 107 | }, 108 | ], 109 | }; 110 | 111 | // Log when we're connected to interactive and setup your game! 112 | client.on('open', () => { 113 | console.log('Connected to Interactive!'); 114 | // Now we can create the controls, We need to add them to a scene though. 115 | // Every Interactive Experience has a "default" scene so we'll add them there there. 116 | client 117 | .createControls({ 118 | sceneID: 'default', 119 | controls: [txtChange, txtSubmit, txtCost], 120 | }) 121 | .then(controls => { 122 | // Now that the controls are created we can add some event listeners to them! 123 | controls.forEach((control: ITextbox) => { 124 | // move here means that someone has clicked the button. 125 | control.on('change', (inputEvent, participant) => { 126 | // Let's tell the user who they are, and what text they sent. 127 | console.log( 128 | `${participant.username} changed ${ 129 | inputEvent.input.controlID 130 | } with the value: ${inputEvent.input.value}`, 131 | ); 132 | 133 | // Did this push involve a spark cost? 134 | if (inputEvent.transactionID) { 135 | 136 | // Unless you capture the transaction the sparks are not deducted. 137 | client.captureTransaction(inputEvent.transactionID) 138 | .then(() => { 139 | console.log(`Charged ${participant.username} ${control.cost} sparks!`); 140 | }); 141 | } 142 | }); 143 | 144 | control.on('submit', (inputEvent, participant) => { 145 | // Let's tell the user who they are, and what text they sent. 146 | console.log( 147 | `${participant.username} submit ${ 148 | inputEvent.input.controlID 149 | } with the value: ${inputEvent.input.value}`, 150 | ); 151 | 152 | // Did this push involve a spark cost? 153 | if (inputEvent.transactionID) { 154 | 155 | // Unless you capture the transaction the sparks are not deducted. 156 | client.captureTransaction(inputEvent.transactionID) 157 | .then(() => { 158 | console.log(`Charged ${participant.username} ${control.cost} sparks!`); 159 | }); 160 | } 161 | }); 162 | }); 163 | // Controls don't appear unless we tell Interactive that we are ready! 164 | client.ready(true); 165 | }); 166 | }); 167 | 168 | // These can be un-commented to see the raw JSON messages under the hood 169 | client.on('message', (err: any) => console.log('<<<', err)); 170 | client.on('send', (err: any) => console.log('>>>', err)); 171 | // client.on('error', (err: any) => console.log(err)); 172 | 173 | // Now we open the connection passing in our authentication details and an experienceId. 174 | client.open({ 175 | authToken: process.argv[2], 176 | versionId: parseInt(process.argv[3], 10), 177 | }); 178 | 179 | client.state.on('participantJoin', participant => { 180 | console.log(`${participant.username}(${participant.sessionID}) Joined`); 181 | }); 182 | client.state.on( 183 | 'participantLeave', 184 | (participantSessionID: string, participant: IParticipant) => { 185 | console.log(`${participant.username}(${participantSessionID}) Left`); 186 | }, 187 | ); 188 | /* tslint:enable:no-console */ 189 | -------------------------------------------------------------------------------- /src/ClockSync.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | import { delay } from './util'; 4 | 5 | export enum ClockSyncerState { 6 | /** 7 | * Indicates that the clock syncer has JUST started up. 8 | */ 9 | Started, 10 | /** 11 | * Indicates that the clock syncer is actively synchronizing its time with the server. 12 | */ 13 | Synchronizing, 14 | /** 15 | * Indicates that the clock syncer is not actively synchronizing. 16 | */ 17 | Idle, 18 | /** 19 | * Indicates that the clock syncer has been stopped. 20 | */ 21 | Stopped, 22 | } 23 | 24 | export interface IClockSyncOptions { 25 | /** 26 | * How often should we check for a sync status 27 | */ 28 | checkInterval?: number; 29 | /** 30 | * When retrieving a time from the server how many samples should we take? 31 | */ 32 | sampleSize?: number; 33 | /** 34 | * If the clock falls this far out of sync, re-sync from the server 35 | */ 36 | threshold?: number; 37 | /** 38 | * the function to call to check the server time. Should resolve with the unix timestamp of the server. 39 | */ 40 | sampleFunc: () => Promise; 41 | /** 42 | * How long to wait between sampling during a sync call. 43 | */ 44 | sampleDelay?: number; 45 | } 46 | 47 | const defaultOptions = { 48 | checkInterval: 30 * 1000, 49 | sampleSize: 3, 50 | threshold: 1000, 51 | sampleDelay: 5000, 52 | }; 53 | /** 54 | * Clock syncer's goal is to keep a local clock in sync with a server clock. 55 | * 56 | * It does this by sampling the server time a few times and then monitoring the 57 | * local clock for any time disturbances. Should these occur it will re-sample the 58 | * server. 59 | * 60 | * After the sample period it is able to provide a delta value for its difference 61 | * from the server clock, which can be used to make time based adjustments to local 62 | * time based operations. 63 | */ 64 | export class ClockSync extends EventEmitter { 65 | public state = ClockSyncerState.Stopped; 66 | private options: IClockSyncOptions; 67 | 68 | private deltas: number[] = []; 69 | private cachedDelta: number = null; 70 | private checkTimer: NodeJS.Timer; 71 | private expectedTime: number; 72 | 73 | private syncing: Promise; 74 | 75 | constructor(options: IClockSyncOptions) { 76 | super(); 77 | this.options = Object.assign({}, defaultOptions, options); 78 | } 79 | 80 | /** 81 | * Starts the clock synchronizer. It will emit `delta` events, 82 | * when it is able to calculate the delta between the client and the server. 83 | */ 84 | public start(): void { 85 | this.state = ClockSyncerState.Started; 86 | this.deltas = []; 87 | 88 | this.sync().then(() => { 89 | this.expectedTime = Date.now() + this.options.checkInterval; 90 | this.checkTimer = setInterval(() => this.checkClock(), this.options.checkInterval); 91 | }); 92 | } 93 | 94 | private checkClock() { 95 | const now = Date.now(); 96 | const diff = Math.abs(now - this.expectedTime); 97 | if (diff > this.options.threshold && this.syncing === null) { 98 | this.sync(); 99 | } 100 | this.expectedTime = Date.now() + this.options.checkInterval; 101 | } 102 | 103 | private sync(): Promise { 104 | this.state = ClockSyncerState.Synchronizing; 105 | const samplePromises: Promise[] = []; 106 | 107 | for (let i = 0; i < this.options.sampleSize; i++) { 108 | samplePromises.push(delay(i * this.options.sampleDelay).then(() => this.sample())); 109 | } 110 | this.syncing = Promise.all(samplePromises).then(() => { 111 | if (this.state !== ClockSyncerState.Synchronizing) { 112 | return; 113 | } 114 | this.state = ClockSyncerState.Idle; 115 | this.emit('delta', this.getDelta()); 116 | return undefined; 117 | }); 118 | 119 | return this.syncing.then(() => (this.syncing = null)); 120 | } 121 | 122 | private sample(): Promise { 123 | if (this.state === ClockSyncerState.Stopped) { 124 | return Promise.resolve(null); 125 | } 126 | const transmitTime = Date.now(); 127 | return this.options 128 | .sampleFunc() 129 | .then(serverTime => this.processResponse(transmitTime, serverTime)) 130 | .catch(err => { 131 | if (this.state !== ClockSyncerState.Stopped) { 132 | return err; 133 | } 134 | }); 135 | } 136 | 137 | /** 138 | * Halts the clock synchronizer. 139 | */ 140 | public stop() { 141 | this.state = ClockSyncerState.Stopped; 142 | if (this.checkTimer) { 143 | clearInterval(this.checkTimer); 144 | } 145 | } 146 | 147 | /** 148 | * Gets the current delta value from the synchronizer. 149 | */ 150 | public getDelta(forceCalculation?: boolean): number { 151 | if (this.cachedDelta === null || forceCalculation) { 152 | this.cachedDelta = this.calculateDelta(); 153 | } 154 | return this.cachedDelta; 155 | } 156 | 157 | private calculateDelta(): number { 158 | if (this.deltas.length === 0) { 159 | return 0; 160 | } 161 | 162 | if (this.deltas.length === 1) { 163 | return this.deltas[0]; 164 | } 165 | 166 | const sorted = this.deltas.slice(0).sort(); 167 | const midPoint = Math.floor(sorted.length / 2); 168 | 169 | if (sorted.length % 2) { 170 | return sorted[midPoint]; 171 | } else { 172 | return (sorted[midPoint + 1] + sorted[midPoint]) / 2; 173 | } 174 | } 175 | 176 | private processResponse(transmitTime: number, serverTime: number): number { 177 | const receiveTime = Date.now(); 178 | const rtt = receiveTime - transmitTime; 179 | const delta = serverTime - rtt / 2 - transmitTime; 180 | return this.addDelta(delta); 181 | } 182 | 183 | private addDelta(delta: number): number { 184 | // Add new one 185 | this.deltas.push(delta); 186 | 187 | // Re-calculate delta with this number 188 | return this.getDelta(true); 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/state/controls/Button.ts: -------------------------------------------------------------------------------- 1 | import { IButton, IButtonData, IButtonUpdate } from '../interfaces/controls/IButton'; 2 | import { IButtonInput } from '../interfaces/controls/IInput'; 3 | import { Control } from './Control'; 4 | 5 | /** 6 | * Buttons can be pushed by participants with their mouse or activated with their keyboards. 7 | */ 8 | export class Button extends Control implements IButton { 9 | /** 10 | * The text displayed on a button, presented to the participants. 11 | * Set this value using [setText]{@link Button.setText} 12 | */ 13 | public text: string; 14 | /** 15 | * The tooltip text displayed when the participant hovers over the button. 16 | * Set this value using [setTooltip]{@link Button.setTooltip} 17 | */ 18 | public tooltip: string; 19 | /** 20 | * The spark cost of this button in sparks. 21 | * Set this value using [setCost]{@link Button.setCost} 22 | */ 23 | public cost: number; 24 | /** 25 | * A decimalized percentage (0.0 - 1.0) which controls how wide 26 | * this button's progress bar is. 27 | * 28 | * Set this value using [setProgress]{@link Button.setProgress} 29 | */ 30 | public progress: number; 31 | /** 32 | * If set this value is the Unix Timestamp at which this button's cooldown will expire. 33 | * Set this value using [setCooldown]{@link Button.setCooldown} 34 | */ 35 | public cooldown: number; 36 | /** 37 | * A keycode which will trigger this button if pressed on a participant's keyboard. 38 | */ 39 | public keyCode: number; 40 | /** 41 | * The color of the text displayed on the button. 42 | * Set this value using [setTextColor]{@link Button.setTextColor} 43 | */ 44 | public textColor: string; 45 | /** 46 | * The size of the text displayed on the button. 47 | * Set this value using [setTextSize]{@link Button.setTextSize} 48 | */ 49 | public textSize: string; 50 | /** 51 | * The color of the border on the button. 52 | * Set this value using [setBorderColor]{@link Button.setBorderColor} 53 | */ 54 | public borderColor: string; 55 | /** 56 | * The color of the background of the button. 57 | * Set this value using [setBackgroundColor]{@link Button.setBackgroundColor} 58 | */ 59 | public backgroundColor: string; 60 | /** 61 | * The image of the background of the button. 62 | * Set this value using [setBackgroundImage]{@link Button.setBackgroundImage} 63 | */ 64 | public backgroundImage: string; 65 | /** 66 | * The color around the border of the button when in focus. 67 | * Set this value using [setFocusColor]{@link Button.setFocusColor} 68 | */ 69 | public focusColor: string; 70 | /** 71 | * The color of the cooldown spinner and progress bar on the button. 72 | * Set this value using [setAccentColor]{@link Button.setAccentColor} 73 | */ 74 | public accentColor: string; 75 | 76 | /** 77 | * Sets a new text value for this button. 78 | */ 79 | public setText(text: string): Promise { 80 | return this.updateAttribute('text', text); 81 | } 82 | 83 | /** 84 | * Sets a progress value for this button. 85 | * A decimalized percentage (0.0 - 1.0) 86 | */ 87 | public setTextSize(textSize: string): Promise { 88 | return this.updateAttribute('textSize', textSize); 89 | } 90 | 91 | /** 92 | * Sets a new border color for this button. 93 | */ 94 | public setBorderColor(borderColor: string): Promise { 95 | return this.updateAttribute('borderColor', borderColor); 96 | } 97 | 98 | /** 99 | * Sets a new background color for this button. 100 | */ 101 | public setBackgroundColor(backgroundColor: string): Promise { 102 | return this.updateAttribute('backgroundColor', backgroundColor); 103 | } 104 | /** 105 | * Sets a new background image for this button. 106 | */ 107 | public setBackgroundImage(backgroundImage: string): Promise { 108 | return this.updateAttribute('backgroundImage', backgroundImage); 109 | } 110 | 111 | /** 112 | * Sets a new focus color for this button. 113 | */ 114 | public setFocusColor(focusColor: string): Promise { 115 | return this.updateAttribute('focusColor', focusColor); 116 | } 117 | 118 | /** 119 | * Sets a new accent color for this button. 120 | */ 121 | public setAccentColor(accentColor: string): Promise { 122 | return this.updateAttribute('accentColor', accentColor); 123 | } 124 | 125 | /** 126 | * Sets a new text color for this button. 127 | */ 128 | public setTextColor(textColor: string): Promise { 129 | return this.updateAttribute('textColor', textColor); 130 | } 131 | 132 | /** 133 | * Sets a new tooltip value for this button. 134 | */ 135 | public setTooltip(tooltip: string): Promise { 136 | return this.updateAttribute('tooltip', tooltip); 137 | } 138 | 139 | /** 140 | * Sets a progress value for this button. 141 | * A decimalized percentage (0.0 - 1.0) 142 | */ 143 | public setProgress(progress: number): Promise { 144 | return this.updateAttribute('progress', progress); 145 | } 146 | 147 | /** 148 | * Sets the cooldown for this button. Specified in Milliseconds. 149 | * The Client will convert this to a Unix timestamp for you. 150 | */ 151 | public setCooldown(duration: number): Promise { 152 | const target = this.client.state.synchronizeLocalTime().getTime() + duration; 153 | return this.updateAttribute('cooldown', target); 154 | } 155 | 156 | /** 157 | * Sets the spark cost for this button. 158 | * An Integer greater than 0 159 | */ 160 | public setCost(cost: number): Promise { 161 | return this.updateAttribute('cost', cost); 162 | } 163 | 164 | /** 165 | * Sends an input event from a participant to the server for consumption. 166 | */ 167 | public giveInput(input: IButtonInput): Promise { 168 | return this.sendInput(input); 169 | } 170 | 171 | /** 172 | * Update this button on the server. 173 | */ 174 | public update(controlUpdate: IButtonUpdate): Promise { 175 | // Clone to prevent mutations 176 | // XXX: Typescript 2.4 is strict, let the compiler be clever. 177 | const changedData = Object.assign({}, controlUpdate); 178 | if (changedData.cooldown) { 179 | changedData.cooldown = 180 | this.client.state.synchronizeLocalTime().getTime() + changedData.cooldown; 181 | } 182 | return super.update(changedData); 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/GameClient.ts: -------------------------------------------------------------------------------- 1 | import { Client, ClientType } from './Client'; 2 | import { EndpointDiscovery } from './EndpointDiscovery'; 3 | import { IRawValues } from './interfaces'; 4 | import { Requester } from './Requester'; 5 | import { 6 | IGroupDataArray, 7 | IGroupDeletionParams, 8 | IParticipantArray, 9 | IParticipantQuery, 10 | IParticipantQueryResult, 11 | ISceneControlDeletion, 12 | ISceneData, 13 | ISceneDataArray, 14 | ISceneDeletionParams, 15 | } from './state/interfaces'; 16 | import { IControl } from './state/interfaces/controls/IControl'; 17 | 18 | export interface IGameClientOptions { 19 | /** 20 | * Your project version id is a unique id to your Interactive Project Version. You can retrieve one 21 | * from the Interactive Studio on Mixer.com in the Code step. 22 | */ 23 | versionId: number; 24 | 25 | /** 26 | * Optional project sharecode to your Interactive Project Version. You can retrieve one 27 | * from the Interactive Studio on Mixer.com in the Code step. 28 | */ 29 | sharecode?: string; 30 | 31 | /** 32 | * An OAuth Bearer token as defined in {@link https://art.tools.ietf.org/html/rfc6750| OAuth 2.0 Bearer Token Usage}. 33 | */ 34 | authToken: string; 35 | 36 | /** 37 | * A url which can be used to discover interactive servers. 38 | * Defaults to https://mixer.com/api/v1/interactive/hosts 39 | */ 40 | discoveryUrl?: string; 41 | } 42 | 43 | export interface IBroadcastEvent { 44 | scope: string[]; 45 | data: any; 46 | } 47 | 48 | export class GameClient extends Client { 49 | private discovery = new EndpointDiscovery(new Requester()); 50 | constructor() { 51 | super(ClientType.GameClient); 52 | } 53 | /** 54 | * Opens a connection to the interactive service using the provided options. 55 | */ 56 | public open(options: IGameClientOptions): Promise { 57 | const extraHeaders = { 58 | 'X-Interactive-Version': options.versionId, 59 | }; 60 | if (options.sharecode) { 61 | extraHeaders['X-Interactive-Sharecode'] = options.sharecode; 62 | } 63 | 64 | return this.discovery.retrieveEndpoints(options.discoveryUrl).then(endpoints => { 65 | return super.open({ 66 | authToken: options.authToken, 67 | urls: endpoints.map(({ address }) => address), 68 | extraHeaders: extraHeaders, 69 | }); 70 | }); 71 | } 72 | 73 | /** 74 | * Creates instructs the server to create new controls on a scene within your project. 75 | * Participants will see the new controls automatically if they are on the scene the 76 | * new controls are added to. 77 | */ 78 | public createControls(data: ISceneData): Promise { 79 | return this.execute('createControls', data, false).then(res => { 80 | const scene = this.state.getScene(res.sceneID); 81 | if (!scene) { 82 | return this.state.onSceneCreate(res).getControls(); 83 | } 84 | return scene.onControlsCreated(res.controls); 85 | }); 86 | } 87 | 88 | /** 89 | * Instructs the server to create new groups with the specified parameters. 90 | */ 91 | public createGroups(groups: IGroupDataArray): Promise { 92 | return this.execute('createGroups', groups, false); 93 | } 94 | 95 | /** 96 | * Instructs the server to create a new scene with the specified parameters. 97 | */ 98 | public createScene(scene: ISceneData): Promise { 99 | return this.createScenes({ scenes: [scene] }).then(scenes => { 100 | return scenes.scenes[0]; 101 | }); 102 | } 103 | 104 | /** 105 | * Instructs the server to create new scenes with the specified parameters. 106 | */ 107 | public createScenes(scenes: ISceneDataArray): Promise { 108 | return this.execute('createScenes', scenes, false); 109 | } 110 | 111 | /** 112 | * Updates a sessions' ready state, when a client is not ready participants cannot 113 | * interact with the controls. 114 | */ 115 | public ready(isReady: boolean = true): Promise { 116 | return this.execute('ready', { isReady }, false); 117 | } 118 | 119 | /** 120 | * Instructs the server to update controls within a scene with your specified parameters. 121 | * Participants on the scene will see the controls update automatically. 122 | */ 123 | public updateControls(params: ISceneData): Promise { 124 | return this.execute('updateControls', params, false); 125 | } 126 | 127 | /** 128 | * Instructs the server to update the participant within the session with your specified parameters. 129 | * Participants within the group will see applicable scene changes automatically. 130 | */ 131 | public updateGroups(groups: IGroupDataArray): Promise { 132 | return this.execute('updateGroups', groups, false); 133 | } 134 | 135 | /** 136 | * Instructs the server to update a scene within the session with your specified parameters. 137 | */ 138 | public updateScenes(scenes: ISceneDataArray): Promise { 139 | return this.execute('updateScenes', scenes, false); 140 | } 141 | 142 | /** 143 | * Instructs the server to update the participant within the session with your specified parameters. 144 | */ 145 | public updateParticipants(participants: IParticipantArray): Promise { 146 | return this.execute('updateParticipants', participants, false); 147 | } 148 | 149 | /** 150 | * Updates the top level properties of an interactive session. 151 | */ 152 | public updateWorld(world: IRawValues): Promise { 153 | return this.execute('updateWorld', { world }, false); 154 | } 155 | 156 | /** 157 | * Makes an attempt to capture a spark transaction and deduct the sparks from the participant 158 | * who created the transaction. 159 | * 160 | * A transaction can fail to capture if: 161 | * * The participant does not have enough sparks. 162 | * * The transaction is expired. 163 | */ 164 | public captureTransaction(transactionID: string): Promise { 165 | return this.execute('capture', { transactionID }, false); 166 | } 167 | 168 | /** 169 | * Instructs the server to delete the provided controls. 170 | */ 171 | public deleteControls(data: ISceneControlDeletion): Promise { 172 | return this.execute('deleteControls', data, false); 173 | } 174 | 175 | /** 176 | * Instructs the server to delete the provided group. 177 | */ 178 | public deleteGroup(data: IGroupDeletionParams): Promise { 179 | return this.execute('deleteGroup', data, false); 180 | } 181 | 182 | /** 183 | * Instructs the server to delete the provided scene. 184 | */ 185 | public deleteScene(data: ISceneDeletionParams): Promise { 186 | return this.execute('deleteScene', data, false); 187 | } 188 | 189 | /** 190 | * Instructs the server to broadcast an event with given data to specified scopes. 191 | */ 192 | public broadcastEvent(data: IBroadcastEvent): Promise { 193 | return this.execute('broadcastEvent', data, false); 194 | } 195 | /** 196 | * Queries the server for a list of participants who match the provided userIDs. 197 | * Future enhancements may allow for querying by other properties. 198 | */ 199 | public getParticipantsByMixerId(data: IParticipantQuery): Promise { 200 | return this.execute('getParticipantsByMixerID', data, false); 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/wire/Socket.spec.ts: -------------------------------------------------------------------------------- 1 | import { assert, expect, use } from 'chai'; 2 | import * as sinon from 'sinon'; 3 | import * as WebSocketModule from 'ws'; 4 | 5 | import { CancelledError, TimeoutError } from '../errors'; 6 | import { delay, resolveOn } from '../util'; 7 | import { Method } from './packets'; 8 | import { ExponentialReconnectionPolicy } from './reconnection'; 9 | import { InteractiveSocket, ISocketOptions } from './Socket'; 10 | 11 | // tslint:disable-next-line:no-require-imports no-var-requires 12 | use(require('sinon-chai')); 13 | 14 | const port: number = parseInt(process.env.SERVER_PORT, 10) || 1339; 15 | const METHOD = { 16 | id: 1, 17 | type: 'method', 18 | method: 'hello', 19 | params: { foo: 'bar' }, 20 | discard: false, 21 | }; 22 | 23 | function closeNormal(ws: WebSocketModule) { 24 | ws.close(1000, 'Normal'); 25 | } 26 | 27 | describe('socket', () => { 28 | let server: WebSocketModule.Server; 29 | let socket: InteractiveSocket; 30 | 31 | const urls = [`ws://127.0.0.1:${port}/`]; 32 | 33 | beforeEach(ready => { 34 | server = new WebSocketModule.Server({ port }, ready); 35 | }); 36 | 37 | afterEach(done => { 38 | server.clients.forEach(client => closeNormal(client)); 39 | if (socket) { 40 | socket.close(); 41 | socket = null; 42 | } 43 | server.close(done); 44 | }); 45 | 46 | describe('connecting', () => { 47 | it('connects with no auth', done => { 48 | socket = new InteractiveSocket({ urls }).connect(); 49 | server.on('connection', (ws: WebSocketModule) => { 50 | expect(ws.upgradeReq.url).to.equal('/'); 51 | expect(ws.upgradeReq.headers.authorization).to.equal( 52 | undefined, 53 | 'authorization header should be undefined when no auth is used', 54 | ); 55 | closeNormal(ws); 56 | done(); 57 | }); 58 | }); 59 | 60 | it('connects with an OAuth token', done => { 61 | socket = new InteractiveSocket({ 62 | urls, 63 | authToken: 'asdf!', 64 | }).connect(); 65 | server.on('connection', (ws: WebSocketModule) => { 66 | expect(ws.upgradeReq.url).to.equal('/'); 67 | expect(ws.upgradeReq.headers.authorization).to.equal('Bearer asdf!'); 68 | closeNormal(ws); 69 | done(); 70 | }); 71 | }); 72 | }); 73 | 74 | describe('sending packets', () => { 75 | let ws: WebSocketModule; 76 | let next: sinon.SinonStub; 77 | let reset: sinon.SinonStub; 78 | let checker: sinon.SinonStub; 79 | 80 | function greet() { 81 | ws.send(JSON.stringify(METHOD)); 82 | } 83 | 84 | function awaitConnect(callback: Function) { 85 | server.once('connection', (_ws: WebSocketModule) => { 86 | ws = _ws; 87 | // Log these details before re-throwing 88 | ws.on('error', (err: any) => { 89 | console.log(err.code, err.message); //tslint:disable-line 90 | throw err; 91 | }); 92 | callback(ws); 93 | }); 94 | } 95 | 96 | function assertAndReplyTo(payload: any, seq: number) { 97 | const data = JSON.parse(payload); 98 | expect(data).to.deep.equal( 99 | { 100 | id: data.id, 101 | type: 'method', 102 | method: 'hello', 103 | discard: false, 104 | params: { 105 | foo: 'bar', 106 | }, 107 | seq, 108 | }, 109 | 'received method should match sent method', 110 | ); 111 | ws.send( 112 | JSON.stringify({ 113 | type: 'reply', 114 | id: data.id, 115 | error: null, 116 | result: 'hi', 117 | seq: seq + 1, 118 | }), 119 | ); 120 | } 121 | 122 | beforeEach(ready => { 123 | awaitConnect(() => ready()); 124 | checker = sinon.stub(); 125 | checker.resolves(); 126 | socket = new InteractiveSocket({ 127 | urls, 128 | pingInterval: 100, 129 | replyTimeout: 50, 130 | }).connect(); 131 | const options: ISocketOptions = { 132 | reconnectionPolicy: new ExponentialReconnectionPolicy(), 133 | reconnectChecker: checker, 134 | }; 135 | next = sinon.stub(options.reconnectionPolicy, 'next').returns(5); 136 | reset = sinon.stub(options.reconnectionPolicy, 'reset'); 137 | socket.setOptions(options); 138 | }); 139 | 140 | it('reconnects if a connection is lost using the back off interval', done => { 141 | expect(reset).to.not.have.been.called; 142 | expect(next).to.not.have.been.called; 143 | greet(); 144 | 145 | // Initially greets and calls reset 146 | socket.once('open', () => { 147 | expect(reset).to.have.been.calledOnce; 148 | closeNormal(ws); 149 | 150 | // Backs off when a healthy connection is lost 151 | awaitConnect((newWs: WebSocketModule) => { 152 | expect(next).to.have.been.calledOnce; 153 | expect(reset).to.have.been.calledOnce; 154 | expect(checker).to.have.been.calledOnce; 155 | closeNormal(newWs); 156 | 157 | // Backs off again if establishing fails 158 | awaitConnect((ws3: WebSocketModule) => { 159 | expect(next).to.have.been.calledTwice; 160 | expect(reset).to.have.been.calledTwice; 161 | expect(checker).to.have.been.calledTwice; 162 | greet(); 163 | 164 | // Resets after connection is healthy again. 165 | socket.once('open', () => { 166 | expect(reset).to.have.been.calledThrice; 167 | closeNormal(ws3); 168 | setTimeout(() => { 169 | done(); 170 | }, 500); 171 | }); 172 | }); 173 | }); 174 | }); 175 | }); 176 | 177 | it('reconnects to the next server on disconnection', done => { 178 | socket.setOptions({ 179 | urls: [...urls, `ws://127.0.0.1:${port + 1}/`], 180 | }); 181 | 182 | // Connect to the first server. 183 | socket.once('open', () => { 184 | const fallbackServer = new WebSocketModule.Server({ port: port + 1 }, () => { 185 | closeNormal(ws); 186 | 187 | // Connect to the second server. 188 | fallbackServer.once('connection', (ws2: WebSocketModule) => { 189 | closeNormal(ws2); 190 | 191 | // Connect to the first server again. 192 | awaitConnect((ws3: WebSocketModule) => { 193 | closeNormal(ws3); 194 | fallbackServer.close(done); 195 | }); 196 | }); 197 | }); 198 | }); 199 | }); 200 | 201 | it('respects closing the socket during a reconnection', done => { 202 | greet(); 203 | resolveOn(socket, 'method') 204 | .then(() => { 205 | closeNormal(ws); 206 | }) 207 | .then(() => delay(1)) 208 | .then(() => { 209 | socket.close(); 210 | }) 211 | .then(() => { 212 | closeNormal(ws); 213 | done(); 214 | }); 215 | 216 | awaitConnect(() => { 217 | assert.fail('Expected not to have reconnected with a closed socket'); 218 | }); 219 | }); 220 | 221 | it('times out message calls if no reply is received', () => { 222 | socket.setOptions({ replyTimeout: 5 }); 223 | return socket 224 | .execute('hello', { foo: 'bar' }) 225 | .catch(err => expect(err).to.be.an.instanceof(TimeoutError)); 226 | }); 227 | 228 | it('retries messages if the socket is closed before replying', () => { 229 | ws.on('message', () => { 230 | closeNormal(ws); 231 | }); 232 | awaitConnect((newWs: WebSocketModule) => { 233 | newWs.on('message', (payload: any) => { 234 | assertAndReplyTo(payload, 0); 235 | expect(socket.getQueueSize()).to.equal(1); 236 | }); 237 | }); 238 | 239 | return socket.execute('hello', { foo: 'bar' }).then(res => { 240 | expect(res).to.equal('hi'); 241 | }); 242 | }); 243 | 244 | it('recieves a reply to a method', () => { 245 | ws.on('message', payload => { 246 | assertAndReplyTo(payload, 0); 247 | }); 248 | 249 | return socket.execute('hello', { foo: 'bar' }).then(res => { 250 | expect(res).to.equal('hi'); 251 | }); 252 | }); 253 | 254 | it('tracks packet sequence numbers', () => { 255 | let completed = false; 256 | ws.once('message', (payload1: any) => { 257 | assertAndReplyTo(payload1, 0); 258 | 259 | ws.once('message', (payload2: any) => { 260 | assertAndReplyTo(payload2, 1); 261 | completed = true; 262 | }); 263 | }); 264 | 265 | return socket 266 | .execute('hello', { foo: 'bar' }) 267 | .then(() => socket.execute('hello', { foo: 'bar' })) 268 | .then(() => expect(completed).to.equal(true, 'expected to have called twice')); 269 | }); 270 | 271 | it('emits a method sent to it', done => { 272 | ws.send(JSON.stringify(METHOD)); 273 | socket.on('method', (method: Method) => { 274 | expect(method).to.deep.equal(Method.fromSocket(METHOD)); 275 | done(); 276 | }); 277 | }); 278 | 279 | it('cancels packets if the socket is closed mid-call', () => { 280 | ws.on('message', () => socket.close()); 281 | return socket 282 | .execute('hello', { foo: 'bar' }) 283 | .catch(err => expect(err).be.an.instanceof(CancelledError)) 284 | .then(() => { 285 | closeNormal(ws); 286 | }) 287 | .then(() => delay(5)); 288 | }); 289 | }); 290 | }); 291 | -------------------------------------------------------------------------------- /src/state/State.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as fs from 'fs'; 3 | import * as path from 'path'; 4 | 5 | import { ClientType } from '../Client'; 6 | import { Method } from '../wire/packets'; 7 | import { Group } from './Group'; 8 | import { IControl } from './interfaces/controls/IControl'; 9 | import { IGroup, IGroupDataArray, IGroupDeletionParams } from './interfaces/IGroup'; 10 | import { ISceneDataArray } from './interfaces/IScene'; 11 | import { State } from './State'; 12 | 13 | function loadFixture(name: string): ISceneDataArray { 14 | return JSON.parse(fs.readFileSync(name).toString()); 15 | } 16 | 17 | const groupsFixture: IGroupDataArray = { 18 | groups: [ 19 | { 20 | groupID: 'default', 21 | sceneID: 'my awesome scene', 22 | }, 23 | { 24 | groupID: 'deleatable', 25 | sceneID: 'my awesome scene', 26 | }, 27 | ], 28 | }; 29 | describe('state', () => { 30 | let state: State; 31 | 32 | function initializeState(fixture: string) { 33 | state = new State(ClientType.GameClient); 34 | const data = loadFixture(path.join(__dirname, '../../test/fixtures', fixture)); 35 | state.processMethod(new Method('onSceneCreate', { scenes: data.scenes })); 36 | state.processMethod(new Method('onGroupCreate', { groups: groupsFixture.groups })); 37 | } 38 | 39 | describe('initialization', () => { 40 | it('initializes state from an initial scene list', () => { 41 | initializeState('testGame.json'); 42 | const scene = state.getScene('my awesome scene'); 43 | expect(scene).to.exist; 44 | }); 45 | }); 46 | describe('scenes', () => { 47 | before(() => { 48 | initializeState('testGame.json'); 49 | }); 50 | it('finds a scene by id', () => { 51 | const targetScene = 'my awesome scene'; 52 | const scene = state.getScene(targetScene); 53 | expect(scene).to.exist; 54 | expect(scene.sceneID).to.be.equal(targetScene); 55 | }); 56 | it('initializes a scene from a method', () => { 57 | const method = new Method('onSceneCreate', { 58 | scenes: [ 59 | { 60 | sceneID: 'scene2', 61 | controls: [ 62 | { 63 | controlID: 'button2', 64 | kind: 'button', 65 | text: 'Win the Game', 66 | cost: 0, 67 | progress: 0.25, 68 | disabled: false, 69 | meta: { 70 | glow: { 71 | value: { 72 | color: '#f00', 73 | radius: 10, 74 | }, 75 | }, 76 | }, 77 | }, 78 | ], 79 | }, 80 | ], 81 | }); 82 | state.processMethod(method); 83 | const scene = state.getScene('scene2'); 84 | expect(scene).to.exist; 85 | expect(scene.sceneID).to.equal('scene2'); 86 | const controlInScene = scene.getControl('button2'); 87 | expect(controlInScene).to.exist; 88 | expect(controlInScene.controlID).to.equal('button2'); 89 | }); 90 | it('deletes a scene', () => { 91 | const method = new Method('onSceneDelete', { 92 | sceneID: 'scene2', 93 | reassignSceneID: 'my awesome scene', 94 | }); 95 | state.processMethod(method); 96 | const scene = state.getScene('scene2'); 97 | expect(scene).to.not.exist; 98 | }); 99 | it('updates a scene', () => { 100 | const meta = { 101 | glow: { 102 | value: { 103 | color: '#f00', 104 | radius: 10, 105 | }, 106 | }, 107 | }; 108 | const method = new Method('onSceneUpdate', { 109 | scenes: [ 110 | { 111 | sceneID: 'my awesome scene', 112 | meta: meta, 113 | }, 114 | ], 115 | }); 116 | state.processMethod(method); 117 | const scene = state.getScene('my awesome scene'); 118 | expect(scene).to.exist; 119 | expect(scene.meta).to.deep.equal(meta); 120 | }); 121 | }); 122 | 123 | describe('participants', () => { 124 | it('adds participants', () => { 125 | state.processMethod( 126 | new Method( 127 | 'onParticipantJoin', 128 | { 129 | participants: [ 130 | { 131 | sessionID: 'abc123', 132 | username: 'connor', 133 | userID: 1337, 134 | }, 135 | ], 136 | }, 137 | false, 138 | ), 139 | ); 140 | expect(state.getParticipantBySessionID('abc123').username).to.equal('connor'); 141 | expect(state.getParticipantByUsername('connor').sessionID).to.equal('abc123'); 142 | }); 143 | }); 144 | 145 | describe('controls', () => { 146 | let control: IControl; 147 | before(() => { 148 | initializeState('testGame.json'); 149 | }); 150 | it('finds a control by id', () => { 151 | const targetControl = 'win_the_game_btn'; 152 | control = state.getControl(targetControl); 153 | expect(control).to.exist; 154 | expect(control.controlID).to.be.equal(targetControl); 155 | }); 156 | it('applies an update to a control', done => { 157 | control = state.getControl('win_the_game_btn'); 158 | expect(control).to.exist; 159 | control.on('updated', () => { 160 | expect(control.disabled).to.equal(true, 'expect control to be disabled'); 161 | done(); 162 | }); 163 | state.processMethod( 164 | new Method('onControlUpdate', { 165 | sceneID: 'my awesome scene', 166 | controls: [ 167 | { 168 | controlID: 'win_the_game_btn', 169 | disabled: true, 170 | }, 171 | ], 172 | }), 173 | ); 174 | }); 175 | it('creates and places a new control within the state tree', () => { 176 | state.processMethod( 177 | new Method('onControlCreate', { 178 | sceneID: 'my awesome scene', 179 | controls: [ 180 | { 181 | controlID: 'lose_the_game_btn', 182 | kind: 'button', 183 | text: 'Lose the Game', 184 | cost: 0, 185 | progress: 0.25, 186 | disabled: false, 187 | meta: { 188 | glow: { 189 | value: { 190 | color: '#f00', 191 | radius: 10, 192 | }, 193 | }, 194 | }, 195 | }, 196 | ], 197 | }), 198 | ); 199 | control = state.getScene('my awesome scene').getControl('lose_the_game_btn'); 200 | expect(control).to.exist; 201 | expect(control.controlID).to.equal('lose_the_game_btn'); 202 | }); 203 | it('deletes a control', done => { 204 | const scene = state.getScene('my awesome scene'); 205 | // TODO How do we overload this? 206 | scene.on('controlDeleted', (id: string) => { 207 | expect(id).to.equal('lose_the_game_btn'); 208 | const searchControl = scene.getControl(id); 209 | expect(searchControl).to.not.exist; 210 | done(); 211 | }); 212 | state.processMethod( 213 | new Method('onControlDelete', { 214 | sceneID: 'my awesome scene', 215 | controls: [ 216 | { 217 | controlID: 'lose_the_game_btn', 218 | }, 219 | ], 220 | }), 221 | ); 222 | }); 223 | }); 224 | describe('groups', () => { 225 | let group: IGroup; 226 | before(() => { 227 | initializeState('testGame.json'); 228 | }); 229 | it('finds a group by ID', () => { 230 | const targetGroup = groupsFixture.groups[0].groupID; 231 | group = state.getGroup(targetGroup); 232 | expect(group).to.exist; 233 | expect(group.groupID).to.be.equal(targetGroup); 234 | }); 235 | it('applies an update to a group', done => { 236 | const targetScene = 'existing second scene'; 237 | group = state.getGroup(groupsFixture.groups[0].groupID); 238 | expect(group).to.exist; 239 | group.on('updated', () => { 240 | expect(group.sceneID).to.equal(targetScene); 241 | done(); 242 | }); 243 | state.processMethod( 244 | new Method('onGroupUpdate', { 245 | groups: [ 246 | { 247 | groupID: group.groupID, 248 | sceneID: targetScene, 249 | }, 250 | ], 251 | }), 252 | ); 253 | }); 254 | it('creates a new group and adds it to state tree', done => { 255 | const targetGroup: IGroupDataArray = { 256 | groups: [ 257 | { 258 | groupID: 'a new group', 259 | sceneID: 'my awesome scene', 260 | }, 261 | ], 262 | }; 263 | state.on('groupCreated', (newGroup: Group) => { 264 | expect(newGroup).to.exist; 265 | expect(newGroup.groupID).to.be.equal(targetGroup.groups[0].groupID); 266 | done(); 267 | }); 268 | state.processMethod(new Method('onGroupCreate', targetGroup)); 269 | group = state.getGroup(targetGroup.groups[0].groupID); 270 | expect(group).to.exist('Group', 'new group should exist'); 271 | expect(group.groupID).to.be.equal( 272 | targetGroup.groups[0].groupID, 273 | 'should have the created group id', 274 | ); 275 | }); 276 | it('deletes a group', done => { 277 | const targetGroup = groupsFixture.groups[1].groupID; 278 | const delGroup = state.getGroup(targetGroup); 279 | const delGroupParams: IGroupDeletionParams = { 280 | groupID: delGroup.groupID, 281 | reassignGroupID: groupsFixture.groups[0].groupID, 282 | }; 283 | state.on('groupDeleted', (id: string, reassignId: string) => { 284 | expect(id).to.equal(targetGroup); 285 | expect(reassignId).to.equal(delGroupParams.reassignGroupID); 286 | group = state.getGroup(targetGroup); 287 | expect(group).to.not.exist; 288 | done(); 289 | }); 290 | state.processMethod(new Method('onGroupDelete', delGroupParams)); 291 | }); 292 | it('handles new worlds', done => { 293 | const newWorld = { 294 | fantastic: true, 295 | dazzling: true, 296 | unbelievable: true, 297 | }; 298 | 299 | const world = state.getWorld(); 300 | expect(world).to.not.equal(newWorld); 301 | state.on('worldUpdated', (w: any) => { 302 | expect(w).to.deep.equal(newWorld); 303 | expect(state.getWorld()).to.deep.equal(newWorld); 304 | done(); 305 | }); 306 | state.processMethod(new Method('onWorldUpdate', { ...newWorld })); 307 | }); 308 | }); 309 | }); 310 | -------------------------------------------------------------------------------- /src/Client.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | import { PermissionDeniedError } from './errors'; 4 | import { IClient } from './IClient'; 5 | import { MethodHandlerManager } from './methods/MethodHandlerManager'; 6 | import { onReadyParams } from './methods/methodTypes'; 7 | import { 8 | IControl, 9 | IGroup, 10 | IGroupDataArray, 11 | IGroupDeletionParams, 12 | IInput, 13 | IParticipantArray, 14 | IParticipantQuery, 15 | IParticipantQueryResult, 16 | IScene, 17 | ISceneControlDeletion, 18 | ISceneData, 19 | ISceneDataArray, 20 | ISceneDeletionParams, 21 | ITransactionCapture, 22 | } from './state/interfaces'; 23 | import { IWorldUpdate } from './state/interfaces/IScene'; 24 | import { IState } from './state/IState'; 25 | import { State } from './state/State'; 26 | import { resolveOn } from './util'; 27 | import { Method, Reply } from './wire/packets'; 28 | import { 29 | CompressionScheme, 30 | InteractiveSocket, 31 | ISocketOptions, 32 | SocketState as InteractiveSocketState, 33 | } from './wire/Socket'; 34 | 35 | export enum ClientType { 36 | /** 37 | * A Participant type is used when the client is participating in the session. 38 | */ 39 | Participant, 40 | /** 41 | * A GameClient type is used when the client is running the interactive session. 42 | */ 43 | GameClient, 44 | } 45 | 46 | export class Client extends EventEmitter implements IClient { 47 | /** 48 | * The type this client instance is running as. 49 | */ 50 | public clientType: ClientType; 51 | 52 | /** 53 | * The client's state store. 54 | */ 55 | public state: IState; 56 | 57 | /** 58 | * The client's socket. 59 | */ 60 | protected socket: InteractiveSocket; 61 | 62 | private methodHandler = new MethodHandlerManager(); 63 | 64 | /** 65 | * Constructs and sets up a client of the given type. 66 | */ 67 | constructor(clientType: ClientType) { 68 | super(); 69 | this.clientType = clientType; 70 | this.state = new State(clientType); 71 | this.state.setClient(this); 72 | this.methodHandler.addHandler('hello', () => { 73 | this.emit('hello'); 74 | }); 75 | } 76 | 77 | /** 78 | * Processes a method through the client's method handler. 79 | */ 80 | public processMethod(method: Method) { 81 | return this.methodHandler.handle(method); 82 | } 83 | 84 | /** 85 | * Creates a socket on the client using the specified options. 86 | * Use [client.open]{@link Client.open} to open the created socket. 87 | */ 88 | private createSocket(options: ISocketOptions): void { 89 | if (this.socket) { 90 | // GC the old socket 91 | if (this.socket.getState() !== InteractiveSocketState.Closing) { 92 | this.socket.close(); 93 | } 94 | this.socket = null; 95 | } 96 | this.socket = new InteractiveSocket(options); 97 | this.socket.on('method', (method: Method) => { 98 | // Sometimes the client may also want to handle methods, 99 | // in these cases, if it replies we value it at a higher 100 | // priority than anything the state handler has. So we 101 | // only send that one. 102 | const clientReply = this.processMethod(method); 103 | if (clientReply) { 104 | this.reply(clientReply); 105 | return; 106 | } 107 | 108 | // Replying to a method is sometimes optional, here we let the state system 109 | // process a message and if it wants replies. 110 | const reply = this.state.processMethod(method); 111 | if (reply) { 112 | this.reply(reply); 113 | } 114 | }); 115 | 116 | this.socket.on('open', () => this.emit('open')); 117 | this.socket.on('error', (err: Error) => this.emit('error', err)); 118 | 119 | // Re-emit these for debugging reasons 120 | this.socket.on('message', (data: any) => this.emit('message', data)); 121 | this.socket.on('send', (data: any) => this.emit('send', data)); 122 | this.socket.on('close', (data: any) => this.emit('close', data)); 123 | } 124 | 125 | /** 126 | * Get the options is use by the socket. 127 | */ 128 | public getOptions() { 129 | return this.socket.getOptions(); 130 | } 131 | 132 | /** 133 | * Sets the given options on the socket. 134 | */ 135 | public setOptions(options: ISocketOptions) { 136 | this.socket.setOptions(options); 137 | } 138 | 139 | /** 140 | * Opens the connection to interactive. 141 | */ 142 | public open(options: ISocketOptions): Promise { 143 | this.state.reset(); 144 | this.createSocket(options); 145 | this.socket.connect(); 146 | return resolveOn(this, 'open').then(() => this); 147 | } 148 | 149 | /** 150 | * Closes and frees the resources associated with the interactive connection. 151 | */ 152 | public close() { 153 | if (this.socket) { 154 | this.socket.close(); 155 | } 156 | } 157 | 158 | //TODO: Actually implement compression 159 | /** 160 | * Begins a negotiation process between the server and this client, 161 | * the compression preferences of the client are sent to the server and then 162 | * the server responds with the chosen compression scheme. 163 | */ 164 | public setCompression(preferences: CompressionScheme[]): Promise { 165 | return this.socket 166 | .execute('setCompression', { 167 | params: preferences, 168 | }) 169 | .then(res => { 170 | this.socket.setOptions({ 171 | compressionScheme: res.scheme, 172 | }); 173 | }); 174 | } 175 | 176 | /** 177 | * Sends a given reply to the server. 178 | */ 179 | public reply(reply: Reply) { 180 | return this.socket.reply(reply); 181 | } 182 | 183 | /** 184 | * Retrieves the scenes stored on the interactive server. 185 | */ 186 | public getScenes(): Promise { 187 | return this.execute('getScenes', null, false); 188 | } 189 | 190 | /** 191 | * Retrieves the scenes on the server and hydrates the state store with them. 192 | */ 193 | public synchronizeScenes(): Promise { 194 | return this.getScenes().then(res => this.state.synchronizeScenes(res)); 195 | } 196 | 197 | /** 198 | * Retrieves the groups stored on the interactive server. 199 | */ 200 | public getGroups(): Promise { 201 | return this.execute('getGroups', null, false); 202 | } 203 | 204 | /** 205 | * Retrieves the groups on the server and hydrates the state store with them. 206 | */ 207 | public synchronizeGroups(): Promise { 208 | return this.getGroups().then(res => this.state.synchronizeGroups(res)); 209 | } 210 | 211 | /** 212 | * Retrieves and hydrates client side stores with state from the server 213 | */ 214 | public synchronizeState(): Promise<[IGroup[], IScene[]]> { 215 | return Promise.all([this.synchronizeGroups(), this.synchronizeScenes()]); 216 | } 217 | 218 | /** 219 | * Gets the time from the server as a unix timestamp in UTC. 220 | */ 221 | public getTime(): Promise { 222 | return this.execute('getTime', null, false).then(res => { 223 | return res.time; 224 | }); 225 | } 226 | /** 227 | * updateWorld, Updates the top-level world state of the session 228 | */ 229 | public execute( 230 | method: 'updateWorld', 231 | params: IWorldUpdate, 232 | discard: false, 233 | ): Promise; 234 | /** 235 | * `createControls` will instruct the server to create your provided controls in the active, 236 | * project. Participants will see the new controls as they are added. 237 | */ 238 | public execute( 239 | method: 'createControls', 240 | params: ISceneData, 241 | discard: false, 242 | ): Promise; 243 | /** 244 | * `ready` allows you to indicate to the server the ready state of your GameClient. 245 | * By specifying `isReady` false you can pause participant interaction whilst you 246 | * setup scenes and controls. 247 | */ 248 | public execute(method: 'ready', params: onReadyParams, discard: false): Promise; 249 | /** 250 | * `capture` is used to capture a spark transaction that you have received from the server. 251 | */ 252 | public execute(method: 'capture', params: ITransactionCapture, discard: false): Promise; 253 | /** 254 | * `getTime` retrieves the server's unix timestamp. You can use this to synchronize your clock with 255 | * the servers. See [ClockSync]{@link ClockSync} for a Clock Synchronizer. 256 | */ 257 | public execute(method: 'getTime', params: null, discard: false): Promise<{ time: number }>; 258 | /** 259 | * `getScenes` retrieves scenes stored ont he server. If you've used the studio to create your project, 260 | * then you can use this to retrieve the scenes and controls created there. 261 | */ 262 | public execute(method: 'getScenes', params: null, discard: false): Promise; 263 | /** 264 | * `giveInput` is used to send participant interactive events to the server. 265 | * These events will be received by the corresponding GameClient. 266 | */ 267 | public execute(method: 'giveInput', params: K, discard: false): Promise; 268 | /** 269 | * `updateControls` is used to update control properties within a scene, such as disabling a control. 270 | */ 271 | public execute(method: 'updateControls', params: ISceneData, discard: false): Promise; 272 | /** 273 | * `deleteControls` will delete the specified controls from the server. Participants will see these controls 274 | * vanish and will not be able to interact with them. 275 | */ 276 | public execute( 277 | method: 'deleteControls', 278 | params: ISceneControlDeletion, 279 | discard: false, 280 | ): Promise; 281 | /** 282 | * Queries the server for a list of participants who match the provided userIDs. 283 | * Future enhancements may allow for querying by other properties 284 | */ 285 | public execute( 286 | method: 'getParticipantsByMixerID', 287 | params: IParticipantQuery, 288 | discard: false, 289 | ): Promise; 290 | public execute(method: string, params: T, discard: boolean): Promise; 291 | /** 292 | * Execute will construct and send a method to the server for execution. 293 | * It will resolve with the server's reply. It is recommended that you use an 294 | * existing Client method if available instead of manually calling `execute`. 295 | */ 296 | public execute(method: string, params: any, discard: boolean): Promise { 297 | return this.socket.execute(method, params, discard); 298 | } 299 | 300 | public createControls(_: ISceneData): Promise { 301 | throw new PermissionDeniedError('createControls', 'Participant'); 302 | } 303 | 304 | public createGroups(_: IGroupDataArray): Promise { 305 | throw new PermissionDeniedError('createGroups', 'Participant'); 306 | } 307 | 308 | public createScene(_: ISceneData): Promise { 309 | throw new PermissionDeniedError('createScene', 'Participant'); 310 | } 311 | 312 | public createScenes(_: ISceneDataArray): Promise { 313 | throw new PermissionDeniedError('createScenes', 'Participant'); 314 | } 315 | 316 | public updateControls(_: ISceneData): Promise { 317 | throw new PermissionDeniedError('updateControls', 'Participant'); 318 | } 319 | 320 | public updateGroups(_: IGroupDataArray): Promise { 321 | throw new PermissionDeniedError('updateGroups', 'Participant'); 322 | } 323 | 324 | public updateScenes(_: ISceneDataArray): Promise { 325 | throw new PermissionDeniedError('updateScenes', 'Participant'); 326 | } 327 | 328 | public updateParticipants(_: IParticipantArray): Promise { 329 | throw new PermissionDeniedError('updateParticipants', 'Participant'); 330 | } 331 | 332 | public giveInput(_: T): Promise { 333 | throw new PermissionDeniedError('giveInput', 'GameClient'); 334 | } 335 | 336 | public deleteControls(_: ISceneControlDeletion): Promise { 337 | throw new PermissionDeniedError('deleteControls', 'Participant'); 338 | } 339 | 340 | public deleteGroup(_: IGroupDeletionParams): Promise { 341 | throw new PermissionDeniedError('deleteGroup', 'Participant'); 342 | } 343 | 344 | public deleteScene(_: ISceneDeletionParams): Promise { 345 | throw new PermissionDeniedError('deleteScene', 'Participant'); 346 | } 347 | 348 | public ready(_: boolean): Promise { 349 | throw new PermissionDeniedError('ready', 'Participant'); 350 | } 351 | } 352 | --------------------------------------------------------------------------------