diff --git a/.env.template b/.env.template index 4ba9bcec..34537b6b 100644 --- a/.env.template +++ b/.env.template @@ -19,3 +19,6 @@ ACME_EMAIL= MAX_PER_GROUP=4 MAX_USERNAME_LENGTH=8 +OPID_CLIENT_ID= +OPID_CLIENT_SECRET= +OPID_CLIENT_ISSUER= diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index faf50c7a..ca2a01cb 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" @@ -81,7 +82,7 @@ jobs: - name: "Setup NodeJS" uses: actions/setup-node@v1 with: - node-version: '12.x' + node-version: '14.x' - name: Install Protoc uses: arduino/setup-protoc@v1 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/CHANGELOG.md b/CHANGELOG.md index 46034e54..a9af163a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,12 @@ -## Version 1.4.x-dev +## Version develop + +### Updates + +- Rewrote the way authentification works: the auth jwt token can now contains an email instead of an uuid +- Added an OpenId login flow than can be plugged to any OIDC provider. +- + +## Version 1.4.10 ### Updates @@ -8,17 +16,27 @@ - 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, delete or change existing property of a layer - Use `WA.player.onPlayerMove(): void` to track the movement of the current player - - Use `WA.player.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 add, delete or 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): 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. - 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/package.json b/back/package.json index 7015b9b8..8a1e445e 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,10 +48,12 @@ "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", "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" @@ -64,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 3df52678..e907fcb7 100644 --- a/back/src/Model/GameRoom.ts +++ b/back/src/Model/GameRoom.ts @@ -5,35 +5,63 @@ import { PositionInterface } from "_Model/PositionInterface"; import { EmoteCallback, EntersCallback, LeavesCallback, MovesCallback } from "_Model/Zone"; import { PositionNotifier } from "./PositionNotifier"; import { Movable } from "_Model/Movable"; -import { EmoteEventMessage, JoinRoomMessage } from "../Messages/generated/messages_pb"; +import { + BatchToPusherMessage, + BatchToPusherRoomMessage, + EmoteEventMessage, + ErrorMessage, + JoinRoomMessage, + SubToPusherRoomMessage, + VariableMessage, + VariableWithTagMessage, +} 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"; +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 { emitErrorOnRoomSocket } from "../Services/MessageHelpers"; 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 users = new Map(); + private readonly usersByUuid = new Map(); + private readonly groups = new Set(); + private readonly admins = new Set(); - private readonly connectCallback: ConnectCallback; - private readonly disconnectCallback: DisconnectCallback; - - private itemsState: Map = new Map(); + private itemsState = new Map(); private readonly positionNotifier: PositionNotifier; - public readonly roomUrl: string; private versionNumber: number = 1; private nextUserId: number = 1; - constructor( + private roomListeners: Set = new Set(); + + 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, @@ -43,19 +71,23 @@ 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[] { @@ -298,6 +330,37 @@ export class GameRoom { return this.itemsState; } + 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); + + // 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); + variableMessage.setValue(value); + if (readableBy) { + variableMessage.setReadableby(readableBy); + } + + 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 { return this.positionNotifier.addZoneListener(call, x, y); } @@ -327,4 +390,98 @@ 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); + } + + /** + * 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 = 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); + + const variablesManager = new VariablesManager(this.roomUrl, null); + return variablesManager.init(); + } else { + throw e; + } + }); + } + return this.variableManagerPromise; + } + + public async getVariablesForTags(tags: string[]): Promise> { + const variablesManager = await this.getVariableManager(); + return variablesManager.getVariablesForTags(tags); + } } 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 9aaf1edb..3369eef9 100644 --- a/back/src/RoomManager.ts +++ b/back/src/RoomManager.ts @@ -5,6 +5,8 @@ import { AdminPusherToBackMessage, AdminRoomMessage, BanMessage, + BatchToPusherMessage, + BatchToPusherRoomMessage, EmotePromptMessage, EmptyMessage, ItemEventMessage, @@ -13,17 +15,18 @@ import { PusherToBackMessage, QueryJitsiJwtMessage, RefreshRoomPromptMessage, + RoomMessage, ServerToAdminClientMessage, - ServerToClientMessage, SilentMessage, UserMovesMessage, + VariableMessage, WebRtcSignalToServerMessage, WorldFullWarningToRoomMessage, ZoneMessage, } 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 +35,8 @@ 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 = { joinRoom: (call: UserSocket): void => { @@ -42,79 +46,96 @@ const roomManager: IRoomManagerServer = { let user: User | null = null; call.on("data", (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); - } - }); - } 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.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); + (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 { + //Connection 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) { - emitError(call, e); - call.end(); - } + })().catch((e) => console.error(e)); }); call.on("end", () => { @@ -136,20 +157,54 @@ 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(); + }); + }, + + listenRoom(call: RoomSocket): void { + debug("listenRoom called"); + const roomMessage = call.request; + + 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)); + call.end(); + }); + + call.on("close", () => { + debug("listenRoom connection closed"); + 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)); call.end(); }); }, @@ -165,9 +220,12 @@ const roomManager: IRoomManagerServer = { if (room === null) { if (message.hasSubscribetoroom()) { const roomId = message.getSubscribetoroom(); - socketManager.handleJoinAdminRoom(admin, roomId).then((gameRoom: GameRoom) => { - room = gameRoom; - }); + 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"); } @@ -192,11 +250,9 @@ const roomManager: IRoomManagerServer = { }); }, sendAdminMessage(call: ServerUnaryCall, callback: sendUnaryData): void { - socketManager.sendAdminMessage( - call.request.getRoomid(), - call.request.getRecipientuuid(), - call.request.getMessage() - ); + socketManager + .sendAdminMessage(call.request.getRoomid(), call.request.getRecipientuuid(), call.request.getMessage()) + .catch((e) => console.error(e)); callback(null, new EmptyMessage()); }, @@ -207,26 +263,33 @@ 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..d3402b92 --- /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..a4984fdd --- /dev/null +++ b/back/src/Services/LocalUrlError.ts @@ -0,0 +1 @@ +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..0a8cb4bd --- /dev/null +++ b/back/src/Services/MapFetcher.ts @@ -0,0 +1,67 @@ +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 + timeout: 10000, // Timeout after 10 seconds + }); + + 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).bind(resolver)(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..606374be 100644 --- a/back/src/Services/MessageHelpers.ts +++ b/back/src/Services/MessageHelpers.ts @@ -1,5 +1,14 @@ -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 +22,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/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..95d757ca --- /dev/null +++ b/back/src/Services/Repository/RedisVariablesRepository.ts @@ -0,0 +1,43 @@ +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>; + private readonly hdel: OmitThisParameter<(arg1: string, arg2: string) => Promise>; + + constructor(private redisClient: RedisClient) { + /* eslint-disable @typescript-eslint/unbound-method */ + this.hgetall = promisify(redisClient.hgetall).bind(redisClient); + this.hset = promisify(redisClient.hset).bind(redisClient); + this.hdel = promisify(redisClient.hdel).bind(redisClient); + /* eslint-enable @typescript-eslint/unbound-method */ + } + + /** + * 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 { + // 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 + + // @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 79e4f99d..9fec90f3 100644 --- a/back/src/Services/SocketManager.ts +++ b/back/src/Services/SocketManager.ts @@ -30,6 +30,9 @@ import { BanUserMessage, RefreshRoomMessage, EmotePromptMessage, + VariableMessage, + BatchToPusherRoomMessage, + SubToPusherRoomMessage, } from "../Messages/generated/messages_pb"; import { User, UserSocket } from "../Model/User"; import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils"; @@ -48,7 +51,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"; @@ -65,7 +68,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) => { @@ -101,6 +106,16 @@ export class SocketManager { roomJoinedMessage.addItem(itemStateMessage); } + const variables = await room.getVariablesForTags(user.tags); + + for (const [name, value] of variables.entries()) { + const variableMessage = new VariableMessage(); + variableMessage.setName(name); + variableMessage.setValue(value); + + roomJoinedMessage.addVariable(variableMessage); + } + roomJoinedMessage.setCurrentuserid(user.id); const serverToClientMessage = new ServerToClientMessage(); @@ -114,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 @@ -156,32 +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): Promise { + return room.setVariable(variableMessage.getName(), variableMessage.getValue(), user); } emitVideo(room: GameRoom, user: User, data: WebRtcSignalToServerMessage): void { @@ -250,7 +254,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); } @@ -261,10 +265,10 @@ 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 roomPromise = this.roomsPromises.get(roomId); + if (roomPromise === undefined) { + roomPromise = GameRoom.create( roomId, (user: User, group: Group) => this.joinWebRtcRoom(user, group), (user: User, group: Group) => this.disConnectedUser(user, group), @@ -278,11 +282,18 @@ export class SocketManager { this.onClientLeave(thing, newZone, listener), (emoteEventMessage: EmoteEventMessage, listener: ZoneSocket) => this.onEmote(emoteEventMessage, listener) - ); - gaugeManager.incNbRoomGauge(); - this.rooms.set(roomId, world); + ) + .then((gameRoom) => { + gaugeManager.incNbRoomGauge(); + return gameRoom; + }) + .catch((e) => { + this.roomsPromises.delete(roomId); + throw e; + }); + this.roomsPromises.set(roomId, roomPromise); } - return Promise.resolve(world); + return roomPromise; } private async joinRoom( @@ -508,21 +519,16 @@ 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); } } - public getWorlds(): Map { - return this.rooms; + public getWorlds(): Map> { + return this.roomsPromises; } public handleQueryJitsiJwtMessage(user: User, queryJitsiJwtMessage: QueryJitsiJwtMessage) { @@ -592,11 +598,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); @@ -637,16 +642,37 @@ 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); } + async addRoomListener(call: RoomSocket, roomId: string) { + const room = await this.getOrCreateRoom(roomId); + if (!room) { + throw new Error("In addRoomListener, could not find room with id '" + roomId + "'"); + } + + room.addRoomListener(call); + + const batchMessage = new BatchToPusherRoomMessage(); + + call.write(batchMessage); + } + + async removeRoomListener(call: RoomSocket, roomId: string) { + const room = await this.roomsPromises.get(roomId); + if (!room) { + throw new Error("In removeRoomListener, could not find room with id '" + roomId + "'"); + } + + room.removeRoomListener(call); + } + public async handleJoinAdminRoom(admin: Admin, roomId: string): Promise { const room = await socketManager.getOrCreateRoom(roomId); @@ -658,14 +684,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 '" + @@ -697,8 +723,8 @@ export class SocketManager { } } - 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 '" + @@ -735,8 +761,8 @@ export class SocketManager { } } - 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( @@ -759,12 +785,12 @@ 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( - "In sendAdminRoomMessage, could not find room with id '" + + "In dispatchWorldFullWarning, could not find room with id '" + roomId + "'. Maybe the room was closed a few milliseconds ago and there was a race condition?" ); @@ -781,8 +807,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..e8aaef25 --- /dev/null +++ b/back/src/Services/VariablesManager.ts @@ -0,0 +1,218 @@ +/** + * 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 { variablesRepository } from "./Repository/VariablesRepository"; +import { redisClient } from "./RedisClient"; + +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 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) { + 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); + } + } + } + } + + /** + * Let's load data from the Redis backend. + */ + public async init(): Promise { + if (!this.shouldPersist()) { + return this; + } + 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; + } + + /** + * 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") { + 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; + } + + /** + * 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; + let variableObject: Variable | undefined; + if (this.variableObjects) { + 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.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; + } + + // If the value is not modified, return false + if (this._variables.get(name) === value) { + return false; + } + + this._variables.set(name, value); + + if (variableObject !== undefined && variableObject.persist) { + variablesRepository + .saveVariable(this.roomUrl, name, value) + .catch((e) => console.error("Error while saving variable in Redis:", e)); + } + + 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.includes(variableObject.readableBy)) { + readableVariables.set(key, value); + } + } + return readableVariables; + } +} 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/tests/MapFetcherTest.ts b/back/tests/MapFetcherTest.ts new file mode 100644 index 00000000..1e7ca447 --- /dev/null +++ b/back/tests/MapFetcherTest.ts @@ -0,0 +1,32 @@ +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(); + }); + + it("should throw error on invalid domain", async () => { + await expectAsync( + mapFetcher.isLocalUrl("https://this.domain.name.doesnotexistfoobgjkgfdjkgldf.com") + ).toBeRejected(); + }); +}); 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..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" @@ -187,6 +194,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" @@ -797,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" @@ -1181,6 +1200,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 +1441,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" @@ -2424,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/benchmark/index.ts b/benchmark/index.ts index 05928dd1..36d2c6f4 100644 --- a/benchmark/index.ts +++ b/benchmark/index.ts @@ -13,7 +13,6 @@ RoomConnection.setWebsocketFactory((url: string) => { }); async function startOneUser(): Promise { - await connectionManager.anonymousLogin(true); const onConnect = await connectionManager.connectToRoomSocket(process.env.ROOM_ID ? process.env.ROOM_ID : '_/global/maps.workadventure.localhost/Floor0/floor0.json', 'TEST', ['male3'], { x: 783, @@ -23,7 +22,7 @@ async function startOneUser(): Promise { bottom: 200, left: 500, right: 800 - }); + }, null); const connection = onConnect.connection; 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..4e85d702 100644 --- a/docker-compose.single-domain.yaml +++ b/docker-compose.single-domain.yaml @@ -53,7 +53,7 @@ services: - "traefik.http.routers.front-ssl.service=front" pusher: - image: thecodingmachine/nodejs:12 + image: thecodingmachine/nodejs:14 command: yarn dev #command: yarn run prod #command: yarn run profile @@ -66,6 +66,10 @@ services: API_URL: back:50051 JITSI_URL: $JITSI_URL JITSI_ISS: $JITSI_ISS + FRONT_URL: http://localhost + OPID_CLIENT_ID: $OPID_CLIENT_ID + OPID_CLIENT_SECRET: $OPID_CLIENT_SECRET + OPID_CLIENT_ISSUER: $OPID_CLIENT_ISSUER volumes: - ./pusher:/usr/src/app labels: @@ -120,6 +124,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 +174,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..4b3904dd 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -55,7 +55,7 @@ services: - "traefik.http.routers.front-ssl.service=front" pusher: - image: thecodingmachine/nodejs:12 + image: thecodingmachine/nodejs:14 command: yarn dev environment: DEBUG: "socket:*" @@ -66,6 +66,10 @@ services: API_URL: back:50051 JITSI_URL: $JITSI_URL JITSI_ISS: $JITSI_ISS + FRONT_URL: http://play.workadventure.localhost + OPID_CLIENT_ID: $OPID_CLIENT_ID + OPID_CLIENT_SECRET: $OPID_CLIENT_SECRET + OPID_CLIENT_ISSUER: $OPID_CLIENT_ISSUER volumes: - ./pusher:/usr/src/app labels: @@ -115,6 +119,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 +163,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-player.md b/docs/maps/api-player.md index 0efe2941..ed73c32d 100644 --- a/docs/maps/api-player.md +++ b/docs/maps/api-player.md @@ -1,6 +1,62 @@ {.section-title.accent.text-primary} # API Player functions Reference +### Get the player name + +``` +WA.player.name: string; +``` + +The player name is available from the `WA.player.name` property. + +{.alert.alert-info} +You need to wait for the end of the initialization before accessing `WA.player.name` + +```typescript +WA.onInit().then(() => { + console.log('Player name: ', WA.player.name); +}) +``` + +### Get the player ID + +``` +WA.player.id: string|undefined; +``` + +The player ID is available from the `WA.player.id` property. +This is a unique identifier for a given player. Anonymous player might not have an id. + +{.alert.alert-info} +You need to wait for the end of the initialization before accessing `WA.player.id` + +```typescript +WA.onInit().then(() => { + console.log('Player ID: ', WA.player.id); +}) +``` + +### Get the tags of the player + +``` +WA.player.tags: string[]; +``` + +The player tags are available from the `WA.player.tags` property. +They represent a set of rights the player acquires after login in. + +{.alert.alert-warn} +Tags attributed to a user depend on the authentication system you are using. For the hosted version +of WorkAdventure, you can define tags related to the user in the [administration panel](https://workadventu.re/admin-guide/manage-members). + +{.alert.alert-info} +You need to wait for the end of the initialization before accessing `WA.player.tags` + +```typescript +WA.onInit().then(() => { + console.log('Tags: ', WA.player.tags); +}) +``` ### Listen to player movement ``` @@ -19,4 +75,4 @@ The event has the following attributes : Example : ```javascript WA.player.onPlayerMove(console.log); -``` \ No newline at end of file +``` diff --git a/docs/maps/api-reference.md b/docs/maps/api-reference.md index 8c8205d8..d044668f 100644 --- a/docs/maps/api-reference.md +++ b/docs/maps/api-reference.md @@ -1,9 +1,11 @@ {.section-title.accent.text-primary} # API Reference +- [Start / Init functions](api-start.md) - [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 8bc2b3d2..ca708b29 100644 --- a/docs/maps/api-room.md +++ b/docs/maps/api-room.md @@ -79,6 +79,58 @@ Example : WA.room.setProperty('wikiLayer', 'openWebsite', 'https://www.wikipedia.org/'); ``` +### Get the room id + +``` +WA.room.id: string; +``` + +The ID of the current room is available from the `WA.room.id` property. + +{.alert.alert-info} +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: 'https://play.workadventu.re/@/myorg/myworld/myroom', or 'https://play.workadventu.re/_/global/mymap.org/map.json" +}) +``` + +### Get the map URL + +``` +WA.room.mapURL: string; +``` + +The URL of the map is available from the `WA.room.mapURL` property. + +{.alert.alert-info} +You need to wait for the end of the initialization before accessing `WA.room.mapURL` + +```typescript +WA.onInit().then(() => { + console.log('Map URL: ', WA.room.mapURL); + // Will output something like: 'https://mymap.org/map.json" +}) +``` + + + +### Getting map data +``` +WA.room.getTiledMap(): Promise +``` + +Returns a promise that resolves to the JSON map file. + +```javascript +const map = await WA.room.getTiledMap(); +console.log("Map generated with Tiled version ", map.tiledversion); +``` + +Check the [Tiled documentation to learn more about the format of the JSON map](https://doc.mapeditor.org/en/stable/reference/json-map-format/). + ### Changing tiles ``` WA.room.setTiles(tiles: TileDescriptor[]): void diff --git a/docs/maps/api-start.md b/docs/maps/api-start.md new file mode 100644 index 00000000..0fcfc62d --- /dev/null +++ b/docs/maps/api-start.md @@ -0,0 +1,30 @@ +{.section-title.accent.text-primary} +# API start functions Reference + +### Waiting for WorkAdventure API to be available + +When your script / iFrame loads WorkAdventure, it takes a few milliseconds for your script / iFrame to exchange +data with WorkAdventure. You should wait for the WorkAdventure API to be fully ready using the `WA.onInit()` method. + +``` +WA.onInit(): Promise +``` + +Some properties (like the current user name, or the room ID) are not available until `WA.onInit` has completed. + +Example: + +```typescript +WA.onInit().then(() => { + console.log('Current player name: ', WA.player.name); +}); +``` + +Or the same code, using await/async: + +```typescript +(async () => { + await WA.onInit(); + console.log('Current player name: ', WA.player.name); +})(); +``` diff --git a/docs/maps/api-state.md b/docs/maps/api-state.md new file mode 100644 index 00000000..87a8b3aa --- /dev/null +++ b/docs/maps/api-state.md @@ -0,0 +1,136 @@ +{.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. + +
+
+ +
+
+ +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 + +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. + +### 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 "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. +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. + +``` +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(); +``` 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. diff --git a/docs/maps/text.md b/docs/maps/text.md new file mode 100644 index 00000000..df3b2660 --- /dev/null +++ b/docs/maps/text.md @@ -0,0 +1,35 @@ +{.section-title.accent.text-primary} +# Writing text on a map + +## Solution 1: design a specific tileset (recommended) + +If you want to write some text on a map, our recommendation is to create a tileset that contains +your text. You will obtain the most pleasant graphical result with this result, since you will be able +to control the fonts you use, and you will be able to disable the antialiasing of the font to get a +"crispy" result easily. + +## Solution 2: using a "text" object in Tiled + +On "object" layers, Tiled has support for "Text" objects. You can use these objects to add some +text on your map. + +WorkAdventure will do its best to display the text properly. However, you need to know that: + +- Tiled displays your system fonts. +- Computers have different sets of fonts. Therefore, browsers never rely on system fonts +- Which means if you select a font in Tiled, it is quite unlikely it will render properly in WorkAdventure + +To circumvent this problem, in your text object in Tiled, you can add an additional property: `font-family`. + +The `font-family` property can contain any "web-font" that can be loaded by your browser. + +{.alert.alert-info} +**Pro-tip:** By default, WorkAdventure uses the **'"Press Start 2P"'** font, which is a great pixelated +font that has support for a variety of accents. It renders great when used at *8px* size. + +
+
+ +
The "font-family" property
+
+
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/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/html/gameMenu.html b/front/dist/resources/html/gameMenu.html index bb0a6e1e..2076ef66 100644 --- a/front/dist/resources/html/gameMenu.html +++ b/front/dist/resources/html/gameMenu.html @@ -60,6 +60,9 @@
+
diff --git a/front/dist/resources/html/warningContainer.html b/front/dist/resources/html/warningContainer.html deleted file mode 100644 index 832ac4da..00000000 --- a/front/dist/resources/html/warningContainer.html +++ /dev/null @@ -1,18 +0,0 @@ - - -
-

Warning!

-

This world is close to its limit!

-
\ No newline at end of file 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/package.json b/front/package.json index 4e4d66c9..5c21019b 100644 --- a/front/package.json +++ b/front/package.json @@ -10,6 +10,7 @@ "@types/mini-css-extract-plugin": "^1.4.3", "@types/node": "^15.3.0", "@types/quill": "^1.3.7", + "@types/uuidv4": "^5.0.0", "@types/webpack-dev-server": "^3.11.4", "@typescript-eslint/eslint-plugin": "^4.23.0", "@typescript-eslint/parser": "^4.23.0", @@ -53,7 +54,8 @@ "rxjs": "^6.6.3", "simple-peer": "^9.11.0", "socket.io-client": "^2.3.0", - "standardized-audio-context": "^25.2.4" + "standardized-audio-context": "^25.2.4", + "uuidv4": "^6.2.10" }, "scripts": { "start": "run-p templater serve svelte-check-watch", diff --git a/front/src/Api/Events/GameStateEvent.ts b/front/src/Api/Events/GameStateEvent.ts index edeeef80..112c2880 100644 --- a/front/src/Api/Events/GameStateEvent.ts +++ b/front/src/Api/Events/GameStateEvent.ts @@ -4,10 +4,11 @@ export const isGameStateEvent = new tg.IsInterface() .withProperties({ roomId: tg.isString, mapUrl: tg.isString, - nickname: tg.isUnion(tg.isString, tg.isNull), + nickname: tg.isString, uuid: tg.isUnion(tg.isString, tg.isUndefined), startLayerName: tg.isUnion(tg.isString, tg.isNull), tags: tg.isArray(tg.isString), + variables: tg.isObject, }) .get(); /** diff --git a/front/src/Api/Events/IframeEvent.ts b/front/src/Api/Events/IframeEvent.ts index fc3384f8..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"; @@ -9,7 +10,7 @@ import type { OpenCoWebSiteEvent } from "./OpenCoWebSiteEvent"; import type { OpenPopupEvent } from "./OpenPopupEvent"; import type { OpenTabEvent } from "./OpenTabEvent"; import type { UserInputChatEvent } from "./UserInputChatEvent"; -import type { DataLayerEvent } from "./DataLayerEvent"; +import type { MapDataEvent } from "./MapDataEvent"; import type { LayerEvent } from "./LayerEvent"; import type { SetPropertyEvent } from "./setPropertyEvent"; import type { LoadSoundEvent } from "./LoadSoundEvent"; @@ -18,6 +19,10 @@ 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 {isGameStateEvent} from "./GameStateEvent"; +import {isMapDataEvent} from "./MapDataEvent"; +import {isSetVariableEvent} from "./SetVariableEvent"; export interface TypedMessageEvent extends MessageEvent { data: T; @@ -43,7 +48,6 @@ export type IframeEventMap = { showLayer: LayerEvent; hideLayer: LayerEvent; setProperty: SetPropertyEvent; - getDataLayer: undefined; loadSound: LoadSoundEvent; playSound: PlaySoundEvent; stopSound: null; @@ -66,8 +70,8 @@ export interface IframeResponseEventMap { leaveEvent: EnterLeaveEvent; buttonClickedEvent: ButtonClickedEvent; hasPlayerMoved: HasPlayerMovedEvent; - dataLayer: DataLayerEvent; menuItemClicked: MenuItemClickedEvent; + setVariable: SetVariableEvent; } export interface IframeResponseEvent { type: T; @@ -81,13 +85,33 @@ 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: tg.isUndefined, + answer: isMapDataEvent, + }, + setVariable: { + 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> + } } export interface IframeQuery { @@ -100,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/Events/DataLayerEvent.ts b/front/src/Api/Events/MapDataEvent.ts similarity index 70% rename from front/src/Api/Events/DataLayerEvent.ts rename to front/src/Api/Events/MapDataEvent.ts index 3062c1bc..f63164ed 100644 --- a/front/src/Api/Events/DataLayerEvent.ts +++ b/front/src/Api/Events/MapDataEvent.ts @@ -1,6 +1,6 @@ import * as tg from "generic-type-guard"; -export const isDataLayerEvent = new tg.IsInterface() +export const isMapDataEvent = new tg.IsInterface() .withProperties({ data: tg.isObject, }) @@ -9,4 +9,4 @@ export const isDataLayerEvent = new tg.IsInterface() /** * A message sent from the game to the iFrame when the data of the layers change after the iFrame send a message to the game that it want to listen to the data of the layers */ -export type DataLayerEvent = tg.GuardedType; +export type MapDataEvent = 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..d0c82253 100644 --- a/front/src/Api/IframeListener.ts +++ b/front/src/Api/IframeListener.ts @@ -26,20 +26,24 @@ import { isLoadSoundEvent, LoadSoundEvent } from "./Events/LoadSoundEvent"; import { isSetPropertyEvent, SetPropertyEvent } from "./Events/setPropertyEvent"; import { isLayerEvent, LayerEvent } from "./Events/LayerEvent"; import { isMenuItemRegisterEvent } from "./Events/ui/MenuItemRegisterEvent"; -import type { DataLayerEvent } from "./Events/DataLayerEvent"; +import type { MapDataEvent } from "./Events/MapDataEvent"; import type { GameStateEvent } from "./Events/GameStateEvent"; 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 type { SetVariableEvent } from "./Events/SetVariableEvent"; -type AnswererCallback = (query: IframeQueryMap[T]['query']) => IframeQueryMap[T]['answer']|Promise; +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. * 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(); @@ -85,9 +89,6 @@ class IframeListener { private readonly _setPropertyStream: Subject = new Subject(); public readonly setPropertyStream = this._setPropertyStream.asObservable(); - private readonly _dataLayerChangeStream: Subject = new Subject(); - public readonly dataLayerChangeStream = this._dataLayerChangeStream.asObservable(); - private readonly _registerMenuCommandStream: Subject = new Subject(); public readonly registerMenuCommandStream = this._registerMenuCommandStream.asObservable(); @@ -111,10 +112,13 @@ class IframeListener { private readonly scripts = new Map(); private sendPlayerMove: boolean = false; + + // 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: { - [key in keyof IframeQueryMap]?: AnswererCallback + [str in keyof IframeQueryMap]?: unknown } = {}; + init() { window.addEventListener( "message", @@ -152,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); @@ -164,19 +168,15 @@ 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: 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({ @@ -184,77 +184,79 @@ class IframeListener { type: query.type, error: reasonMsg } as IframeErrorAnswerEvent, '*'); - }); + }; + try { + Promise.resolve(answerer(query.data, message.source)).then((value) => { + iframe?.contentWindow?.postMessage({ + id: queryId, + type: query.type, + data: value + }, '*'); + }).catch(errorHandler); + } catch (reason) { + errorHandler(reason); + } } 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); - } + 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 (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); } + } }, false ); } - sendDataLayerEvent(dataLayerEvent: DataLayerEvent) { - this.postMessage({ - type: "dataLayer", - data: dataLayerEvent, - }); - } - /** * Allows the passed iFrame to send/receive messages via the API. */ @@ -394,6 +396,13 @@ class IframeListener { }); } + setVariable(setVariableEvent: SetVariableEvent) { + this.postMessage({ + 'type': 'setVariable', + 'data': setVariableEvent + }); + } + /** * Sends the message... to all allowed iframes. */ @@ -411,13 +420,28 @@ 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']|Promise ): void { + public registerAnswerer(key: T, callback: AnswererCallback ): void { this.answerers[key] = callback; } 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/player.ts b/front/src/Api/iframe/player.ts index cad66a36..078a1926 100644 --- a/front/src/Api/iframe/player.ts +++ b/front/src/Api/iframe/player.ts @@ -2,17 +2,28 @@ 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; + +export const setPlayerName = (name: string) => { + playerName = name; +}; + +let tags: string[] | undefined; + +export const setTags = (_tags: string[]) => { + tags = _tags; +}; + +let uuid: string | undefined; + +export const setUuid = (_uuid: string | undefined) => { + uuid = _uuid; +}; + export class WorkadventurePlayerCommands extends IframeApiContribution { callbacks = [ apiCallback({ @@ -31,10 +42,30 @@ export class WorkadventurePlayerCommands extends IframeApiContribution { - return getGameState().then((gameState) => { - return { id: gameState.uuid, nickName: gameState.nickname, tags: gameState.tags }; - }); + + get name(): string { + if (playerName === undefined) { + throw new Error( + "Player name not initialized yet. You should call WA.player.name within a WA.onInit callback." + ); + } + return playerName; + } + + get tags(): string[] { + if (tags === undefined) { + throw new Error("Tags not initialized yet. You should call WA.player.tags within a WA.onInit callback."); + } + return tags; + } + + get id(): string | undefined { + // Note: this is not a type, we are checking if playerName is undefined because playerName cannot be undefined + // while uuid could. + if (playerName === undefined) { + throw new Error("Player id not initialized yet. You should call WA.player.id within a WA.onInit callback."); + } + return uuid; } } diff --git a/front/src/Api/iframe/room.ts b/front/src/Api/iframe/room.ts index 0256dfc8..b5b5c0dd 100644 --- a/front/src/Api/iframe/room.ts +++ b/front/src/Api/iframe/room.ts @@ -1,28 +1,14 @@ -import { Subject } from "rxjs"; +import { Observable, Subject } from "rxjs"; -import { isDataLayerEvent } from "../Events/DataLayerEvent"; import { EnterLeaveEvent, isEnterLeaveEvent } from "../Events/EnterLeaveEvent"; -import { isGameStateEvent } from "../Events/GameStateEvent"; import { IframeApiContribution, queryWorkadventure, sendToWorkadventure } from "./IframeApiContribution"; import { apiCallback } from "./registeredCallbacks"; import type { ITiledMap } from "../../Phaser/Map/ITiledMap"; -import type { DataLayerEvent } from "../Events/DataLayerEvent"; -import type { GameStateEvent } from "../Events/GameStateEvent"; const enterStreams: Map> = new Map>(); const leaveStreams: Map> = new Map>(); -const dataLayerResolver = new Subject(); - -let immutableDataPromise: Promise | undefined = undefined; - -interface Room { - id: string; - mapUrl: string; - map: ITiledMap; - startLayer: string | null; -} interface TileDescriptor { x: number; @@ -31,19 +17,17 @@ interface TileDescriptor { layer: string; } -export function getGameState(): Promise { - if (immutableDataPromise === undefined) { - immutableDataPromise = queryWorkadventure({ type: "getState", data: undefined }); - } - return immutableDataPromise; -} +let roomId: string | undefined; -function getDataLayer(): Promise { - return new Promise((resolver, thrower) => { - dataLayerResolver.subscribe(resolver); - sendToWorkadventure({ type: "getDataLayer", data: null }); - }); -} +export const setRoomId = (id: string) => { + roomId = id; +}; + +let mapURL: string | undefined; + +export const setMapURL = (url: string) => { + mapURL = url; +}; export class WorkadventureRoomCommands extends IframeApiContribution { callbacks = [ @@ -61,13 +45,6 @@ export class WorkadventureRoomCommands extends IframeApiContribution { - dataLayerResolver.next(payloadData); - }, - }), ]; onEnterZone(name: string, callback: () => void): void { @@ -102,17 +79,9 @@ export class WorkadventureRoomCommands extends IframeApiContribution { - return getGameState().then((gameState) => { - return getDataLayer().then((mapJson) => { - return { - id: gameState.roomId, - map: mapJson.data as ITiledMap, - mapUrl: gameState.mapUrl, - startLayer: gameState.startLayerName, - }; - }); - }); + async getTiledMap(): Promise { + const event = await queryWorkadventure({ type: "getMapData", data: undefined }); + return event.data as ITiledMap; } setTiles(tiles: TileDescriptor[]) { sendToWorkadventure({ @@ -120,6 +89,22 @@ export class WorkadventureRoomCommands extends IframeApiContribution(); +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) => { + const oldValue = variables.get(event.key); + // If we are setting the same value, no need to do anything. + // 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); + 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; + }, +}) as WorkadventureStateCommands & { [key: string]: unknown }; + +export default proxyCommand; diff --git a/front/src/Components/App.svelte b/front/src/Components/App.svelte index 0f808074..1e846185 100644 --- a/front/src/Components/App.svelte +++ b/front/src/Components/App.svelte @@ -27,6 +27,8 @@ import {gameOverlayVisibilityStore} from "../Stores/GameOverlayStoreVisibility"; import {consoleGlobalMessageManagerVisibleStore} from "../Stores/ConsoleGlobalMessageManagerStore"; import ConsoleGlobalMessageManager from "./ConsoleGlobalMessageManager/ConsoleGlobalMessageManager.svelte"; + import {warningContainerStore} from "../Stores/MenuStore"; + import WarningContainer from "./WarningContainer/WarningContainer.svelte"; export let game: Game; @@ -91,4 +93,7 @@ {#if $chatVisibilityStore} {/if} + {#if $warningContainerStore} + + {/if}
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!

+ {#if $userIsAdminStore} +

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

+ {:else} +

This world is close to its limit!

+ {/if} + +
+ + \ No newline at end of file diff --git a/front/src/Connexion/ConnectionManager.ts b/front/src/Connexion/ConnectionManager.ts index 379b161f..aeaddc34 100644 --- a/front/src/Connexion/ConnectionManager.ts +++ b/front/src/Connexion/ConnectionManager.ts @@ -1,146 +1,221 @@ import Axios from "axios"; -import {PUSHER_URL, START_ROOM_URL} from "../Enum/EnvironmentVariable"; -import {RoomConnection} from "./RoomConnection"; -import type {OnConnectInterface, PositionInterface, ViewportInterface} from "./ConnexionModels"; -import {GameConnexionTypes, urlManager} from "../Url/UrlManager"; -import {localUserStore} from "./LocalUserStore"; -import {CharacterTexture, LocalUser} from "./LocalUser"; -import {Room} from "./Room"; - +import { PUSHER_URL, START_ROOM_URL } from "../Enum/EnvironmentVariable"; +import { RoomConnection } from "./RoomConnection"; +import type { OnConnectInterface, PositionInterface, ViewportInterface } from "./ConnexionModels"; +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; + private localUser!: LocalUser; - private connexionType?: GameConnexionTypes - private reconnectingTimeout: NodeJS.Timeout|null = null; - private _unloading:boolean = false; + private connexionType?: GameConnexionTypes; + private reconnectingTimeout: NodeJS.Timeout | null = null; + private _unloading: boolean = false; + private authToken: string | null = null; - get unloading () { + private serviceWorker?: _ServiceWorker; + + get unloading() { return this._unloading; } constructor() { - window.addEventListener('beforeunload', () => { + window.addEventListener("beforeunload", () => { this._unloading = true; - if (this.reconnectingTimeout) clearTimeout(this.reconnectingTimeout) - }) + if (this.reconnectingTimeout) clearTimeout(this.reconnectingTimeout); + }); } + + public loadOpenIDScreen() { + localUserStore.setAuthToken(null); + const state = localUserStore.generateState(); + const nonce = localUserStore.generateNonce(); + window.location.assign(`http://${PUSHER_URL}/login-screen?state=${state}&nonce=${nonce}`); + } + + public logout() { + localUserStore.setAuthToken(null); + window.location.reload(); + } + /** * Tries to login to the node server and return the starting map url to be loaded */ public async initGameConnexion(): Promise { - const connexionType = urlManager.getGameConnexionType(); this.connexionType = connexionType; - if(connexionType === GameConnexionTypes.register) { - const organizationMemberToken = urlManager.getOrganizationToken(); - const data = await Axios.post(`${PUSHER_URL}/register`, {organizationMemberToken}).then(res => res.data); - this.localUser = new LocalUser(data.userUuid, data.authToken, data.textures); + let room: Room | null = null; + if (connexionType === GameConnexionTypes.jwt) { + const urlParams = new URLSearchParams(window.location.search); + const code = urlParams.get("code"); + const state = urlParams.get("state"); + if (!state || !localUserStore.verifyState(state)) { + throw "Could not validate state!"; + } + if (!code) { + throw "No Auth code provided"; + } + const nonce = localUserStore.getNonce(); + const { authToken } = await Axios.get(`${PUSHER_URL}/login-callback`, { params: { code, nonce } }).then( + (res) => res.data + ); + localUserStore.setAuthToken(authToken); + this.authToken = authToken; + room = await Room.createRoom( + new URL(localUserStore.getLastRoomUrl()) + ); + urlManager.pushRoomIdToUrl(room); + + } else if (connexionType === GameConnexionTypes.register) { + //@deprecated + const organizationMemberToken = urlManager.getOrganizationToken(); + const data = await Axios.post(`${PUSHER_URL}/register`, { organizationMemberToken }).then( + (res) => res.data + ); + this.localUser = new LocalUser(data.userUuid, data.textures); + this.authToken = data.authToken; localUserStore.saveUser(this.localUser); + localUserStore.setAuthToken(this.authToken); const roomUrl = data.roomUrl; - const room = await Room.createRoom(new URL(window.location.protocol + '//' + window.location.host + roomUrl + window.location.search + window.location.hash)); + room = await Room.createRoom( + new URL( + window.location.protocol + + "//" + + window.location.host + + roomUrl + + window.location.search + + window.location.hash + ) + ); urlManager.pushRoomIdToUrl(room); - return Promise.resolve(room); - } else if (connexionType === GameConnexionTypes.organization || connexionType === GameConnexionTypes.anonymous || connexionType === GameConnexionTypes.empty) { - - let localUser = localUserStore.getLocalUser(); - if (localUser && localUser.jwtToken && localUser.uuid && localUser.textures) { - this.localUser = localUser; - try { - await this.verifyToken(localUser.jwtToken); - } catch(e) { - // If the token is invalid, let's generate an anonymous one. - console.error('JWT token invalid. Did it expire? Login anonymously instead.'); - await this.anonymousLogin(); - } - }else{ + } else if ( + connexionType === GameConnexionTypes.organization || + connexionType === GameConnexionTypes.anonymous || + connexionType === GameConnexionTypes.empty + ) { + this.authToken = localUserStore.getAuthToken(); + //todo: add here some kind of warning if authToken has expired. + if (!this.authToken) { await this.anonymousLogin(); } - - localUser = localUserStore.getLocalUser(); - if(!localUser){ - throw "Error to store local user data"; - } + this.localUser = localUserStore.getLocalUser() as LocalUser; //if authToken exist in localStorage then localUser cannot be null let roomPath: string; if (connexionType === GameConnexionTypes.empty) { - roomPath = window.location.protocol + '//' + window.location.host + START_ROOM_URL; + roomPath = window.location.protocol + "//" + window.location.host + START_ROOM_URL; } else { - roomPath = window.location.protocol + '//' + window.location.host + window.location.pathname + window.location.search + window.location.hash; + roomPath = + window.location.protocol + + "//" + + window.location.host + + window.location.pathname + + window.location.search + + window.location.hash; } //get detail map for anonymous login and set texture in local storage - const room = await Room.createRoom(new URL(roomPath)); - if(room.textures != undefined && room.textures.length > 0) { + 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){ - localUser.textures = room.textures; - }else{ + if (this.localUser.textures.length === 0) { + this.localUser.textures = room.textures; + } else { room.textures.forEach((newTexture) => { - const alreadyExistTexture = localUser?.textures.find((c) => newTexture.id === c.id); - if(localUser?.textures.findIndex((c) => newTexture.id === c.id) !== -1){ + const alreadyExistTexture = this.localUser.textures.find((c) => newTexture.id === c.id); + if (this.localUser.textures.findIndex((c) => newTexture.id === c.id) !== -1) { return; } - localUser?.textures.push(newTexture) + this.localUser.textures.push(newTexture); }); } - this.localUser = localUser; - localUserStore.saveUser(localUser); + localUserStore.saveUser(this.localUser); } - return Promise.resolve(room); + } + if (room == undefined) { + return Promise.reject(new Error("Invalid URL")); } - return Promise.reject(new Error('Invalid URL')); - } - - private async verifyToken(token: string): Promise { - await Axios.get(`${PUSHER_URL}/verify`, {params: {token}}); + this.serviceWorker = new _ServiceWorker(); + return Promise.resolve(room); } public async anonymousLogin(isBenchmark: boolean = false): Promise { - const data = await Axios.post(`${PUSHER_URL}/anonymLogin`).then(res => res.data); - this.localUser = new LocalUser(data.userUuid, data.authToken, []); - if (!isBenchmark) { // In benchmark, we don't have a local storage. + const data = await Axios.post(`${PUSHER_URL}/anonymLogin`).then((res) => res.data); + this.localUser = new LocalUser(data.userUuid, []); + this.authToken = data.authToken; + if (!isBenchmark) { + // In benchmark, we don't have a local storage. localUserStore.saveUser(this.localUser); + localUserStore.setAuthToken(this.authToken); } } public initBenchmark(): void { - this.localUser = new LocalUser('', 'test', []); + this.localUser = new LocalUser("", []); } - public connectToRoomSocket(roomUrl: string, name: string, characterLayers: string[], position: PositionInterface, viewport: ViewportInterface, companion: string|null): Promise { + public connectToRoomSocket( + roomUrl: string, + name: string, + characterLayers: string[], + position: PositionInterface, + viewport: ViewportInterface, + companion: string | null + ): Promise { return new Promise((resolve, reject) => { - const connection = new RoomConnection(this.localUser.jwtToken, roomUrl, name, characterLayers, position, viewport, companion); + const connection = new RoomConnection( + this.authToken, + roomUrl, + name, + characterLayers, + position, + viewport, + companion + ); + connection.onConnectError((error: object) => { - console.log('An error occurred while connecting to socket server. Retrying'); + console.log("An error occurred while connecting to socket server. Retrying"); reject(error); }); connection.onConnectingError((event: CloseEvent) => { - console.log('An error occurred while connecting to socket server. Retrying'); - reject(new Error('An error occurred while connecting to socket server. Retrying. Code: '+event.code+', Reason: '+event.reason)); + console.log("An error occurred while connecting to socket server. Retrying"); + reject( + new Error( + "An error occurred while connecting to socket server. Retrying. Code: " + + event.code + + ", Reason: " + + event.reason + ) + ); }); connection.onConnect((connect: OnConnectInterface) => { + //save last room url connected + localUserStore.setLastRoomUrl(roomUrl); + resolve(connect); }); - }).catch((err) => { // Let's retry in 4-6 seconds return new Promise((resolve, reject) => { this.reconnectingTimeout = setTimeout(() => { //todo: allow a way to break recursion? //todo: find a way to avoid recursive function. Otherwise, the call stack will grow indefinitely. - this.connectToRoomSocket(roomUrl, name, characterLayers, position, viewport, companion).then((connection) => resolve(connection)); - }, 4000 + Math.floor(Math.random() * 2000) ); + this.connectToRoomSocket(roomUrl, name, characterLayers, position, viewport, companion).then( + (connection) => resolve(connection) + ); + }, 4000 + Math.floor(Math.random() * 2000)); }); }); } - get getConnexionType(){ + get getConnexionType() { return this.connexionType; } } 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/LocalUser.ts b/front/src/Connexion/LocalUser.ts index d240a90e..42b4bbf8 100644 --- a/front/src/Connexion/LocalUser.ts +++ b/front/src/Connexion/LocalUser.ts @@ -1,10 +1,10 @@ -import {MAX_USERNAME_LENGTH} from "../Enum/EnvironmentVariable"; +import { MAX_USERNAME_LENGTH } from "../Enum/EnvironmentVariable"; export interface CharacterTexture { - id: number, - level: number, - url: string, - rights: string + id: number; + level: number; + url: string; + rights: string; } export const maxUserNameLength: number = MAX_USERNAME_LENGTH; @@ -24,6 +24,5 @@ export function areCharacterLayersValid(value: string[] | null): boolean { } export class LocalUser { - constructor(public readonly uuid:string, public readonly jwtToken: string, public textures: CharacterTexture[]) { - } + constructor(public readonly uuid: string, public textures: CharacterTexture[]) {} } diff --git a/front/src/Connexion/LocalUserStore.ts b/front/src/Connexion/LocalUserStore.ts index ace7b17e..07c2487e 100644 --- a/front/src/Connexion/LocalUserStore.ts +++ b/front/src/Connexion/LocalUserStore.ts @@ -1,60 +1,65 @@ -import {areCharacterLayersValid, isUserNameValid, LocalUser} from "./LocalUser"; +import { areCharacterLayersValid, isUserNameValid, LocalUser } from "./LocalUser"; +import { v4 as uuidv4 } from "uuid"; -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"; +const authToken = "authToken"; +const state = "state"; +const nonce = "nonce"; 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 +73,82 @@ 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) ?? ""; + } + + setAuthToken(value: string | null) { + value ? localStorage.setItem(authToken, value) : localStorage.removeItem(authToken); + } + getAuthToken(): string | null { + return localStorage.getItem(authToken); + } + + generateState(): string { + const newState = uuidv4(); + localStorage.setItem(state, newState); + return newState; + } + + verifyState(value: string): boolean { + const oldValue = localStorage.getItem(state); + localStorage.removeItem(state); + return oldValue === value; + } + generateNonce(): string { + const newNonce = uuidv4(); + localStorage.setItem(nonce, newNonce); + return newNonce; + } + + getNonce(): string | null { + const oldValue = localStorage.getItem(nonce); + localStorage.removeItem(nonce); + return oldValue; } } 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(); } diff --git a/front/src/Connexion/RoomConnection.ts b/front/src/Connexion/RoomConnection.ts index 1d3c3702..dc160a88 100644 --- a/front/src/Connexion/RoomConnection.ts +++ b/front/src/Connexion/RoomConnection.ts @@ -32,6 +32,8 @@ import { EmotePromptMessage, SendUserMessage, BanUserMessage, + VariableMessage, + ErrorMessage, } from "../Messages/generated/messages_pb"; import type { UserSimplePeerInterface } from "../WebRtc/SimplePeer"; @@ -53,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; @@ -74,7 +76,7 @@ export class RoomConnection implements RoomConnection { /** * - * @param token A JWT token containing the UUID of the user + * @param token A JWT token containing the email of the user * @param roomUrl The URL of the room in the form "https://example.com/_/[instance]/[map_url]" or "https://example.com/@/[org]/[event]/[map]" */ public constructor( @@ -164,6 +166,12 @@ 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(); } else { throw new Error("Unexpected batch message type"); } @@ -180,6 +188,22 @@ export class RoomConnection implements RoomConnection { items[item.getItemid()] = JSON.parse(item.getStatejson()); } + const variables = new Map(); + for (const variable of roomJoinedMessage.getVariableList()) { + 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(); this.tags = roomJoinedMessage.getTagList(); @@ -187,11 +211,15 @@ export class RoomConnection implements RoomConnection { connection: this, room: { items, + variables, } as RoomJoinedMessageInterface, }); } else if (message.hasWorldfullmessage()) { worldFullMessageStream.onMessage(); this.closed = true; + } else if (message.hasTokenexpiredmessage()) { + connectionManager.loadOpenIDScreen(); + this.closed = true; //technically, this isn't needed since loadOpenIDScreen() will do window.location.assign() but I prefer to leave it for consistency } else if (message.hasWorldconnexionmessage()) { worldFullMessageStream.onMessage(message.getWorldconnexionmessage()?.getMessage()); this.closed = true; @@ -219,7 +247,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 { @@ -536,6 +564,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({ @@ -622,6 +661,29 @@ 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) { + 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); + }); + } + public hasTag(tag: string): boolean { return this.tags.includes(tag); } 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/Enum/EnvironmentVariable.ts b/front/src/Enum/EnvironmentVariable.ts index 73f6427c..163489bb 100644 --- a/front/src/Enum/EnvironmentVariable.ts +++ b/front/src/Enum/EnvironmentVariable.ts @@ -1,22 +1,24 @@ const DEBUG_MODE: boolean = process.env.DEBUG_MODE == "true"; -const START_ROOM_URL : string = process.env.START_ROOM_URL || '/_/global/maps.workadventure.localhost/Floor0/floor0.json'; -const PUSHER_URL = process.env.PUSHER_URL || '//pusher.workadventure.localhost'; -const UPLOADER_URL = process.env.UPLOADER_URL || '//uploader.workadventure.localhost'; +const START_ROOM_URL: string = + process.env.START_ROOM_URL || "/_/global/maps.workadventure.localhost/Floor1/floor1.json"; +const PUSHER_URL = process.env.PUSHER_URL || "//pusher.workadventure.localhost"; +export const ADMIN_URL = process.env.ADMIN_URL || "//workadventu.re"; +const UPLOADER_URL = process.env.UPLOADER_URL || "//uploader.workadventure.localhost"; const STUN_SERVER: string = process.env.STUN_SERVER || "stun:stun.l.google.com:19302"; const TURN_SERVER: string = process.env.TURN_SERVER || ""; const SKIP_RENDER_OPTIMIZATIONS: boolean = process.env.SKIP_RENDER_OPTIMIZATIONS == "true"; const DISABLE_NOTIFICATIONS: boolean = process.env.DISABLE_NOTIFICATIONS == "true"; -const TURN_USER: string = process.env.TURN_USER || ''; -const TURN_PASSWORD: string = process.env.TURN_PASSWORD || ''; -const JITSI_URL : string|undefined = (process.env.JITSI_URL === '') ? undefined : process.env.JITSI_URL; -const JITSI_PRIVATE_MODE : boolean = process.env.JITSI_PRIVATE_MODE == "true"; +const TURN_USER: string = process.env.TURN_USER || ""; +const TURN_PASSWORD: string = process.env.TURN_PASSWORD || ""; +const JITSI_URL: string | undefined = process.env.JITSI_URL === "" ? undefined : process.env.JITSI_URL; +const JITSI_PRIVATE_MODE: boolean = process.env.JITSI_PRIVATE_MODE == "true"; const POSITION_DELAY = 200; // Wait 200ms between sending position events const MAX_EXTRAPOLATION_TIME = 100; // Extrapolate a maximum of 250ms if no new movement is sent by the player -export const MAX_USERNAME_LENGTH = parseInt(process.env.MAX_USERNAME_LENGTH || '') || 8; -export const MAX_PER_GROUP = parseInt(process.env.MAX_PER_GROUP || '4'); -export const DISPLAY_TERMS_OF_USE = process.env.DISPLAY_TERMS_OF_USE == 'true'; +export const MAX_USERNAME_LENGTH = parseInt(process.env.MAX_USERNAME_LENGTH || "") || 8; +export const MAX_PER_GROUP = parseInt(process.env.MAX_PER_GROUP || "4"); +export const DISPLAY_TERMS_OF_USE = process.env.DISPLAY_TERMS_OF_USE == "true"; -export const isMobile = ():boolean => ( ( window.innerWidth <= 800 ) || ( window.innerHeight <= 600 ) ); +export const isMobile = (): boolean => window.innerWidth <= 800 || window.innerHeight <= 600; export { DEBUG_MODE, @@ -32,5 +34,5 @@ export { TURN_USER, TURN_PASSWORD, JITSI_URL, - JITSI_PRIVATE_MODE -} + JITSI_PRIVATE_MODE, +}; 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/Phaser/Components/TextUtils.ts b/front/src/Phaser/Components/TextUtils.ts index 972c50c7..e9cb9260 100644 --- a/front/src/Phaser/Components/TextUtils.ts +++ b/front/src/Phaser/Components/TextUtils.ts @@ -1,35 +1,43 @@ -import type {ITiledMapObject} from "../Map/ITiledMap"; -import type {GameScene} from "../Game/GameScene"; +import type { ITiledMapObject } from "../Map/ITiledMap"; +import type { GameScene } from "../Game/GameScene"; +import { type } from "os"; export class TextUtils { public static createTextFromITiledMapObject(scene: GameScene, object: ITiledMapObject): void { if (object.text === undefined) { - throw new Error('This object has not textual representation.'); + throw new Error("This object has not textual representation."); } const options: { - fontStyle?: string, - fontSize?: string, - fontFamily?: string, - color?: string, - align?: string, + fontStyle?: string; + fontSize?: string; + fontFamily?: string; + color?: string; + align?: string; wordWrap?: { - width: number, - useAdvancedWrap?: boolean - } + width: number; + useAdvancedWrap?: boolean; + }; } = {}; if (object.text.italic) { - options.fontStyle = 'italic'; + options.fontStyle = "italic"; } // Note: there is no support for "strikeout" and "underline" let fontSize: number = 16; if (object.text.pixelsize) { fontSize = object.text.pixelsize; } - options.fontSize = fontSize + 'px'; + options.fontSize = fontSize + "px"; if (object.text.fontfamily) { - options.fontFamily = '"'+object.text.fontfamily+'"'; + options.fontFamily = '"' + object.text.fontfamily + '"'; } - let color = '#000000'; + if (object.properties !== undefined) { + for (const property of object.properties) { + if (property.name === "font-family" && typeof property.value === "string") { + options.fontFamily = property.value; + } + } + } + let color = "#000000"; if (object.text.color !== undefined) { color = object.text.color; } @@ -38,7 +46,7 @@ export class TextUtils { options.wordWrap = { width: object.width, //useAdvancedWrap: true - } + }; } if (object.text.halign !== undefined) { options.align = object.text.halign; 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/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/AddPlayerInterface.ts b/front/src/Phaser/Game/AddPlayerInterface.ts index d2f12013..cf7f9092 100644 --- a/front/src/Phaser/Game/AddPlayerInterface.ts +++ b/front/src/Phaser/Game/AddPlayerInterface.ts @@ -1,5 +1,5 @@ -import type {PointInterface} from "../../Connexion/ConnexionModels"; -import type {PlayerInterface} from "./PlayerInterface"; +import type { PointInterface } from "../../Connexion/ConnexionModels"; +import type { PlayerInterface } from "./PlayerInterface"; export interface AddPlayerInterface extends PlayerInterface { position: PointInterface; diff --git a/front/src/Phaser/Game/Game.ts b/front/src/Phaser/Game/Game.ts index e267e80a..865026f7 100644 --- a/front/src/Phaser/Game/Game.ts +++ b/front/src/Phaser/Game/Game.ts @@ -1,7 +1,7 @@ -import {SKIP_RENDER_OPTIMIZATIONS} from "../../Enum/EnvironmentVariable"; -import {coWebsiteManager} from "../../WebRtc/CoWebsiteManager"; -import {waScaleManager} from "../Services/WaScaleManager"; -import {ResizableScene} from "../Login/ResizableScene"; +import { SKIP_RENDER_OPTIMIZATIONS } from "../../Enum/EnvironmentVariable"; +import { coWebsiteManager } from "../../WebRtc/CoWebsiteManager"; +import { waScaleManager } from "../Services/WaScaleManager"; +import { ResizableScene } from "../Login/ResizableScene"; const Events = Phaser.Core.Events; @@ -14,10 +14,8 @@ const Events = Phaser.Core.Events; * It also automatically calls "onResize" on any scenes extending ResizableScene. */ export class Game extends Phaser.Game { - private _isDirty = false; - constructor(GameConfig: Phaser.Types.Core.GameConfig) { super(GameConfig); @@ -27,7 +25,7 @@ export class Game extends Phaser.Game { scene.onResize(); } } - }) + }); /*window.addEventListener('resize', (event) => { // Let's trigger the onResize method of any active scene that is a ResizableScene @@ -39,11 +37,9 @@ export class Game extends Phaser.Game { });*/ } - public step(time: number, delta: number) - { + public step(time: number, delta: number) { // @ts-ignore - if (this.pendingDestroy) - { + if (this.pendingDestroy) { // @ts-ignore return this.runDestroy(); } @@ -100,15 +96,17 @@ export class Game extends Phaser.Game { } // Loop through the scenes in forward order - for (let i = 0; i < this.scene.scenes.length; i++) - { + for (let i = 0; i < this.scene.scenes.length; i++) { const scene = this.scene.scenes[i]; const sys = scene.sys; - if (sys.settings.visible && sys.settings.status >= Phaser.Scenes.LOADING && sys.settings.status < Phaser.Scenes.SLEEPING) - { + if ( + sys.settings.visible && + sys.settings.status >= Phaser.Scenes.LOADING && + sys.settings.status < Phaser.Scenes.SLEEPING + ) { // @ts-ignore - if(typeof scene.isDirty === 'function') { + if (typeof scene.isDirty === "function") { // @ts-ignore const isDirty = scene.isDirty() || scene.tweens.getAllTweens().length > 0; if (isDirty) { @@ -129,4 +127,11 @@ export class Game extends Phaser.Game { public markDirty(): void { this._isDirty = true; } + + /** + * Return the first scene found in the game + */ + public findAnyScene(): Phaser.Scene { + return this.scene.getScenes()[0]; + } } diff --git a/front/src/Phaser/Game/GameMap.ts b/front/src/Phaser/Game/GameMap.ts index d2b28531..98583cba 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 { if (this.tileSetPropertyMap[index]) { return this.tileSetPropertyMap[index]; } diff --git a/front/src/Phaser/Game/GameScene.ts b/front/src/Phaser/Game/GameScene.ts index 37f3937d..31acb83a 100644 --- a/front/src/Phaser/Game/GameScene.ts +++ b/front/src/Phaser/Game/GameScene.ts @@ -47,13 +47,7 @@ import { RemotePlayer } from "../Entity/RemotePlayer"; import type { ActionableItem } from "../Items/ActionableItem"; import type { ItemFactoryInterface } from "../Items/ItemFactoryInterface"; import { SelectCharacterScene, SelectCharacterSceneName } from "../Login/SelectCharacterScene"; -import type { - ITiledMap, - ITiledMapLayer, - ITiledMapLayerProperty, - ITiledMapObject, - ITiledTileSet, -} from "../Map/ITiledMap"; +import type { ITiledMap, ITiledMapLayer, ITiledMapProperty, ITiledMapObject, ITiledTileSet } from "../Map/ITiledMap"; import { MenuScene, MenuSceneName } from "../Menu/MenuScene"; import { PlayerAnimationDirections } from "../Player/Animation"; import { hasMovedEventName, Player, requestEmoteEventName } from "../Player/Player"; @@ -91,8 +85,10 @@ 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 { playersStore } from "../../Stores/PlayersStore"; import { chatVisibilityStore } from "../../Stores/ChatStore"; +import { userIsAdminStore } from "../../Stores/GameStore"; export interface GameSceneInitInterface { initPosition: PointInterface | null; @@ -199,10 +195,11 @@ 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; - startPositionCalculator!: StartPositionCalculator; + private startPositionCalculator!: StartPositionCalculator; + private sharedVariablesManager!: SharedVariablesManager; constructor(private room: Room, MapUrlFile: string, customKey?: string | undefined) { super({ @@ -440,7 +437,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) => { @@ -608,6 +605,8 @@ export class GameScene extends DirtyScene { playersStore.connectToRoomConnection(this.connection); + userIsAdminStore.set(this.connection.hasTag("admin")); + this.connection.onUserJoins((message: MessageUserJoined) => { const userMessage: AddPlayerInterface = { userId: message.userId, @@ -718,6 +717,13 @@ export class GameScene extends DirtyScene { this.gameMap.setPosition(event.x, event.y); }); + // Set up variables manager + this.sharedVariablesManager = new SharedVariablesManager( + this.connection, + this.gameMap, + onConnect.room.variables + ); + //this.initUsersPosition(roomJoinedMessage.users); this.connectionAnswerPromiseResolve(onConnect.room); // Analyze tags to find if we are admin. If yes, show console. @@ -1053,20 +1059,24 @@ ${escapedMessage} }) ); - this.iframeSubscriptionList.push( - iframeListener.dataLayerChangeStream.subscribe(() => { - iframeListener.sendDataLayerEvent({ data: this.gameMap.getMap() }); - }) - ); + iframeListener.registerAnswerer("getMapData", () => { + return { + data: this.gameMap.getMap(), + }; + }); - iframeListener.registerAnswerer("getState", () => { + iframeListener.registerAnswerer("getState", async () => { + // The sharedVariablesManager is not instantiated before the connection is established. So we need to wait + // for the connection to send back the answer. + await this.connectionAnswerPromise; return { mapUrl: this.MapUrlFile, startLayerName: this.startPositionCalculator.startLayerName, uuid: localUserStore.getLocalUser()?.uuid, - nickname: localUserStore.getName(), + nickname: this.playerName, roomId: this.roomUrl, tags: this.connection ? this.connection.getAllTags() : [], + variables: this.sharedVariablesManager.variables, }; }); this.iframeSubscriptionList.push( @@ -1197,6 +1207,7 @@ ${escapedMessage} this.chatVisibilityUnsubscribe(); this.biggestAvailableAreaStoreUnsubscribe(); iframeListener.unregisterAnswerer("getState"); + this.sharedVariablesManager?.close(); mediaManager.hideGameOverlay(); @@ -1236,12 +1247,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; @@ -1250,12 +1261,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 new file mode 100644 index 00000000..6a06d97e --- /dev/null +++ b/front/src/Phaser/Game/SharedVariablesManager.ts @@ -0,0 +1,167 @@ +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; +} + +/** + * 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 + ) { + // 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()) { + 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); + } + + // Override default values with the variables from the server: + for (const [name, value] of serverVariables) { + this._variables.set(name, value); + } + + roomConnection.onSetVariable((name, value) => { + 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, source) => { + 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"'; + 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); + } + + // 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); + }); + } + + 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") { + 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)); + } + } + } + } + return objects; + } + + private static iTiledObjectToVariable(object: ITiledMapObject): Variable { + const variable: Variable = { + defaultValue: undefined, + }; + + if (object.properties) { + for (const property of object.properties) { + 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; + } + } + } + + return variable; + } + + public close(): void { + iframeListener.unregisterAnswerer("setVariable"); + } + + get variables(): Map { + return this._variables; + } +} diff --git a/front/src/Phaser/Game/StartPositionCalculator.ts b/front/src/Phaser/Game/StartPositionCalculator.ts index 7460c81c..0827b623 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"; @@ -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; @@ -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..57bb13c9 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; @@ -141,6 +141,7 @@ export interface ITiledMapObject { polyline: { x: number; y: number }[]; text?: ITiledText; + template?: string; } export interface ITiledText { @@ -163,7 +164,7 @@ export interface ITiledTileSet { imagewidth: number; margin: number; name: string; - properties: { [key: string]: string }; + properties?: ITiledMapProperty[]; spacing: number; tilecount: number; tileheight: number; @@ -182,7 +183,7 @@ export interface ITile { id: number; type?: string; - properties?: Array; + properties?: ITiledMapProperty[]; } export interface ITiledMapTerrain { diff --git a/front/src/Phaser/Menu/MenuScene.ts b/front/src/Phaser/Menu/MenuScene.ts index 4e9297b6..2c08882a 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"; @@ -21,6 +19,7 @@ import { get } from "svelte/store"; import { playersStore } from "../../Stores/PlayersStore"; import { mediaManager } from "../../WebRtc/MediaManager"; import { chatVisibilityStore } from "../../Stores/ChatStore"; +import { ADMIN_URL } from "../../Enum/EnvironmentVariable"; export const MenuSceneName = "MenuScene"; const gameMenuKey = "gameMenu"; @@ -45,8 +44,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 +88,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 +143,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 +189,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; @@ -363,6 +344,9 @@ export class MenuScene extends Phaser.Scene { case "editGameSettingsButton": this.openGameSettingsMenu(); break; + case "oidcLogin": + connectionManager.loadOpenIDScreen(); + break; case "toggleFullscreen": this.toggleFullscreen(); break; @@ -403,7 +387,7 @@ export class MenuScene extends Phaser.Scene { private gotToCreateMapPage() { //const sparkHost = 'https://'+window.location.host.replace('play.', '')+'/choose-map.html'; //TODO fix me: this button can to send us on WorkAdventure BO. - const sparkHost = "https://workadventu.re/getting-started"; + const sparkHost = ADMIN_URL + "/getting-started"; window.open(sparkHost, "_blank"); } diff --git a/front/src/Phaser/Reconnecting/ErrorScene.ts b/front/src/Phaser/Reconnecting/ErrorScene.ts index 3f53c6fd..fb3d333a 100644 --- a/front/src/Phaser/Reconnecting/ErrorScene.ts +++ b/front/src/Phaser/Reconnecting/ErrorScene.ts @@ -1,14 +1,14 @@ -import {TextField} from "../Components/TextField"; +import { TextField } from "../Components/TextField"; import Image = Phaser.GameObjects.Image; import Sprite = Phaser.GameObjects.Sprite; import Text = Phaser.GameObjects.Text; import ScenePlugin = Phaser.Scenes.ScenePlugin; -import {WAError} from "./WAError"; +import { WAError } from "./WAError"; export const ErrorSceneName = "ErrorScene"; enum Textures { icon = "icon", - mainFont = "main_font" + mainFont = "main_font", } export class ErrorScene extends Phaser.Scene { @@ -23,25 +23,21 @@ export class ErrorScene extends Phaser.Scene { constructor() { super({ - key: ErrorSceneName + key: ErrorSceneName, }); } - init({title, subTitle, message}: { title?: string, subTitle?: string, message?: string }) { - this.title = title ? title : ''; - this.subTitle = subTitle ? subTitle : ''; - this.message = message ? message : ''; + init({ title, subTitle, message }: { title?: string; subTitle?: string; message?: string }) { + this.title = title ? title : ""; + this.subTitle = subTitle ? subTitle : ""; + this.message = message ? message : ""; } preload() { this.load.image(Textures.icon, "static/images/favicons/favicon-32x32.png"); // Note: arcade.png from the Phaser 3 examples at: https://github.com/photonstorm/phaser3-examples/tree/master/public/assets/fonts/bitmap - this.load.bitmapFont(Textures.mainFont, 'resources/fonts/arcade.png', 'resources/fonts/arcade.xml'); - this.load.spritesheet( - 'cat', - 'resources/characters/pipoya/Cat 01-1.png', - {frameWidth: 32, frameHeight: 32} - ); + this.load.bitmapFont(Textures.mainFont, "resources/fonts/arcade.png", "resources/fonts/arcade.xml"); + this.load.spritesheet("cat", "resources/characters/pipoya/Cat 01-1.png", { frameWidth: 32, frameHeight: 32 }); } create() { @@ -50,15 +46,25 @@ export class ErrorScene extends Phaser.Scene { this.titleField = new TextField(this, this.game.renderer.width / 2, this.game.renderer.height / 2, this.title); - this.subTitleField = new TextField(this, this.game.renderer.width / 2, this.game.renderer.height / 2 + 24, this.subTitle); + this.subTitleField = new TextField( + this, + this.game.renderer.width / 2, + this.game.renderer.height / 2 + 24, + this.subTitle + ); - this.messageField = this.add.text(this.game.renderer.width / 2, this.game.renderer.height / 2 + 48, this.message, { - fontFamily: 'Georgia, "Goudy Bookletter 1911", Times, serif', - fontSize: '10px' - }); + this.messageField = this.add.text( + this.game.renderer.width / 2, + this.game.renderer.height / 2 + 48, + this.message, + { + fontFamily: 'Georgia, "Goudy Bookletter 1911", Times, serif', + fontSize: "10px", + } + ); this.messageField.setOrigin(0.5, 0.5); - this.cat = this.physics.add.sprite(this.game.renderer.width / 2, this.game.renderer.height / 2 - 32, 'cat', 6); + this.cat = this.physics.add.sprite(this.game.renderer.width / 2, this.game.renderer.height / 2 - 32, "cat", 6); this.cat.flipY = true; } @@ -69,38 +75,38 @@ export class ErrorScene extends Phaser.Scene { public static showError(error: any, scene: ScenePlugin): void { console.error(error); - if (typeof error === 'string' || error instanceof String) { + if (typeof error === "string" || error instanceof String) { scene.start(ErrorSceneName, { - title: 'An error occurred', - subTitle: error + title: "An error occurred", + subTitle: error, }); } else if (error instanceof WAError) { scene.start(ErrorSceneName, { title: error.title, subTitle: error.subTitle, - message: error.details + message: error.details, }); } else if (error.response) { // Axios HTTP error // client received an error response (5xx, 4xx) scene.start(ErrorSceneName, { - title: 'HTTP ' + error.response.status + ' - ' + error.response.statusText, - subTitle: 'An error occurred while accessing URL:', - message: error.response.config.url + title: "HTTP " + error.response.status + " - " + error.response.statusText, + subTitle: "An error occurred while accessing URL:", + message: error.response.config.url, }); } else if (error.request) { // Axios HTTP error // client never received a response, or request never left scene.start(ErrorSceneName, { - title: 'Network error', - subTitle: error.message + title: "Network error", + subTitle: error.message, }); } else if (error instanceof Error) { // Error scene.start(ErrorSceneName, { - title: 'An error occurred', + title: "An error occurred", subTitle: error.name, - message: error.message + message: error.message, }); } else { throw error; @@ -114,7 +120,7 @@ export class ErrorScene extends Phaser.Scene { scene.start(ErrorSceneName, { title, subTitle, - message + message, }); } } diff --git a/front/src/Phaser/Reconnecting/ReconnectingScene.ts b/front/src/Phaser/Reconnecting/ReconnectingScene.ts index 4e49b880..3c8a966c 100644 --- a/front/src/Phaser/Reconnecting/ReconnectingScene.ts +++ b/front/src/Phaser/Reconnecting/ReconnectingScene.ts @@ -1,11 +1,11 @@ -import {TextField} from "../Components/TextField"; +import { TextField } from "../Components/TextField"; import Image = Phaser.GameObjects.Image; import Sprite = Phaser.GameObjects.Sprite; export const ReconnectingSceneName = "ReconnectingScene"; enum ReconnectingTextures { icon = "icon", - mainFont = "main_font" + mainFont = "main_font", } export class ReconnectingScene extends Phaser.Scene { @@ -14,35 +14,40 @@ export class ReconnectingScene extends Phaser.Scene { constructor() { super({ - key: ReconnectingSceneName + key: ReconnectingSceneName, }); } preload() { this.load.image(ReconnectingTextures.icon, "static/images/favicons/favicon-32x32.png"); // Note: arcade.png from the Phaser 3 examples at: https://github.com/photonstorm/phaser3-examples/tree/master/public/assets/fonts/bitmap - this.load.bitmapFont(ReconnectingTextures.mainFont, 'resources/fonts/arcade.png', 'resources/fonts/arcade.xml'); - this.load.spritesheet( - 'cat', - 'resources/characters/pipoya/Cat 01-1.png', - {frameWidth: 32, frameHeight: 32} - ); + this.load.bitmapFont(ReconnectingTextures.mainFont, "resources/fonts/arcade.png", "resources/fonts/arcade.xml"); + this.load.spritesheet("cat", "resources/characters/pipoya/Cat 01-1.png", { frameWidth: 32, frameHeight: 32 }); } create() { - this.logo = new Image(this, this.game.renderer.width - 30, this.game.renderer.height - 20, ReconnectingTextures.icon); + this.logo = new Image( + this, + this.game.renderer.width - 30, + this.game.renderer.height - 20, + ReconnectingTextures.icon + ); this.add.existing(this.logo); - this.reconnectingField = new TextField(this, this.game.renderer.width / 2, this.game.renderer.height / 2, "Connection lost. Reconnecting..."); + this.reconnectingField = new TextField( + this, + this.game.renderer.width / 2, + this.game.renderer.height / 2, + "Connection lost. Reconnecting..." + ); - const cat = this.physics.add.sprite(this.game.renderer.width / 2, this.game.renderer.height / 2 - 32, 'cat'); + const cat = this.physics.add.sprite(this.game.renderer.width / 2, this.game.renderer.height / 2 - 32, "cat"); this.anims.create({ - key: 'right', - frames: this.anims.generateFrameNumbers('cat', { start: 6, end: 8 }), + key: "right", + frames: this.anims.generateFrameNumbers("cat", { start: 6, end: 8 }), frameRate: 10, - repeat: -1 + repeat: -1, }); - cat.play('right'); - + cat.play("right"); } } 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/GameStore.ts b/front/src/Stores/GameStore.ts index 8899aa12..eada6d26 100644 --- a/front/src/Stores/GameStore.ts +++ b/front/src/Stores/GameStore.ts @@ -2,4 +2,6 @@ import { writable } from "svelte/store"; export const userMovingStore = writable(false); -export const requestVisitCardsStore = writable(null); +export const requestVisitCardsStore = writable(null); + +export const userIsAdminStore = writable(false); 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/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(); diff --git a/front/src/Url/UrlManager.ts b/front/src/Url/UrlManager.ts index b502467f..6d4949ab 100644 --- a/front/src/Url/UrlManager.ts +++ b/front/src/Url/UrlManager.ts @@ -1,45 +1,46 @@ -import type {Room} from "../Connexion/Room"; +import type { Room } from "../Connexion/Room"; export enum GameConnexionTypes { - anonymous=1, + anonymous = 1, organization, register, empty, unknown, + jwt, } //this class is responsible with analysing and editing the game's url class UrlManager { - - //todo: use that to detect if we can find a token in localstorage public getGameConnexionType(): GameConnexionTypes { const url = window.location.pathname.toString(); - if (url.includes('_/')) { + if (url === "/jwt") { + return GameConnexionTypes.jwt; + } else if (url.includes("_/")) { return GameConnexionTypes.anonymous; - } else if (url.includes('@/')) { + } else if (url.includes("@/")) { return GameConnexionTypes.organization; - } else if(url.includes('register/')) { + } else if (url.includes("register/")) { return GameConnexionTypes.register; - } else if(url === '/') { + } else if (url === "/") { return GameConnexionTypes.empty; } else { return GameConnexionTypes.unknown; } } - public getOrganizationToken(): string|null { + public getOrganizationToken(): string | null { const match = /\/register\/(.+)/.exec(window.location.pathname.toString()); - return match ? match [1] : null; + return match ? match[1] : null; } - public pushRoomIdToUrl(room:Room): void { + public pushRoomIdToUrl(room: Room): void { if (window.location.pathname === room.id) return; const hash = window.location.hash; const search = room.search.toString(); - history.pushState({}, 'WorkAdventure', room.id+(search?'?'+search:'')+hash); + history.pushState({}, "WorkAdventure", room.id + (search ? "?" + search : "") + hash); } - public getStartLayerNameFromUrl(): string|null { + public getStartLayerNameFromUrl(): string | null { const hash = window.location.hash; return hash.length > 1 ? hash.substring(1) : null; } diff --git a/front/src/WebRtc/HtmlUtils.ts b/front/src/WebRtc/HtmlUtils.ts index 47475cd8..a12fddfe 100644 --- a/front/src/WebRtc/HtmlUtils.ts +++ b/front/src/WebRtc/HtmlUtils.ts @@ -2,9 +2,9 @@ export class HtmlUtils { public static getElementByIdOrFail(id: string): T { const elem = document.getElementById(id); if (HtmlUtils.isHtmlElement(elem)) { - return elem; + return elem; } - throw new Error("Cannot find HTML element with id '"+id+"'"); + throw new Error("Cannot find HTML element with id '" + id + "'"); } public static querySelectorOrFail(selector: string): T { @@ -12,7 +12,7 @@ export class HtmlUtils { if (HtmlUtils.isHtmlElement(elem)) { return elem; } - throw new Error("Cannot find HTML element with selector '"+selector+"'"); + throw new Error("Cannot find HTML element with selector '" + selector + "'"); } public static removeElementByIdOrFail(id: string): T { @@ -21,12 +21,12 @@ export class HtmlUtils { elem.remove(); return elem; } - throw new Error("Cannot find HTML element with id '"+id+"'"); + throw new Error("Cannot find HTML element with id '" + id + "'"); } public static escapeHtml(html: string): string { - const text = document.createTextNode(html.replace(/(\r\n|\r|\n)/g,'
')); - const p = document.createElement('p'); + const text = document.createTextNode(html.replace(/(\r\n|\r|\n)/g, "
")); + const p = document.createElement("p"); p.appendChild(text); return p.innerHTML; } @@ -35,7 +35,7 @@ export class HtmlUtils { const urlRegex = /(https?:\/\/[^\s]+)/g; text = HtmlUtils.escapeHtml(text); return text.replace(urlRegex, (url: string) => { - const link = document.createElement('a'); + const link = document.createElement("a"); link.href = url; link.target = "_blank"; const text = document.createTextNode(url); 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/src/iframe_api.ts b/front/src/iframe_api.ts index 1915020e..dcd10fdc 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,12 +13,26 @@ 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 from "./Api/iframe/room"; -import player 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, sendToWorkadventure} from "./Api/iframe/IframeApiContribution"; +import { answerPromises, queryWorkadventure, sendToWorkadventure } from "./Api/iframe/IframeApiContribution"; + +// Notify WorkAdventure that we are ready to receive data +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 = { ui, @@ -26,6 +42,11 @@ const wa = { sound, room, player, + state, + + onInit(): Promise { + return initPromise; + }, // All methods below are deprecated and should not be used anymore. // They are kept here for backward compatibility. @@ -123,7 +144,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"); @@ -164,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 (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.'); + "message", + (message: TypedMessageEvent>) => { + if (message.source !== window.parent) { + return; // Skip message in this event listener } - resolver.resolve(payloadData); + const payload = message.data; - answerPromises.delete(queryId); - } else if (isIframeErrorAnswerEvent(payload)) { - const queryId = payload.id; - const payloadError = payload.error; + //console.debug(payload); - 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); + 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)) { diff --git a/front/src/index.ts b/front/src/index.ts index 3d53ac89..6d2931a7 100644 --- a/front/src/index.ts +++ b/front/src/index.ts @@ -1,35 +1,34 @@ -import 'phaser'; +import "phaser"; import GameConfig = Phaser.Types.Core.GameConfig; import "../style/index.scss"; -import {DEBUG_MODE, isMobile} from "./Enum/EnvironmentVariable"; -import {LoginScene} from "./Phaser/Login/LoginScene"; -import {ReconnectingScene} from "./Phaser/Reconnecting/ReconnectingScene"; -import {SelectCharacterScene} from "./Phaser/Login/SelectCharacterScene"; -import {SelectCompanionScene} from "./Phaser/Login/SelectCompanionScene"; -import {EnableCameraScene} from "./Phaser/Login/EnableCameraScene"; -import {CustomizeScene} from "./Phaser/Login/CustomizeScene"; -import WebFontLoaderPlugin from 'phaser3-rex-plugins/plugins/webfontloader-plugin.js'; -import OutlinePipelinePlugin from 'phaser3-rex-plugins/plugins/outlinepipeline-plugin.js'; -import {EntryScene} from "./Phaser/Login/EntryScene"; -import {coWebsiteManager} from "./WebRtc/CoWebsiteManager"; -import {MenuScene} from "./Phaser/Menu/MenuScene"; -import {localUserStore} from "./Connexion/LocalUserStore"; -import {ErrorScene} from "./Phaser/Reconnecting/ErrorScene"; -import {iframeListener} from "./Api/IframeListener"; -import { SelectCharacterMobileScene } from './Phaser/Login/SelectCharacterMobileScene'; -import {HdpiManager} from "./Phaser/Services/HdpiManager"; -import {waScaleManager} from "./Phaser/Services/WaScaleManager"; -import {Game} from "./Phaser/Game/Game"; -import App from './Components/App.svelte'; -import {HtmlUtils} from "./WebRtc/HtmlUtils"; +import { DEBUG_MODE, isMobile } from "./Enum/EnvironmentVariable"; +import { LoginScene } from "./Phaser/Login/LoginScene"; +import { ReconnectingScene } from "./Phaser/Reconnecting/ReconnectingScene"; +import { SelectCharacterScene } from "./Phaser/Login/SelectCharacterScene"; +import { SelectCompanionScene } from "./Phaser/Login/SelectCompanionScene"; +import { EnableCameraScene } from "./Phaser/Login/EnableCameraScene"; +import { CustomizeScene } from "./Phaser/Login/CustomizeScene"; +import WebFontLoaderPlugin from "phaser3-rex-plugins/plugins/webfontloader-plugin.js"; +import OutlinePipelinePlugin from "phaser3-rex-plugins/plugins/outlinepipeline-plugin.js"; +import { EntryScene } from "./Phaser/Login/EntryScene"; +import { coWebsiteManager } from "./WebRtc/CoWebsiteManager"; +import { MenuScene } from "./Phaser/Menu/MenuScene"; +import { localUserStore } from "./Connexion/LocalUserStore"; +import { ErrorScene } from "./Phaser/Reconnecting/ErrorScene"; +import { iframeListener } from "./Api/IframeListener"; +import { SelectCharacterMobileScene } from "./Phaser/Login/SelectCharacterMobileScene"; +import { HdpiManager } from "./Phaser/Services/HdpiManager"; +import { waScaleManager } from "./Phaser/Services/WaScaleManager"; +import { Game } from "./Phaser/Game/Game"; +import App from "./Components/App.svelte"; +import { HtmlUtils } from "./WebRtc/HtmlUtils"; import WebGLRenderer = Phaser.Renderer.WebGL.WebGLRenderer; - -const {width, height} = coWebsiteManager.getGameSize(); +const { width, height } = coWebsiteManager.getGameSize(); const valueGameQuality = localUserStore.getGameQualityValue(); -const fps : Phaser.Types.Core.FPSConfig = { +const fps: Phaser.Types.Core.FPSConfig = { /** * The minimum acceptable rendering rate, in frames per second. */ @@ -53,30 +52,30 @@ const fps : Phaser.Types.Core.FPSConfig = { /** * Apply delta smoothing during the game update to help avoid spikes? */ - smoothStep: false -} + smoothStep: false, +}; // the ?phaserMode=canvas parameter can be used to force Canvas usage const params = new URLSearchParams(document.location.search.substring(1)); const phaserMode = params.get("phaserMode"); let mode: number; switch (phaserMode) { - case 'auto': + case "auto": case null: mode = Phaser.AUTO; break; - case 'canvas': + case "canvas": mode = Phaser.CANVAS; break; - case 'webgl': + case "webgl": mode = Phaser.WEBGL; break; default: throw new Error('phaserMode parameter must be one of "auto", "canvas" or "webgl"'); } -const hdpiManager = new HdpiManager(640*480, 196*196); -const { game: gameSize, real: realSize } = hdpiManager.getOptimalGameSize({width, height}); +const hdpiManager = new HdpiManager(640 * 480, 196 * 196); +const { game: gameSize, real: realSize } = hdpiManager.getOptimalGameSize({ width, height }); const config: GameConfig = { type: mode, @@ -87,9 +86,10 @@ const config: GameConfig = { height: gameSize.height, zoom: realSize.width / gameSize.width, autoRound: true, - resizeInterval: 999999999999 + resizeInterval: 999999999999, }, - scene: [EntryScene, + scene: [ + EntryScene, LoginScene, isMobile() ? SelectCharacterMobileScene : SelectCharacterScene, SelectCompanionScene, @@ -102,37 +102,39 @@ const config: GameConfig = { //resolution: window.devicePixelRatio / 2, fps: fps, dom: { - createContainer: true + createContainer: true, }, render: { pixelArt: true, roundPixels: true, - antialias: false + antialias: false, }, plugins: { - global: [{ - key: 'rexWebFontLoader', - plugin: WebFontLoaderPlugin, - start: true - }] + global: [ + { + key: "rexWebFontLoader", + plugin: WebFontLoaderPlugin, + start: true, + }, + ], }, physics: { default: "arcade", arcade: { debug: DEBUG_MODE, - } + }, }, // Instruct systems with 2 GPU to choose the low power one. We don't need that extra power and we want to save battery powerPreference: "low-power", callbacks: { - postBoot: game => { + postBoot: (game) => { // Install rexOutlinePipeline only if the renderer is WebGL. const renderer = game.renderer; if (renderer instanceof WebGLRenderer) { - game.plugins.install('rexOutlinePipeline', OutlinePipelinePlugin, true); + game.plugins.install("rexOutlinePipeline", OutlinePipelinePlugin, true); } - } - } + }, + }, }; //const game = new Phaser.Game(config); @@ -140,7 +142,7 @@ const game = new Game(config); waScaleManager.setGame(game); -window.addEventListener('resize', function (event) { +window.addEventListener("resize", function (event) { coWebsiteManager.resetStyle(); waScaleManager.applyNewSize(); @@ -153,22 +155,10 @@ coWebsiteManager.onResize.subscribe(() => { iframeListener.init(); const app = new App({ - target: HtmlUtils.getElementByIdOrFail('svelte-overlay'), + target: HtmlUtils.getElementByIdOrFail("svelte-overlay"), props: { - game: game + game: game, }, -}) +}); -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); - }); - }); -} +export default app; diff --git a/front/style/index.scss b/front/style/index.scss index a6afa557..e69517fb 100644 --- a/front/style/index.scss +++ b/front/style/index.scss @@ -3,4 +3,4 @@ @import "style"; @import "mobile-style.scss"; @import "fonts.scss"; -@import "svelte-style.scss"; +@import "inputTextGlobalMessageSvelte-Style.scss"; diff --git a/front/style/inputTextGlobalMessageSvelte-Style.scss b/front/style/inputTextGlobalMessageSvelte-Style.scss new file mode 100644 index 00000000..dd46351e --- /dev/null +++ b/front/style/inputTextGlobalMessageSvelte-Style.scss @@ -0,0 +1,24 @@ +//InputTextGlobalMessage +section.section-input-send-text { + height: 100%; + + .ql-toolbar{ + max-height: 100px; + + background: whitesmoke; + } + + div.input-send-text{ + height: auto; + max-height: calc(100% - 100px); + overflow: auto; + + color: whitesmoke; + font-size: 1rem; + + .ql-editor.ql-blank::before { + color: whitesmoke; + font-size: 1rem; + } + } +} diff --git a/front/style/svelte-style.scss b/front/style/svelte-style.scss deleted file mode 100644 index 7881dabb..00000000 --- a/front/style/svelte-style.scss +++ /dev/null @@ -1,60 +0,0 @@ -//Contains all styles not unique to a svelte component. - -//ConsoleGlobalMessage -div.main-console.nes-container { - pointer-events: auto; - margin-left: auto; - margin-right: auto; - top: 20vh; - width: 50vw; - height: 50vh; - padding: 0; - background-color: #333333; - - .btn-action{ - margin: 10px; - text-align: center; - } - - .main-global-message { - width: 100%; - max-height: 100%; - } - - .main-global-message h2 { - text-align: center; - color: white; - } - - div.global-message { - display: flex; - max-height: 100%; - width: 100%; - } - - div.menu { - flex: auto; - } - - div.menu button { - margin: 7px; - } - - .main-input { - width: 95%; - } - -//InputTextGlobalMessage - .section-input-send-text { - margin: 10px; - } - - .section-input-send-text .input-send-text .ql-editor{ - color: white; - min-height: 200px; - } - - .section-input-send-text .ql-toolbar{ - background: white; - } -} 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/front/webpack.config.ts b/front/webpack.config.ts index 37362baf..13647b30 100644 --- a/front/webpack.config.ts +++ b/front/webpack.config.ts @@ -189,7 +189,7 @@ module.exports = { DISABLE_NOTIFICATIONS: false, PUSHER_URL: undefined, UPLOADER_URL: null, - ADMIN_URL: null, + ADMIN_URL: undefined, DEBUG_MODE: null, STUN_SERVER: null, TURN_SERVER: null, diff --git a/front/yarn.lock b/front/yarn.lock index 6ee607d3..f1fa2fe2 100644 --- a/front/yarn.lock +++ b/front/yarn.lock @@ -291,6 +291,18 @@ dependencies: source-map "^0.6.1" +"@types/uuid@8.3.0": + version "8.3.0" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.0.tgz#215c231dff736d5ba92410e6d602050cce7e273f" + integrity sha512-eQ9qFW/fhfGJF8WKHGEHZEyVWfZxrT+6CLIJGBcZPfxUh/+BnEj+UCGYMlr9qZuX/2AltsvwrGqp0LhEW8D0zQ== + +"@types/uuidv4@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@types/uuidv4/-/uuidv4-5.0.0.tgz#2c94e67b0c06d5adb28fb7ced1a1b5f0866ecd50" + integrity sha512-xUrhYSJnkTq9CP79cU3svoKTLPCIbMMnu9Twf/tMpHATYSHCAAeDNeb2a/29YORhk5p4atHhCTMsIBU/tvdh6A== + dependencies: + uuidv4 "*" + "@types/webpack-dev-server@^3.11.4": version "3.11.4" resolved "https://registry.yarnpkg.com/@types/webpack-dev-server/-/webpack-dev-server-3.11.4.tgz#90d47dd660b696d409431ab8c1e9fa3615103a07" @@ -5775,11 +5787,24 @@ utils-merge@1.0.1: resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= +uuid@8.3.2: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + uuid@^3.3.2, uuid@^3.4.0: version "3.4.0" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== +uuidv4@*, uuidv4@^6.2.10: + version "6.2.10" + resolved "https://registry.yarnpkg.com/uuidv4/-/uuidv4-6.2.10.tgz#42fc1c12b6f85ad536c2c5c1e836079d1e15003c" + integrity sha512-FMo1exd9l5UvoUPHRR6NrtJ/OJRePh0ca7IhPwBuMNuYRqjtuh8lE3WDxAUvZ4Yss5FbCOsPFjyWJf9lVTEmnw== + dependencies: + "@types/uuid" "8.3.0" + uuid "8.3.2" + v8-compile-cache@^2.0.3, v8-compile-cache@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee" 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/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 deleted file mode 100644 index 7429b2a8..00000000 --- a/maps/tests/Metadata/getCurrentRoom.html +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - \ 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..df3a995c --- /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.getTiledMap().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 4122cc50..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/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/Variables/script.js b/maps/tests/Variables/script.js new file mode 100644 index 00000000..1ab1b2e5 --- /dev/null +++ b/maps/tests/Variables/script.js @@ -0,0 +1,33 @@ +WA.onInit().then(() => { + console.log('Trying to read variable "doorOpened" whose default property is true. This should display "true".'); + 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.state.saveVariable('not_exists', 'foo').catch((e) => { + console.log('Successfully caught error: ', e); + }); + + console.log('Trying to set variable "myvar". This should work.'); + WA.state.saveVariable('myvar', {'foo': 'bar'}); + + console.log('Trying to read variable "myvar". This should display a {"foo": "bar"} object.'); + 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.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.html b/maps/tests/Variables/shared_variables.html new file mode 100644 index 00000000..21e0b998 --- /dev/null +++ b/maps/tests/Variables/shared_variables.html @@ -0,0 +1,48 @@ + + + + + + + + + + + +
+ + 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 new file mode 100644 index 00000000..b0f5b5b0 --- /dev/null +++ b/maps/tests/Variables/variables.json @@ -0,0 +1,205 @@ +{ "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, 0, 0, 0, 0, 0, 0, 0, 0, 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:\nOpen your console\n\nResult:\nYou should see a list of tests performed and results associated.", + "wrap":true + }, + "type":"", + "visible":true, + "width":252.4375, + "x":2.78125, + "y":2.5 + }, + { + "height":0, + "id":5, + "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 + }, + { + "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 + }, + { + "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 + }, + { + "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", + "visible":true, + "x":0, + "y":0 + }], + "nextlayerid":8, + "nextobjectid":11, + "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 dba14eec..fbff09e5 100644 --- a/maps/tests/index.html +++ b/maps/tests/index.html @@ -122,20 +122,12 @@ 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 - Testing return current user attributes by Scripting API + Testing return current player attributes in Scripting API + WA.onInit @@ -186,14 +178,6 @@ Test start tile (S2) - - - Success Failure Pending - - - Test cowebsite opened by script is allowed to use IFrame API - - Success Failure Pending @@ -202,6 +186,22 @@ Test set tiles + + + Success Failure Pending + + + Testing scripting variables locally + + + + + Success Failure Pending + + + Testing shared scripting variables + +