From 1806ef9d7e012d6a7ad6350d14ef9f5ddcf39674 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Mon, 28 Jun 2021 09:22:51 +0200 Subject: [PATCH 01/31] First version of the room metadata doc --- docs/maps/api-room.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/maps/api-room.md b/docs/maps/api-room.md index 9d08ce1b..b8a99a53 100644 --- a/docs/maps/api-room.md +++ b/docs/maps/api-room.md @@ -144,3 +144,34 @@ WA.room.setTiles([ {x: 9, y: 4, tile: 'blue', layer: 'setTiles'} ]); ``` + +### Saving / loading metadata + +``` +WA.room.saveMetadata(key : string, data : any): void +WA.room.loadMetadata(key : string) : any +``` + +These 2 methods can be used to save and load data related to the current room. + +`data` can be any value that is serializable in JSON. + +Please refrain from storing large amounts of data in a room. Those functions are typically useful for saving or restoring +configuration / metadatas. + +Example : +```javascript +WA.room.saveMetadata('config', { + 'bottomExitUrl': '/@/org/world/castle', + 'topExitUrl': '/@/org/world/tower', + 'enableBirdSound': true +}); +//... +let config = WA.room.loadMetadata('config'); +``` + +{.alert.alert-danger} +Important: metadata can only be saved/loaded if an administration server is attached to WorkAdventure. The `WA.room.saveMetadata` +and `WA.room.loadMetadata` functions will therefore be available on the hosted version of WorkAdventure, but will not +be available in the self-hosted version (unless you decide to code an administration server stub to provide storage for +those data) From ea1460abaf341f7bbb5c9e86051c144c752387b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Fri, 2 Jul 2021 11:31:44 +0200 Subject: [PATCH 02/31] Adding variables (on the front side for now) --- docs/maps/api-room.md | 68 +++++++-- front/src/Api/Events/IframeEvent.ts | 8 ++ front/src/Api/Events/InitEvent.ts | 10 ++ front/src/Api/Events/SetVariableEvent.ts | 18 +++ front/src/Api/IframeListener.ts | 136 +++++++++++------- front/src/Api/iframe/room.ts | 47 +++++- front/src/Phaser/Game/GameScene.ts | 26 +++- .../src/Phaser/Game/SharedVariablesManager.ts | 59 ++++++++ front/src/iframe_api.ts | 6 + maps/tests/Variables/script.js | 11 ++ maps/tests/Variables/variables.json | 112 +++++++++++++++ maps/tests/index.html | 8 ++ messages/protos/messages.proto | 12 +- 13 files changed, 453 insertions(+), 68 deletions(-) create mode 100644 front/src/Api/Events/InitEvent.ts create mode 100644 front/src/Api/Events/SetVariableEvent.ts create mode 100644 front/src/Phaser/Game/SharedVariablesManager.ts create mode 100644 maps/tests/Variables/script.js create mode 100644 maps/tests/Variables/variables.json diff --git a/docs/maps/api-room.md b/docs/maps/api-room.md index b8a99a53..a307b2da 100644 --- a/docs/maps/api-room.md +++ b/docs/maps/api-room.md @@ -145,14 +145,15 @@ WA.room.setTiles([ ]); ``` -### Saving / loading metadata +### Saving / loading state ``` -WA.room.saveMetadata(key : string, data : any): void -WA.room.loadMetadata(key : string) : any +WA.room.saveVariable(key : string, data : unknown): void +WA.room.loadVariable(key : string) : unknown +WA.room.onVariableChange(key : string).subscribe((data: unknown) => {}) : Subscription ``` -These 2 methods can be used to save and load data related to the current room. +These 3 methods can be used to save, load and track changes in variables related to the current room. `data` can be any value that is serializable in JSON. @@ -161,17 +162,62 @@ configuration / metadatas. Example : ```javascript -WA.room.saveMetadata('config', { +WA.room.saveVariable('config', { 'bottomExitUrl': '/@/org/world/castle', 'topExitUrl': '/@/org/world/tower', 'enableBirdSound': true }); //... -let config = WA.room.loadMetadata('config'); +let config = WA.room.loadVariable('config'); ``` -{.alert.alert-danger} -Important: metadata can only be saved/loaded if an administration server is attached to WorkAdventure. The `WA.room.saveMetadata` -and `WA.room.loadMetadata` functions will therefore be available on the hosted version of WorkAdventure, but will not -be available in the self-hosted version (unless you decide to code an administration server stub to provide storage for -those data) +If you are using Typescript, please note that the return type of `loadVariable` is `unknown`. This is +for security purpose, as we don't know the type of the variable. In order to use the returned value, +you will need to cast it to the correct type (or better, use a [Type guard](https://www.typescriptlang.org/docs/handbook/2/narrowing.html) to actually check at runtime +that you get the expected type). + +{.alert.alert-warning} +For security reasons, you cannot load or save **any** variable (otherwise, anyone on your map could set any data). +Variables storage is subject to an authorization process. Read below to learn more. + +#### Declaring allowed keys + +In order to declare allowed keys related to a room, you need to add a **objects** in an "object layer" of the map. + +Each object will represent a variable. + +
+
+ +
+
+ +TODO: move the image in https://workadventu.re/img/docs + + +The name of the variable is the name of the object. +The object **type** MUST be **variable**. + +You can set a default value for the object in the `default` property. + +Use the `persist` property to save the state of the variable in database. If `persist` is false, the variable will stay +in the memory of the WorkAdventure servers but will be wiped out of the memory as soon as the room is empty (or if the +server restarts). + +{.alert.alert-info} +Do not use `persist` for highly dynamic values that have a short life spawn. + +With `readableBy` and `writableBy`, you control who can read of write in this variable. The property accepts a string +representing a "tag". Anyone having this "tag" can read/write in the variable. + +{.alert.alert-warning} +`readableBy` and `writableBy` are specific to the public version of WorkAdventure because the notion of tags +is not available unless you have an "admin" server (that is not part of the self-hosted version of WorkAdventure). + +Finally, the `jsonSchema` property can contain [a complete JSON schema](https://json-schema.org/) to validate the content of the variable. +Trying to set a variable to a value that is not compatible with the schema will fail. + + + + +TODO: document tracking, unsubscriber, etc... diff --git a/front/src/Api/Events/IframeEvent.ts b/front/src/Api/Events/IframeEvent.ts index fc3384f8..83d0e12e 100644 --- a/front/src/Api/Events/IframeEvent.ts +++ b/front/src/Api/Events/IframeEvent.ts @@ -18,6 +18,9 @@ import type { MenuItemClickedEvent } from "./ui/MenuItemClickedEvent"; import type { MenuItemRegisterEvent } from "./ui/MenuItemRegisterEvent"; import type { HasPlayerMovedEvent } from "./HasPlayerMovedEvent"; import type { SetTilesEvent } from "./SetTilesEvent"; +import type { SetVariableEvent } from "./SetVariableEvent"; +import type {InitEvent} from "./InitEvent"; + export interface TypedMessageEvent extends MessageEvent { data: T; @@ -50,6 +53,9 @@ export type IframeEventMap = { getState: undefined; registerMenuCommand: MenuItemRegisterEvent; setTiles: SetTilesEvent; + setVariable: SetVariableEvent; + // A script/iframe is ready to receive events + ready: null; }; export interface IframeEvent { type: T; @@ -68,6 +74,8 @@ export interface IframeResponseEventMap { hasPlayerMoved: HasPlayerMovedEvent; dataLayer: DataLayerEvent; menuItemClicked: MenuItemClickedEvent; + setVariable: SetVariableEvent; + init: InitEvent; } export interface IframeResponseEvent { type: T; diff --git a/front/src/Api/Events/InitEvent.ts b/front/src/Api/Events/InitEvent.ts new file mode 100644 index 00000000..47326f81 --- /dev/null +++ b/front/src/Api/Events/InitEvent.ts @@ -0,0 +1,10 @@ +import * as tg from "generic-type-guard"; + +export const isInitEvent = + new tg.IsInterface().withProperties({ + variables: tg.isObject + }).get(); +/** + * A message sent from the game just after an iFrame opens, to send all important data (like variables) + */ +export type InitEvent = tg.GuardedType; diff --git a/front/src/Api/Events/SetVariableEvent.ts b/front/src/Api/Events/SetVariableEvent.ts new file mode 100644 index 00000000..b0effb30 --- /dev/null +++ b/front/src/Api/Events/SetVariableEvent.ts @@ -0,0 +1,18 @@ +import * as tg from "generic-type-guard"; +import {isMenuItemRegisterEvent} from "./ui/MenuItemRegisterEvent"; + +export const isSetVariableEvent = + new tg.IsInterface().withProperties({ + key: tg.isString, + value: tg.isUnknown, + }).get(); +/** + * A message sent from the iFrame to the game to change the value of the property of the layer + */ +export type SetVariableEvent = tg.GuardedType; + +export const isSetVariableIframeEvent = + new tg.IsInterface().withProperties({ + type: tg.isSingletonString("setVariable"), + data: isSetVariableEvent + }).get(); diff --git a/front/src/Api/IframeListener.ts b/front/src/Api/IframeListener.ts index 314d5d2e..6caecc1f 100644 --- a/front/src/Api/IframeListener.ts +++ b/front/src/Api/IframeListener.ts @@ -32,6 +32,7 @@ import type { HasPlayerMovedEvent } from "./Events/HasPlayerMovedEvent"; import { isLoadPageEvent } from "./Events/LoadPageEvent"; import { handleMenuItemRegistrationEvent, isMenuItemRegisterIframeEvent } from "./Events/ui/MenuItemRegisterEvent"; import { SetTilesEvent, isSetTilesEvent } from "./Events/SetTilesEvent"; +import { isSetVariableIframeEvent, SetVariableEvent } from "./Events/SetVariableEvent"; type AnswererCallback = (query: IframeQueryMap[T]['query']) => IframeQueryMap[T]['answer']|Promise; @@ -40,6 +41,9 @@ type AnswererCallback = (query: IframeQueryMap[T * Also allows to send messages to those iframes. */ class IframeListener { + private readonly _readyStream: Subject = new Subject(); + public readonly readyStream = this._readyStream.asObservable(); + private readonly _chatStream: Subject = new Subject(); public readonly chatStream = this._chatStream.asObservable(); @@ -106,6 +110,9 @@ class IframeListener { private readonly _setTilesStream: Subject = new Subject(); public readonly setTilesStream = this._setTilesStream.asObservable(); + private readonly _setVariableStream: Subject = new Subject(); + public readonly setVariableStream = this._setVariableStream.asObservable(); + private readonly iframes = new Set(); private readonly iframeCloseCallbacks = new Map void)[]>(); private readonly scripts = new Map(); @@ -187,62 +194,76 @@ class IframeListener { }); } else if (isIframeEventWrapper(payload)) { - if (payload.type === "showLayer" && isLayerEvent(payload.data)) { - this._showLayerStream.next(payload.data); - } else if (payload.type === "hideLayer" && isLayerEvent(payload.data)) { - this._hideLayerStream.next(payload.data); - } else if (payload.type === "setProperty" && isSetPropertyEvent(payload.data)) { - this._setPropertyStream.next(payload.data); - } else if (payload.type === "chat" && isChatEvent(payload.data)) { - this._chatStream.next(payload.data); - } else if (payload.type === "openPopup" && isOpenPopupEvent(payload.data)) { - this._openPopupStream.next(payload.data); - } else if (payload.type === "closePopup" && isClosePopupEvent(payload.data)) { - this._closePopupStream.next(payload.data); - } else if (payload.type === "openTab" && isOpenTabEvent(payload.data)) { - scriptUtils.openTab(payload.data.url); - } else if (payload.type === "goToPage" && isGoToPageEvent(payload.data)) { - scriptUtils.goToPage(payload.data.url); - } else if (payload.type === "loadPage" && isLoadPageEvent(payload.data)) { - this._loadPageStream.next(payload.data.url); - } else if (payload.type === "playSound" && isPlaySoundEvent(payload.data)) { - this._playSoundStream.next(payload.data); - } else if (payload.type === "stopSound" && isStopSoundEvent(payload.data)) { - this._stopSoundStream.next(payload.data); - } else if (payload.type === "loadSound" && isLoadSoundEvent(payload.data)) { - this._loadSoundStream.next(payload.data); - } else if (payload.type === "openCoWebSite" && isOpenCoWebsite(payload.data)) { - scriptUtils.openCoWebsite( - payload.data.url, - foundSrc, - payload.data.allowApi, - payload.data.allowPolicy - ); - } else if (payload.type === "closeCoWebSite") { - scriptUtils.closeCoWebSite(); - } else if (payload.type === "disablePlayerControls") { - this._disablePlayerControlStream.next(); - } else if (payload.type === "restorePlayerControls") { - this._enablePlayerControlStream.next(); - } else if (payload.type === "displayBubble") { - this._displayBubbleStream.next(); - } else if (payload.type === "removeBubble") { - this._removeBubbleStream.next(); - } else if (payload.type == "onPlayerMove") { - this.sendPlayerMove = true; - } else if (payload.type == "getDataLayer") { - this._dataLayerChangeStream.next(); - } else if (isMenuItemRegisterIframeEvent(payload)) { - const data = payload.data.menutItem; - // @ts-ignore - this.iframeCloseCallbacks.get(iframe).push(() => { - this._unregisterMenuCommandStream.next(data); - }); - handleMenuItemRegistrationEvent(payload.data); - } else if (payload.type == "setTiles" && isSetTilesEvent(payload.data)) { - this._setTilesStream.next(payload.data); + if (payload.type === 'ready') { + this._readyStream.next(); + } else if (payload.type === "showLayer" && isLayerEvent(payload.data)) { + this._showLayerStream.next(payload.data); + } else if (payload.type === "hideLayer" && isLayerEvent(payload.data)) { + this._hideLayerStream.next(payload.data); + } else if (payload.type === "setProperty" && isSetPropertyEvent(payload.data)) { + this._setPropertyStream.next(payload.data); + } else if (payload.type === "chat" && isChatEvent(payload.data)) { + this._chatStream.next(payload.data); + } else if (payload.type === "openPopup" && isOpenPopupEvent(payload.data)) { + this._openPopupStream.next(payload.data); + } else if (payload.type === "closePopup" && isClosePopupEvent(payload.data)) { + this._closePopupStream.next(payload.data); + } else if (payload.type === "openTab" && isOpenTabEvent(payload.data)) { + scriptUtils.openTab(payload.data.url); + } else if (payload.type === "goToPage" && isGoToPageEvent(payload.data)) { + scriptUtils.goToPage(payload.data.url); + } else if (payload.type === "loadPage" && isLoadPageEvent(payload.data)) { + this._loadPageStream.next(payload.data.url); + } else if (payload.type === "playSound" && isPlaySoundEvent(payload.data)) { + this._playSoundStream.next(payload.data); + } else if (payload.type === "stopSound" && isStopSoundEvent(payload.data)) { + this._stopSoundStream.next(payload.data); + } else if (payload.type === "loadSound" && isLoadSoundEvent(payload.data)) { + this._loadSoundStream.next(payload.data); + } else if (payload.type === "openCoWebSite" && isOpenCoWebsite(payload.data)) { + scriptUtils.openCoWebsite( + payload.data.url, + foundSrc, + payload.data.allowApi, + payload.data.allowPolicy + ); + } else if (payload.type === "closeCoWebSite") { + scriptUtils.closeCoWebSite(); + } else if (payload.type === "disablePlayerControls") { + this._disablePlayerControlStream.next(); + } else if (payload.type === "restorePlayerControls") { + this._enablePlayerControlStream.next(); + } else if (payload.type === "displayBubble") { + this._displayBubbleStream.next(); + } else if (payload.type === "removeBubble") { + this._removeBubbleStream.next(); + } else if (payload.type == "onPlayerMove") { + this.sendPlayerMove = true; + } else if (payload.type == "getDataLayer") { + this._dataLayerChangeStream.next(); + } else if (isMenuItemRegisterIframeEvent(payload)) { + const data = payload.data.menutItem; + // @ts-ignore + this.iframeCloseCallbacks.get(iframe).push(() => { + this._unregisterMenuCommandStream.next(data); + }); + handleMenuItemRegistrationEvent(payload.data); + } else if (payload.type == "setTiles" && isSetTilesEvent(payload.data)) { + this._setTilesStream.next(payload.data); + } else if (isSetVariableIframeEvent(payload)) { + this._setVariableStream.next(payload.data); + + // Let's dispatch the message to the other iframes + for (iframe of this.iframes) { + if (iframe.contentWindow !== message.source) { + iframe.contentWindow?.postMessage({ + 'type': 'setVariable', + 'data': payload.data + }, '*'); + } } } + } }, false ); @@ -394,6 +415,13 @@ class IframeListener { }); } + setVariable(setVariableEvent: SetVariableEvent) { + this.postMessage({ + 'type': 'setVariable', + 'data': setVariableEvent + }); + } + /** * Sends the message... to all allowed iframes. */ diff --git a/front/src/Api/iframe/room.ts b/front/src/Api/iframe/room.ts index c70d0aad..623773c3 100644 --- a/front/src/Api/iframe/room.ts +++ b/front/src/Api/iframe/room.ts @@ -1,4 +1,4 @@ -import { Subject } from "rxjs"; +import {Observable, Subject} from "rxjs"; import { isDataLayerEvent } from "../Events/DataLayerEvent"; import { EnterLeaveEvent, isEnterLeaveEvent } from "../Events/EnterLeaveEvent"; @@ -6,6 +6,9 @@ import { isGameStateEvent } from "../Events/GameStateEvent"; import {IframeApiContribution, queryWorkadventure, sendToWorkadventure} from "./IframeApiContribution"; import { apiCallback } from "./registeredCallbacks"; +import type {LayerEvent} from "../Events/LayerEvent"; +import type {SetPropertyEvent} from "../Events/setPropertyEvent"; +import {isSetVariableEvent, SetVariableEvent} from "../Events/SetVariableEvent"; import type { ITiledMap } from "../../Phaser/Map/ITiledMap"; import type { DataLayerEvent } from "../Events/DataLayerEvent"; @@ -15,6 +18,9 @@ const enterStreams: Map> = new Map> = new Map>(); const dataLayerResolver = new Subject(); const stateResolvers = new Subject(); +const setVariableResolvers = new Subject(); +const variables = new Map(); +const variableSubscribers = new Map>(); let immutableDataPromise: Promise | undefined = undefined; @@ -52,6 +58,14 @@ function getDataLayer(): Promise { }); } +setVariableResolvers.subscribe((event) => { + variables.set(event.key, event.value); + const subject = variableSubscribers.get(event.key); + if (subject !== undefined) { + subject.next(event.value); + } +}); + export class WorkadventureRoomCommands extends IframeApiContribution { callbacks = [ apiCallback({ @@ -75,6 +89,13 @@ export class WorkadventureRoomCommands extends IframeApiContribution { + setVariableResolvers.next(payloadData); + } + }), ]; onEnterZone(name: string, callback: () => void): void { @@ -132,6 +153,30 @@ export class WorkadventureRoomCommands extends IframeApiContribution { + let subject = variableSubscribers.get(key); + if (subject === undefined) { + subject = new Subject(); + variableSubscribers.set(key, subject); + } + return subject.asObservable(); + } } export default new WorkadventureRoomCommands(); diff --git a/front/src/Phaser/Game/GameScene.ts b/front/src/Phaser/Game/GameScene.ts index d767f0f4..c2a2b38d 100644 --- a/front/src/Phaser/Game/GameScene.ts +++ b/front/src/Phaser/Game/GameScene.ts @@ -91,6 +91,8 @@ import { soundManager } from "./SoundManager"; import { peerStore, screenSharingPeerStore } from "../../Stores/PeerStore"; import { videoFocusStore } from "../../Stores/VideoFocusStore"; import { biggestAvailableAreaStore } from "../../Stores/BiggestAvailableAreaStore"; +import { SharedVariablesManager } from "./SharedVariablesManager"; +import type {InitEvent} from "../../Api/Events/InitEvent"; export interface GameSceneInitInterface { initPosition: PointInterface | null; @@ -199,7 +201,8 @@ export class GameScene extends DirtyScene { private mapTransitioning: boolean = false; //used to prevent transitions happenning at the same time. private emoteManager!: EmoteManager; private preloading: boolean = true; - startPositionCalculator!: StartPositionCalculator; + private startPositionCalculator!: StartPositionCalculator; + private sharedVariablesManager!: SharedVariablesManager; constructor(private room: Room, MapUrlFile: string, customKey?: string | undefined) { super({ @@ -396,6 +399,23 @@ export class GameScene extends DirtyScene { }); } + + this.iframeSubscriptionList.push(iframeListener.readyStream.subscribe((iframe) => { + this.connectionAnswerPromise.then(connection => { + // Generate init message for an iframe + // TODO: merge with GameStateEvent + const initEvent: InitEvent = { + variables: this.sharedVariablesManager.variables + } + + }); + // TODO: SEND INIT MESSAGE TO IFRAMES ONLY WHEN CONNECTION IS ESTABLISHED + // TODO: SEND INIT MESSAGE TO IFRAMES ONLY WHEN CONNECTION IS ESTABLISHED + // TODO: SEND INIT MESSAGE TO IFRAMES ONLY WHEN CONNECTION IS ESTABLISHED + // TODO: SEND INIT MESSAGE TO IFRAMES ONLY WHEN CONNECTION IS ESTABLISHED + // TODO: SEND INIT MESSAGE TO IFRAMES ONLY WHEN CONNECTION IS ESTABLISHED + })); + // Now, let's load the script, if any const scripts = this.getScriptUrls(this.mapFile); for (const script of scripts) { @@ -706,6 +726,9 @@ export class GameScene extends DirtyScene { this.gameMap.setPosition(event.x, event.y); }); + // Set up variables manager + this.sharedVariablesManager = new SharedVariablesManager(this.connection, this.gameMap); + //this.initUsersPosition(roomJoinedMessage.users); this.connectionAnswerPromiseResolve(onConnect.room); // Analyze tags to find if we are admin. If yes, show console. @@ -1148,6 +1171,7 @@ ${escapedMessage} this.peerStoreUnsubscribe(); this.biggestAvailableAreaStoreUnsubscribe(); iframeListener.unregisterAnswerer('getState'); + this.sharedVariablesManager?.close(); mediaManager.hideGameOverlay(); diff --git a/front/src/Phaser/Game/SharedVariablesManager.ts b/front/src/Phaser/Game/SharedVariablesManager.ts new file mode 100644 index 00000000..abd2474e --- /dev/null +++ b/front/src/Phaser/Game/SharedVariablesManager.ts @@ -0,0 +1,59 @@ +/** + * Handles variables shared between the scripting API and the server. + */ +import type {RoomConnection} from "../../Connexion/RoomConnection"; +import {iframeListener} from "../../Api/IframeListener"; +import type {Subscription} from "rxjs"; +import type {GameMap} from "./GameMap"; +import type {ITiledMapObject} from "../Map/ITiledMap"; + +export class SharedVariablesManager { + private _variables = new Map(); + private iframeListenerSubscription: Subscription; + private variableObjects: Map; + + constructor(private roomConnection: RoomConnection, private gameMap: GameMap) { + // We initialize the list of variable object at room start. The objects cannot be edited later + // (otherwise, this would cause a security issue if the scripting API can edit this list of objects) + this.variableObjects = SharedVariablesManager.findVariablesInMap(gameMap); + + // When a variable is modified from an iFrame + this.iframeListenerSubscription = iframeListener.setVariableStream.subscribe((event) => { + const key = event.key; + + if (!this.variableObjects.has(key)) { + const errMsg = 'A script is trying to modify variable "'+key+'" but this variable is not defined in the map.' + + 'There should be an object in the map whose name is "'+key+'" and whose type is "variable"'; + console.error(errMsg); + throw new Error(errMsg); + } + + this._variables.set(key, event.value); + // TODO: dispatch to the room connection. + }); + } + + private static findVariablesInMap(gameMap: GameMap): Map { + const objects = new Map(); + for (const layer of gameMap.getMap().layers) { + if (layer.type === 'objectgroup') { + for (const object of layer.objects) { + if (object.type === 'variable') { + // We store a copy of the object (to make it immutable) + objects.set(object.name, {...object}); + } + } + } + } + return objects; + } + + + public close(): void { + this.iframeListenerSubscription.unsubscribe(); + } + + get variables(): Map { + return this._variables; + } +} diff --git a/front/src/iframe_api.ts b/front/src/iframe_api.ts index 1915020e..b27bda2d 100644 --- a/front/src/iframe_api.ts +++ b/front/src/iframe_api.ts @@ -206,3 +206,9 @@ window.addEventListener( // ... } ); + +// Notify WorkAdventure that we are ready to receive data +sendToWorkadventure({ + type: 'ready', + data: null +}); diff --git a/maps/tests/Variables/script.js b/maps/tests/Variables/script.js new file mode 100644 index 00000000..afd16773 --- /dev/null +++ b/maps/tests/Variables/script.js @@ -0,0 +1,11 @@ + +console.log('Trying to set variable "not_exists". This should display an error in the console.') +WA.room.saveVariable('not_exists', 'foo'); + +console.log('Trying to set variable "config". This should work.'); +WA.room.saveVariable('config', {'foo': 'bar'}); + +console.log('Trying to read variable "config". This should display a {"foo": "bar"} object.'); +console.log(WA.room.loadVariable('config')); + + diff --git a/maps/tests/Variables/variables.json b/maps/tests/Variables/variables.json new file mode 100644 index 00000000..93573da8 --- /dev/null +++ b/maps/tests/Variables/variables.json @@ -0,0 +1,112 @@ +{ "compressionlevel":-1, + "height":10, + "infinite":false, + "layers":[ + { + "data":[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], + "height":10, + "id":1, + "name":"floor", + "opacity":1, + "type":"tilelayer", + "visible":true, + "width":10, + "x":0, + "y":0 + }, + { + "data":[0, 0, 0, 0, 0, 0, 0, 0, 23, 23, 0, 0, 0, 0, 0, 0, 0, 0, 23, 23, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "height":10, + "id":6, + "name":"triggerZone", + "opacity":1, + "properties":[ + { + "name":"zone", + "type":"string", + "value":"myTrigger" + }], + "type":"tilelayer", + "visible":true, + "width":10, + "x":0, + "y":0 + }, + { + "data":[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "height":10, + "id":2, + "name":"start", + "opacity":1, + "type":"tilelayer", + "visible":true, + "width":10, + "x":0, + "y":0 + }, + { + "draworder":"topdown", + "id":3, + "name":"floorLayer", + "objects":[ + { + "height":67, + "id":3, + "name":"", + "rotation":0, + "text": + { + "fontfamily":"Sans Serif", + "pixelsize":11, + "text":"Test:\nTODO", + "wrap":true + }, + "type":"", + "visible":true, + "width":252.4375, + "x":2.78125, + "y":2.5 + }, + { + "id":5, + "template":"config.tx", + "x":57.5, + "y":111 + }], + "opacity":1, + "type":"objectgroup", + "visible":true, + "x":0, + "y":0 + }], + "nextlayerid":8, + "nextobjectid":6, + "orientation":"orthogonal", + "properties":[ + { + "name":"script", + "type":"string", + "value":"script.js" + }], + "renderorder":"right-down", + "tiledversion":"2021.03.23", + "tileheight":32, + "tilesets":[ + { + "columns":11, + "firstgid":1, + "image":"..\/tileset1.png", + "imageheight":352, + "imagewidth":352, + "margin":0, + "name":"tileset1", + "spacing":0, + "tilecount":121, + "tileheight":32, + "tilewidth":32 + }], + "tilewidth":32, + "type":"map", + "version":1.5, + "width":10 +} \ No newline at end of file diff --git a/maps/tests/index.html b/maps/tests/index.html index 38ee51ef..a96690b8 100644 --- a/maps/tests/index.html +++ b/maps/tests/index.html @@ -202,6 +202,14 @@ Test set tiles + + + Success Failure Pending + + + Testing scripting variables + + - - - - - \ No newline at end of file diff --git a/maps/tests/Metadata/getCurrentRoom.js b/maps/tests/Metadata/getCurrentRoom.js new file mode 100644 index 00000000..8e90a4ae --- /dev/null +++ b/maps/tests/Metadata/getCurrentRoom.js @@ -0,0 +1,11 @@ +WA.onInit().then(() => { + console.log('id: ', WA.room.id); + console.log('Map URL: ', WA.room.mapURL); + console.log('Player name: ', WA.player.name); + console.log('Player id: ', WA.player.id); + console.log('Player tags: ', WA.player.tags); +}); + +WA.room.getMap().then((data) => { + console.log('Map data', data); +}) diff --git a/maps/tests/Metadata/getCurrentRoom.json b/maps/tests/Metadata/getCurrentRoom.json index c14bb946..05591521 100644 --- a/maps/tests/Metadata/getCurrentRoom.json +++ b/maps/tests/Metadata/getCurrentRoom.json @@ -1,11 +1,4 @@ { "compressionlevel":-1, - "editorsettings": - { - "export": - { - "target":"." - } - }, "height":10, "infinite":false, "layers":[ @@ -51,29 +44,6 @@ "x":0, "y":0 }, - { - "data":[0, 0, 0, 0, 0, 0, 128, 128, 128, 128, 0, 0, 0, 0, 0, 0, 128, 128, 128, 128, 0, 0, 0, 0, 0, 0, 128, 128, 128, 128, 0, 0, 0, 0, 0, 0, 128, 128, 128, 128, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - "height":10, - "id":4, - "name":"metadata", - "opacity":1, - "properties":[ - { - "name":"openWebsite", - "type":"string", - "value":"getCurrentRoom.html" - }, - { - "name":"openWebsiteAllowApi", - "type":"bool", - "value":true - }], - "type":"tilelayer", - "visible":true, - "width":10, - "x":0, - "y":0 - }, { "draworder":"topdown", "id":5, @@ -88,7 +58,7 @@ { "fontfamily":"Sans Serif", "pixelsize":9, - "text":"Test : \nWalk on the grass and open the console.\n\nResult : \nYou should see a console.log() of the following attributes : \n\t- id : ID of the current room\n\t- map : data of the JSON file of the map\n\t- mapUrl : url of the JSON file of the map\n\t- startLayer : Name of the layer where the current user started (HereYouAppered)\n\n\n", + "text":"Test : \nOpen the console.\n\nResult : \nYou should see a console.log() of the following attributes : \n\t- id : ID of the current room\n\t- mapUrl : url of the JSON file of the map\n\t- Player name\n - Player ID\n - Player tags\n\nAnd also:\n\t- map : data of the JSON file of the map\n\n", "wrap":true }, "type":"", @@ -106,8 +76,14 @@ "nextlayerid":11, "nextobjectid":2, "orientation":"orthogonal", + "properties":[ + { + "name":"script", + "type":"string", + "value":"getCurrentRoom.js" + }], "renderorder":"right-down", - "tiledversion":"1.4.3", + "tiledversion":"2021.03.23", "tileheight":32, "tilesets":[ { @@ -274,6 +250,6 @@ }], "tilewidth":32, "type":"map", - "version":1.4, + "version":1.5, "width":10 } \ No newline at end of file diff --git a/maps/tests/Metadata/getCurrentUser.html b/maps/tests/Metadata/getCurrentUser.html deleted file mode 100644 index 02be24f7..00000000 --- a/maps/tests/Metadata/getCurrentUser.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/maps/tests/Metadata/getCurrentUser.json b/maps/tests/Metadata/getCurrentUser.json deleted file mode 100644 index 9efd0d09..00000000 --- a/maps/tests/Metadata/getCurrentUser.json +++ /dev/null @@ -1,296 +0,0 @@ -{ "compressionlevel":-1, - "editorsettings": - { - "export": - { - "target":"." - } - }, - "height":10, - "infinite":false, - "layers":[ - { - "data":[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 52, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - "height":10, - "id":1, - "name":"start", - "opacity":1, - "type":"tilelayer", - "visible":true, - "width":10, - "x":0, - "y":0 - }, - { - "data":[33, 34, 34, 34, 34, 34, 34, 34, 34, 35, 41, 42, 42, 42, 42, 42, 42, 42, 42, 43, 41, 42, 42, 42, 42, 42, 42, 42, 42, 43, 41, 42, 42, 42, 42, 42, 42, 42, 42, 43, 41, 42, 42, 42, 42, 42, 42, 42, 42, 43, 41, 42, 42, 42, 42, 42, 42, 42, 42, 43, 41, 42, 42, 42, 42, 42, 42, 42, 42, 43, 41, 42, 42, 42, 42, 42, 42, 42, 42, 43, 41, 42, 42, 42, 42, 42, 42, 42, 42, 43, 49, 50, 50, 50, 50, 50, 50, 50, 50, 51], - "height":10, - "id":2, - "name":"bottom", - "opacity":1, - "type":"tilelayer", - "visible":true, - "width":10, - "x":0, - "y":0 - }, - { - "data":[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 101, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - "height":10, - "id":9, - "name":"exit", - "opacity":1, - "properties":[ - { - "name":"exitUrl", - "type":"string", - "value":"getCurrentRoom.json#HereYouAppered" - }], - "type":"tilelayer", - "visible":true, - "width":10, - "x":0, - "y":0 - }, - { - "data":[0, 0, 0, 0, 0, 0, 128, 128, 128, 128, 0, 0, 0, 0, 0, 0, 128, 128, 128, 128, 0, 0, 0, 0, 0, 0, 128, 128, 128, 128, 0, 0, 0, 0, 0, 0, 128, 128, 128, 128, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - "height":10, - "id":4, - "name":"metadata", - "opacity":1, - "properties":[ - { - "name":"openWebsite", - "type":"string", - "value":"getCurrentUser.html" - }, - { - "name":"openWebsiteAllowApi", - "type":"bool", - "value":true - }], - "type":"tilelayer", - "visible":true, - "width":10, - "x":0, - "y":0 - }, - { - "draworder":"topdown", - "id":5, - "name":"floorLayer", - "objects":[ - { - "height":151.839293303871, - "id":1, - "name":"", - "rotation":0, - "text": - { - "fontfamily":"Sans Serif", - "pixelsize":9, - "text":"Test : \nWalk on the grass, open the console.\n\nResut : \nYou should see a console.log() of the following attributes :\n\t- id : ID of the current user\n\t- nickName : Name of the current user\n\t- tags : List of tags of the current user\n\nFinally : \nWalk on the red tile and continue the test in an another room.", - "wrap":true - }, - "type":"", - "visible":true, - "width":305.097705765524, - "x":14.750638909983, - "y":159.621625296353 - }], - "opacity":1, - "type":"objectgroup", - "visible":true, - "x":0, - "y":0 - }], - "nextlayerid":10, - "nextobjectid":2, - "orientation":"orthogonal", - "renderorder":"right-down", - "tiledversion":"1.4.3", - "tileheight":32, - "tilesets":[ - { - "columns":8, - "firstgid":1, - "image":"tileset_dungeon.png", - "imageheight":256, - "imagewidth":256, - "margin":0, - "name":"TDungeon", - "spacing":0, - "tilecount":64, - "tileheight":32, - "tiles":[ - { - "id":0, - "properties":[ - { - "name":"collides", - "type":"bool", - "value":true - }] - }, - { - "id":1, - "properties":[ - { - "name":"collides", - "type":"bool", - "value":true - }] - }, - { - "id":2, - "properties":[ - { - "name":"collides", - "type":"bool", - "value":true - }] - }, - { - "id":3, - "properties":[ - { - "name":"collides", - "type":"bool", - "value":true - }] - }, - { - "id":4, - "properties":[ - { - "name":"collides", - "type":"bool", - "value":true - }] - }, - { - "id":8, - "properties":[ - { - "name":"collides", - "type":"bool", - "value":true - }] - }, - { - "id":9, - "properties":[ - { - "name":"collides", - "type":"bool", - "value":true - }] - }, - { - "id":10, - "properties":[ - { - "name":"collides", - "type":"bool", - "value":true - }] - }, - { - "id":11, - "properties":[ - { - "name":"collides", - "type":"bool", - "value":true - }] - }, - { - "id":12, - "properties":[ - { - "name":"collides", - "type":"bool", - "value":true - }] - }, - { - "id":16, - "properties":[ - { - "name":"collides", - "type":"bool", - "value":true - }] - }, - { - "id":17, - "properties":[ - { - "name":"collides", - "type":"bool", - "value":true - }] - }, - { - "id":18, - "properties":[ - { - "name":"collides", - "type":"bool", - "value":true - }] - }, - { - "id":19, - "properties":[ - { - "name":"collides", - "type":"bool", - "value":true - }] - }, - { - "id":20, - "properties":[ - { - "name":"collides", - "type":"bool", - "value":true - }] - }], - "tilewidth":32 - }, - { - "columns":8, - "firstgid":65, - "image":"floortileset.png", - "imageheight":288, - "imagewidth":256, - "margin":0, - "name":"Floor", - "spacing":0, - "tilecount":72, - "tileheight":32, - "tiles":[ - { - "animation":[ - { - "duration":100, - "tileid":9 - }, - { - "duration":100, - "tileid":64 - }, - { - "duration":100, - "tileid":55 - }], - "id":0 - }], - "tilewidth":32 - }], - "tilewidth":32, - "type":"map", - "version":1.4, - "width":10 -} \ No newline at end of file diff --git a/maps/tests/index.html b/maps/tests/index.html index a96690b8..dbcf8287 100644 --- a/maps/tests/index.html +++ b/maps/tests/index.html @@ -127,15 +127,7 @@ Success Failure Pending - Testing return current room attributes by Scripting API (Need to test from current user) - - - - - Success Failure Pending - - - Testing return current user attributes by Scripting API + Testing room/player attributes in Scripting API + WA.onInit From c30de8c6db35a726bb1dd7f49b84fa7631843f14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Mon, 5 Jul 2021 17:25:23 +0200 Subject: [PATCH 05/31] Adding support for default variables values --- front/src/Api/Events/IframeEvent.ts | 4 +-- front/src/Api/iframe/room.ts | 16 +++++---- front/src/Phaser/Game/GameMap.ts | 8 ++--- front/src/Phaser/Game/GameScene.ts | 10 +++--- .../src/Phaser/Game/SharedVariablesManager.ts | 35 ++++++++++++++++--- .../Phaser/Game/StartPositionCalculator.ts | 6 ++-- front/src/Phaser/Map/ITiledMap.ts | 16 ++++----- front/src/iframe_api.ts | 3 +- maps/tests/Variables/script.js | 18 +++++----- maps/tests/Variables/variables.json | 20 ++++++++++- 10 files changed, 92 insertions(+), 44 deletions(-) diff --git a/front/src/Api/Events/IframeEvent.ts b/front/src/Api/Events/IframeEvent.ts index 613ae525..a0e7717a 100644 --- a/front/src/Api/Events/IframeEvent.ts +++ b/front/src/Api/Events/IframeEvent.ts @@ -88,13 +88,11 @@ export type IframeQueryMap = { getState: { query: undefined, answer: GameStateEvent, - callback: () => GameStateEvent|PromiseLike }, getMapData: { query: undefined, answer: MapDataEvent, - callback: () => MapDataEvent|PromiseLike - } + }, } export interface IframeQuery { diff --git a/front/src/Api/iframe/room.ts b/front/src/Api/iframe/room.ts index 2e4f9fd5..00d974dc 100644 --- a/front/src/Api/iframe/room.ts +++ b/front/src/Api/iframe/room.ts @@ -1,18 +1,12 @@ import {Observable, Subject} from "rxjs"; -import { isMapDataEvent } from "../Events/MapDataEvent"; import { EnterLeaveEvent, isEnterLeaveEvent } from "../Events/EnterLeaveEvent"; -import { isGameStateEvent } from "../Events/GameStateEvent"; import {IframeApiContribution, queryWorkadventure, sendToWorkadventure} from "./IframeApiContribution"; import { apiCallback } from "./registeredCallbacks"; -import type {LayerEvent} from "../Events/LayerEvent"; -import type {SetPropertyEvent} from "../Events/setPropertyEvent"; import {isSetVariableEvent, SetVariableEvent} from "../Events/SetVariableEvent"; import type { ITiledMap } from "../../Phaser/Map/ITiledMap"; -import type { MapDataEvent } from "../Events/MapDataEvent"; -import type { GameStateEvent } from "../Events/GameStateEvent"; const enterStreams: Map> = new Map>(); const leaveStreams: Map> = new Map>(); @@ -39,6 +33,16 @@ export const setMapURL = (url: string) => { mapURL = url; } +export const initVariables = (_variables: Map): void => { + for (const [name, value] of _variables.entries()) { + // In case the user already decided to put values in the variables (before onInit), let's make sure onInit does not override this. + if (!variables.has(name)) { + variables.set(name, value); + } + } + +} + setVariableResolvers.subscribe((event) => { variables.set(event.key, event.value); const subject = variableSubscribers.get(event.key); diff --git a/front/src/Phaser/Game/GameMap.ts b/front/src/Phaser/Game/GameMap.ts index a616cf4a..e095dab1 100644 --- a/front/src/Phaser/Game/GameMap.ts +++ b/front/src/Phaser/Game/GameMap.ts @@ -1,4 +1,4 @@ -import type { ITiledMap, ITiledMapLayer, ITiledMapLayerProperty } from "../Map/ITiledMap"; +import type { ITiledMap, ITiledMapLayer, ITiledMapProperty } from "../Map/ITiledMap"; import { flattenGroupLayersMap } from "../Map/LayersFlattener"; import TilemapLayer = Phaser.Tilemaps.TilemapLayer; import { DEPTH_OVERLAY_INDEX } from "./DepthIndexes"; @@ -19,7 +19,7 @@ export class GameMap { private callbacks = new Map>(); private tileNameMap = new Map(); - private tileSetPropertyMap: { [tile_index: number]: Array } = {}; + private tileSetPropertyMap: { [tile_index: number]: Array } = {}; public readonly flatLayers: ITiledMapLayer[]; public readonly phaserLayers: TilemapLayer[] = []; @@ -61,7 +61,7 @@ export class GameMap { } } - public getPropertiesForIndex(index: number): Array { + public getPropertiesForIndex(index: number): Array { if (this.tileSetPropertyMap[index]) { return this.tileSetPropertyMap[index]; } @@ -151,7 +151,7 @@ export class GameMap { return this.map; } - private getTileProperty(index: number): Array { + private getTileProperty(index: number): Array { return this.tileSetPropertyMap[index]; } diff --git a/front/src/Phaser/Game/GameScene.ts b/front/src/Phaser/Game/GameScene.ts index 1c522703..5d4c6b2b 100644 --- a/front/src/Phaser/Game/GameScene.ts +++ b/front/src/Phaser/Game/GameScene.ts @@ -50,7 +50,7 @@ import { SelectCharacterScene, SelectCharacterSceneName } from "../Login/SelectC import type { ITiledMap, ITiledMapLayer, - ITiledMapLayerProperty, + ITiledMapProperty, ITiledMapObject, ITiledTileSet, } from "../Map/ITiledMap"; @@ -1197,12 +1197,12 @@ ${escapedMessage} } private getProperty(layer: ITiledMapLayer | ITiledMap, name: string): string | boolean | number | undefined { - const properties: ITiledMapLayerProperty[] | undefined = layer.properties; + const properties: ITiledMapProperty[] | undefined = layer.properties; if (!properties) { return undefined; } const obj = properties.find( - (property: ITiledMapLayerProperty) => property.name.toLowerCase() === name.toLowerCase() + (property: ITiledMapProperty) => property.name.toLowerCase() === name.toLowerCase() ); if (obj === undefined) { return undefined; @@ -1211,12 +1211,12 @@ ${escapedMessage} } private getProperties(layer: ITiledMapLayer | ITiledMap, name: string): (string | number | boolean | undefined)[] { - const properties: ITiledMapLayerProperty[] | undefined = layer.properties; + const properties: ITiledMapProperty[] | undefined = layer.properties; if (!properties) { return []; } return properties - .filter((property: ITiledMapLayerProperty) => property.name.toLowerCase() === name.toLowerCase()) + .filter((property: ITiledMapProperty) => property.name.toLowerCase() === name.toLowerCase()) .map((property) => property.value); } diff --git a/front/src/Phaser/Game/SharedVariablesManager.ts b/front/src/Phaser/Game/SharedVariablesManager.ts index abd2474e..aeb26d68 100644 --- a/front/src/Phaser/Game/SharedVariablesManager.ts +++ b/front/src/Phaser/Game/SharedVariablesManager.ts @@ -5,18 +5,28 @@ import type {RoomConnection} from "../../Connexion/RoomConnection"; import {iframeListener} from "../../Api/IframeListener"; import type {Subscription} from "rxjs"; import type {GameMap} from "./GameMap"; -import type {ITiledMapObject} from "../Map/ITiledMap"; +import type {ITile, ITiledMapObject} from "../Map/ITiledMap"; +import type {Var} from "svelte/types/compiler/interfaces"; + +interface Variable { + defaultValue: unknown +} export class SharedVariablesManager { private _variables = new Map(); private iframeListenerSubscription: Subscription; - private variableObjects: Map; + private variableObjects: Map; constructor(private roomConnection: RoomConnection, private gameMap: GameMap) { // We initialize the list of variable object at room start. The objects cannot be edited later // (otherwise, this would cause a security issue if the scripting API can edit this list of objects) this.variableObjects = SharedVariablesManager.findVariablesInMap(gameMap); + // Let's initialize default values + for (const [name, variableObject] of this.variableObjects.entries()) { + this._variables.set(name, variableObject.defaultValue); + } + // When a variable is modified from an iFrame this.iframeListenerSubscription = iframeListener.setVariableStream.subscribe((event) => { const key = event.key; @@ -33,14 +43,14 @@ export class SharedVariablesManager { }); } - private static findVariablesInMap(gameMap: GameMap): Map { - const objects = new Map(); + private static findVariablesInMap(gameMap: GameMap): Map { + const objects = new Map(); for (const layer of gameMap.getMap().layers) { if (layer.type === 'objectgroup') { for (const object of layer.objects) { if (object.type === 'variable') { // We store a copy of the object (to make it immutable) - objects.set(object.name, {...object}); + objects.set(object.name, this.iTiledObjectToVariable(object)); } } } @@ -48,6 +58,21 @@ export class SharedVariablesManager { return objects; } + private static iTiledObjectToVariable(object: ITiledMapObject): Variable { + const variable: Variable = { + defaultValue: undefined + }; + + if (object.properties) { + for (const property of object.properties) { + if (property.name === 'default') { + variable.defaultValue = property.value; + } + } + } + + return variable; + } public close(): void { this.iframeListenerSubscription.unsubscribe(); diff --git a/front/src/Phaser/Game/StartPositionCalculator.ts b/front/src/Phaser/Game/StartPositionCalculator.ts index 7460c81c..a0184d2b 100644 --- a/front/src/Phaser/Game/StartPositionCalculator.ts +++ b/front/src/Phaser/Game/StartPositionCalculator.ts @@ -1,5 +1,5 @@ import type { PositionInterface } from "../../Connexion/ConnexionModels"; -import type { ITiledMap, ITiledMapLayer, ITiledMapLayerProperty, ITiledMapTileLayer } from "../Map/ITiledMap"; +import type { ITiledMap, ITiledMapLayer, ITiledMapProperty, ITiledMapTileLayer } from "../Map/ITiledMap"; import type { GameMap } from "./GameMap"; const defaultStartLayerName = "start"; @@ -112,12 +112,12 @@ export class StartPositionCalculator { } private getProperty(layer: ITiledMapLayer | ITiledMap, name: string): string | boolean | number | undefined { - const properties: ITiledMapLayerProperty[] | undefined = layer.properties; + const properties: ITiledMapProperty[] | undefined = layer.properties; if (!properties) { return undefined; } const obj = properties.find( - (property: ITiledMapLayerProperty) => property.name.toLowerCase() === name.toLowerCase() + (property: ITiledMapProperty) => property.name.toLowerCase() === name.toLowerCase() ); if (obj === undefined) { return undefined; diff --git a/front/src/Phaser/Map/ITiledMap.ts b/front/src/Phaser/Map/ITiledMap.ts index 0653e83a..c5b96f22 100644 --- a/front/src/Phaser/Map/ITiledMap.ts +++ b/front/src/Phaser/Map/ITiledMap.ts @@ -16,7 +16,7 @@ export interface ITiledMap { * Map orientation (orthogonal) */ orientation: string; - properties?: ITiledMapLayerProperty[]; + properties?: ITiledMapProperty[]; /** * Render order (right-down) @@ -33,7 +33,7 @@ export interface ITiledMap { type?: string; } -export interface ITiledMapLayerProperty { +export interface ITiledMapProperty { name: string; type: string; value: string | boolean | number | undefined; @@ -51,7 +51,7 @@ export interface ITiledMapGroupLayer { id?: number; name: string; opacity: number; - properties?: ITiledMapLayerProperty[]; + properties?: ITiledMapProperty[]; type: "group"; visible: boolean; @@ -69,7 +69,7 @@ export interface ITiledMapTileLayer { height: number; name: string; opacity: number; - properties?: ITiledMapLayerProperty[]; + properties?: ITiledMapProperty[]; encoding?: string; compression?: string; @@ -91,7 +91,7 @@ export interface ITiledMapObjectLayer { height: number; name: string; opacity: number; - properties?: ITiledMapLayerProperty[]; + properties?: ITiledMapProperty[]; encoding?: string; compression?: string; @@ -117,7 +117,7 @@ export interface ITiledMapObject { gid: number; height: number; name: string; - properties: { [key: string]: string }; + properties?: ITiledMapProperty[]; rotation: number; type: string; visible: boolean; @@ -163,7 +163,7 @@ export interface ITiledTileSet { imagewidth: number; margin: number; name: string; - properties: { [key: string]: string }; + properties?: ITiledMapProperty[]; spacing: number; tilecount: number; tileheight: number; @@ -182,7 +182,7 @@ export interface ITile { id: number; type?: string; - properties?: Array; + properties?: ITiledMapProperty[]; } export interface ITiledMapTerrain { diff --git a/front/src/iframe_api.ts b/front/src/iframe_api.ts index ee68270e..fb44738f 100644 --- a/front/src/iframe_api.ts +++ b/front/src/iframe_api.ts @@ -11,7 +11,7 @@ import nav from "./Api/iframe/nav"; import controls from "./Api/iframe/controls"; import ui from "./Api/iframe/ui"; import sound from "./Api/iframe/sound"; -import room, {setMapURL, setRoomId} from "./Api/iframe/room"; +import room, {initVariables, setMapURL, setRoomId} from "./Api/iframe/room"; import player, {setPlayerName, setTags, setUuid} from "./Api/iframe/player"; import type { ButtonDescriptor } from "./Api/iframe/Ui/ButtonDescriptor"; import type { Popup } from "./Api/iframe/Ui/Popup"; @@ -29,6 +29,7 @@ const initPromise = new Promise((resolve) => { setMapURL(state.mapUrl); setTags(state.tags); setUuid(state.uuid); + initVariables(state.variables as Map); resolve(); })); }); diff --git a/maps/tests/Variables/script.js b/maps/tests/Variables/script.js index afd16773..cef9818e 100644 --- a/maps/tests/Variables/script.js +++ b/maps/tests/Variables/script.js @@ -1,11 +1,13 @@ +WA.onInit().then(() => { + console.log('Trying to read variable "doorOpened" whose default property is true. This should display "true".'); + console.log('doorOpened', WA.room.loadVariable('doorOpened')); -console.log('Trying to set variable "not_exists". This should display an error in the console.') -WA.room.saveVariable('not_exists', 'foo'); - -console.log('Trying to set variable "config". This should work.'); -WA.room.saveVariable('config', {'foo': 'bar'}); - -console.log('Trying to read variable "config". This should display a {"foo": "bar"} object.'); -console.log(WA.room.loadVariable('config')); + console.log('Trying to set variable "not_exists". This should display an error in the console.') + WA.room.saveVariable('not_exists', 'foo'); + console.log('Trying to set variable "config". This should work.'); + WA.room.saveVariable('config', {'foo': 'bar'}); + console.log('Trying to read variable "config". This should display a {"foo": "bar"} object.'); + console.log(WA.room.loadVariable('config')); +}); diff --git a/maps/tests/Variables/variables.json b/maps/tests/Variables/variables.json index 93573da8..61067071 100644 --- a/maps/tests/Variables/variables.json +++ b/maps/tests/Variables/variables.json @@ -72,6 +72,24 @@ "template":"config.tx", "x":57.5, "y":111 + }, + { + "height":0, + "id":6, + "name":"doorOpened", + "point":true, + "properties":[ + { + "name":"default", + "type":"bool", + "value":true + }], + "rotation":0, + "type":"variable", + "visible":true, + "width":0, + "x":131.38069962269, + "y":106.004988169086 }], "opacity":1, "type":"objectgroup", @@ -80,7 +98,7 @@ "y":0 }], "nextlayerid":8, - "nextobjectid":6, + "nextobjectid":8, "orientation":"orthogonal", "properties":[ { From bf17ad4567b2c07638e7ade47f3b33343cda92f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Mon, 5 Jul 2021 18:29:34 +0200 Subject: [PATCH 06/31] Switching setVariable to a query and fixing error hangling in query mechanism --- front/src/Api/Events/IframeEvent.ts | 5 +- front/src/Api/IframeListener.ts | 48 ++++++++++--------- front/src/Api/iframe/room.ts | 4 +- .../src/Phaser/Game/SharedVariablesManager.ts | 5 +- front/src/iframe_api.ts | 24 +++++----- maps/tests/Variables/script.js | 6 ++- 6 files changed, 49 insertions(+), 43 deletions(-) diff --git a/front/src/Api/Events/IframeEvent.ts b/front/src/Api/Events/IframeEvent.ts index a0e7717a..54319fd3 100644 --- a/front/src/Api/Events/IframeEvent.ts +++ b/front/src/Api/Events/IframeEvent.ts @@ -50,7 +50,6 @@ export type IframeEventMap = { getState: undefined; registerMenuCommand: MenuItemRegisterEvent; setTiles: SetTilesEvent; - setVariable: SetVariableEvent; }; export interface IframeEvent { type: T; @@ -93,6 +92,10 @@ export type IframeQueryMap = { query: undefined, answer: MapDataEvent, }, + setVariable: { + query: SetVariableEvent, + answer: void + } } export interface IframeQuery { diff --git a/front/src/Api/IframeListener.ts b/front/src/Api/IframeListener.ts index c74e68a7..ee969721 100644 --- a/front/src/Api/IframeListener.ts +++ b/front/src/Api/IframeListener.ts @@ -105,9 +105,6 @@ class IframeListener { private readonly _setTilesStream: Subject = new Subject(); public readonly setTilesStream = this._setTilesStream.asObservable(); - private readonly _setVariableStream: Subject = new Subject(); - public readonly setVariableStream = this._setVariableStream.asObservable(); - private readonly iframes = new Set(); private readonly iframeCloseCallbacks = new Map void)[]>(); private readonly scripts = new Map(); @@ -171,13 +168,7 @@ class IframeListener { return; } - Promise.resolve(answerer(query.data)).then((value) => { - iframe?.contentWindow?.postMessage({ - id: queryId, - type: query.type, - data: value - }, '*'); - }).catch(reason => { + const errorHandler = (reason: any) => { console.error('An error occurred while responding to an iFrame query.', reason); let reasonMsg: string; if (reason instanceof Error) { @@ -191,8 +182,31 @@ class IframeListener { type: query.type, error: reasonMsg } as IframeErrorAnswerEvent, '*'); - }); + }; + try { + Promise.resolve(answerer(query.data)).then((value) => { + iframe?.contentWindow?.postMessage({ + id: queryId, + type: query.type, + data: value + }, '*'); + }).catch(errorHandler); + } catch (reason) { + errorHandler(reason); + } + + if (isSetVariableIframeEvent(payload.query)) { + // Let's dispatch the message to the other iframes + for (iframe of this.iframes) { + if (iframe.contentWindow !== message.source) { + iframe.contentWindow?.postMessage({ + 'type': 'setVariable', + 'data': payload.query.data + }, '*'); + } + } + } } else if (isIframeEventWrapper(payload)) { if (payload.type === "showLayer" && isLayerEvent(payload.data)) { this._showLayerStream.next(payload.data); @@ -246,18 +260,6 @@ class IframeListener { handleMenuItemRegistrationEvent(payload.data); } else if (payload.type == "setTiles" && isSetTilesEvent(payload.data)) { this._setTilesStream.next(payload.data); - } else if (isSetVariableIframeEvent(payload)) { - this._setVariableStream.next(payload.data); - - // Let's dispatch the message to the other iframes - for (iframe of this.iframes) { - if (iframe.contentWindow !== message.source) { - iframe.contentWindow?.postMessage({ - 'type': 'setVariable', - 'data': payload.data - }, '*'); - } - } } } }, diff --git a/front/src/Api/iframe/room.ts b/front/src/Api/iframe/room.ts index 00d974dc..db639cd9 100644 --- a/front/src/Api/iframe/room.ts +++ b/front/src/Api/iframe/room.ts @@ -119,9 +119,9 @@ export class WorkadventureRoomCommands extends IframeApiContribution { variables.set(key, value); - sendToWorkadventure({ + return queryWorkadventure({ type: 'setVariable', data: { key, diff --git a/front/src/Phaser/Game/SharedVariablesManager.ts b/front/src/Phaser/Game/SharedVariablesManager.ts index aeb26d68..dfedcf80 100644 --- a/front/src/Phaser/Game/SharedVariablesManager.ts +++ b/front/src/Phaser/Game/SharedVariablesManager.ts @@ -14,7 +14,6 @@ interface Variable { export class SharedVariablesManager { private _variables = new Map(); - private iframeListenerSubscription: Subscription; private variableObjects: Map; constructor(private roomConnection: RoomConnection, private gameMap: GameMap) { @@ -28,7 +27,7 @@ export class SharedVariablesManager { } // When a variable is modified from an iFrame - this.iframeListenerSubscription = iframeListener.setVariableStream.subscribe((event) => { + iframeListener.registerAnswerer('setVariable', (event) => { const key = event.key; if (!this.variableObjects.has(key)) { @@ -75,7 +74,7 @@ export class SharedVariablesManager { } public close(): void { - this.iframeListenerSubscription.unsubscribe(); + iframeListener.unregisterAnswerer('setVariable'); } get variables(): Map { diff --git a/front/src/iframe_api.ts b/front/src/iframe_api.ts index fb44738f..da2e922e 100644 --- a/front/src/iframe_api.ts +++ b/front/src/iframe_api.ts @@ -192,7 +192,18 @@ window.addEventListener( console.debug(payload); - if (isIframeAnswerEvent(payload)) { + if (isIframeErrorAnswerEvent(payload)) { + const queryId = payload.id; + const payloadError = payload.error; + + const resolver = answerPromises.get(queryId); + if (resolver === undefined) { + throw new Error('In Iframe API, got an error answer for a question that we have no track of.'); + } + resolver.reject(new Error(payloadError)); + + answerPromises.delete(queryId); + } else if (isIframeAnswerEvent(payload)) { const queryId = payload.id; const payloadData = payload.data; @@ -202,17 +213,6 @@ window.addEventListener( } resolver.resolve(payloadData); - answerPromises.delete(queryId); - } else if (isIframeErrorAnswerEvent(payload)) { - const queryId = payload.id; - const payloadError = payload.error; - - const resolver = answerPromises.get(queryId); - if (resolver === undefined) { - throw new Error('In Iframe API, got an error answer for a question that we have no track of.'); - } - resolver.reject(payloadError); - answerPromises.delete(queryId); } else if (isIframeResponseEventWrapper(payload)) { const payloadData = payload.data; diff --git a/maps/tests/Variables/script.js b/maps/tests/Variables/script.js index cef9818e..ea381018 100644 --- a/maps/tests/Variables/script.js +++ b/maps/tests/Variables/script.js @@ -2,8 +2,10 @@ WA.onInit().then(() => { console.log('Trying to read variable "doorOpened" whose default property is true. This should display "true".'); console.log('doorOpened', WA.room.loadVariable('doorOpened')); - console.log('Trying to set variable "not_exists". This should display an error in the console.') - WA.room.saveVariable('not_exists', 'foo'); + console.log('Trying to set variable "not_exists". This should display an error in the console, followed by a log saying the error was caught.') + WA.room.saveVariable('not_exists', 'foo').catch((e) => { + console.log('Successfully caught error: ', e); + }); console.log('Trying to set variable "config". This should work.'); WA.room.saveVariable('config', {'foo': 'bar'}); From 0aa93543bc285d7c50ef4587b1eb51bb1da1147a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Mon, 5 Jul 2021 18:48:26 +0200 Subject: [PATCH 07/31] Adding warning if "template" object is used as a variable --- .../src/Phaser/Game/SharedVariablesManager.ts | 4 +++ front/src/Phaser/Map/ITiledMap.ts | 1 + front/src/iframe_api.ts | 2 +- maps/tests/Variables/variables.json | 36 +++++++++++++++++-- 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/front/src/Phaser/Game/SharedVariablesManager.ts b/front/src/Phaser/Game/SharedVariablesManager.ts index dfedcf80..283eb5c3 100644 --- a/front/src/Phaser/Game/SharedVariablesManager.ts +++ b/front/src/Phaser/Game/SharedVariablesManager.ts @@ -48,6 +48,10 @@ export class SharedVariablesManager { if (layer.type === 'objectgroup') { for (const object of layer.objects) { if (object.type === 'variable') { + if (object.template) { + console.warn('Warning, a variable object is using a Tiled "template". WorkAdventure does not support objects generated from Tiled templates.') + } + // We store a copy of the object (to make it immutable) objects.set(object.name, this.iTiledObjectToVariable(object)); } diff --git a/front/src/Phaser/Map/ITiledMap.ts b/front/src/Phaser/Map/ITiledMap.ts index c5b96f22..57bb13c9 100644 --- a/front/src/Phaser/Map/ITiledMap.ts +++ b/front/src/Phaser/Map/ITiledMap.ts @@ -141,6 +141,7 @@ export interface ITiledMapObject { polyline: { x: number; y: number }[]; text?: ITiledText; + template?: string; } export interface ITiledText { diff --git a/front/src/iframe_api.ts b/front/src/iframe_api.ts index da2e922e..cd610ab0 100644 --- a/front/src/iframe_api.ts +++ b/front/src/iframe_api.ts @@ -190,7 +190,7 @@ window.addEventListener( } const payload = message.data; - console.debug(payload); + //console.debug(payload); if (isIframeErrorAnswerEvent(payload)) { const queryId = payload.id; diff --git a/maps/tests/Variables/variables.json b/maps/tests/Variables/variables.json index 61067071..94d40560 100644 --- a/maps/tests/Variables/variables.json +++ b/maps/tests/Variables/variables.json @@ -68,8 +68,40 @@ "y":2.5 }, { + "height":0, "id":5, - "template":"config.tx", + "name":"config", + "point":true, + "properties":[ + { + "name":"default", + "type":"string", + "value":"{}" + }, + { + "name":"jsonSchema", + "type":"string", + "value":"{}" + }, + { + "name":"persist", + "type":"bool", + "value":true + }, + { + "name":"readableBy", + "type":"string", + "value":"" + }, + { + "name":"writableBy", + "type":"string", + "value":"admin" + }], + "rotation":0, + "type":"variable", + "visible":true, + "width":0, "x":57.5, "y":111 }, @@ -98,7 +130,7 @@ "y":0 }], "nextlayerid":8, - "nextobjectid":8, + "nextobjectid":9, "orientation":"orthogonal", "properties":[ { From 86fa869b20b0f1ce01247b5a37d6a5cb83318ae9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Tue, 6 Jul 2021 10:26:44 +0200 Subject: [PATCH 08/31] Actually using Type Guards in queries received by WA. --- front/src/Api/Events/IframeEvent.ts | 47 +++++++++++++++++++++++------ front/src/Api/IframeListener.ts | 10 +++--- maps/tests/Variables/variables.json | 20 +----------- 3 files changed, 45 insertions(+), 32 deletions(-) diff --git a/front/src/Api/Events/IframeEvent.ts b/front/src/Api/Events/IframeEvent.ts index 54319fd3..0d995255 100644 --- a/front/src/Api/Events/IframeEvent.ts +++ b/front/src/Api/Events/IframeEvent.ts @@ -1,3 +1,4 @@ +import * as tg from "generic-type-guard"; import type { GameStateEvent } from "./GameStateEvent"; import type { ButtonClickedEvent } from "./ButtonClickedEvent"; import type { ChatEvent } from "./ChatEvent"; @@ -19,6 +20,9 @@ import type { MenuItemRegisterEvent } from "./ui/MenuItemRegisterEvent"; import type { HasPlayerMovedEvent } from "./HasPlayerMovedEvent"; import type { SetTilesEvent } from "./SetTilesEvent"; import type { SetVariableEvent } from "./SetVariableEvent"; +import {isGameStateEvent} from "./GameStateEvent"; +import {isMapDataEvent} from "./MapDataEvent"; +import {isSetVariableEvent} from "./SetVariableEvent"; export interface TypedMessageEvent extends MessageEvent { data: T; @@ -81,20 +85,32 @@ export const isIframeResponseEventWrapper = (event: { /** - * List event types sent from an iFrame to WorkAdventure that expect a unique answer from WorkAdventure along the type for the answer from WorkAdventure to the iFrame + * List event types sent from an iFrame to WorkAdventure that expect a unique answer from WorkAdventure along the type for the answer from WorkAdventure to the iFrame. + * Types are defined using Type guards that will actually bused to enforce and check types. */ -export type IframeQueryMap = { +export const iframeQueryMapTypeGuards = { getState: { - query: undefined, - answer: GameStateEvent, + query: tg.isUndefined, + answer: isGameStateEvent, }, getMapData: { - query: undefined, - answer: MapDataEvent, + query: tg.isUndefined, + answer: isMapDataEvent, }, setVariable: { - query: SetVariableEvent, - answer: void + query: isSetVariableEvent, + answer: tg.isUndefined, + }, +} + +type GuardedType = T extends (x: unknown) => x is (infer T) ? T : never; +type IframeQueryMapTypeGuardsType = typeof iframeQueryMapTypeGuards; +type UnknownToVoid = undefined extends T ? void : T; + +export type IframeQueryMap = { + [key in keyof IframeQueryMapTypeGuardsType]: { + query: GuardedType + answer: UnknownToVoid> } } @@ -108,8 +124,21 @@ export interface IframeQueryWrapper { query: IframeQuery; } +export const isIframeQueryKey = (type: string): type is keyof IframeQueryMap => { + return type in iframeQueryMapTypeGuards; +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any -export const isIframeQuery = (event: any): event is IframeQuery => typeof event.type === 'string'; +export const isIframeQuery = (event: any): event is IframeQuery => { + const type = event.type; + if (typeof type !== 'string') { + return false; + } + if (!isIframeQueryKey(type)) { + return false; + } + return iframeQueryMapTypeGuards[type].query(event.data); +} // eslint-disable-next-line @typescript-eslint/no-explicit-any export const isIframeQueryWrapper = (event: any): event is IframeQueryWrapper => typeof event.id === 'number' && isIframeQuery(event.query); diff --git a/front/src/Api/IframeListener.ts b/front/src/Api/IframeListener.ts index ee969721..9c61bdf8 100644 --- a/front/src/Api/IframeListener.ts +++ b/front/src/Api/IframeListener.ts @@ -168,13 +168,15 @@ class IframeListener { return; } - const errorHandler = (reason: any) => { + const errorHandler = (reason: unknown) => { console.error('An error occurred while responding to an iFrame query.', reason); - let reasonMsg: string; + let reasonMsg: string = ''; if (reason instanceof Error) { reasonMsg = reason.message; - } else { - reasonMsg = reason.toString(); + } else if (typeof reason === 'object') { + reasonMsg = reason ? reason.toString() : ''; + } else if (typeof reason === 'string') { + reasonMsg = reason; } iframe?.contentWindow?.postMessage({ diff --git a/maps/tests/Variables/variables.json b/maps/tests/Variables/variables.json index 94d40560..d74e90cc 100644 --- a/maps/tests/Variables/variables.json +++ b/maps/tests/Variables/variables.json @@ -14,24 +14,6 @@ "x":0, "y":0 }, - { - "data":[0, 0, 0, 0, 0, 0, 0, 0, 23, 23, 0, 0, 0, 0, 0, 0, 0, 0, 23, 23, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - "height":10, - "id":6, - "name":"triggerZone", - "opacity":1, - "properties":[ - { - "name":"zone", - "type":"string", - "value":"myTrigger" - }], - "type":"tilelayer", - "visible":true, - "width":10, - "x":0, - "y":0 - }, { "data":[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], "height":10, @@ -58,7 +40,7 @@ { "fontfamily":"Sans Serif", "pixelsize":11, - "text":"Test:\nTODO", + "text":"Test:\nOpen your console\n\nResult:\nYou should see a list of tests performed and results associated.", "wrap":true }, "type":"", From cb78ff333bb62f2c896ea5f30ba60f1290c24f58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Tue, 6 Jul 2021 10:58:12 +0200 Subject: [PATCH 09/31] Adding client side check of setVariable with writableBy property --- front/src/Connexion/RoomConnection.ts | 13 +++++- .../src/Phaser/Game/SharedVariablesManager.ts | 38 ++++++++++++++++-- maps/tests/Variables/script.js | 13 ++++-- maps/tests/Variables/variables.json | 40 ++++++++++++++++++- messages/protos/messages.proto | 2 +- 5 files changed, 95 insertions(+), 11 deletions(-) diff --git a/front/src/Connexion/RoomConnection.ts b/front/src/Connexion/RoomConnection.ts index 1b080a55..c2d4157b 100644 --- a/front/src/Connexion/RoomConnection.ts +++ b/front/src/Connexion/RoomConnection.ts @@ -31,7 +31,7 @@ import { EmoteEventMessage, EmotePromptMessage, SendUserMessage, - BanUserMessage, + BanUserMessage, VariableMessage, } from "../Messages/generated/messages_pb"; import type { UserSimplePeerInterface } from "../WebRtc/SimplePeer"; @@ -536,6 +536,17 @@ export class RoomConnection implements RoomConnection { this.socket.send(clientToServerMessage.serializeBinary().buffer); } + emitSetVariableEvent(name: string, value: unknown): void { + const variableMessage = new VariableMessage(); + variableMessage.setName(name); + variableMessage.setValue(JSON.stringify(value)); + + const clientToServerMessage = new ClientToServerMessage(); + clientToServerMessage.setVariablemessage(variableMessage); + + this.socket.send(clientToServerMessage.serializeBinary().buffer); + } + onActionableEvent(callback: (message: ItemEventMessageInterface) => void): void { this.onMessage(EventMessage.ITEM_EVENT, (message: ItemEventMessage) => { callback({ diff --git a/front/src/Phaser/Game/SharedVariablesManager.ts b/front/src/Phaser/Game/SharedVariablesManager.ts index 283eb5c3..284dec1d 100644 --- a/front/src/Phaser/Game/SharedVariablesManager.ts +++ b/front/src/Phaser/Game/SharedVariablesManager.ts @@ -9,7 +9,9 @@ import type {ITile, ITiledMapObject} from "../Map/ITiledMap"; import type {Var} from "svelte/types/compiler/interfaces"; interface Variable { - defaultValue: unknown + defaultValue: unknown, + readableBy?: string, + writableBy?: string, } export class SharedVariablesManager { @@ -30,15 +32,24 @@ export class SharedVariablesManager { iframeListener.registerAnswerer('setVariable', (event) => { const key = event.key; - if (!this.variableObjects.has(key)) { + const object = this.variableObjects.get(key); + + if (object === undefined) { const errMsg = 'A script is trying to modify variable "'+key+'" but this variable is not defined in the map.' + 'There should be an object in the map whose name is "'+key+'" and whose type is "variable"'; console.error(errMsg); throw new Error(errMsg); } + if (object.writableBy && !this.roomConnection.hasTag(object.writableBy)) { + const errMsg = 'A script is trying to modify variable "'+key+'" but this variable is only writable for users with tag "'+object.writableBy+'".'; + console.error(errMsg); + throw new Error(errMsg); + } + this._variables.set(key, event.value); // TODO: dispatch to the room connection. + this.roomConnection.emitSetVariableEvent(key, event.value); }); } @@ -68,8 +79,27 @@ export class SharedVariablesManager { if (object.properties) { for (const property of object.properties) { - if (property.name === 'default') { - variable.defaultValue = property.value; + const value = property.value; + switch (property.name) { + case 'default': + variable.defaultValue = value; + break; + case 'writableBy': + if (typeof value !== 'string') { + throw new Error('The writableBy property of variable "'+object.name+'" must be a string'); + } + if (value) { + variable.writableBy = value; + } + break; + case 'readableBy': + if (typeof value !== 'string') { + throw new Error('The readableBy property of variable "'+object.name+'" must be a string'); + } + if (value) { + variable.readableBy = value; + } + break; } } } diff --git a/maps/tests/Variables/script.js b/maps/tests/Variables/script.js index ea381018..120a4425 100644 --- a/maps/tests/Variables/script.js +++ b/maps/tests/Variables/script.js @@ -7,9 +7,14 @@ WA.onInit().then(() => { console.log('Successfully caught error: ', e); }); - console.log('Trying to set variable "config". This should work.'); - WA.room.saveVariable('config', {'foo': 'bar'}); + console.log('Trying to set variable "myvar". This should work.'); + WA.room.saveVariable('myvar', {'foo': 'bar'}); - console.log('Trying to read variable "config". This should display a {"foo": "bar"} object.'); - console.log(WA.room.loadVariable('config')); + console.log('Trying to read variable "myvar". This should display a {"foo": "bar"} object.'); + console.log(WA.room.loadVariable('myvar')); + + console.log('Trying to set variable "config". This should not work because we are not logged as admin.'); + WA.room.saveVariable('config', {'foo': 'bar'}).catch(e => { + console.log('Successfully caught error because variable "config" is not writable: ', e); + }); }); diff --git a/maps/tests/Variables/variables.json b/maps/tests/Variables/variables.json index d74e90cc..79ca591b 100644 --- a/maps/tests/Variables/variables.json +++ b/maps/tests/Variables/variables.json @@ -104,6 +104,44 @@ "width":0, "x":131.38069962269, "y":106.004988169086 + }, + { + "height":0, + "id":9, + "name":"myvar", + "point":true, + "properties":[ + { + "name":"default", + "type":"string", + "value":"{}" + }, + { + "name":"jsonSchema", + "type":"string", + "value":"{}" + }, + { + "name":"persist", + "type":"bool", + "value":true + }, + { + "name":"readableBy", + "type":"string", + "value":"" + }, + { + "name":"writableBy", + "type":"string", + "value":"" + }], + "rotation":0, + "type":"variable", + "visible":true, + "width":0, + "x":88.8149900876127, + "y":147.75212636695 }], "opacity":1, "type":"objectgroup", @@ -112,7 +150,7 @@ "y":0 }], "nextlayerid":8, - "nextobjectid":9, + "nextobjectid":10, "orientation":"orthogonal", "properties":[ { diff --git a/messages/protos/messages.proto b/messages/protos/messages.proto index a9483dd9..30882cd9 100644 --- a/messages/protos/messages.proto +++ b/messages/protos/messages.proto @@ -186,7 +186,7 @@ message RoomJoinedMessage { repeated ItemStateMessage item = 3; int32 currentUserId = 4; repeated string tag = 5; - repeated VariableMessage = 6; + repeated VariableMessage variable = 6; } message WebRtcStartMessage { From a1f1927b6d94f03ef97fc070b22016a30ea8302c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Tue, 6 Jul 2021 15:30:49 +0200 Subject: [PATCH 10/31] Starting adding variables server-side --- back/src/Model/GameRoom.ts | 7 +- back/src/RoomManager.ts | 4 +- back/src/Services/SocketManager.ts | 26 ++++++- messages/protos/messages.proto | 24 ++++++- pusher/src/Controller/IoSocketController.ts | 4 +- pusher/src/Model/PusherRoom.ts | 79 +++++++++++++++++++-- pusher/src/Services/SocketManager.ts | 33 ++++----- 7 files changed, 147 insertions(+), 30 deletions(-) diff --git a/back/src/Model/GameRoom.ts b/back/src/Model/GameRoom.ts index 020f4c29..33af483f 100644 --- a/back/src/Model/GameRoom.ts +++ b/back/src/Model/GameRoom.ts @@ -34,7 +34,8 @@ export class GameRoom { private readonly connectCallback: ConnectCallback; private readonly disconnectCallback: DisconnectCallback; - private itemsState: Map = new Map(); + private itemsState = new Map(); + private variables = new Map(); private readonly positionNotifier: PositionNotifier; public readonly roomId: string; @@ -309,6 +310,10 @@ export class GameRoom { return this.itemsState; } + public setVariable(name: string, value: string): void { + this.variables.set(name, value); + } + public addZoneListener(call: ZoneSocket, x: number, y: number): Set { return this.positionNotifier.addZoneListener(call, x, y); } diff --git a/back/src/RoomManager.ts b/back/src/RoomManager.ts index 9aaf1edb..2514c576 100644 --- a/back/src/RoomManager.ts +++ b/back/src/RoomManager.ts @@ -16,7 +16,7 @@ import { ServerToAdminClientMessage, ServerToClientMessage, SilentMessage, - UserMovesMessage, + UserMovesMessage, VariableMessage, WebRtcSignalToServerMessage, WorldFullWarningToRoomMessage, ZoneMessage, @@ -72,6 +72,8 @@ const roomManager: IRoomManagerServer = { socketManager.handleSilentMessage(room, user, message.getSilentmessage() as SilentMessage); } else if (message.hasItemeventmessage()) { socketManager.handleItemEvent(room, user, message.getItemeventmessage() as ItemEventMessage); + } else if (message.hasVariablemessage()) { + socketManager.handleVariableEvent(room, user, message.getVariablemessage() as VariableMessage); } else if (message.hasWebrtcsignaltoservermessage()) { socketManager.emitVideo( room, diff --git a/back/src/Services/SocketManager.ts b/back/src/Services/SocketManager.ts index e61763cd..e8356245 100644 --- a/back/src/Services/SocketManager.ts +++ b/back/src/Services/SocketManager.ts @@ -29,7 +29,7 @@ import { EmoteEventMessage, BanUserMessage, RefreshRoomMessage, - EmotePromptMessage, + EmotePromptMessage, VariableMessage, } from "../Messages/generated/messages_pb"; import { User, UserSocket } from "../Model/User"; import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils"; @@ -184,6 +184,28 @@ export class SocketManager { } } + handleVariableEvent(room: GameRoom, user: User, variableMessage: VariableMessage) { + const itemEvent = ProtobufUtils.toItemEvent(itemEventMessage); + + try { + // TODO: DISPATCH ON NEW ROOM CHANNEL + + const subMessage = new SubMessage(); + subMessage.setItemeventmessage(itemEventMessage); + + // Let's send the event without using the SocketIO room. + // TODO: move this in the GameRoom class. + for (const user of room.getUsers().values()) { + user.emitInBatch(subMessage); + } + + room.setVariable(variableMessage.getName(), variableMessage.getValue()); + } catch (e) { + console.error('An error occurred on "item_event"'); + console.error(e); + } + } + emitVideo(room: GameRoom, user: User, data: WebRtcSignalToServerMessage): void { //send only at user const remoteUser = room.getUsers().get(data.getReceiverid()); @@ -425,6 +447,7 @@ export class SocketManager { // Let's send 2 messages: one to the user joining the group and one to the other user const webrtcStartMessage1 = new WebRtcStartMessage(); webrtcStartMessage1.setUserid(otherUser.id); + webrtcStartMessage1.setUseruuid(otherUser.uuid); webrtcStartMessage1.setName(otherUser.name); webrtcStartMessage1.setInitiator(true); if (TURN_STATIC_AUTH_SECRET !== "") { @@ -443,6 +466,7 @@ export class SocketManager { const webrtcStartMessage2 = new WebRtcStartMessage(); webrtcStartMessage2.setUserid(user.id); + webrtcStartMessage2.setUseruuid(user.uuid); webrtcStartMessage2.setName(user.name); webrtcStartMessage2.setInitiator(false); if (TURN_STATIC_AUTH_SECRET !== "") { diff --git a/messages/protos/messages.proto b/messages/protos/messages.proto index 30882cd9..289c0724 100644 --- a/messages/protos/messages.proto +++ b/messages/protos/messages.proto @@ -325,6 +325,10 @@ message ZoneMessage { int32 y = 3; } +message RoomMessage { + string roomId = 1; +} + message PusherToBackMessage { oneof message { JoinRoomMessage joinRoomMessage = 1; @@ -360,10 +364,20 @@ message SubToPusherMessage { SendUserMessage sendUserMessage = 7; BanUserMessage banUserMessage = 8; EmoteEventMessage emoteEventMessage = 9; - VariableMessage variableMessage = 10; } } +message BatchToPusherRoomMessage { + repeated SubToPusherRoomMessage payload = 2; +} + +message SubToPusherRoomMessage { + oneof message { + VariableMessage variableMessage = 1; + } +} + + /*message BatchToAdminPusherMessage { repeated SubToAdminPusherMessage payload = 2; }*/ @@ -433,9 +447,13 @@ message EmptyMessage { } +/** + * Service handled by the "back". Pusher servers connect to this service. + */ service RoomManager { - rpc joinRoom(stream PusherToBackMessage) returns (stream ServerToClientMessage); - rpc listenZone(ZoneMessage) returns (stream BatchToPusherMessage); + rpc joinRoom(stream PusherToBackMessage) returns (stream ServerToClientMessage); // Holds a connection between one given client and the back + rpc listenZone(ZoneMessage) returns (stream BatchToPusherMessage); // Connection used to send to a pusher messages related to a given zone of a given room + rpc listenRoom(RoomMessage) returns (stream BatchToPusherRoomMessage); // Connection used to send to a pusher messages related to a given room rpc adminRoom(stream AdminPusherToBackMessage) returns (stream ServerToAdminClientMessage); rpc sendAdminMessage(AdminMessage) returns (EmptyMessage); rpc sendGlobalAdminMessage(AdminGlobalMessage) returns (EmptyMessage); diff --git a/pusher/src/Controller/IoSocketController.ts b/pusher/src/Controller/IoSocketController.ts index 1af9d917..a6fddb34 100644 --- a/pusher/src/Controller/IoSocketController.ts +++ b/pusher/src/Controller/IoSocketController.ts @@ -16,7 +16,7 @@ import { SendUserMessage, ServerToClientMessage, CompanionMessage, - EmotePromptMessage, + EmotePromptMessage, VariableMessage, } from "../Messages/generated/messages_pb"; import { UserMovesMessage } from "../Messages/generated/messages_pb"; import { TemplatedApp } from "uWebSockets.js"; @@ -358,6 +358,8 @@ export class IoSocketController { socketManager.handleSilentMessage(client, message.getSilentmessage() as SilentMessage); } else if (message.hasItemeventmessage()) { socketManager.handleItemEvent(client, message.getItemeventmessage() as ItemEventMessage); + } else if (message.hasVariablemessage()) { + socketManager.handleVariableEvent(client, message.getVariablemessage() as VariableMessage); } else if (message.hasWebrtcsignaltoservermessage()) { socketManager.emitVideo( client, diff --git a/pusher/src/Model/PusherRoom.ts b/pusher/src/Model/PusherRoom.ts index a49fce3e..1eae7a9f 100644 --- a/pusher/src/Model/PusherRoom.ts +++ b/pusher/src/Model/PusherRoom.ts @@ -3,10 +3,21 @@ import { PositionDispatcher } from "./PositionDispatcher"; import { ViewportInterface } from "_Model/Websocket/ViewportMessage"; import { extractDataFromPrivateRoomId, extractRoomSlugPublicRoomId, isRoomAnonymous } from "./RoomIdentifier"; import { arrayIntersect } from "../Services/ArrayHelper"; -import { ZoneEventListener } from "_Model/Zone"; +import {GroupDescriptor, UserDescriptor, ZoneEventListener} from "_Model/Zone"; +import {apiClientRepository} from "../Services/ApiClientRepository"; +import { + BatchToPusherMessage, BatchToPusherRoomMessage, EmoteEventMessage, GroupLeftZoneMessage, + GroupUpdateZoneMessage, RoomMessage, SubMessage, + UserJoinedZoneMessage, UserLeftZoneMessage, UserMovedMessage, VariableMessage, + ZoneMessage +} from "../Messages/generated/messages_pb"; +import Debug from "debug"; +import {ClientReadableStream} from "grpc"; + +const debug = Debug("room"); export enum GameRoomPolicyTypes { - ANONYMUS_POLICY = 1, + ANONYMOUS_POLICY = 1, MEMBERS_ONLY_POLICY, USE_TAGS_POLICY, } @@ -20,11 +31,14 @@ export class PusherRoom { public readonly worldSlug: string = ""; public readonly organizationSlug: string = ""; private versionNumber: number = 1; + private backConnection!: ClientReadableStream; + private isClosing: boolean = false; + private listeners: Set = new Set(); - constructor(public readonly roomId: string, private socketListener: ZoneEventListener) { + constructor(public readonly roomId: string, private socketListener: ZoneEventListener, private onBackFailure: (e: Error | null, room: PusherRoom) => void) { this.public = isRoomAnonymous(roomId); this.tags = []; - this.policyType = GameRoomPolicyTypes.ANONYMUS_POLICY; + this.policyType = GameRoomPolicyTypes.ANONYMOUS_POLICY; if (this.public) { this.roomSlug = extractRoomSlugPublicRoomId(this.roomId); @@ -43,8 +57,13 @@ export class PusherRoom { this.positionNotifier.setViewport(socket, viewport); } + public join(socket: ExSocketInterface) { + this.listeners.add(socket); + } + public leave(socket: ExSocketInterface) { this.positionNotifier.removeViewport(socket); + this.listeners.delete(socket); } public canAccess(userTags: string[]): boolean { @@ -63,4 +82,56 @@ export class PusherRoom { return false; } } + + /** + * Creates a connection to the back server to track global messages relative to this room (like variable changes). + */ + public async init(): Promise { + debug("Opening connection to room %s on back server", this.roomId); + const apiClient = await apiClientRepository.getClient(this.roomId); + const roomMessage = new RoomMessage(); + roomMessage.setRoomid(this.roomId); + this.backConnection = apiClient.listenRoom(roomMessage); + this.backConnection.on("data", (batch: BatchToPusherRoomMessage) => { + for (const message of batch.getPayloadList()) { + if (message.hasVariablemessage()) { + const variableMessage = message.getVariablemessage() as VariableMessage; + // We need to dispatch this variable to all the listeners + for (const listener of this.listeners) { + const subMessage = new SubMessage(); + subMessage.setVariablemessage(variableMessage); + listener.emitInBatch(subMessage); + } + } else { + throw new Error("Unexpected message"); + } + } + }); + + this.backConnection.on("error", (e) => { + if (!this.isClosing) { + debug("Error on back connection"); + this.close(); + this.onBackFailure(e, this); + } + }); + this.backConnection.on("close", () => { + if (!this.isClosing) { + debug("Close on back connection"); + this.close(); + this.onBackFailure(null, this); + } + }); + } + + public close(): void { + debug("Closing connection to room %s on back server", this.roomId); + this.isClosing = true; + this.backConnection.cancel(); + + // Let's close all connections linked to that room + for (const listener of this.listeners) { + listener.close(); + } + } } diff --git a/pusher/src/Services/SocketManager.ts b/pusher/src/Services/SocketManager.ts index 8a0d3673..6c78d398 100644 --- a/pusher/src/Services/SocketManager.ts +++ b/pusher/src/Services/SocketManager.ts @@ -29,7 +29,7 @@ import { AdminMessage, BanMessage, RefreshRoomMessage, - EmotePromptMessage, + EmotePromptMessage, VariableMessage, } from "../Messages/generated/messages_pb"; import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils"; import { JITSI_ISS, SECRET_JITSI_KEY } from "../Enum/EnvironmentVariable"; @@ -227,6 +227,9 @@ export class SocketManager implements ZoneEventListener { const pusherToBackMessage = new PusherToBackMessage(); pusherToBackMessage.setJoinroommessage(joinRoomMessage); streamToPusher.write(pusherToBackMessage); + + const pusherRoom = await this.getOrCreateRoom(client.roomId); + pusherRoom.join(client); } catch (e) { console.error('An error occurred on "join_room" event'); console.error(e); @@ -300,6 +303,13 @@ export class SocketManager implements ZoneEventListener { client.backConnection.write(pusherToBackMessage); } + handleVariableEvent(client: ExSocketInterface, variableMessage: VariableMessage) { + const pusherToBackMessage = new PusherToBackMessage(); + pusherToBackMessage.setVariablemessage(variableMessage); + + client.backConnection.write(pusherToBackMessage); + } + async handleReportMessage(client: ExSocketInterface, reportPlayerMessage: ReportPlayerMessage) { try { const reportedSocket = this.sockets.get(reportPlayerMessage.getReporteduserid()); @@ -334,14 +344,6 @@ export class SocketManager implements ZoneEventListener { socket.backConnection.write(pusherToBackMessage); } - private searchClientByIdOrFail(userId: number): ExSocketInterface { - const client: ExSocketInterface | undefined = this.sockets.get(userId); - if (client === undefined) { - throw new Error("Could not find user with id " + userId); - } - return client; - } - leaveRoom(socket: ExSocketInterface) { // leave previous room and world try { @@ -354,6 +356,7 @@ export class SocketManager implements ZoneEventListener { room.leave(socket); if (room.isEmpty()) { + room.close(); this.rooms.delete(socket.roomId); debug("Room %s is empty. Deleting.", socket.roomId); } @@ -384,9 +387,10 @@ export class SocketManager implements ZoneEventListener { if (!world.public) { await this.updateRoomWithAdminData(world); } + await world.init(); this.rooms.set(roomId, world); } - return Promise.resolve(world); + return world; } public async updateRoomWithAdminData(world: PusherRoom): Promise { @@ -410,15 +414,6 @@ export class SocketManager implements ZoneEventListener { return this.rooms; } - searchClientByUuid(uuid: string): ExSocketInterface | null { - for (const socket of this.sockets.values()) { - if (socket.userUuid === uuid) { - return socket; - } - } - return null; - } - public handleQueryJitsiJwtMessage(client: ExSocketInterface, queryJitsiJwtMessage: QueryJitsiJwtMessage) { try { const room = queryJitsiJwtMessage.getJitsiroom(); From e65e8b2097d443ac3be8f504c4ca208ed84a0c34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Wed, 7 Jul 2021 17:17:28 +0200 Subject: [PATCH 11/31] First version with variables that actually work --- back/src/Model/GameRoom.ts | 37 +++++++- back/src/RoomManager.ts | 30 +++++- back/src/Services/SocketManager.ts | 91 ++++++++++++++----- front/src/Api/IframeListener.ts | 16 ++-- front/src/Connexion/ConnexionModels.ts | 2 + front/src/Connexion/RoomConnection.ts | 22 +++++ front/src/Phaser/Game/GameScene.ts | 2 +- .../src/Phaser/Game/SharedVariablesManager.ts | 22 ++++- maps/tests/Variables/shared_variables.html | 41 +++++++++ maps/tests/index.html | 10 +- pusher/src/Model/PusherRoom.ts | 27 ++++-- 11 files changed, 250 insertions(+), 50 deletions(-) create mode 100644 maps/tests/Variables/shared_variables.html diff --git a/back/src/Model/GameRoom.ts b/back/src/Model/GameRoom.ts index 33af483f..ffe3563f 100644 --- a/back/src/Model/GameRoom.ts +++ b/back/src/Model/GameRoom.ts @@ -7,9 +7,14 @@ import { PositionNotifier } from "./PositionNotifier"; import { Movable } from "_Model/Movable"; import { extractDataFromPrivateRoomId, extractRoomSlugPublicRoomId, isRoomAnonymous } from "./RoomIdentifier"; import { arrayIntersect } from "../Services/ArrayHelper"; -import { EmoteEventMessage, JoinRoomMessage } from "../Messages/generated/messages_pb"; +import { + BatchToPusherMessage, + BatchToPusherRoomMessage, + EmoteEventMessage, + JoinRoomMessage, SubToPusherRoomMessage, VariableMessage +} from "../Messages/generated/messages_pb"; import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils"; -import { ZoneSocket } from "src/RoomManager"; +import {RoomSocket, ZoneSocket} from "src/RoomManager"; import { Admin } from "../Model/Admin"; export type ConnectCallback = (user: User, group: Group) => void; @@ -35,7 +40,7 @@ export class GameRoom { private readonly disconnectCallback: DisconnectCallback; private itemsState = new Map(); - private variables = new Map(); + public readonly variables = new Map(); private readonly positionNotifier: PositionNotifier; public readonly roomId: string; @@ -45,6 +50,8 @@ export class GameRoom { private versionNumber: number = 1; private nextUserId: number = 1; + private roomListeners: Set = new Set(); + constructor( roomId: string, connectCallback: ConnectCallback, @@ -312,6 +319,22 @@ export class GameRoom { public setVariable(name: string, value: string): void { this.variables.set(name, value); + + // TODO: should we batch those every 100ms? + const variableMessage = new VariableMessage(); + variableMessage.setName(name); + variableMessage.setValue(value); + + const subMessage = new SubToPusherRoomMessage(); + subMessage.setVariablemessage(variableMessage); + + const batchMessage = new BatchToPusherRoomMessage(); + batchMessage.addPayload(subMessage); + + // Dispatch the message on the room listeners + for (const socket of this.roomListeners) { + socket.write(batchMessage); + } } public addZoneListener(call: ZoneSocket, x: number, y: number): Set { @@ -343,4 +366,12 @@ export class GameRoom { public emitEmoteEvent(user: User, emoteEventMessage: EmoteEventMessage) { this.positionNotifier.emitEmoteEvent(user, emoteEventMessage); } + + public addRoomListener(socket: RoomSocket) { + this.roomListeners.add(socket); + } + + public removeRoomListener(socket: RoomSocket) { + this.roomListeners.delete(socket); + } } diff --git a/back/src/RoomManager.ts b/back/src/RoomManager.ts index 2514c576..d4dcc6d4 100644 --- a/back/src/RoomManager.ts +++ b/back/src/RoomManager.ts @@ -4,7 +4,7 @@ import { AdminMessage, AdminPusherToBackMessage, AdminRoomMessage, - BanMessage, + BanMessage, BatchToPusherRoomMessage, EmotePromptMessage, EmptyMessage, ItemEventMessage, @@ -12,7 +12,7 @@ import { PlayGlobalMessage, PusherToBackMessage, QueryJitsiJwtMessage, - RefreshRoomPromptMessage, + RefreshRoomPromptMessage, RoomMessage, ServerToAdminClientMessage, ServerToClientMessage, SilentMessage, @@ -33,6 +33,7 @@ const debug = Debug("roommanager"); export type AdminSocket = ServerDuplexStream; export type ZoneSocket = ServerWritableStream; +export type RoomSocket = ServerWritableStream; const roomManager: IRoomManagerServer = { joinRoom: (call: UserSocket): void => { @@ -156,6 +157,29 @@ const roomManager: IRoomManagerServer = { }); }, + listenRoom(call: RoomSocket): void { + debug("listenRoom called"); + const roomMessage = call.request; + + socketManager.addRoomListener(call, roomMessage.getRoomid()); + + call.on("cancelled", () => { + debug("listenRoom cancelled"); + socketManager.removeRoomListener(call, roomMessage.getRoomid()); + call.end(); + }); + + call.on("close", () => { + debug("listenRoom connection closed"); + socketManager.removeRoomListener(call, roomMessage.getRoomid()); + }).on("error", (e) => { + console.error("An error occurred in listenRoom stream:", e); + socketManager.removeRoomListener(call, roomMessage.getRoomid()); + call.end(); + }); + + }, + adminRoom(call: AdminSocket): void { console.log("adminRoom called"); @@ -230,7 +254,7 @@ const roomManager: IRoomManagerServer = { ): void { socketManager.dispatchRoomRefresh(call.request.getRoomid()); callback(null, new EmptyMessage()); - }, + } }; export { roomManager }; diff --git a/back/src/Services/SocketManager.ts b/back/src/Services/SocketManager.ts index 9f655da3..824c8bfb 100644 --- a/back/src/Services/SocketManager.ts +++ b/back/src/Services/SocketManager.ts @@ -30,7 +30,7 @@ import { BanUserMessage, RefreshRoomMessage, EmotePromptMessage, - VariableMessage, + VariableMessage, BatchToPusherRoomMessage, SubToPusherRoomMessage, } from "../Messages/generated/messages_pb"; import { User, UserSocket } from "../Model/User"; import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils"; @@ -49,7 +49,7 @@ import Jwt from "jsonwebtoken"; import { JITSI_URL } from "../Enum/EnvironmentVariable"; import { clientEventsEmitter } from "./ClientEventsEmitter"; import { gaugeManager } from "./GaugeManager"; -import { ZoneSocket } from "../RoomManager"; +import {RoomSocket, ZoneSocket} from "../RoomManager"; import { Zone } from "_Model/Zone"; import Debug from "debug"; import { Admin } from "_Model/Admin"; @@ -66,7 +66,9 @@ function emitZoneMessage(subMessage: SubToPusherMessage, socket: ZoneSocket): vo } export class SocketManager { - private rooms: Map = new Map(); + private rooms = new Map(); + // List of rooms in process of loading. + private roomsPromises = new Map>(); constructor() { clientEventsEmitter.registerToClientJoin((clientUUid: string, roomId: string) => { @@ -102,6 +104,14 @@ export class SocketManager { roomJoinedMessage.addItem(itemStateMessage); } + for (const [name, value] of room.variables.entries()) { + const variableMessage = new VariableMessage(); + variableMessage.setName(name); + variableMessage.setValue(value); + + roomJoinedMessage.addVariable(variableMessage); + } + roomJoinedMessage.setCurrentuserid(user.id); const serverToClientMessage = new ServerToClientMessage(); @@ -186,23 +196,10 @@ export class SocketManager { } handleVariableEvent(room: GameRoom, user: User, variableMessage: VariableMessage) { - const itemEvent = ProtobufUtils.toItemEvent(itemEventMessage); - try { - // TODO: DISPATCH ON NEW ROOM CHANNEL - - const subMessage = new SubMessage(); - subMessage.setItemeventmessage(itemEventMessage); - - // Let's send the event without using the SocketIO room. - // TODO: move this in the GameRoom class. - for (const user of room.getUsers().values()) { - user.emitInBatch(subMessage); - } - room.setVariable(variableMessage.getName(), variableMessage.getValue()); } catch (e) { - console.error('An error occurred on "item_event"'); + console.error('An error occurred on "handleVariableEvent"'); console.error(e); } } @@ -284,10 +281,18 @@ export class SocketManager { } async getOrCreateRoom(roomId: string): Promise { - //check and create new world for a room - let world = this.rooms.get(roomId); - if (world === undefined) { - world = new GameRoom( + //check and create new room + let room = this.rooms.get(roomId); + if (room === undefined) { + let roomPromise = this.roomsPromises.get(roomId); + if (roomPromise) { + return roomPromise; + } + + // Note: for now, the promise is useless (because this is synchronous, but soon, we will need to + // load the map server side. + + room = new GameRoom( roomId, (user: User, group: Group) => this.joinWebRtcRoom(user, group), (user: User, group: Group) => this.disConnectedUser(user, group), @@ -303,9 +308,12 @@ export class SocketManager { this.onEmote(emoteEventMessage, listener) ); gaugeManager.incNbRoomGauge(); - this.rooms.set(roomId, world); + this.rooms.set(roomId, room); + + // TODO: change this the to new Promise()... when the method becomes actually asynchronous + roomPromise = Promise.resolve(room); } - return Promise.resolve(world); + return Promise.resolve(room); } private async joinRoom( @@ -676,6 +684,42 @@ export class SocketManager { room.removeZoneListener(call, x, y); } + async addRoomListener(call: RoomSocket, roomId: string) { + const room = await this.getOrCreateRoom(roomId); + if (!room) { + console.error("In addRoomListener, could not find room with id '" + roomId + "'"); + return; + } + + room.addRoomListener(call); + //const things = room.addZoneListener(call, x, y); + + const batchMessage = new BatchToPusherRoomMessage(); + + for (const [name, value] of room.variables.entries()) { + const variableMessage = new VariableMessage(); + variableMessage.setName(name); + variableMessage.setValue(value); + + const subMessage = new SubToPusherRoomMessage(); + subMessage.setVariablemessage(variableMessage); + + batchMessage.addPayload(subMessage); + } + + call.write(batchMessage); + } + + removeRoomListener(call: RoomSocket, roomId: string) { + const room = this.rooms.get(roomId); + if (!room) { + console.error("In removeRoomListener, could not find room with id '" + roomId + "'"); + return; + } + + room.removeRoomListener(call); + } + public async handleJoinAdminRoom(admin: Admin, roomId: string): Promise { const room = await socketManager.getOrCreateRoom(roomId); @@ -831,6 +875,7 @@ export class SocketManager { emoteEventMessage.setActoruserid(user.id); room.emitEmoteEvent(user, emoteEventMessage); } + } export const socketManager = new SocketManager(); diff --git a/front/src/Api/IframeListener.ts b/front/src/Api/IframeListener.ts index 9c61bdf8..d8559aa0 100644 --- a/front/src/Api/IframeListener.ts +++ b/front/src/Api/IframeListener.ts @@ -34,6 +34,8 @@ import { handleMenuItemRegistrationEvent, isMenuItemRegisterIframeEvent } from " import { SetTilesEvent, isSetTilesEvent } from "./Events/SetTilesEvent"; import { isSetVariableIframeEvent, SetVariableEvent } from "./Events/SetVariableEvent"; +type AnswererCallback = (query: IframeQueryMap[T]['query']) => IframeQueryMap[T]['answer']|PromiseLike; + /** * Listens to messages from iframes and turn those messages into easy to use observables. * Also allows to send messages to those iframes. @@ -111,12 +113,10 @@ class IframeListener { private sendPlayerMove: boolean = false; - // Note: we are forced to type this in "any" because of https://github.com/microsoft/TypeScript/issues/31904 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private answerers: any = {}; - /*private answerers: { - [key in keyof IframeQueryMap]?: (query: IframeQueryMap[key]['query']) => IframeQueryMap[key]['answer']|PromiseLike - } = {};*/ + // Note: we are forced to type this in unknown and later cast with "as" because of https://github.com/microsoft/TypeScript/issues/31904 + private answerers: { + [str in keyof IframeQueryMap]?: unknown + } = {}; init() { @@ -156,7 +156,7 @@ class IframeListener { const queryId = payload.id; const query = payload.query; - const answerer = this.answerers[query.type]; + const answerer = this.answerers[query.type] as AnswererCallback | undefined; if (answerer === undefined) { const errorMsg = 'The iFrame sent a message of type "'+query.type+'" but there is no service configured to answer these messages.'; console.error(errorMsg); @@ -432,7 +432,7 @@ class IframeListener { * @param key The "type" of the query we are answering * @param callback */ - public registerAnswerer(key: T, callback: (query: IframeQueryMap[T]['query']) => IframeQueryMap[T]['answer']|PromiseLike ): void { + public registerAnswerer(key: T, callback: AnswererCallback ): void { this.answerers[key] = callback; } diff --git a/front/src/Connexion/ConnexionModels.ts b/front/src/Connexion/ConnexionModels.ts index 189aea7c..2f4c414b 100644 --- a/front/src/Connexion/ConnexionModels.ts +++ b/front/src/Connexion/ConnexionModels.ts @@ -31,6 +31,7 @@ export enum EventMessage { TELEPORT = "teleport", USER_MESSAGE = "user-message", START_JITSI_ROOM = "start-jitsi-room", + SET_VARIABLE = "set-variable", } export interface PointInterface { @@ -105,6 +106,7 @@ export interface RoomJoinedMessageInterface { //users: MessageUserPositionInterface[], //groups: GroupCreatedUpdatedMessageInterface[], items: { [itemId: number]: unknown }; + variables: Map; } export interface PlayGlobalMessageInterface { diff --git a/front/src/Connexion/RoomConnection.ts b/front/src/Connexion/RoomConnection.ts index b2836a03..53eff010 100644 --- a/front/src/Connexion/RoomConnection.ts +++ b/front/src/Connexion/RoomConnection.ts @@ -165,6 +165,9 @@ export class RoomConnection implements RoomConnection { } else if (subMessage.hasEmoteeventmessage()) { const emoteMessage = subMessage.getEmoteeventmessage() as EmoteEventMessage; emoteEventStream.fire(emoteMessage.getActoruserid(), emoteMessage.getEmote()); + } else if (subMessage.hasVariablemessage()) { + event = EventMessage.SET_VARIABLE; + payload = subMessage.getVariablemessage(); } else { throw new Error("Unexpected batch message type"); } @@ -174,6 +177,7 @@ export class RoomConnection implements RoomConnection { } } } else if (message.hasRoomjoinedmessage()) { + console.error('COUCOU') const roomJoinedMessage = message.getRoomjoinedmessage() as RoomJoinedMessage; const items: { [itemId: number]: unknown } = {}; @@ -181,6 +185,11 @@ export class RoomConnection implements RoomConnection { items[item.getItemid()] = JSON.parse(item.getStatejson()); } + const variables = new Map(); + for (const variable of roomJoinedMessage.getVariableList()) { + variables.set(variable.getName(), JSON.parse(variable.getValue())); + } + this.userId = roomJoinedMessage.getCurrentuserid(); this.tags = roomJoinedMessage.getTagList(); @@ -188,6 +197,7 @@ export class RoomConnection implements RoomConnection { connection: this, room: { items, + variables, } as RoomJoinedMessageInterface, }); } else if (message.hasWorldfullmessage()) { @@ -634,6 +644,18 @@ export class RoomConnection implements RoomConnection { }); } + public onSetVariable(callback: (name: string, value: unknown) => void): void { + this.onMessage(EventMessage.SET_VARIABLE, (message: VariableMessage) => { + const name = message.getName(); + const serializedValue = message.getValue(); + let value: unknown = undefined; + if (serializedValue) { + value = JSON.parse(serializedValue); + } + callback(name, value); + }); + } + public hasTag(tag: string): boolean { return this.tags.includes(tag); } diff --git a/front/src/Phaser/Game/GameScene.ts b/front/src/Phaser/Game/GameScene.ts index 1f326837..3ed0254b 100644 --- a/front/src/Phaser/Game/GameScene.ts +++ b/front/src/Phaser/Game/GameScene.ts @@ -707,7 +707,7 @@ export class GameScene extends DirtyScene { }); // Set up variables manager - this.sharedVariablesManager = new SharedVariablesManager(this.connection, this.gameMap); + this.sharedVariablesManager = new SharedVariablesManager(this.connection, this.gameMap, onConnect.room.variables); //this.initUsersPosition(roomJoinedMessage.users); this.connectionAnswerPromiseResolve(onConnect.room); diff --git a/front/src/Phaser/Game/SharedVariablesManager.ts b/front/src/Phaser/Game/SharedVariablesManager.ts index 284dec1d..c73ffac4 100644 --- a/front/src/Phaser/Game/SharedVariablesManager.ts +++ b/front/src/Phaser/Game/SharedVariablesManager.ts @@ -7,6 +7,7 @@ import type {Subscription} from "rxjs"; import type {GameMap} from "./GameMap"; import type {ITile, ITiledMapObject} from "../Map/ITiledMap"; import type {Var} from "svelte/types/compiler/interfaces"; +import {init} from "svelte/internal"; interface Variable { defaultValue: unknown, @@ -18,7 +19,7 @@ export class SharedVariablesManager { private _variables = new Map(); private variableObjects: Map; - constructor(private roomConnection: RoomConnection, private gameMap: GameMap) { + constructor(private roomConnection: RoomConnection, private gameMap: GameMap, serverVariables: Map) { // We initialize the list of variable object at room start. The objects cannot be edited later // (otherwise, this would cause a security issue if the scripting API can edit this list of objects) this.variableObjects = SharedVariablesManager.findVariablesInMap(gameMap); @@ -28,6 +29,22 @@ export class SharedVariablesManager { this._variables.set(name, variableObject.defaultValue); } + // Override default values with the variables from the server: + for (const [name, value] of serverVariables) { + this._variables.set(name, value); + } + + roomConnection.onSetVariable((name, value) => { + console.log('Set Variable received from server'); + this._variables.set(name, value); + + // On server change, let's notify the iframes + iframeListener.setVariable({ + key: name, + value: value, + }) + }); + // When a variable is modified from an iFrame iframeListener.registerAnswerer('setVariable', (event) => { const key = event.key; @@ -48,7 +65,8 @@ export class SharedVariablesManager { } this._variables.set(key, event.value); - // TODO: dispatch to the room connection. + + // Dispatch to the room connection. this.roomConnection.emitSetVariableEvent(key, event.value); }); } diff --git a/maps/tests/Variables/shared_variables.html b/maps/tests/Variables/shared_variables.html new file mode 100644 index 00000000..ae282b1c --- /dev/null +++ b/maps/tests/Variables/shared_variables.html @@ -0,0 +1,41 @@ + + + + + + + + + +
+ + diff --git a/maps/tests/index.html b/maps/tests/index.html index dbcf8287..aba4c41a 100644 --- a/maps/tests/index.html +++ b/maps/tests/index.html @@ -199,7 +199,15 @@ Success Failure Pending - Testing scripting variables + Testing scripting variables locally + + + + + Success Failure Pending + + + Testing shared scripting variables diff --git a/pusher/src/Model/PusherRoom.ts b/pusher/src/Model/PusherRoom.ts index 1eae7a9f..f0dd0e8f 100644 --- a/pusher/src/Model/PusherRoom.ts +++ b/pusher/src/Model/PusherRoom.ts @@ -13,6 +13,7 @@ import { } from "../Messages/generated/messages_pb"; import Debug from "debug"; import {ClientReadableStream} from "grpc"; +import {ExAdminSocketInterface} from "_Model/Websocket/ExAdminSocketInterface"; const debug = Debug("room"); @@ -34,8 +35,9 @@ export class PusherRoom { private backConnection!: ClientReadableStream; private isClosing: boolean = false; private listeners: Set = new Set(); + public readonly variables = new Map(); - constructor(public readonly roomId: string, private socketListener: ZoneEventListener, private onBackFailure: (e: Error | null, room: PusherRoom) => void) { + constructor(public readonly roomId: string, private socketListener: ZoneEventListener) { this.public = isRoomAnonymous(roomId); this.tags = []; this.policyType = GameRoomPolicyTypes.ANONYMOUS_POLICY; @@ -96,7 +98,11 @@ export class PusherRoom { for (const message of batch.getPayloadList()) { if (message.hasVariablemessage()) { const variableMessage = message.getVariablemessage() as VariableMessage; - // We need to dispatch this variable to all the listeners + + // We need to store all variables to dispatch variables later to the listeners + this.variables.set(variableMessage.getName(), variableMessage.getValue()); + + // Let's dispatch this variable to all the listeners for (const listener of this.listeners) { const subMessage = new SubMessage(); subMessage.setVariablemessage(variableMessage); @@ -112,14 +118,22 @@ export class PusherRoom { if (!this.isClosing) { debug("Error on back connection"); this.close(); - this.onBackFailure(e, this); + // Let's close all connections linked to that room + for (const listener of this.listeners) { + listener.disconnecting = true; + listener.end(1011, "Connection error between pusher and back server") + } } }); this.backConnection.on("close", () => { if (!this.isClosing) { debug("Close on back connection"); this.close(); - this.onBackFailure(null, this); + // Let's close all connections linked to that room + for (const listener of this.listeners) { + listener.disconnecting = true; + listener.end(1011, "Connection closed between pusher and back server") + } } }); } @@ -128,10 +142,5 @@ export class PusherRoom { debug("Closing connection to room %s on back server", this.roomId); this.isClosing = true; this.backConnection.cancel(); - - // Let's close all connections linked to that room - for (const listener of this.listeners) { - listener.close(); - } } } From b1cb12861fc10c2b8e2d2e91692bc4139b02deac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Wed, 7 Jul 2021 22:14:59 +0200 Subject: [PATCH 12/31] Migrating variables functions to the "state" namespace. --- CHANGELOG.md | 16 +++- front/src/Api/iframe/room.ts | 54 -------------- front/src/Api/iframe/state.ts | 85 ++++++++++++++++++++++ front/src/Connexion/RoomConnection.ts | 1 - front/src/iframe_api.ts | 4 +- maps/tests/Variables/script.js | 16 ++-- maps/tests/Variables/shared_variables.html | 10 +-- 7 files changed, 117 insertions(+), 69 deletions(-) create mode 100644 front/src/Api/iframe/state.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a83e8213..e8070634 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,14 +8,24 @@ - Migrated the admin console to Svelte, and redesigned the console #1211 - Layer properties (like `exitUrl`, `silent`, etc...) can now also used in tile properties #1210 (@jonnytest1) - New scripting API features : + - Use `WA.onInit(): Promise` to wait for scripting API initialization - Use `WA.room.showLayer(): void` to show a layer - Use `WA.room.hideLayer(): void` to hide a layer - Use `WA.room.setProperty() : void` to add or change existing property of a layer - Use `WA.player.onPlayerMove(): void` to track the movement of the current player - - Use `WA.room.getCurrentUser(): Promise` to get the ID, name and tags of the current player - - Use `WA.room.getCurrentRoom(): Promise` to get the ID, JSON map file, url of the map of the current room and the layer where the current player started - - Use `WA.ui.registerMenuCommand(): void` to add a custom menu + - Use `WA.player.id: string|undefined` to get the ID of the current player + - Use `WA.player.name: string` to get the name of the current player + - Use `WA.player.tags: string[]` to get the tags of the current player + - Use `WA.room.id: string` to get the ID of the room + - Use `WA.room.mapURL: string` to get the URL of the map + - Use `WA.room.mapURL: string` to get the URL of the map + - Use `WA.room.getMap(): Promise` to get the JSON map file - Use `WA.room.setTiles(): void` to change an array of tiles + - Use `WA.ui.registerMenuCommand(): void` to add a custom menu + - Use `WA.state.loadVariable(key: string): unknown` to retrieve a variable + - Use `WA.state.saveVariable(key: string, value: unknown): Promise` to set a variable (across the room, for all users) + - Use `WA.state.onVariableChange(key: string): Subscription` to track a variable + - Use `WA.state.[any variable]: unknown` to access directly any variable (this is a shortcut to using `WA.state.loadVariable` and `WA.state.saveVariable`) - Users blocking now relies on UUID rather than ID. A blocked user that leaves a room and comes back will stay blocked. ## Version 1.4.3 - 1.4.4 - 1.4.5 diff --git a/front/src/Api/iframe/room.ts b/front/src/Api/iframe/room.ts index db639cd9..9954cb7c 100644 --- a/front/src/Api/iframe/room.ts +++ b/front/src/Api/iframe/room.ts @@ -4,15 +4,11 @@ import { EnterLeaveEvent, isEnterLeaveEvent } from "../Events/EnterLeaveEvent"; import {IframeApiContribution, queryWorkadventure, sendToWorkadventure} from "./IframeApiContribution"; import { apiCallback } from "./registeredCallbacks"; -import {isSetVariableEvent, SetVariableEvent} from "../Events/SetVariableEvent"; import type { ITiledMap } from "../../Phaser/Map/ITiledMap"; const enterStreams: Map> = new Map>(); const leaveStreams: Map> = new Map>(); -const setVariableResolvers = new Subject(); -const variables = new Map(); -const variableSubscribers = new Map>(); interface TileDescriptor { x: number; @@ -33,24 +29,6 @@ export const setMapURL = (url: string) => { mapURL = url; } -export const initVariables = (_variables: Map): void => { - for (const [name, value] of _variables.entries()) { - // In case the user already decided to put values in the variables (before onInit), let's make sure onInit does not override this. - if (!variables.has(name)) { - variables.set(name, value); - } - } - -} - -setVariableResolvers.subscribe((event) => { - variables.set(event.key, event.value); - const subject = variableSubscribers.get(event.key); - if (subject !== undefined) { - subject.next(event.value); - } -}); - export class WorkadventureRoomCommands extends IframeApiContribution { callbacks = [ apiCallback({ @@ -67,13 +45,6 @@ export class WorkadventureRoomCommands extends IframeApiContribution { - setVariableResolvers.next(payloadData); - } - }), ]; onEnterZone(name: string, callback: () => void): void { @@ -119,31 +90,6 @@ export class WorkadventureRoomCommands extends IframeApiContribution { - variables.set(key, value); - return queryWorkadventure({ - type: 'setVariable', - data: { - key, - value - } - }) - } - - loadVariable(key: string): unknown { - return variables.get(key); - } - - onVariableChange(key: string): Observable { - let subject = variableSubscribers.get(key); - if (subject === undefined) { - subject = new Subject(); - variableSubscribers.set(key, subject); - } - return subject.asObservable(); - } - - get id() : string { if (roomId === undefined) { throw new Error('Room id not initialized yet. You should call WA.room.id within a WA.onInit callback.'); diff --git a/front/src/Api/iframe/state.ts b/front/src/Api/iframe/state.ts new file mode 100644 index 00000000..c894e09e --- /dev/null +++ b/front/src/Api/iframe/state.ts @@ -0,0 +1,85 @@ +import {Observable, Subject} from "rxjs"; + +import { EnterLeaveEvent, isEnterLeaveEvent } from "../Events/EnterLeaveEvent"; + +import {IframeApiContribution, queryWorkadventure, sendToWorkadventure} from "./IframeApiContribution"; +import { apiCallback } from "./registeredCallbacks"; +import {isSetVariableEvent, SetVariableEvent} from "../Events/SetVariableEvent"; + +import type { ITiledMap } from "../../Phaser/Map/ITiledMap"; + +const setVariableResolvers = new Subject(); +const variables = new Map(); +const variableSubscribers = new Map>(); + +export const initVariables = (_variables: Map): void => { + for (const [name, value] of _variables.entries()) { + // In case the user already decided to put values in the variables (before onInit), let's make sure onInit does not override this. + if (!variables.has(name)) { + variables.set(name, value); + } + } + +} + +setVariableResolvers.subscribe((event) => { + variables.set(event.key, event.value); + const subject = variableSubscribers.get(event.key); + if (subject !== undefined) { + subject.next(event.value); + } +}); + +export class WorkadventureStateCommands extends IframeApiContribution { + callbacks = [ + apiCallback({ + type: "setVariable", + typeChecker: isSetVariableEvent, + callback: (payloadData) => { + setVariableResolvers.next(payloadData); + } + }), + ]; + + saveVariable(key : string, value : unknown): Promise { + variables.set(key, value); + return queryWorkadventure({ + type: 'setVariable', + data: { + key, + value + } + }) + } + + loadVariable(key: string): unknown { + return variables.get(key); + } + + onVariableChange(key: string): Observable { + let subject = variableSubscribers.get(key); + if (subject === undefined) { + subject = new Subject(); + variableSubscribers.set(key, subject); + } + return subject.asObservable(); + } + +} + +const proxyCommand = new Proxy(new WorkadventureStateCommands(), { + get(target: WorkadventureStateCommands, p: PropertyKey, receiver: unknown): unknown { + if (p in target) { + return Reflect.get(target, p, receiver); + } + return target.loadVariable(p.toString()); + }, + set(target: WorkadventureStateCommands, p: PropertyKey, value: unknown, receiver: unknown): boolean { + // Note: when using "set", there is no way to wait, so we ignore the return of the promise. + // User must use WA.state.saveVariable to have error message. + target.saveVariable(p.toString(), value); + return true; + } +}); + +export default proxyCommand; diff --git a/front/src/Connexion/RoomConnection.ts b/front/src/Connexion/RoomConnection.ts index 53eff010..33122caa 100644 --- a/front/src/Connexion/RoomConnection.ts +++ b/front/src/Connexion/RoomConnection.ts @@ -177,7 +177,6 @@ export class RoomConnection implements RoomConnection { } } } else if (message.hasRoomjoinedmessage()) { - console.error('COUCOU') const roomJoinedMessage = message.getRoomjoinedmessage() as RoomJoinedMessage; const items: { [itemId: number]: unknown } = {}; diff --git a/front/src/iframe_api.ts b/front/src/iframe_api.ts index cd610ab0..2bf1185b 100644 --- a/front/src/iframe_api.ts +++ b/front/src/iframe_api.ts @@ -11,7 +11,8 @@ import nav from "./Api/iframe/nav"; import controls from "./Api/iframe/controls"; import ui from "./Api/iframe/ui"; import sound from "./Api/iframe/sound"; -import room, {initVariables, setMapURL, setRoomId} from "./Api/iframe/room"; +import room, {setMapURL, setRoomId} from "./Api/iframe/room"; +import state, {initVariables} from "./Api/iframe/state"; import player, {setPlayerName, setTags, setUuid} from "./Api/iframe/player"; import type { ButtonDescriptor } from "./Api/iframe/Ui/ButtonDescriptor"; import type { Popup } from "./Api/iframe/Ui/Popup"; @@ -42,6 +43,7 @@ const wa = { sound, room, player, + state, onInit(): Promise { return initPromise; diff --git a/maps/tests/Variables/script.js b/maps/tests/Variables/script.js index 120a4425..ae663cc9 100644 --- a/maps/tests/Variables/script.js +++ b/maps/tests/Variables/script.js @@ -1,20 +1,26 @@ WA.onInit().then(() => { console.log('Trying to read variable "doorOpened" whose default property is true. This should display "true".'); - console.log('doorOpened', WA.room.loadVariable('doorOpened')); + console.log('doorOpened', WA.state.loadVariable('doorOpened')); console.log('Trying to set variable "not_exists". This should display an error in the console, followed by a log saying the error was caught.') - WA.room.saveVariable('not_exists', 'foo').catch((e) => { + WA.state.saveVariable('not_exists', 'foo').catch((e) => { console.log('Successfully caught error: ', e); }); console.log('Trying to set variable "myvar". This should work.'); - WA.room.saveVariable('myvar', {'foo': 'bar'}); + WA.state.saveVariable('myvar', {'foo': 'bar'}); console.log('Trying to read variable "myvar". This should display a {"foo": "bar"} object.'); - console.log(WA.room.loadVariable('myvar')); + console.log(WA.state.loadVariable('myvar')); + + console.log('Trying to set variable "myvar" using proxy. This should work.'); + WA.state.myvar = {'baz': 42}; + + console.log('Trying to read variable "myvar" using proxy. This should display a {"baz": 42} object.'); + console.log(WA.state.myvar); console.log('Trying to set variable "config". This should not work because we are not logged as admin.'); - WA.room.saveVariable('config', {'foo': 'bar'}).catch(e => { + WA.state.saveVariable('config', {'foo': 'bar'}).catch(e => { console.log('Successfully caught error because variable "config" is not writable: ', e); }); }); diff --git a/maps/tests/Variables/shared_variables.html b/maps/tests/Variables/shared_variables.html index ae282b1c..80fdbdd4 100644 --- a/maps/tests/Variables/shared_variables.html +++ b/maps/tests/Variables/shared_variables.html @@ -12,21 +12,21 @@ WA.onInit().then(() => { console.log('After WA init'); const textField = document.getElementById('textField'); - textField.value = WA.room.loadVariable('textField'); + textField.value = WA.state.loadVariable('textField'); textField.addEventListener('change', function (evt) { console.log('saving variable') - WA.room.saveVariable('textField', this.value); + WA.state.saveVariable('textField', this.value); }); - WA.room.onVariableChange('textField').subscribe((value) => { + WA.state.onVariableChange('textField').subscribe((value) => { console.log('variable changed received') textField.value = value; }); document.getElementById('btn').addEventListener('click', () => { - console.log(WA.room.loadVariable('textField')); - document.getElementById('placeholder').innerText = WA.room.loadVariable('textField'); + console.log(WA.state.loadVariable('textField')); + document.getElementById('placeholder').innerText = WA.state.loadVariable('textField'); }); }); }) From 52fd9067b80ddc6054a2e58f368552a1475c70ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Thu, 8 Jul 2021 11:46:30 +0200 Subject: [PATCH 13/31] Editing do to add "state" API doc --- CHANGELOG.md | 2 +- docs/maps/api-reference.md | 1 + docs/maps/api-room.md | 77 ------------------------- docs/maps/api-state.md | 105 ++++++++++++++++++++++++++++++++++ front/src/Api/iframe/state.ts | 7 +++ 5 files changed, 114 insertions(+), 78 deletions(-) create mode 100644 docs/maps/api-state.md diff --git a/CHANGELOG.md b/CHANGELOG.md index e8070634..33658d2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ - Use `WA.ui.registerMenuCommand(): void` to add a custom menu - Use `WA.state.loadVariable(key: string): unknown` to retrieve a variable - Use `WA.state.saveVariable(key: string, value: unknown): Promise` to set a variable (across the room, for all users) - - Use `WA.state.onVariableChange(key: string): Subscription` to track a variable + - Use `WA.state.onVariableChange(key: string): Observable` to track a variable - Use `WA.state.[any variable]: unknown` to access directly any variable (this is a shortcut to using `WA.state.loadVariable` and `WA.state.saveVariable`) - Users blocking now relies on UUID rather than ID. A blocked user that leaves a room and comes back will stay blocked. diff --git a/docs/maps/api-reference.md b/docs/maps/api-reference.md index 2fcc4613..d044668f 100644 --- a/docs/maps/api-reference.md +++ b/docs/maps/api-reference.md @@ -5,6 +5,7 @@ - [Navigation functions](api-nav.md) - [Chat functions](api-chat.md) - [Room functions](api-room.md) +- [State related functions](api-state.md) - [Player functions](api-player.md) - [UI functions](api-ui.md) - [Sound functions](api-sound.md) diff --git a/docs/maps/api-room.md b/docs/maps/api-room.md index ad79f246..9f911b35 100644 --- a/docs/maps/api-room.md +++ b/docs/maps/api-room.md @@ -158,80 +158,3 @@ WA.room.setTiles([ {x: 9, y: 4, tile: 'blue', layer: 'setTiles'} ]); ``` - -### Saving / loading state - -``` -WA.room.saveVariable(key : string, data : unknown): void -WA.room.loadVariable(key : string) : unknown -WA.room.onVariableChange(key : string).subscribe((data: unknown) => {}) : Subscription -``` - -These 3 methods can be used to save, load and track changes in variables related to the current room. - -`data` can be any value that is serializable in JSON. - -Please refrain from storing large amounts of data in a room. Those functions are typically useful for saving or restoring -configuration / metadatas. - -Example : -```javascript -WA.room.saveVariable('config', { - 'bottomExitUrl': '/@/org/world/castle', - 'topExitUrl': '/@/org/world/tower', - 'enableBirdSound': true -}); -//... -let config = WA.room.loadVariable('config'); -``` - -If you are using Typescript, please note that the return type of `loadVariable` is `unknown`. This is -for security purpose, as we don't know the type of the variable. In order to use the returned value, -you will need to cast it to the correct type (or better, use a [Type guard](https://www.typescriptlang.org/docs/handbook/2/narrowing.html) to actually check at runtime -that you get the expected type). - -{.alert.alert-warning} -For security reasons, you cannot load or save **any** variable (otherwise, anyone on your map could set any data). -Variables storage is subject to an authorization process. Read below to learn more. - -#### Declaring allowed keys - -In order to declare allowed keys related to a room, you need to add a **objects** in an "object layer" of the map. - -Each object will represent a variable. - -
-
- -
-
- -TODO: move the image in https://workadventu.re/img/docs - - -The name of the variable is the name of the object. -The object **type** MUST be **variable**. - -You can set a default value for the object in the `default` property. - -Use the `persist` property to save the state of the variable in database. If `persist` is false, the variable will stay -in the memory of the WorkAdventure servers but will be wiped out of the memory as soon as the room is empty (or if the -server restarts). - -{.alert.alert-info} -Do not use `persist` for highly dynamic values that have a short life spawn. - -With `readableBy` and `writableBy`, you control who can read of write in this variable. The property accepts a string -representing a "tag". Anyone having this "tag" can read/write in the variable. - -{.alert.alert-warning} -`readableBy` and `writableBy` are specific to the public version of WorkAdventure because the notion of tags -is not available unless you have an "admin" server (that is not part of the self-hosted version of WorkAdventure). - -Finally, the `jsonSchema` property can contain [a complete JSON schema](https://json-schema.org/) to validate the content of the variable. -Trying to set a variable to a value that is not compatible with the schema will fail. - - - - -TODO: document tracking, unsubscriber, etc... diff --git a/docs/maps/api-state.md b/docs/maps/api-state.md new file mode 100644 index 00000000..6b74389b --- /dev/null +++ b/docs/maps/api-state.md @@ -0,0 +1,105 @@ +{.section-title.accent.text-primary} +# API state related functions Reference + +### Saving / loading state + +The `WA.state` functions allow you to easily share a common state between all the players in a given room. +Moreover, `WA.state` functions can be used to persist this state across reloads. + +``` +WA.state.saveVariable(key : string, data : unknown): void +WA.state.loadVariable(key : string) : unknown +WA.state.onVariableChange(key : string).subscribe((data: unknown) => {}) : Subscription +WA.state.[any property]: unknown +``` + +These methods and properties can be used to save, load and track changes in variables related to the current room. + +Variables stored in `WA.state` can be any value that is serializable in JSON. + +Please refrain from storing large amounts of data in a room. Those functions are typically useful for saving or restoring +configuration / metadata. + +{.alert.alert-warning} +We are in the process of fine-tuning variables, and we will eventually put limits on the maximum size a variable can hold. We will also put limits on the number of calls you can make to saving variables, so don't change the value of a variable every 10ms, this will fail in the future. + + +Example : +```javascript +WA.state.saveVariable('config', { + 'bottomExitUrl': '/@/org/world/castle', + 'topExitUrl': '/@/org/world/tower', + 'enableBirdSound': true +}).catch(e => console.error('Something went wrong while saving variable', e)); +//... +let config = WA.state.loadVariable('config'); +``` + +You can use the shortcut properties to load and save variables. The code above is similar to: + +```javascript +WA.state.config = { + 'bottomExitUrl': '/@/org/world/castle', + 'topExitUrl': '/@/org/world/tower', + 'enableBirdSound': true +}; + +//... +let config = WA.state.config; +``` + +Note: `saveVariable` returns a promise that will fail in case the variable cannot be saved. This +can happen if your user does not have the required rights (more on that in the next chapter). +In contrast, if you use the WA.state properties, you cannot access the promise and therefore cannot +know for sure if your variable was properly saved. + +If you are using Typescript, please note that the type of variables is `unknown`. This is +for security purpose, as we don't know the type of the variable. In order to use the returned value, +you will need to cast it to the correct type (or better, use a [Type guard](https://www.typescriptlang.org/docs/handbook/2/narrowing.html) to actually check at runtime +that you get the expected type). + +{.alert.alert-warning} +For security reasons, the list of variables you are allowed to access and modify is **restricted** (otherwise, anyone on your map could set any data). +Variables storage is subject to an authorization process. Read below to learn more. + +#### Declaring allowed keys + +In order to declare allowed keys related to a room, you need to add **objects** in an "object layer" of the map. + +Each object will represent a variable. + +
+
+ +
+
+ +TODO: move the image in https://workadventu.re/img/docs + + +The name of the variable is the name of the object. +The object **type** MUST be **variable**. + +You can set a default value for the object in the `default` property. + +Use the `persist` property to save the state of the variable in database. If `persist` is false, the variable will stay +in the memory of the WorkAdventure servers but will be wiped out of the memory as soon as the room is empty (or if the +server restarts). + +{.alert.alert-info} +Do not use `persist` for highly dynamic values that have a short life spawn. + +With `readableBy` and `writableBy`, you control who can read of write in this variable. The property accepts a string +representing a "tag". Anyone having this "tag" can read/write in the variable. + +{.alert.alert-warning} +`readableBy` and `writableBy` are specific to the public version of WorkAdventure because the notion of tags +is not available unless you have an "admin" server (that is not part of the self-hosted version of WorkAdventure). + +Finally, the `jsonSchema` property can contain [a complete JSON schema](https://json-schema.org/) to validate the content of the variable. +Trying to set a variable to a value that is not compatible with the schema will fail. + + + + +TODO: document tracking, unsubscriber, etc... diff --git a/front/src/Api/iframe/state.ts b/front/src/Api/iframe/state.ts index c894e09e..90e8cb81 100644 --- a/front/src/Api/iframe/state.ts +++ b/front/src/Api/iframe/state.ts @@ -23,6 +23,13 @@ export const initVariables = (_variables: Map): void => { } setVariableResolvers.subscribe((event) => { + const oldValue = variables.get(event.key); + + // If we are setting the same value, no need to do anything. + if (oldValue === event.value) { + return; + } + variables.set(event.key, event.value); const subject = variableSubscribers.get(event.key); if (subject !== undefined) { From 28effd8ad4a4bc4e3374d57dd5735f30b3a9ef78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Mon, 12 Jul 2021 16:43:40 +0200 Subject: [PATCH 14/31] Using proxy variables in test --- maps/tests/Variables/shared_variables.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/maps/tests/Variables/shared_variables.html b/maps/tests/Variables/shared_variables.html index 80fdbdd4..c0a586d8 100644 --- a/maps/tests/Variables/shared_variables.html +++ b/maps/tests/Variables/shared_variables.html @@ -12,11 +12,11 @@ WA.onInit().then(() => { console.log('After WA init'); const textField = document.getElementById('textField'); - textField.value = WA.state.loadVariable('textField'); + textField.value = WA.state.textField; textField.addEventListener('change', function (evt) { console.log('saving variable') - WA.state.saveVariable('textField', this.value); + WA.state.textField = this.value; }); WA.state.onVariableChange('textField').subscribe((value) => { From 3d76f76d3e1db5195ce7cb014b4daf3a4e2db547 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Fri, 16 Jul 2021 11:37:44 +0200 Subject: [PATCH 15/31] Fixing merge --- front/src/Api/iframe/player.ts | 7 ------- pusher/src/Model/PusherRoom.ts | 17 ++++------------- pusher/src/Services/SocketManager.ts | 2 +- 3 files changed, 5 insertions(+), 21 deletions(-) diff --git a/front/src/Api/iframe/player.ts b/front/src/Api/iframe/player.ts index b2c8d58d..078a1926 100644 --- a/front/src/Api/iframe/player.ts +++ b/front/src/Api/iframe/player.ts @@ -2,15 +2,8 @@ import { IframeApiContribution, sendToWorkadventure } from "./IframeApiContribut import type { HasPlayerMovedEvent, HasPlayerMovedEventCallback } from "../Events/HasPlayerMovedEvent"; import { Subject } from "rxjs"; import { apiCallback } from "./registeredCallbacks"; -import { getGameState } from "./room"; import { isHasPlayerMovedEvent } from "../Events/HasPlayerMovedEvent"; -interface User { - id: string | undefined; - nickName: string | null; - tags: string[]; -} - const moveStream = new Subject(); let playerName: string | undefined; diff --git a/pusher/src/Model/PusherRoom.ts b/pusher/src/Model/PusherRoom.ts index f2d656c6..713e9d25 100644 --- a/pusher/src/Model/PusherRoom.ts +++ b/pusher/src/Model/PusherRoom.ts @@ -44,15 +44,6 @@ export class PusherRoom { this.tags = []; this.policyType = GameRoomPolicyTypes.ANONYMOUS_POLICY; - if (this.public) { - this.roomSlug = extractRoomSlugPublicRoomId(this.roomId); - } else { - const { organizationSlug, worldSlug, roomSlug } = extractDataFromPrivateRoomId(this.roomId); - this.roomSlug = roomSlug; - this.organizationSlug = organizationSlug; - this.worldSlug = worldSlug; - } - // A zone is 10 sprites wide. this.positionNotifier = new PositionDispatcher(this.roomUrl, 320, 320, this.socketListener); } @@ -91,10 +82,10 @@ export class PusherRoom { * Creates a connection to the back server to track global messages relative to this room (like variable changes). */ public async init(): Promise { - debug("Opening connection to room %s on back server", this.roomId); - const apiClient = await apiClientRepository.getClient(this.roomId); + debug("Opening connection to room %s on back server", this.roomUrl); + const apiClient = await apiClientRepository.getClient(this.roomUrl); const roomMessage = new RoomMessage(); - roomMessage.setRoomid(this.roomId); + roomMessage.setRoomid(this.roomUrl); this.backConnection = apiClient.listenRoom(roomMessage); this.backConnection.on("data", (batch: BatchToPusherRoomMessage) => { for (const message of batch.getPayloadList()) { @@ -141,7 +132,7 @@ export class PusherRoom { } public close(): void { - debug("Closing connection to room %s on back server", this.roomId); + debug("Closing connection to room %s on back server", this.roomUrl); this.isClosing = true; this.backConnection.cancel(); } diff --git a/pusher/src/Services/SocketManager.ts b/pusher/src/Services/SocketManager.ts index 12597b26..5a544966 100644 --- a/pusher/src/Services/SocketManager.ts +++ b/pusher/src/Services/SocketManager.ts @@ -380,7 +380,7 @@ export class SocketManager implements ZoneEventListener { if (ADMIN_API_URL) { await this.updateRoomWithAdminData(room); } - await world.init(); + await room.init(); this.rooms.set(roomUrl, room); } return room; From dbd5b80636a5a2c5fa22641f222864af0df56e08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Mon, 19 Jul 2021 10:16:43 +0200 Subject: [PATCH 16/31] Adding support for "readableBy" and "writableBy" in back This means that we are now loading maps from server side. --- back/package.json | 2 + back/src/Model/GameRoom.ts | 150 ++++++++++++++---- back/src/RoomManager.ts | 44 ++--- back/src/Services/AdminApi.ts | 24 +++ .../src/Services/AdminApi/CharacterTexture.ts | 11 ++ back/src/Services/AdminApi/MapDetailsData.ts | 21 +++ back/src/Services/AdminApi/RoomRedirect.ts | 8 + back/src/Services/LocalUrlError.ts | 2 + back/src/Services/MapFetcher.ts | 64 ++++++++ back/src/Services/MessageHelpers.ts | 44 ++++- back/src/Services/SocketManager.ts | 137 ++++++++-------- back/src/Services/VariablesManager.ts | 139 ++++++++++++++++ back/tsconfig.json | 2 +- back/yarn.lock | 17 ++ front/src/Connexion/RoomConnection.ts | 5 +- .../src/Phaser/Game/SharedVariablesManager.ts | 6 +- maps/tests/Variables/script.js | 7 + maps/tests/Variables/shared_variables.json | 131 +++++++++++++++ maps/tests/Variables/variables.json | 25 ++- messages/protos/messages.proto | 14 +- pusher/src/Model/PusherRoom.ts | 24 ++- pusher/src/Model/Zone.ts | 12 +- pusher/src/Services/SocketManager.ts | 9 +- pusher/tsconfig.json | 2 +- 24 files changed, 768 insertions(+), 132 deletions(-) create mode 100644 back/src/Services/AdminApi.ts create mode 100644 back/src/Services/AdminApi/CharacterTexture.ts create mode 100644 back/src/Services/AdminApi/MapDetailsData.ts create mode 100644 back/src/Services/AdminApi/RoomRedirect.ts create mode 100644 back/src/Services/LocalUrlError.ts create mode 100644 back/src/Services/MapFetcher.ts create mode 100644 back/src/Services/VariablesManager.ts create mode 100644 maps/tests/Variables/shared_variables.json diff --git a/back/package.json b/back/package.json index 7015b9b8..a532f5cd 100644 --- a/back/package.json +++ b/back/package.json @@ -40,6 +40,7 @@ }, "homepage": "https://github.com/thecodingmachine/workadventure#readme", "dependencies": { + "@workadventure/tiled-map-type-guard": "^1.0.0", "axios": "^0.21.1", "busboy": "^0.3.1", "circular-json": "^0.5.9", @@ -47,6 +48,7 @@ "generic-type-guard": "^3.2.0", "google-protobuf": "^3.13.0", "grpc": "^1.24.4", + "ipaddr.js": "^2.0.1", "jsonwebtoken": "^8.5.1", "mkdirp": "^1.0.4", "prom-client": "^12.0.0", diff --git a/back/src/Model/GameRoom.ts b/back/src/Model/GameRoom.ts index f26fd459..fd711ae8 100644 --- a/back/src/Model/GameRoom.ts +++ b/back/src/Model/GameRoom.ts @@ -11,39 +11,54 @@ import { EmoteEventMessage, JoinRoomMessage, SubToPusherRoomMessage, - VariableMessage, + VariableMessage, VariableWithTagMessage, } from "../Messages/generated/messages_pb"; import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils"; import { RoomSocket, ZoneSocket } from "src/RoomManager"; import { Admin } from "../Model/Admin"; +import {adminApi} from "../Services/AdminApi"; +import {isMapDetailsData, MapDetailsData} from "../Services/AdminApi/MapDetailsData"; +import {ITiledMap} from "@workadventure/tiled-map-type-guard/dist"; +import {mapFetcher} from "../Services/MapFetcher"; +import {VariablesManager} from "../Services/VariablesManager"; +import {ADMIN_API_URL} from "../Enum/EnvironmentVariable"; +import {LocalUrlError} from "../Services/LocalUrlError"; export type ConnectCallback = (user: User, group: Group) => void; export type DisconnectCallback = (user: User, group: Group) => void; export class GameRoom { - private readonly minDistance: number; - private readonly groupRadius: number; - // Users, sorted by ID - private readonly users: Map; - private readonly usersByUuid: Map; - private readonly groups: Set; - private readonly admins: Set; - - private readonly connectCallback: ConnectCallback; - private readonly disconnectCallback: DisconnectCallback; + private readonly users = new Map(); + private readonly usersByUuid = new Map(); + private readonly groups = new Set(); + private readonly admins = new Set(); private itemsState = new Map(); - public readonly variables = new Map(); private readonly positionNotifier: PositionNotifier; - public readonly roomUrl: string; private versionNumber: number = 1; private nextUserId: number = 1; private roomListeners: Set = new Set(); - constructor( + private constructor( + public readonly roomUrl: string, + private mapUrl: string, + private readonly connectCallback: ConnectCallback, + private readonly disconnectCallback: DisconnectCallback, + private readonly minDistance: number, + private readonly groupRadius: number, + onEnters: EntersCallback, + onMoves: MovesCallback, + onLeaves: LeavesCallback, + onEmote: EmoteCallback + ) { + // A zone is 10 sprites wide. + this.positionNotifier = new PositionNotifier(320, 320, onEnters, onMoves, onLeaves, onEmote); + } + + public static async create( roomUrl: string, connectCallback: ConnectCallback, disconnectCallback: DisconnectCallback, @@ -53,19 +68,12 @@ export class GameRoom { onMoves: MovesCallback, onLeaves: LeavesCallback, onEmote: EmoteCallback - ) { - this.roomUrl = roomUrl; + ) : Promise { + const mapDetails = await GameRoom.getMapDetails(roomUrl); - this.users = new Map(); - this.usersByUuid = new Map(); - this.admins = new Set(); - this.groups = new Set(); - this.connectCallback = connectCallback; - this.disconnectCallback = disconnectCallback; - this.minDistance = minDistance; - this.groupRadius = groupRadius; - // A zone is 10 sprites wide. - this.positionNotifier = new PositionNotifier(320, 320, onEnters, onMoves, onLeaves, onEmote); + const gameRoom = new GameRoom(roomUrl, mapDetails.mapUrl, connectCallback, disconnectCallback, minDistance, groupRadius, onEnters, onMoves, onLeaves, onEmote); + + return gameRoom; } public getGroups(): Group[] { @@ -299,13 +307,19 @@ export class GameRoom { return this.itemsState; } - public setVariable(name: string, value: string): void { - this.variables.set(name, value); + public async setVariable(name: string, value: string, user: User): Promise { + // First, let's check if "user" is allowed to modify the variable. + const variableManager = await this.getVariableManager(); + + const readableBy = variableManager.setVariable(name, value, user); // TODO: should we batch those every 100ms? - const variableMessage = new VariableMessage(); + const variableMessage = new VariableWithTagMessage(); variableMessage.setName(name); variableMessage.setValue(value); + if (readableBy) { + variableMessage.setReadableby(readableBy); + } const subMessage = new SubToPusherRoomMessage(); subMessage.setVariablemessage(variableMessage); @@ -356,4 +370,82 @@ export class GameRoom { public removeRoomListener(socket: RoomSocket) { this.roomListeners.delete(socket); } + + /** + * Connects to the admin server to fetch map details. + * If there is no admin server, the map details are generated by analysing the map URL (that must be in the form: /_/instance/map_url) + */ + private static async getMapDetails(roomUrl: string): Promise { + if (!ADMIN_API_URL) { + const roomUrlObj = new URL(roomUrl); + + const match = /\/_\/[^/]+\/(.+)/.exec(roomUrlObj.pathname); + if (!match) { + console.error('Unexpected room URL', roomUrl); + throw new Error('Unexpected room URL "' + roomUrl + '"'); + } + + const mapUrl = roomUrlObj.protocol + "//" + match[1]; + + return { + mapUrl, + policy_type: 1, + textures: [], + tags: [], + } + } + + const result = await adminApi.fetchMapDetails(roomUrl); + if (!isMapDetailsData(result)) { + console.error('Unexpected room details received from server', result); + throw new Error('Unexpected room details received from server'); + } + return result; + } + + private mapPromise: Promise|undefined; + + /** + * Returns a promise to the map file. + * @throws LocalUrlError if the map we are trying to load is hosted on a local network + * @throws Error + */ + private getMap(): Promise { + if (!this.mapPromise) { + this.mapPromise = mapFetcher.fetchMap(this.mapUrl); + } + + return this.mapPromise; + } + + private variableManagerPromise: Promise|undefined; + + private getVariableManager(): Promise { + if (!this.variableManagerPromise) { + this.variableManagerPromise = new Promise((resolve, reject) => { + this.getMap().then((map) => { + resolve(new VariablesManager(map)); + }).catch(e => { + if (e instanceof LocalUrlError) { + // If we are trying to load a local URL, we are probably in test mode. + // In this case, let's bypass the server-side checks completely. + + // FIXME: find a way to send a warning to the client side + // FIXME: find a way to send a warning to the client side + // FIXME: find a way to send a warning to the client side + // FIXME: find a way to send a warning to the client side + resolve(new VariablesManager(null)); + } else { + reject(e); + } + }) + }); + } + return this.variableManagerPromise; + } + + public async getVariablesForTags(tags: string[]): Promise> { + const variablesManager = await this.getVariableManager(); + return variablesManager.getVariablesForTags(tags); + } } diff --git a/back/src/RoomManager.ts b/back/src/RoomManager.ts index d4dcc6d4..a6a99993 100644 --- a/back/src/RoomManager.ts +++ b/back/src/RoomManager.ts @@ -4,7 +4,7 @@ import { AdminMessage, AdminPusherToBackMessage, AdminRoomMessage, - BanMessage, BatchToPusherRoomMessage, + BanMessage, BatchToPusherMessage, BatchToPusherRoomMessage, EmotePromptMessage, EmptyMessage, ItemEventMessage, @@ -14,7 +14,6 @@ import { QueryJitsiJwtMessage, RefreshRoomPromptMessage, RoomMessage, ServerToAdminClientMessage, - ServerToClientMessage, SilentMessage, UserMovesMessage, VariableMessage, WebRtcSignalToServerMessage, @@ -23,7 +22,7 @@ import { } from "./Messages/generated/messages_pb"; import { sendUnaryData, ServerDuplexStream, ServerUnaryCall, ServerWritableStream } from "grpc"; import { socketManager } from "./Services/SocketManager"; -import { emitError } from "./Services/MessageHelpers"; +import {emitError, emitErrorOnRoomSocket, emitErrorOnZoneSocket} from "./Services/MessageHelpers"; import { User, UserSocket } from "./Model/User"; import { GameRoom } from "./Model/GameRoom"; import Debug from "debug"; @@ -32,7 +31,7 @@ import { Admin } from "./Model/Admin"; const debug = Debug("roommanager"); export type AdminSocket = ServerDuplexStream; -export type ZoneSocket = ServerWritableStream; +export type ZoneSocket = ServerWritableStream; export type RoomSocket = ServerWritableStream; const roomManager: IRoomManagerServer = { @@ -56,7 +55,7 @@ const roomManager: IRoomManagerServer = { //Connexion may have been closed before the init was finished, so we have to manually disconnect the user. socketManager.leaveRoom(gameRoom, myUser); } - }); + }).catch(e => emitError(call, e)); } else { throw new Error("The first message sent MUST be of type JoinRoomMessage"); } @@ -139,20 +138,22 @@ const roomManager: IRoomManagerServer = { debug("listenZone called"); const zoneMessage = call.request; - socketManager.addZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()); + socketManager.addZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()).catch(e => { + emitErrorOnZoneSocket(call, e.toString()); + }); call.on("cancelled", () => { debug("listenZone cancelled"); - socketManager.removeZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()); + socketManager.removeZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()).catch(e => console.error(e)); call.end(); }); call.on("close", () => { debug("listenZone connection closed"); - socketManager.removeZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()); + socketManager.removeZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()).catch(e => console.error(e)); }).on("error", (e) => { console.error("An error occurred in listenZone stream:", e); - socketManager.removeZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()); + socketManager.removeZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()).catch(e => console.error(e)); call.end(); }); }, @@ -161,20 +162,22 @@ const roomManager: IRoomManagerServer = { debug("listenRoom called"); const roomMessage = call.request; - socketManager.addRoomListener(call, roomMessage.getRoomid()); + socketManager.addRoomListener(call, roomMessage.getRoomid()).catch(e => { + emitErrorOnRoomSocket(call, e.toString()); + }); call.on("cancelled", () => { debug("listenRoom cancelled"); - socketManager.removeRoomListener(call, roomMessage.getRoomid()); + socketManager.removeRoomListener(call, roomMessage.getRoomid()).catch(e => console.error(e)); call.end(); }); call.on("close", () => { debug("listenRoom connection closed"); - socketManager.removeRoomListener(call, roomMessage.getRoomid()); + socketManager.removeRoomListener(call, roomMessage.getRoomid()).catch(e => console.error(e)); }).on("error", (e) => { console.error("An error occurred in listenRoom stream:", e); - socketManager.removeRoomListener(call, roomMessage.getRoomid()); + socketManager.removeRoomListener(call, roomMessage.getRoomid()).catch(e => console.error(e)); call.end(); }); @@ -193,7 +196,7 @@ const roomManager: IRoomManagerServer = { const roomId = message.getSubscribetoroom(); socketManager.handleJoinAdminRoom(admin, roomId).then((gameRoom: GameRoom) => { room = gameRoom; - }); + }).catch(e => console.error(e)); } else { throw new Error("The first message sent MUST be of type JoinRoomMessage"); } @@ -222,7 +225,7 @@ const roomManager: IRoomManagerServer = { call.request.getRoomid(), call.request.getRecipientuuid(), call.request.getMessage() - ); + ).catch(e => console.error(e)); callback(null, new EmptyMessage()); }, @@ -233,26 +236,29 @@ const roomManager: IRoomManagerServer = { }, ban(call: ServerUnaryCall, callback: sendUnaryData): void { // FIXME Work in progress - socketManager.banUser(call.request.getRoomid(), call.request.getRecipientuuid(), call.request.getMessage()); + socketManager.banUser(call.request.getRoomid(), call.request.getRecipientuuid(), call.request.getMessage()).catch(e => console.error(e)); callback(null, new EmptyMessage()); }, sendAdminMessageToRoom(call: ServerUnaryCall, callback: sendUnaryData): void { - socketManager.sendAdminRoomMessage(call.request.getRoomid(), call.request.getMessage()); + // FIXME: we could improve return message by returning a Success|ErrorMessage message + socketManager.sendAdminRoomMessage(call.request.getRoomid(), call.request.getMessage()).catch(e => console.error(e)); callback(null, new EmptyMessage()); }, sendWorldFullWarningToRoom( call: ServerUnaryCall, callback: sendUnaryData ): void { - socketManager.dispatchWorlFullWarning(call.request.getRoomid()); + // FIXME: we could improve return message by returning a Success|ErrorMessage message + socketManager.dispatchWorldFullWarning(call.request.getRoomid()).catch(e => console.error(e)); callback(null, new EmptyMessage()); }, sendRefreshRoomPrompt( call: ServerUnaryCall, callback: sendUnaryData ): void { - socketManager.dispatchRoomRefresh(call.request.getRoomid()); + // FIXME: we could improve return message by returning a Success|ErrorMessage message + socketManager.dispatchRoomRefresh(call.request.getRoomid()).catch(e => console.error(e)); callback(null, new EmptyMessage()); } }; diff --git a/back/src/Services/AdminApi.ts b/back/src/Services/AdminApi.ts new file mode 100644 index 00000000..158a47c1 --- /dev/null +++ b/back/src/Services/AdminApi.ts @@ -0,0 +1,24 @@ +import { ADMIN_API_TOKEN, ADMIN_API_URL } from "../Enum/EnvironmentVariable"; +import Axios from "axios"; +import { MapDetailsData } from "./AdminApi/MapDetailsData"; +import { RoomRedirect } from "./AdminApi/RoomRedirect"; + +class AdminApi { + async fetchMapDetails(playUri: string): Promise { + if (!ADMIN_API_URL) { + return Promise.reject(new Error("No admin backoffice set!")); + } + + const params: { playUri: string } = { + playUri, + }; + + const res = await Axios.get(ADMIN_API_URL + "/api/map", { + headers: { Authorization: `${ADMIN_API_TOKEN}` }, + params, + }); + return res.data; + } +} + +export const adminApi = new AdminApi(); diff --git a/back/src/Services/AdminApi/CharacterTexture.ts b/back/src/Services/AdminApi/CharacterTexture.ts new file mode 100644 index 00000000..055b3033 --- /dev/null +++ b/back/src/Services/AdminApi/CharacterTexture.ts @@ -0,0 +1,11 @@ +import * as tg from "generic-type-guard"; + +export const isCharacterTexture = new tg.IsInterface() + .withProperties({ + id: tg.isNumber, + level: tg.isNumber, + url: tg.isString, + rights: tg.isString, + }) + .get(); +export type CharacterTexture = tg.GuardedType; diff --git a/back/src/Services/AdminApi/MapDetailsData.ts b/back/src/Services/AdminApi/MapDetailsData.ts new file mode 100644 index 00000000..54320791 --- /dev/null +++ b/back/src/Services/AdminApi/MapDetailsData.ts @@ -0,0 +1,21 @@ +import * as tg from "generic-type-guard"; +import { isCharacterTexture } from "./CharacterTexture"; +import { isAny, isNumber } from "generic-type-guard"; + +/*const isNumericEnum = + (vs: T) => + (v: any): v is T => + typeof v === "number" && v in vs;*/ + +export const isMapDetailsData = new tg.IsInterface() + .withProperties({ + mapUrl: tg.isString, + policy_type: isNumber, //isNumericEnum(GameRoomPolicyTypes), + tags: tg.isArray(tg.isString), + textures: tg.isArray(isCharacterTexture), + }) + .withOptionalProperties({ + roomSlug: tg.isUnion(tg.isString, tg.isNull), // deprecated + }) + .get(); +export type MapDetailsData = tg.GuardedType; diff --git a/back/src/Services/AdminApi/RoomRedirect.ts b/back/src/Services/AdminApi/RoomRedirect.ts new file mode 100644 index 00000000..7257ebd3 --- /dev/null +++ b/back/src/Services/AdminApi/RoomRedirect.ts @@ -0,0 +1,8 @@ +import * as tg from "generic-type-guard"; + +export const isRoomRedirect = new tg.IsInterface() + .withProperties({ + redirectUrl: tg.isString, + }) + .get(); +export type RoomRedirect = tg.GuardedType; diff --git a/back/src/Services/LocalUrlError.ts b/back/src/Services/LocalUrlError.ts new file mode 100644 index 00000000..fc3fa617 --- /dev/null +++ b/back/src/Services/LocalUrlError.ts @@ -0,0 +1,2 @@ +export class LocalUrlError extends Error { +} diff --git a/back/src/Services/MapFetcher.ts b/back/src/Services/MapFetcher.ts new file mode 100644 index 00000000..fa1a831e --- /dev/null +++ b/back/src/Services/MapFetcher.ts @@ -0,0 +1,64 @@ +import Axios from "axios"; +import ipaddr from 'ipaddr.js'; +import { Resolver } from 'dns'; +import { promisify } from 'util'; +import {LocalUrlError} from "./LocalUrlError"; +import {ITiledMap} from "@workadventure/tiled-map-type-guard"; +import {isTiledMap} from "@workadventure/tiled-map-type-guard/dist"; + +class MapFetcher { + async fetchMap(mapUrl: string): Promise { + // Before trying to make the query, let's verify the map is actually on the open internet (and not a local test map) + + if (await this.isLocalUrl(mapUrl)) { + throw new LocalUrlError('URL for map "'+mapUrl+'" targets a local map'); + } + + // Note: mapUrl is provided by the client. A possible attack vector would be to use a rogue DNS server that + // returns local URLs. Alas, Axios cannot pin a URL to a given IP. So "isLocalUrl" and Axios.get could potentially + // target to different servers (and one could trick Axios.get into loading resources on the internal network + // despite isLocalUrl checking that. + // We can deem this problem not that important because: + // - We make sure we are only passing "GET" requests + // - The result of the query is never displayed to the end user + const res = await Axios.get(mapUrl, { + maxContentLength: 50*1024*1024, // Max content length: 50MB. Maps should not be bigger + }); + + if (!isTiledMap(res.data)) { + throw new Error('Invalid map format for map '+mapUrl); + } + + return res.data; + } + + /** + * Returns true if the domain name is localhost of *.localhost + * Returns true if the domain name resolves to an IP address that is "private" (like 10.x.x.x or 192.168.x.x) + */ + private async isLocalUrl(url: string): Promise { + const urlObj = new URL(url); + if (urlObj.hostname === 'localhost' || urlObj.hostname.endsWith('.localhost')) { + return true; + } + + let addresses = []; + if (!ipaddr.isValid(urlObj.hostname)) { + const resolver = new Resolver(); + addresses = await promisify(resolver.resolve)(urlObj.hostname); + } else { + addresses = [urlObj.hostname]; + } + + for (const address of addresses) { + const addr = ipaddr.parse(address); + if (addr.range() !== 'unicast') { + return true; + } + } + + return false; + } +} + +export const mapFetcher = new MapFetcher(); diff --git a/back/src/Services/MessageHelpers.ts b/back/src/Services/MessageHelpers.ts index 493f7173..069d3c78 100644 --- a/back/src/Services/MessageHelpers.ts +++ b/back/src/Services/MessageHelpers.ts @@ -1,5 +1,11 @@ -import { ErrorMessage, ServerToClientMessage } from "../Messages/generated/messages_pb"; +import { + BatchMessage, + BatchToPusherMessage, BatchToPusherRoomMessage, + ErrorMessage, + ServerToClientMessage, SubToPusherMessage, SubToPusherRoomMessage +} from "../Messages/generated/messages_pb"; import { UserSocket } from "_Model/User"; +import {RoomSocket, ZoneSocket} from "../RoomManager"; export function emitError(Client: UserSocket, message: string): void { const errorMessage = new ErrorMessage(); @@ -13,3 +19,39 @@ export function emitError(Client: UserSocket, message: string): void { //} console.warn(message); } + +export function emitErrorOnRoomSocket(Client: RoomSocket, message: string): void { + console.error(message); + + const errorMessage = new ErrorMessage(); + errorMessage.setMessage(message); + + const subToPusherRoomMessage = new SubToPusherRoomMessage(); + subToPusherRoomMessage.setErrormessage(errorMessage); + + const batchToPusherMessage = new BatchToPusherRoomMessage(); + batchToPusherMessage.addPayload(subToPusherRoomMessage); + + //if (!Client.disconnecting) { + Client.write(batchToPusherMessage); + //} + console.warn(message); +} + +export function emitErrorOnZoneSocket(Client: ZoneSocket, message: string): void { + console.error(message); + + const errorMessage = new ErrorMessage(); + errorMessage.setMessage(message); + + const subToPusherMessage = new SubToPusherMessage(); + subToPusherMessage.setErrormessage(errorMessage); + + const batchToPusherMessage = new BatchToPusherMessage(); + batchToPusherMessage.addPayload(subToPusherMessage); + + //if (!Client.disconnecting) { + Client.write(batchToPusherMessage); + //} + console.warn(message); +} diff --git a/back/src/Services/SocketManager.ts b/back/src/Services/SocketManager.ts index 82efc71b..35494b2c 100644 --- a/back/src/Services/SocketManager.ts +++ b/back/src/Services/SocketManager.ts @@ -68,7 +68,7 @@ function emitZoneMessage(subMessage: SubToPusherMessage, socket: ZoneSocket): vo } export class SocketManager { - private rooms = new Map(); + //private rooms = new Map(); // List of rooms in process of loading. private roomsPromises = new Map>(); @@ -106,7 +106,9 @@ export class SocketManager { roomJoinedMessage.addItem(itemStateMessage); } - for (const [name, value] of room.variables.entries()) { + const variables = await room.getVariablesForTags(user.tags); + + for (const [name, value] of variables.entries()) { const variableMessage = new VariableMessage(); variableMessage.setName(name); variableMessage.setValue(value); @@ -198,12 +200,14 @@ export class SocketManager { } handleVariableEvent(room: GameRoom, user: User, variableMessage: VariableMessage) { - try { - room.setVariable(variableMessage.getName(), variableMessage.getValue()); - } catch (e) { - console.error('An error occurred on "handleVariableEvent"'); - console.error(e); - } + (async () => { + try { + await room.setVariable(variableMessage.getName(), variableMessage.getValue(), user); + } catch (e) { + console.error('An error occurred on "handleVariableEvent"'); + console.error(e); + } + })(); } emitVideo(room: GameRoom, user: User, data: WebRtcSignalToServerMessage): void { @@ -272,7 +276,7 @@ export class SocketManager { //user leave previous world room.leave(user); if (room.isEmpty()) { - this.rooms.delete(room.roomUrl); + this.roomsPromises.delete(room.roomUrl); gaugeManager.decNbRoomGauge(); debug('Room is empty. Deleting room "%s"', room.roomUrl); } @@ -284,38 +288,34 @@ export class SocketManager { async getOrCreateRoom(roomId: string): Promise { //check and create new room - let room = this.rooms.get(roomId); - if (room === undefined) { - let roomPromise = this.roomsPromises.get(roomId); - if (roomPromise) { - return roomPromise; - } - - // Note: for now, the promise is useless (because this is synchronous, but soon, we will need to - // load the map server side. - - room = new GameRoom( - roomId, - (user: User, group: Group) => this.joinWebRtcRoom(user, group), - (user: User, group: Group) => this.disConnectedUser(user, group), - MINIMUM_DISTANCE, - GROUP_RADIUS, - (thing: Movable, fromZone: Zone | null, listener: ZoneSocket) => - this.onZoneEnter(thing, fromZone, listener), - (thing: Movable, position: PositionInterface, listener: ZoneSocket) => - this.onClientMove(thing, position, listener), - (thing: Movable, newZone: Zone | null, listener: ZoneSocket) => - this.onClientLeave(thing, newZone, listener), - (emoteEventMessage: EmoteEventMessage, listener: ZoneSocket) => - this.onEmote(emoteEventMessage, listener) - ); - gaugeManager.incNbRoomGauge(); - this.rooms.set(roomId, room); - - // TODO: change this the to new Promise()... when the method becomes actually asynchronous - roomPromise = Promise.resolve(room); + let roomPromise = this.roomsPromises.get(roomId); + if (roomPromise === undefined) { + roomPromise = new Promise((resolve, reject) => { + GameRoom.create( + roomId, + (user: User, group: Group) => this.joinWebRtcRoom(user, group), + (user: User, group: Group) => this.disConnectedUser(user, group), + MINIMUM_DISTANCE, + GROUP_RADIUS, + (thing: Movable, fromZone: Zone | null, listener: ZoneSocket) => + this.onZoneEnter(thing, fromZone, listener), + (thing: Movable, position: PositionInterface, listener: ZoneSocket) => + this.onClientMove(thing, position, listener), + (thing: Movable, newZone: Zone | null, listener: ZoneSocket) => + this.onClientLeave(thing, newZone, listener), + (emoteEventMessage: EmoteEventMessage, listener: ZoneSocket) => + this.onEmote(emoteEventMessage, listener) + ).then((gameRoom) => { + gaugeManager.incNbRoomGauge(); + resolve(gameRoom); + }).catch((e) => { + this.roomsPromises.delete(roomId); + reject(e); + }); + }); + this.roomsPromises.set(roomId, roomPromise); } - return Promise.resolve(room); + return roomPromise; } private async joinRoom( @@ -554,8 +554,8 @@ export class SocketManager { } } - public getWorlds(): Map { - return this.rooms; + public getWorlds(): Map> { + return this.roomsPromises; } public handleQueryJitsiJwtMessage(user: User, queryJitsiJwtMessage: QueryJitsiJwtMessage) { @@ -625,11 +625,10 @@ export class SocketManager { }, 10000); } - public addZoneListener(call: ZoneSocket, roomId: string, x: number, y: number): void { - const room = this.rooms.get(roomId); + public async addZoneListener(call: ZoneSocket, roomId: string, x: number, y: number): Promise { + const room = await this.roomsPromises.get(roomId); if (!room) { - console.error("In addZoneListener, could not find room with id '" + roomId + "'"); - return; + throw new Error("In addZoneListener, could not find room with id '" + roomId + "'"); } const things = room.addZoneListener(call, x, y); @@ -670,11 +669,10 @@ export class SocketManager { call.write(batchMessage); } - removeZoneListener(call: ZoneSocket, roomId: string, x: number, y: number) { - const room = this.rooms.get(roomId); + async removeZoneListener(call: ZoneSocket, roomId: string, x: number, y: number): Promise { + const room = await this.roomsPromises.get(roomId); if (!room) { - console.error("In removeZoneListener, could not find room with id '" + roomId + "'"); - return; + throw new Error("In removeZoneListener, could not find room with id '" + roomId + "'"); } room.removeZoneListener(call, x, y); @@ -683,8 +681,7 @@ export class SocketManager { async addRoomListener(call: RoomSocket, roomId: string) { const room = await this.getOrCreateRoom(roomId); if (!room) { - console.error("In addRoomListener, could not find room with id '" + roomId + "'"); - return; + throw new Error("In addRoomListener, could not find room with id '" + roomId + "'"); } room.addRoomListener(call); @@ -692,7 +689,10 @@ export class SocketManager { const batchMessage = new BatchToPusherRoomMessage(); - for (const [name, value] of room.variables.entries()) { + // Finally, no need to store variables in the pusher, let's only make it act as a relay + /*const variables = await room.getVariables(); + + for (const [name, value] of variables.entries()) { const variableMessage = new VariableMessage(); variableMessage.setName(name); variableMessage.setValue(value); @@ -701,16 +701,15 @@ export class SocketManager { subMessage.setVariablemessage(variableMessage); batchMessage.addPayload(subMessage); - } + }*/ call.write(batchMessage); } - removeRoomListener(call: RoomSocket, roomId: string) { - const room = this.rooms.get(roomId); + async removeRoomListener(call: RoomSocket, roomId: string) { + const room = await this.roomsPromises.get(roomId); if (!room) { - console.error("In removeRoomListener, could not find room with id '" + roomId + "'"); - return; + throw new Error("In removeRoomListener, could not find room with id '" + roomId + "'"); } room.removeRoomListener(call); @@ -727,14 +726,14 @@ export class SocketManager { public leaveAdminRoom(room: GameRoom, admin: Admin) { room.adminLeave(admin); if (room.isEmpty()) { - this.rooms.delete(room.roomUrl); + this.roomsPromises.delete(room.roomUrl); gaugeManager.decNbRoomGauge(); debug('Room is empty. Deleting room "%s"', room.roomUrl); } } - public sendAdminMessage(roomId: string, recipientUuid: string, message: string): void { - const room = this.rooms.get(roomId); + public async sendAdminMessage(roomId: string, recipientUuid: string, message: string): Promise { + const room = await this.roomsPromises.get(roomId); if (!room) { console.error( "In sendAdminMessage, could not find room with id '" + @@ -764,8 +763,8 @@ export class SocketManager { recipient.socket.write(serverToClientMessage); } - public banUser(roomId: string, recipientUuid: string, message: string): void { - const room = this.rooms.get(roomId); + public async banUser(roomId: string, recipientUuid: string, message: string): Promise { + const room = await this.roomsPromises.get(roomId); if (!room) { console.error( "In banUser, could not find room with id '" + @@ -800,8 +799,8 @@ export class SocketManager { recipient.socket.end(); } - sendAdminRoomMessage(roomId: string, message: string) { - const room = this.rooms.get(roomId); + async sendAdminRoomMessage(roomId: string, message: string) { + const room = await this.roomsPromises.get(roomId); if (!room) { //todo: this should cause the http call to return a 500 console.error( @@ -824,8 +823,8 @@ export class SocketManager { }); } - dispatchWorlFullWarning(roomId: string): void { - const room = this.rooms.get(roomId); + async dispatchWorldFullWarning(roomId: string): Promise { + const room = await this.roomsPromises.get(roomId); if (!room) { //todo: this should cause the http call to return a 500 console.error( @@ -846,8 +845,8 @@ export class SocketManager { }); } - dispatchRoomRefresh(roomId: string): void { - const room = this.rooms.get(roomId); + async dispatchRoomRefresh(roomId: string): Promise { + const room = await this.roomsPromises.get(roomId); if (!room) { return; } diff --git a/back/src/Services/VariablesManager.ts b/back/src/Services/VariablesManager.ts new file mode 100644 index 00000000..36acd9e4 --- /dev/null +++ b/back/src/Services/VariablesManager.ts @@ -0,0 +1,139 @@ +/** + * Handles variables shared between the scripting API and the server. + */ +import {ITiledMap, ITiledMapObject, ITiledMapObjectLayer} from "@workadventure/tiled-map-type-guard/dist"; +import {User} from "_Model/User"; + +interface Variable { + defaultValue?: string, + persist?: boolean, + readableBy?: string, + writableBy?: string, +} + +export class VariablesManager { + /** + * The actual values of the variables for the current room + */ + private _variables = new Map(); + + /** + * The list of variables that are allowed + */ + private variableObjects: Map | undefined; + + /** + * @param map The map can be "null" if it is hosted on a private network. In this case, we assume this is a test setup and bypass any server-side checks. + */ + constructor(private map: ITiledMap | null) { + // We initialize the list of variable object at room start. The objects cannot be edited later + // (otherwise, this would cause a security issue if the scripting API can edit this list of objects) + if (map) { + this.variableObjects = VariablesManager.findVariablesInMap(map); + + // Let's initialize default values + for (const [name, variableObject] of this.variableObjects.entries()) { + if (variableObject.defaultValue !== undefined) { + this._variables.set(name, variableObject.defaultValue); + } + } + } + } + + private static findVariablesInMap(map: ITiledMap): Map { + const objects = new Map(); + for (const layer of map.layers) { + if (layer.type === 'objectgroup') { + for (const object of (layer as ITiledMapObjectLayer).objects) { + if (object.type === 'variable') { + if (object.template) { + console.warn('Warning, a variable object is using a Tiled "template". WorkAdventure does not support objects generated from Tiled templates.') + continue; + } + + // We store a copy of the object (to make it immutable) + objects.set(object.name, this.iTiledObjectToVariable(object)); + } + } + } + } + return objects; + } + + private static iTiledObjectToVariable(object: ITiledMapObject): Variable { + const variable: Variable = {}; + + if (object.properties) { + for (const property of object.properties) { + const value = property.value; + switch (property.name) { + case 'default': + variable.defaultValue = JSON.stringify(value); + break; + case 'persist': + if (typeof value !== 'boolean') { + throw new Error('The persist property of variable "' + object.name + '" must be a boolean'); + } + variable.persist = value; + break; + case 'writableBy': + if (typeof value !== 'string') { + throw new Error('The writableBy property of variable "' + object.name + '" must be a string'); + } + if (value) { + variable.writableBy = value; + } + break; + case 'readableBy': + if (typeof value !== 'string') { + throw new Error('The readableBy property of variable "' + object.name + '" must be a string'); + } + if (value) { + variable.readableBy = value; + } + break; + } + } + } + + return variable; + } + + setVariable(name: string, value: string, user: User): string | undefined { + let readableBy: string | undefined; + if (this.variableObjects) { + const variableObject = this.variableObjects.get(name); + if (variableObject === undefined) { + throw new Error('Trying to set a variable "' + name + '" that is not defined as an object in the map.'); + } + + if (variableObject.writableBy && user.tags.indexOf(variableObject.writableBy) === -1) { + throw new Error('Trying to set a variable "' + name + '". User "' + user.name + '" does not have sufficient permission. Required tag: "' + variableObject.writableBy + '". User tags: ' + user.tags.join(', ') + "."); + } + + readableBy = variableObject.readableBy; + } + + this._variables.set(name, value); + return readableBy; + } + + public getVariablesForTags(tags: string[]): Map { + if (this.variableObjects === undefined) { + return this._variables; + } + + const readableVariables = new Map(); + + for (const [key, value] of this._variables.entries()) { + const variableObject = this.variableObjects.get(key); + if (variableObject === undefined) { + throw new Error('Unexpected variable "'+key+'" found has no associated variableObject.'); + } + if (!variableObject.readableBy || tags.indexOf(variableObject.readableBy) !== -1) { + readableVariables.set(key, value); + } + } + return readableVariables; + } +} diff --git a/back/tsconfig.json b/back/tsconfig.json index 6972715f..e149d304 100644 --- a/back/tsconfig.json +++ b/back/tsconfig.json @@ -3,7 +3,7 @@ "experimentalDecorators": true, /* Basic Options */ // "incremental": true, /* Enable incremental compilation */ - "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ + "target": "ES2019", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ "downlevelIteration": true, "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ // "lib": [], /* Specify library files to be included in the compilation. */ diff --git a/back/yarn.lock b/back/yarn.lock index 242728db..54833963 100644 --- a/back/yarn.lock +++ b/back/yarn.lock @@ -187,6 +187,13 @@ semver "^7.3.2" tsutils "^3.17.1" +"@workadventure/tiled-map-type-guard@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@workadventure/tiled-map-type-guard/-/tiled-map-type-guard-1.0.0.tgz#02524602ee8b2688429a1f56df1d04da3fc171ba" + integrity sha512-Mc0SE128otQnYlScQWVaQVyu1+CkailU/FTBh09UTrVnBAhyMO+jIn9vT9+Dv244xq+uzgQDpXmiVdjgrYFQ+A== + dependencies: + generic-type-guard "^3.4.1" + abbrev@1: version "1.1.1" resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" @@ -1181,6 +1188,11 @@ generic-type-guard@^3.2.0: resolved "https://registry.yarnpkg.com/generic-type-guard/-/generic-type-guard-3.3.3.tgz#954b846fecff91047cadb0dcc28930811fcb9dc1" integrity sha512-SXraZvNW/uTfHVgB48iEwWaD1XFJ1nvZ8QP6qy9pSgaScEyQqFHYN5E6d6rCsJgrvlWKygPrNum7QeJHegzNuQ== +generic-type-guard@^3.4.1: + version "3.4.1" + resolved "https://registry.yarnpkg.com/generic-type-guard/-/generic-type-guard-3.4.1.tgz#0896dc018de915c890562a34763858076e4676da" + integrity sha512-sXce0Lz3Wfy2rR1W8O8kUemgEriTeG1x8shqSJeWGb0FwJu2qBEkB1M2qXbdSLmpgDnHcIXo0Dj/1VLNJkK/QA== + get-own-enumerable-property-symbols@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz#b5fde77f22cbe35f390b4e089922c50bce6ef664" @@ -1417,6 +1429,11 @@ invert-kv@^1.0.0: resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6" integrity sha1-EEqOSqym09jNFXqO+L+rLXo//bY= +ipaddr.js@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.0.1.tgz#eca256a7a877e917aeb368b0a7497ddf42ef81c0" + integrity sha512-1qTgH9NG+IIJ4yfKs2e6Pp1bZg8wbDbKHT21HrLIeYBTRLgMYKnMTPAuI3Lcs61nfx5h1xlXnbJtH1kX5/d/ng== + is-accessor-descriptor@^0.1.6: version "0.1.6" resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6" diff --git a/front/src/Connexion/RoomConnection.ts b/front/src/Connexion/RoomConnection.ts index 04ef6619..b23f9549 100644 --- a/front/src/Connexion/RoomConnection.ts +++ b/front/src/Connexion/RoomConnection.ts @@ -32,7 +32,7 @@ import { EmotePromptMessage, SendUserMessage, BanUserMessage, - VariableMessage, + VariableMessage, ErrorMessage, } from "../Messages/generated/messages_pb"; import type { UserSimplePeerInterface } from "../WebRtc/SimplePeer"; @@ -165,6 +165,9 @@ export class RoomConnection implements RoomConnection { } else if (subMessage.hasEmoteeventmessage()) { const emoteMessage = subMessage.getEmoteeventmessage() as EmoteEventMessage; emoteEventStream.fire(emoteMessage.getActoruserid(), emoteMessage.getEmote()); + } else if (subMessage.hasErrormessage()) { + const errorMessage = subMessage.getErrormessage() as ErrorMessage; + console.error('An error occurred server side: '+errorMessage.getMessage()); } else if (subMessage.hasVariablemessage()) { event = EventMessage.SET_VARIABLE; payload = subMessage.getVariablemessage(); diff --git a/front/src/Phaser/Game/SharedVariablesManager.ts b/front/src/Phaser/Game/SharedVariablesManager.ts index c73ffac4..f177438d 100644 --- a/front/src/Phaser/Game/SharedVariablesManager.ts +++ b/front/src/Phaser/Game/SharedVariablesManager.ts @@ -26,6 +26,11 @@ export class SharedVariablesManager { // Let's initialize default values for (const [name, variableObject] of this.variableObjects.entries()) { + if (variableObject.readableBy && !this.roomConnection.hasTag(variableObject.readableBy)) { + // Do not initialize default value for variables that are not readable + continue; + } + this._variables.set(name, variableObject.defaultValue); } @@ -35,7 +40,6 @@ export class SharedVariablesManager { } roomConnection.onSetVariable((name, value) => { - console.log('Set Variable received from server'); this._variables.set(name, value); // On server change, let's notify the iframes diff --git a/maps/tests/Variables/script.js b/maps/tests/Variables/script.js index ae663cc9..1ab1b2e5 100644 --- a/maps/tests/Variables/script.js +++ b/maps/tests/Variables/script.js @@ -23,4 +23,11 @@ WA.onInit().then(() => { WA.state.saveVariable('config', {'foo': 'bar'}).catch(e => { console.log('Successfully caught error because variable "config" is not writable: ', e); }); + + console.log('Trying to read variable "readableByAdmin" that can only be read by "admin". We are not admin so we should not get the default value.'); + if (WA.state.readableByAdmin === true) { + console.error('Failed test: readableByAdmin can be read.'); + } else { + console.log('Success test: readableByAdmin was not read.'); + } }); diff --git a/maps/tests/Variables/shared_variables.json b/maps/tests/Variables/shared_variables.json new file mode 100644 index 00000000..2de5e4c0 --- /dev/null +++ b/maps/tests/Variables/shared_variables.json @@ -0,0 +1,131 @@ +{ "compressionlevel":-1, + "height":10, + "infinite":false, + "layers":[ + { + "data":[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], + "height":10, + "id":1, + "name":"floor", + "opacity":1, + "properties":[ + { + "name":"openWebsite", + "type":"string", + "value":"shared_variables.html" + }, + { + "name":"openWebsiteAllowApi", + "type":"bool", + "value":true + }], + "type":"tilelayer", + "visible":true, + "width":10, + "x":0, + "y":0 + }, + { + "data":[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "height":10, + "id":2, + "name":"start", + "opacity":1, + "type":"tilelayer", + "visible":true, + "width":10, + "x":0, + "y":0 + }, + { + "draworder":"topdown", + "id":3, + "name":"floorLayer", + "objects":[ + { + "height":67, + "id":3, + "name":"", + "rotation":0, + "text": + { + "fontfamily":"Sans Serif", + "pixelsize":11, + "text":"Test:\nChange the form\nConnect with another user\n\nResult:\nThe form should open in the same state for the other user\nAlso, a change on one user is directly propagated to the other user", + "wrap":true + }, + "type":"", + "visible":true, + "width":252.4375, + "x":2.78125, + "y":2.5 + }, + { + "height":0, + "id":5, + "name":"textField", + "point":true, + "properties":[ + { + "name":"default", + "type":"string", + "value":"default value" + }, + { + "name":"jsonSchema", + "type":"string", + "value":"{}" + }, + { + "name":"persist", + "type":"bool", + "value":true + }, + { + "name":"readableBy", + "type":"string", + "value":"" + }, + { + "name":"writableBy", + "type":"string", + "value":"" + }], + "rotation":0, + "type":"variable", + "visible":true, + "width":0, + "x":57.5, + "y":111 + }], + "opacity":1, + "type":"objectgroup", + "visible":true, + "x":0, + "y":0 + }], + "nextlayerid":8, + "nextobjectid":10, + "orientation":"orthogonal", + "renderorder":"right-down", + "tiledversion":"2021.03.23", + "tileheight":32, + "tilesets":[ + { + "columns":11, + "firstgid":1, + "image":"..\/tileset1.png", + "imageheight":352, + "imagewidth":352, + "margin":0, + "name":"tileset1", + "spacing":0, + "tilecount":121, + "tileheight":32, + "tilewidth":32 + }], + "tilewidth":32, + "type":"map", + "version":1.5, + "width":10 +} \ No newline at end of file diff --git a/maps/tests/Variables/variables.json b/maps/tests/Variables/variables.json index 79ca591b..b0f5b5b0 100644 --- a/maps/tests/Variables/variables.json +++ b/maps/tests/Variables/variables.json @@ -142,6 +142,29 @@ "width":0, "x":88.8149900876127, "y":147.75212636695 + }, + { + "height":0, + "id":10, + "name":"readableByAdmin", + "point":true, + "properties":[ + { + "name":"default", + "type":"bool", + "value":true + }, + { + "name":"readableBy", + "type":"string", + "value":"admin" + }], + "rotation":0, + "type":"variable", + "visible":true, + "width":0, + "x":182.132122529897, + "y":157.984268082113 }], "opacity":1, "type":"objectgroup", @@ -150,7 +173,7 @@ "y":0 }], "nextlayerid":8, - "nextobjectid":10, + "nextobjectid":11, "orientation":"orthogonal", "properties":[ { diff --git a/messages/protos/messages.proto b/messages/protos/messages.proto index c352a324..12caf32d 100644 --- a/messages/protos/messages.proto +++ b/messages/protos/messages.proto @@ -113,6 +113,15 @@ message VariableMessage { string value = 2; } +/** + * A variable, along the tag describing who it is targeted at + */ +message VariableWithTagMessage { + string name = 1; + string value = 2; + string readableBy = 3; +} + message PlayGlobalMessage { string id = 1; string type = 2; @@ -140,6 +149,7 @@ message SubMessage { ItemEventMessage itemEventMessage = 6; EmoteEventMessage emoteEventMessage = 7; VariableMessage variableMessage = 8; + ErrorMessage errorMessage = 9; } } @@ -365,6 +375,7 @@ message SubToPusherMessage { SendUserMessage sendUserMessage = 7; BanUserMessage banUserMessage = 8; EmoteEventMessage emoteEventMessage = 9; + ErrorMessage errorMessage = 10; } } @@ -374,7 +385,8 @@ message BatchToPusherRoomMessage { message SubToPusherRoomMessage { oneof message { - VariableMessage variableMessage = 1; + VariableWithTagMessage variableMessage = 1; + ErrorMessage errorMessage = 2; } } diff --git a/pusher/src/Model/PusherRoom.ts b/pusher/src/Model/PusherRoom.ts index 713e9d25..d7cfffe4 100644 --- a/pusher/src/Model/PusherRoom.ts +++ b/pusher/src/Model/PusherRoom.ts @@ -7,7 +7,7 @@ import { apiClientRepository } from "../Services/ApiClientRepository"; import { BatchToPusherMessage, BatchToPusherRoomMessage, - EmoteEventMessage, + EmoteEventMessage, ErrorMessage, GroupLeftZoneMessage, GroupUpdateZoneMessage, RoomMessage, @@ -15,7 +15,7 @@ import { UserJoinedZoneMessage, UserLeftZoneMessage, UserMovedMessage, - VariableMessage, + VariableMessage, VariableWithTagMessage, ZoneMessage, } from "../Messages/generated/messages_pb"; import Debug from "debug"; @@ -38,7 +38,7 @@ export class PusherRoom { private backConnection!: ClientReadableStream; private isClosing: boolean = false; private listeners: Set = new Set(); - public readonly variables = new Map(); + //public readonly variables = new Map(); constructor(public readonly roomUrl: string, private socketListener: ZoneEventListener) { this.tags = []; @@ -90,15 +90,27 @@ export class PusherRoom { this.backConnection.on("data", (batch: BatchToPusherRoomMessage) => { for (const message of batch.getPayloadList()) { if (message.hasVariablemessage()) { - const variableMessage = message.getVariablemessage() as VariableMessage; + const variableMessage = message.getVariablemessage() as VariableWithTagMessage; + const readableBy = variableMessage.getReadableby(); // We need to store all variables to dispatch variables later to the listeners - this.variables.set(variableMessage.getName(), variableMessage.getValue()); + //this.variables.set(variableMessage.getName(), variableMessage.getValue(), readableBy); // Let's dispatch this variable to all the listeners for (const listener of this.listeners) { const subMessage = new SubMessage(); - subMessage.setVariablemessage(variableMessage); + if (!readableBy || listener.tags.indexOf(readableBy) !== -1) { + subMessage.setVariablemessage(variableMessage); + } + listener.emitInBatch(subMessage); + } + } else if (message.hasErrormessage()) { + const errorMessage = message.getErrormessage() as ErrorMessage; + + // Let's dispatch this error to all the listeners + for (const listener of this.listeners) { + const subMessage = new SubMessage(); + subMessage.setErrormessage(errorMessage); listener.emitInBatch(subMessage); } } else { diff --git a/pusher/src/Model/Zone.ts b/pusher/src/Model/Zone.ts index 501a2541..d116bb79 100644 --- a/pusher/src/Model/Zone.ts +++ b/pusher/src/Model/Zone.ts @@ -14,7 +14,7 @@ import { UserMovedMessage, ZoneMessage, EmoteEventMessage, - CompanionMessage, + CompanionMessage, ErrorMessage, } from "../Messages/generated/messages_pb"; import { ClientReadableStream } from "grpc"; import { PositionDispatcher } from "_Model/PositionDispatcher"; @@ -30,6 +30,7 @@ export interface ZoneEventListener { onGroupMoves(group: GroupDescriptor, listener: ExSocketInterface): void; onGroupLeaves(groupId: number, listener: ExSocketInterface): void; onEmote(emoteMessage: EmoteEventMessage, listener: ExSocketInterface): void; + onError(errorMessage: ErrorMessage, listener: ExSocketInterface): void; } /*export type EntersCallback = (thing: Movable, listener: User) => void; @@ -217,6 +218,9 @@ export class Zone { } else if (message.hasEmoteeventmessage()) { const emoteEventMessage = message.getEmoteeventmessage() as EmoteEventMessage; this.notifyEmote(emoteEventMessage); + } else if (message.hasErrormessage()) { + const errorMessage = message.getErrormessage() as ErrorMessage; + this.notifyError(errorMessage); } else { throw new Error("Unexpected message"); } @@ -303,6 +307,12 @@ export class Zone { } } + private notifyError(errorMessage: ErrorMessage) { + for (const listener of this.listeners) { + this.socketListener.onError(errorMessage, listener); + } + } + /** * Notify listeners of this zone that this group left */ diff --git a/pusher/src/Services/SocketManager.ts b/pusher/src/Services/SocketManager.ts index 5a544966..dfd9c15a 100644 --- a/pusher/src/Services/SocketManager.ts +++ b/pusher/src/Services/SocketManager.ts @@ -30,7 +30,7 @@ import { BanMessage, RefreshRoomMessage, EmotePromptMessage, - VariableMessage, + VariableMessage, ErrorMessage, } from "../Messages/generated/messages_pb"; import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils"; import { ADMIN_API_URL, JITSI_ISS, SECRET_JITSI_KEY } from "../Enum/EnvironmentVariable"; @@ -281,6 +281,13 @@ export class SocketManager implements ZoneEventListener { emitInBatch(listener, subMessage); } + onError(errorMessage: ErrorMessage, listener: ExSocketInterface): void { + const subMessage = new SubMessage(); + subMessage.setErrormessage(errorMessage); + + emitInBatch(listener, subMessage); + } + // Useless now, will be useful again if we allow editing details in game handleSetPlayerDetails(client: ExSocketInterface, playerDetailsMessage: SetPlayerDetailsMessage) { const pusherToBackMessage = new PusherToBackMessage(); diff --git a/pusher/tsconfig.json b/pusher/tsconfig.json index 6972715f..e149d304 100644 --- a/pusher/tsconfig.json +++ b/pusher/tsconfig.json @@ -3,7 +3,7 @@ "experimentalDecorators": true, /* Basic Options */ // "incremental": true, /* Enable incremental compilation */ - "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ + "target": "ES2019", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ "downlevelIteration": true, "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ // "lib": [], /* Specify library files to be included in the compilation. */ From 18e4d2ba4e0e73cde0ac2d11a40ee1eea6686ad0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Mon, 19 Jul 2021 10:32:31 +0200 Subject: [PATCH 17/31] Setting a timeout to map loading --- back/src/Services/MapFetcher.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/back/src/Services/MapFetcher.ts b/back/src/Services/MapFetcher.ts index fa1a831e..9869d26a 100644 --- a/back/src/Services/MapFetcher.ts +++ b/back/src/Services/MapFetcher.ts @@ -23,6 +23,7 @@ class MapFetcher { // - The result of the query is never displayed to the end user const res = await Axios.get(mapUrl, { maxContentLength: 50*1024*1024, // Max content length: 50MB. Maps should not be bigger + timeout: 10000, // Timeout after 10 seconds }); if (!isTiledMap(res.data)) { From d955ddfe821691324818c7127615bd3655ed27ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Mon, 19 Jul 2021 15:57:50 +0200 Subject: [PATCH 18/31] Adding support to persist variables in Redis --- back/package.json | 2 + back/src/Enum/EnvironmentVariable.ts | 3 + back/src/Model/GameRoom.ts | 92 ++++++++++++------ back/src/RoomManager.ts | 75 +++++++++------ back/src/Services/AdminApi/MapDetailsData.ts | 2 +- back/src/Services/LocalUrlError.ts | 3 +- back/src/Services/MapFetcher.ts | 22 ++--- back/src/Services/MessageHelpers.ts | 9 +- back/src/Services/RedisClient.ts | 23 +++++ .../Repository/RedisVariablesRepository.ts | 40 ++++++++ .../Repository/VariablesRepository.ts | 14 +++ .../VariablesRepositoryInterface.ts | 10 ++ .../Repository/VoidVariablesRepository.ts | 14 +++ back/src/Services/SocketManager.ts | 16 ++-- back/src/Services/VariablesManager.ts | 94 ++++++++++++++----- back/tests/GameRoomTest.ts | 13 ++- back/yarn.lock | 39 ++++++++ deeployer.libsonnet | 5 + docker-compose.single-domain.yaml | 5 + docker-compose.yaml | 16 ++++ docs/maps/api-state.md | 6 +- pusher/src/Model/PusherRoom.ts | 8 +- pusher/src/Model/Zone.ts | 3 +- pusher/src/Services/SocketManager.ts | 3 +- 24 files changed, 397 insertions(+), 120 deletions(-) create mode 100644 back/src/Services/RedisClient.ts create mode 100644 back/src/Services/Repository/RedisVariablesRepository.ts create mode 100644 back/src/Services/Repository/VariablesRepository.ts create mode 100644 back/src/Services/Repository/VariablesRepositoryInterface.ts create mode 100644 back/src/Services/Repository/VoidVariablesRepository.ts diff --git a/back/package.json b/back/package.json index a532f5cd..8a1e445e 100644 --- a/back/package.json +++ b/back/package.json @@ -53,6 +53,7 @@ "mkdirp": "^1.0.4", "prom-client": "^12.0.0", "query-string": "^6.13.3", + "redis": "^3.1.2", "systeminformation": "^4.31.1", "uWebSockets.js": "uNetworking/uWebSockets.js#v18.5.0", "uuidv4": "^6.0.7" @@ -66,6 +67,7 @@ "@types/jasmine": "^3.5.10", "@types/jsonwebtoken": "^8.3.8", "@types/mkdirp": "^1.0.1", + "@types/redis": "^2.8.31", "@types/uuidv4": "^5.0.0", "@typescript-eslint/eslint-plugin": "^2.26.0", "@typescript-eslint/parser": "^2.26.0", diff --git a/back/src/Enum/EnvironmentVariable.ts b/back/src/Enum/EnvironmentVariable.ts index 19eddd3e..92f62b0b 100644 --- a/back/src/Enum/EnvironmentVariable.ts +++ b/back/src/Enum/EnvironmentVariable.ts @@ -12,6 +12,9 @@ const GRPC_PORT = parseInt(process.env.GRPC_PORT || "50051") || 50051; export const SOCKET_IDLE_TIMER = parseInt(process.env.SOCKET_IDLE_TIMER as string) || 30; // maximum time (in second) without activity before a socket is closed export const TURN_STATIC_AUTH_SECRET = process.env.TURN_STATIC_AUTH_SECRET || ""; export const MAX_PER_GROUP = parseInt(process.env.MAX_PER_GROUP || "4"); +export const REDIS_HOST = process.env.REDIS_HOST || undefined; +export const REDIS_PORT = parseInt(process.env.REDIS_PORT || "6379") || 6379; +export const REDIS_PASSWORD = process.env.REDIS_PASSWORD || undefined; export { MINIMUM_DISTANCE, diff --git a/back/src/Model/GameRoom.ts b/back/src/Model/GameRoom.ts index fd711ae8..2e30bf52 100644 --- a/back/src/Model/GameRoom.ts +++ b/back/src/Model/GameRoom.ts @@ -11,18 +11,19 @@ import { EmoteEventMessage, JoinRoomMessage, SubToPusherRoomMessage, - VariableMessage, VariableWithTagMessage, + VariableMessage, + VariableWithTagMessage, } from "../Messages/generated/messages_pb"; import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils"; import { RoomSocket, ZoneSocket } from "src/RoomManager"; import { Admin } from "../Model/Admin"; -import {adminApi} from "../Services/AdminApi"; -import {isMapDetailsData, MapDetailsData} from "../Services/AdminApi/MapDetailsData"; -import {ITiledMap} from "@workadventure/tiled-map-type-guard/dist"; -import {mapFetcher} from "../Services/MapFetcher"; -import {VariablesManager} from "../Services/VariablesManager"; -import {ADMIN_API_URL} from "../Enum/EnvironmentVariable"; -import {LocalUrlError} from "../Services/LocalUrlError"; +import { adminApi } from "../Services/AdminApi"; +import { isMapDetailsData, MapDetailsData } from "../Services/AdminApi/MapDetailsData"; +import { ITiledMap } from "@workadventure/tiled-map-type-guard/dist"; +import { mapFetcher } from "../Services/MapFetcher"; +import { VariablesManager } from "../Services/VariablesManager"; +import { ADMIN_API_URL } from "../Enum/EnvironmentVariable"; +import { LocalUrlError } from "../Services/LocalUrlError"; export type ConnectCallback = (user: User, group: Group) => void; export type DisconnectCallback = (user: User, group: Group) => void; @@ -68,10 +69,21 @@ export class GameRoom { onMoves: MovesCallback, onLeaves: LeavesCallback, onEmote: EmoteCallback - ) : Promise { + ): Promise { const mapDetails = await GameRoom.getMapDetails(roomUrl); - const gameRoom = new GameRoom(roomUrl, mapDetails.mapUrl, connectCallback, disconnectCallback, minDistance, groupRadius, onEnters, onMoves, onLeaves, onEmote); + const gameRoom = new GameRoom( + roomUrl, + mapDetails.mapUrl, + connectCallback, + disconnectCallback, + minDistance, + groupRadius, + onEnters, + onMoves, + onLeaves, + onEmote + ); return gameRoom; } @@ -381,7 +393,7 @@ export class GameRoom { const match = /\/_\/[^/]+\/(.+)/.exec(roomUrlObj.pathname); if (!match) { - console.error('Unexpected room URL', roomUrl); + console.error("Unexpected room URL", roomUrl); throw new Error('Unexpected room URL "' + roomUrl + '"'); } @@ -392,18 +404,18 @@ export class GameRoom { policy_type: 1, textures: [], tags: [], - } + }; } const result = await adminApi.fetchMapDetails(roomUrl); if (!isMapDetailsData(result)) { - console.error('Unexpected room details received from server', result); - throw new Error('Unexpected room details received from server'); + console.error("Unexpected room details received from server", result); + throw new Error("Unexpected room details received from server"); } return result; } - private mapPromise: Promise|undefined; + private mapPromise: Promise | undefined; /** * Returns a promise to the map file. @@ -418,27 +430,45 @@ export class GameRoom { return this.mapPromise; } - private variableManagerPromise: Promise|undefined; + private variableManagerPromise: Promise | undefined; private getVariableManager(): Promise { if (!this.variableManagerPromise) { this.variableManagerPromise = new Promise((resolve, reject) => { - this.getMap().then((map) => { - resolve(new VariablesManager(map)); - }).catch(e => { - if (e instanceof LocalUrlError) { - // If we are trying to load a local URL, we are probably in test mode. - // In this case, let's bypass the server-side checks completely. + this.getMap() + .then((map) => { + const variablesManager = new VariablesManager(this.roomUrl, map); + variablesManager + .init() + .then(() => { + resolve(variablesManager); + }) + .catch((e) => { + reject(e); + }); + }) + .catch((e) => { + if (e instanceof LocalUrlError) { + // If we are trying to load a local URL, we are probably in test mode. + // In this case, let's bypass the server-side checks completely. - // FIXME: find a way to send a warning to the client side - // FIXME: find a way to send a warning to the client side - // FIXME: find a way to send a warning to the client side - // FIXME: find a way to send a warning to the client side - resolve(new VariablesManager(null)); - } else { - reject(e); - } - }) + // FIXME: find a way to send a warning to the client side + // FIXME: find a way to send a warning to the client side + // FIXME: find a way to send a warning to the client side + // FIXME: find a way to send a warning to the client side + const variablesManager = new VariablesManager(this.roomUrl, null); + variablesManager + .init() + .then(() => { + resolve(variablesManager); + }) + .catch((e) => { + reject(e); + }); + } else { + reject(e); + } + }); }); } return this.variableManagerPromise; diff --git a/back/src/RoomManager.ts b/back/src/RoomManager.ts index a6a99993..6a879202 100644 --- a/back/src/RoomManager.ts +++ b/back/src/RoomManager.ts @@ -4,7 +4,9 @@ import { AdminMessage, AdminPusherToBackMessage, AdminRoomMessage, - BanMessage, BatchToPusherMessage, BatchToPusherRoomMessage, + BanMessage, + BatchToPusherMessage, + BatchToPusherRoomMessage, EmotePromptMessage, EmptyMessage, ItemEventMessage, @@ -12,17 +14,19 @@ import { PlayGlobalMessage, PusherToBackMessage, QueryJitsiJwtMessage, - RefreshRoomPromptMessage, RoomMessage, + RefreshRoomPromptMessage, + RoomMessage, ServerToAdminClientMessage, SilentMessage, - UserMovesMessage, VariableMessage, + UserMovesMessage, + VariableMessage, WebRtcSignalToServerMessage, WorldFullWarningToRoomMessage, ZoneMessage, } from "./Messages/generated/messages_pb"; import { sendUnaryData, ServerDuplexStream, ServerUnaryCall, ServerWritableStream } from "grpc"; import { socketManager } from "./Services/SocketManager"; -import {emitError, emitErrorOnRoomSocket, emitErrorOnZoneSocket} from "./Services/MessageHelpers"; +import { emitError, emitErrorOnRoomSocket, emitErrorOnZoneSocket } from "./Services/MessageHelpers"; import { User, UserSocket } from "./Model/User"; import { GameRoom } from "./Model/GameRoom"; import Debug from "debug"; @@ -55,7 +59,8 @@ const roomManager: IRoomManagerServer = { //Connexion may have been closed before the init was finished, so we have to manually disconnect the user. socketManager.leaveRoom(gameRoom, myUser); } - }).catch(e => emitError(call, e)); + }) + .catch((e) => emitError(call, e)); } else { throw new Error("The first message sent MUST be of type JoinRoomMessage"); } @@ -138,22 +143,30 @@ const roomManager: IRoomManagerServer = { debug("listenZone called"); const zoneMessage = call.request; - socketManager.addZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()).catch(e => { - emitErrorOnZoneSocket(call, e.toString()); - }); + socketManager + .addZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()) + .catch((e) => { + emitErrorOnZoneSocket(call, e.toString()); + }); call.on("cancelled", () => { debug("listenZone cancelled"); - socketManager.removeZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()).catch(e => console.error(e)); + socketManager + .removeZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()) + .catch((e) => console.error(e)); call.end(); }); call.on("close", () => { debug("listenZone connection closed"); - socketManager.removeZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()).catch(e => console.error(e)); + socketManager + .removeZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()) + .catch((e) => console.error(e)); }).on("error", (e) => { console.error("An error occurred in listenZone stream:", e); - socketManager.removeZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()).catch(e => console.error(e)); + socketManager + .removeZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()) + .catch((e) => console.error(e)); call.end(); }); }, @@ -162,25 +175,24 @@ const roomManager: IRoomManagerServer = { debug("listenRoom called"); const roomMessage = call.request; - socketManager.addRoomListener(call, roomMessage.getRoomid()).catch(e => { + socketManager.addRoomListener(call, roomMessage.getRoomid()).catch((e) => { emitErrorOnRoomSocket(call, e.toString()); }); call.on("cancelled", () => { debug("listenRoom cancelled"); - socketManager.removeRoomListener(call, roomMessage.getRoomid()).catch(e => console.error(e)); + socketManager.removeRoomListener(call, roomMessage.getRoomid()).catch((e) => console.error(e)); call.end(); }); call.on("close", () => { debug("listenRoom connection closed"); - socketManager.removeRoomListener(call, roomMessage.getRoomid()).catch(e => console.error(e)); + socketManager.removeRoomListener(call, roomMessage.getRoomid()).catch((e) => console.error(e)); }).on("error", (e) => { console.error("An error occurred in listenRoom stream:", e); - socketManager.removeRoomListener(call, roomMessage.getRoomid()).catch(e => console.error(e)); + socketManager.removeRoomListener(call, roomMessage.getRoomid()).catch((e) => console.error(e)); call.end(); }); - }, adminRoom(call: AdminSocket): void { @@ -194,9 +206,12 @@ const roomManager: IRoomManagerServer = { if (room === null) { if (message.hasSubscribetoroom()) { const roomId = message.getSubscribetoroom(); - socketManager.handleJoinAdminRoom(admin, roomId).then((gameRoom: GameRoom) => { - room = gameRoom; - }).catch(e => console.error(e)); + socketManager + .handleJoinAdminRoom(admin, roomId) + .then((gameRoom: GameRoom) => { + room = gameRoom; + }) + .catch((e) => console.error(e)); } else { throw new Error("The first message sent MUST be of type JoinRoomMessage"); } @@ -221,11 +236,9 @@ const roomManager: IRoomManagerServer = { }); }, sendAdminMessage(call: ServerUnaryCall, callback: sendUnaryData): void { - socketManager.sendAdminMessage( - call.request.getRoomid(), - call.request.getRecipientuuid(), - call.request.getMessage() - ).catch(e => console.error(e)); + socketManager + .sendAdminMessage(call.request.getRoomid(), call.request.getRecipientuuid(), call.request.getMessage()) + .catch((e) => console.error(e)); callback(null, new EmptyMessage()); }, @@ -236,13 +249,17 @@ const roomManager: IRoomManagerServer = { }, ban(call: ServerUnaryCall, callback: sendUnaryData): void { // FIXME Work in progress - socketManager.banUser(call.request.getRoomid(), call.request.getRecipientuuid(), call.request.getMessage()).catch(e => console.error(e)); + socketManager + .banUser(call.request.getRoomid(), call.request.getRecipientuuid(), call.request.getMessage()) + .catch((e) => console.error(e)); callback(null, new EmptyMessage()); }, sendAdminMessageToRoom(call: ServerUnaryCall, callback: sendUnaryData): void { // FIXME: we could improve return message by returning a Success|ErrorMessage message - socketManager.sendAdminRoomMessage(call.request.getRoomid(), call.request.getMessage()).catch(e => console.error(e)); + socketManager + .sendAdminRoomMessage(call.request.getRoomid(), call.request.getMessage()) + .catch((e) => console.error(e)); callback(null, new EmptyMessage()); }, sendWorldFullWarningToRoom( @@ -250,7 +267,7 @@ const roomManager: IRoomManagerServer = { callback: sendUnaryData ): void { // FIXME: we could improve return message by returning a Success|ErrorMessage message - socketManager.dispatchWorldFullWarning(call.request.getRoomid()).catch(e => console.error(e)); + socketManager.dispatchWorldFullWarning(call.request.getRoomid()).catch((e) => console.error(e)); callback(null, new EmptyMessage()); }, sendRefreshRoomPrompt( @@ -258,9 +275,9 @@ const roomManager: IRoomManagerServer = { callback: sendUnaryData ): void { // FIXME: we could improve return message by returning a Success|ErrorMessage message - socketManager.dispatchRoomRefresh(call.request.getRoomid()).catch(e => console.error(e)); + socketManager.dispatchRoomRefresh(call.request.getRoomid()).catch((e) => console.error(e)); callback(null, new EmptyMessage()); - } + }, }; export { roomManager }; diff --git a/back/src/Services/AdminApi/MapDetailsData.ts b/back/src/Services/AdminApi/MapDetailsData.ts index 54320791..d3402b92 100644 --- a/back/src/Services/AdminApi/MapDetailsData.ts +++ b/back/src/Services/AdminApi/MapDetailsData.ts @@ -15,7 +15,7 @@ export const isMapDetailsData = new tg.IsInterface() textures: tg.isArray(isCharacterTexture), }) .withOptionalProperties({ - roomSlug: tg.isUnion(tg.isString, tg.isNull), // deprecated + roomSlug: tg.isUnion(tg.isString, tg.isNull), // deprecated }) .get(); export type MapDetailsData = tg.GuardedType; diff --git a/back/src/Services/LocalUrlError.ts b/back/src/Services/LocalUrlError.ts index fc3fa617..a4984fdd 100644 --- a/back/src/Services/LocalUrlError.ts +++ b/back/src/Services/LocalUrlError.ts @@ -1,2 +1 @@ -export class LocalUrlError extends Error { -} +export class LocalUrlError extends Error {} diff --git a/back/src/Services/MapFetcher.ts b/back/src/Services/MapFetcher.ts index 9869d26a..99465ac4 100644 --- a/back/src/Services/MapFetcher.ts +++ b/back/src/Services/MapFetcher.ts @@ -1,17 +1,17 @@ import Axios from "axios"; -import ipaddr from 'ipaddr.js'; -import { Resolver } from 'dns'; -import { promisify } from 'util'; -import {LocalUrlError} from "./LocalUrlError"; -import {ITiledMap} from "@workadventure/tiled-map-type-guard"; -import {isTiledMap} from "@workadventure/tiled-map-type-guard/dist"; +import ipaddr from "ipaddr.js"; +import { Resolver } from "dns"; +import { promisify } from "util"; +import { LocalUrlError } from "./LocalUrlError"; +import { ITiledMap } from "@workadventure/tiled-map-type-guard"; +import { isTiledMap } from "@workadventure/tiled-map-type-guard/dist"; class MapFetcher { async fetchMap(mapUrl: string): Promise { // Before trying to make the query, let's verify the map is actually on the open internet (and not a local test map) if (await this.isLocalUrl(mapUrl)) { - throw new LocalUrlError('URL for map "'+mapUrl+'" targets a local map'); + throw new LocalUrlError('URL for map "' + mapUrl + '" targets a local map'); } // Note: mapUrl is provided by the client. A possible attack vector would be to use a rogue DNS server that @@ -22,12 +22,12 @@ class MapFetcher { // - We make sure we are only passing "GET" requests // - The result of the query is never displayed to the end user const res = await Axios.get(mapUrl, { - maxContentLength: 50*1024*1024, // Max content length: 50MB. Maps should not be bigger + maxContentLength: 50 * 1024 * 1024, // Max content length: 50MB. Maps should not be bigger timeout: 10000, // Timeout after 10 seconds }); if (!isTiledMap(res.data)) { - throw new Error('Invalid map format for map '+mapUrl); + throw new Error("Invalid map format for map " + mapUrl); } return res.data; @@ -39,7 +39,7 @@ class MapFetcher { */ private async isLocalUrl(url: string): Promise { const urlObj = new URL(url); - if (urlObj.hostname === 'localhost' || urlObj.hostname.endsWith('.localhost')) { + if (urlObj.hostname === "localhost" || urlObj.hostname.endsWith(".localhost")) { return true; } @@ -53,7 +53,7 @@ class MapFetcher { for (const address of addresses) { const addr = ipaddr.parse(address); - if (addr.range() !== 'unicast') { + if (addr.range() !== "unicast") { return true; } } diff --git a/back/src/Services/MessageHelpers.ts b/back/src/Services/MessageHelpers.ts index 069d3c78..606374be 100644 --- a/back/src/Services/MessageHelpers.ts +++ b/back/src/Services/MessageHelpers.ts @@ -1,11 +1,14 @@ import { BatchMessage, - BatchToPusherMessage, BatchToPusherRoomMessage, + BatchToPusherMessage, + BatchToPusherRoomMessage, ErrorMessage, - ServerToClientMessage, SubToPusherMessage, SubToPusherRoomMessage + ServerToClientMessage, + SubToPusherMessage, + SubToPusherRoomMessage, } from "../Messages/generated/messages_pb"; import { UserSocket } from "_Model/User"; -import {RoomSocket, ZoneSocket} from "../RoomManager"; +import { RoomSocket, ZoneSocket } from "../RoomManager"; export function emitError(Client: UserSocket, message: string): void { const errorMessage = new ErrorMessage(); diff --git a/back/src/Services/RedisClient.ts b/back/src/Services/RedisClient.ts new file mode 100644 index 00000000..1f8c1ecd --- /dev/null +++ b/back/src/Services/RedisClient.ts @@ -0,0 +1,23 @@ +import { ClientOpts, createClient, RedisClient } from "redis"; +import { REDIS_HOST, REDIS_PASSWORD, REDIS_PORT } from "../Enum/EnvironmentVariable"; + +let redisClient: RedisClient | null = null; + +if (REDIS_HOST !== undefined) { + const config: ClientOpts = { + host: REDIS_HOST, + port: REDIS_PORT, + }; + + if (REDIS_PASSWORD) { + config.password = REDIS_PASSWORD; + } + + redisClient = createClient(config); + + redisClient.on("error", (err) => { + console.error("Error connecting to Redis:", err); + }); +} + +export { redisClient }; diff --git a/back/src/Services/Repository/RedisVariablesRepository.ts b/back/src/Services/Repository/RedisVariablesRepository.ts new file mode 100644 index 00000000..f59e37ab --- /dev/null +++ b/back/src/Services/Repository/RedisVariablesRepository.ts @@ -0,0 +1,40 @@ +import { promisify } from "util"; +import { RedisClient } from "redis"; +import { VariablesRepositoryInterface } from "./VariablesRepositoryInterface"; + +/** + * Class in charge of saving/loading variables from the data store + */ +export class RedisVariablesRepository implements VariablesRepositoryInterface { + private readonly hgetall: OmitThisParameter<(arg1: string) => Promise<{ [p: string]: string }>>; + private readonly hset: OmitThisParameter<(arg1: [string, ...string[]]) => Promise>; + + constructor(redisClient: RedisClient) { + // @eslint-disable-next-line @typescript-eslint/unbound-method + this.hgetall = promisify(redisClient.hgetall).bind(redisClient); + // @eslint-disable-next-line @typescript-eslint/unbound-method + this.hset = promisify(redisClient.hset).bind(redisClient); + } + + /** + * Load all variables for a room. + * + * Note: in Redis, variables are stored in a hashmap and the key is the roomUrl + */ + async loadVariables(roomUrl: string): Promise<{ [key: string]: string }> { + return this.hgetall(roomUrl); + } + + async saveVariable(roomUrl: string, key: string, value: string): Promise { + // TODO: handle the case for "undefined" + // TODO: handle the case for "undefined" + // TODO: handle the case for "undefined" + // TODO: handle the case for "undefined" + // TODO: handle the case for "undefined" + + // TODO: SLOW WRITING EVERY 2 SECONDS WITH A TIMEOUT + + // @ts-ignore See https://stackoverflow.com/questions/63539317/how-do-i-use-hmset-with-node-promisify + return this.hset(roomUrl, key, value); + } +} diff --git a/back/src/Services/Repository/VariablesRepository.ts b/back/src/Services/Repository/VariablesRepository.ts new file mode 100644 index 00000000..9f668bcf --- /dev/null +++ b/back/src/Services/Repository/VariablesRepository.ts @@ -0,0 +1,14 @@ +import { RedisVariablesRepository } from "./RedisVariablesRepository"; +import { redisClient } from "../RedisClient"; +import { VoidVariablesRepository } from "./VoidVariablesRepository"; +import { VariablesRepositoryInterface } from "./VariablesRepositoryInterface"; + +let variablesRepository: VariablesRepositoryInterface; +if (!redisClient) { + console.warn("WARNING: Redis isnot configured. No variables will be persisted."); + variablesRepository = new VoidVariablesRepository(); +} else { + variablesRepository = new RedisVariablesRepository(redisClient); +} + +export { variablesRepository }; diff --git a/back/src/Services/Repository/VariablesRepositoryInterface.ts b/back/src/Services/Repository/VariablesRepositoryInterface.ts new file mode 100644 index 00000000..d927f5ff --- /dev/null +++ b/back/src/Services/Repository/VariablesRepositoryInterface.ts @@ -0,0 +1,10 @@ +export interface VariablesRepositoryInterface { + /** + * Load all variables for a room. + * + * Note: in Redis, variables are stored in a hashmap and the key is the roomUrl + */ + loadVariables(roomUrl: string): Promise<{ [key: string]: string }>; + + saveVariable(roomUrl: string, key: string, value: string): Promise; +} diff --git a/back/src/Services/Repository/VoidVariablesRepository.ts b/back/src/Services/Repository/VoidVariablesRepository.ts new file mode 100644 index 00000000..0a2664e8 --- /dev/null +++ b/back/src/Services/Repository/VoidVariablesRepository.ts @@ -0,0 +1,14 @@ +import { VariablesRepositoryInterface } from "./VariablesRepositoryInterface"; + +/** + * Mock class in charge of NOT saving/loading variables from the data store + */ +export class VoidVariablesRepository implements VariablesRepositoryInterface { + loadVariables(roomUrl: string): Promise<{ [key: string]: string }> { + return Promise.resolve({}); + } + + saveVariable(roomUrl: string, key: string, value: string): Promise { + return Promise.resolve(0); + } +} diff --git a/back/src/Services/SocketManager.ts b/back/src/Services/SocketManager.ts index 35494b2c..4f02b6ca 100644 --- a/back/src/Services/SocketManager.ts +++ b/back/src/Services/SocketManager.ts @@ -305,13 +305,15 @@ export class SocketManager { this.onClientLeave(thing, newZone, listener), (emoteEventMessage: EmoteEventMessage, listener: ZoneSocket) => this.onEmote(emoteEventMessage, listener) - ).then((gameRoom) => { - gaugeManager.incNbRoomGauge(); - resolve(gameRoom); - }).catch((e) => { - this.roomsPromises.delete(roomId); - reject(e); - }); + ) + .then((gameRoom) => { + gaugeManager.incNbRoomGauge(); + resolve(gameRoom); + }) + .catch((e) => { + this.roomsPromises.delete(roomId); + reject(e); + }); }); this.roomsPromises.set(roomId, roomPromise); } diff --git a/back/src/Services/VariablesManager.ts b/back/src/Services/VariablesManager.ts index 36acd9e4..a900894c 100644 --- a/back/src/Services/VariablesManager.ts +++ b/back/src/Services/VariablesManager.ts @@ -1,14 +1,16 @@ /** * Handles variables shared between the scripting API and the server. */ -import {ITiledMap, ITiledMapObject, ITiledMapObjectLayer} from "@workadventure/tiled-map-type-guard/dist"; -import {User} from "_Model/User"; +import { ITiledMap, ITiledMapObject, ITiledMapObjectLayer } from "@workadventure/tiled-map-type-guard/dist"; +import { User } from "_Model/User"; +import { variablesRepository } from "./Repository/VariablesRepository"; +import { redisClient } from "./RedisClient"; interface Variable { - defaultValue?: string, - persist?: boolean, - readableBy?: string, - writableBy?: string, + defaultValue?: string; + persist?: boolean; + readableBy?: string; + writableBy?: string; } export class VariablesManager { @@ -25,7 +27,7 @@ export class VariablesManager { /** * @param map The map can be "null" if it is hosted on a private network. In this case, we assume this is a test setup and bypass any server-side checks. */ - constructor(private map: ITiledMap | null) { + constructor(private roomUrl: string, private map: ITiledMap | null) { // We initialize the list of variable object at room start. The objects cannot be edited later // (otherwise, this would cause a security issue if the scripting API can edit this list of objects) if (map) { @@ -40,14 +42,43 @@ export class VariablesManager { } } + /** + * Let's load data from the Redis backend. + */ + public async init(): Promise { + if (!this.shouldPersist()) { + return; + } + const variables = await variablesRepository.loadVariables(this.roomUrl); + console.error("LIST OF VARIABLES FETCHED", variables); + for (const key in variables) { + this._variables.set(key, variables[key]); + } + } + + /** + * Returns true if saving should be enabled, and false otherwise. + * + * Saving is enabled if REDIS_HOST is set + * unless we are editing a local map + * unless we are in dev mode in which case it is ok to save + * + * @private + */ + private shouldPersist(): boolean { + return redisClient !== null && (this.map !== null || process.env.NODE_ENV === "development"); + } + private static findVariablesInMap(map: ITiledMap): Map { const objects = new Map(); for (const layer of map.layers) { - if (layer.type === 'objectgroup') { + if (layer.type === "objectgroup") { for (const object of (layer as ITiledMapObjectLayer).objects) { - if (object.type === 'variable') { + if (object.type === "variable") { if (object.template) { - console.warn('Warning, a variable object is using a Tiled "template". WorkAdventure does not support objects generated from Tiled templates.') + console.warn( + 'Warning, a variable object is using a Tiled "template". WorkAdventure does not support objects generated from Tiled templates.' + ); continue; } @@ -67,26 +98,30 @@ export class VariablesManager { for (const property of object.properties) { const value = property.value; switch (property.name) { - case 'default': + case "default": variable.defaultValue = JSON.stringify(value); break; - case 'persist': - if (typeof value !== 'boolean') { + case "persist": + if (typeof value !== "boolean") { throw new Error('The persist property of variable "' + object.name + '" must be a boolean'); } variable.persist = value; break; - case 'writableBy': - if (typeof value !== 'string') { - throw new Error('The writableBy property of variable "' + object.name + '" must be a string'); + case "writableBy": + if (typeof value !== "string") { + throw new Error( + 'The writableBy property of variable "' + object.name + '" must be a string' + ); } if (value) { variable.writableBy = value; } break; - case 'readableBy': - if (typeof value !== 'string') { - throw new Error('The readableBy property of variable "' + object.name + '" must be a string'); + case "readableBy": + if (typeof value !== "string") { + throw new Error( + 'The readableBy property of variable "' + object.name + '" must be a string' + ); } if (value) { variable.readableBy = value; @@ -107,14 +142,27 @@ export class VariablesManager { throw new Error('Trying to set a variable "' + name + '" that is not defined as an object in the map.'); } - if (variableObject.writableBy && user.tags.indexOf(variableObject.writableBy) === -1) { - throw new Error('Trying to set a variable "' + name + '". User "' + user.name + '" does not have sufficient permission. Required tag: "' + variableObject.writableBy + '". User tags: ' + user.tags.join(', ') + "."); + if (variableObject.writableBy && !user.tags.includes(variableObject.writableBy)) { + throw new Error( + 'Trying to set a variable "' + + name + + '". User "' + + user.name + + '" does not have sufficient permission. Required tag: "' + + variableObject.writableBy + + '". User tags: ' + + user.tags.join(", ") + + "." + ); } readableBy = variableObject.readableBy; } this._variables.set(name, value); + variablesRepository + .saveVariable(this.roomUrl, name, value) + .catch((e) => console.error("Error while saving variable in Redis:", e)); return readableBy; } @@ -128,9 +176,9 @@ export class VariablesManager { for (const [key, value] of this._variables.entries()) { const variableObject = this.variableObjects.get(key); if (variableObject === undefined) { - throw new Error('Unexpected variable "'+key+'" found has no associated variableObject.'); + throw new Error('Unexpected variable "' + key + '" found has no associated variableObject.'); } - if (!variableObject.readableBy || tags.indexOf(variableObject.readableBy) !== -1) { + if (!variableObject.readableBy || tags.includes(variableObject.readableBy)) { readableVariables.set(key, value); } } diff --git a/back/tests/GameRoomTest.ts b/back/tests/GameRoomTest.ts index 6bdc6912..4b1b519a 100644 --- a/back/tests/GameRoomTest.ts +++ b/back/tests/GameRoomTest.ts @@ -37,7 +37,7 @@ function createJoinRoomMessage(uuid: string, x: number, y: number): JoinRoomMess const emote: EmoteCallback = (emoteEventMessage, listener): void => {} describe("GameRoom", () => { - it("should connect user1 and user2", () => { + it("should connect user1 and user2", async () => { let connectCalledNumber: number = 0; const connect: ConnectCallback = (user: User, group: Group): void => { connectCalledNumber++; @@ -47,8 +47,7 @@ describe("GameRoom", () => { } - const world = new GameRoom('_/global/test.json', connect, disconnect, 160, 160, () => {}, () => {}, () => {}, emote); - + const world = await GameRoom.create('https://play.workadventu.re/_/global/localhost/test.json', connect, disconnect, 160, 160, () => {}, () => {}, () => {}, emote); const user1 = world.join(createMockUserSocket(), createJoinRoomMessage('1', 100, 100)); @@ -67,7 +66,7 @@ describe("GameRoom", () => { expect(connectCalledNumber).toBe(2); }); - it("should connect 3 users", () => { + it("should connect 3 users", async () => { let connectCalled: boolean = false; const connect: ConnectCallback = (user: User, group: Group): void => { connectCalled = true; @@ -76,7 +75,7 @@ describe("GameRoom", () => { } - const world = new GameRoom('_/global/test.json', connect, disconnect, 160, 160, () => {}, () => {}, () => {}, emote); + const world = await GameRoom.create('https://play.workadventu.re/_/global/localhost/test.json', connect, disconnect, 160, 160, () => {}, () => {}, () => {}, emote); const user1 = world.join(createMockUserSocket(), createJoinRoomMessage('1', 100, 100)); @@ -95,7 +94,7 @@ describe("GameRoom", () => { expect(connectCalled).toBe(true); }); - it("should disconnect user1 and user2", () => { + it("should disconnect user1 and user2", async () => { let connectCalled: boolean = false; let disconnectCallNumber: number = 0; const connect: ConnectCallback = (user: User, group: Group): void => { @@ -105,7 +104,7 @@ describe("GameRoom", () => { disconnectCallNumber++; } - const world = new GameRoom('_/global/test.json', connect, disconnect, 160, 160, () => {}, () => {}, () => {}, emote); + const world = await GameRoom.create('https://play.workadventu.re/_/global/localhost/test.json', connect, disconnect, 160, 160, () => {}, () => {}, () => {}, emote); const user1 = world.join(createMockUserSocket(), createJoinRoomMessage('1', 100, 100)); diff --git a/back/yarn.lock b/back/yarn.lock index 54833963..98d675ee 100644 --- a/back/yarn.lock +++ b/back/yarn.lock @@ -122,6 +122,13 @@ resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== +"@types/redis@^2.8.31": + version "2.8.31" + resolved "https://registry.yarnpkg.com/@types/redis/-/redis-2.8.31.tgz#c11c1b269fec132ac2ec9eb891edf72fc549149e" + integrity sha512-daWrrTDYaa5iSDFbgzZ9gOOzyp2AJmYK59OlG/2KGBgYWF3lfs8GDKm1c//tik5Uc93hDD36O+qLPvzDolChbA== + dependencies: + "@types/node" "*" + "@types/strip-bom@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@types/strip-bom/-/strip-bom-3.0.0.tgz#14a8ec3956c2e81edb7520790aecf21c290aebd2" @@ -804,6 +811,11 @@ delegates@^1.0.0: resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= +denque@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/denque/-/denque-1.5.0.tgz#773de0686ff2d8ec2ff92914316a47b73b1c73de" + integrity sha512-CYiCSgIF1p6EUByQPlGkKnP1M9g0ZV3qMIrqMqZqdwazygIA/YP2vrbcyl1h/WppKJTdl1F85cXIle+394iDAQ== + detect-libc@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" @@ -2441,6 +2453,33 @@ redent@^1.0.0: indent-string "^2.1.0" strip-indent "^1.0.1" +redis-commands@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/redis-commands/-/redis-commands-1.7.0.tgz#15a6fea2d58281e27b1cd1acfb4b293e278c3a89" + integrity sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ== + +redis-errors@^1.0.0, redis-errors@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/redis-errors/-/redis-errors-1.2.0.tgz#eb62d2adb15e4eaf4610c04afe1529384250abad" + integrity sha1-62LSrbFeTq9GEMBK/hUpOEJQq60= + +redis-parser@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-3.0.0.tgz#b66d828cdcafe6b4b8a428a7def4c6bcac31c8b4" + integrity sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ= + dependencies: + redis-errors "^1.0.0" + +redis@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/redis/-/redis-3.1.2.tgz#766851117e80653d23e0ed536254677ab647638c" + integrity sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw== + dependencies: + denque "^1.5.0" + redis-commands "^1.7.0" + redis-errors "^1.2.0" + redis-parser "^3.0.0" + regex-not@^1.0.0, regex-not@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c" diff --git a/deeployer.libsonnet b/deeployer.libsonnet index 8d9c2bfd..494c72b8 100644 --- a/deeployer.libsonnet +++ b/deeployer.libsonnet @@ -22,6 +22,7 @@ "JITSI_URL": env.JITSI_URL, "SECRET_JITSI_KEY": env.SECRET_JITSI_KEY, "TURN_STATIC_AUTH_SECRET": env.TURN_STATIC_AUTH_SECRET, + "REDIS_HOST": "redis", } + (if adminUrl != null then { "ADMIN_API_URL": adminUrl, } else {}) @@ -40,6 +41,7 @@ "JITSI_URL": env.JITSI_URL, "SECRET_JITSI_KEY": env.SECRET_JITSI_KEY, "TURN_STATIC_AUTH_SECRET": env.TURN_STATIC_AUTH_SECRET, + "REDIS_HOST": "redis", } + (if adminUrl != null then { "ADMIN_API_URL": adminUrl, } else {}) @@ -97,6 +99,9 @@ }, "ports": [80] }, + "redis": { + "image": "redis:6", + } }, "config": { k8sextension(k8sConf):: diff --git a/docker-compose.single-domain.yaml b/docker-compose.single-domain.yaml index 345ccf8d..b2e9b7c8 100644 --- a/docker-compose.single-domain.yaml +++ b/docker-compose.single-domain.yaml @@ -120,6 +120,8 @@ services: JITSI_URL: $JITSI_URL JITSI_ISS: $JITSI_ISS MAX_PER_GROUP: "$MAX_PER_GROUP" + REDIS_HOST: redis + NODE_ENV: development volumes: - ./back:/usr/src/app labels: @@ -168,6 +170,9 @@ services: - ./front:/usr/src/front - ./pusher:/usr/src/pusher + redis: + image: redis:6 + # coturn: # image: coturn/coturn:4.5.2 # command: diff --git a/docker-compose.yaml b/docker-compose.yaml index 1c1bcb8f..d0254d21 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -115,6 +115,8 @@ services: JITSI_ISS: $JITSI_ISS TURN_STATIC_AUTH_SECRET: SomeStaticAuthSecret MAX_PER_GROUP: "MAX_PER_GROUP" + REDIS_HOST: redis + NODE_ENV: development volumes: - ./back:/usr/src/app labels: @@ -157,6 +159,20 @@ services: - ./front:/usr/src/front - ./pusher:/usr/src/pusher + redis: + image: redis:6 + + redisinsight: + image: redislabs/redisinsight:latest + labels: + - "traefik.http.routers.redisinsight.rule=Host(`redis.workadventure.localhost`)" + - "traefik.http.routers.redisinsight.entryPoints=web" + - "traefik.http.services.redisinsight.loadbalancer.server.port=8001" + - "traefik.http.routers.redisinsight-ssl.rule=Host(`redis.workadventure.localhost`)" + - "traefik.http.routers.redisinsight-ssl.entryPoints=websecure" + - "traefik.http.routers.redisinsight-ssl.tls=true" + - "traefik.http.routers.redisinsight-ssl.service=redisinsight" + # coturn: # image: coturn/coturn:4.5.2 # command: diff --git a/docs/maps/api-state.md b/docs/maps/api-state.md index 6b74389b..38352861 100644 --- a/docs/maps/api-state.md +++ b/docs/maps/api-state.md @@ -82,6 +82,8 @@ The object **type** MUST be **variable**. You can set a default value for the object in the `default` property. +#### Persisting variables state + Use the `persist` property to save the state of the variable in database. If `persist` is false, the variable will stay in the memory of the WorkAdventure servers but will be wiped out of the memory as soon as the room is empty (or if the server restarts). @@ -89,11 +91,13 @@ server restarts). {.alert.alert-info} Do not use `persist` for highly dynamic values that have a short life spawn. +#### Managing access rights to variables + With `readableBy` and `writableBy`, you control who can read of write in this variable. The property accepts a string representing a "tag". Anyone having this "tag" can read/write in the variable. {.alert.alert-warning} -`readableBy` and `writableBy` are specific to the public version of WorkAdventure because the notion of tags +`readableBy` and `writableBy` are specific to the "online" version of WorkAdventure because the notion of tags is not available unless you have an "admin" server (that is not part of the self-hosted version of WorkAdventure). Finally, the `jsonSchema` property can contain [a complete JSON schema](https://json-schema.org/) to validate the content of the variable. diff --git a/pusher/src/Model/PusherRoom.ts b/pusher/src/Model/PusherRoom.ts index d7cfffe4..89ed772a 100644 --- a/pusher/src/Model/PusherRoom.ts +++ b/pusher/src/Model/PusherRoom.ts @@ -7,7 +7,8 @@ import { apiClientRepository } from "../Services/ApiClientRepository"; import { BatchToPusherMessage, BatchToPusherRoomMessage, - EmoteEventMessage, ErrorMessage, + EmoteEventMessage, + ErrorMessage, GroupLeftZoneMessage, GroupUpdateZoneMessage, RoomMessage, @@ -15,7 +16,8 @@ import { UserJoinedZoneMessage, UserLeftZoneMessage, UserMovedMessage, - VariableMessage, VariableWithTagMessage, + VariableMessage, + VariableWithTagMessage, ZoneMessage, } from "../Messages/generated/messages_pb"; import Debug from "debug"; @@ -99,7 +101,7 @@ export class PusherRoom { // Let's dispatch this variable to all the listeners for (const listener of this.listeners) { const subMessage = new SubMessage(); - if (!readableBy || listener.tags.indexOf(readableBy) !== -1) { + if (!readableBy || listener.tags.includes(readableBy)) { subMessage.setVariablemessage(variableMessage); } listener.emitInBatch(subMessage); diff --git a/pusher/src/Model/Zone.ts b/pusher/src/Model/Zone.ts index d116bb79..d5a6058f 100644 --- a/pusher/src/Model/Zone.ts +++ b/pusher/src/Model/Zone.ts @@ -14,7 +14,8 @@ import { UserMovedMessage, ZoneMessage, EmoteEventMessage, - CompanionMessage, ErrorMessage, + CompanionMessage, + ErrorMessage, } from "../Messages/generated/messages_pb"; import { ClientReadableStream } from "grpc"; import { PositionDispatcher } from "_Model/PositionDispatcher"; diff --git a/pusher/src/Services/SocketManager.ts b/pusher/src/Services/SocketManager.ts index dfd9c15a..bd3e2cad 100644 --- a/pusher/src/Services/SocketManager.ts +++ b/pusher/src/Services/SocketManager.ts @@ -30,7 +30,8 @@ import { BanMessage, RefreshRoomMessage, EmotePromptMessage, - VariableMessage, ErrorMessage, + VariableMessage, + ErrorMessage, } from "../Messages/generated/messages_pb"; import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils"; import { ADMIN_API_URL, JITSI_ISS, SECRET_JITSI_KEY } from "../Enum/EnvironmentVariable"; From ac3d1240ae37dc43d5d3f6292acddec09d226edc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Mon, 19 Jul 2021 18:46:33 +0200 Subject: [PATCH 19/31] Setting a variable to undefined now removes it from server-side storage. --- .../Repository/RedisVariablesRepository.ts | 18 ++++++++++++------ front/src/Connexion/RoomConnection.ts | 12 ++++++++++-- maps/tests/Variables/shared_variables.html | 7 +++++++ 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/back/src/Services/Repository/RedisVariablesRepository.ts b/back/src/Services/Repository/RedisVariablesRepository.ts index f59e37ab..70ff447a 100644 --- a/back/src/Services/Repository/RedisVariablesRepository.ts +++ b/back/src/Services/Repository/RedisVariablesRepository.ts @@ -8,12 +8,16 @@ import { VariablesRepositoryInterface } from "./VariablesRepositoryInterface"; export class RedisVariablesRepository implements VariablesRepositoryInterface { private readonly hgetall: OmitThisParameter<(arg1: string) => Promise<{ [p: string]: string }>>; private readonly hset: OmitThisParameter<(arg1: [string, ...string[]]) => Promise>; + private readonly hdel: OmitThisParameter<(arg1: string, arg2: string) => Promise>; - constructor(redisClient: RedisClient) { + + constructor(private redisClient: RedisClient) { // @eslint-disable-next-line @typescript-eslint/unbound-method this.hgetall = promisify(redisClient.hgetall).bind(redisClient); // @eslint-disable-next-line @typescript-eslint/unbound-method this.hset = promisify(redisClient.hset).bind(redisClient); + // @eslint-disable-next-line @typescript-eslint/unbound-method + this.hdel = promisify(redisClient.hdel).bind(redisClient); } /** @@ -26,11 +30,13 @@ export class RedisVariablesRepository implements VariablesRepositoryInterface { } async saveVariable(roomUrl: string, key: string, value: string): Promise { - // TODO: handle the case for "undefined" - // TODO: handle the case for "undefined" - // TODO: handle the case for "undefined" - // TODO: handle the case for "undefined" - // TODO: handle the case for "undefined" + + // The value is passed to JSON.stringify client side. If value is "undefined", JSON.stringify returns "undefined" + // which is translated to empty string when fetching the value in the pusher. + // Therefore, empty string server side == undefined client side. + if (value === '') { + return this.hdel(roomUrl, key); + } // TODO: SLOW WRITING EVERY 2 SECONDS WITH A TIMEOUT diff --git a/front/src/Connexion/RoomConnection.ts b/front/src/Connexion/RoomConnection.ts index b23f9549..521a8473 100644 --- a/front/src/Connexion/RoomConnection.ts +++ b/front/src/Connexion/RoomConnection.ts @@ -189,7 +189,11 @@ export class RoomConnection implements RoomConnection { const variables = new Map(); for (const variable of roomJoinedMessage.getVariableList()) { - variables.set(variable.getName(), JSON.parse(variable.getValue())); + try { + variables.set(variable.getName(), JSON.parse(variable.getValue())); + } catch (e) { + console.error('Unable to unserialize value received from server for variable "'+variable.getName()+'". Value received: "'+variable.getValue()+'". Error: ', e); + } } this.userId = roomJoinedMessage.getCurrentuserid(); @@ -652,7 +656,11 @@ export class RoomConnection implements RoomConnection { const serializedValue = message.getValue(); let value: unknown = undefined; if (serializedValue) { - value = JSON.parse(serializedValue); + try { + value = JSON.parse(serializedValue); + } catch (e) { + console.error('Unable to unserialize value received from server for variable "'+name+'". Value received: "'+serializedValue+'". Error: ', e); + } } callback(name, value); }); diff --git a/maps/tests/Variables/shared_variables.html b/maps/tests/Variables/shared_variables.html index c0a586d8..21e0b998 100644 --- a/maps/tests/Variables/shared_variables.html +++ b/maps/tests/Variables/shared_variables.html @@ -28,6 +28,11 @@ console.log(WA.state.loadVariable('textField')); document.getElementById('placeholder').innerText = WA.state.loadVariable('textField'); }); + + document.getElementById('setUndefined').addEventListener('click', () => { + WA.state.textField = undefined; + document.getElementById('textField').value = ''; + }); }); }) @@ -35,6 +40,8 @@ + +
From bfd9ae324b42272a131f63f2cfe67870c25cc1f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Tue, 20 Jul 2021 09:19:44 +0200 Subject: [PATCH 20/31] Adding documentation about `onVariableChange` --- docs/maps/api-state.md | 43 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/docs/maps/api-state.md b/docs/maps/api-state.md index 38352861..87a8b3aa 100644 --- a/docs/maps/api-state.md +++ b/docs/maps/api-state.md @@ -1,7 +1,7 @@ {.section-title.accent.text-primary} # API state related functions Reference -### Saving / loading state +## Saving / loading state The `WA.state` functions allow you to easily share a common state between all the players in a given room. Moreover, `WA.state` functions can be used to persist this state across reloads. @@ -62,7 +62,7 @@ that you get the expected type). For security reasons, the list of variables you are allowed to access and modify is **restricted** (otherwise, anyone on your map could set any data). Variables storage is subject to an authorization process. Read below to learn more. -#### Declaring allowed keys +### Declaring allowed keys In order to declare allowed keys related to a room, you need to add **objects** in an "object layer" of the map. @@ -74,15 +74,12 @@ Each object will represent a variable. -TODO: move the image in https://workadventu.re/img/docs - - The name of the variable is the name of the object. The object **type** MUST be **variable**. You can set a default value for the object in the `default` property. -#### Persisting variables state +### Persisting variables state Use the `persist` property to save the state of the variable in database. If `persist` is false, the variable will stay in the memory of the WorkAdventure servers but will be wiped out of the memory as soon as the room is empty (or if the @@ -91,7 +88,7 @@ server restarts). {.alert.alert-info} Do not use `persist` for highly dynamic values that have a short life spawn. -#### Managing access rights to variables +### Managing access rights to variables With `readableBy` and `writableBy`, you control who can read of write in this variable. The property accepts a string representing a "tag". Anyone having this "tag" can read/write in the variable. @@ -104,6 +101,36 @@ Finally, the `jsonSchema` property can contain [a complete JSON schema](https:// Trying to set a variable to a value that is not compatible with the schema will fail. +## Tracking variables changes +The properties of the `WA.state` object are shared in real-time between users of a same room. You can listen to modifications +of any property of `WA.state` by using the `WA.state.onVariableChange()` method. -TODO: document tracking, unsubscriber, etc... +``` +WA.state.onVariableChange(name: string): Observable +``` + +Usage: + +```javascript +WA.state.onVariableChange('config').subscribe((value) => { + console.log('Variable "config" changed. New value: ', value); +}); +``` + +The `WA.state.onVariableChange` method returns an [RxJS `Observable` object](https://rxjs.dev/guide/observable). This is +an object on which you can add subscriptions using the `subscribe` method. + +### Stopping tracking variables + +If you want to stop tracking a variable change, the `subscribe` method returns a subscription object with an `unsubscribe` method. + +**Example with unsubscription:** + +```javascript +const subscription = WA.state.onVariableChange('config').subscribe((value) => { + console.log('Variable "config" changed. New value: ', value); +}); +// Later: +subscription.unsubscribe(); +``` From fe59b4512b9f02ff9e55b0794b56544b2e18ca62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Tue, 20 Jul 2021 09:30:45 +0200 Subject: [PATCH 21/31] Fixing CI --- back/src/Services/Repository/RedisVariablesRepository.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/back/src/Services/Repository/RedisVariablesRepository.ts b/back/src/Services/Repository/RedisVariablesRepository.ts index 70ff447a..95d757ca 100644 --- a/back/src/Services/Repository/RedisVariablesRepository.ts +++ b/back/src/Services/Repository/RedisVariablesRepository.ts @@ -10,14 +10,12 @@ export class RedisVariablesRepository implements VariablesRepositoryInterface { private readonly hset: OmitThisParameter<(arg1: [string, ...string[]]) => Promise>; private readonly hdel: OmitThisParameter<(arg1: string, arg2: string) => Promise>; - constructor(private redisClient: RedisClient) { - // @eslint-disable-next-line @typescript-eslint/unbound-method + /* eslint-disable @typescript-eslint/unbound-method */ this.hgetall = promisify(redisClient.hgetall).bind(redisClient); - // @eslint-disable-next-line @typescript-eslint/unbound-method this.hset = promisify(redisClient.hset).bind(redisClient); - // @eslint-disable-next-line @typescript-eslint/unbound-method this.hdel = promisify(redisClient.hdel).bind(redisClient); + /* eslint-enable @typescript-eslint/unbound-method */ } /** @@ -30,11 +28,10 @@ export class RedisVariablesRepository implements VariablesRepositoryInterface { } async saveVariable(roomUrl: string, key: string, value: string): Promise { - // The value is passed to JSON.stringify client side. If value is "undefined", JSON.stringify returns "undefined" // which is translated to empty string when fetching the value in the pusher. // Therefore, empty string server side == undefined client side. - if (value === '') { + if (value === "") { return this.hdel(roomUrl, key); } From aa19e8a7cdc3c50cdb9fcc4d7781cfbcb6acfc4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Wed, 21 Jul 2021 16:29:38 +0200 Subject: [PATCH 22/31] Adding a warning when editing a map locally. --- back/src/Model/GameRoom.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/back/src/Model/GameRoom.ts b/back/src/Model/GameRoom.ts index 2e30bf52..3f355f49 100644 --- a/back/src/Model/GameRoom.ts +++ b/back/src/Model/GameRoom.ts @@ -9,6 +9,7 @@ import { BatchToPusherMessage, BatchToPusherRoomMessage, EmoteEventMessage, + ErrorMessage, JoinRoomMessage, SubToPusherRoomMessage, VariableMessage, @@ -24,6 +25,7 @@ import { mapFetcher } from "../Services/MapFetcher"; import { VariablesManager } from "../Services/VariablesManager"; import { ADMIN_API_URL } from "../Enum/EnvironmentVariable"; import { LocalUrlError } from "../Services/LocalUrlError"; +import { emitErrorOnRoomSocket } from "../Services/MessageHelpers"; export type ConnectCallback = (user: User, group: Group) => void; export type DisconnectCallback = (user: User, group: Group) => void; @@ -452,10 +454,16 @@ export class GameRoom { // If we are trying to load a local URL, we are probably in test mode. // In this case, let's bypass the server-side checks completely. - // FIXME: find a way to send a warning to the client side - // FIXME: find a way to send a warning to the client side - // FIXME: find a way to send a warning to the client side - // FIXME: find a way to send a warning to the client side + // Note: we run this message inside a setTimeout so that the room listeners can have time to connect. + setTimeout(() => { + for (const roomListener of this.roomListeners) { + emitErrorOnRoomSocket( + roomListener, + "You are loading a local map. If you use the scripting API in this map, please be aware that server-side checks and variable persistence is disabled." + ); + } + }, 1000); + const variablesManager = new VariablesManager(this.roomUrl, null); variablesManager .init() From 181545e6b7b6104fbe5e74636459b0c8857dd877 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Wed, 21 Jul 2021 16:33:56 +0200 Subject: [PATCH 23/31] Removing dead code --- back/src/Services/SocketManager.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/back/src/Services/SocketManager.ts b/back/src/Services/SocketManager.ts index 4f02b6ca..1440df39 100644 --- a/back/src/Services/SocketManager.ts +++ b/back/src/Services/SocketManager.ts @@ -687,24 +687,9 @@ export class SocketManager { } room.addRoomListener(call); - //const things = room.addZoneListener(call, x, y); const batchMessage = new BatchToPusherRoomMessage(); - // Finally, no need to store variables in the pusher, let's only make it act as a relay - /*const variables = await room.getVariables(); - - for (const [name, value] of variables.entries()) { - const variableMessage = new VariableMessage(); - variableMessage.setName(name); - variableMessage.setValue(value); - - const subMessage = new SubToPusherRoomMessage(); - subMessage.setVariablemessage(variableMessage); - - batchMessage.addPayload(subMessage); - }*/ - call.write(batchMessage); } From 080d495044e2312779836961b93465c81990d210 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Wed, 21 Jul 2021 16:40:53 +0200 Subject: [PATCH 24/31] Renaming `WA.room.getMap` to `WA.room.getTiledMap` --- docs/maps/api-room.md | 6 +++--- front/src/Api/iframe/room.ts | 2 +- maps/tests/Metadata/getCurrentRoom.js | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/maps/api-room.md b/docs/maps/api-room.md index 69d40df9..ca708b29 100644 --- a/docs/maps/api-room.md +++ b/docs/maps/api-room.md @@ -93,7 +93,7 @@ You need to wait for the end of the initialization before accessing `WA.room.id` ```typescript WA.onInit().then(() => { console.log('Room id: ', WA.room.id); - // Will output something like: '/@/myorg/myworld/myroom', or '/_/global/mymap.org/map.json" + // Will output something like: 'https://play.workadventu.re/@/myorg/myworld/myroom', or 'https://play.workadventu.re/_/global/mymap.org/map.json" }) ``` @@ -119,13 +119,13 @@ WA.onInit().then(() => { ### Getting map data ``` -WA.room.getMap(): Promise +WA.room.getTiledMap(): Promise ``` Returns a promise that resolves to the JSON map file. ```javascript -const map = await WA.room.getMap(); +const map = await WA.room.getTiledMap(); console.log("Map generated with Tiled version ", map.tiledversion); ``` diff --git a/front/src/Api/iframe/room.ts b/front/src/Api/iframe/room.ts index bb381601..b5b5c0dd 100644 --- a/front/src/Api/iframe/room.ts +++ b/front/src/Api/iframe/room.ts @@ -79,7 +79,7 @@ export class WorkadventureRoomCommands extends IframeApiContribution { + async getTiledMap(): Promise { const event = await queryWorkadventure({ type: "getMapData", data: undefined }); return event.data as ITiledMap; } diff --git a/maps/tests/Metadata/getCurrentRoom.js b/maps/tests/Metadata/getCurrentRoom.js index 8e90a4ae..df3a995c 100644 --- a/maps/tests/Metadata/getCurrentRoom.js +++ b/maps/tests/Metadata/getCurrentRoom.js @@ -6,6 +6,6 @@ WA.onInit().then(() => { console.log('Player tags: ', WA.player.tags); }); -WA.room.getMap().then((data) => { +WA.room.getTiledMap().then((data) => { console.log('Map data', data); }) From 3cfb74be54c5e105b3b1ef27322a598eddbe5715 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Wed, 21 Jul 2021 16:55:34 +0200 Subject: [PATCH 25/31] Removing useless console log --- back/src/Services/VariablesManager.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/back/src/Services/VariablesManager.ts b/back/src/Services/VariablesManager.ts index a900894c..20b13f5f 100644 --- a/back/src/Services/VariablesManager.ts +++ b/back/src/Services/VariablesManager.ts @@ -50,7 +50,6 @@ export class VariablesManager { return; } const variables = await variablesRepository.loadVariables(this.roomUrl); - console.error("LIST OF VARIABLES FETCHED", variables); for (const key in variables) { this._variables.set(key, variables[key]); } From 1bb6d893e0cf0a8b0155a427fc06d799752d4180 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Wed, 21 Jul 2021 18:21:12 +0200 Subject: [PATCH 26/31] Simplifying promises --- back/src/Model/GameRoom.ts | 64 ++++++++++----------------- back/src/Services/VariablesManager.ts | 5 ++- 2 files changed, 27 insertions(+), 42 deletions(-) diff --git a/back/src/Model/GameRoom.ts b/back/src/Model/GameRoom.ts index 3f355f49..2892a7bd 100644 --- a/back/src/Model/GameRoom.ts +++ b/back/src/Model/GameRoom.ts @@ -436,48 +436,32 @@ export class GameRoom { private getVariableManager(): Promise { if (!this.variableManagerPromise) { - this.variableManagerPromise = new Promise((resolve, reject) => { - this.getMap() - .then((map) => { - const variablesManager = new VariablesManager(this.roomUrl, map); - variablesManager - .init() - .then(() => { - resolve(variablesManager); - }) - .catch((e) => { - reject(e); - }); - }) - .catch((e) => { - if (e instanceof LocalUrlError) { - // If we are trying to load a local URL, we are probably in test mode. - // In this case, let's bypass the server-side checks completely. + this.variableManagerPromise = this.getMap() + .then((map) => { + const variablesManager = new VariablesManager(this.roomUrl, map); + return variablesManager.init(); + }) + .catch((e) => { + if (e instanceof LocalUrlError) { + // If we are trying to load a local URL, we are probably in test mode. + // In this case, let's bypass the server-side checks completely. - // Note: we run this message inside a setTimeout so that the room listeners can have time to connect. - setTimeout(() => { - for (const roomListener of this.roomListeners) { - emitErrorOnRoomSocket( - roomListener, - "You are loading a local map. If you use the scripting API in this map, please be aware that server-side checks and variable persistence is disabled." - ); - } - }, 1000); + // Note: we run this message inside a setTimeout so that the room listeners can have time to connect. + setTimeout(() => { + for (const roomListener of this.roomListeners) { + emitErrorOnRoomSocket( + roomListener, + "You are loading a local map. If you use the scripting API in this map, please be aware that server-side checks and variable persistence is disabled." + ); + } + }, 1000); - const variablesManager = new VariablesManager(this.roomUrl, null); - variablesManager - .init() - .then(() => { - resolve(variablesManager); - }) - .catch((e) => { - reject(e); - }); - } else { - reject(e); - } - }); - }); + const variablesManager = new VariablesManager(this.roomUrl, null); + return variablesManager.init(); + } else { + throw e; + } + }); } return this.variableManagerPromise; } diff --git a/back/src/Services/VariablesManager.ts b/back/src/Services/VariablesManager.ts index 20b13f5f..5137a32d 100644 --- a/back/src/Services/VariablesManager.ts +++ b/back/src/Services/VariablesManager.ts @@ -45,14 +45,15 @@ export class VariablesManager { /** * Let's load data from the Redis backend. */ - public async init(): Promise { + public async init(): Promise { if (!this.shouldPersist()) { - return; + return this; } const variables = await variablesRepository.loadVariables(this.roomUrl); for (const key in variables) { this._variables.set(key, variables[key]); } + return this; } /** From 1435ec89c95825fd6af6506bd2de37e3d35c68eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Wed, 21 Jul 2021 18:42:20 +0200 Subject: [PATCH 27/31] Adding unit test and fixing an issue with DNS solving --- back/src/Services/MapFetcher.ts | 6 ++++-- back/tests/MapFetcherTest.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 back/tests/MapFetcherTest.ts diff --git a/back/src/Services/MapFetcher.ts b/back/src/Services/MapFetcher.ts index 99465ac4..0a8cb4bd 100644 --- a/back/src/Services/MapFetcher.ts +++ b/back/src/Services/MapFetcher.ts @@ -36,8 +36,10 @@ class MapFetcher { /** * Returns true if the domain name is localhost of *.localhost * Returns true if the domain name resolves to an IP address that is "private" (like 10.x.x.x or 192.168.x.x) + * + * @private */ - private async isLocalUrl(url: string): Promise { + async isLocalUrl(url: string): Promise { const urlObj = new URL(url); if (urlObj.hostname === "localhost" || urlObj.hostname.endsWith(".localhost")) { return true; @@ -46,7 +48,7 @@ class MapFetcher { let addresses = []; if (!ipaddr.isValid(urlObj.hostname)) { const resolver = new Resolver(); - addresses = await promisify(resolver.resolve)(urlObj.hostname); + addresses = await promisify(resolver.resolve).bind(resolver)(urlObj.hostname); } else { addresses = [urlObj.hostname]; } diff --git a/back/tests/MapFetcherTest.ts b/back/tests/MapFetcherTest.ts new file mode 100644 index 00000000..3b47e73b --- /dev/null +++ b/back/tests/MapFetcherTest.ts @@ -0,0 +1,26 @@ +import { arrayIntersect } from "../src/Services/ArrayHelper"; +import { mapFetcher } from "../src/Services/MapFetcher"; + +describe("MapFetcher", () => { + it("should return true on localhost ending URLs", async () => { + expect(await mapFetcher.isLocalUrl("https://localhost")).toBeTrue(); + expect(await mapFetcher.isLocalUrl("https://foo.localhost")).toBeTrue(); + }); + + it("should return true on DNS resolving to a local domain", async () => { + expect(await mapFetcher.isLocalUrl("https://127.0.0.1.nip.io")).toBeTrue(); + }); + + it("should return true on an IP resolving to a local domain", async () => { + expect(await mapFetcher.isLocalUrl("https://127.0.0.1")).toBeTrue(); + expect(await mapFetcher.isLocalUrl("https://192.168.0.1")).toBeTrue(); + }); + + it("should return false on an IP resolving to a global domain", async () => { + expect(await mapFetcher.isLocalUrl("https://51.12.42.42")).toBeFalse(); + }); + + it("should return false on an DNS resolving to a global domain", async () => { + expect(await mapFetcher.isLocalUrl("https://maps.workadventu.re")).toBeFalse(); + }); +}); From ddabda1c4baf8b97a7afd20fedc5bb393195fa71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Wed, 21 Jul 2021 18:49:25 +0200 Subject: [PATCH 28/31] Adding error case in test --- back/tests/MapFetcherTest.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/back/tests/MapFetcherTest.ts b/back/tests/MapFetcherTest.ts index 3b47e73b..1e7ca447 100644 --- a/back/tests/MapFetcherTest.ts +++ b/back/tests/MapFetcherTest.ts @@ -23,4 +23,10 @@ describe("MapFetcher", () => { it("should return false on an DNS resolving to a global domain", async () => { expect(await mapFetcher.isLocalUrl("https://maps.workadventu.re")).toBeFalse(); }); + + it("should throw error on invalid domain", async () => { + await expectAsync( + mapFetcher.isLocalUrl("https://this.domain.name.doesnotexistfoobgjkgfdjkgldf.com") + ).toBeRejected(); + }); }); From 6d4c2cfd39f2077df5b96de4d8fc531966e1c102 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Thu, 22 Jul 2021 10:33:07 +0200 Subject: [PATCH 29/31] Simplifying error handling --- back/src/RoomManager.ts | 9 ++- back/src/Services/SocketManager.ts | 91 +++++++++++------------------- 2 files changed, 39 insertions(+), 61 deletions(-) diff --git a/back/src/RoomManager.ts b/back/src/RoomManager.ts index 6a879202..7eaf4b01 100644 --- a/back/src/RoomManager.ts +++ b/back/src/RoomManager.ts @@ -45,7 +45,7 @@ const roomManager: IRoomManagerServer = { let room: GameRoom | null = null; let user: User | null = null; - call.on("data", (message: PusherToBackMessage) => { + call.on("data", async (message: PusherToBackMessage) => { try { if (room === null || user === null) { if (message.hasJoinroommessage()) { @@ -78,7 +78,11 @@ const roomManager: IRoomManagerServer = { } else if (message.hasItemeventmessage()) { socketManager.handleItemEvent(room, user, message.getItemeventmessage() as ItemEventMessage); } else if (message.hasVariablemessage()) { - socketManager.handleVariableEvent(room, user, message.getVariablemessage() as VariableMessage); + await socketManager.handleVariableEvent( + room, + user, + message.getVariablemessage() as VariableMessage + ); } else if (message.hasWebrtcsignaltoservermessage()) { socketManager.emitVideo( room, @@ -119,6 +123,7 @@ const roomManager: IRoomManagerServer = { } } } catch (e) { + console.error(e); emitError(call, e); call.end(); } diff --git a/back/src/Services/SocketManager.ts b/back/src/Services/SocketManager.ts index 1440df39..43af48e4 100644 --- a/back/src/Services/SocketManager.ts +++ b/back/src/Services/SocketManager.ts @@ -129,30 +129,25 @@ export class SocketManager { } handleUserMovesMessage(room: GameRoom, user: User, userMovesMessage: UserMovesMessage) { - try { - const userMoves = userMovesMessage.toObject(); - const position = userMovesMessage.getPosition(); + const userMoves = userMovesMessage.toObject(); + const position = userMovesMessage.getPosition(); - // If CPU is high, let's drop messages of users moving (we will only dispatch the final position) - if (cpuTracker.isOverHeating() && userMoves.position?.moving === true) { - return; - } - - if (position === undefined) { - throw new Error("Position not found in message"); - } - const viewport = userMoves.viewport; - if (viewport === undefined) { - throw new Error("Viewport not found in message"); - } - - // update position in the world - room.updatePosition(user, ProtobufUtils.toPointInterface(position)); - //room.setViewport(client, client.viewport); - } catch (e) { - console.error('An error occurred on "user_position" event'); - console.error(e); + // If CPU is high, let's drop messages of users moving (we will only dispatch the final position) + if (cpuTracker.isOverHeating() && userMoves.position?.moving === true) { + return; } + + if (position === undefined) { + throw new Error("Position not found in message"); + } + const viewport = userMoves.viewport; + if (viewport === undefined) { + throw new Error("Viewport not found in message"); + } + + // update position in the world + room.updatePosition(user, ProtobufUtils.toPointInterface(position)); + //room.setViewport(client, client.viewport); } // Useless now, will be useful again if we allow editing details in game @@ -171,43 +166,26 @@ export class SocketManager { }*/ handleSilentMessage(room: GameRoom, user: User, silentMessage: SilentMessage) { - try { - room.setSilent(user, silentMessage.getSilent()); - } catch (e) { - console.error('An error occurred on "handleSilentMessage"'); - console.error(e); - } + room.setSilent(user, silentMessage.getSilent()); } handleItemEvent(room: GameRoom, user: User, itemEventMessage: ItemEventMessage) { const itemEvent = ProtobufUtils.toItemEvent(itemEventMessage); - try { - const subMessage = new SubMessage(); - subMessage.setItemeventmessage(itemEventMessage); + const subMessage = new SubMessage(); + subMessage.setItemeventmessage(itemEventMessage); - // Let's send the event without using the SocketIO room. - // TODO: move this in the GameRoom class. - for (const user of room.getUsers().values()) { - user.emitInBatch(subMessage); - } - - room.setItemState(itemEvent.itemId, itemEvent.state); - } catch (e) { - console.error('An error occurred on "item_event"'); - console.error(e); + // Let's send the event without using the SocketIO room. + // TODO: move this in the GameRoom class. + for (const user of room.getUsers().values()) { + user.emitInBatch(subMessage); } + + room.setItemState(itemEvent.itemId, itemEvent.state); } - handleVariableEvent(room: GameRoom, user: User, variableMessage: VariableMessage) { - (async () => { - try { - await room.setVariable(variableMessage.getName(), variableMessage.getValue(), user); - } catch (e) { - console.error('An error occurred on "handleVariableEvent"'); - console.error(e); - } - })(); + handleVariableEvent(room: GameRoom, user: User, variableMessage: VariableMessage): Promise { + return room.setVariable(variableMessage.getName(), variableMessage.getValue(), user); } emitVideo(room: GameRoom, user: User, data: WebRtcSignalToServerMessage): void { @@ -543,16 +521,11 @@ export class SocketManager { } emitPlayGlobalMessage(room: GameRoom, playGlobalMessage: PlayGlobalMessage) { - try { - const serverToClientMessage = new ServerToClientMessage(); - serverToClientMessage.setPlayglobalmessage(playGlobalMessage); + const serverToClientMessage = new ServerToClientMessage(); + serverToClientMessage.setPlayglobalmessage(playGlobalMessage); - for (const [id, user] of room.getUsers().entries()) { - user.socket.write(serverToClientMessage); - } - } catch (e) { - console.error('An error occurred on "emitPlayGlobalMessage" event'); - console.error(e); + for (const [id, user] of room.getUsers().entries()) { + user.socket.write(serverToClientMessage); } } From ae5617f3a06800b85565036009d17bf8bb44f724 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Thu, 22 Jul 2021 10:41:45 +0200 Subject: [PATCH 30/31] Simplifying promises --- back/src/Services/SocketManager.ts | 48 ++++++++-------- front/src/iframe_api.ts | 92 +++++++++++++++--------------- 2 files changed, 69 insertions(+), 71 deletions(-) diff --git a/back/src/Services/SocketManager.ts b/back/src/Services/SocketManager.ts index 43af48e4..a7a10f5f 100644 --- a/back/src/Services/SocketManager.ts +++ b/back/src/Services/SocketManager.ts @@ -268,31 +268,29 @@ export class SocketManager { //check and create new room let roomPromise = this.roomsPromises.get(roomId); if (roomPromise === undefined) { - roomPromise = new Promise((resolve, reject) => { - GameRoom.create( - roomId, - (user: User, group: Group) => this.joinWebRtcRoom(user, group), - (user: User, group: Group) => this.disConnectedUser(user, group), - MINIMUM_DISTANCE, - GROUP_RADIUS, - (thing: Movable, fromZone: Zone | null, listener: ZoneSocket) => - this.onZoneEnter(thing, fromZone, listener), - (thing: Movable, position: PositionInterface, listener: ZoneSocket) => - this.onClientMove(thing, position, listener), - (thing: Movable, newZone: Zone | null, listener: ZoneSocket) => - this.onClientLeave(thing, newZone, listener), - (emoteEventMessage: EmoteEventMessage, listener: ZoneSocket) => - this.onEmote(emoteEventMessage, listener) - ) - .then((gameRoom) => { - gaugeManager.incNbRoomGauge(); - resolve(gameRoom); - }) - .catch((e) => { - this.roomsPromises.delete(roomId); - reject(e); - }); - }); + roomPromise = GameRoom.create( + roomId, + (user: User, group: Group) => this.joinWebRtcRoom(user, group), + (user: User, group: Group) => this.disConnectedUser(user, group), + MINIMUM_DISTANCE, + GROUP_RADIUS, + (thing: Movable, fromZone: Zone | null, listener: ZoneSocket) => + this.onZoneEnter(thing, fromZone, listener), + (thing: Movable, position: PositionInterface, listener: ZoneSocket) => + this.onClientMove(thing, position, listener), + (thing: Movable, newZone: Zone | null, listener: ZoneSocket) => + this.onClientLeave(thing, newZone, listener), + (emoteEventMessage: EmoteEventMessage, listener: ZoneSocket) => + this.onEmote(emoteEventMessage, listener) + ) + .then((gameRoom) => { + gaugeManager.incNbRoomGauge(); + return gameRoom; + }) + .catch((e) => { + this.roomsPromises.delete(roomId); + throw e; + }); this.roomsPromises.set(roomId, roomPromise); } return roomPromise; diff --git a/front/src/iframe_api.ts b/front/src/iframe_api.ts index 2bf1185b..2bef9d1b 100644 --- a/front/src/iframe_api.ts +++ b/front/src/iframe_api.ts @@ -1,7 +1,9 @@ import { registeredCallbacks } from "./Api/iframe/registeredCallbacks"; import { IframeResponseEvent, - IframeResponseEventMap, isIframeAnswerEvent, isIframeErrorAnswerEvent, + IframeResponseEventMap, + isIframeAnswerEvent, + isIframeErrorAnswerEvent, isIframeResponseEventWrapper, TypedMessageEvent, } from "./Api/Events/IframeEvent"; @@ -11,28 +13,25 @@ import nav from "./Api/iframe/nav"; import controls from "./Api/iframe/controls"; import ui from "./Api/iframe/ui"; import sound from "./Api/iframe/sound"; -import room, {setMapURL, setRoomId} from "./Api/iframe/room"; -import state, {initVariables} from "./Api/iframe/state"; -import player, {setPlayerName, setTags, setUuid} from "./Api/iframe/player"; +import room, { setMapURL, setRoomId } from "./Api/iframe/room"; +import state, { initVariables } from "./Api/iframe/state"; +import player, { setPlayerName, setTags, setUuid } from "./Api/iframe/player"; import type { ButtonDescriptor } from "./Api/iframe/Ui/ButtonDescriptor"; import type { Popup } from "./Api/iframe/Ui/Popup"; import type { Sound } from "./Api/iframe/Sound/Sound"; -import {answerPromises, queryWorkadventure, sendToWorkadventure} from "./Api/iframe/IframeApiContribution"; +import { answerPromises, queryWorkadventure, sendToWorkadventure } from "./Api/iframe/IframeApiContribution"; -const initPromise = new Promise((resolve) => { // Notify WorkAdventure that we are ready to receive data - queryWorkadventure({ - type: 'getState', - data: undefined - }).then((state => { - setPlayerName(state.nickname); - setRoomId(state.roomId); - setMapURL(state.mapUrl); - setTags(state.tags); - setUuid(state.uuid); - initVariables(state.variables as Map); - resolve(); - })); +const initPromise = queryWorkadventure({ + type: "getState", + data: undefined, +}).then((state) => { + setPlayerName(state.nickname); + setRoomId(state.roomId); + setMapURL(state.mapUrl); + setTags(state.tags); + setUuid(state.uuid); + initVariables(state.variables as Map); }); const wa = { @@ -186,38 +185,39 @@ declare global { window.WA = wa; window.addEventListener( - "message", (message: TypedMessageEvent>) => { - if (message.source !== window.parent) { - return; // Skip message in this event listener - } - const payload = message.data; - - //console.debug(payload); - - if (isIframeErrorAnswerEvent(payload)) { - const queryId = payload.id; - const payloadError = payload.error; - - const resolver = answerPromises.get(queryId); - if (resolver === undefined) { - throw new Error('In Iframe API, got an error answer for a question that we have no track of.'); + "message", + (message: TypedMessageEvent>) => { + if (message.source !== window.parent) { + return; // Skip message in this event listener } - resolver.reject(new Error(payloadError)); + const payload = message.data; - answerPromises.delete(queryId); - } else if (isIframeAnswerEvent(payload)) { - const queryId = payload.id; - const payloadData = payload.data; + //console.debug(payload); - const resolver = answerPromises.get(queryId); - if (resolver === undefined) { - throw new Error('In Iframe API, got an answer for a question that we have no track of.'); - } - resolver.resolve(payloadData); + if (isIframeErrorAnswerEvent(payload)) { + const queryId = payload.id; + const payloadError = payload.error; - answerPromises.delete(queryId); - } else if (isIframeResponseEventWrapper(payload)) { - const payloadData = payload.data; + const resolver = answerPromises.get(queryId); + if (resolver === undefined) { + throw new Error("In Iframe API, got an error answer for a question that we have no track of."); + } + resolver.reject(new Error(payloadError)); + + answerPromises.delete(queryId); + } else if (isIframeAnswerEvent(payload)) { + const queryId = payload.id; + const payloadData = payload.data; + + const resolver = answerPromises.get(queryId); + if (resolver === undefined) { + throw new Error("In Iframe API, got an answer for a question that we have no track of."); + } + resolver.resolve(payloadData); + + answerPromises.delete(queryId); + } else if (isIframeResponseEventWrapper(payload)) { + const payloadData = payload.data; const callback = registeredCallbacks[payload.type] as IframeCallback | undefined; if (callback?.typeChecker(payloadData)) { From 31811ab906c582e5831d2e44b55a364f2fda94f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Thu, 22 Jul 2021 11:24:30 +0200 Subject: [PATCH 31/31] Improve docblock --- .../src/Phaser/Game/SharedVariablesManager.ts | 82 ++++++++++++------- 1 file changed, 51 insertions(+), 31 deletions(-) diff --git a/front/src/Phaser/Game/SharedVariablesManager.ts b/front/src/Phaser/Game/SharedVariablesManager.ts index f177438d..2d015246 100644 --- a/front/src/Phaser/Game/SharedVariablesManager.ts +++ b/front/src/Phaser/Game/SharedVariablesManager.ts @@ -1,25 +1,29 @@ -/** - * Handles variables shared between the scripting API and the server. - */ -import type {RoomConnection} from "../../Connexion/RoomConnection"; -import {iframeListener} from "../../Api/IframeListener"; -import type {Subscription} from "rxjs"; -import type {GameMap} from "./GameMap"; -import type {ITile, ITiledMapObject} from "../Map/ITiledMap"; -import type {Var} from "svelte/types/compiler/interfaces"; -import {init} from "svelte/internal"; +import type { RoomConnection } from "../../Connexion/RoomConnection"; +import { iframeListener } from "../../Api/IframeListener"; +import type { Subscription } from "rxjs"; +import type { GameMap } from "./GameMap"; +import type { ITile, ITiledMapObject } from "../Map/ITiledMap"; +import type { Var } from "svelte/types/compiler/interfaces"; +import { init } from "svelte/internal"; interface Variable { - defaultValue: unknown, - readableBy?: string, - writableBy?: string, + defaultValue: unknown; + readableBy?: string; + writableBy?: string; } +/** + * Stores variables and provides a bridge between scripts and the pusher server. + */ export class SharedVariablesManager { private _variables = new Map(); private variableObjects: Map; - constructor(private roomConnection: RoomConnection, private gameMap: GameMap, serverVariables: Map) { + constructor( + private roomConnection: RoomConnection, + private gameMap: GameMap, + serverVariables: Map + ) { // We initialize the list of variable object at room start. The objects cannot be edited later // (otherwise, this would cause a security issue if the scripting API can edit this list of objects) this.variableObjects = SharedVariablesManager.findVariablesInMap(gameMap); @@ -46,24 +50,34 @@ export class SharedVariablesManager { iframeListener.setVariable({ key: name, value: value, - }) + }); }); // When a variable is modified from an iFrame - iframeListener.registerAnswerer('setVariable', (event) => { + iframeListener.registerAnswerer("setVariable", (event) => { const key = event.key; const object = this.variableObjects.get(key); if (object === undefined) { - const errMsg = 'A script is trying to modify variable "'+key+'" but this variable is not defined in the map.' + - 'There should be an object in the map whose name is "'+key+'" and whose type is "variable"'; + const errMsg = + 'A script is trying to modify variable "' + + key + + '" but this variable is not defined in the map.' + + 'There should be an object in the map whose name is "' + + key + + '" and whose type is "variable"'; console.error(errMsg); throw new Error(errMsg); } if (object.writableBy && !this.roomConnection.hasTag(object.writableBy)) { - const errMsg = 'A script is trying to modify variable "'+key+'" but this variable is only writable for users with tag "'+object.writableBy+'".'; + const errMsg = + 'A script is trying to modify variable "' + + key + + '" but this variable is only writable for users with tag "' + + object.writableBy + + '".'; console.error(errMsg); throw new Error(errMsg); } @@ -78,11 +92,13 @@ export class SharedVariablesManager { private static findVariablesInMap(gameMap: GameMap): Map { const objects = new Map(); for (const layer of gameMap.getMap().layers) { - if (layer.type === 'objectgroup') { + if (layer.type === "objectgroup") { for (const object of layer.objects) { - if (object.type === 'variable') { + if (object.type === "variable") { if (object.template) { - console.warn('Warning, a variable object is using a Tiled "template". WorkAdventure does not support objects generated from Tiled templates.') + console.warn( + 'Warning, a variable object is using a Tiled "template". WorkAdventure does not support objects generated from Tiled templates.' + ); } // We store a copy of the object (to make it immutable) @@ -96,27 +112,31 @@ export class SharedVariablesManager { private static iTiledObjectToVariable(object: ITiledMapObject): Variable { const variable: Variable = { - defaultValue: undefined + defaultValue: undefined, }; if (object.properties) { for (const property of object.properties) { const value = property.value; switch (property.name) { - case 'default': + case "default": variable.defaultValue = value; break; - case 'writableBy': - if (typeof value !== 'string') { - throw new Error('The writableBy property of variable "'+object.name+'" must be a string'); + case "writableBy": + if (typeof value !== "string") { + throw new Error( + 'The writableBy property of variable "' + object.name + '" must be a string' + ); } if (value) { variable.writableBy = value; } break; - case 'readableBy': - if (typeof value !== 'string') { - throw new Error('The readableBy property of variable "'+object.name+'" must be a string'); + case "readableBy": + if (typeof value !== "string") { + throw new Error( + 'The readableBy property of variable "' + object.name + '" must be a string' + ); } if (value) { variable.readableBy = value; @@ -130,7 +150,7 @@ export class SharedVariablesManager { } public close(): void { - iframeListener.unregisterAnswerer('setVariable'); + iframeListener.unregisterAnswerer("setVariable"); } get variables(): Map {