Merge pull request #142 from thecodingmachine/optimize_event_sent

Limiting the number of messages sent while moving
This commit is contained in:
David Négrier 2020-06-02 14:26:56 +02:00 committed by GitHub
commit f151a37da9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 277 additions and 14 deletions

View File

@ -36,6 +36,10 @@ jobs:
run: yarn run lint run: yarn run lint
working-directory: "front" working-directory: "front"
- name: "Jasmine"
run: yarn test
working-directory: "front"
continuous-integration-back: continuous-integration-back:
name: "Continuous Integration Back" name: "Continuous Integration Back"

5
front/jasmine.json Normal file
View File

@ -0,0 +1,5 @@
{
"spec_dir": "tests",
"spec_files": ["**/*[tT]est.ts"],
"stopSpecOnExpectationFailure": false
}

View File

@ -4,11 +4,14 @@
"main": "index.js", "main": "index.js",
"license": "AGPL", "license": "AGPL",
"devDependencies": { "devDependencies": {
"@types/jasmine": "^3.5.10",
"@typescript-eslint/eslint-plugin": "^2.26.0", "@typescript-eslint/eslint-plugin": "^2.26.0",
"@typescript-eslint/parser": "^2.26.0", "@typescript-eslint/parser": "^2.26.0",
"eslint": "^6.8.0", "eslint": "^6.8.0",
"html-webpack-plugin": "^4.3.0", "html-webpack-plugin": "^4.3.0",
"jasmine": "^3.5.0",
"ts-loader": "^6.2.2", "ts-loader": "^6.2.2",
"ts-node": "^8.10.2",
"typescript": "^3.8.3", "typescript": "^3.8.3",
"webpack": "^4.42.1", "webpack": "^4.42.1",
"webpack-cli": "^3.3.11", "webpack-cli": "^3.3.11",
@ -25,6 +28,7 @@
"scripts": { "scripts": {
"start": "webpack-dev-server --open", "start": "webpack-dev-server --open",
"build": "webpack", "build": "webpack",
"test": "ts-node node_modules/jasmine/bin/jasmine --config=jasmine.json",
"lint": "node_modules/.bin/eslint src/ . --ext .ts", "lint": "node_modules/.bin/eslint src/ . --ext .ts",
"fix": "node_modules/.bin/eslint --fix src/ . --ext .ts" "fix": "node_modules/.bin/eslint --fix src/ . --ext .ts"
} }

View File

@ -1,13 +1,15 @@
const DEBUG_MODE: boolean = process.env.DEBUG_MODE as any === true; const DEBUG_MODE: boolean = process.env.DEBUG_MODE as any === true;
const API_URL = process.env.API_URL || "http://api.workadventure.localhost"; const API_URL = process.env.API_URL || "http://api.workadventure.localhost";
const ROOM = [process.env.ROOM || "THECODINGMACHINE"];
const RESOLUTION = 3; const RESOLUTION = 3;
const ZOOM_LEVEL = 1/*3/4*/; const ZOOM_LEVEL = 1/*3/4*/;
const POSITION_DELAY = 200; // Wait 200ms between sending position events
const MAX_EXTRAPOLATION_TIME = 250; // Extrapolate a maximum of 250ms if no new movement is sent by the player
export { export {
DEBUG_MODE, DEBUG_MODE,
API_URL, API_URL,
RESOLUTION, RESOLUTION,
ZOOM_LEVEL, ZOOM_LEVEL,
ROOM POSITION_DELAY,
MAX_EXTRAPOLATION_TIME
} }

View File

@ -5,7 +5,7 @@ import {
MessageUserPositionInterface, PointInterface, PositionInterface MessageUserPositionInterface, PointInterface, PositionInterface
} from "../../Connection"; } from "../../Connection";
import {CurrentGamerInterface, GamerInterface, hasMovedEventName, Player} from "../Player/Player"; import {CurrentGamerInterface, GamerInterface, hasMovedEventName, Player} from "../Player/Player";
import { DEBUG_MODE, ZOOM_LEVEL} from "../../Enum/EnvironmentVariable"; import { DEBUG_MODE, ZOOM_LEVEL, POSITION_DELAY } from "../../Enum/EnvironmentVariable";
import {ITiledMap, ITiledMapLayer, ITiledTileSet} from "../Map/ITiledMap"; import {ITiledMap, ITiledMapLayer, ITiledTileSet} from "../Map/ITiledMap";
import {PLAYER_RESOURCES} from "../Entity/PlayableCaracter"; import {PLAYER_RESOURCES} from "../Entity/PlayableCaracter";
import Texture = Phaser.Textures.Texture; import Texture = Phaser.Textures.Texture;
@ -13,6 +13,8 @@ import Sprite = Phaser.GameObjects.Sprite;
import CanvasTexture = Phaser.Textures.CanvasTexture; import CanvasTexture = Phaser.Textures.CanvasTexture;
import {AddPlayerInterface} from "./AddPlayerInterface"; import {AddPlayerInterface} from "./AddPlayerInterface";
import {PlayerAnimationNames} from "../Player/Animation"; import {PlayerAnimationNames} from "../Player/Animation";
import {PlayerMovement} from "./PlayerMovement";
import {PlayersPositionInterpolator} from "./PlayersPositionInterpolator";
export enum Textures { export enum Textures {
Player = "male1" Player = "male1"
@ -37,12 +39,22 @@ export class GameScene extends Phaser.Scene {
startY = 32; // 1 case startY = 32; // 1 case
circleTexture: CanvasTexture; circleTexture: CanvasTexture;
initPosition: PositionInterface; initPosition: PositionInterface;
private playersPositionInterpolator = new PlayersPositionInterpolator();
MapKey: string; MapKey: string;
MapUrlFile: string; MapUrlFile: string;
RoomId: string; RoomId: string;
instance: string; instance: string;
currentTick: number;
lastSentTick: number; // The last tick at which a position was sent.
lastMoveEventSent: HasMovedEvent = {
direction: '',
moving: false,
x: -1000,
y: -1000
}
PositionNextScene: Array<any> = new Array<any>(); PositionNextScene: Array<any> = new Array<any>();
static createFromUrl(mapUrlFile: string, instance: string): GameScene { static createFromUrl(mapUrlFile: string, instance: string): GameScene {
@ -323,6 +335,34 @@ export class GameScene extends Phaser.Scene {
} }
pushPlayerPosition(event: HasMovedEvent) { pushPlayerPosition(event: HasMovedEvent) {
if (this.lastMoveEventSent === event) {
return;
}
// If the player is not moving, let's send the info right now.
if (event.moving === false) {
this.doPushPlayerPosition(event);
return;
}
// If the player is moving, and if it changed direction, let's send an event
if (event.direction !== this.lastMoveEventSent.direction) {
this.doPushPlayerPosition(event);
return;
}
// If more than 200ms happened since last event sent
if (this.currentTick - this.lastSentTick >= POSITION_DELAY) {
this.doPushPlayerPosition(event);
return;
}
// Otherwise, do nothing.
}
private doPushPlayerPosition(event: HasMovedEvent): void {
this.lastMoveEventSent = event;
this.lastSentTick = this.currentTick;
this.GameManager.pushPlayerPosition(event); this.GameManager.pushPlayerPosition(event);
} }
@ -342,7 +382,19 @@ export class GameScene extends Phaser.Scene {
* @param delta The delta time in ms since the last frame. This is a smoothed and capped value based on the FPS rate. * @param delta The delta time in ms since the last frame. This is a smoothed and capped value based on the FPS rate.
*/ */
update(time: number, delta: number) : void { update(time: number, delta: number) : void {
this.currentTick = time;
this.CurrentPlayer.moveUser(delta); this.CurrentPlayer.moveUser(delta);
// Let's move all users
let updatedPlayersPositions = this.playersPositionInterpolator.getUpdatedPositions(time);
updatedPlayersPositions.forEach((moveEvent: HasMovedEvent, userId: string) => {
let player : GamerInterface | undefined = this.MapPlayersByKey.get(userId);
if (player === undefined) {
throw new Error('Cannot find player with ID "' + userId +'"');
}
player.updatePosition(moveEvent);
});
let nextSceneKey = this.checkToExit(); let nextSceneKey = this.checkToExit();
if(nextSceneKey){ if(nextSceneKey){
this.scene.start(nextSceneKey.key); this.scene.start(nextSceneKey.key);
@ -386,15 +438,6 @@ export class GameScene extends Phaser.Scene {
}); });
} }
private findPlayerInMap(UserId : string) : GamerInterface | null{
return this.MapPlayersByKey.get(UserId);
/*let player = this.MapPlayers.getChildren().find((player: Player) => UserId === player.userId);
if(!player){
return null;
}
return (player as GamerInterface);*/
}
/** /**
* Create new player * Create new player
*/ */
@ -437,6 +480,7 @@ export class GameScene extends Phaser.Scene {
player.destroy(); player.destroy();
this.MapPlayers.remove(player); this.MapPlayers.remove(player);
this.MapPlayersByKey.delete(userId); this.MapPlayersByKey.delete(userId);
this.playersPositionInterpolator.removePlayer(userId);
} }
updatePlayerPosition(message: MessageUserMovedInterface): void { updatePlayerPosition(message: MessageUserMovedInterface): void {
@ -444,7 +488,11 @@ export class GameScene extends Phaser.Scene {
if (player === undefined) { if (player === undefined) {
throw new Error('Cannot find player with ID "' + message.userId +'"'); throw new Error('Cannot find player with ID "' + message.userId +'"');
} }
player.updatePosition(message.position);
// We do not update the player position directly (because it is sent only every 200ms).
// Instead we use the PlayersPositionInterpolator that will do a smooth animation over the next 200ms.
let playerMovement = new PlayerMovement({ x: player.x, y: player.y }, this.currentTick, message.position, this.currentTick + POSITION_DELAY);
this.playersPositionInterpolator.updatePlayerPosition(player.userId, playerMovement);
} }
shareGroupPosition(groupPositionMessage: GroupCreatedUpdatedMessageInterface) { shareGroupPosition(groupPositionMessage: GroupCreatedUpdatedMessageInterface) {

View File

@ -0,0 +1,36 @@
import {HasMovedEvent} from "./GameManager";
import {MAX_EXTRAPOLATION_TIME} from "../../Enum/EnvironmentVariable";
import {PositionInterface} from "../../Connection";
export class PlayerMovement {
public constructor(private startPosition: PositionInterface, private startTick: number, private endPosition: HasMovedEvent, private endTick: number) {
}
public isOutdated(tick: number): boolean {
//console.log(tick, this.endTick, MAX_EXTRAPOLATION_TIME)
// If the endPosition is NOT moving, no extrapolation needed.
if (this.endPosition.moving === false && tick > this.endTick) {
return true;
}
return tick > this.endTick + MAX_EXTRAPOLATION_TIME;
}
public getPosition(tick: number): HasMovedEvent {
// Special case: end position reached and end position is not moving
if (tick >= this.endTick && this.endPosition.moving === false) {
return this.endPosition;
}
let x = (this.endPosition.x - this.startPosition.x) * ((tick - this.startTick) / (this.endTick - this.startTick)) + this.startPosition.x;
let y = (this.endPosition.y - this.startPosition.y) * ((tick - this.startTick) / (this.endTick - this.startTick)) + this.startPosition.y;
return {
x,
y,
direction: this.endPosition.direction,
moving: true
}
}
}

View File

@ -0,0 +1,31 @@
/**
* This class is in charge of computing the position of all players.
* Player movement is delayed by 200ms so position depends on ticks.
*/
import {PlayerMovement} from "./PlayerMovement";
import {HasMovedEvent} from "./GameManager";
export class PlayersPositionInterpolator {
playerMovements: Map<string, PlayerMovement> = new Map<string, PlayerMovement>();
updatePlayerPosition(userId: string, playerMovement: PlayerMovement) : void {
this.playerMovements.set(userId, playerMovement);
}
removePlayer(userId: string): void {
this.playerMovements.delete(userId);
}
getUpdatedPositions(tick: number) : Map<string, HasMovedEvent> {
let positions = new Map<string, HasMovedEvent>();
this.playerMovements.forEach((playerMovement: PlayerMovement, userId: string) => {
if (playerMovement.isOutdated(tick)) {
//console.log("outdated")
this.playerMovements.delete(userId);
}
//console.log("moving")
positions.set(userId, playerMovement.getPosition(tick))
});
return positions;
}
}

View File

@ -0,0 +1,76 @@
import "jasmine";
import {PlayerMovement} from "../../../src/Phaser/Game/PlayerMovement";
describe("Interpolation / Extrapolation", () => {
it("should interpolate", () => {
let playerMovement = new PlayerMovement({
x: 100, y: 200
}, 42000,
{
x: 200, y: 100, moving: true, direction: "up"
},
42200
);
expect(playerMovement.isOutdated(42100)).toBe(false);
expect(playerMovement.isOutdated(43000)).toBe(true);
expect(playerMovement.getPosition(42100)).toEqual({
x: 150,
y: 150,
direction: 'up',
moving: true
});
expect(playerMovement.getPosition(42200)).toEqual({
x: 200,
y: 100,
direction: 'up',
moving: true
});
expect(playerMovement.getPosition(42300)).toEqual({
x: 250,
y: 50,
direction: 'up',
moving: true
});
});
it("should not extrapolate if we stop", () => {
let playerMovement = new PlayerMovement({
x: 100, y: 200
}, 42000,
{
x: 200, y: 100, moving: false, direction: "up"
},
42200
);
expect(playerMovement.getPosition(42300)).toEqual({
x: 200,
y: 100,
direction: 'up',
moving: false
});
});
it("should should keep moving until it stops", () => {
let playerMovement = new PlayerMovement({
x: 100, y: 200
}, 42000,
{
x: 200, y: 100, moving: false, direction: "up"
},
42200
);
expect(playerMovement.getPosition(42100)).toEqual({
x: 150,
y: 150,
direction: 'up',
moving: true
});
});
})

View File

@ -4,7 +4,7 @@
"sourceMap": true, "sourceMap": true,
"moduleResolution": "node", "moduleResolution": "node",
"noImplicitAny": true, "noImplicitAny": true,
"module": "es6", "module": "CommonJS",
"target": "es5", "target": "es5",
"jsx": "react", "jsx": "react",
"allowJs": true "allowJs": true

View File

@ -62,6 +62,11 @@
resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-5.1.0.tgz#551a4589b6ee2cc9c1dff08056128aec29b94880" resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-5.1.0.tgz#551a4589b6ee2cc9c1dff08056128aec29b94880"
integrity sha512-iYCgjm1dGPRuo12+BStjd1HiVQqhlRhWDOQigNxn023HcjnhsiFz9pc6CzJj4HwDCSQca9bxTL4PxJDbkdm3PA== integrity sha512-iYCgjm1dGPRuo12+BStjd1HiVQqhlRhWDOQigNxn023HcjnhsiFz9pc6CzJj4HwDCSQca9bxTL4PxJDbkdm3PA==
"@types/jasmine@^3.5.10":
version "3.5.10"
resolved "https://registry.yarnpkg.com/@types/jasmine/-/jasmine-3.5.10.tgz#a1a41012012b5da9d4b205ba9eba58f6cce2ab7b"
integrity sha512-3F8qpwBAiVc5+HPJeXJpbrl+XjawGmciN5LgiO7Gv1pl1RHtjoMNqZpqEksaPJW05ViKe8snYInRs6xB25Xdew==
"@types/json-schema@^7.0.3": "@types/json-schema@^7.0.3":
version "7.0.4" version "7.0.4"
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.4.tgz#38fd73ddfd9b55abb1e1b2ed578cb55bd7b7d339" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.4.tgz#38fd73ddfd9b55abb1e1b2ed578cb55bd7b7d339"
@ -403,6 +408,11 @@ aproba@^1.1.1:
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a"
arg@^4.1.0:
version "4.1.3"
resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089"
integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==
argparse@^1.0.7: argparse@^1.0.7:
version "1.0.10" version "1.0.10"
resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911"
@ -1204,6 +1214,11 @@ detect-node@^2.0.4:
version "2.0.4" version "2.0.4"
resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.0.4.tgz#014ee8f8f669c5c58023da64b8179c083a28c46c" resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.0.4.tgz#014ee8f8f669c5c58023da64b8179c083a28c46c"
diff@^4.0.1:
version "4.0.2"
resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
diffie-hellman@^5.0.0: diffie-hellman@^5.0.0:
version "5.0.3" version "5.0.3"
resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875" resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875"
@ -2500,6 +2515,19 @@ isobject@^3.0.0, isobject@^3.0.1:
version "3.0.1" version "3.0.1"
resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df"
jasmine-core@~3.5.0:
version "3.5.0"
resolved "https://registry.yarnpkg.com/jasmine-core/-/jasmine-core-3.5.0.tgz#132c23e645af96d85c8bca13c8758b18429fc1e4"
integrity sha512-nCeAiw37MIMA9w9IXso7bRaLl+c/ef3wnxsoSAlYrzS+Ot0zTG6nU8G/cIfGkqpkjX2wNaIW9RFG0TwIFnG6bA==
jasmine@^3.5.0:
version "3.5.0"
resolved "https://registry.yarnpkg.com/jasmine/-/jasmine-3.5.0.tgz#7101eabfd043a1fc82ac24e0ab6ec56081357f9e"
integrity sha512-DYypSryORqzsGoMazemIHUfMkXM7I7easFaxAvNM3Mr6Xz3Fy36TupTrAOxZWN8MVKEU5xECv22J4tUQf3uBzQ==
dependencies:
glob "^7.1.4"
jasmine-core "~3.5.0"
js-tokens@^4.0.0: js-tokens@^4.0.0:
version "4.0.0" version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
@ -2629,6 +2657,11 @@ make-dir@^2.0.0:
pify "^4.0.1" pify "^4.0.1"
semver "^5.6.0" semver "^5.6.0"
make-error@^1.1.1:
version "1.3.6"
resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2"
integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==
map-age-cleaner@^0.1.1: map-age-cleaner@^0.1.1:
version "0.1.3" version "0.1.3"
resolved "https://registry.yarnpkg.com/map-age-cleaner/-/map-age-cleaner-0.1.3.tgz#7d583a7306434c055fe474b0f45078e6e1b4b92a" resolved "https://registry.yarnpkg.com/map-age-cleaner/-/map-age-cleaner-0.1.3.tgz#7d583a7306434c055fe474b0f45078e6e1b4b92a"
@ -3812,6 +3845,14 @@ source-map-resolve@^0.5.0:
source-map-url "^0.4.0" source-map-url "^0.4.0"
urix "^0.1.0" urix "^0.1.0"
source-map-support@^0.5.17:
version "0.5.19"
resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61"
integrity sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==
dependencies:
buffer-from "^1.0.0"
source-map "^0.6.0"
source-map-support@~0.5.12: source-map-support@~0.5.12:
version "0.5.16" version "0.5.16"
resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.16.tgz#0ae069e7fe3ba7538c64c98515e35339eac5a042" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.16.tgz#0ae069e7fe3ba7538c64c98515e35339eac5a042"
@ -4169,6 +4210,17 @@ ts-loader@^6.2.2:
micromatch "^4.0.0" micromatch "^4.0.0"
semver "^6.0.0" semver "^6.0.0"
ts-node@^8.10.2:
version "8.10.2"
resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-8.10.2.tgz#eee03764633b1234ddd37f8db9ec10b75ec7fb8d"
integrity sha512-ISJJGgkIpDdBhWVu3jufsWpK3Rzo7bdiIXJjQc0ynKxVOVcg2oIrf2H2cejminGrptVc6q6/uynAHNCuWGbpVA==
dependencies:
arg "^4.1.0"
diff "^4.0.1"
make-error "^1.1.1"
source-map-support "^0.5.17"
yn "3.1.1"
tslib@^1.10.0: tslib@^1.10.0:
version "1.13.0" version "1.13.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043"
@ -4605,3 +4657,8 @@ yeast@0.1.2:
version "0.1.2" version "0.1.2"
resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419" resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419"
integrity sha1-AI4G2AlDIMNy28L47XagymyKxBk= integrity sha1-AI4G2AlDIMNy28L47XagymyKxBk=
yn@3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50"
integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==