Merge pull request #1295 from thecodingmachine/develop

v1.4.8
This commit is contained in:
Kharhamel 2021-07-19 17:10:00 +02:00 committed by GitHub
commit 4dcd8cfb18
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 462 additions and 541 deletions

View file

@ -23,6 +23,10 @@
- The chat allows your to see the visit card of users
- You can close the chat window with the escape key
- Added a 'Enable notifications' button in the menu.
- The exchange format between Pusher and Admin servers has changed. If you have your own implementation of an admin server, these endpoints signatures have changed:
- `/api/map`: now accepts a complete room URL instead of organization/world/room slugs
- `/api/ban`: new endpoint to report users
- as a side effect, the "routing" is now completely stored on the admin side, so by implementing your own admin server, you can develop completely custom routing
## Version 1.4.3 - 1.4.4 - 1.4.5

View file

@ -5,8 +5,6 @@ import { PositionInterface } from "_Model/PositionInterface";
import { EmoteCallback, EntersCallback, LeavesCallback, MovesCallback } from "_Model/Zone";
import { PositionNotifier } from "./PositionNotifier";
import { Movable } from "_Model/Movable";
import { extractDataFromPrivateRoomId, extractRoomSlugPublicRoomId, isRoomAnonymous } from "./RoomIdentifier";
import { arrayIntersect } from "../Services/ArrayHelper";
import { EmoteEventMessage, JoinRoomMessage } from "../Messages/generated/messages_pb";
import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils";
import { ZoneSocket } from "src/RoomManager";
@ -31,15 +29,12 @@ export class GameRoom {
private itemsState: Map<number, unknown> = new Map<number, unknown>();
private readonly positionNotifier: PositionNotifier;
public readonly roomId: string;
public readonly roomSlug: string;
public readonly worldSlug: string = "";
public readonly organizationSlug: string = "";
public readonly roomUrl: string;
private versionNumber: number = 1;
private nextUserId: number = 1;
constructor(
roomId: string,
roomUrl: string,
connectCallback: ConnectCallback,
disconnectCallback: DisconnectCallback,
minDistance: number,
@ -49,16 +44,7 @@ export class GameRoom {
onLeaves: LeavesCallback,
onEmote: EmoteCallback
) {
this.roomId = roomId;
if (isRoomAnonymous(roomId)) {
this.roomSlug = extractRoomSlugPublicRoomId(this.roomId);
} else {
const { organizationSlug, worldSlug, roomSlug } = extractDataFromPrivateRoomId(this.roomId);
this.roomSlug = roomSlug;
this.organizationSlug = organizationSlug;
this.worldSlug = worldSlug;
}
this.roomUrl = roomUrl;
this.users = new Map<number, User>();
this.usersByUuid = new Map<string, User>();
@ -177,7 +163,7 @@ export class GameRoom {
} else {
const closestUser: User = closestItem;
const group: Group = new Group(
this.roomId,
this.roomUrl,
[user, closestUser],
this.connectCallback,
this.disconnectCallback,

View file

@ -1,30 +0,0 @@
//helper functions to parse room IDs
export const isRoomAnonymous = (roomID: string): boolean => {
if (roomID.startsWith("_/")) {
return true;
} else if (roomID.startsWith("@/")) {
return false;
} else {
throw new Error("Incorrect room ID: " + roomID);
}
};
export const extractRoomSlugPublicRoomId = (roomId: string): string => {
const idParts = roomId.split("/");
if (idParts.length < 3) throw new Error("Incorrect roomId: " + roomId);
return idParts.slice(2).join("/");
};
export interface extractDataFromPrivateRoomIdResponse {
organizationSlug: string;
worldSlug: string;
roomSlug: string;
}
export const extractDataFromPrivateRoomId = (roomId: string): extractDataFromPrivateRoomIdResponse => {
const idParts = roomId.split("/");
if (idParts.length < 4) throw new Error("Incorrect roomId: " + roomId);
const organizationSlug = idParts[1];
const worldSlug = idParts[2];
const roomSlug = idParts[3];
return { organizationSlug, worldSlug, roomSlug };
};

View file

@ -250,12 +250,12 @@ export class SocketManager {
//user leave previous world
room.leave(user);
if (room.isEmpty()) {
this.rooms.delete(room.roomId);
this.rooms.delete(room.roomUrl);
gaugeManager.decNbRoomGauge();
debug('Room is empty. Deleting room "%s"', room.roomId);
debug('Room is empty. Deleting room "%s"', room.roomUrl);
}
} finally {
clientEventsEmitter.emitClientLeave(user.uuid, room.roomId);
clientEventsEmitter.emitClientLeave(user.uuid, room.roomUrl);
console.log("A user left");
}
}
@ -658,9 +658,9 @@ export class SocketManager {
public leaveAdminRoom(room: GameRoom, admin: Admin) {
room.adminLeave(admin);
if (room.isEmpty()) {
this.rooms.delete(room.roomId);
this.rooms.delete(room.roomUrl);
gaugeManager.decNbRoomGauge();
debug('Room is empty. Deleting room "%s"', room.roomId);
debug('Room is empty. Deleting room "%s"', room.roomUrl);
}
}

View file

@ -1,19 +0,0 @@
import {extractDataFromPrivateRoomId, extractRoomSlugPublicRoomId, isRoomAnonymous} from "../src/Model/RoomIdentifier";
describe("RoomIdentifier", () => {
it("should flag public id as anonymous", () => {
expect(isRoomAnonymous('_/global/test')).toBe(true);
});
it("should flag public id as not anonymous", () => {
expect(isRoomAnonymous('@/afup/afup2020/1floor')).toBe(false);
});
it("should extract roomSlug from public ID", () => {
expect(extractRoomSlugPublicRoomId('_/global/npeguin/test.json')).toBe('npeguin/test.json');
});
it("should extract correct from private ID", () => {
const {organizationSlug, worldSlug, roomSlug} = extractDataFromPrivateRoomId('@/afup/afup2020/1floor');
expect(organizationSlug).toBe('afup');
expect(worldSlug).toBe('afup2020');
expect(roomSlug).toBe('1floor');
});
})

View file

@ -30,12 +30,10 @@
<aside class="chatWindow" transition:fly="{{ x: -1000, duration: 500 }}">
<section class="chatWindowTitle">
<h1>Your chat history <span class="float-right" on:click={closeChat}>&times</span></h1>
</section>
<i class="close-icon" on:click={closeChat}>&times</i>
<section class="messagesList" bind:this={listDom}>
<ul>
<li><p class="system-text">Here is your chat history: </p></li>
{#each $chatMessagesStore as message, i}
<li><ChatElement message={message} line={i}></ChatElement></li>
{/each}
@ -47,16 +45,24 @@
</aside>
<style lang="scss">
h1 {
font-family: Lato;
span.float-right {
font-size: 30px;
line-height: 25px;
font-weight: bold;
float: right;
cursor: pointer;
}
i.close-icon {
position: absolute;
padding: 4px;
right: 12px;
font-size: 30px;
line-height: 25px;
font-weight: bold;
cursor: pointer;
}
p.system-text {
border-radius: 8px;
margin-bottom: 10px;
padding:6px;
overflow-wrap: break-word;
max-width: 100%;
background: gray;
display: inline-block;
}
aside.chatWindow {
@ -78,16 +84,8 @@
border-bottom-right-radius: 16px;
border-top-right-radius: 16px;
h1 {
background-color: #5f5f5f;
border-radius: 8px;
padding: 2px;
}
.chatWindowTitle {
flex: 0 100px;
}
.messagesList {
margin-top: 35px;
overflow-y: auto;
flex: auto;
@ -98,7 +96,7 @@
}
.messageForm {
flex: 0 70px;
padding-top: 20px;
padding-top: 15px;
}
}
</style>

View file

@ -32,7 +32,7 @@
input {
flex: auto;
background-color: #42464d;
background-color: #254560;
color: white;
border-bottom-left-radius: 4px;
border-top-left-radius: 4px;
@ -45,11 +45,11 @@
}
button {
background-color: #42464d;
background-color: #254560;
border-bottom-right-radius: 4px;
border-top-right-radius: 4px;
border: none;
border-left: solid black 1px;
border-left: solid white 1px;
font-size: 16px;
}
}

View file

@ -38,11 +38,9 @@ class ConnectionManager {
this.localUser = new LocalUser(data.userUuid, data.authToken, data.textures);
localUserStore.saveUser(this.localUser);
const organizationSlug = data.organizationSlug;
const worldSlug = data.worldSlug;
const roomSlug = data.roomSlug;
const roomUrl = data.roomUrl;
const room = new Room('/@/'+organizationSlug+'/'+worldSlug+'/'+roomSlug + window.location.search + window.location.hash);
const 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) {
@ -66,22 +64,21 @@ class ConnectionManager {
throw "Error to store local user data";
}
let roomId: string;
let roomPath: string;
if (connexionType === GameConnexionTypes.empty) {
roomId = START_ROOM_URL;
roomPath = window.location.protocol + '//' + window.location.host + START_ROOM_URL;
} else {
roomId = 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 = new Room(roomId);
const mapDetail = await room.getMapDetail();
if(mapDetail.textures != undefined && mapDetail.textures.length > 0) {
const 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 = mapDetail.textures;
localUser.textures = room.textures;
}else{
mapDetail.textures.forEach((newTexture) => {
room.textures.forEach((newTexture) => {
const alreadyExistTexture = localUser?.textures.find((c) => newTexture.id === c.id);
if(localUser?.textures.findIndex((c) => newTexture.id === c.id) !== -1){
return;
@ -114,9 +111,9 @@ class ConnectionManager {
this.localUser = new LocalUser('', 'test', []);
}
public connectToRoomSocket(roomId: string, name: string, characterLayers: string[], position: PositionInterface, viewport: ViewportInterface, companion: string|null): Promise<OnConnectInterface> {
public connectToRoomSocket(roomUrl: string, name: string, characterLayers: string[], position: PositionInterface, viewport: ViewportInterface, companion: string|null): Promise<OnConnectInterface> {
return new Promise<OnConnectInterface>((resolve, reject) => {
const connection = new RoomConnection(this.localUser.jwtToken, roomId, name, characterLayers, position, viewport, companion);
const connection = new RoomConnection(this.localUser.jwtToken, roomUrl, name, characterLayers, position, viewport, companion);
connection.onConnectError((error: object) => {
console.log('An error occurred while connecting to socket server. Retrying');
reject(error);
@ -137,7 +134,7 @@ class ConnectionManager {
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(roomId, name, characterLayers, position, viewport, companion).then((connection) => resolve(connection));
this.connectToRoomSocket(roomUrl, name, characterLayers, position, viewport, companion).then((connection) => resolve(connection));
}, 4000 + Math.floor(Math.random() * 2000) );
});
});

View file

@ -3,91 +3,103 @@ import { PUSHER_URL } from "../Enum/EnvironmentVariable";
import type { CharacterTexture } from "./LocalUser";
export class MapDetail {
constructor(public readonly mapUrl: string, public readonly textures: CharacterTexture[] | undefined) {
}
constructor(public readonly mapUrl: string, public readonly textures: CharacterTexture[] | undefined) {}
}
export interface RoomRedirect {
redirectUrl: string;
}
export class Room {
public readonly id: string;
public readonly isPublic: boolean;
private mapUrl: string | undefined;
private textures: CharacterTexture[] | undefined;
private _mapUrl: string | undefined;
private _textures: CharacterTexture[] | undefined;
private instance: string | undefined;
private _search: URLSearchParams;
private readonly _search: URLSearchParams;
constructor(id: string) {
const url = new URL(id, 'https://example.com');
private constructor(private roomUrl: URL) {
this.id = roomUrl.pathname;
this.id = url.pathname;
if (this.id.startsWith('/')) {
if (this.id.startsWith("/")) {
this.id = this.id.substr(1);
}
if (this.id.startsWith('_/')) {
if (this.id.startsWith("_/")) {
this.isPublic = true;
} else if (this.id.startsWith('@/')) {
} else if (this.id.startsWith("@/")) {
this.isPublic = false;
} else {
throw new Error('Invalid room ID');
throw new Error("Invalid room ID");
}
this._search = new URLSearchParams(url.search);
this._search = new URLSearchParams(roomUrl.search);
}
public static getIdFromIdentifier(identifier: string, baseUrl: string, currentInstance: string): { roomId: string, hash: string | null } {
let roomId = '';
let hash = null;
if (!identifier.startsWith('/_/') && !identifier.startsWith('/@/')) { //relative file link
//Relative identifier can be deep enough to rewrite the base domain, so we cannot use the variable 'baseUrl' as the actual base url for the URL objects.
//We instead use 'workadventure' as a dummy base value.
const baseUrlObject = new URL(baseUrl);
const absoluteExitSceneUrl = new URL(identifier, 'http://workadventure/_/' + currentInstance + '/' + baseUrlObject.hostname + baseUrlObject.pathname);
roomId = absoluteExitSceneUrl.pathname; //in case of a relative url, we need to create a public roomId
roomId = roomId.substring(1); //remove the leading slash
hash = absoluteExitSceneUrl.hash;
hash = hash.substring(1); //remove the leading diese
if (!hash.length) {
hash = null
}
} else { //absolute room Id
const parts = identifier.split('#');
roomId = parts[0];
roomId = roomId.substring(1); //remove the leading slash
if (parts.length > 1) {
hash = parts[1]
/**
* Creates a "Room" object representing the room.
* This method will follow room redirects if necessary, so the instance returned is a "real" room.
*/
public static async createRoom(roomUrl: URL): Promise<Room> {
let redirectCount = 0;
while (redirectCount < 32) {
const room = new Room(roomUrl);
const result = await room.getMapDetail();
if (result instanceof MapDetail) {
return room;
}
redirectCount++;
roomUrl = new URL(result.redirectUrl);
}
return { roomId, hash }
throw new Error("Room resolving seems stuck in a redirect loop after 32 redirect attempts");
}
public async getMapDetail(): Promise<MapDetail> {
return new Promise<MapDetail>((resolve, reject) => {
if (this.mapUrl !== undefined && this.textures != undefined) {
resolve(new MapDetail(this.mapUrl, this.textures));
return;
}
public static getRoomPathFromExitUrl(exitUrl: string, currentRoomUrl: string): URL {
const url = new URL(exitUrl, currentRoomUrl);
return url;
}
if (this.isPublic) {
const match = /_\/[^/]+\/(.+)/.exec(this.id);
if (!match) throw new Error('Could not extract url from "' + this.id + '"');
this.mapUrl = window.location.protocol + '//' + match[1];
resolve(new MapDetail(this.mapUrl, this.textures));
return;
} else {
// We have a private ID, we need to query the map URL from the server.
const urlParts = this.parsePrivateUrl(this.id);
/**
* @deprecated USage of exitSceneUrl is deprecated and therefore, this method is deprecated too.
*/
public static getRoomPathFromExitSceneUrl(
exitSceneUrl: string,
currentRoomUrl: string,
currentMapUrl: string
): URL {
const absoluteExitSceneUrl = new URL(exitSceneUrl, currentMapUrl);
const baseUrl = new URL(currentRoomUrl);
Axios.get(`${PUSHER_URL}/map`, {
params: urlParts
}).then(({ data }) => {
console.log('Map ', this.id, ' resolves to URL ', data.mapUrl);
resolve(data);
return;
}).catch((reason) => {
reject(reason);
});
}
const currentRoom = new Room(baseUrl);
let instance: string = "global";
if (currentRoom.isPublic) {
instance = currentRoom.instance as string;
}
baseUrl.pathname = "/_/" + instance + "/" + absoluteExitSceneUrl.host + absoluteExitSceneUrl.pathname;
if (absoluteExitSceneUrl.hash) {
baseUrl.hash = absoluteExitSceneUrl.hash;
}
return baseUrl;
}
private async getMapDetail(): Promise<MapDetail | RoomRedirect> {
const result = await Axios.get(`${PUSHER_URL}/map`, {
params: {
playUri: this.roomUrl.toString(),
},
});
const data = result.data;
if (data.redirectUrl) {
return {
redirectUrl: data.redirectUrl as string,
};
}
console.log("Map ", this.id, " resolves to URL ", data.mapUrl);
this._mapUrl = data.mapUrl;
this._textures = data.textures;
return new MapDetail(data.mapUrl, data.textures);
}
/**
@ -108,21 +120,24 @@ export class Room {
} else {
const match = /@\/([^/]+)\/([^/]+)\/.+/.exec(this.id);
if (!match) throw new Error('Could not extract instance from "' + this.id + '"');
this.instance = match[1] + '/' + match[2];
this.instance = match[1] + "/" + match[2];
return this.instance;
}
}
private parsePrivateUrl(url: string): { organizationSlug: string, worldSlug: string, roomSlug?: string } {
/**
* @deprecated
*/
private parsePrivateUrl(url: string): { organizationSlug: string; worldSlug: string; roomSlug?: string } {
const regex = /@\/([^/]+)\/([^/]+)(?:\/([^/]*))?/gm;
const match = regex.exec(url);
if (!match) {
throw new Error('Invalid URL ' + url);
throw new Error("Invalid URL " + url);
}
const results: { organizationSlug: string, worldSlug: string, roomSlug?: string } = {
const results: { organizationSlug: string; worldSlug: string; roomSlug?: string } = {
organizationSlug: match[1],
worldSlug: match[2],
}
};
if (match[3] !== undefined) {
results.roomSlug = match[3];
}
@ -130,8 +145,8 @@ export class Room {
}
public isDisconnected(): boolean {
const alone = this._search.get('alone');
if (alone && alone !== '0' && alone.toLowerCase() !== 'false') {
const alone = this._search.get("alone");
if (alone && alone !== "0" && alone.toLowerCase() !== "false") {
return true;
}
return false;
@ -140,4 +155,32 @@ export class Room {
public get search(): URLSearchParams {
return this._search;
}
/**
* 2 rooms are equal if they share the same path (but not necessarily the same hash)
* @param room
*/
public isEqual(room: Room): boolean {
return room.key === this.key;
}
/**
* A key representing this room
*/
public get key(): string {
const newUrl = new URL(this.roomUrl.toString());
newUrl.hash = "";
return newUrl.toString();
}
get textures(): CharacterTexture[] | undefined {
return this._textures;
}
get mapUrl(): string {
if (!this._mapUrl) {
throw new Error("Map URL not fetched yet");
}
return this._mapUrl;
}
}

View file

@ -75,11 +75,11 @@ export class RoomConnection implements RoomConnection {
/**
*
* @param token A JWT token containing the UUID of the user
* @param roomId The ID of the room in the form "_/[instance]/[map_url]" or "@/[org]/[event]/[map]"
* @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(
token: string | null,
roomId: string,
roomUrl: string,
name: string,
characterLayers: string[],
position: PositionInterface,
@ -92,7 +92,7 @@ export class RoomConnection implements RoomConnection {
url += "/";
}
url += "room";
url += "?roomId=" + (roomId ? encodeURIComponent(roomId) : "");
url += "?roomId=" + encodeURIComponent(roomUrl);
url += "&token=" + (token ? encodeURIComponent(token) : "");
url += "&name=" + encodeURIComponent(name);
for (const layer of characterLayers) {

View file

@ -28,7 +28,7 @@ export class GameManager {
public async init(scenePlugin: Phaser.Scenes.ScenePlugin): Promise<string> {
this.startRoom = await connectionManager.initGameConnexion();
await this.loadMap(this.startRoom, scenePlugin);
this.loadMap(this.startRoom, scenePlugin);
if (!this.playerName) {
return LoginSceneName;
@ -68,20 +68,19 @@ export class GameManager {
return this.companion;
}
public async loadMap(room: Room, scenePlugin: Phaser.Scenes.ScenePlugin): Promise<void> {
const roomID = room.id;
const mapDetail = await room.getMapDetail();
public loadMap(room: Room, scenePlugin: Phaser.Scenes.ScenePlugin) {
const roomID = room.key;
const gameIndex = scenePlugin.getIndex(roomID);
if (gameIndex === -1) {
const game: Phaser.Scene = new GameScene(room, mapDetail.mapUrl);
const game: Phaser.Scene = new GameScene(room, room.mapUrl);
scenePlugin.add(roomID, game, false);
}
}
public goToStartingMap(scenePlugin: Phaser.Scenes.ScenePlugin): void {
console.log("starting " + (this.currentGameSceneName || this.startRoom.id));
scenePlugin.start(this.currentGameSceneName || this.startRoom.id);
console.log("starting " + (this.currentGameSceneName || this.startRoom.key));
scenePlugin.start(this.currentGameSceneName || this.startRoom.key);
scenePlugin.launch(MenuSceneName);
if (

View file

@ -173,7 +173,7 @@ export class GameScene extends DirtyScene {
private chatVisibilityUnsubscribe!: () => void;
private biggestAvailableAreaStoreUnsubscribe!: () => void;
MapUrlFile: string;
RoomId: string;
roomUrl: string;
instance: string;
currentTick!: number;
@ -206,14 +206,14 @@ export class GameScene extends DirtyScene {
constructor(private room: Room, MapUrlFile: string, customKey?: string | undefined) {
super({
key: customKey ?? room.id,
key: customKey ?? room.key,
});
this.Terrains = [];
this.groups = new Map<number, Sprite>();
this.instance = room.getInstance();
this.MapUrlFile = MapUrlFile;
this.RoomId = room.id;
this.roomUrl = room.key;
this.createPromise = new Promise<void>((resolve, reject): void => {
this.createPromiseResolve = resolve;
@ -465,11 +465,13 @@ export class GameScene extends DirtyScene {
if (layer.type === "tilelayer") {
const exitSceneUrl = this.getExitSceneUrl(layer);
if (exitSceneUrl !== undefined) {
this.loadNextGame(exitSceneUrl);
this.loadNextGame(
Room.getRoomPathFromExitSceneUrl(exitSceneUrl, window.location.toString(), this.MapUrlFile)
);
}
const exitUrl = this.getExitUrl(layer);
if (exitUrl !== undefined) {
this.loadNextGame(exitUrl);
this.loadNextGameFromExitUrl(exitUrl);
}
}
if (layer.type === "objectgroup") {
@ -482,7 +484,7 @@ export class GameScene extends DirtyScene {
}
this.gameMap.exitUrls.forEach((exitUrl) => {
this.loadNextGame(exitUrl);
this.loadNextGameFromExitUrl(exitUrl);
});
this.startPositionCalculator = new StartPositionCalculator(
@ -587,7 +589,7 @@ export class GameScene extends DirtyScene {
connectionManager
.connectToRoomSocket(
this.RoomId,
this.roomUrl,
this.playerName,
this.characterLayers,
{
@ -775,10 +777,13 @@ export class GameScene extends DirtyScene {
private triggerOnMapLayerPropertyChange() {
this.gameMap.onPropertyChange("exitSceneUrl", (newValue, oldValue) => {
if (newValue) this.onMapExit(newValue as string);
if (newValue)
this.onMapExit(
Room.getRoomPathFromExitSceneUrl(newValue as string, window.location.toString(), this.MapUrlFile)
);
});
this.gameMap.onPropertyChange("exitUrl", (newValue, oldValue) => {
if (newValue) this.onMapExit(newValue as string);
if (newValue) this.onMapExit(Room.getRoomPathFromExitUrl(newValue as string, window.location.toString()));
});
this.gameMap.onPropertyChange("openWebsite", (newValue, oldValue, allProps) => {
if (newValue === undefined) {
@ -1003,9 +1008,9 @@ ${escapedMessage}
);
this.iframeSubscriptionList.push(
iframeListener.loadPageStream.subscribe((url: string) => {
this.loadNextGame(url).then(() => {
this.loadNextGameFromExitUrl(url).then(() => {
this.events.once(EVENT_TYPE.POST_UPDATE, () => {
this.onMapExit(url);
this.onMapExit(Room.getRoomPathFromExitUrl(url, window.location.toString()));
});
});
})
@ -1060,7 +1065,7 @@ ${escapedMessage}
startLayerName: this.startPositionCalculator.startLayerName,
uuid: localUserStore.getLocalUser()?.uuid,
nickname: localUserStore.getName(),
roomId: this.RoomId,
roomId: this.roomUrl,
tags: this.connection ? this.connection.getAllTags() : [],
};
});
@ -1084,7 +1089,7 @@ ${escapedMessage}
return;
}
if (propertyName === "exitUrl" && typeof propertyValue === "string") {
this.loadNextGame(propertyValue);
this.loadNextGameFromExitUrl(propertyValue);
}
if (layer.properties === undefined) {
layer.properties = [];
@ -1131,28 +1136,38 @@ ${escapedMessage}
return this.MapUrlFile.substr(0, this.MapUrlFile.lastIndexOf("/"));
}
private onMapExit(exitKey: string) {
private async onMapExit(roomUrl: URL) {
if (this.mapTransitioning) return;
this.mapTransitioning = true;
const { roomId, hash } = Room.getIdFromIdentifier(exitKey, this.MapUrlFile, this.instance);
if (!roomId) throw new Error("Could not find the room from its exit key: " + exitKey);
if (hash) {
urlManager.pushStartLayerNameToUrl(hash);
let targetRoom: Room;
try {
targetRoom = await Room.createRoom(roomUrl);
} catch (e: unknown) {
console.error('Error while fetching new room "' + roomUrl.toString() + '"', e);
this.mapTransitioning = false;
return;
}
if (roomUrl.hash) {
urlManager.pushStartLayerNameToUrl(roomUrl.hash);
}
const menuScene: MenuScene = this.scene.get(MenuSceneName) as MenuScene;
menuScene.reset();
if (roomId !== this.scene.key) {
if (this.scene.get(roomId) === null) {
console.error("next room not loaded", exitKey);
if (!targetRoom.isEqual(this.room)) {
if (this.scene.get(targetRoom.key) === null) {
console.error("next room not loaded", targetRoom.key);
return;
}
this.cleanupClosingScene();
this.scene.stop();
this.scene.start(targetRoom.key);
this.scene.remove(this.scene.key);
this.scene.start(roomId);
} else {
//if the exit points to the current map, we simply teleport the user back to the startLayer
this.startPositionCalculator.initPositionFromLayerName(hash, hash);
this.startPositionCalculator.initPositionFromLayerName(roomUrl.hash, roomUrl.hash);
this.CurrentPlayer.x = this.startPositionCalculator.startPosition.x;
this.CurrentPlayer.y = this.startPositionCalculator.startPosition.y;
setTimeout(() => (this.mapTransitioning = false), 500);
@ -1244,11 +1259,18 @@ ${escapedMessage}
.map((property) => property.value);
}
private loadNextGameFromExitUrl(exitUrl: string): Promise<void> {
return this.loadNextGame(Room.getRoomPathFromExitUrl(exitUrl, window.location.toString()));
}
//todo: push that into the gameManager
private loadNextGame(exitSceneIdentifier: string): Promise<void> {
const { roomId, hash } = Room.getIdFromIdentifier(exitSceneIdentifier, this.MapUrlFile, this.instance);
const room = new Room(roomId);
return gameManager.loadMap(room, this.scene).catch(() => {});
private async loadNextGame(exitRoomPath: URL): Promise<void> {
try {
const room = await Room.createRoom(exitRoomPath);
return gameManager.loadMap(room, this.scene);
} catch (e: unknown) {
console.warn('Error while pre-loading exit room "' + exitRoomPath.toString() + '"', e);
}
}
//todo: in a dedicated class/function?
@ -1691,7 +1713,7 @@ ${escapedMessage}
this.scene.start(ErrorSceneName, {
title: "Banned",
subTitle: "You were banned from WorkAdventure",
message: "If you want more information, you may contact us at: workadventure@thecodingmachine.com",
message: "If you want more information, you may contact us at: hello@workadventu.re",
});
}
@ -1706,14 +1728,14 @@ ${escapedMessage}
this.scene.start(ErrorSceneName, {
title: "Connection rejected",
subTitle: "The world you are trying to join is full. Try again later.",
message: "If you want more information, you may contact us at: workadventure@thecodingmachine.com",
message: "If you want more information, you may contact us at: hello@workadventu.re",
});
} else {
this.scene.start(ErrorSceneName, {
title: "Connection rejected",
subTitle: "You cannot join the World. Try again later. \n\r \n\r Error: " + message + ".",
message:
"If you want more information, you may contact administrator or contact us at: workadventure@thecodingmachine.com",
"If you want more information, you may contact administrator or contact us at: hello@workadventu.re",
});
}
}

View file

@ -1,8 +1,8 @@
import {gameManager} from "../Game/GameManager";
import {Scene} from "phaser";
import {ErrorScene} from "../Reconnecting/ErrorScene";
import {WAError} from "../Reconnecting/WAError";
import {waScaleManager} from "../Services/WaScaleManager";
import { gameManager } from "../Game/GameManager";
import { Scene } from "phaser";
import { ErrorScene } from "../Reconnecting/ErrorScene";
import { WAError } from "../Reconnecting/WAError";
import { waScaleManager } from "../Services/WaScaleManager";
export const EntrySceneName = "EntryScene";
@ -13,26 +13,32 @@ export const EntrySceneName = "EntryScene";
export class EntryScene extends Scene {
constructor() {
super({
key: EntrySceneName
key: EntrySceneName,
});
}
create() {
gameManager.init(this.scene).then((nextSceneName) => {
// Let's rescale before starting the game
// We can do it at this stage.
waScaleManager.applyNewSize();
this.scene.start(nextSceneName);
}).catch((err) => {
if (err.response && err.response.status == 404) {
ErrorScene.showError(new WAError(
'Access link incorrect',
'Could not find map. Please check your access link.',
'If you want more information, you may contact administrator or contact us at: workadventure@thecodingmachine.com'), this.scene);
} else {
ErrorScene.showError(err, this.scene);
}
});
gameManager
.init(this.scene)
.then((nextSceneName) => {
// Let's rescale before starting the game
// We can do it at this stage.
waScaleManager.applyNewSize();
this.scene.start(nextSceneName);
})
.catch((err) => {
if (err.response && err.response.status == 404) {
ErrorScene.showError(
new WAError(
"Access link incorrect",
"Could not find map. Please check your access link.",
"If you want more information, you may contact administrator or contact us at: hello@workadventu.re"
),
this.scene
);
} else {
ErrorScene.showError(err, this.scene);
}
});
}
}

View file

@ -1,25 +1,25 @@
import {gameManager} from "../Game/GameManager";
import { gameManager } from "../Game/GameManager";
import Rectangle = Phaser.GameObjects.Rectangle;
import {EnableCameraSceneName} from "./EnableCameraScene";
import {CustomizeSceneName} from "./CustomizeScene";
import {localUserStore} from "../../Connexion/LocalUserStore";
import {loadAllDefaultModels} from "../Entity/PlayerTexturesLoadingManager";
import {addLoader} from "../Components/Loader";
import type {BodyResourceDescriptionInterface} from "../Entity/PlayerTextures";
import {AbstractCharacterScene} from "./AbstractCharacterScene";
import {areCharacterLayersValid} from "../../Connexion/LocalUser";
import {touchScreenManager} from "../../Touch/TouchScreenManager";
import {PinchManager} from "../UserInput/PinchManager";
import {selectCharacterSceneVisibleStore} from "../../Stores/SelectCharacterStore";
import {waScaleManager} from "../Services/WaScaleManager";
import {isMobile} from "../../Enum/EnvironmentVariable";
import { EnableCameraSceneName } from "./EnableCameraScene";
import { CustomizeSceneName } from "./CustomizeScene";
import { localUserStore } from "../../Connexion/LocalUserStore";
import { loadAllDefaultModels } from "../Entity/PlayerTexturesLoadingManager";
import { addLoader } from "../Components/Loader";
import type { BodyResourceDescriptionInterface } from "../Entity/PlayerTextures";
import { AbstractCharacterScene } from "./AbstractCharacterScene";
import { areCharacterLayersValid } from "../../Connexion/LocalUser";
import { touchScreenManager } from "../../Touch/TouchScreenManager";
import { PinchManager } from "../UserInput/PinchManager";
import { selectCharacterSceneVisibleStore } from "../../Stores/SelectCharacterStore";
import { waScaleManager } from "../Services/WaScaleManager";
import { isMobile } from "../../Enum/EnvironmentVariable";
//todo: put this constants in a dedicated file
export const SelectCharacterSceneName = "SelectCharacterScene";
export class SelectCharacterScene extends AbstractCharacterScene {
protected readonly nbCharactersPerRow = 6;
protected selectedPlayer!: Phaser.Physics.Arcade.Sprite|null; // null if we are selecting the "customize" option
protected selectedPlayer!: Phaser.Physics.Arcade.Sprite | null; // null if we are selecting the "customize" option
protected players: Array<Phaser.Physics.Arcade.Sprite> = new Array<Phaser.Physics.Arcade.Sprite>();
protected playerModels!: BodyResourceDescriptionInterface[];
@ -38,7 +38,6 @@ export class SelectCharacterScene extends AbstractCharacterScene {
}
preload() {
this.loadSelectSceneCharacters().then((bodyResourceDescriptions) => {
bodyResourceDescriptions.forEach((bodyResourceDescription) => {
this.playerModels.push(bodyResourceDescription);
@ -54,7 +53,7 @@ export class SelectCharacterScene extends AbstractCharacterScene {
create() {
selectCharacterSceneVisibleStore.set(true);
this.events.addListener('wake', () => {
this.events.addListener("wake", () => {
waScaleManager.saveZoom();
waScaleManager.zoomModifier = isMobile() ? 2 : 1;
selectCharacterSceneVisibleStore.set(true);
@ -68,26 +67,26 @@ export class SelectCharacterScene extends AbstractCharacterScene {
waScaleManager.zoomModifier = isMobile() ? 2 : 1;
const rectangleXStart = this.game.renderer.width / 2 - (this.nbCharactersPerRow / 2) * 32 + 16;
this.selectedRectangle = this.add.rectangle(rectangleXStart, 90, 32, 32).setStrokeStyle(2, 0xFFFFFF);
this.selectedRectangle = this.add.rectangle(rectangleXStart, 90, 32, 32).setStrokeStyle(2, 0xffffff);
this.selectedRectangle.setDepth(2);
/*create user*/
this.createCurrentPlayer();
this.input.keyboard.on('keyup-ENTER', () => {
this.input.keyboard.on("keyup-ENTER", () => {
return this.nextSceneToCameraScene();
});
this.input.keyboard.on('keydown-RIGHT', () => {
this.input.keyboard.on("keydown-RIGHT", () => {
this.moveToRight();
});
this.input.keyboard.on('keydown-LEFT', () => {
this.input.keyboard.on("keydown-LEFT", () => {
this.moveToLeft();
});
this.input.keyboard.on('keydown-UP', () => {
this.input.keyboard.on("keydown-UP", () => {
this.moveToUp();
});
this.input.keyboard.on('keydown-DOWN', () => {
this.input.keyboard.on("keydown-DOWN", () => {
this.moveToDown();
});
}
@ -96,7 +95,7 @@ export class SelectCharacterScene extends AbstractCharacterScene {
if (this.selectedPlayer !== null && !areCharacterLayersValid([this.selectedPlayer.texture.key])) {
return;
}
if(!this.selectedPlayer){
if (!this.selectedPlayer) {
return;
}
this.scene.stop(SelectCharacterSceneName);
@ -105,7 +104,7 @@ export class SelectCharacterScene extends AbstractCharacterScene {
gameManager.tryResumingGame(this, EnableCameraSceneName);
this.players = [];
selectCharacterSceneVisibleStore.set(false);
this.events.removeListener('wake');
this.events.removeListener("wake");
}
public nextSceneToCustomizeScene(): void {
@ -119,11 +118,11 @@ export class SelectCharacterScene extends AbstractCharacterScene {
}
createCurrentPlayer(): void {
for (let i = 0; i <this.playerModels.length; i++) {
for (let i = 0; i < this.playerModels.length; i++) {
const playerResource = this.playerModels[i];
//check already exist texture
if(this.players.find((c) => c.texture.key === playerResource.name)){
if (this.players.find((c) => c.texture.key === playerResource.name)) {
continue;
}
@ -132,9 +131,9 @@ export class SelectCharacterScene extends AbstractCharacterScene {
this.setUpPlayer(player, i);
this.anims.create({
key: playerResource.name,
frames: this.anims.generateFrameNumbers(playerResource.name, {start: 0, end: 11}),
frames: this.anims.generateFrameNumbers(playerResource.name, { start: 0, end: 11 }),
frameRate: 8,
repeat: -1
repeat: -1,
});
player.setInteractive().on("pointerdown", () => {
if (this.pointerClicked) {
@ -153,77 +152,79 @@ export class SelectCharacterScene extends AbstractCharacterScene {
});
this.players.push(player);
}
if (this.currentSelectUser >= this.players.length) {
this.currentSelectUser = 0;
}
this.selectedPlayer = this.players[this.currentSelectUser];
this.selectedPlayer.play(this.playerModels[this.currentSelectUser].name);
}
protected moveUser(){
for(let i = 0; i < this.players.length; i++){
protected moveUser() {
for (let i = 0; i < this.players.length; i++) {
const player = this.players[i];
this.setUpPlayer(player, i);
}
this.updateSelectedPlayer();
}
public moveToLeft(){
if(this.currentSelectUser === 0){
public moveToLeft() {
if (this.currentSelectUser === 0) {
return;
}
this.currentSelectUser -= 1;
this.moveUser();
}
public moveToRight(){
if(this.currentSelectUser === (this.players.length - 1)){
public moveToRight() {
if (this.currentSelectUser === this.players.length - 1) {
return;
}
this.currentSelectUser += 1;
this.moveUser();
}
protected moveToUp(){
if(this.currentSelectUser < this.nbCharactersPerRow){
protected moveToUp() {
if (this.currentSelectUser < this.nbCharactersPerRow) {
return;
}
this.currentSelectUser -= this.nbCharactersPerRow;
this.moveUser();
}
protected moveToDown(){
if((this.currentSelectUser + this.nbCharactersPerRow) > (this.players.length - 1)){
protected moveToDown() {
if (this.currentSelectUser + this.nbCharactersPerRow > this.players.length - 1) {
return;
}
this.currentSelectUser += this.nbCharactersPerRow;
this.moveUser();
}
protected defineSetupPlayer(num: number){
protected defineSetupPlayer(num: number) {
const deltaX = 32;
const deltaY = 32;
let [playerX, playerY] = this.getCharacterPosition(); // player X and player y are middle of the
playerX = ( (playerX - (deltaX * 2.5)) + ((deltaX) * (num % this.nbCharactersPerRow)) ); // calcul position on line users
playerY = ( (playerY - (deltaY * 2)) + ((deltaY) * ( Math.floor(num / this.nbCharactersPerRow) )) ); // calcul position on column users
playerX = playerX - deltaX * 2.5 + deltaX * (num % this.nbCharactersPerRow); // calcul position on line users
playerY = playerY - deltaY * 2 + deltaY * Math.floor(num / this.nbCharactersPerRow); // calcul position on column users
const playerVisible = true;
const playerScale = 1;
const playerOpacity = 1;
// if selected
if( num === this.currentSelectUser ){
if (num === this.currentSelectUser) {
this.selectedRectangle.setX(playerX);
this.selectedRectangle.setY(playerY);
}
return {playerX, playerY, playerScale, playerOpacity, playerVisible}
return { playerX, playerY, playerScale, playerOpacity, playerVisible };
}
protected setUpPlayer(player: Phaser.Physics.Arcade.Sprite, num: number){
const {playerX, playerY, playerScale, playerOpacity, playerVisible} = this.defineSetupPlayer(num);
protected setUpPlayer(player: Phaser.Physics.Arcade.Sprite, num: number) {
const { playerX, playerY, playerScale, playerOpacity, playerVisible } = this.defineSetupPlayer(num);
player.setBounce(0.2);
player.setCollideWorldBounds(false);
player.setVisible( playerVisible );
player.setVisible(playerVisible);
player.setScale(playerScale, playerScale);
player.setAlpha(playerOpacity);
player.setX(playerX);
@ -234,10 +235,7 @@ export class SelectCharacterScene extends AbstractCharacterScene {
* Returns pixel position by on column and row number
*/
protected getCharacterPosition(): [number, number] {
return [
this.game.renderer.width / 2,
this.game.renderer.height / 2.5
];
return [this.game.renderer.width / 2, this.game.renderer.height / 2.5];
}
protected updateSelectedPlayer(): void {
@ -256,7 +254,7 @@ export class SelectCharacterScene extends AbstractCharacterScene {
this.pointerClicked = false;
}
if(this.lazyloadingAttempt){
if (this.lazyloadingAttempt) {
//re-render players list
this.createCurrentPlayer();
this.moveUser();

View file

@ -20,6 +20,7 @@ import { consoleGlobalMessageManagerVisibleStore } from "../../Stores/ConsoleGlo
import { get } from "svelte/store";
import { playersStore } from "../../Stores/PlayersStore";
import { mediaManager } from "../../WebRtc/MediaManager";
import { chatVisibilityStore } from "../../Stores/ChatStore";
export const MenuSceneName = "MenuScene";
const gameMenuKey = "gameMenu";
@ -147,6 +148,9 @@ export class MenuScene extends Phaser.Scene {
this.menuElement.on("click", this.onMenuClick.bind(this));
worldFullWarningStream.stream.subscribe(() => this.showWorldCapacityWarning());
chatVisibilityStore.subscribe((v) => {
this.menuButton.setVisible(!v);
});
}
//todo put this method in a parent menuElement class

View file

@ -96,6 +96,7 @@ function createChatMessagesStore() {
}
return list;
});
chatVisibilityStore.set(true);
},
};
}

View file

@ -3,6 +3,8 @@ import type { PlayerInterface } from "../Phaser/Game/PlayerInterface";
import type { RoomConnection } from "../Connexion/RoomConnection";
import { getRandomColor } from "../WebRtc/ColorGenerator";
let idCount = 0;
/**
* A store that contains the list of players currently known.
*/
@ -40,6 +42,27 @@ function createPlayersStore() {
getPlayerById(userId: number): PlayerInterface | undefined {
return players.get(userId);
},
addFacticePlayer(name: string): number {
let userId: number | null = null;
players.forEach((p) => {
if (p.name === name) userId = p.userId;
});
if (userId) return userId;
const newUserId = idCount--;
update((users) => {
users.set(newUserId, {
userId: newUserId,
name,
characterLayers: [],
visitCardUrl: null,
companion: null,
userUuid: "dummy",
color: getRandomColor(),
});
return users;
});
return newUserId;
},
};
}

View file

@ -1,11 +1,12 @@
import { iframeListener } from "../Api/IframeListener";
import { chatMessagesStore, chatVisibilityStore } from "../Stores/ChatStore";
import { chatMessagesStore } from "../Stores/ChatStore";
import { playersStore } from "../Stores/PlayersStore";
export class DiscussionManager {
constructor() {
iframeListener.chatStream.subscribe((chatEvent) => {
chatMessagesStore.addExternalMessage(parseInt(chatEvent.author), chatEvent.message);
chatVisibilityStore.set(true);
const userId = playersStore.addFacticePlayer(chatEvent.author);
chatMessagesStore.addExternalMessage(userId, chatEvent.message);
});
}
}

View file

@ -170,7 +170,6 @@ export class VideoPeer extends Peer {
} else if (message.type === MESSAGE_TYPE_MESSAGE) {
if (!blackListManager.isBlackListed(this.userUuid)) {
chatMessagesStore.addExternalMessage(this.userId, message.message);
chatVisibilityStore.set(true);
}
} else if (message.type === MESSAGE_TYPE_BLOCKED) {
//FIXME when A blacklists B, the output stream from A is muted in B's js client. This is insecure since B can manipulate the code to unmute A stream.

View file

@ -1,58 +0,0 @@
import "jasmine";
import { Room } from "../../../src/Connexion/Room";
describe("Room getIdFromIdentifier()", () => {
it("should work with an absolute room id and no hash as parameter", () => {
const { roomId, hash } = Room.getIdFromIdentifier('/_/global/maps.workadventu.re/test2.json', '', '');
expect(roomId).toEqual('_/global/maps.workadventu.re/test2.json');
expect(hash).toEqual(null);
});
it("should work with an absolute room id and a hash as parameters", () => {
const { roomId, hash } = Room.getIdFromIdentifier('/_/global/maps.workadventu.re/test2.json#start', '', '');
expect(roomId).toEqual('_/global/maps.workadventu.re/test2.json');
expect(hash).toEqual("start");
});
it("should work with an absolute room id, regardless of baseUrl or instance", () => {
const { roomId, hash } = Room.getIdFromIdentifier('/_/global/maps.workadventu.re/test2.json', 'https://another.domain/_/global/test.json', 'lol');
expect(roomId).toEqual('_/global/maps.workadventu.re/test2.json');
expect(hash).toEqual(null);
});
it("should work with a relative file link and no hash as parameters", () => {
const { roomId, hash } = Room.getIdFromIdentifier('./test2.json', 'https://maps.workadventu.re/test.json', 'global');
expect(roomId).toEqual('_/global/maps.workadventu.re/test2.json');
expect(hash).toEqual(null);
});
it("should work with a relative file link with no dot", () => {
const { roomId, hash } = Room.getIdFromIdentifier('test2.json', 'https://maps.workadventu.re/test.json', 'global');
expect(roomId).toEqual('_/global/maps.workadventu.re/test2.json');
expect(hash).toEqual(null);
});
it("should work with a relative file link two levels deep", () => {
const { roomId, hash } = Room.getIdFromIdentifier('../floor1/Floor1.json', 'https://maps.workadventu.re/floor0/Floor0.json', 'global');
expect(roomId).toEqual('_/global/maps.workadventu.re/floor1/Floor1.json');
expect(hash).toEqual(null);
});
it("should work with a relative file link that rewrite the map domain", () => {
const { roomId, hash } = Room.getIdFromIdentifier('../../maps.workadventure.localhost/Floor1/floor1.json', 'https://maps.workadventu.re/floor0/Floor0.json', 'global');
expect(roomId).toEqual('_/global/maps.workadventure.localhost/Floor1/floor1.json');
expect(hash).toEqual(null);
});
it("should work with a relative file link that rewrite the map instance", () => {
const { roomId, hash } = Room.getIdFromIdentifier('../../../notglobal/maps.workadventu.re/Floor1/floor1.json', 'https://maps.workadventu.re/floor0/Floor0.json', 'global');
expect(roomId).toEqual('_/notglobal/maps.workadventu.re/Floor1/floor1.json');
expect(hash).toEqual(null);
});
it("should work with a relative file link that change the map type", () => {
const { roomId, hash } = Room.getIdFromIdentifier('../../../../@/tcm/is/great', 'https://maps.workadventu.re/floor0/Floor0.json', 'global');
expect(roomId).toEqual('@/tcm/is/great');
expect(hash).toEqual(null);
});
it("should work with a relative file link and a hash as parameters", () => {
const { roomId, hash } = Room.getIdFromIdentifier('./test2.json#start', 'https://maps.workadventu.re/test.json', 'global');
expect(roomId).toEqual('_/global/maps.workadventu.re/test2.json');
expect(hash).toEqual("start");
});
});

View file

@ -1,11 +1,4 @@
{ "compressionlevel":-1,
"editorsettings":
{
"export":
{
"target":"."
}
},
"height":26,
"infinite":false,
"layers":[
@ -101,7 +94,7 @@
"opacity":1,
"properties":[
{
"name":"exitSceneUrl",
"name":"exitUrl",
"type":"string",
"value":"\/@\/tcm\/workadventure\/floor2#down-the-stairs"
}],
@ -119,7 +112,7 @@
"opacity":1,
"properties":[
{
"name":"exitSceneUrl",
"name":"exitUrl",
"type":"string",
"value":"\/@\/tcm\/workadventure\/floor2#down-the-stairs-secours"
}],
@ -264,7 +257,7 @@
"nextobjectid":1,
"orientation":"orthogonal",
"renderorder":"right-down",
"tiledversion":"1.3.3",
"tiledversion":"2021.03.23",
"tileheight":32,
"tilesets":[
{
@ -1959,6 +1952,6 @@
}],
"tilewidth":32,
"type":"map",
"version":1.2,
"version":1.5,
"width":46
}

View file

@ -8,7 +8,7 @@ GNU GPL 3.0:
- http://www.gnu.org/licenses/gpl-3.0.html
- See the file: gpl-3.0.txt
Assets from: workadventure@thecodingmachine.com
Assets from: hello@workadventu.re
BASE assets:
------------

View file

@ -8,7 +8,7 @@ GNU GPL 3.0:
- http://www.gnu.org/licenses/gpl-3.0.html
- See the file: gpl-3.0.txt
Assets from: workadventure@thecodingmachine.com
Assets from: hello@workadventu.re
BASE assets:
------------

View file

@ -188,7 +188,7 @@
</tr>
<tr>
<td>
<input type="radio" name="test-cowebsite-allowAPI"> Success <input type="radio" name="test-cowebsite-allowAPI"> Failure <input type="radio" name="test-cowebsite-allowAPI" checked> Pending
<input type="radio" name="test-cowebsite-allowAPI2"> Success <input type="radio" name="test-cowebsite-allowAPI2"> Failure <input type="radio" name="test-cowebsite-allowAPI2" checked> Pending
</td>
<td>
<a href="#" class="testLink" data-testmap="Metadata/cowebsiteAllowApi.json" target="_blank">Test cowebsite opened by script is allowed to use IFrame API</a>

View file

@ -39,9 +39,7 @@ export class AuthenticateController extends BaseController {
if (typeof organizationMemberToken != "string") throw new Error("No organization token");
const data = await adminApi.fetchMemberDataByToken(organizationMemberToken);
const userUuid = data.userUuid;
const organizationSlug = data.organizationSlug;
const worldSlug = data.worldSlug;
const roomSlug = data.roomSlug;
const roomUrl = data.roomUrl;
const mapUrlStart = data.mapUrlStart;
const textures = data.textures;
@ -52,9 +50,7 @@ export class AuthenticateController extends BaseController {
JSON.stringify({
authToken,
userUuid,
organizationSlug,
worldSlug,
roomSlug,
roomUrl,
mapUrlStart,
organizationMemberToken,
textures,

View file

@ -22,13 +22,14 @@ import { UserMovesMessage } from "../Messages/generated/messages_pb";
import { TemplatedApp } from "uWebSockets.js";
import { parse } from "query-string";
import { jwtTokenManager } from "../Services/JWTTokenManager";
import { adminApi, CharacterTexture, FetchMemberDataByUuidResponse } from "../Services/AdminApi";
import { adminApi, FetchMemberDataByUuidResponse } from "../Services/AdminApi";
import { SocketManager, socketManager } from "../Services/SocketManager";
import { emitInBatch } from "../Services/IoSocketHelpers";
import { ADMIN_API_TOKEN, ADMIN_API_URL, SOCKET_IDLE_TIMER } from "../Enum/EnvironmentVariable";
import { Zone } from "_Model/Zone";
import { ExAdminSocketInterface } from "_Model/Websocket/ExAdminSocketInterface";
import { v4 } from "uuid";
import { CharacterTexture } from "../Services/AdminApi/CharacterTexture";
export class IoSocketController {
private nextUserId: number = 1;
@ -221,14 +222,12 @@ export class IoSocketController {
memberVisitCardUrl = userData.visitCardUrl;
memberTextures = userData.textures;
if (
!room.public &&
room.policyType === GameRoomPolicyTypes.USE_TAGS_POLICY &&
(userData.anonymous === true || !room.canAccess(memberTags))
) {
throw new Error("Insufficient privileges to access this room");
}
if (
!room.public &&
room.policyType === GameRoomPolicyTypes.MEMBERS_ONLY_POLICY &&
userData.anonymous === true
) {

View file

@ -2,6 +2,9 @@ import { HttpRequest, HttpResponse, TemplatedApp } from "uWebSockets.js";
import { BaseController } from "./BaseController";
import { parse } from "query-string";
import { adminApi } from "../Services/AdminApi";
import { ADMIN_API_URL } from "../Enum/EnvironmentVariable";
import { GameRoomPolicyTypes } from "../Model/PusherRoom";
import { MapDetailsData } from "../Services/AdminApi/MapDetailsData";
export class MapController extends BaseController {
constructor(private App: TemplatedApp) {
@ -25,35 +28,46 @@ export class MapController extends BaseController {
const query = parse(req.getQuery());
if (typeof query.organizationSlug !== "string") {
console.error("Expected organizationSlug parameter");
if (typeof query.playUri !== "string") {
console.error("Expected playUri parameter in /map endpoint");
res.writeStatus("400 Bad request");
this.addCorsHeaders(res);
res.end("Expected organizationSlug parameter");
res.end("Expected playUri parameter");
return;
}
if (typeof query.worldSlug !== "string") {
console.error("Expected worldSlug parameter");
res.writeStatus("400 Bad request");
// If no admin URL is set, let's react on '/_/[instance]/[map url]' URLs
if (!ADMIN_API_URL) {
const roomUrl = new URL(query.playUri);
const match = /\/_\/[^/]+\/(.+)/.exec(roomUrl.pathname);
if (!match) {
res.writeStatus("404 Not Found");
this.addCorsHeaders(res);
res.end(JSON.stringify({}));
return;
}
const mapUrl = roomUrl.protocol + "//" + match[1];
res.writeStatus("200 OK");
this.addCorsHeaders(res);
res.end("Expected worldSlug parameter");
return;
}
if (typeof query.roomSlug !== "string" && query.roomSlug !== undefined) {
console.error("Expected only one roomSlug parameter");
res.writeStatus("400 Bad request");
this.addCorsHeaders(res);
res.end("Expected only one roomSlug parameter");
res.end(
JSON.stringify({
mapUrl,
policy_type: GameRoomPolicyTypes.ANONYMOUS_POLICY,
roomSlug: "", // Deprecated
tags: [],
textures: [],
} as MapDetailsData)
);
return;
}
(async () => {
try {
const mapDetails = await adminApi.fetchMapDetails(
query.organizationSlug as string,
query.worldSlug as string,
query.roomSlug as string | undefined
);
const mapDetails = await adminApi.fetchMapDetails(query.playUri as string);
res.writeStatus("200 OK");
this.addCorsHeaders(res);

View file

@ -1,42 +1,27 @@
import { ExSocketInterface } from "_Model/Websocket/ExSocketInterface";
import { PositionDispatcher } from "./PositionDispatcher";
import { ViewportInterface } from "_Model/Websocket/ViewportMessage";
import { extractDataFromPrivateRoomId, extractRoomSlugPublicRoomId, isRoomAnonymous } from "./RoomIdentifier";
import { arrayIntersect } from "../Services/ArrayHelper";
import { ZoneEventListener } from "_Model/Zone";
export enum GameRoomPolicyTypes {
ANONYMUS_POLICY = 1,
ANONYMOUS_POLICY = 1,
MEMBERS_ONLY_POLICY,
USE_TAGS_POLICY,
}
export class PusherRoom {
private readonly positionNotifier: PositionDispatcher;
public readonly public: boolean;
public tags: string[];
public policyType: GameRoomPolicyTypes;
public readonly roomSlug: string;
public readonly worldSlug: string = "";
public readonly organizationSlug: string = "";
private versionNumber: number = 1;
constructor(public readonly roomId: string, private socketListener: ZoneEventListener) {
this.public = isRoomAnonymous(roomId);
constructor(public readonly roomUrl: string, private socketListener: ZoneEventListener) {
this.tags = [];
this.policyType = GameRoomPolicyTypes.ANONYMUS_POLICY;
if (this.public) {
this.roomSlug = extractRoomSlugPublicRoomId(this.roomId);
} else {
const { organizationSlug, worldSlug, roomSlug } = extractDataFromPrivateRoomId(this.roomId);
this.roomSlug = roomSlug;
this.organizationSlug = organizationSlug;
this.worldSlug = worldSlug;
}
this.policyType = GameRoomPolicyTypes.ANONYMOUS_POLICY;
// A zone is 10 sprites wide.
this.positionNotifier = new PositionDispatcher(this.roomId, 320, 320, this.socketListener);
this.positionNotifier = new PositionDispatcher(this.roomUrl, 320, 320, this.socketListener);
}
public setViewport(socket: ExSocketInterface, viewport: ViewportInterface): void {

View file

@ -1,30 +0,0 @@
//helper functions to parse room IDs
export const isRoomAnonymous = (roomID: string): boolean => {
if (roomID.startsWith("_/")) {
return true;
} else if (roomID.startsWith("@/")) {
return false;
} else {
throw new Error("Incorrect room ID: " + roomID);
}
};
export const extractRoomSlugPublicRoomId = (roomId: string): string => {
const idParts = roomId.split("/");
if (idParts.length < 3) throw new Error("Incorrect roomId: " + roomId);
return idParts.slice(2).join("/");
};
export interface extractDataFromPrivateRoomIdResponse {
organizationSlug: string;
worldSlug: string;
roomSlug: string;
}
export const extractDataFromPrivateRoomId = (roomId: string): extractDataFromPrivateRoomIdResponse => {
const idParts = roomId.split("/");
if (idParts.length < 4) throw new Error("Incorrect roomId: " + roomId);
const organizationSlug = idParts[1];
const worldSlug = idParts[2];
const roomSlug = idParts[3];
return { organizationSlug, worldSlug, roomSlug };
};

View file

@ -10,7 +10,6 @@ import {
SubMessage,
} from "../../Messages/generated/messages_pb";
import { WebSocket } from "uWebSockets.js";
import { CharacterTexture } from "../../Services/AdminApi";
import { ClientDuplexStream } from "grpc";
import { Zone } from "_Model/Zone";

View file

@ -9,9 +9,9 @@ import {
SubMessage,
} from "../../Messages/generated/messages_pb";
import { WebSocket } from "uWebSockets.js";
import { CharacterTexture } from "../../Services/AdminApi";
import { ClientDuplexStream } from "grpc";
import { Zone } from "_Model/Zone";
import { CharacterTexture } from "../../Services/AdminApi/CharacterTexture";
export type BackConnection = ClientDuplexStream<PusherToBackMessage, ServerToClientMessage>;

View file

@ -1,11 +1,12 @@
import { ADMIN_API_TOKEN, ADMIN_API_URL } from "../Enum/EnvironmentVariable";
import Axios from "axios";
import { GameRoomPolicyTypes } from "_Model/PusherRoom";
import { CharacterTexture } from "./AdminApi/CharacterTexture";
import { MapDetailsData } from "./AdminApi/MapDetailsData";
import { RoomRedirect } from "./AdminApi/RoomRedirect";
export interface AdminApiData {
organizationSlug: string;
worldSlug: string;
roomSlug: string;
roomUrl: string;
mapUrlStart: string;
tags: string[];
policy_type: number;
@ -14,25 +15,11 @@ export interface AdminApiData {
textures: CharacterTexture[];
}
export interface MapDetailsData {
roomSlug: string;
mapUrl: string;
policy_type: GameRoomPolicyTypes;
tags: string[];
}
export interface AdminBannedData {
is_banned: boolean;
message: string;
}
export interface CharacterTexture {
id: number;
level: number;
url: string;
rights: string;
}
export interface FetchMemberDataByUuidResponse {
uuid: string;
tags: string[];
@ -43,24 +30,15 @@ export interface FetchMemberDataByUuidResponse {
}
class AdminApi {
async fetchMapDetails(
organizationSlug: string,
worldSlug: string,
roomSlug: string | undefined
): Promise<MapDetailsData> {
async fetchMapDetails(playUri: string): Promise<MapDetailsData | RoomRedirect> {
if (!ADMIN_API_URL) {
return Promise.reject(new Error("No admin backoffice set!"));
}
const params: { organizationSlug: string; worldSlug: string; roomSlug?: string } = {
organizationSlug,
worldSlug,
const params: { playUri: string } = {
playUri,
};
if (roomSlug) {
params.roomSlug = roomSlug;
}
const res = await Axios.get(ADMIN_API_URL + "/api/map", {
headers: { Authorization: `${ADMIN_API_TOKEN}` },
params,
@ -121,26 +99,20 @@ class AdminApi {
);
}
async verifyBanUser(
organizationMemberToken: string,
ipAddress: string,
organization: string,
world: string
): Promise<AdminBannedData> {
async verifyBanUser(userUuid: string, ipAddress: string, roomUrl: string): Promise<AdminBannedData> {
if (!ADMIN_API_URL) {
return Promise.reject(new Error("No admin backoffice set!"));
}
//todo: this call can fail if the corresponding world is not activated or if the token is invalid. Handle that case.
return Axios.get(
ADMIN_API_URL +
"/api/check-moderate-user/" +
organization +
"/" +
world +
"/api/ban" +
"?ipAddress=" +
ipAddress +
encodeURIComponent(ipAddress) +
"&token=" +
organizationMemberToken,
encodeURIComponent(userUuid) +
"&roomUrl=" +
encodeURIComponent(roomUrl),
{ headers: { Authorization: `${ADMIN_API_TOKEN}` } }
).then((data) => {
return data.data;

View file

@ -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<typeof isCharacterTexture>;

View file

@ -0,0 +1,20 @@
import * as tg from "generic-type-guard";
import { GameRoomPolicyTypes } from "_Model/PusherRoom";
import { isCharacterTexture } from "./CharacterTexture";
import { isAny, isNumber } from "generic-type-guard";
/*const isNumericEnum =
<T extends { [n: number]: string }>(vs: T) =>
(v: any): v is T =>
typeof v === "number" && v in vs;*/
export const isMapDetailsData = new tg.IsInterface()
.withProperties({
roomSlug: tg.isOptional(tg.isString), // deprecated
mapUrl: tg.isString,
policy_type: isNumber, //isNumericEnum(GameRoomPolicyTypes),
tags: tg.isArray(tg.isString),
textures: tg.isArray(isCharacterTexture),
})
.get();
export type MapDetailsData = tg.GuardedType<typeof isMapDetailsData>;

View file

@ -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<typeof isRoomRedirect>;

View file

@ -9,7 +9,7 @@ class JWTTokenManager {
return Jwt.sign({ userUuid: userUuid }, SECRET_KEY, { expiresIn: "200d" }); //todo: add a mechanic to refresh or recreate token
}
public async getUserUuidFromToken(token: unknown, ipAddress?: string, room?: string): Promise<string> {
public async getUserUuidFromToken(token: unknown, ipAddress?: string, roomUrl?: string): Promise<string> {
if (!token) {
throw new Error("An authentication error happened, a user tried to connect without a token.");
}
@ -50,8 +50,8 @@ class JWTTokenManager {
if (ADMIN_API_URL) {
//verify user in admin
let promise = new Promise((resolve) => resolve());
if (ipAddress && room) {
promise = this.verifyBanUser(tokenInterface.userUuid, ipAddress, room);
if (ipAddress && roomUrl) {
promise = this.verifyBanUser(tokenInterface.userUuid, ipAddress, roomUrl);
}
promise
.then(() => {
@ -79,19 +79,9 @@ class JWTTokenManager {
});
}
private verifyBanUser(userUuid: string, ipAddress: string, room: string): Promise<AdminBannedData> {
const parts = room.split("/");
if (parts.length < 3 || parts[0] !== "@") {
return Promise.resolve({
is_banned: false,
message: "",
});
}
const organization = parts[1];
const world = parts[2];
private verifyBanUser(userUuid: string, ipAddress: string, roomUrl: string): Promise<AdminBannedData> {
return adminApi
.verifyBanUser(userUuid, ipAddress, organization, world)
.verifyBanUser(userUuid, ipAddress, roomUrl)
.then((data: AdminBannedData) => {
if (data && data.is_banned) {
throw new Error("User was banned");

View file

@ -32,8 +32,8 @@ import {
EmotePromptMessage,
} from "../Messages/generated/messages_pb";
import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils";
import { JITSI_ISS, SECRET_JITSI_KEY } from "../Enum/EnvironmentVariable";
import { adminApi, CharacterTexture } from "./AdminApi";
import { ADMIN_API_URL, JITSI_ISS, SECRET_JITSI_KEY } from "../Enum/EnvironmentVariable";
import { adminApi } from "./AdminApi";
import { emitInBatch } from "./IoSocketHelpers";
import Jwt from "jsonwebtoken";
import { JITSI_URL } from "../Enum/EnvironmentVariable";
@ -44,6 +44,8 @@ import { GroupDescriptor, UserDescriptor, ZoneEventListener } from "_Model/Zone"
import Debug from "debug";
import { ExAdminSocketInterface } from "_Model/Websocket/ExAdminSocketInterface";
import { WebSocket } from "uWebSockets.js";
import { isRoomRedirect } from "./AdminApi/RoomRedirect";
import { CharacterTexture } from "./AdminApi/CharacterTexture";
const debug = Debug("socket");
@ -358,23 +360,30 @@ export class SocketManager implements ZoneEventListener {
}
}
async getOrCreateRoom(roomId: string): Promise<PusherRoom> {
async getOrCreateRoom(roomUrl: string): Promise<PusherRoom> {
//check and create new world for a room
let world = this.rooms.get(roomId);
if (world === undefined) {
world = new PusherRoom(roomId, this);
if (!world.public) {
await this.updateRoomWithAdminData(world);
let room = this.rooms.get(roomUrl);
if (room === undefined) {
room = new PusherRoom(roomUrl, this);
if (ADMIN_API_URL) {
await this.updateRoomWithAdminData(room);
}
this.rooms.set(roomId, world);
this.rooms.set(roomUrl, room);
}
return Promise.resolve(world);
return room;
}
public async updateRoomWithAdminData(world: PusherRoom): Promise<void> {
const data = await adminApi.fetchMapDetails(world.organizationSlug, world.worldSlug, world.roomSlug);
world.tags = data.tags;
world.policyType = Number(data.policy_type);
public async updateRoomWithAdminData(room: PusherRoom): Promise<void> {
const data = await adminApi.fetchMapDetails(room.roomUrl);
if (isRoomRedirect(data)) {
// TODO: if the updated room data is actually a redirect, we need to take everybody on the map
// and redirect everybody to the new location (so we need to close the connection for everybody)
} else {
room.tags = data.tags;
room.policyType = Number(data.policy_type);
}
}
emitPlayGlobalMessage(client: ExSocketInterface, playglobalmessage: PlayGlobalMessage) {

View file

@ -1,19 +0,0 @@
import {extractDataFromPrivateRoomId, extractRoomSlugPublicRoomId, isRoomAnonymous} from "../src/Model/RoomIdentifier";
describe("RoomIdentifier", () => {
it("should flag public id as anonymous", () => {
expect(isRoomAnonymous('_/global/test')).toBe(true);
});
it("should flag public id as not anonymous", () => {
expect(isRoomAnonymous('@/afup/afup2020/1floor')).toBe(false);
});
it("should extract roomSlug from public ID", () => {
expect(extractRoomSlugPublicRoomId('_/global/npeguin/test.json')).toBe('npeguin/test.json');
});
it("should extract correct from private ID", () => {
const {organizationSlug, worldSlug, roomSlug} = extractDataFromPrivateRoomId('@/afup/afup2020/1floor');
expect(organizationSlug).toBe('afup');
expect(worldSlug).toBe('afup2020');
expect(roomSlug).toBe('1floor');
});
})