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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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 2d55f982d3f1128f3669938938d97e56e46f7337 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Tue, 20 Jul 2021 18:29:41 +0200 Subject: [PATCH 22/47] Removing the 'search' parameters from the room URL sent to pusher --- front/src/Connexion/Room.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/front/src/Connexion/Room.ts b/front/src/Connexion/Room.ts index 57d52766..2053911d 100644 --- a/front/src/Connexion/Room.ts +++ b/front/src/Connexion/Room.ts @@ -169,6 +169,7 @@ export class Room { */ public get key(): string { const newUrl = new URL(this.roomUrl.toString()); + newUrl.search = ""; newUrl.hash = ""; return newUrl.toString(); } From 4f513fb1e0fede17358b05611a43468c9ac76044 Mon Sep 17 00:00:00 2001 From: GRL78 <80678534+GRL78@users.noreply.github.com> Date: Tue, 20 Jul 2021 19:54:45 +0200 Subject: [PATCH 23/47] Fix test Scripting API (#1298) * fix tests of Scripting API * Suppression ts-ignore --- maps/tests/Metadata/customMenu.html | 19 +++++++++++------ maps/tests/Metadata/getCurrentRoom.html | 25 ++++++++++++++-------- maps/tests/Metadata/getCurrentUser.html | 23 +++++++++++++------- maps/tests/Metadata/playerMove.html | 16 +++++++++----- maps/tests/Metadata/setProperty.html | 17 ++++++++++----- maps/tests/Metadata/showHideLayer.html | 28 +++++++++++++++---------- maps/tests/index.html | 24 +++++++-------------- 7 files changed, 92 insertions(+), 60 deletions(-) diff --git a/maps/tests/Metadata/customMenu.html b/maps/tests/Metadata/customMenu.html index a80dca08..404673f3 100644 --- a/maps/tests/Metadata/customMenu.html +++ b/maps/tests/Metadata/customMenu.html @@ -1,13 +1,20 @@ - - - - + + +

Add a custom menu

\ No newline at end of file diff --git a/maps/tests/Metadata/getCurrentRoom.html b/maps/tests/Metadata/getCurrentRoom.html index 7429b2a8..485f2ac8 100644 --- a/maps/tests/Metadata/getCurrentRoom.html +++ b/maps/tests/Metadata/getCurrentRoom.html @@ -1,16 +1,23 @@ - + - +

Log in the console the information of the current room

\ No newline at end of file diff --git a/maps/tests/Metadata/getCurrentUser.html b/maps/tests/Metadata/getCurrentUser.html index 4122cc50..da0c3191 100644 --- a/maps/tests/Metadata/getCurrentUser.html +++ b/maps/tests/Metadata/getCurrentUser.html @@ -1,15 +1,22 @@ - + - +

Log in the console the information of the current player

\ No newline at end of file diff --git a/maps/tests/Metadata/playerMove.html b/maps/tests/Metadata/playerMove.html index 339a3fd2..46a36845 100644 --- a/maps/tests/Metadata/playerMove.html +++ b/maps/tests/Metadata/playerMove.html @@ -1,12 +1,18 @@ - + -
- +

Log in the console the movement of the current player in the zone of the iframe

\ No newline at end of file diff --git a/maps/tests/Metadata/setProperty.html b/maps/tests/Metadata/setProperty.html index 5259ec0a..c61aa5fa 100644 --- a/maps/tests/Metadata/setProperty.html +++ b/maps/tests/Metadata/setProperty.html @@ -1,12 +1,19 @@ - + - +

Change the url of this iframe and add the 'openWebsite' property to the red tile layer

\ No newline at end of file diff --git a/maps/tests/Metadata/showHideLayer.html b/maps/tests/Metadata/showHideLayer.html index 4677f9e5..c6103722 100644 --- a/maps/tests/Metadata/showHideLayer.html +++ b/maps/tests/Metadata/showHideLayer.html @@ -1,21 +1,27 @@ - +
- \ No newline at end of file diff --git a/maps/tests/index.html b/maps/tests/index.html index dba14eec..df305cfa 100644 --- a/maps/tests/index.html +++ b/maps/tests/index.html @@ -122,14 +122,6 @@ Testing add a custom menu by scripting API - - - Success Failure Pending - - - Testing return current room attributes by Scripting API (Need to test from current user) - - Success Failure Pending @@ -138,6 +130,14 @@ Testing return current user attributes by Scripting API + + + Success Failure Pending + + + Testing return current room attributes by Scripting API (Need to test from current user) + + Success Failure Pending @@ -186,14 +186,6 @@ Test start tile (S2) - - - Success Failure Pending - - - Test cowebsite opened by script is allowed to use IFrame API - - Success Failure Pending From cd49fd5b83b30c4557ce2f614d054ab1e8c974a8 Mon Sep 17 00:00:00 2001 From: Valdo Romao Date: Wed, 21 Jul 2021 16:10:30 +0200 Subject: [PATCH 24/47] Fixe openPopup deprecated annotation --- front/src/iframe_api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/src/iframe_api.ts b/front/src/iframe_api.ts index 1915020e..189457ab 100644 --- a/front/src/iframe_api.ts +++ b/front/src/iframe_api.ts @@ -123,7 +123,7 @@ const wa = { }, /** - * @deprecated Use WA.controls.restorePlayerControls instead + * @deprecated Use WA.ui.openPopup instead */ openPopup(targetObject: string, message: string, buttons: ButtonDescriptor[]): Popup { console.warn("Method WA.openPopup is deprecated. Please use WA.ui.openPopup instead"); 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 25/47] 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 26/47] 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 27/47] 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 28/47] 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 29/47] 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 30/47] 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 31/47] 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 32/47] 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 33/47] 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 34/47] 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 { From 756a495ac6a4cbd2e0297957aea6a4d17a74941c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Thu, 22 Jul 2021 17:14:15 +0200 Subject: [PATCH 35/47] Fixing CI --- back/src/RoomManager.ts | 158 ++++++++++++++++++++-------------------- 1 file changed, 80 insertions(+), 78 deletions(-) diff --git a/back/src/RoomManager.ts b/back/src/RoomManager.ts index 7eaf4b01..e4a7af39 100644 --- a/back/src/RoomManager.ts +++ b/back/src/RoomManager.ts @@ -45,88 +45,90 @@ const roomManager: IRoomManagerServer = { let room: GameRoom | null = null; let user: User | null = null; - call.on("data", async (message: PusherToBackMessage) => { - try { - if (room === null || user === null) { - if (message.hasJoinroommessage()) { - socketManager - .handleJoinRoom(call, message.getJoinroommessage() as JoinRoomMessage) - .then(({ room: gameRoom, user: myUser }) => { - if (call.writable) { - room = gameRoom; - user = myUser; - } else { - //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"); - } - } else { - if (message.hasJoinroommessage()) { - throw new Error("Cannot call JoinRoomMessage twice!"); - } else if (message.hasUsermovesmessage()) { - socketManager.handleUserMovesMessage( - room, - user, - message.getUsermovesmessage() as UserMovesMessage - ); - } else if (message.hasSilentmessage()) { - socketManager.handleSilentMessage(room, user, message.getSilentmessage() as SilentMessage); - } else if (message.hasItemeventmessage()) { - socketManager.handleItemEvent(room, user, message.getItemeventmessage() as ItemEventMessage); - } else if (message.hasVariablemessage()) { - await socketManager.handleVariableEvent( - room, - user, - message.getVariablemessage() as VariableMessage - ); - } else if (message.hasWebrtcsignaltoservermessage()) { - socketManager.emitVideo( - room, - user, - message.getWebrtcsignaltoservermessage() as WebRtcSignalToServerMessage - ); - } else if (message.hasWebrtcscreensharingsignaltoservermessage()) { - socketManager.emitScreenSharing( - room, - user, - message.getWebrtcscreensharingsignaltoservermessage() as WebRtcSignalToServerMessage - ); - } else if (message.hasPlayglobalmessage()) { - socketManager.emitPlayGlobalMessage(room, message.getPlayglobalmessage() as PlayGlobalMessage); - } else if (message.hasQueryjitsijwtmessage()) { - socketManager.handleQueryJitsiJwtMessage( - user, - message.getQueryjitsijwtmessage() as QueryJitsiJwtMessage - ); - } else if (message.hasEmotepromptmessage()) { - socketManager.handleEmoteEventMessage( - room, - user, - message.getEmotepromptmessage() as EmotePromptMessage - ); - } else if (message.hasSendusermessage()) { - const sendUserMessage = message.getSendusermessage(); - if (sendUserMessage !== undefined) { - socketManager.handlerSendUserMessage(user, sendUserMessage); - } - } else if (message.hasBanusermessage()) { - const banUserMessage = message.getBanusermessage(); - if (banUserMessage !== undefined) { - socketManager.handlerBanUserMessage(room, user, banUserMessage); + call.on("data", (message: PusherToBackMessage) => { + (async () => { + try { + if (room === null || user === null) { + if (message.hasJoinroommessage()) { + socketManager + .handleJoinRoom(call, message.getJoinroommessage() as JoinRoomMessage) + .then(({ room: gameRoom, user: myUser }) => { + if (call.writable) { + room = gameRoom; + user = myUser; + } else { + //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"); } } else { - throw new Error("Unhandled message type"); + if (message.hasJoinroommessage()) { + throw new Error("Cannot call JoinRoomMessage twice!"); + } else if (message.hasUsermovesmessage()) { + socketManager.handleUserMovesMessage( + room, + user, + message.getUsermovesmessage() as UserMovesMessage + ); + } else if (message.hasSilentmessage()) { + socketManager.handleSilentMessage(room, user, message.getSilentmessage() as SilentMessage); + } else if (message.hasItemeventmessage()) { + socketManager.handleItemEvent(room, user, message.getItemeventmessage() as ItemEventMessage); + } else if (message.hasVariablemessage()) { + await socketManager.handleVariableEvent( + room, + user, + message.getVariablemessage() as VariableMessage + ); + } else if (message.hasWebrtcsignaltoservermessage()) { + socketManager.emitVideo( + room, + user, + message.getWebrtcsignaltoservermessage() as WebRtcSignalToServerMessage + ); + } else if (message.hasWebrtcscreensharingsignaltoservermessage()) { + socketManager.emitScreenSharing( + room, + user, + message.getWebrtcscreensharingsignaltoservermessage() as WebRtcSignalToServerMessage + ); + } else if (message.hasPlayglobalmessage()) { + socketManager.emitPlayGlobalMessage(room, message.getPlayglobalmessage() as PlayGlobalMessage); + } else if (message.hasQueryjitsijwtmessage()) { + socketManager.handleQueryJitsiJwtMessage( + user, + message.getQueryjitsijwtmessage() as QueryJitsiJwtMessage + ); + } else if (message.hasEmotepromptmessage()) { + socketManager.handleEmoteEventMessage( + room, + user, + message.getEmotepromptmessage() as EmotePromptMessage + ); + } else if (message.hasSendusermessage()) { + const sendUserMessage = message.getSendusermessage(); + if (sendUserMessage !== undefined) { + socketManager.handlerSendUserMessage(user, sendUserMessage); + } + } else if (message.hasBanusermessage()) { + const banUserMessage = message.getBanusermessage(); + if (banUserMessage !== undefined) { + socketManager.handlerBanUserMessage(room, user, banUserMessage); + } + } else { + throw new Error("Unhandled message type"); + } } + } catch (e) { + console.error(e); + emitError(call, e); + call.end(); } - } catch (e) { - console.error(e); - emitError(call, e); - call.end(); - } + })().catch(e => console.error(e)); }); call.on("end", () => { From 84df25f86357fbf9b8d631a23922b1bc1e15a91f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Thu, 22 Jul 2021 17:14:36 +0200 Subject: [PATCH 36/47] Improving WA.state typings --- front/src/Api/iframe/state.ts | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/front/src/Api/iframe/state.ts b/front/src/Api/iframe/state.ts index 90e8cb81..011be1bc 100644 --- a/front/src/Api/iframe/state.ts +++ b/front/src/Api/iframe/state.ts @@ -1,10 +1,10 @@ -import {Observable, Subject} from "rxjs"; +import { Observable, Subject } from "rxjs"; import { EnterLeaveEvent, isEnterLeaveEvent } from "../Events/EnterLeaveEvent"; -import {IframeApiContribution, queryWorkadventure, sendToWorkadventure} from "./IframeApiContribution"; +import { IframeApiContribution, queryWorkadventure, sendToWorkadventure } from "./IframeApiContribution"; import { apiCallback } from "./registeredCallbacks"; -import {isSetVariableEvent, SetVariableEvent} from "../Events/SetVariableEvent"; +import { isSetVariableEvent, SetVariableEvent } from "../Events/SetVariableEvent"; import type { ITiledMap } from "../../Phaser/Map/ITiledMap"; @@ -19,8 +19,7 @@ export const initVariables = (_variables: Map): void => { variables.set(name, value); } } - -} +}; setVariableResolvers.subscribe((event) => { const oldValue = variables.get(event.key); @@ -44,19 +43,19 @@ export class WorkadventureStateCommands extends IframeApiContribution { setVariableResolvers.next(payloadData); - } + }, }), ]; - saveVariable(key : string, value : unknown): Promise { + saveVariable(key: string, value: unknown): Promise { variables.set(key, value); return queryWorkadventure({ - type: 'setVariable', + type: "setVariable", data: { key, - value - } - }) + value, + }, + }); } loadVariable(key: string): unknown { @@ -71,7 +70,6 @@ export class WorkadventureStateCommands extends IframeApiContribution Date: Fri, 23 Jul 2021 11:50:03 +0200 Subject: [PATCH 37/47] Fixing loop when setting variables Setting a variable would makes the application enter in an infinite loop of events (between all the scripts and the back) This fix makes sure a variable does not emit any event if it is changed to a value it already has. --- back/src/Model/GameRoom.ts | 5 +++ back/src/RoomManager.ts | 13 ++++++-- back/src/Services/VariablesManager.ts | 17 +++++++++- front/src/Api/IframeListener.ts | 33 ++++++++++--------- front/src/Api/iframe/state.ts | 6 ++-- .../src/Phaser/Game/SharedVariablesManager.ts | 10 +++++- 6 files changed, 61 insertions(+), 23 deletions(-) diff --git a/back/src/Model/GameRoom.ts b/back/src/Model/GameRoom.ts index 2892a7bd..491dd4af 100644 --- a/back/src/Model/GameRoom.ts +++ b/back/src/Model/GameRoom.ts @@ -327,6 +327,11 @@ export class GameRoom { const readableBy = variableManager.setVariable(name, value, user); + // If the variable was not changed, let's not dispatch anything. + if (readableBy === false) { + return; + } + // TODO: should we batch those every 100ms? const variableMessage = new VariableWithTagMessage(); variableMessage.setName(name); diff --git a/back/src/RoomManager.ts b/back/src/RoomManager.ts index e4a7af39..0465ade6 100644 --- a/back/src/RoomManager.ts +++ b/back/src/RoomManager.ts @@ -77,7 +77,11 @@ const roomManager: IRoomManagerServer = { } else if (message.hasSilentmessage()) { socketManager.handleSilentMessage(room, user, message.getSilentmessage() as SilentMessage); } else if (message.hasItemeventmessage()) { - socketManager.handleItemEvent(room, user, message.getItemeventmessage() as ItemEventMessage); + socketManager.handleItemEvent( + room, + user, + message.getItemeventmessage() as ItemEventMessage + ); } else if (message.hasVariablemessage()) { await socketManager.handleVariableEvent( room, @@ -97,7 +101,10 @@ const roomManager: IRoomManagerServer = { message.getWebrtcscreensharingsignaltoservermessage() as WebRtcSignalToServerMessage ); } else if (message.hasPlayglobalmessage()) { - socketManager.emitPlayGlobalMessage(room, message.getPlayglobalmessage() as PlayGlobalMessage); + socketManager.emitPlayGlobalMessage( + room, + message.getPlayglobalmessage() as PlayGlobalMessage + ); } else if (message.hasQueryjitsijwtmessage()) { socketManager.handleQueryJitsiJwtMessage( user, @@ -128,7 +135,7 @@ const roomManager: IRoomManagerServer = { emitError(call, e); call.end(); } - })().catch(e => console.error(e)); + })().catch((e) => console.error(e)); }); call.on("end", () => { diff --git a/back/src/Services/VariablesManager.ts b/back/src/Services/VariablesManager.ts index 5137a32d..c6b6f5fe 100644 --- a/back/src/Services/VariablesManager.ts +++ b/back/src/Services/VariablesManager.ts @@ -134,7 +134,17 @@ export class VariablesManager { return variable; } - setVariable(name: string, value: string, user: User): string | undefined { + /** + * Sets the variable. + * + * Returns who is allowed to read the variable (the readableby property) or "undefined" if anyone can read it. + * Also, returns "false" if the variable was not modified (because we set it to the value it already has) + * + * @param name + * @param value + * @param user + */ + setVariable(name: string, value: string, user: User): string | undefined | false { let readableBy: string | undefined; if (this.variableObjects) { const variableObject = this.variableObjects.get(name); @@ -159,6 +169,11 @@ export class VariablesManager { readableBy = variableObject.readableBy; } + // If the value is not modified, return false + if (this._variables.get(name) === value) { + return false; + } + this._variables.set(name, value); variablesRepository .saveVariable(this.roomUrl, name, value) diff --git a/front/src/Api/IframeListener.ts b/front/src/Api/IframeListener.ts index d8559aa0..d0c82253 100644 --- a/front/src/Api/IframeListener.ts +++ b/front/src/Api/IframeListener.ts @@ -32,9 +32,9 @@ 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"; +import type { SetVariableEvent } from "./Events/SetVariableEvent"; -type AnswererCallback = (query: IframeQueryMap[T]['query']) => IframeQueryMap[T]['answer']|PromiseLike; +type AnswererCallback = (query: IframeQueryMap[T]["query"], source: MessageEventSource | null) => IframeQueryMap[T]["answer"] | PromiseLike; /** * Listens to messages from iframes and turn those messages into easy to use observables. @@ -187,7 +187,7 @@ class IframeListener { }; try { - Promise.resolve(answerer(query.data)).then((value) => { + Promise.resolve(answerer(query.data, message.source)).then((value) => { iframe?.contentWindow?.postMessage({ id: queryId, type: query.type, @@ -197,18 +197,6 @@ class IframeListener { } 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); @@ -439,6 +427,21 @@ class IframeListener { public unregisterAnswerer(key: keyof IframeQueryMap): void { delete this.answerers[key]; } + + dispatchVariableToOtherIframes(key: string, value: unknown, source: MessageEventSource | null) { + // Let's dispatch the message to the other iframes + for (const iframe of this.iframes) { + if (iframe.contentWindow !== source) { + iframe.contentWindow?.postMessage({ + 'type': 'setVariable', + 'data': { + key, + value, + } + }, '*'); + } + } + } } export const iframeListener = new IframeListener(); diff --git a/front/src/Api/iframe/state.ts b/front/src/Api/iframe/state.ts index 011be1bc..3b551864 100644 --- a/front/src/Api/iframe/state.ts +++ b/front/src/Api/iframe/state.ts @@ -23,11 +23,11 @@ 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) { + // No need to do this check since it is already performed in SharedVariablesManager + /*if (JSON.stringify(oldValue) === JSON.stringify(event.value)) { return; - } + }*/ variables.set(event.key, event.value); const subject = variableSubscribers.get(event.key); diff --git a/front/src/Phaser/Game/SharedVariablesManager.ts b/front/src/Phaser/Game/SharedVariablesManager.ts index 2d015246..6a06d97e 100644 --- a/front/src/Phaser/Game/SharedVariablesManager.ts +++ b/front/src/Phaser/Game/SharedVariablesManager.ts @@ -54,7 +54,7 @@ export class SharedVariablesManager { }); // When a variable is modified from an iFrame - iframeListener.registerAnswerer("setVariable", (event) => { + iframeListener.registerAnswerer("setVariable", (event, source) => { const key = event.key; const object = this.variableObjects.get(key); @@ -82,10 +82,18 @@ export class SharedVariablesManager { throw new Error(errMsg); } + // Let's stop any propagation of the value we set is the same as the existing value. + if (JSON.stringify(event.value) === JSON.stringify(this._variables.get(key))) { + return; + } + this._variables.set(key, event.value); // Dispatch to the room connection. this.roomConnection.emitSetVariableEvent(key, event.value); + + // Dispatch to other iframes + iframeListener.dispatchVariableToOtherIframes(key, event.value, source); }); } From 88f2bfdf3974815c67e9b485d960c26f1498697b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Fri, 23 Jul 2021 12:19:47 +0200 Subject: [PATCH 38/47] Taking into account persist property The "persist" property was not taken into account and all variables were stored in DB. This change makes sure only variables tagged with "persist" are actually persisted. --- back/src/Services/VariablesManager.ts | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/back/src/Services/VariablesManager.ts b/back/src/Services/VariablesManager.ts index c6b6f5fe..e8aaef25 100644 --- a/back/src/Services/VariablesManager.ts +++ b/back/src/Services/VariablesManager.ts @@ -51,6 +51,17 @@ export class VariablesManager { } const variables = await variablesRepository.loadVariables(this.roomUrl); for (const key in variables) { + // Let's only set variables if they are in the map (if the map has changed, maybe stored variables do not exist anymore) + if (this.variableObjects) { + const variableObject = this.variableObjects.get(key); + if (variableObject === undefined) { + continue; + } + if (!variableObject.persist) { + continue; + } + } + this._variables.set(key, variables[key]); } return this; @@ -146,8 +157,9 @@ export class VariablesManager { */ setVariable(name: string, value: string, user: User): string | undefined | false { let readableBy: string | undefined; + let variableObject: Variable | undefined; if (this.variableObjects) { - const variableObject = this.variableObjects.get(name); + 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.'); } @@ -175,9 +187,13 @@ export class VariablesManager { } this._variables.set(name, value); - variablesRepository - .saveVariable(this.roomUrl, name, value) - .catch((e) => console.error("Error while saving variable in Redis:", e)); + + if (variableObject !== undefined && variableObject.persist) { + variablesRepository + .saveVariable(this.roomUrl, name, value) + .catch((e) => console.error("Error while saving variable in Redis:", e)); + } + return readableBy; } From c1cd464a7bdd312bb8cc8c7ed1b74e2a591f9877 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Fri, 23 Jul 2021 12:26:18 +0200 Subject: [PATCH 39/47] Fixing reference to deprecated method in doc --- docs/maps/scripting.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/maps/scripting.md b/docs/maps/scripting.md index 5f645b81..5be57ee1 100644 --- a/docs/maps/scripting.md +++ b/docs/maps/scripting.md @@ -55,10 +55,10 @@ Start by testing this with a simple message sent to the chat. **script.js** ```javascript -WA.sendChatMessage('Hello world', 'Mr Robot'); +WA.chat.sendChatMessage('Hello world', 'Mr Robot'); ``` -The `WA` objects contains a number of useful methods enabling you to interact with the WorkAdventure game. For instance, `WA.sendChatMessage` opens the chat and adds a message in it. +The `WA` objects contains a number of useful methods enabling you to interact with the WorkAdventure game. For instance, `WA.chat.sendChatMessage` opens the chat and adds a message in it. In your browser console, when you open the map, the chat message should be displayed right away. From 64065b2798f792bf5c9f5a53b00042804977a512 Mon Sep 17 00:00:00 2001 From: Stefan Weil Date: Tue, 27 Jul 2021 14:29:09 +0200 Subject: [PATCH 40/47] Fix some typos (found by codespell) (#1316) Signed-off-by: Stefan Weil --- CHANGELOG.md | 2 +- back/src/Model/PositionNotifier.ts | 2 +- back/src/RoomManager.ts | 2 +- docs/maps/wa-maps.md | 2 +- front/src/Phaser/Entity/PlayerTexturesLoadingManager.ts | 2 +- front/src/Phaser/Game/GameScene.ts | 4 ++-- front/src/Phaser/Game/StartPositionCalculator.ts | 4 ++-- front/src/Phaser/UserInput/UserInputManager.ts | 2 +- front/src/Stores/MediaStore.ts | 4 ++-- front/src/WebRtc/SimplePeer.ts | 8 ++++---- front/tests/Phaser/Game/PlayerTexturesLoadingTest.ts | 6 +++--- maps/Tuto/tilesets/LPc-submissions-Final Attribution.txt | 2 +- pusher/src/Model/PositionDispatcher.ts | 2 +- pusher/src/Services/SocketManager.ts | 2 +- 14 files changed, 22 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50c09ca4..11435ad5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,7 @@ - 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. - The text chat was redesigned to be prettier and to use more features : - - The chat is now persistent bewteen discussions and always accesible + - The chat is now persistent between discussions and always accessible - The chat now tracks incoming and outcoming users in your conversation - The chat allows your to see the visit card of users - You can close the chat window with the escape key diff --git a/back/src/Model/PositionNotifier.ts b/back/src/Model/PositionNotifier.ts index c34c1ef1..4f911637 100644 --- a/back/src/Model/PositionNotifier.ts +++ b/back/src/Model/PositionNotifier.ts @@ -21,7 +21,7 @@ interface ZoneDescriptor { } export class PositionNotifier { - // TODO: we need a way to clean the zones if noone is in the zone and noone listening (to free memory!) + // TODO: we need a way to clean the zones if no one is in the zone and no one listening (to free memory!) private zones: Zone[][] = []; diff --git a/back/src/RoomManager.ts b/back/src/RoomManager.ts index 0465ade6..3369eef9 100644 --- a/back/src/RoomManager.ts +++ b/back/src/RoomManager.ts @@ -57,7 +57,7 @@ const roomManager: IRoomManagerServer = { room = gameRoom; user = myUser; } else { - //Connexion may have been closed before the init was finished, so we have to manually disconnect the user. + //Connection may have been closed before the init was finished, so we have to manually disconnect the user. socketManager.leaveRoom(gameRoom, myUser); } }) diff --git a/docs/maps/wa-maps.md b/docs/maps/wa-maps.md index d7a349c5..0eb94dbf 100644 --- a/docs/maps/wa-maps.md +++ b/docs/maps/wa-maps.md @@ -56,7 +56,7 @@ A few things to notice: ## Building walls and "collidable" areas -By default, the characters can traverse any tiles. If you want to prevent your characeter from going through a tile (like a wall or a desktop), you must make this tile "collidable". You can do this by settings the `collides` property on a given tile. +By default, the characters can traverse any tiles. If you want to prevent your character from going through a tile (like a wall or a desktop), you must make this tile "collidable". You can do this by settings the `collides` property on a given tile. To make a tile "collidable", you should: diff --git a/front/src/Phaser/Entity/PlayerTexturesLoadingManager.ts b/front/src/Phaser/Entity/PlayerTexturesLoadingManager.ts index 3c47c9d9..92954bfb 100644 --- a/front/src/Phaser/Entity/PlayerTexturesLoadingManager.ts +++ b/front/src/Phaser/Entity/PlayerTexturesLoadingManager.ts @@ -107,7 +107,7 @@ export const createLoadingPromise = ( loadPlugin.spritesheet(playerResourceDescriptor.name, playerResourceDescriptor.img, frameConfig); const errorCallback = (file: { src: string }) => { if (file.src !== playerResourceDescriptor.img) return; - console.error("failed loading player ressource: ", playerResourceDescriptor); + console.error("failed loading player resource: ", playerResourceDescriptor); rej(playerResourceDescriptor); loadPlugin.off("filecomplete-spritesheet-" + playerResourceDescriptor.name, successCallback); loadPlugin.off("loaderror", errorCallback); diff --git a/front/src/Phaser/Game/GameScene.ts b/front/src/Phaser/Game/GameScene.ts index c5deaba5..e7738265 100644 --- a/front/src/Phaser/Game/GameScene.ts +++ b/front/src/Phaser/Game/GameScene.ts @@ -194,7 +194,7 @@ export class GameScene extends DirtyScene { private popUpElements: Map = new Map(); private originalMapUrl: string | undefined; private pinchManager: PinchManager | undefined; - private mapTransitioning: boolean = false; //used to prevent transitions happenning at the same time. + private mapTransitioning: boolean = false; //used to prevent transitions happening at the same time. private emoteManager!: EmoteManager; private preloading: boolean = true; private startPositionCalculator!: StartPositionCalculator; @@ -436,7 +436,7 @@ export class GameScene extends DirtyScene { this.characterLayers = gameManager.getCharacterLayers(); this.companion = gameManager.getCompanion(); - //initalise map + //initialise map this.Map = this.add.tilemap(this.MapUrlFile); const mapDirUrl = this.MapUrlFile.substr(0, this.MapUrlFile.lastIndexOf("/")); this.mapFile.tilesets.forEach((tileset: ITiledTileSet) => { diff --git a/front/src/Phaser/Game/StartPositionCalculator.ts b/front/src/Phaser/Game/StartPositionCalculator.ts index a0184d2b..0827b623 100644 --- a/front/src/Phaser/Game/StartPositionCalculator.ts +++ b/front/src/Phaser/Game/StartPositionCalculator.ts @@ -45,7 +45,7 @@ export class StartPositionCalculator { /** * * @param selectedLayer this is always the layer that is selected with the hash in the url - * @param selectedOrDefaultLayer this can also be the {defaultStartLayerName} if the {selectedLayer} didnt yield any start points + * @param selectedOrDefaultLayer this can also be the {defaultStartLayerName} if the {selectedLayer} did not yield any start points */ public initPositionFromLayerName(selectedOrDefaultLayer: string | null, selectedLayer: string | null) { if (!selectedOrDefaultLayer) { @@ -73,7 +73,7 @@ export class StartPositionCalculator { /** * * @param selectedLayer this is always the layer that is selected with the hash in the url - * @param selectedOrDefaultLayer this can also be the default layer if the {selectedLayer} didnt yield any start points + * @param selectedOrDefaultLayer this can also be the default layer if the {selectedLayer} did not yield any start points */ private startUser(selectedOrDefaultLayer: ITiledMapTileLayer, selectedLayer: string | null): PositionInterface { const tiles = selectedOrDefaultLayer.data; diff --git a/front/src/Phaser/UserInput/UserInputManager.ts b/front/src/Phaser/UserInput/UserInputManager.ts index 068e84a2..edfcdd25 100644 --- a/front/src/Phaser/UserInput/UserInputManager.ts +++ b/front/src/Phaser/UserInput/UserInputManager.ts @@ -21,7 +21,7 @@ export enum UserInputEvent { } -//we cannot use a map structure so we have to create a replacment +//we cannot use a map structure so we have to create a replacement export class ActiveEventList { private eventMap : Map = new Map(); diff --git a/front/src/Stores/MediaStore.ts b/front/src/Stores/MediaStore.ts index 9144a6ee..10e4523d 100644 --- a/front/src/Stores/MediaStore.ts +++ b/front/src/Stores/MediaStore.ts @@ -274,12 +274,12 @@ export const mediaStreamConstraintsStore = derived( currentAudioConstraint = false; } - // Disable webcam for privacy reasons (the game is not visible and we were talking to noone) + // Disable webcam for privacy reasons (the game is not visible and we were talking to no one) if ($privacyShutdownStore === true) { currentVideoConstraint = false; } - // Disable webcam for energy reasons (the user is not moving and we are talking to noone) + // Disable webcam for energy reasons (the user is not moving and we are talking to no one) if ($cameraEnergySavingStore === true) { currentVideoConstraint = false; currentAudioConstraint = false; diff --git a/front/src/WebRtc/SimplePeer.ts b/front/src/WebRtc/SimplePeer.ts index e30f1b1f..510918a2 100644 --- a/front/src/WebRtc/SimplePeer.ts +++ b/front/src/WebRtc/SimplePeer.ts @@ -295,7 +295,7 @@ export class SimplePeer { // I do understand the method closeConnection is called twice, but I don't understand how they manage to run in parallel. peer.destroy(); - //Comment this peer connexion because if we delete and try to reshare screen, the RTCPeerConnection send renegociate event. This array will be remove when user left circle discussion + //Comment this peer connection because if we delete and try to reshare screen, the RTCPeerConnection send renegotiate event. This array will be remove when user left circle discussion /*if(!this.PeerScreenSharingConnectionArray.delete(userId)){ throw 'Couln\'t delete peer screen sharing connexion'; }*/ @@ -370,14 +370,14 @@ export class SimplePeer { console.error( 'Could not find peer whose ID is "' + data.userId + '" in receiveWebrtcScreenSharingSignal' ); - console.info("Attempt to create new peer connexion"); + console.info("Attempt to create new peer connection"); if (stream) { this.sendLocalScreenSharingStreamToUser(data.userId, stream); } } } catch (e) { console.error(`receiveWebrtcSignal => ${data.userId}`, e); - //Comment this peer connexion because if we delete and try to reshare screen, the RTCPeerConnection send renegociate event. This array will be remove when user left circle discussion + //Comment this peer connection because if we delete and try to reshare screen, the RTCPeerConnection send renegotiate event. This array will be remove when user left circle discussion //this.PeerScreenSharingConnectionArray.delete(data.userId); this.receiveWebrtcScreenSharingSignal(data); } @@ -485,7 +485,7 @@ export class SimplePeer { if (!PeerConnectionScreenSharing.isReceivingScreenSharingStream()) { PeerConnectionScreenSharing.destroy(); - //Comment this peer connexion because if we delete and try to reshare screen, the RTCPeerConnection send renegociate event. This array will be remove when user left circle discussion + //Comment this peer connection because if we delete and try to reshare screen, the RTCPeerConnection send renegotiate event. This array will be remove when user left circle discussion //this.PeerScreenSharingConnectionArray.delete(userId); } } diff --git a/front/tests/Phaser/Game/PlayerTexturesLoadingTest.ts b/front/tests/Phaser/Game/PlayerTexturesLoadingTest.ts index d58d55db..44502725 100644 --- a/front/tests/Phaser/Game/PlayerTexturesLoadingTest.ts +++ b/front/tests/Phaser/Game/PlayerTexturesLoadingTest.ts @@ -8,19 +8,19 @@ describe("getRessourceDescriptor()", () => { expect(desc.img).toEqual('url'); }); - it(", if given a string as parameter, should search trough hardcoded values", () => { + it(", if given a string as parameter, should search through hardcoded values", () => { const desc = getRessourceDescriptor('male1'); expect(desc.name).toEqual('male1'); expect(desc.img).toEqual("resources/characters/pipoya/Male 01-1.png"); }); - it(", if given a string as parameter, should search trough hardcoded values (bis)", () => { + it(", if given a string as parameter, should search through hardcoded values (bis)", () => { const desc = getRessourceDescriptor('color_2'); expect(desc.name).toEqual('color_2'); expect(desc.img).toEqual("resources/customisation/character_color/character_color1.png"); }); - it(", if given a descriptor without url as parameter, should search trough hardcoded values", () => { + it(", if given a descriptor without url as parameter, should search through hardcoded values", () => { const desc = getRessourceDescriptor({name: 'male1', img: ''}); expect(desc.name).toEqual('male1'); expect(desc.img).toEqual("resources/characters/pipoya/Male 01-1.png"); diff --git a/maps/Tuto/tilesets/LPc-submissions-Final Attribution.txt b/maps/Tuto/tilesets/LPc-submissions-Final Attribution.txt index d04221dc..aec5b1b1 100644 --- a/maps/Tuto/tilesets/LPc-submissions-Final Attribution.txt +++ b/maps/Tuto/tilesets/LPc-submissions-Final Attribution.txt @@ -176,7 +176,7 @@ Tuomo Untinen CC-BY-3.0 Casper Nilsson ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - Asain themed shrine including red lantern + - Asian themed shrine including red lantern - foodog statue - Toro - Cherry blossom tree diff --git a/pusher/src/Model/PositionDispatcher.ts b/pusher/src/Model/PositionDispatcher.ts index 594328e3..f868cd2c 100644 --- a/pusher/src/Model/PositionDispatcher.ts +++ b/pusher/src/Model/PositionDispatcher.ts @@ -21,7 +21,7 @@ interface ZoneDescriptor { } export class PositionDispatcher { - // TODO: we need a way to clean the zones if noone is in the zone and noone listening (to free memory!) + // TODO: we need a way to clean the zones if no one is in the zone and no one listening (to free memory!) private zones: Zone[][] = []; diff --git a/pusher/src/Services/SocketManager.ts b/pusher/src/Services/SocketManager.ts index bd3e2cad..741eaa42 100644 --- a/pusher/src/Services/SocketManager.ts +++ b/pusher/src/Services/SocketManager.ts @@ -463,7 +463,7 @@ export class SocketManager implements ZoneEventListener { client.send(serverToClientMessage.serializeBinary().buffer, true); } catch (e) { - console.error("An error occured while generating the Jitsi JWT token: ", e); + console.error("An error occurred while generating the Jitsi JWT token: ", e); } } From 2a1af2a131f72ad5a00b6f4a4990a12fcedb0342 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?gr=C3=A9goire=20parant?= Date: Thu, 29 Jul 2021 16:42:31 +0200 Subject: [PATCH 41/47] PWA service workers (#1319) * PWA services worker - [x] Register service worker of PWA to install WorkAdventure application on desktop and mobile - [x] Create webpage specifique for PWA - [ ] Add register service to save and redirect on a card - [ ] Add possibilities to install PWA for one World (with register token if existing) * Finish PWA strategy to load last map visited * Fix feedback @Kharhamel * Fix feedback @Kharhamel --- front/dist/index.tmpl.html | 1 + front/dist/resources/service-worker.html | 62 ++++++++++++++ front/dist/resources/service-worker.js | 12 ++- .../dist/static/images/favicons/manifest.json | 5 +- front/src/Connexion/ConnectionManager.ts | 21 +++-- front/src/Connexion/LocalUserStore.ts | 80 ++++++++++--------- front/src/Network/ServiceWorker.ts | 20 +++++ front/src/index.ts | 13 --- 8 files changed, 156 insertions(+), 58 deletions(-) create mode 100644 front/dist/resources/service-worker.html create mode 100644 front/src/Network/ServiceWorker.ts diff --git a/front/dist/index.tmpl.html b/front/dist/index.tmpl.html index 30ea8353..187e513a 100644 --- a/front/dist/index.tmpl.html +++ b/front/dist/index.tmpl.html @@ -34,6 +34,7 @@ WorkAdventure +
diff --git a/front/dist/resources/service-worker.html b/front/dist/resources/service-worker.html new file mode 100644 index 00000000..45615b1a --- /dev/null +++ b/front/dist/resources/service-worker.html @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + WorkAdventure PWA + + + + + WorkAdventure logo +

Charging your workspace ...

+ + + \ No newline at end of file diff --git a/front/dist/resources/service-worker.js b/front/dist/resources/service-worker.js index e496f7fc..d9509b6f 100644 --- a/front/dist/resources/service-worker.js +++ b/front/dist/resources/service-worker.js @@ -48,6 +48,14 @@ self.addEventListener('fetch', function(event) { ); }); -self.addEventListener('activate', function(event) { - //TODO activate service worker +self.addEventListener('wait', function(event) { + //TODO wait +}); + +self.addEventListener('update', function(event) { + //TODO update +}); + +self.addEventListener('beforeinstallprompt', (e) => { + //TODO change prompt }); \ No newline at end of file diff --git a/front/dist/static/images/favicons/manifest.json b/front/dist/static/images/favicons/manifest.json index 30d08769..9f9e9af1 100644 --- a/front/dist/static/images/favicons/manifest.json +++ b/front/dist/static/images/favicons/manifest.json @@ -128,11 +128,12 @@ "type": "image\/png" } ], - "start_url": "/", + "start_url": "/resources/service-worker.html", "background_color": "#000000", "display_override": ["window-control-overlay", "minimal-ui"], "display": "standalone", - "scope": "/", + "orientation": "portrait-primary", + "scope": "/resources/", "lang": "en", "theme_color": "#000000", "shortcuts": [ diff --git a/front/src/Connexion/ConnectionManager.ts b/front/src/Connexion/ConnectionManager.ts index 0c459629..bca7f692 100644 --- a/front/src/Connexion/ConnectionManager.ts +++ b/front/src/Connexion/ConnectionManager.ts @@ -6,6 +6,7 @@ import { GameConnexionTypes, urlManager } from "../Url/UrlManager"; import { localUserStore } from "./LocalUserStore"; import { CharacterTexture, LocalUser } from "./LocalUser"; import { Room } from "./Room"; +import { _ServiceWorker } from "../Network/ServiceWorker"; class ConnectionManager { private localUser!: LocalUser; @@ -14,6 +15,8 @@ class ConnectionManager { private reconnectingTimeout: NodeJS.Timeout | null = null; private _unloading: boolean = false; + private serviceWorker?: _ServiceWorker; + get unloading() { return this._unloading; } @@ -30,6 +33,8 @@ class ConnectionManager { public async initGameConnexion(): Promise { const connexionType = urlManager.getGameConnexionType(); this.connexionType = connexionType; + + let room: Room | null = null; if (connexionType === GameConnexionTypes.register) { const organizationMemberToken = urlManager.getOrganizationToken(); const data = await Axios.post(`${PUSHER_URL}/register`, { organizationMemberToken }).then( @@ -40,7 +45,7 @@ class ConnectionManager { const roomUrl = data.roomUrl; - const room = await Room.createRoom( + room = await Room.createRoom( new URL( window.location.protocol + "//" + @@ -51,7 +56,6 @@ class ConnectionManager { ) ); urlManager.pushRoomIdToUrl(room); - return Promise.resolve(room); } else if ( connexionType === GameConnexionTypes.organization || connexionType === GameConnexionTypes.anonymous || @@ -90,7 +94,7 @@ class ConnectionManager { } //get detail map for anonymous login and set texture in local storage - const room = await Room.createRoom(new URL(roomPath)); + room = await Room.createRoom(new URL(roomPath)); if (room.textures != undefined && room.textures.length > 0) { //check if texture was changed if (localUser.textures.length === 0) { @@ -107,10 +111,13 @@ class ConnectionManager { this.localUser = localUser; localUserStore.saveUser(localUser); } - return Promise.resolve(room); + } + if (room == undefined) { + return Promise.reject(new Error("Invalid URL")); } - return Promise.reject(new Error("Invalid URL")); + this.serviceWorker = new _ServiceWorker(); + return Promise.resolve(room); } private async verifyToken(token: string): Promise { @@ -148,6 +155,7 @@ class ConnectionManager { viewport, companion ); + connection.onConnectError((error: object) => { console.log("An error occurred while connecting to socket server. Retrying"); reject(error); @@ -166,6 +174,9 @@ class ConnectionManager { }); connection.onConnect((connect: OnConnectInterface) => { + //save last room url connected + localUserStore.setLastRoomUrl(roomUrl); + resolve(connect); }); }).catch((err) => { diff --git a/front/src/Connexion/LocalUserStore.ts b/front/src/Connexion/LocalUserStore.ts index ace7b17e..065c8839 100644 --- a/front/src/Connexion/LocalUserStore.ts +++ b/front/src/Connexion/LocalUserStore.ts @@ -1,60 +1,61 @@ -import {areCharacterLayersValid, isUserNameValid, LocalUser} from "./LocalUser"; +import { areCharacterLayersValid, isUserNameValid, LocalUser } from "./LocalUser"; -const playerNameKey = 'playerName'; -const selectedPlayerKey = 'selectedPlayer'; -const customCursorPositionKey = 'customCursorPosition'; -const characterLayersKey = 'characterLayers'; -const companionKey = 'companion'; -const gameQualityKey = 'gameQuality'; -const videoQualityKey = 'videoQuality'; -const audioPlayerVolumeKey = 'audioVolume'; -const audioPlayerMuteKey = 'audioMute'; -const helpCameraSettingsShown = 'helpCameraSettingsShown'; -const fullscreenKey = 'fullscreen'; +const playerNameKey = "playerName"; +const selectedPlayerKey = "selectedPlayer"; +const customCursorPositionKey = "customCursorPosition"; +const characterLayersKey = "characterLayers"; +const companionKey = "companion"; +const gameQualityKey = "gameQuality"; +const videoQualityKey = "videoQuality"; +const audioPlayerVolumeKey = "audioVolume"; +const audioPlayerMuteKey = "audioMute"; +const helpCameraSettingsShown = "helpCameraSettingsShown"; +const fullscreenKey = "fullscreen"; +const lastRoomUrl = "lastRoomUrl"; class LocalUserStore { saveUser(localUser: LocalUser) { - localStorage.setItem('localUser', JSON.stringify(localUser)); + localStorage.setItem("localUser", JSON.stringify(localUser)); } - getLocalUser(): LocalUser|null { - const data = localStorage.getItem('localUser'); + getLocalUser(): LocalUser | null { + const data = localStorage.getItem("localUser"); return data ? JSON.parse(data) : null; } - setName(name:string): void { + setName(name: string): void { localStorage.setItem(playerNameKey, name); } - getName(): string|null { - const value = localStorage.getItem(playerNameKey) || ''; + getName(): string | null { + const value = localStorage.getItem(playerNameKey) || ""; return isUserNameValid(value) ? value : null; } setPlayerCharacterIndex(playerCharacterIndex: number): void { - localStorage.setItem(selectedPlayerKey, ''+playerCharacterIndex); + localStorage.setItem(selectedPlayerKey, "" + playerCharacterIndex); } getPlayerCharacterIndex(): number { - return parseInt(localStorage.getItem(selectedPlayerKey) || ''); + return parseInt(localStorage.getItem(selectedPlayerKey) || ""); } - setCustomCursorPosition(activeRow:number, selectedLayers: number[]): void { - localStorage.setItem(customCursorPositionKey, JSON.stringify({activeRow, selectedLayers})); + setCustomCursorPosition(activeRow: number, selectedLayers: number[]): void { + localStorage.setItem(customCursorPositionKey, JSON.stringify({ activeRow, selectedLayers })); } - getCustomCursorPosition(): {activeRow:number, selectedLayers:number[]}|null { + getCustomCursorPosition(): { activeRow: number; selectedLayers: number[] } | null { return JSON.parse(localStorage.getItem(customCursorPositionKey) || "null"); } setCharacterLayers(layers: string[]): void { localStorage.setItem(characterLayersKey, JSON.stringify(layers)); } - getCharacterLayers(): string[]|null { + getCharacterLayers(): string[] | null { const value = JSON.parse(localStorage.getItem(characterLayersKey) || "null"); return areCharacterLayersValid(value) ? value : null; } - setCompanion(companion: string|null): void { + setCompanion(companion: string | null): void { return localStorage.setItem(companionKey, JSON.stringify(companion)); } - getCompanion(): string|null { + getCompanion(): string | null { const companion = JSON.parse(localStorage.getItem(companionKey) || "null"); if (typeof companion !== "string" || companion === "") { @@ -68,45 +69,52 @@ class LocalUserStore { } setGameQualityValue(value: number): void { - localStorage.setItem(gameQualityKey, '' + value); + localStorage.setItem(gameQualityKey, "" + value); } getGameQualityValue(): number { - return parseInt(localStorage.getItem(gameQualityKey) || '60'); + return parseInt(localStorage.getItem(gameQualityKey) || "60"); } setVideoQualityValue(value: number): void { - localStorage.setItem(videoQualityKey, '' + value); + localStorage.setItem(videoQualityKey, "" + value); } getVideoQualityValue(): number { - return parseInt(localStorage.getItem(videoQualityKey) || '20'); + return parseInt(localStorage.getItem(videoQualityKey) || "20"); } setAudioPlayerVolume(value: number): void { - localStorage.setItem(audioPlayerVolumeKey, '' + value); + localStorage.setItem(audioPlayerVolumeKey, "" + value); } getAudioPlayerVolume(): number { - return parseFloat(localStorage.getItem(audioPlayerVolumeKey) || '1'); + return parseFloat(localStorage.getItem(audioPlayerVolumeKey) || "1"); } setAudioPlayerMuted(value: boolean): void { localStorage.setItem(audioPlayerMuteKey, value.toString()); } getAudioPlayerMuted(): boolean { - return localStorage.getItem(audioPlayerMuteKey) === 'true'; + return localStorage.getItem(audioPlayerMuteKey) === "true"; } setHelpCameraSettingsShown(): void { - localStorage.setItem(helpCameraSettingsShown, '1'); + localStorage.setItem(helpCameraSettingsShown, "1"); } getHelpCameraSettingsShown(): boolean { - return localStorage.getItem(helpCameraSettingsShown) === '1'; + return localStorage.getItem(helpCameraSettingsShown) === "1"; } setFullscreen(value: boolean): void { localStorage.setItem(fullscreenKey, value.toString()); } getFullscreen(): boolean { - return localStorage.getItem(fullscreenKey) === 'true'; + return localStorage.getItem(fullscreenKey) === "true"; + } + + setLastRoomUrl(roomUrl: string): void { + localStorage.setItem(lastRoomUrl, roomUrl.toString()); + } + getLastRoomUrl(): string { + return localStorage.getItem(lastRoomUrl) ?? ""; } } diff --git a/front/src/Network/ServiceWorker.ts b/front/src/Network/ServiceWorker.ts new file mode 100644 index 00000000..9bbcca85 --- /dev/null +++ b/front/src/Network/ServiceWorker.ts @@ -0,0 +1,20 @@ +export class _ServiceWorker { + constructor() { + if ("serviceWorker" in navigator) { + this.init(); + } + } + + init() { + window.addEventListener("load", () => { + navigator.serviceWorker + .register("/resources/service-worker.js") + .then((serviceWorker) => { + console.info("Service Worker registered: ", serviceWorker); + }) + .catch((error) => { + console.error("Error registering the Service Worker: ", error); + }); + }); + } +} diff --git a/front/src/index.ts b/front/src/index.ts index da243bde..6d2931a7 100644 --- a/front/src/index.ts +++ b/front/src/index.ts @@ -162,16 +162,3 @@ const app = new App({ }); export default app; - -if ("serviceWorker" in navigator) { - window.addEventListener("load", function () { - navigator.serviceWorker - .register("/resources/service-worker.js") - .then((serviceWorker) => { - console.log("Service Worker registered: ", serviceWorker); - }) - .catch((error) => { - console.error("Error registering the Service Worker: ", error); - }); - }); -} From 7ffe564e8eddcfdc0ef8c20d09f43405934e83f9 Mon Sep 17 00:00:00 2001 From: GRL78 <80678534+GRL78@users.noreply.github.com> Date: Thu, 29 Jul 2021 17:42:16 +0200 Subject: [PATCH 42/47] Graphic upgrade of the global message console (#1287) * Graphic upgrade of the global message console Fix: error if LoginScene doesn't exist * Rework graphic of global message console * Rework graphic of global message console * Remove console.log --- .../ConsoleGlobalMessageManager.svelte | 152 +++++++++++++++--- .../InputTextGlobalMessage.svelte | 60 ++++--- .../UploadAudioGlobalMessage.svelte | 136 ++++++++-------- front/src/Phaser/Game/Game.ts | 37 +++-- front/style/index.scss | 2 +- .../inputTextGlobalMessageSvelte-Style.scss | 24 +++ front/style/svelte-style.scss | 60 ------- 7 files changed, 273 insertions(+), 198 deletions(-) create mode 100644 front/style/inputTextGlobalMessageSvelte-Style.scss delete mode 100644 front/style/svelte-style.scss diff --git a/front/src/Components/ConsoleGlobalMessageManager/ConsoleGlobalMessageManager.svelte b/front/src/Components/ConsoleGlobalMessageManager/ConsoleGlobalMessageManager.svelte index 83837f28..eee061d0 100644 --- a/front/src/Components/ConsoleGlobalMessageManager/ConsoleGlobalMessageManager.svelte +++ b/front/src/Components/ConsoleGlobalMessageManager/ConsoleGlobalMessageManager.svelte @@ -1,12 +1,27 @@ + -
- -
-

Global Message

-
- -
- {#if inputSendTextActive} - - {/if} - {#if uploadMusicActive} - - {/if} -
+
+ +
+
+

Global Message

+ +
+
+ {#if inputSendTextActive} + + {/if} + {#if uploadMusicActive} + + {/if} +
+
-
\ No newline at end of file +
+ + + + diff --git a/front/src/Components/ConsoleGlobalMessageManager/InputTextGlobalMessage.svelte b/front/src/Components/ConsoleGlobalMessageManager/InputTextGlobalMessage.svelte index c11b4b0e..217e1710 100644 --- a/front/src/Components/ConsoleGlobalMessageManager/InputTextGlobalMessage.svelte +++ b/front/src/Components/ConsoleGlobalMessageManager/InputTextGlobalMessage.svelte @@ -1,15 +1,14 @@ -
-
- -
diff --git a/front/src/Components/ConsoleGlobalMessageManager/UploadAudioGlobalMessage.svelte b/front/src/Components/ConsoleGlobalMessageManager/UploadAudioGlobalMessage.svelte index 50954005..cae6bc7c 100644 --- a/front/src/Components/ConsoleGlobalMessageManager/UploadAudioGlobalMessage.svelte +++ b/front/src/Components/ConsoleGlobalMessageManager/UploadAudioGlobalMessage.svelte @@ -1,12 +1,11 @@ + +
+

Warning!

+

This world is close to its limit!

+
+ + \ No newline at end of file diff --git a/front/src/Connexion/RoomConnection.ts b/front/src/Connexion/RoomConnection.ts index 521a8473..f37c59d2 100644 --- a/front/src/Connexion/RoomConnection.ts +++ b/front/src/Connexion/RoomConnection.ts @@ -32,7 +32,8 @@ import { EmotePromptMessage, SendUserMessage, BanUserMessage, - VariableMessage, ErrorMessage, + VariableMessage, + ErrorMessage, } from "../Messages/generated/messages_pb"; import type { UserSimplePeerInterface } from "../WebRtc/SimplePeer"; @@ -54,9 +55,9 @@ import { import type { BodyResourceDescriptionInterface } from "../Phaser/Entity/PlayerTextures"; import { adminMessagesService } from "./AdminMessagesService"; import { worldFullMessageStream } from "./WorldFullMessageStream"; -import { worldFullWarningStream } from "./WorldFullWarningStream"; import { connectionManager } from "./ConnectionManager"; import { emoteEventStream } from "./EmoteEventStream"; +import { warningContainerStore } from "../Stores/MenuStore"; const manualPingDelay = 20000; @@ -167,7 +168,7 @@ export class RoomConnection implements RoomConnection { 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()); + console.error("An error occurred server side: " + errorMessage.getMessage()); } else if (subMessage.hasVariablemessage()) { event = EventMessage.SET_VARIABLE; payload = subMessage.getVariablemessage(); @@ -192,7 +193,14 @@ export class RoomConnection implements RoomConnection { 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); + console.error( + 'Unable to unserialize value received from server for variable "' + + variable.getName() + + '". Value received: "' + + variable.getValue() + + '". Error: ', + e + ); } } @@ -236,7 +244,7 @@ export class RoomConnection implements RoomConnection { } else if (message.hasBanusermessage()) { adminMessagesService.onSendusermessage(message.getBanusermessage() as BanUserMessage); } else if (message.hasWorldfullwarningmessage()) { - worldFullWarningStream.onMessage(); + warningContainerStore.activateWarningContainer(); } else if (message.hasRefreshroommessage()) { //todo: implement a way to notify the user the room was refreshed. } else { @@ -659,7 +667,14 @@ export class RoomConnection implements RoomConnection { try { value = JSON.parse(serializedValue); } catch (e) { - console.error('Unable to unserialize value received from server for variable "'+name+'". Value received: "'+serializedValue+'". Error: ', e); + console.error( + 'Unable to unserialize value received from server for variable "' + + name + + '". Value received: "' + + serializedValue + + '". Error: ', + e + ); } } callback(name, value); diff --git a/front/src/Connexion/WorldFullWarningStream.ts b/front/src/Connexion/WorldFullWarningStream.ts deleted file mode 100644 index 5e552830..00000000 --- a/front/src/Connexion/WorldFullWarningStream.ts +++ /dev/null @@ -1,14 +0,0 @@ -import {Subject} from "rxjs"; - -class WorldFullWarningStream { - - private _stream:Subject = new Subject(); - public stream = this._stream.asObservable(); - - - onMessage() { - this._stream.next(); - } -} - -export const worldFullWarningStream = new WorldFullWarningStream(); \ No newline at end of file diff --git a/front/src/Phaser/Components/WarningContainer.ts b/front/src/Phaser/Components/WarningContainer.ts deleted file mode 100644 index 97e97660..00000000 --- a/front/src/Phaser/Components/WarningContainer.ts +++ /dev/null @@ -1,14 +0,0 @@ - -export const warningContainerKey = 'warningContainer'; -export const warningContainerHtml = 'resources/html/warningContainer.html'; - -export class WarningContainer extends Phaser.GameObjects.DOMElement { - - constructor(scene: Phaser.Scene) { - super(scene, 100, 0); - this.setOrigin(0, 0); - this.createFromCache(warningContainerKey); - this.scene.add.existing(this); - } - -} \ No newline at end of file diff --git a/front/src/Phaser/Menu/MenuScene.ts b/front/src/Phaser/Menu/MenuScene.ts index 4e9297b6..5c8949a7 100644 --- a/front/src/Phaser/Menu/MenuScene.ts +++ b/front/src/Phaser/Menu/MenuScene.ts @@ -6,8 +6,6 @@ import { localUserStore } from "../../Connexion/LocalUserStore"; import { gameReportKey, gameReportRessource, ReportMenu } from "./ReportMenu"; import { connectionManager } from "../../Connexion/ConnectionManager"; import { GameConnexionTypes } from "../../Url/UrlManager"; -import { WarningContainer, warningContainerHtml, warningContainerKey } from "../Components/WarningContainer"; -import { worldFullWarningStream } from "../../Connexion/WorldFullWarningStream"; import { menuIconVisible } from "../../Stores/MenuStore"; import { videoConstraintStore } from "../../Stores/MediaStore"; import { showReportScreenStore } from "../../Stores/ShowReportScreenStore"; @@ -45,8 +43,6 @@ export class MenuScene extends Phaser.Scene { private gameQualityValue: number; private videoQualityValue: number; private menuButton!: Phaser.GameObjects.DOMElement; - private warningContainer: WarningContainer | null = null; - private warningContainerTimeout: NodeJS.Timeout | null = null; private subscriptions = new Subscription(); constructor() { super({ key: MenuSceneName }); @@ -91,7 +87,6 @@ export class MenuScene extends Phaser.Scene { this.load.html(gameSettingsMenuKey, "resources/html/gameQualityMenu.html"); this.load.html(gameShare, "resources/html/gameShare.html"); this.load.html(gameReportKey, gameReportRessource); - this.load.html(warningContainerKey, warningContainerHtml); } create() { @@ -147,7 +142,6 @@ export class MenuScene extends Phaser.Scene { this.menuElement.addListener("click"); this.menuElement.on("click", this.onMenuClick.bind(this)); - worldFullWarningStream.stream.subscribe(() => this.showWorldCapacityWarning()); chatVisibilityStore.subscribe((v) => { this.menuButton.setVisible(!v); }); @@ -194,20 +188,6 @@ export class MenuScene extends Phaser.Scene { }); } - private showWorldCapacityWarning() { - if (!this.warningContainer) { - this.warningContainer = new WarningContainer(this); - } - if (this.warningContainerTimeout) { - clearTimeout(this.warningContainerTimeout); - } - this.warningContainerTimeout = setTimeout(() => { - this.warningContainer?.destroy(); - this.warningContainer = null; - this.warningContainerTimeout = null; - }, 120000); - } - private closeSideMenu(): void { if (!this.sideMenuOpened) return; this.sideMenuOpened = false; diff --git a/front/src/Stores/MenuStore.ts b/front/src/Stores/MenuStore.ts index c7c02130..084e8ce8 100644 --- a/front/src/Stores/MenuStore.ts +++ b/front/src/Stores/MenuStore.ts @@ -1,3 +1,23 @@ -import { derived, writable, Writable } from "svelte/store"; +import { writable } from "svelte/store"; +import Timeout = NodeJS.Timeout; export const menuIconVisible = writable(false); + +let warningContainerTimeout: Timeout | null = null; +function createWarningContainerStore() { + const { subscribe, set } = writable(false); + + return { + subscribe, + activateWarningContainer() { + set(true); + if (warningContainerTimeout) clearTimeout(warningContainerTimeout); + warningContainerTimeout = setTimeout(() => { + set(false); + warningContainerTimeout = null; + }, 120000); + }, + }; +} + +export const warningContainerStore = createWarningContainerStore(); From ebdcf8804d7ab72a51bac10eeb476467caa16f43 Mon Sep 17 00:00:00 2001 From: kharhamel Date: Fri, 30 Jul 2021 14:08:27 +0200 Subject: [PATCH 45/47] added admin link to the warning container --- .github/workflows/continuous_integration.yml | 1 + .github/workflows/push-to-npm.yml | 1 + .../WarningContainer/WarningContainer.svelte | 13 ++++++++- front/src/Enum/EnvironmentVariable.ts | 28 ++++++++++--------- front/src/Phaser/Game/GameScene.ts | 3 ++ front/src/Phaser/Menu/MenuScene.ts | 3 +- front/src/Stores/GameStore.ts | 4 ++- front/webpack.config.ts | 2 +- 8 files changed, 38 insertions(+), 17 deletions(-) diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index faf50c7a..ecd7ffef 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -50,6 +50,7 @@ jobs: run: yarn run build env: PUSHER_URL: "//localhost:8080" + ADMIN_URL: "//localhost:80" working-directory: "front" - name: "Svelte check" diff --git a/.github/workflows/push-to-npm.yml b/.github/workflows/push-to-npm.yml index fd247b11..1208e0c0 100644 --- a/.github/workflows/push-to-npm.yml +++ b/.github/workflows/push-to-npm.yml @@ -47,6 +47,7 @@ jobs: run: yarn run build-typings env: PUSHER_URL: "//localhost:8080" + ADMIN_URL: "//localhost:80" working-directory: "front" # We build the front to generate the typings of iframe_api, then we copy those typings in a separate package. diff --git a/front/src/Components/WarningContainer/WarningContainer.svelte b/front/src/Components/WarningContainer/WarningContainer.svelte index 26961357..57b956e2 100644 --- a/front/src/Components/WarningContainer/WarningContainer.svelte +++ b/front/src/Components/WarningContainer/WarningContainer.svelte @@ -1,14 +1,25 @@

Warning!

-

This world is close to its limit!

+ {#if $userIsAdminStore} +

This world is close to its limit!. You can upgrade its capacity here

+ {:else} +

This world is close to its limit!

+ {/if} +