From 305bc72bcb3495e7461ace4da22c2d561cd60897 Mon Sep 17 00:00:00 2001 From: simoleo89 <11816867+simoleo89@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:00:44 +0200 Subject: [PATCH 1/6] feat(room): a flip state the room can leave, and a way to turn the furni being placed The flip command set the canvas scale to -1 and nothing remembered it, so the room could not flip back and no widget could ask. The canvas now keeps an isFlipped state, negating both axes as the official client does, and suspends offset scrolling while flipped; the engine exposes the state per canvas and can rotate the object preview being placed, so the client can turn a furni with the mouse wheel. --- packages/api/src/octane/room/IRoomEngine.ts | 8 +- .../src/room/renderer/IRoomRenderingCanvas.ts | 8 +- packages/room/src/RoomEngine.ts | 91 ++++++++-- packages/room/src/RoomEngineFlip.test.ts | 166 ++++++++++++++++++ packages/room/src/RoomObjectEventHandler.ts | 31 ++++ ...oomObjectEventHandlerRotatePreview.test.ts | 78 ++++++++ .../room/src/renderer/RoomSpriteCanvas.ts | 93 ++++++++-- .../src/renderer/RoomSpriteCanvasFlip.test.ts | 159 +++++++++++++++++ 8 files changed, 593 insertions(+), 41 deletions(-) create mode 100644 packages/room/src/RoomEngineFlip.test.ts create mode 100644 packages/room/src/RoomObjectEventHandlerRotatePreview.test.ts create mode 100644 packages/room/src/renderer/RoomSpriteCanvasFlip.test.ts diff --git a/packages/api/src/octane/room/IRoomEngine.ts b/packages/api/src/octane/room/IRoomEngine.ts index 750b8ff3..ea8a7298 100644 --- a/packages/api/src/octane/room/IRoomEngine.ts +++ b/packages/api/src/octane/room/IRoomEngine.ts @@ -1,4 +1,4 @@ -import { Container, Point, Rectangle, RenderTexture, Texture } from 'pixi.js'; +import { Container, Point, PointData, Rectangle, RenderTexture, Texture } from 'pixi.js'; import { IRoomGeometry, IRoomManager, IRoomObject, IRoomObjectController, IRoomRenderingCanvas } from '../../room'; import { IVector3D } from '../../utils'; import { IPetCustomPart } from '../session'; @@ -14,12 +14,15 @@ export interface IRoomEngine setActiveRoomId(roomId: number): void; createRoomInstance(roomId: number, roomMap: IRoomMapData): void; getRoomInstanceDisplay(roomId: number, id: number, width: number, height: number, scale: number): Container; - setRoomInstanceRenderingCanvasScale(roomId: number, canvasId: number, level: number, point?: Point, offsetPoint?: Point, isFlipForced?: boolean): void; + // `isFlipForced` toggles the 180 degree room flip instead of zooming; + // `isAnimated` passes a fractional scale through without snapping it. + setRoomInstanceRenderingCanvasScale(roomId: number, canvasId: number, level: number, point?: PointData, offsetPoint?: PointData, isFlipForced?: boolean, isAnimated?: boolean): void; setRoomInstanceRenderingCanvasMask(roomId: number, canvasId: number, flag: boolean): void; getRoomInstanceRenderingCanvas(roomId: number, canvasId?: number): IRoomRenderingCanvas; getRoomInstanceRenderingCanvasOffset(roomId: number, canvasId?: number): Point; setRoomInstanceRenderingCanvasOffset(roomId: number, canvasId: number, point: Point): boolean; getRoomInstanceRenderingCanvasScale(roomId?: number, canvasId?: number): number; + getRoomInstanceRenderingCanvasIsFlipped(roomId?: number, canvasId?: number): boolean; initializeRoomInstanceRenderingCanvas(roomId: number, canvasId: number, width: number, height: number): void; updateRoomInstancePlaneVisibility(roomId: number, wallVisible: boolean, floorVisible?: boolean): boolean; updateRoomInstancePlaneThickness(roomId: number, wallThickness: number, floorThickness: number): boolean; @@ -75,6 +78,7 @@ export interface IRoomEngine changeObjectModelData(roomId: number, objectId: number, category: number, numberKey: string, numberValue: number): boolean; changeObjectState(roomId: number, objectId: number, category: number): void; processRoomObjectOperation(objectId: number, category: number, operation: string): boolean; + rotateActiveObjectPreview(positive: boolean): boolean; modifyRoomObjectDataWithMap(objectId: number, category: number, operation: string, data: Map): boolean modifyRoomObjectData(objectId: number, category: number, colorHex: string, data: string): boolean processRoomObjectPlacement(placementSource: string, id: number, category: number, typeId: number, legacyString?: string, stuffData?: IObjectData, state?: number, frameNumber?: number, posture?: string): boolean; diff --git a/packages/api/src/room/renderer/IRoomRenderingCanvas.ts b/packages/api/src/room/renderer/IRoomRenderingCanvas.ts index 2617757e..380efe2c 100644 --- a/packages/api/src/room/renderer/IRoomRenderingCanvas.ts +++ b/packages/api/src/room/renderer/IRoomRenderingCanvas.ts @@ -1,4 +1,4 @@ -import { Container, Point, Texture } from 'pixi.js'; +import { Container, PointData, Texture } from 'pixi.js'; import { IRoomGeometry } from '../IRoomGeometry'; import { RoomObjectSpriteData } from '../RoomObjectSpriteData'; import { ISortableSprite } from '../object'; @@ -9,7 +9,10 @@ export interface IRoomRenderingCanvas dispose(): void; initialize(width: number, height: number): void; setMask(flag: boolean): void; - setScale(scale: number, point?: Point, offsetPoint?: Point, isFlipForced?: boolean): void; + // `point` is the screen position that must stay under the cursor across the + // transform change; `offsetPoint` is where it should land (defaults to `point`). + setScale(scale: number, point?: PointData, offsetPoint?: PointData, isFlipForced?: boolean): void; + setFlip(flag: boolean, point?: PointData, offsetPoint?: PointData): void; render(time: number, update?: boolean): void; update(): void; setMouseListener(listener: IRoomCanvasMouseListener): void; @@ -30,6 +33,7 @@ export interface IRoomRenderingCanvas screenOffsetX: number; screenOffsetY: number; scale: number; + isFlipped: boolean; width: number; height: number; canvasUpdated: boolean; diff --git a/packages/room/src/RoomEngine.ts b/packages/room/src/RoomEngine.ts index 6fcf6bce..dac3713a 100644 --- a/packages/room/src/RoomEngine.ts +++ b/packages/room/src/RoomEngine.ts @@ -4,7 +4,7 @@ import { GetConfiguration } from '@octane/configuration'; import { BadgeImageReadyEvent, GetEventDispatcher, OctaneToolbarAnimateIconEvent, RoomBackgroundColorEvent, RoomDragEvent, RoomEngineAreaHideStateEvent, RoomEngineEvent, RoomEngineObjectEvent, RoomObjectEvent, RoomObjectFurnitureActionEvent, RoomObjectMouseEvent, RoomSessionEvent, RoomToObjectOwnAvatarMoveEvent } from '@octane/events'; import { GetRoomSessionManager, GetSessionDataManager } from '@octane/session'; import { FurniId, GetTickerTime, OctaneLogger, NumberBank, TextureUtils, Vector3d } from '@octane/utils'; -import { Container, Matrix, Point, Rectangle, RenderTexture, Sprite, Texture, Ticker } from 'pixi.js'; +import { Container, Matrix, Point, PointData, Rectangle, RenderTexture, Sprite, Texture, Ticker } from 'pixi.js'; import { GetRoomContentLoader } from './GetRoomContentLoader'; import { GetRoomManager } from './GetRoomManager'; import { GetRoomMessageHandler } from './GetRoomMessageHandler'; @@ -391,17 +391,22 @@ export class RoomEngine implements IRoomEngine, IRoomCreator, IRoomEngineService if(roomCanvas) roomCanvas.setMask(flag); } - public setRoomInstanceRenderingCanvasScale(roomId: number, canvasId: number, level: number, point: Point = null, offsetPoint: Point = null, isFlipForced: boolean = false, flag: boolean = false): void + public setRoomInstanceRenderingCanvasScale(roomId: number, canvasId: number, level: number, point: PointData = null, offsetPoint: PointData = null, isFlipForced: boolean = false, isAnimated: boolean = false): void { if(!GetConfiguration().getValue('room.zoom.enabled', true)) return; - if(!flag) level = ((isFlipForced) ? -1 : ((level) < 1) ? 0.5 : Math.floor(level)); + // Discrete zoom levels are snapped; an animation frame passes its + // fractional scale through untouched. + if(!isAnimated && !isFlipForced) level = (((level) < 1) ? 0.5 : Math.floor(level)); const roomCanvas = this.getRoomInstanceRenderingCanvas(roomId, canvasId); if(!roomCanvas) return; - roomCanvas.setScale(level, point, offsetPoint, isFlipForced); + // A forced flip toggles the 180 degree flip and leaves the zoom scale alone. + if(isFlipForced) roomCanvas.setFlip(!roomCanvas.isFlipped, point, offsetPoint); + else roomCanvas.setScale(level, point, offsetPoint, false); + this.syncRoomCameraLocationToCanvasOffset(roomId, roomCanvas); GetEventDispatcher().dispatchEvent(new RoomEngineEvent(RoomEngineEvent.ROOM_ZOOMED, roomId)); @@ -419,7 +424,8 @@ export class RoomEngine implements IRoomEngine, IRoomCreator, IRoomEngineService private syncRoomCameraLocationToCanvasOffset(roomId: number, canvas: IRoomRenderingCanvas): void { - if(!this.useOffsetScrolling || !canvas || (canvas.scale <= 0)) return; + // Offset scrolling is suspended while the room is flipped. + if(!this.useOffsetScrolling || !canvas || (canvas.scale <= 0) || canvas.isFlipped) return; const instanceData = this.getRoomInstanceData(roomId); @@ -497,6 +503,25 @@ export class RoomEngine implements IRoomEngine, IRoomCreator, IRoomEngineService return canvas.scale; } + public getRoomInstanceRenderingCanvasIsFlipped(roomId: number = -1000, canvasId: number = -1): boolean + { + if(roomId === -1000) roomId = this._activeRoomId; + + if(canvasId === -1) canvasId = this._activeRoomActiveCanvas; + + const canvas = this.getRoomInstanceRenderingCanvas(roomId, canvasId); + + return !!(canvas && canvas.isFlipped); + } + + // Signed display scale of a canvas: negative on both axes while flipped. + private getCanvasTransformScale(canvas: IRoomRenderingCanvas): number + { + if(!canvas) return 1; + + return (canvas.isFlipped ? -canvas.scale : canvas.scale); + } + public initializeRoomInstanceRenderingCanvas(roomId: number, canvasId: number, width: number, height: number): void { const canvas = this.getRoomInstanceRenderingCanvas(roomId, canvasId); @@ -1097,7 +1122,8 @@ export class RoomEngine implements IRoomEngine, IRoomCreator, IRoomEngineService const renderingCanvas = this.getRoomInstanceRenderingCanvas(roomId, canvasId); const instanceData = this.getRoomInstanceData(roomId); - if(!renderingCanvas || !instanceData || (renderingCanvas.scale !== 1)) return; + // The camera never follows while the room is flipped. + if(!renderingCanvas || !instanceData || (renderingCanvas.scale !== 1) || renderingCanvas.isFlipped) return; const roomGeometry = (renderingCanvas.geometry as RoomGeometry); const roomCamera = instanceData.roomCamera; @@ -1403,7 +1429,7 @@ export class RoomEngine implements IRoomEngine, IRoomCreator, IRoomEngineService const rectangle = visualization.getBoundingRectangle(); const canvas = this.getRoomInstanceRenderingCanvas(roomId, canvasId); - const scale = ((canvas) ? canvas.scale : 1); + const scale = this.getCanvasTransformScale(canvas); const screenPoint = geometry.getScreenPoint(roomObject.getLocation()); if(!screenPoint) return null; @@ -1411,10 +1437,22 @@ export class RoomEngine implements IRoomEngine, IRoomCreator, IRoomEngineService screenPoint.x = Math.round(screenPoint.x); screenPoint.y = Math.round(screenPoint.y); - rectangle.x = (rectangle.x * scale); - rectangle.y = (rectangle.y * scale); - rectangle.width = (rectangle.width * scale); - rectangle.height = (rectangle.height * scale); + if(scale < 0) + { + // A flipped room projects the far corner of the rectangle first, + // so the far edge becomes the near one and the size stays positive. + rectangle.x = ((rectangle.x + rectangle.width) * scale); + rectangle.y = ((rectangle.y + rectangle.height) * scale); + rectangle.width = (rectangle.width * -scale); + rectangle.height = (rectangle.height * -scale); + } + else + { + rectangle.x = (rectangle.x * scale); + rectangle.y = (rectangle.y * scale); + rectangle.width = (rectangle.width * scale); + rectangle.height = (rectangle.height * scale); + } screenPoint.x = (screenPoint.x * scale); screenPoint.y = (screenPoint.y * scale); @@ -1424,8 +1462,11 @@ export class RoomEngine implements IRoomEngine, IRoomCreator, IRoomEngineService if(!canvas) return null; - rectangle.x += (Math.round(canvas.width / 2) + canvas.screenOffsetX); - rectangle.y += (Math.round(canvas.height / 2) + canvas.screenOffsetY); + const canvasWidth = (canvas.isFlipped ? -canvas.width : canvas.width); + const canvasHeight = (canvas.isFlipped ? -canvas.height : canvas.height); + + rectangle.x += (Math.round(canvasWidth / 2) + canvas.screenOffsetX); + rectangle.y += (Math.round(canvasHeight / 2) + canvas.screenOffsetY); return rectangle; } @@ -2506,7 +2547,9 @@ export class RoomEngine implements IRoomEngine, IRoomCreator, IRoomEngineService if(camera) { - if(this.useOffsetScrolling) + // The camera never follows a flipped room, so its + // location is left alone until the flip is undone. + if(this.useOffsetScrolling && !canvas.isFlipped) { if(!camera.isMoving) { @@ -2616,6 +2659,13 @@ export class RoomEngine implements IRoomEngine, IRoomCreator, IRoomEngineService this._roomObjectEventHandler.modifyRoomObject(this._activeRoomId, objectId, category, operation); } + public rotateActiveObjectPreview(positive: boolean): boolean + { + if(!this._roomObjectEventHandler) return false; + + return this._roomObjectEventHandler.rotateActiveObjectPreview(this._activeRoomId, positive); + } + public modifyRoomObjectDataWithMap(objectId: number, category: number, operation: string, data: Map): boolean { if(!this._roomObjectEventHandler) return false; @@ -2678,11 +2728,16 @@ export class RoomEngine implements IRoomEngine, IRoomCreator, IRoomEngineService if(!renderingCanvas) return null; - screenPoint.x = (screenPoint.x * renderingCanvas.scale); - screenPoint.y = (screenPoint.y * renderingCanvas.scale); + // A flipped canvas projects through the negative scale and size. + const scale = this.getCanvasTransformScale(renderingCanvas); + const canvasWidth = (renderingCanvas.isFlipped ? -renderingCanvas.width : renderingCanvas.width); + const canvasHeight = (renderingCanvas.isFlipped ? -renderingCanvas.height : renderingCanvas.height); + + screenPoint.x = (screenPoint.x * scale); + screenPoint.y = (screenPoint.y * scale); - screenPoint.x += ((renderingCanvas.width / 2) + renderingCanvas.screenOffsetX); - screenPoint.y += ((renderingCanvas.height / 2) + renderingCanvas.screenOffsetY); + screenPoint.x += ((canvasWidth / 2) + renderingCanvas.screenOffsetX); + screenPoint.y += ((canvasHeight / 2) + renderingCanvas.screenOffsetY); screenPoint.x = Math.round(screenPoint.x); screenPoint.y = Math.round(screenPoint.y); diff --git a/packages/room/src/RoomEngineFlip.test.ts b/packages/room/src/RoomEngineFlip.test.ts new file mode 100644 index 00000000..56d7dd59 --- /dev/null +++ b/packages/room/src/RoomEngineFlip.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Rectangle } from 'pixi.js'; +import { RoomEngine } from './RoomEngine'; +import { RoomInstanceData } from './utils'; + +vi.mock('./GetRoomMessageHandler', () => ({ + GetRoomMessageHandler: () => ({}) +})); + +vi.mock('./GetRoomEngine', () => ({ + GetRoomEngine: () => null +})); + +const createFakeCanvas = () => +{ + const canvas = { + width: 800, + height: 600, + scale: 1, + isFlipped: false, + screenOffsetX: 0, + screenOffsetY: 0, + setScale: vi.fn((scale: number) => + { + canvas.scale = scale; + }), + setFlip: vi.fn((flag: boolean) => + { + canvas.isFlipped = flag; + }) + }; + + return canvas; +}; + +const createEngine = (roomId: number, canvas: ReturnType) => +{ + const instanceData = new RoomInstanceData(roomId); + const engine = Object.create(RoomEngine.prototype) as RoomEngine; + + Object.assign(engine, { + _activeRoomId: roomId, + _activeRoomActiveCanvas: 1, + _roomInstanceDatas: new Map([[ roomId, instanceData ]]), + getRoomInstanceRenderingCanvas: () => canvas + }); + + return { engine, instanceData }; +}; + +// A room object standing at room-space screen point (100, 40) whose sprite +// bounding box spans (-20, -60) to (30, 0) around that point. +const stubRoomObject = (engine: RoomEngine) => +{ + Object.assign(engine, { + getRoomInstanceGeometry: () => ({ getScreenPoint: () => ({ x: 100, y: 40 }) }), + getRoomObject: () => ({ + getLocation: () => ({ x: 0, y: 0, z: 0 }), + visualization: { getBoundingRectangle: () => new Rectangle(-20, -60, 50, 60) } + }) + }); +}; + +describe('RoomEngine room flip', () => +{ + it('toggles the canvas flip on every forced flip and exposes it through the getter', () => + { + const roomId = 7; + const canvas = createFakeCanvas(); + const { engine } = createEngine(roomId, canvas); + + expect(engine.getRoomInstanceRenderingCanvasIsFlipped(roomId, 1)).toBe(false); + + engine.setRoomInstanceRenderingCanvasScale(roomId, 1, -1, null, null, true); + + expect(canvas.setFlip).toHaveBeenLastCalledWith(true, null, null); + expect(canvas.setScale).not.toHaveBeenCalled(); + expect(engine.getRoomInstanceRenderingCanvasIsFlipped(roomId, 1)).toBe(true); + expect(engine.getRoomInstanceRenderingCanvasIsFlipped()).toBe(true); + + engine.setRoomInstanceRenderingCanvasScale(roomId, 1, -1, null, null, true); + + expect(canvas.setFlip).toHaveBeenLastCalledWith(false, null, null); + expect(engine.getRoomInstanceRenderingCanvasIsFlipped(roomId, 1)).toBe(false); + expect(canvas.scale).toBe(1); + }); + + it('does not sync the camera to the canvas offset while flipped', () => + { + const roomId = 8; + const canvas = createFakeCanvas(); + const { engine, instanceData } = createEngine(roomId, canvas); + + canvas.isFlipped = true; + canvas.screenOffsetX = -600; + canvas.screenOffsetY = -300; + + engine.setRoomInstanceRenderingCanvasScale(roomId, 1, 2); + + expect(canvas.scale).toBe(2); + expect(instanceData.roomCamera.location).toBeNull(); + }); + + it('snaps discrete levels but passes animated scales through untouched', () => + { + const roomId = 9; + const canvas = createFakeCanvas(); + const { engine } = createEngine(roomId, canvas); + + engine.setRoomInstanceRenderingCanvasScale(roomId, 1, 2.7); + + expect(canvas.scale).toBe(2); + + engine.setRoomInstanceRenderingCanvasScale(roomId, 1, 2.7, null, null, false, true); + + expect(canvas.scale).toBe(2.7); + }); + + it('reports an unflipped canvas when there is no room', () => + { + const engine = Object.create(RoomEngine.prototype) as RoomEngine; + + Object.assign(engine, { _activeRoomId: 1, _activeRoomActiveCanvas: 1, getRoomInstanceRenderingCanvas: () => null }); + + expect(engine.getRoomInstanceRenderingCanvasIsFlipped(1, 1)).toBe(false); + }); + + it('projects object screen locations through the flipped transform', () => + { + const roomId = 10; + const canvas = createFakeCanvas(); + const { engine } = createEngine(roomId, canvas); + + stubRoomObject(engine); + + expect(engine.getRoomObjectScreenLocation(roomId, 1, 10, 1)).toMatchObject({ x: 500, y: 340 }); + + // Flipping mirrors the point through the canvas centre on both axes. + canvas.isFlipped = true; + + expect(engine.getRoomObjectScreenLocation(roomId, 1, 10, 1)).toMatchObject({ x: -500, y: -340 }); + + canvas.screenOffsetX = 800; + canvas.screenOffsetY = 600; + + expect(engine.getRoomObjectScreenLocation(roomId, 1, 10, 1)).toMatchObject({ x: 300, y: 260 }); + }); + + it('keeps flipped bounding rectangles positive and reflected', () => + { + const roomId = 11; + const canvas = createFakeCanvas(); + const { engine } = createEngine(roomId, canvas); + + stubRoomObject(engine); + + expect(engine.getRoomObjectBoundingRectangle(roomId, 1, 10, 1)).toMatchObject({ x: 480, y: 280, width: 50, height: 60 }); + + canvas.isFlipped = true; + canvas.screenOffsetX = 800; + canvas.screenOffsetY = 600; + + // The far corner of the box (30, 0) becomes its near corner once flipped. + expect(engine.getRoomObjectBoundingRectangle(roomId, 1, 10, 1)).toMatchObject({ x: 270, y: 260, width: 50, height: 60 }); + }); +}); diff --git a/packages/room/src/RoomObjectEventHandler.ts b/packages/room/src/RoomObjectEventHandler.ts index 8b8025b6..3e202a68 100644 --- a/packages/room/src/RoomObjectEventHandler.ts +++ b/packages/room/src/RoomObjectEventHandler.ts @@ -128,6 +128,37 @@ export class RoomObjectEventHandler implements IRoomCanvasMouseListener, IRoomOb return true; } + // Rotates the floor item currently being moved or placed without talking to + // the server: the preview direction only becomes real once it is dropped. + public rotateActiveObjectPreview(roomId: number, positive: boolean): boolean + { + if(!this._roomEngine) return false; + + const selectedData = this.getSelectedRoomObjectData(roomId); + + if(!selectedData || (selectedData.category !== RoomObjectCategory.FLOOR)) return false; + + if((selectedData.operation !== RoomObjectOperationType.OBJECT_MOVE) && (selectedData.operation !== RoomObjectOperationType.OBJECT_PLACE)) return false; + + const roomObject = this._roomEngine.getRoomObject(roomId, selectedData.id, selectedData.category); + + if(!roomObject) return false; + + const direction = this.getValidRoomObjectDirection(roomObject, positive); + + if(direction === roomObject.getDirection().x) return false; + + const newDirection = new Vector3d(direction); + + if(!this.isValidLocation(roomObject, newDirection, this._roomEngine.getFurnitureStackingHeightMap(roomId))) return false; + + roomObject.setDirection(newDirection); + + this.updateSelectedObjectData(roomId, selectedData.id, selectedData.category, roomObject.getLocation(), newDirection, selectedData.operation, selectedData.typeId, selectedData.instanceData, selectedData.stuffData, selectedData.state, selectedData.animFrame, selectedData.posture); + + return true; + } + private getMouseEventId(category: number, eventType: string): string { const existing = this._eventIds.get(category); diff --git a/packages/room/src/RoomObjectEventHandlerRotatePreview.test.ts b/packages/room/src/RoomObjectEventHandlerRotatePreview.test.ts new file mode 100644 index 00000000..8865146c --- /dev/null +++ b/packages/room/src/RoomObjectEventHandlerRotatePreview.test.ts @@ -0,0 +1,78 @@ +import { RoomObjectCategory, RoomObjectOperationType } from '@octane/api'; +import { describe, expect, it, vi } from 'vitest'; +import { RoomObjectEventHandler } from './RoomObjectEventHandler'; + +vi.mock('./GetRoomEngine', () => ({ + GetRoomEngine: () => null +})); + +const createHandler = (selectedData: Record, options: { currentDirection?: number; nextDirection?: number; validLocation?: boolean } = {}) => +{ + const { currentDirection = 0, nextDirection = 2, validLocation = true } = options; + const direction = { x: currentDirection, y: 0, z: 0 }; + const roomObject = { + getDirection: () => direction, + getLocation: () => ({ x: 3, y: 4, z: 0 }), + setDirection: vi.fn((vector: { x: number }) => + { + direction.x = vector.x; + }) + }; + const handler = Object.create(RoomObjectEventHandler.prototype) as RoomObjectEventHandler; + const updateSelectedObjectData = vi.fn(); + + Object.assign(handler, { + _roomEngine: { + getRoomObject: () => roomObject, + getFurnitureStackingHeightMap: () => ({}) + }, + getSelectedRoomObjectData: () => selectedData, + getValidRoomObjectDirection: () => nextDirection, + isValidLocation: () => validLocation, + updateSelectedObjectData + }); + + return { handler, roomObject, updateSelectedObjectData }; +}; + +const movingFloorItem = { id: 5, category: RoomObjectCategory.FLOOR, operation: RoomObjectOperationType.OBJECT_MOVE, typeId: 9, instanceData: null, stuffData: null, state: -1, animFrame: -1, posture: null }; + +describe('RoomObjectEventHandler rotateActiveObjectPreview', () => +{ + it('rotates the floor item being moved and refreshes the selection without a server round trip', () => + { + const { handler, roomObject, updateSelectedObjectData } = createHandler(movingFloorItem); + + expect(handler.rotateActiveObjectPreview(1, true)).toBe(true); + + expect(roomObject.setDirection).toHaveBeenCalledWith(expect.objectContaining({ x: 2 })); + expect(updateSelectedObjectData).toHaveBeenCalledWith(1, 5, RoomObjectCategory.FLOOR, { x: 3, y: 4, z: 0 }, expect.objectContaining({ x: 2 }), RoomObjectOperationType.OBJECT_MOVE, 9, null, null, -1, -1, null); + }); + + it('also rotates an item being placed from the inventory', () => + { + const { handler } = createHandler({ ...movingFloorItem, operation: RoomObjectOperationType.OBJECT_PLACE }); + + expect(handler.rotateActiveObjectPreview(1, false)).toBe(true); + }); + + it('ignores wall items and objects that are not being moved or placed', () => + { + expect(createHandler({ ...movingFloorItem, category: RoomObjectCategory.WALL }).handler.rotateActiveObjectPreview(1, true)).toBe(false); + expect(createHandler({ ...movingFloorItem, operation: RoomObjectOperationType.OBJECT_ROTATE_POSITIVE }).handler.rotateActiveObjectPreview(1, true)).toBe(false); + expect(createHandler(null).handler.rotateActiveObjectPreview(1, true)).toBe(false); + }); + + it('leaves the preview alone when the next direction is the current one or does not fit', () => + { + const same = createHandler(movingFloorItem, { currentDirection: 2, nextDirection: 2 }); + + expect(same.handler.rotateActiveObjectPreview(1, true)).toBe(false); + expect(same.roomObject.setDirection).not.toHaveBeenCalled(); + + const blocked = createHandler(movingFloorItem, { validLocation: false }); + + expect(blocked.handler.rotateActiveObjectPreview(1, true)).toBe(false); + expect(blocked.roomObject.setDirection).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/room/src/renderer/RoomSpriteCanvas.ts b/packages/room/src/renderer/RoomSpriteCanvas.ts index 0ebf33ad..3802736b 100644 --- a/packages/room/src/renderer/RoomSpriteCanvas.ts +++ b/packages/room/src/renderer/RoomSpriteCanvas.ts @@ -2,7 +2,7 @@ import { IPlaneVisualization, IRoomCanvasMouseListener, IRoomGeometry, IRoomObje import { GetConfiguration } from '@octane/configuration'; import { RoomSpriteMouseEvent } from '@octane/events'; import { GetTicker, TextureUtils, Vector3d } from '@octane/utils'; -import { Container, Graphics, Matrix, Point, Rectangle, Sprite, Texture } from 'pixi.js'; +import { Container, Graphics, Matrix, Point, PointData, Rectangle, Sprite, Texture } from 'pixi.js'; import { RoomEnterEffect, RoomGeometry, RoomRotatingEffect, RoomShakingEffect } from '../utils'; import { RoomObjectCache, RoomObjectCacheItem } from './cache'; import { getObjectAltitudeDepth } from './ObjectAltitudeDepth'; @@ -24,6 +24,7 @@ export class RoomSpriteCanvas implements IRoomRenderingCanvas private _lastBoundaryOffsetY: number = NaN; private _lastBoundaryGeometryId: number = -1; private _lastBoundaryScale: number = NaN; + private _lastBoundaryFlipped: boolean = false; private _sortableSprites: SortableSprite[] = []; private _spriteCount: number = 0; @@ -47,6 +48,7 @@ export class RoomSpriteCanvas implements IRoomRenderingCanvas private _eventCache: Map = new Map(); private _eventId: number = 0; private _scale: number = 1; + private _isFlipped: boolean = false; private _SafeStr_4507: boolean = false; private _rotation: number = 0; @@ -245,20 +247,43 @@ export class RoomSpriteCanvas implements IRoomRenderingCanvas } } - public setScale(scale: number, point: Point = null, offsetPoint: Point = null, isFlipForced: boolean = false): void + public setScale(scale: number, point: PointData = null, offsetPoint: PointData = null, isFlipForced: boolean = false): void + { + // The zoom scale is always positive; the flip is a separate flag so it + // can be toggled back and zooming keeps working while flipped. + this.setTransform(Math.abs(scale), this._isFlipped, point, offsetPoint); + } + + public setFlip(flag: boolean, point: PointData = null, offsetPoint: PointData = null): void + { + this.setTransform(this._scale, flag, point, offsetPoint); + } + + private setTransform(scale: number, isFlipped: boolean, point: PointData, offsetPoint: PointData): void { if(!this._master || !this._display) return; - if(!point) point = new Point((this._width / 2), (this._height / 2)); + if(!point) point = { x: (this._width / 2), y: (this._height / 2) }; if(!offsetPoint) offsetPoint = point; - point = this._display.toLocal(point); + // Keep the room content under `point` fixed: convert it to display space + // with the old transform, then place it back at `offsetPoint` with the new one. + const localX = ((point.x - this._screenOffsetX) / this.displayScale); + const localY = ((point.y - this._screenOffsetY) / this.displayScale); this._scale = scale; + this._isFlipped = isFlipped; - this.screenOffsetX = (offsetPoint.x - (point.x * this._scale)); - this.screenOffsetY = (offsetPoint.y - (point.y * this._scale)); + this.screenOffsetX = (offsetPoint.x - (localX * this.displayScale)); + this.screenOffsetY = (offsetPoint.y - (localY * this.displayScale)); + } + + // Signed display scale on both axes: the official flip turns the room 180 + // degrees around the anchor point, it is not a horizontal mirror. + private get displayScale(): number + { + return (this._isFlipped ? -this._scale : this._scale); } private updateBoundaryMask(): void @@ -269,13 +294,16 @@ export class RoomSpriteCanvas implements IRoomRenderingCanvas const offsetX = this._screenOffsetX; const offsetY = this._screenOffsetY; const scale = this._scale; + const displayScale = this.displayScale; + const flipped = this._isFlipped; - if(geometryId === this._lastBoundaryGeometryId && offsetX === this._lastBoundaryOffsetX && offsetY === this._lastBoundaryOffsetY && scale === this._lastBoundaryScale) return; + if(geometryId === this._lastBoundaryGeometryId && offsetX === this._lastBoundaryOffsetX && offsetY === this._lastBoundaryOffsetY && scale === this._lastBoundaryScale && flipped === this._lastBoundaryFlipped) return; this._lastBoundaryGeometryId = geometryId; this._lastBoundaryOffsetX = offsetX; this._lastBoundaryOffsetY = offsetY; this._lastBoundaryScale = scale; + this._lastBoundaryFlipped = flipped; const pts: { x: number; y: number }[] = []; const w2 = this._width / 2; @@ -312,7 +340,7 @@ export class RoomSpriteCanvas implements IRoomRenderingCanvas if(!sp) continue; - pts.push({ x: (sp.x + w2) * scale + offsetX, y: (sp.y + h2) * scale + offsetY }); + pts.push({ x: (sp.x + w2) * displayScale + offsetX, y: (sp.y + h2) * displayScale + offsetY }); } } @@ -420,9 +448,9 @@ export class RoomSpriteCanvas implements IRoomRenderingCanvas update = true; } - if(this._display.scale.x !== this._scale) + if((this._display.scale.x !== this.displayScale) || (this._display.scale.y !== this.displayScale)) { - this._display.scale.set(this._scale); + this._display.scale.set(this.displayScale); update = true; } @@ -870,10 +898,26 @@ export class RoomSpriteCanvas implements IRoomRenderingCanvas { if(this._noSpriteVisibilityChecking) return true; - x = (((x - this._screenOffsetX) * this._scale) + this._screenOffsetX); - y = (((y - this._screenOffsetY) * this._scale) + this._screenOffsetY); - width = (width * this._scale); - height = (height * this._scale); + const displayScale = this.displayScale; + + x = (((x - this._screenOffsetX) * displayScale) + this._screenOffsetX); + y = (((y - this._screenOffsetY) * displayScale) + this._screenOffsetY); + width = (width * displayScale); + height = (height * displayScale); + + // A flipped room projects the sprite behind its origin, so the span is + // normalised back to a positive size before the bounds test. + if(width < 0) + { + x += width; + width = -width; + } + + if(height < 0) + { + y += height; + height = -height; + } if(((x < this._width) && ((x + width) >= 0)) && ((y < this._height) && ((y + height) >= 0))) { @@ -888,12 +932,16 @@ export class RoomSpriteCanvas implements IRoomRenderingCanvas x = (x - this._screenOffsetX); y = (y - this._screenOffsetY); - this._mouseLocation.x = (x / this._scale); - this._mouseLocation.y = (y / this._scale); + // Screen coordinates map back through the signed scale, so a flipped + // room still hit-tests the sprite that is actually under the cursor. + const displayScale = this.displayScale; + + this._mouseLocation.x = (x / displayScale); + this._mouseLocation.y = (y / displayScale); if((this._mouseCheckCount > 0) && (type == MouseEventType.MOUSE_MOVE)) return this._mouseSpriteWasHit; - this._mouseSpriteWasHit = this.checkMouseHits(Math.trunc(x / this._scale), Math.trunc(y / this._scale), type, altKey, ctrlKey, shiftKey, buttonDown); + this._mouseSpriteWasHit = this.checkMouseHits(Math.trunc(x / displayScale), Math.trunc(y / displayScale), type, altKey, ctrlKey, shiftKey, buttonDown); this._mouseCheckCount++; @@ -1074,10 +1122,12 @@ export class RoomSpriteCanvas implements IRoomRenderingCanvas this._noSpriteVisibilityChecking = true; const currentScale = this._scale; + const currentFlipped = this._isFlipped; const currentOffsetX = this._screenOffsetX; const currentOffsetY = this._screenOffsetY; - this.setScale(1); + // Captures are always taken unflipped at scale 1. + this.setTransform(1, false, null, null); this._screenOffsetX = 0; this._screenOffsetY = 0; @@ -1093,7 +1143,7 @@ export class RoomSpriteCanvas implements IRoomRenderingCanvas this._noSpriteVisibilityChecking = false; - this.setScale(currentScale); + this.setTransform(currentScale, currentFlipped, null, null); this._screenOffsetX = currentOffsetX; this._screenOffsetY = currentOffsetY; @@ -1323,6 +1373,11 @@ export class RoomSpriteCanvas implements IRoomRenderingCanvas return this._scale; } + public get isFlipped(): boolean + { + return this._isFlipped; + } + public get width(): number { return (this._width * this._scale); diff --git a/packages/room/src/renderer/RoomSpriteCanvasFlip.test.ts b/packages/room/src/renderer/RoomSpriteCanvasFlip.test.ts new file mode 100644 index 00000000..59dab5cf --- /dev/null +++ b/packages/room/src/renderer/RoomSpriteCanvasFlip.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it, vi } from 'vitest'; +import { RoomSpriteCanvas } from './RoomSpriteCanvas'; + +vi.mock('@octane/utils', () => ({ + GetTicker: () => ({ deltaTime: 1 }), + TextureUtils: {}, + Vector3d: class + { + constructor(public x: number = 0, public y: number = 0, public z: number = 0) + {} + } +})); + +vi.mock('@octane/configuration', () => ({ + GetConfiguration: () => ({ getValue: (_key: string, fallback: unknown) => fallback }) +})); + +vi.mock('../utils', () => ({ + RoomEnterEffect: { isVisualizationOn: () => false }, + RoomGeometry: class + {}, + RoomRotatingEffect: {}, + RoomShakingEffect: {} +})); + +const createCanvas = () => +{ + const canvas = Object.create(RoomSpriteCanvas.prototype) as RoomSpriteCanvas & Record; + + Object.assign(canvas, { + _master: {}, + _display: { scale: { x: 1, y: 1, set(x: number, y: number = x) { this.x = x; this.y = y; } }, x: 0, y: 0 }, + _width: 800, + _height: 600, + _screenOffsetX: 0, + _screenOffsetY: 0, + _scale: 1, + _isFlipped: false, + _mouseLocation: { x: 0, y: 0 }, + _mouseCheckCount: 0, + _mouseSpriteWasHit: false, + _noSpriteVisibilityChecking: false, + _usesExclusionRectangles: false + }); + + return canvas; +}; + +describe('RoomSpriteCanvas flip', () => +{ + it('starts unflipped and reports the flip state through the getter', () => + { + const canvas = createCanvas(); + + expect(canvas.isFlipped).toBe(false); + + canvas.setFlip(true); + + expect(canvas.isFlipped).toBe(true); + expect(canvas.scale).toBe(1); + }); + + it('turns the room 180 degrees around the canvas centre and toggling back restores the offset', () => + { + const canvas = createCanvas(); + + canvas.setFlip(true); + + // The centre pixel stays put: the display origin moves to the far corner. + expect(canvas.screenOffsetX).toBe(800); + expect(canvas.screenOffsetY).toBe(600); + + canvas.setFlip(false); + + expect(canvas.isFlipped).toBe(false); + expect(canvas.screenOffsetX).toBe(0); + expect(canvas.screenOffsetY).toBe(0); + }); + + it('keeps the content under the anchor point fixed when flipping', () => + { + const canvas = createCanvas(); + + canvas.setFlip(true, { x: 200, y: 100 }); + + // Local (200, 100) must still project to screen (200, 100) through the negative scale. + expect((200 * -1) + canvas.screenOffsetX).toBe(200); + expect((100 * -1) + canvas.screenOffsetY).toBe(100); + }); + + it('keeps the zoom scale positive and the flip state when zooming while flipped', () => + { + const canvas = createCanvas(); + + canvas.setFlip(true); + canvas.setScale(2); + + expect(canvas.scale).toBe(2); + expect(canvas.isFlipped).toBe(true); + expect(canvas.width).toBe(1600); + + // A legacy negative scale request is treated as its magnitude. + canvas.setScale(-1); + + expect(canvas.scale).toBe(1); + expect(canvas.isFlipped).toBe(true); + }); + + it('applies the negative display scale to both axes only while flipped', () => + { + const canvas = createCanvas(); + + canvas.setFlip(true); + + expect((canvas as any).displayScale).toBe(-1); + + canvas.setScale(2); + + expect((canvas as any).displayScale).toBe(-2); + + canvas.setFlip(false); + + expect((canvas as any).displayScale).toBe(2); + }); + + it('maps mouse coordinates back through the flipped transform', () => + { + const canvas = createCanvas(); + const seen: number[][] = []; + + Object.assign(canvas, { checkMouseHits: (x: number, y: number) => { seen.push([ x, y ]); return false; } }); + + canvas.setFlip(true); + canvas.handleMouseEvent(100, 50, 'click', false, false, false, false); + + // Screen (100, 50) sits 700 px left and 550 px above the flipped origin at (800, 600). + expect(seen[0]).toEqual([ 700, 550 ]); + expect((canvas as any)._mouseLocation).toEqual({ x: 700, y: 550 }); + }); + + it('tests sprite visibility against the flipped screen span', () => + { + const canvas = createCanvas(); + const isVisible = (x: number, y: number) => (canvas as any).isSpriteVisible(x + canvas.screenOffsetX, y + canvas.screenOffsetY, 50, 50); + + canvas.setFlip(true); + + // Local (100, 100) lands on screen at (650..700, 450..500): inside the canvas. + expect(isVisible(100, 100)).toBe(true); + + // Local (900, 100) lands left of the screen once flipped. + expect(isVisible(900, 100)).toBe(false); + + canvas.setFlip(false); + + expect(isVisible(100, 100)).toBe(true); + expect(isVisible(900, 100)).toBe(false); + }); +}); From 850d951f229d403d089a69d5221a076b60316087 Mon Sep 17 00:00:00 2001 From: simoleo89 <11816867+simoleo89@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:11:50 +0200 Subject: [PATCH 2/6] feat(communication): the chat, online-indicator and wired-menu preference composers Headers 2506, 818 and 1226 as the AIR 13 client sends them; the user settings parser reads the five trailing values the server now sends. --- .../roomevents/WiredMenuSettingsComposer.ts | 26 ++++++++ .../src/messages/outgoing/roomevents/index.ts | 1 + .../UserSettingsChatPreferencesComposer.ts | 26 ++++++++ .../UserSettingsOnlineIndicatorComposer.ts | 25 ++++++++ .../messages/outgoing/user/settings/index.ts | 2 + .../parser/user/data/UserSettingsParser.ts | 42 +++++++++++++ .../data/__tests__/UserSettingsParser.test.ts | 60 +++++++++++++++++++ 7 files changed, 182 insertions(+) create mode 100644 packages/communication/src/messages/outgoing/roomevents/WiredMenuSettingsComposer.ts create mode 100644 packages/communication/src/messages/outgoing/user/settings/UserSettingsChatPreferencesComposer.ts create mode 100644 packages/communication/src/messages/outgoing/user/settings/UserSettingsOnlineIndicatorComposer.ts diff --git a/packages/communication/src/messages/outgoing/roomevents/WiredMenuSettingsComposer.ts b/packages/communication/src/messages/outgoing/roomevents/WiredMenuSettingsComposer.ts new file mode 100644 index 00000000..ddaa5e93 --- /dev/null +++ b/packages/communication/src/messages/outgoing/roomevents/WiredMenuSettingsComposer.ts @@ -0,0 +1,26 @@ +import { IMessageComposer } from '@octane/api'; + +/** + * Official AIR 13 wired menu preferences composer (header 1226, WiredMenuController.sendPreferences): + * menu button, inspect button, play-test mode, a reserved int (always 0), wired whisper disabled, + * show all notifications and the ui style name. + */ +export class WiredMenuSettingsComposer implements IMessageComposer<[boolean, boolean, boolean, number, boolean, boolean, string]> +{ + private _data: [boolean, boolean, boolean, number, boolean, boolean, string]; + + constructor(menuButton: boolean, inspectButton: boolean, playTestMode: boolean, wiredWhisperDisabled: boolean, showAllNotifications: boolean, uiStyle: string) + { + this._data = [ menuButton, inspectButton, playTestMode, 0, wiredWhisperDisabled, showAllNotifications, uiStyle ]; + } + + public getMessageArray() + { + return this._data; + } + + public dispose(): void + { + return; + } +} diff --git a/packages/communication/src/messages/outgoing/roomevents/index.ts b/packages/communication/src/messages/outgoing/roomevents/index.ts index 72bc2ba6..e6678569 100644 --- a/packages/communication/src/messages/outgoing/roomevents/index.ts +++ b/packages/communication/src/messages/outgoing/roomevents/index.ts @@ -13,3 +13,4 @@ export * from './WiredUserInspectMoveComposer'; export * from './WiredUserVariableManageComposer'; export * from './WiredUserVariablesRequestComposer'; export * from './WiredUserVariableUpdateComposer'; +export * from './WiredMenuSettingsComposer'; diff --git a/packages/communication/src/messages/outgoing/user/settings/UserSettingsChatPreferencesComposer.ts b/packages/communication/src/messages/outgoing/user/settings/UserSettingsChatPreferencesComposer.ts new file mode 100644 index 00000000..cce273f5 --- /dev/null +++ b/packages/communication/src/messages/outgoing/user/settings/UserSettingsChatPreferencesComposer.ts @@ -0,0 +1,26 @@ +import { IMessageComposer } from '@octane/api'; + +/** + * Official AIR 13 chat preferences composer (header 2506): a leading boolean the official client + * always sends as false, then chat mode (0 free flow / 1 line by line), bubble width + * (0 wide / 1 normal / 2 thin) and scroll speed (0 fast / 1 normal / 2 slow). + */ +export class UserSettingsChatPreferencesComposer implements IMessageComposer<[boolean, number, number, number]> +{ + private _data: [boolean, number, number, number]; + + constructor(chatMode: number, chatBubbleWidth: number, chatScrollSpeed: number) + { + this._data = [ false, chatMode, chatBubbleWidth, chatScrollSpeed ]; + } + + public getMessageArray() + { + return this._data; + } + + public dispose(): void + { + return; + } +} diff --git a/packages/communication/src/messages/outgoing/user/settings/UserSettingsOnlineIndicatorComposer.ts b/packages/communication/src/messages/outgoing/user/settings/UserSettingsOnlineIndicatorComposer.ts new file mode 100644 index 00000000..36cbfccc --- /dev/null +++ b/packages/communication/src/messages/outgoing/user/settings/UserSettingsOnlineIndicatorComposer.ts @@ -0,0 +1,25 @@ +import { IMessageComposer } from '@octane/api'; + +/** + * Official AIR 13 friend-online notification preference composer (header 818): + * 0 everyone, 1 users in my relationship status, 2 nobody. + */ +export class UserSettingsOnlineIndicatorComposer implements IMessageComposer> +{ + private _data: ConstructorParameters; + + constructor(preference: number) + { + this._data = [ preference ]; + } + + public getMessageArray() + { + return this._data; + } + + public dispose(): void + { + return; + } +} diff --git a/packages/communication/src/messages/outgoing/user/settings/index.ts b/packages/communication/src/messages/outgoing/user/settings/index.ts index 312e9d48..361904e5 100644 --- a/packages/communication/src/messages/outgoing/user/settings/index.ts +++ b/packages/communication/src/messages/outgoing/user/settings/index.ts @@ -3,3 +3,5 @@ export * from './UserSettingsOldChatComposer'; export * from './UserSettingsPrivacyComposer'; export * from './UserSettingsRoomInvitesComposer'; export * from './UserSettingsSoundComposer'; +export * from './UserSettingsChatPreferencesComposer'; +export * from './UserSettingsOnlineIndicatorComposer'; diff --git a/packages/communication/src/messages/parser/user/data/UserSettingsParser.ts b/packages/communication/src/messages/parser/user/data/UserSettingsParser.ts index 0666f908..aac6a674 100644 --- a/packages/communication/src/messages/parser/user/data/UserSettingsParser.ts +++ b/packages/communication/src/messages/parser/user/data/UserSettingsParser.ts @@ -14,6 +14,11 @@ export class UserSettingsParser implements IMessageParser private _onlineStatusVisible: boolean; private _friendsCanFollow: boolean; private _friendRequestsAllowed: boolean; + private _wiredWhisperDisabled: boolean; + private _chatMode: number; + private _chatBubbleWidth: number; + private _chatScrollSpeed: number; + private _onlineIndicatorPreference: number; public flush(): boolean { @@ -29,6 +34,11 @@ export class UserSettingsParser implements IMessageParser this._onlineStatusVisible = true; this._friendsCanFollow = true; this._friendRequestsAllowed = true; + this._wiredWhisperDisabled = false; + this._chatMode = 0; + this._chatBubbleWidth = 1; + this._chatScrollSpeed = 1; + this._onlineIndicatorPreference = 0; return true; } @@ -49,6 +59,13 @@ export class UserSettingsParser implements IMessageParser this._friendsCanFollow = wrapper.readBoolean(); this._friendRequestsAllowed = wrapper.readBoolean(); this._volumeSoundboard = wrapper.bytesAvailable ? wrapper.readInt() : 80; + // Trailing per-user preferences (official UserSettings 3574 layout, each optional so an older + // emulator that stops at the soundboard volume keeps the official defaults). + this._wiredWhisperDisabled = wrapper.bytesAvailable ? wrapper.readBoolean() : false; + this._chatMode = wrapper.bytesAvailable ? wrapper.readInt() : 0; + this._chatBubbleWidth = wrapper.bytesAvailable ? wrapper.readInt() : 1; + this._chatScrollSpeed = wrapper.bytesAvailable ? wrapper.readInt() : 1; + this._onlineIndicatorPreference = wrapper.bytesAvailable ? wrapper.readInt() : 0; return true; } @@ -112,4 +129,29 @@ export class UserSettingsParser implements IMessageParser { return this._friendRequestsAllowed; } + + public get wiredWhisperDisabled(): boolean + { + return this._wiredWhisperDisabled; + } + + public get chatMode(): number + { + return this._chatMode; + } + + public get chatBubbleWidth(): number + { + return this._chatBubbleWidth; + } + + public get chatScrollSpeed(): number + { + return this._chatScrollSpeed; + } + + public get onlineIndicatorPreference(): number + { + return this._onlineIndicatorPreference; + } } diff --git a/packages/communication/src/messages/parser/user/data/__tests__/UserSettingsParser.test.ts b/packages/communication/src/messages/parser/user/data/__tests__/UserSettingsParser.test.ts index 80e6bb69..7dc219f7 100644 --- a/packages/communication/src/messages/parser/user/data/__tests__/UserSettingsParser.test.ts +++ b/packages/communication/src/messages/parser/user/data/__tests__/UserSettingsParser.test.ts @@ -86,3 +86,63 @@ describe('UserSettingsParser Soundboard volume', () => expect(parser.volumeSoundboard).toBe(80); }); }); + +describe('UserSettingsParser per-user preferences', () => +{ + const parse = (writer: BinaryWriter) => + { + const parser = new UserSettingsParser(); + parser.flush(); + expect(parser.parse(new TestWrapper(new BinaryReader(writer.getBuffer())) as any)).toBe(true); + return parser; + }; + + it('reads the trailing wired whisper, chat and friend-online preferences', () => + { + const writer = new BinaryWriter(); + writeLegacySettings(writer); + writer.writeInt(40); + writer.writeByte(1); + writer.writeInt(1); + writer.writeInt(2); + writer.writeInt(0); + writer.writeInt(2); + + const parser = parse(writer); + + expect(parser.volumeSoundboard).toBe(40); + expect(parser.wiredWhisperDisabled).toBe(true); + expect(parser.chatMode).toBe(1); + expect(parser.chatBubbleWidth).toBe(2); + expect(parser.chatScrollSpeed).toBe(0); + expect(parser.onlineIndicatorPreference).toBe(2); + }); + + it('keeps the official defaults when the emulator stops at the soundboard volume', () => + { + const writer = new BinaryWriter(); + writeLegacySettings(writer); + writer.writeInt(40); + + const parser = parse(writer); + + expect(parser.chatType).toBe(4); + expect(parser.wiredWhisperDisabled).toBe(false); + expect(parser.chatMode).toBe(0); + expect(parser.chatBubbleWidth).toBe(1); + expect(parser.chatScrollSpeed).toBe(1); + expect(parser.onlineIndicatorPreference).toBe(0); + }); + + it('keeps the official defaults for the legacy packet without the soundboard volume', () => + { + const writer = new BinaryWriter(); + writeLegacySettings(writer); + + const parser = parse(writer); + + expect(parser.volumeSoundboard).toBe(80); + expect(parser.chatBubbleWidth).toBe(1); + expect(parser.onlineIndicatorPreference).toBe(0); + }); +}); From 4a8fc35ace8b845542b2e0d254b2132cd393e62e Mon Sep 17 00:00:00 2001 From: simoleo89 <11816867+simoleo89@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:11:51 +0200 Subject: [PATCH 3/6] feat(communication): the personal word filter packets GetCustomFilter (145), AddCustomFilterWord (68), RemoveCustomFilterWord (1996), CustomFilterResult (3883) and ModifyCustomFilterResult (3333). --- packages/communication/src/OctaneMessages.ts | 12 +++ .../src/messages/incoming/IncomingHeader.ts | 2 + .../src/messages/incoming/user/index.ts | 1 + .../wordfilter/CustomFilterResultEvent.ts | 16 ++++ .../ModifyCustomFilterResultEvent.ts | 23 ++++++ .../incoming/user/wordfilter/index.ts | 2 + .../src/messages/outgoing/OutgoingHeader.ts | 8 ++ .../src/messages/outgoing/user/index.ts | 1 + .../AddCustomFilterWordMessageComposer.ts | 22 ++++++ .../GetCustomFilterMessageComposer.ts | 22 ++++++ .../RemoveCustomFilterWordMessageComposer.ts | 22 ++++++ .../outgoing/user/wordfilter/index.ts | 3 + .../src/messages/parser/user/index.ts | 1 + .../wordfilter/CustomFilterResultParser.ts | 35 ++++++++ .../CustomWordFilterPacketContract.test.ts | 79 +++++++++++++++++++ .../ModifyCustomFilterResultParser.ts | 36 +++++++++ .../messages/parser/user/wordfilter/index.ts | 2 + 17 files changed, 287 insertions(+) create mode 100644 packages/communication/src/messages/incoming/user/wordfilter/CustomFilterResultEvent.ts create mode 100644 packages/communication/src/messages/incoming/user/wordfilter/ModifyCustomFilterResultEvent.ts create mode 100644 packages/communication/src/messages/incoming/user/wordfilter/index.ts create mode 100644 packages/communication/src/messages/outgoing/user/wordfilter/AddCustomFilterWordMessageComposer.ts create mode 100644 packages/communication/src/messages/outgoing/user/wordfilter/GetCustomFilterMessageComposer.ts create mode 100644 packages/communication/src/messages/outgoing/user/wordfilter/RemoveCustomFilterWordMessageComposer.ts create mode 100644 packages/communication/src/messages/outgoing/user/wordfilter/index.ts create mode 100644 packages/communication/src/messages/parser/user/wordfilter/CustomFilterResultParser.ts create mode 100644 packages/communication/src/messages/parser/user/wordfilter/CustomWordFilterPacketContract.test.ts create mode 100644 packages/communication/src/messages/parser/user/wordfilter/ModifyCustomFilterResultParser.ts create mode 100644 packages/communication/src/messages/parser/user/wordfilter/index.ts diff --git a/packages/communication/src/OctaneMessages.ts b/packages/communication/src/OctaneMessages.ts index 86cf3d4e..4edd425d 100644 --- a/packages/communication/src/OctaneMessages.ts +++ b/packages/communication/src/OctaneMessages.ts @@ -1,9 +1,13 @@ import { IMessageConfiguration } from '@octane/api'; import { UnsupportedOutgoingHeader } from './messages/outgoing/UnsupportedOutgoingHeader'; import { UserSettingsPrivacyComposer } from './messages/outgoing/user/settings/UserSettingsPrivacyComposer'; +import { UserSettingsChatPreferencesComposer } from './messages/outgoing/user/settings/UserSettingsChatPreferencesComposer'; +import { UserSettingsOnlineIndicatorComposer } from './messages/outgoing/user/settings/UserSettingsOnlineIndicatorComposer'; +import { WiredMenuSettingsComposer } from './messages/outgoing/roomevents/WiredMenuSettingsComposer'; import { RequestOfflineMessagesComposer } from './messages/outgoing/friendlist/RequestOfflineMessagesComposer'; import { HotelViewLandingEvent, HotelViewLandingRequestComposer, HotelViewLandingSaveComposer, HotelViewLandingSaveSceneComposer, HotelViewLandingVoteComposer, HotelViewLandingResetVotesComposer } from './messages'; import { RoomUnitHabbiconEvent, UseHabbiconComposer } from './messages'; +import { AddCustomFilterWordMessageComposer, CustomFilterResultEvent, GetCustomFilterMessageComposer, ModifyCustomFilterResultEvent, RemoveCustomFilterWordMessageComposer } from './messages'; import { MarkMessengerReadComposer, MessengerConversationsEvent, MessengerHistoryEvent, MessengerMessageAckEvent, MessengerMessageFailedEvent, MessengerMessageEvent, MessengerReadCursorEvent, RequestMessengerConversationsComposer, RequestMessengerHistoryComposer, SendMessengerMessageComposer } from './messages'; import { GetTraxEditorSongsComposer, TraxEditorBuySongComposer, TraxEditorDeleteSongComposer, TraxEditorErrorEvent, TraxEditorSaveSongComposer, TraxEditorSongsEvent } from './messages'; import { SnowWarGetAllTimeFriendsLeaderboardComposer, SnowWarGetAllTimeLeaderboardComposer, SnowWarGetWeeklyFriendsLeaderboardComposer, SnowWarGetWeeklyLeaderboardComposer, SnowWarSelectArenaComposer } from './messages'; @@ -545,6 +549,8 @@ export class OctaneMessages implements IMessageConfiguration this._events.set(IncomingHeader.ROOM_PAINT, RoomPaintEvent); this._events.set(IncomingHeader.ROOM_THICKNESS, RoomVisualizationSettingsEvent); this._events.set(IncomingHeader.ROOM_GET_FILTER_WORDS, RoomFilterSettingsMessageEvent); + this._events.set(IncomingHeader.CUSTOM_FILTER_RESULT, CustomFilterResultEvent); + this._events.set(IncomingHeader.MODIFY_CUSTOM_FILTER_RESULT, ModifyCustomFilterResultEvent); this._events.set(IncomingHeader.ROOM_MODEL_BLOCKED_TILES, RoomOccupiedTilesMessageEvent); this._events.set(IncomingHeader.PET_FIGURE_UPDATE, PetFigureUpdateEvent); this._events.set(IncomingHeader.PET_INFO, PetInfoEvent); @@ -917,6 +923,9 @@ export class OctaneMessages implements IMessageConfiguration this._composers.set(OutgoingHeader.SNOWWAR_GET_WEEKLY_FRIENDS_LEADERBOARD, SnowWarGetWeeklyFriendsLeaderboardComposer); this._composers.set(OutgoingHeader.USE_HABBICON, UseHabbiconComposer); this._composers.set(OutgoingHeader.USER_SETTINGS_PRIVACY, UserSettingsPrivacyComposer); + this._composers.set(OutgoingHeader.USER_SETTINGS_CHAT_PREFERENCES, UserSettingsChatPreferencesComposer); + this._composers.set(OutgoingHeader.USER_SETTINGS_ONLINE_INDICATOR, UserSettingsOnlineIndicatorComposer); + this._composers.set(OutgoingHeader.WIRED_MENU_SETTINGS_SAVE, WiredMenuSettingsComposer); this._composers.set(OutgoingHeader.REQUEST_OFFLINE_MESSAGES, RequestOfflineMessagesComposer); this._composers.set(OutgoingHeader.HOTEL_VIEW_LANDING_REQUEST, HotelViewLandingRequestComposer); this._composers.set(OutgoingHeader.HOTEL_VIEW_LANDING_SAVE, HotelViewLandingSaveComposer); @@ -1055,6 +1064,9 @@ export class OctaneMessages implements IMessageConfiguration this._composers.set(OutgoingHeader.ROOM_STAFF_PICK, ToggleStaffPickMessageComposer); this._composers.set(OutgoingHeader.ROOM_FILTER_WORDS, GetCustomRoomFilterMessageComposer); this._composers.set(OutgoingHeader.ROOM_FILTER_WORDS_MODIFY, UpdateRoomFilterMessageComposer); + this._composers.set(OutgoingHeader.GET_CUSTOM_FILTER, GetCustomFilterMessageComposer); + this._composers.set(OutgoingHeader.ADD_CUSTOM_FILTER_WORD, AddCustomFilterWordMessageComposer); + this._composers.set(OutgoingHeader.REMOVE_CUSTOM_FILTER_WORD, RemoveCustomFilterWordMessageComposer); this._composers.set(OutgoingHeader.USER_HOME_ROOM, UpdateHomeRoomMessageComposer); this._composers.set(OutgoingHeader.UPDATE_ROOM_THUMBNAIL, UpdateRoomThumbnailMessageComposer); // NEW NAVIGATOR diff --git a/packages/communication/src/messages/incoming/IncomingHeader.ts b/packages/communication/src/messages/incoming/IncomingHeader.ts index 6db27a19..b0c4dad6 100644 --- a/packages/communication/src/messages/incoming/IncomingHeader.ts +++ b/packages/communication/src/messages/incoming/IncomingHeader.ts @@ -211,6 +211,8 @@ export class IncomingHeader public static ROOM_SPECTATOR = 1033; public static ROOM_THICKNESS = 3547; public static ROOM_GET_FILTER_WORDS = 2937; + public static CUSTOM_FILTER_RESULT = 3883; + public static MODIFY_CUSTOM_FILTER_RESULT = 3333; public static ROOM_MESSAGE_NOTIFICATION = 1634; public static ROOM_POPULAR_TAGS_RESULT = 2012; public static INFO_FEED_ENABLE = 3284; diff --git a/packages/communication/src/messages/incoming/user/index.ts b/packages/communication/src/messages/incoming/user/index.ts index f6f86421..44c63f6c 100644 --- a/packages/communication/src/messages/incoming/user/index.ts +++ b/packages/communication/src/messages/incoming/user/index.ts @@ -25,3 +25,4 @@ export * from './RespectReceivedEvent'; export * from './ScrSendKickbackInfoMessageEvent'; export * from './wardrobe'; export * from './WelcomeGiftChangeEmailResultEvent'; +export * from './wordfilter'; diff --git a/packages/communication/src/messages/incoming/user/wordfilter/CustomFilterResultEvent.ts b/packages/communication/src/messages/incoming/user/wordfilter/CustomFilterResultEvent.ts new file mode 100644 index 00000000..50ffc181 --- /dev/null +++ b/packages/communication/src/messages/incoming/user/wordfilter/CustomFilterResultEvent.ts @@ -0,0 +1,16 @@ +import { IMessageEvent } from '@octane/api'; +import { MessageEvent } from '@octane/events'; +import { CustomFilterResultParser } from '../../../parser'; + +export class CustomFilterResultEvent extends MessageEvent implements IMessageEvent +{ + constructor(callBack: Function) + { + super(callBack, CustomFilterResultParser); + } + + public getParser(): CustomFilterResultParser + { + return this.parser as CustomFilterResultParser; + } +} diff --git a/packages/communication/src/messages/incoming/user/wordfilter/ModifyCustomFilterResultEvent.ts b/packages/communication/src/messages/incoming/user/wordfilter/ModifyCustomFilterResultEvent.ts new file mode 100644 index 00000000..aaf5695f --- /dev/null +++ b/packages/communication/src/messages/incoming/user/wordfilter/ModifyCustomFilterResultEvent.ts @@ -0,0 +1,23 @@ +import { IMessageEvent } from '@octane/api'; +import { MessageEvent } from '@octane/events'; +import { ModifyCustomFilterResultParser } from '../../../parser'; + +export class ModifyCustomFilterResultEvent extends MessageEvent implements IMessageEvent +{ + /** The word could not be added or removed (already listed, empty, ...). */ + public static FAILED: number = 0; + /** The word was added to the list. */ + public static ADDED: number = 1; + /** The word was removed from the list. */ + public static REMOVED: number = 3; + + constructor(callBack: Function) + { + super(callBack, ModifyCustomFilterResultParser); + } + + public getParser(): ModifyCustomFilterResultParser + { + return this.parser as ModifyCustomFilterResultParser; + } +} diff --git a/packages/communication/src/messages/incoming/user/wordfilter/index.ts b/packages/communication/src/messages/incoming/user/wordfilter/index.ts new file mode 100644 index 00000000..aa38ff28 --- /dev/null +++ b/packages/communication/src/messages/incoming/user/wordfilter/index.ts @@ -0,0 +1,2 @@ +export * from './CustomFilterResultEvent'; +export * from './ModifyCustomFilterResultEvent'; diff --git a/packages/communication/src/messages/outgoing/OutgoingHeader.ts b/packages/communication/src/messages/outgoing/OutgoingHeader.ts index cb2f9950..a03e59bb 100644 --- a/packages/communication/src/messages/outgoing/OutgoingHeader.ts +++ b/packages/communication/src/messages/outgoing/OutgoingHeader.ts @@ -243,6 +243,9 @@ export class OutgoingHeader public static ROOM_STAFF_PICK = 1918; public static ROOM_FILTER_WORDS = 1911; public static ROOM_FILTER_WORDS_MODIFY = 3001; + public static GET_CUSTOM_FILTER = 145; + public static ADD_CUSTOM_FILTER_WORD = 68; + public static REMOVE_CUSTOM_FILTER_WORD = 1996; public static MYSTERYBOXWAITINGCANCELEDMESSAGE = 2012; public static MYSTERYBOX_OPEN_TROPHY = 3074; public static SECURITY_MACHINE = 2490; @@ -294,6 +297,11 @@ export class OutgoingHeader public static USER_SETTINGS_INVITES = 1086; public static USER_SETTINGS_OLD_CHAT = 1262; public static USER_SETTINGS_VOLUME = 1367; + // Official AIR 13 ids (client -> server): chat mode/bubble width/scroll speed, friend-online + // notification preference and the wired menu preferences (wired whisper switch). + public static USER_SETTINGS_CHAT_PREFERENCES = 2506; + public static USER_SETTINGS_ONLINE_INDICATOR = 818; + public static WIRED_MENU_SETTINGS_SAVE = 1226; public static USER_SUBSCRIPTION = 3166; public static GET_WARDROBE = 2742; public static SAVE_WARDROBE_OUTFIT = 800; diff --git a/packages/communication/src/messages/outgoing/user/index.ts b/packages/communication/src/messages/outgoing/user/index.ts index 9a10b1fe..dd76e389 100644 --- a/packages/communication/src/messages/outgoing/user/index.ts +++ b/packages/communication/src/messages/outgoing/user/index.ts @@ -12,3 +12,4 @@ export * from './settings'; export * from './UnblockGroupMemberMessageComposer'; export * from './UserRespectComposer'; export * from './WelcomeGiftChangeEmailComposer'; +export * from './wordfilter'; diff --git a/packages/communication/src/messages/outgoing/user/wordfilter/AddCustomFilterWordMessageComposer.ts b/packages/communication/src/messages/outgoing/user/wordfilter/AddCustomFilterWordMessageComposer.ts new file mode 100644 index 00000000..7cd592a8 --- /dev/null +++ b/packages/communication/src/messages/outgoing/user/wordfilter/AddCustomFilterWordMessageComposer.ts @@ -0,0 +1,22 @@ +import { IMessageComposer } from '@octane/api'; + +/** AddCustomFilterWord (68). */ +export class AddCustomFilterWordMessageComposer implements IMessageComposer> +{ + private _data: ConstructorParameters; + + constructor(word: string) + { + this._data = [word]; + } + + public getMessageArray() + { + return this._data; + } + + public dispose(): void + { + return; + } +} diff --git a/packages/communication/src/messages/outgoing/user/wordfilter/GetCustomFilterMessageComposer.ts b/packages/communication/src/messages/outgoing/user/wordfilter/GetCustomFilterMessageComposer.ts new file mode 100644 index 00000000..b2244755 --- /dev/null +++ b/packages/communication/src/messages/outgoing/user/wordfilter/GetCustomFilterMessageComposer.ts @@ -0,0 +1,22 @@ +import { IMessageComposer } from '@octane/api'; + +/** GetCustomFilter (145): asks for the personal word filter list. */ +export class GetCustomFilterMessageComposer implements IMessageComposer> +{ + private _data: ConstructorParameters; + + constructor() + { + this._data = []; + } + + public getMessageArray() + { + return this._data; + } + + public dispose(): void + { + return; + } +} diff --git a/packages/communication/src/messages/outgoing/user/wordfilter/RemoveCustomFilterWordMessageComposer.ts b/packages/communication/src/messages/outgoing/user/wordfilter/RemoveCustomFilterWordMessageComposer.ts new file mode 100644 index 00000000..447e6ae4 --- /dev/null +++ b/packages/communication/src/messages/outgoing/user/wordfilter/RemoveCustomFilterWordMessageComposer.ts @@ -0,0 +1,22 @@ +import { IMessageComposer } from '@octane/api'; + +/** RemoveCustomFilterWord (1996). */ +export class RemoveCustomFilterWordMessageComposer implements IMessageComposer> +{ + private _data: ConstructorParameters; + + constructor(word: string) + { + this._data = [word]; + } + + public getMessageArray() + { + return this._data; + } + + public dispose(): void + { + return; + } +} diff --git a/packages/communication/src/messages/outgoing/user/wordfilter/index.ts b/packages/communication/src/messages/outgoing/user/wordfilter/index.ts new file mode 100644 index 00000000..6b383fe0 --- /dev/null +++ b/packages/communication/src/messages/outgoing/user/wordfilter/index.ts @@ -0,0 +1,3 @@ +export * from './AddCustomFilterWordMessageComposer'; +export * from './GetCustomFilterMessageComposer'; +export * from './RemoveCustomFilterWordMessageComposer'; diff --git a/packages/communication/src/messages/parser/user/index.ts b/packages/communication/src/messages/parser/user/index.ts index 82dfb339..361d910d 100644 --- a/packages/communication/src/messages/parser/user/index.ts +++ b/packages/communication/src/messages/parser/user/index.ts @@ -28,3 +28,4 @@ export * from './ScrKickbackData'; export * from './ScrSendKickbackInfoMessageParser'; export * from './wardrobe'; export * from './WelcomeGiftChangeEmailResultParser'; +export * from './wordfilter'; diff --git a/packages/communication/src/messages/parser/user/wordfilter/CustomFilterResultParser.ts b/packages/communication/src/messages/parser/user/wordfilter/CustomFilterResultParser.ts new file mode 100644 index 00000000..759a28a2 --- /dev/null +++ b/packages/communication/src/messages/parser/user/wordfilter/CustomFilterResultParser.ts @@ -0,0 +1,35 @@ +import { IMessageDataWrapper, IMessageParser } from '@octane/api'; + +/** CustomFilterResult (3883): the user's personal word filter list. */ +export class CustomFilterResultParser implements IMessageParser +{ + private _words: string[]; + + public flush(): boolean + { + this._words = []; + + return true; + } + + public parse(wrapper: IMessageDataWrapper): boolean + { + if(!wrapper) return false; + + let totalWords = wrapper.readInt(); + + while(totalWords > 0) + { + this._words.push(wrapper.readString()); + + totalWords--; + } + + return true; + } + + public get words(): string[] + { + return this._words; + } +} diff --git a/packages/communication/src/messages/parser/user/wordfilter/CustomWordFilterPacketContract.test.ts b/packages/communication/src/messages/parser/user/wordfilter/CustomWordFilterPacketContract.test.ts new file mode 100644 index 00000000..3455d44f --- /dev/null +++ b/packages/communication/src/messages/parser/user/wordfilter/CustomWordFilterPacketContract.test.ts @@ -0,0 +1,79 @@ +import { BinaryReader, BinaryWriter } from '@octane/utils'; +import { describe, expect, it } from 'vitest'; +import { OctaneMessages } from '../../../../OctaneMessages'; +import { IncomingHeader } from '../../../incoming/IncomingHeader'; +import { CustomFilterResultEvent, ModifyCustomFilterResultEvent } from '../../../incoming/user/wordfilter'; +import { OutgoingHeader } from '../../../outgoing/OutgoingHeader'; +import { AddCustomFilterWordMessageComposer, GetCustomFilterMessageComposer, RemoveCustomFilterWordMessageComposer } from '../../../outgoing/user/wordfilter'; +import { CustomFilterResultParser } from './CustomFilterResultParser'; +import { ModifyCustomFilterResultParser } from './ModifyCustomFilterResultParser'; + +class TestWrapper +{ + constructor(private reader: BinaryReader) + {} + readInt() + { + return this.reader.readInt(); + } + readString() + { + const length = this.reader.readShort(); return this.reader.readBytes(length).toString(); + } + get bytesAvailable() + { + return this.reader.remaining() > 0; + } +} + +/** AIR 13 class_1956: composers 145 / 68 / 1996, events 3883 / 3333. */ +describe('personal word filter packet contract', () => +{ + it('uses the official headers and registers every request and response', () => + { + const messages = new OctaneMessages(); + + expect(OutgoingHeader.GET_CUSTOM_FILTER).toBe(145); + expect(OutgoingHeader.ADD_CUSTOM_FILTER_WORD).toBe(68); + expect(OutgoingHeader.REMOVE_CUSTOM_FILTER_WORD).toBe(1996); + expect(IncomingHeader.CUSTOM_FILTER_RESULT).toBe(3883); + expect(IncomingHeader.MODIFY_CUSTOM_FILTER_RESULT).toBe(3333); + + expect(messages.composers.get(145)).toBe(GetCustomFilterMessageComposer); + expect(messages.composers.get(68)).toBe(AddCustomFilterWordMessageComposer); + expect(messages.composers.get(1996)).toBe(RemoveCustomFilterWordMessageComposer); + expect(messages.events.get(3883)).toBe(CustomFilterResultEvent); + expect(messages.events.get(3333)).toBe(ModifyCustomFilterResultEvent); + }); + + it('serializes the request bodies as the official composers do', () => + { + expect(new GetCustomFilterMessageComposer().getMessageArray()).toEqual([]); + expect(new AddCustomFilterWordMessageComposer('pippo').getMessageArray()).toEqual([ 'pippo' ]); + expect(new RemoveCustomFilterWordMessageComposer('pippo').getMessageArray()).toEqual([ 'pippo' ]); + }); + + it('parses the word list as a counted string block', () => + { + const writer = new BinaryWriter(); + writer.writeInt(2); writer.writeString('pippo'); writer.writeString('pluto'); + + const parser = new CustomFilterResultParser(); + expect(parser.flush()).toBe(true); + expect(parser.parse(new TestWrapper(new BinaryReader(writer.getBuffer())) as any)).toBe(true); + expect(parser.words).toEqual([ 'pippo', 'pluto' ]); + }); + + it('parses the modify result as result code then word', () => + { + const writer = new BinaryWriter(); + writer.writeInt(ModifyCustomFilterResultEvent.REMOVED); writer.writeString('pippo'); + + const parser = new ModifyCustomFilterResultParser(); + expect(parser.flush()).toBe(true); + expect(parser.parse(new TestWrapper(new BinaryReader(writer.getBuffer())) as any)).toBe(true); + expect(parser.result).toBe(3); + expect(parser.word).toBe('pippo'); + expect(ModifyCustomFilterResultEvent.ADDED).toBe(1); + }); +}); diff --git a/packages/communication/src/messages/parser/user/wordfilter/ModifyCustomFilterResultParser.ts b/packages/communication/src/messages/parser/user/wordfilter/ModifyCustomFilterResultParser.ts new file mode 100644 index 00000000..ae5fc2ca --- /dev/null +++ b/packages/communication/src/messages/parser/user/wordfilter/ModifyCustomFilterResultParser.ts @@ -0,0 +1,36 @@ +import { IMessageDataWrapper, IMessageParser } from '@octane/api'; + +/** ModifyCustomFilterResult (3333): result code followed by the word it refers to. */ +export class ModifyCustomFilterResultParser implements IMessageParser +{ + private _result: number; + private _word: string; + + public flush(): boolean + { + this._result = -1; + this._word = ''; + + return true; + } + + public parse(wrapper: IMessageDataWrapper): boolean + { + if(!wrapper) return false; + + this._result = wrapper.readInt(); + this._word = wrapper.readString(); + + return true; + } + + public get result(): number + { + return this._result; + } + + public get word(): string + { + return this._word; + } +} diff --git a/packages/communication/src/messages/parser/user/wordfilter/index.ts b/packages/communication/src/messages/parser/user/wordfilter/index.ts new file mode 100644 index 00000000..4c34f858 --- /dev/null +++ b/packages/communication/src/messages/parser/user/wordfilter/index.ts @@ -0,0 +1,2 @@ +export * from './CustomFilterResultParser'; +export * from './ModifyCustomFilterResultParser'; From a502b39b30d2d3c3f92dfe7b0f8830f6293381a6 Mon Sep 17 00:00:00 2001 From: simoleo89 <11816867+simoleo89@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:21:32 +0200 Subject: [PATCH 4/6] chore(protocol): register the preference and word filter packets in the contract manifest Mirrors the emulator's manifest: the three preference composers are exempted (their Java side reads through a shared parser), the word filter packets carry their fields, and the user settings contract lists the five trailing values. --- protocol/packet-field-contracts.json | 176 +++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) diff --git a/protocol/packet-field-contracts.json b/protocol/packet-field-contracts.json index daefb620..e322149e 100644 --- a/protocol/packet-field-contracts.json +++ b/protocol/packet-field-contracts.json @@ -508,6 +508,28 @@ }, "fields": [] }, + { + "name": "ADD_CUSTOM_FILTER_WORD", + "direction": "client_to_server", + "header": 68, + "java": { + "symbol": "AddCustomWordFilterWordEvent", + "className": "AddCustomWordFilterWordEvent", + "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/users/AddCustomWordFilterWordEvent.java" + }, + "typescript": { + "symbol": "ADD_CUSTOM_FILTER_WORD", + "className": "AddCustomFilterWordMessageComposer", + "path": "packages/communication/src/messages/outgoing/user/wordfilter/AddCustomFilterWordMessageComposer.ts" + }, + "fields": [ + { + "kind": "scalar", + "type": "string", + "name": "word" + } + ] + }, { "name": "FURNITURE_MULTISTATE", "direction": "client_to_server", @@ -573,6 +595,22 @@ } ] }, + { + "name": "GET_CUSTOM_FILTER", + "direction": "client_to_server", + "header": 145, + "java": { + "symbol": "RequestCustomWordFilterEvent", + "className": "RequestCustomWordFilterEvent", + "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/users/RequestCustomWordFilterEvent.java" + }, + "typescript": { + "symbol": "GET_CUSTOM_FILTER", + "className": "GetCustomFilterMessageComposer", + "path": "packages/communication/src/messages/outgoing/user/wordfilter/GetCustomFilterMessageComposer.ts" + }, + "fields": [] + }, { "name": "FURNITURE_WALL_UPDATE", "direction": "client_to_server", @@ -3165,6 +3203,28 @@ } ] }, + { + "name": "REMOVE_CUSTOM_FILTER_WORD", + "direction": "client_to_server", + "header": 1996, + "java": { + "symbol": "RemoveCustomWordFilterWordEvent", + "className": "RemoveCustomWordFilterWordEvent", + "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/users/RemoveCustomWordFilterWordEvent.java" + }, + "typescript": { + "symbol": "REMOVE_CUSTOM_FILTER_WORD", + "className": "RemoveCustomFilterWordMessageComposer", + "path": "packages/communication/src/messages/outgoing/user/wordfilter/RemoveCustomFilterWordMessageComposer.ts" + }, + "fields": [ + { + "kind": "scalar", + "type": "string", + "name": "word" + } + ] + }, { "name": "SET_TARGETTED_OFFER_STATE", "direction": "client_to_server", @@ -10489,6 +10549,31 @@ "kind": "scalar", "type": "int", "name": "" + }, + { + "kind": "scalar", + "type": "boolean", + "name": "wiredWhisperDisabled" + }, + { + "kind": "scalar", + "type": "int", + "name": "chatMode" + }, + { + "kind": "scalar", + "type": "int", + "name": "chatBubbleWidth" + }, + { + "kind": "scalar", + "type": "int", + "name": "chatScrollSpeed" + }, + { + "kind": "scalar", + "type": "int", + "name": "onlineIndicatorPreference" } ] }, @@ -14765,6 +14850,33 @@ } ] }, + { + "name": "MODIFY_CUSTOM_FILTER_RESULT", + "direction": "server_to_client", + "header": 3333, + "java": { + "symbol": "CustomWordFilterModifyResultComposer", + "className": "CustomWordFilterModifyResultComposer", + "path": "Emulator/src/main/java/com/eu/habbo/messages/outgoing/users/CustomWordFilterModifyResultComposer.java" + }, + "typescript": { + "symbol": "MODIFY_CUSTOM_FILTER_RESULT", + "className": "ModifyCustomFilterResultParser", + "path": "packages/communication/src/messages/parser/user/wordfilter/ModifyCustomFilterResultParser.ts" + }, + "fields": [ + { + "kind": "scalar", + "type": "int", + "name": "result" + }, + { + "kind": "scalar", + "type": "string", + "name": "word" + } + ] + }, { "name": "REDEEM_VOUCHER_OK", "direction": "server_to_client", @@ -23205,6 +23317,22 @@ }, "reason": "TypeScript analyzer: Data-dependent packet operations in IfStatement inside parse" }, + { + "name": "CUSTOM_FILTER_RESULT", + "direction": "server_to_client", + "header": 3883, + "java": { + "symbol": "CustomWordFilterWordsComposer", + "className": "CustomWordFilterWordsComposer", + "path": "Emulator/src/main/java/com/eu/habbo/messages/outgoing/users/CustomWordFilterWordsComposer.java" + }, + "typescript": { + "symbol": "CUSTOM_FILTER_RESULT", + "className": "CustomFilterResultParser", + "path": "packages/communication/src/messages/parser/user/wordfilter/CustomFilterResultParser.ts" + }, + "reason": "Java analyzer: Data-dependent packet operations in ForEachStmt inside composeInternal; TypeScript analyzer: Data-dependent packet operations in WhileStatement inside parse" + }, { "name": "MARKETPLACE_OWN_ITEMS", "direction": "server_to_client", @@ -24356,6 +24484,54 @@ "path": "packages/communication/src/messages/parser/catalog/metadata/CatalogProductMetadataMessageParser.ts" }, "reason": "Java analyzer: Data-dependent packet operations in ForStmt inside composeInternal; TypeScript analyzer: Data-dependent packet operations in ForStatement inside parse" + }, + { + "name": "USER_SETTINGS_CHAT_PREFERENCES", + "direction": "client_to_server", + "header": 2506, + "java": { + "symbol": "SaveChatPreferencesEvent", + "className": "SaveChatPreferencesEvent", + "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/users/SaveChatPreferencesEvent.java" + }, + "typescript": { + "symbol": "USER_SETTINGS_CHAT_PREFERENCES", + "className": "UserSettingsChatPreferencesComposer", + "path": "packages/communication/src/messages/outgoing/user/settings/UserSettingsChatPreferencesComposer.ts" + }, + "reason": "Java analyzer: Packet fields delegated to external reader in UserPreferencePackets inside handle" + }, + { + "name": "USER_SETTINGS_ONLINE_INDICATOR", + "direction": "client_to_server", + "header": 818, + "java": { + "symbol": "SaveOnlineIndicatorPreferenceEvent", + "className": "SaveOnlineIndicatorPreferenceEvent", + "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/users/SaveOnlineIndicatorPreferenceEvent.java" + }, + "typescript": { + "symbol": "USER_SETTINGS_ONLINE_INDICATOR", + "className": "UserSettingsOnlineIndicatorComposer", + "path": "packages/communication/src/messages/outgoing/user/settings/UserSettingsOnlineIndicatorComposer.ts" + }, + "reason": "Java analyzer: Packet fields delegated to external reader in UserPreferencePackets inside handle" + }, + { + "name": "WIRED_MENU_SETTINGS_SAVE", + "direction": "client_to_server", + "header": 1226, + "java": { + "symbol": "SaveWiredMenuSettingsEvent", + "className": "SaveWiredMenuSettingsEvent", + "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/users/SaveWiredMenuSettingsEvent.java" + }, + "typescript": { + "symbol": "WIRED_MENU_SETTINGS_SAVE", + "className": "WiredMenuSettingsComposer", + "path": "packages/communication/src/messages/outgoing/roomevents/WiredMenuSettingsComposer.ts" + }, + "reason": "Java analyzer: Packet fields delegated to external reader in UserPreferencePackets inside handle" } ] } From c1d5c02bef224ce9aa23b7c2d78e0e630b431e56 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Mon, 7 Sep 2026 21:35:29 +0200 Subject: [PATCH 5/6] chore(protocol): mirror the emulator's packet contract manifest The user settings contract carries the five trailing preference values and the flood control entry follows dev. --- protocol/packet-field-contracts.json | 50 ++++++++++++++-------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/protocol/packet-field-contracts.json b/protocol/packet-field-contracts.json index e322149e..65966704 100644 --- a/protocol/packet-field-contracts.json +++ b/protocol/packet-field-contracts.json @@ -10448,6 +10448,31 @@ "kind": "scalar", "type": "int", "name": "" + }, + { + "kind": "scalar", + "type": "boolean", + "name": "wiredWhisperDisabled" + }, + { + "kind": "scalar", + "type": "int", + "name": "chatMode" + }, + { + "kind": "scalar", + "type": "int", + "name": "chatBubbleWidth" + }, + { + "kind": "scalar", + "type": "int", + "name": "chatScrollSpeed" + }, + { + "kind": "scalar", + "type": "int", + "name": "onlineIndicatorPreference" } ] }, @@ -10549,31 +10574,6 @@ "kind": "scalar", "type": "int", "name": "" - }, - { - "kind": "scalar", - "type": "boolean", - "name": "wiredWhisperDisabled" - }, - { - "kind": "scalar", - "type": "int", - "name": "chatMode" - }, - { - "kind": "scalar", - "type": "int", - "name": "chatBubbleWidth" - }, - { - "kind": "scalar", - "type": "int", - "name": "chatScrollSpeed" - }, - { - "kind": "scalar", - "type": "int", - "name": "onlineIndicatorPreference" } ] }, From 57eb8d3bcab2e08b0a51fb40efc12c383b966798 Mon Sep 17 00:00:00 2001 From: simoleo89 <11816867+simoleo89@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:22:43 +0200 Subject: [PATCH 6/6] feat(communication): quests, daily tasks, reward track, hot looks, group block, badge tiers, snowwar stats The classic quest packets gain the seasonal fields and the reject composer; daily tasks (4100/4101, 2900/670/9451) and the reward track (1111/3022/9450, 2327/596->9452/2142/2248) arrive with their data classes; hot looks (9360 both ways); the kick composer's block flag and the blocked rank; the badges parser reads owner count and rarity tier per slot; the snowwar ending and lobby parsers read the appended stats and skill levels. Packet contract manifest mirrored from the emulator. --- packages/communication/src/OctaneMessages.ts | 16 + .../src/messages/incoming/IncomingHeader.ts | 10 + .../incoming/hotlooks/HotLooksEvent.ts | 16 + .../src/messages/incoming/hotlooks/index.ts | 1 + .../src/messages/incoming/index.ts | 1 + .../quest/ActiveDailyTasksMessageEvent.ts | 16 + .../quest/DailyTaskUpdatedMessageEvent.ts | 16 + .../quest/DailyTasksAddedMessageEvent.ts | 16 + .../RewardTrackClaimResultMessageEvent.ts | 16 + ...dTrackPremiumPurchaseResultMessageEvent.ts | 16 + .../quest/RewardTrackProgressMessageEvent.ts | 16 + .../quest/RewardTracksMessageEvent.ts | 16 + .../src/messages/incoming/quest/index.ts | 7 + .../src/messages/outgoing/OutgoingHeader.ts | 8 + .../group/GroupRemoveMemberComposer.ts | 5 +- .../outgoing/hotlooks/GetHotLooksComposer.ts | 25 + .../src/messages/outgoing/hotlooks/index.ts | 1 + .../src/messages/outgoing/index.ts | 1 + .../quest/ClaimDailyTaskMessageComposer.ts | 21 + .../ClaimRewardTrackPrizeMessageComposer.ts | 21 + .../quest/GetDailyTasksMessageComposer.ts | 21 + .../quest/GetRewardTracksMessageComposer.ts | 21 + ...rchaseRewardTrackPremiumMessageComposer.ts | 21 + .../quest/RejectQuestMessageComposer.ts | 4 +- .../src/messages/outgoing/quest/index.ts | 5 + .../snowwar/SnowWarGameResultPlayerData.ts | 47 + .../game/snowwar/SnowWarLobbyTeamsParser.ts | 20 +- .../game/snowwar/SnowWarOnGameEndingParser.ts | 59 + .../parser/game/snowwar/SnowWarPlayerData.ts | 12 + .../game/snowwar/SnowWarStatsTail.test.ts | 116 ++ .../parser/group/utils/GroupMemberParser.ts | 2 + .../parser/hotlooks/HotLooksParser.ts | 42 + .../src/messages/parser/hotlooks/index.ts | 1 + .../src/messages/parser/index.ts | 1 + .../quest/ActiveDailyTasksMessageParser.ts | 31 + .../messages/parser/quest/DailyTaskData.ts | 189 +++ .../quest/DailyTaskUpdatedMessageParser.ts | 53 + .../quest/QuestCancelledMessageParser.ts | 12 + .../messages/parser/quest/QuestMessageData.ts | 27 + .../RewardTrackClaimResultMessageParser.ts | 44 + .../messages/parser/quest/RewardTrackData.ts | 421 +++++++ ...TrackPremiumPurchaseResultMessageParser.ts | 44 + .../quest/RewardTrackProgressMessageParser.ts | 52 + .../parser/quest/RewardTracksMessageParser.ts | 49 + .../src/messages/parser/quest/index.ts | 8 + .../user/data/UserCurrentBadgesParser.ts | 24 + .../src/session/RoomSessionUserBadgesEvent.ts | 18 +- .../session/src/handler/RoomUsersHandler.ts | 2 +- protocol/packet-field-contracts.json | 1050 +++++++++++++---- 49 files changed, 2396 insertions(+), 245 deletions(-) create mode 100644 packages/communication/src/messages/incoming/hotlooks/HotLooksEvent.ts create mode 100644 packages/communication/src/messages/incoming/hotlooks/index.ts create mode 100644 packages/communication/src/messages/incoming/quest/ActiveDailyTasksMessageEvent.ts create mode 100644 packages/communication/src/messages/incoming/quest/DailyTaskUpdatedMessageEvent.ts create mode 100644 packages/communication/src/messages/incoming/quest/DailyTasksAddedMessageEvent.ts create mode 100644 packages/communication/src/messages/incoming/quest/RewardTrackClaimResultMessageEvent.ts create mode 100644 packages/communication/src/messages/incoming/quest/RewardTrackPremiumPurchaseResultMessageEvent.ts create mode 100644 packages/communication/src/messages/incoming/quest/RewardTrackProgressMessageEvent.ts create mode 100644 packages/communication/src/messages/incoming/quest/RewardTracksMessageEvent.ts create mode 100644 packages/communication/src/messages/outgoing/hotlooks/GetHotLooksComposer.ts create mode 100644 packages/communication/src/messages/outgoing/hotlooks/index.ts create mode 100644 packages/communication/src/messages/outgoing/quest/ClaimDailyTaskMessageComposer.ts create mode 100644 packages/communication/src/messages/outgoing/quest/ClaimRewardTrackPrizeMessageComposer.ts create mode 100644 packages/communication/src/messages/outgoing/quest/GetDailyTasksMessageComposer.ts create mode 100644 packages/communication/src/messages/outgoing/quest/GetRewardTracksMessageComposer.ts create mode 100644 packages/communication/src/messages/outgoing/quest/PurchaseRewardTrackPremiumMessageComposer.ts create mode 100644 packages/communication/src/messages/parser/game/snowwar/SnowWarStatsTail.test.ts create mode 100644 packages/communication/src/messages/parser/hotlooks/HotLooksParser.ts create mode 100644 packages/communication/src/messages/parser/hotlooks/index.ts create mode 100644 packages/communication/src/messages/parser/quest/ActiveDailyTasksMessageParser.ts create mode 100644 packages/communication/src/messages/parser/quest/DailyTaskData.ts create mode 100644 packages/communication/src/messages/parser/quest/DailyTaskUpdatedMessageParser.ts create mode 100644 packages/communication/src/messages/parser/quest/RewardTrackClaimResultMessageParser.ts create mode 100644 packages/communication/src/messages/parser/quest/RewardTrackData.ts create mode 100644 packages/communication/src/messages/parser/quest/RewardTrackPremiumPurchaseResultMessageParser.ts create mode 100644 packages/communication/src/messages/parser/quest/RewardTrackProgressMessageParser.ts create mode 100644 packages/communication/src/messages/parser/quest/RewardTracksMessageParser.ts diff --git a/packages/communication/src/OctaneMessages.ts b/packages/communication/src/OctaneMessages.ts index 4edd425d..e4a721d1 100644 --- a/packages/communication/src/OctaneMessages.ts +++ b/packages/communication/src/OctaneMessages.ts @@ -28,6 +28,7 @@ import { CatalogProductMetadataComposer } from './messages/outgoing/catalog/meta import { CatalogRuntimeConfigurationEvent } from './messages/incoming/catalog/configuration'; import { CatalogRuntimeConfigurationComposer } from './messages/outgoing/catalog/configuration'; import { RareValuesEvent, RequestRareValuesComposer } from './messages'; +import { GetHotLooksComposer, HotLooksEvent } from './messages'; import { WheelBuySpinComposer, WheelDataEvent, WheelOpenComposer, WheelRecentWinsEvent, WheelResultEvent, WheelSpinComposer } from './messages'; import { WheelAdminGetPrizesComposer, WheelAdminPrizesEvent, WheelAdminSavePrizesComposer } from './messages'; import { ChestDataEvent, ChestDepositComposer, ChestDepositFurniComposer, ChestDepositInventoryItemComposer, ChestFurniChunkEvent, ChestFurniDeltaEvent, ChestOpenComposer, ChestOpenEvent, ChestStartDepositComposer, ChestWithdrawAllFurniComposer, ChestWithdrawComposer, ChestWithdrawFurniComposer, ChestLogEvent, ChestCloseComposer, ChestEnableWiredComposer, ChestSaveOptionsComposer, ChestSaveSettingsComposer, ChestSaveNotificationsComposer, ChestUpgradeCapacityComposer, ChestNotificationEvent, ChestUpgradeResultEvent, ChestRequestLogComposer, WiredChestRoomLogsComposer, WiredChestLockComposer, WiredChestTransactionDetailsComposer, WiredChestRoomLogsEvent, WiredChestLockStateEvent, WiredChestTransactionDetailsEvent, WiredTradeOpenEvent, WiredTradeItemsEvent, WiredTradeCancelledEvent, WiredTradeCompletedEvent, WiredTradeOfferItemsComposer, WiredTradeAcceptComposer, WiredTradeCancelComposer } from './messages'; @@ -35,6 +36,7 @@ import { SoundboardCatalogEvent, SoundboardCatalogReorderComposer, SoundboardCat import { PressKeybindComposer } from './messages'; import { EarningsCenterEvent, EarningsClaimResultEvent, RequestEarningsCenterComposer, ClaimEarningsRewardComposer, ClaimAllEarningsRewardsComposer } from './messages'; import { DeleteMentionComposer, MarkMentionsReadComposer, MentionReceivedEvent, MentionsListEvent, RequestMentionsComposer } from './messages'; +import { ActiveDailyTasksMessageEvent, ClaimDailyTaskMessageComposer, ClaimRewardTrackPrizeMessageComposer, DailyTaskUpdatedMessageEvent, DailyTasksAddedMessageEvent, GetDailyTasksMessageComposer, GetRewardTracksMessageComposer, PurchaseRewardTrackPremiumMessageComposer, RewardTrackClaimResultMessageEvent, RewardTrackPremiumPurchaseResultMessageEvent, RewardTrackProgressMessageEvent, RewardTracksMessageEvent } from './messages'; export class OctaneMessages implements IMessageConfiguration { private _events: Map; @@ -455,6 +457,13 @@ export class OctaneMessages implements IMessageConfiguration this._events.set(IncomingHeader.COMMUNITY_GOAL_PROGRESS, CommunityGoalProgressMessageEvent); this._events.set(IncomingHeader.CONCURRENT_USERS_GOAL_PROGRESS, ConcurrentUsersGoalProgressMessageEvent); this._events.set(IncomingHeader.QUEST_DAILY, QuestDailyMessageEvent); + this._events.set(IncomingHeader.ACTIVE_DAILY_TASKS, ActiveDailyTasksMessageEvent); + this._events.set(IncomingHeader.DAILY_TASKS_ADDED, DailyTasksAddedMessageEvent); + this._events.set(IncomingHeader.DAILY_TASK_UPDATED, DailyTaskUpdatedMessageEvent); + this._events.set(IncomingHeader.REWARD_TRACKS, RewardTracksMessageEvent); + this._events.set(IncomingHeader.REWARD_TRACK_CLAIM_RESULT, RewardTrackClaimResultMessageEvent); + this._events.set(IncomingHeader.REWARD_TRACK_PROGRESS, RewardTrackProgressMessageEvent); + this._events.set(IncomingHeader.REWARD_TRACK_PREMIUM_PURCHASE_RESULT, RewardTrackPremiumPurchaseResultMessageEvent); this._events.set(IncomingHeader.QUEST_CANCELLED, QuestCancelledMessageEvent); this._events.set(IncomingHeader.QUEST_COMPLETED, QuestCompletedMessageEvent); this._events.set(IncomingHeader.COMMUNITY_GOAL_HALL_OF_FAME, CommunityGoalHallOfFameMessageEvent); @@ -604,6 +613,7 @@ export class OctaneMessages implements IMessageConfiguration // Custom features this._events.set(IncomingHeader.RARE_VALUES, RareValuesEvent); + this._events.set(IncomingHeader.HOT_LOOKS, HotLooksEvent); this._events.set(IncomingHeader.WHEEL_DATA, WheelDataEvent); this._events.set(IncomingHeader.WHEEL_RESULT, WheelResultEvent); this._events.set(IncomingHeader.WHEEL_RECENT_WINS, WheelRecentWinsEvent); @@ -1404,6 +1414,11 @@ export class OctaneMessages implements IMessageConfiguration this._composers.set(OutgoingHeader.GET_CONCURRENT_USERS_GOAL_PROGRESS, GetConcurrentUsersGoalProgressMessageComposer); this._composers.set(OutgoingHeader.GET_CONCURRENT_USERS_REWARD, GetConcurrentUsersRewardMessageComposer); this._composers.set(OutgoingHeader.GET_DAILY_QUEST, GetDailyQuestMessageComposer); + this._composers.set(OutgoingHeader.GET_DAILY_TASKS, GetDailyTasksMessageComposer); + this._composers.set(OutgoingHeader.CLAIM_DAILY_TASK, ClaimDailyTaskMessageComposer); + this._composers.set(OutgoingHeader.GET_REWARD_TRACKS, GetRewardTracksMessageComposer); + this._composers.set(OutgoingHeader.CLAIM_REWARD_TRACK_PRIZE, ClaimRewardTrackPrizeMessageComposer); + this._composers.set(OutgoingHeader.PURCHASE_REWARD_TRACK_PREMIUM, PurchaseRewardTrackPremiumMessageComposer); this._composers.set(OutgoingHeader.GET_QUESTS, GetQuestsMessageComposer); this._composers.set(OutgoingHeader.GET_SEASONAL_QUESTS_ONLY, GetSeasonalQuestsOnlyMessageComposer); this._composers.set(OutgoingHeader.OPEN_QUEST_TRACKER, OpenQuestTrackerMessageComposer); @@ -1500,6 +1515,7 @@ export class OctaneMessages implements IMessageConfiguration // Custom features this._composers.set(OutgoingHeader.REQUEST_RARE_VALUES, RequestRareValuesComposer); + this._composers.set(OutgoingHeader.GET_HOT_LOOKS, GetHotLooksComposer); this._composers.set(OutgoingHeader.WHEEL_OPEN, WheelOpenComposer); this._composers.set(OutgoingHeader.WHEEL_SPIN, WheelSpinComposer); this._composers.set(OutgoingHeader.WHEEL_BUY_SPIN, WheelBuySpinComposer); diff --git a/packages/communication/src/messages/incoming/IncomingHeader.ts b/packages/communication/src/messages/incoming/IncomingHeader.ts index b0c4dad6..4bf5cb42 100644 --- a/packages/communication/src/messages/incoming/IncomingHeader.ts +++ b/packages/communication/src/messages/incoming/IncomingHeader.ts @@ -366,6 +366,14 @@ export class IncomingHeader public static SEASONAL_QUESTS = 1122; public static QUESTS = 3625; public static QUEST = 230; + // AIR 13 daily tasks and reward track (server -> client); 9450-9452 replace the colliding official 2392/596/2142 + public static ACTIVE_DAILY_TASKS = 2900; + public static DAILY_TASKS_ADDED = 670; + public static DAILY_TASK_UPDATED = 9450; + public static REWARD_TRACKS = 2327; + public static REWARD_TRACK_CLAIM_RESULT = 9451; + public static REWARD_TRACK_PROGRESS = 9452; + public static REWARD_TRACK_PREMIUM_PURCHASE_RESULT = 2248; public static BONUS_RARE_INFO = 1533; public static CRAFTABLE_PRODUCTS = 1000; public static CRAFTING_RECIPE = 2774; @@ -602,4 +610,6 @@ export class IncomingHeader // Room unit habbicon broadcast (server -> client). 9410 to match emulator // RoomUserHabbiconComposer; 6011 is reserved by SnowWar SAVE_EDITOR. public static UNIT_HABBICON = 9410; + // AIR 13 avatar editor hot looks tab (server -> client). 9360 to match emulator HotLooksComposer. + public static HOT_LOOKS = 9360; } diff --git a/packages/communication/src/messages/incoming/hotlooks/HotLooksEvent.ts b/packages/communication/src/messages/incoming/hotlooks/HotLooksEvent.ts new file mode 100644 index 00000000..75ab7c20 --- /dev/null +++ b/packages/communication/src/messages/incoming/hotlooks/HotLooksEvent.ts @@ -0,0 +1,16 @@ +import { IMessageEvent } from '@octane/api'; +import { MessageEvent } from '@octane/events'; +import { HotLooksParser } from '../../parser'; + +export class HotLooksEvent extends MessageEvent implements IMessageEvent +{ + constructor(callBack: Function) + { + super(callBack, HotLooksParser); + } + + public getParser(): HotLooksParser + { + return this.parser as HotLooksParser; + } +} diff --git a/packages/communication/src/messages/incoming/hotlooks/index.ts b/packages/communication/src/messages/incoming/hotlooks/index.ts new file mode 100644 index 00000000..0352b685 --- /dev/null +++ b/packages/communication/src/messages/incoming/hotlooks/index.ts @@ -0,0 +1 @@ +export * from './HotLooksEvent'; diff --git a/packages/communication/src/messages/incoming/index.ts b/packages/communication/src/messages/incoming/index.ts index 2b7d6854..021174a5 100644 --- a/packages/communication/src/messages/incoming/index.ts +++ b/packages/communication/src/messages/incoming/index.ts @@ -29,6 +29,7 @@ export * from './handshake'; export * from './help'; export * from './housekeeping'; export * from './rarevalues'; +export * from './hotlooks'; export * from './soundboard'; export * from './traxeditor'; export * from './wheel'; diff --git a/packages/communication/src/messages/incoming/quest/ActiveDailyTasksMessageEvent.ts b/packages/communication/src/messages/incoming/quest/ActiveDailyTasksMessageEvent.ts new file mode 100644 index 00000000..7d7cc4d4 --- /dev/null +++ b/packages/communication/src/messages/incoming/quest/ActiveDailyTasksMessageEvent.ts @@ -0,0 +1,16 @@ +import { IMessageEvent } from '@octane/api'; +import { MessageEvent } from '@octane/events'; +import { ActiveDailyTasksMessageParser } from '../../parser'; + +export class ActiveDailyTasksMessageEvent extends MessageEvent implements IMessageEvent +{ + constructor(callBack: Function) + { + super(callBack, ActiveDailyTasksMessageParser); + } + + public getParser(): ActiveDailyTasksMessageParser + { + return this.parser as ActiveDailyTasksMessageParser; + } +} diff --git a/packages/communication/src/messages/incoming/quest/DailyTaskUpdatedMessageEvent.ts b/packages/communication/src/messages/incoming/quest/DailyTaskUpdatedMessageEvent.ts new file mode 100644 index 00000000..36b9af99 --- /dev/null +++ b/packages/communication/src/messages/incoming/quest/DailyTaskUpdatedMessageEvent.ts @@ -0,0 +1,16 @@ +import { IMessageEvent } from '@octane/api'; +import { MessageEvent } from '@octane/events'; +import { DailyTaskUpdatedMessageParser } from '../../parser'; + +export class DailyTaskUpdatedMessageEvent extends MessageEvent implements IMessageEvent +{ + constructor(callBack: Function) + { + super(callBack, DailyTaskUpdatedMessageParser); + } + + public getParser(): DailyTaskUpdatedMessageParser + { + return this.parser as DailyTaskUpdatedMessageParser; + } +} diff --git a/packages/communication/src/messages/incoming/quest/DailyTasksAddedMessageEvent.ts b/packages/communication/src/messages/incoming/quest/DailyTasksAddedMessageEvent.ts new file mode 100644 index 00000000..2cc712fb --- /dev/null +++ b/packages/communication/src/messages/incoming/quest/DailyTasksAddedMessageEvent.ts @@ -0,0 +1,16 @@ +import { IMessageEvent } from '@octane/api'; +import { MessageEvent } from '@octane/events'; +import { ActiveDailyTasksMessageParser } from '../../parser'; + +export class DailyTasksAddedMessageEvent extends MessageEvent implements IMessageEvent +{ + constructor(callBack: Function) + { + super(callBack, ActiveDailyTasksMessageParser); + } + + public getParser(): ActiveDailyTasksMessageParser + { + return this.parser as ActiveDailyTasksMessageParser; + } +} diff --git a/packages/communication/src/messages/incoming/quest/RewardTrackClaimResultMessageEvent.ts b/packages/communication/src/messages/incoming/quest/RewardTrackClaimResultMessageEvent.ts new file mode 100644 index 00000000..3a23edb2 --- /dev/null +++ b/packages/communication/src/messages/incoming/quest/RewardTrackClaimResultMessageEvent.ts @@ -0,0 +1,16 @@ +import { IMessageEvent } from '@octane/api'; +import { MessageEvent } from '@octane/events'; +import { RewardTrackClaimResultMessageParser } from '../../parser'; + +export class RewardTrackClaimResultMessageEvent extends MessageEvent implements IMessageEvent +{ + constructor(callBack: Function) + { + super(callBack, RewardTrackClaimResultMessageParser); + } + + public getParser(): RewardTrackClaimResultMessageParser + { + return this.parser as RewardTrackClaimResultMessageParser; + } +} diff --git a/packages/communication/src/messages/incoming/quest/RewardTrackPremiumPurchaseResultMessageEvent.ts b/packages/communication/src/messages/incoming/quest/RewardTrackPremiumPurchaseResultMessageEvent.ts new file mode 100644 index 00000000..af3b62c0 --- /dev/null +++ b/packages/communication/src/messages/incoming/quest/RewardTrackPremiumPurchaseResultMessageEvent.ts @@ -0,0 +1,16 @@ +import { IMessageEvent } from '@octane/api'; +import { MessageEvent } from '@octane/events'; +import { RewardTrackPremiumPurchaseResultMessageParser } from '../../parser'; + +export class RewardTrackPremiumPurchaseResultMessageEvent extends MessageEvent implements IMessageEvent +{ + constructor(callBack: Function) + { + super(callBack, RewardTrackPremiumPurchaseResultMessageParser); + } + + public getParser(): RewardTrackPremiumPurchaseResultMessageParser + { + return this.parser as RewardTrackPremiumPurchaseResultMessageParser; + } +} diff --git a/packages/communication/src/messages/incoming/quest/RewardTrackProgressMessageEvent.ts b/packages/communication/src/messages/incoming/quest/RewardTrackProgressMessageEvent.ts new file mode 100644 index 00000000..32683ba1 --- /dev/null +++ b/packages/communication/src/messages/incoming/quest/RewardTrackProgressMessageEvent.ts @@ -0,0 +1,16 @@ +import { IMessageEvent } from '@octane/api'; +import { MessageEvent } from '@octane/events'; +import { RewardTrackProgressMessageParser } from '../../parser'; + +export class RewardTrackProgressMessageEvent extends MessageEvent implements IMessageEvent +{ + constructor(callBack: Function) + { + super(callBack, RewardTrackProgressMessageParser); + } + + public getParser(): RewardTrackProgressMessageParser + { + return this.parser as RewardTrackProgressMessageParser; + } +} diff --git a/packages/communication/src/messages/incoming/quest/RewardTracksMessageEvent.ts b/packages/communication/src/messages/incoming/quest/RewardTracksMessageEvent.ts new file mode 100644 index 00000000..f8ea3d6c --- /dev/null +++ b/packages/communication/src/messages/incoming/quest/RewardTracksMessageEvent.ts @@ -0,0 +1,16 @@ +import { IMessageEvent } from '@octane/api'; +import { MessageEvent } from '@octane/events'; +import { RewardTracksMessageParser } from '../../parser'; + +export class RewardTracksMessageEvent extends MessageEvent implements IMessageEvent +{ + constructor(callBack: Function) + { + super(callBack, RewardTracksMessageParser); + } + + public getParser(): RewardTracksMessageParser + { + return this.parser as RewardTracksMessageParser; + } +} diff --git a/packages/communication/src/messages/incoming/quest/index.ts b/packages/communication/src/messages/incoming/quest/index.ts index f2b87077..b6c1c6b5 100644 --- a/packages/communication/src/messages/incoming/quest/index.ts +++ b/packages/communication/src/messages/incoming/quest/index.ts @@ -9,3 +9,10 @@ export * from './QuestDailyMessageEvent'; export * from './QuestMessageEvent'; export * from './QuestsMessageEvent'; export * from './SeasonalQuestsMessageEvent'; +export * from './ActiveDailyTasksMessageEvent'; +export * from './DailyTasksAddedMessageEvent'; +export * from './DailyTaskUpdatedMessageEvent'; +export * from './RewardTracksMessageEvent'; +export * from './RewardTrackClaimResultMessageEvent'; +export * from './RewardTrackProgressMessageEvent'; +export * from './RewardTrackPremiumPurchaseResultMessageEvent'; diff --git a/packages/communication/src/messages/outgoing/OutgoingHeader.ts b/packages/communication/src/messages/outgoing/OutgoingHeader.ts index a03e59bb..5a447ae7 100644 --- a/packages/communication/src/messages/outgoing/OutgoingHeader.ts +++ b/packages/communication/src/messages/outgoing/OutgoingHeader.ts @@ -400,6 +400,12 @@ export class OutgoingHeader public static REDEEM_COMMUNITY_GOAL_PRIZE = 90; public static REJECT_QUEST = 2397; public static START_CAMPAIGN = 1697; + // AIR 13 daily tasks and reward track (client -> server); GET_REWARD_TRACKS is custom, the official client is pushed + public static GET_DAILY_TASKS = 4100; + public static CLAIM_DAILY_TASK = 4101; + public static GET_REWARD_TRACKS = 9450; + public static CLAIM_REWARD_TRACK_PRIZE = 1111; + public static PURCHASE_REWARD_TRACK_PREMIUM = 3022; public static GET_BONUS_RARE_INFO = 957; public static CRAFT = 3591; public static CRAFT_SECRET = 1251; @@ -675,4 +681,6 @@ export class OutgoingHeader // Use habbicon (client -> server). 9417 to match emulator RoomUserHabbiconEvent; // 6010 is reserved by SnowWar EDIT_ROOM. public static USE_HABBICON = 9417; + // AIR 13 avatar editor hot looks tab (client -> server). 9360 to match emulator GetHotLooksEvent. + public static GET_HOT_LOOKS = 9360; } diff --git a/packages/communication/src/messages/outgoing/group/GroupRemoveMemberComposer.ts b/packages/communication/src/messages/outgoing/group/GroupRemoveMemberComposer.ts index 001423c8..492719a7 100644 --- a/packages/communication/src/messages/outgoing/group/GroupRemoveMemberComposer.ts +++ b/packages/communication/src/messages/outgoing/group/GroupRemoveMemberComposer.ts @@ -4,9 +4,10 @@ export class GroupRemoveMemberComposer implements IMessageComposer; - constructor(groupId: number, memberId: number) + // AIR 13 KickMember: `block` true kicks and blocks the member in one message. + constructor(groupId: number, memberId: number, block: boolean = false) { - this._data = [groupId, memberId]; + this._data = [groupId, memberId, block]; } public getMessageArray() diff --git a/packages/communication/src/messages/outgoing/hotlooks/GetHotLooksComposer.ts b/packages/communication/src/messages/outgoing/hotlooks/GetHotLooksComposer.ts new file mode 100644 index 00000000..d6e45fd3 --- /dev/null +++ b/packages/communication/src/messages/outgoing/hotlooks/GetHotLooksComposer.ts @@ -0,0 +1,25 @@ +import { IMessageComposer } from '@octane/api'; + +/** + * AIR 13 GetHotLooks: the avatar editor asks for up to `count` ready-made looks of the user's gender + * (the official client always sends 20). + */ +export class GetHotLooksComposer implements IMessageComposer> +{ + private _data: ConstructorParameters; + + constructor(count: number = 20) + { + this._data = [count]; + } + + public getMessageArray() + { + return this._data; + } + + public dispose(): void + { + return; + } +} diff --git a/packages/communication/src/messages/outgoing/hotlooks/index.ts b/packages/communication/src/messages/outgoing/hotlooks/index.ts new file mode 100644 index 00000000..0c87da8b --- /dev/null +++ b/packages/communication/src/messages/outgoing/hotlooks/index.ts @@ -0,0 +1 @@ +export * from './GetHotLooksComposer'; diff --git a/packages/communication/src/messages/outgoing/index.ts b/packages/communication/src/messages/outgoing/index.ts index 66e70a11..addbd54b 100644 --- a/packages/communication/src/messages/outgoing/index.ts +++ b/packages/communication/src/messages/outgoing/index.ts @@ -25,6 +25,7 @@ export * from './handshake'; export * from './help'; export * from './housekeeping'; export * from './rarevalues'; +export * from './hotlooks'; export * from './soundboard'; export * from './traxeditor'; export * from './wheel'; diff --git a/packages/communication/src/messages/outgoing/quest/ClaimDailyTaskMessageComposer.ts b/packages/communication/src/messages/outgoing/quest/ClaimDailyTaskMessageComposer.ts new file mode 100644 index 00000000..24724224 --- /dev/null +++ b/packages/communication/src/messages/outgoing/quest/ClaimDailyTaskMessageComposer.ts @@ -0,0 +1,21 @@ +import { IMessageComposer } from '@octane/api'; + +export class ClaimDailyTaskMessageComposer implements IMessageComposer> +{ + private _data: ConstructorParameters; + + constructor(taskId: number) + { + this._data = [taskId]; + } + + public getMessageArray() + { + return this._data; + } + + public dispose(): void + { + return; + } +} diff --git a/packages/communication/src/messages/outgoing/quest/ClaimRewardTrackPrizeMessageComposer.ts b/packages/communication/src/messages/outgoing/quest/ClaimRewardTrackPrizeMessageComposer.ts new file mode 100644 index 00000000..ef32153a --- /dev/null +++ b/packages/communication/src/messages/outgoing/quest/ClaimRewardTrackPrizeMessageComposer.ts @@ -0,0 +1,21 @@ +import { IMessageComposer } from '@octane/api'; + +export class ClaimRewardTrackPrizeMessageComposer implements IMessageComposer> +{ + private _data: ConstructorParameters; + + constructor(trackId: string, rewardId: string) + { + this._data = [trackId, rewardId]; + } + + public getMessageArray() + { + return this._data; + } + + public dispose(): void + { + return; + } +} diff --git a/packages/communication/src/messages/outgoing/quest/GetDailyTasksMessageComposer.ts b/packages/communication/src/messages/outgoing/quest/GetDailyTasksMessageComposer.ts new file mode 100644 index 00000000..77a9cd01 --- /dev/null +++ b/packages/communication/src/messages/outgoing/quest/GetDailyTasksMessageComposer.ts @@ -0,0 +1,21 @@ +import { IMessageComposer } from '@octane/api'; + +export class GetDailyTasksMessageComposer implements IMessageComposer> +{ + private _data: ConstructorParameters; + + constructor() + { + this._data = []; + } + + public getMessageArray() + { + return this._data; + } + + public dispose(): void + { + return; + } +} diff --git a/packages/communication/src/messages/outgoing/quest/GetRewardTracksMessageComposer.ts b/packages/communication/src/messages/outgoing/quest/GetRewardTracksMessageComposer.ts new file mode 100644 index 00000000..c10610e8 --- /dev/null +++ b/packages/communication/src/messages/outgoing/quest/GetRewardTracksMessageComposer.ts @@ -0,0 +1,21 @@ +import { IMessageComposer } from '@octane/api'; + +export class GetRewardTracksMessageComposer implements IMessageComposer> +{ + private _data: ConstructorParameters; + + constructor() + { + this._data = []; + } + + public getMessageArray() + { + return this._data; + } + + public dispose(): void + { + return; + } +} diff --git a/packages/communication/src/messages/outgoing/quest/PurchaseRewardTrackPremiumMessageComposer.ts b/packages/communication/src/messages/outgoing/quest/PurchaseRewardTrackPremiumMessageComposer.ts new file mode 100644 index 00000000..ea2125a9 --- /dev/null +++ b/packages/communication/src/messages/outgoing/quest/PurchaseRewardTrackPremiumMessageComposer.ts @@ -0,0 +1,21 @@ +import { IMessageComposer } from '@octane/api'; + +export class PurchaseRewardTrackPremiumMessageComposer implements IMessageComposer> +{ + private _data: ConstructorParameters; + + constructor(trackId: string) + { + this._data = [trackId]; + } + + public getMessageArray() + { + return this._data; + } + + public dispose(): void + { + return; + } +} diff --git a/packages/communication/src/messages/outgoing/quest/RejectQuestMessageComposer.ts b/packages/communication/src/messages/outgoing/quest/RejectQuestMessageComposer.ts index a498991f..e6cef6d4 100644 --- a/packages/communication/src/messages/outgoing/quest/RejectQuestMessageComposer.ts +++ b/packages/communication/src/messages/outgoing/quest/RejectQuestMessageComposer.ts @@ -4,9 +4,9 @@ export class RejectQuestMessageComposer implements IMessageComposer; - constructor() + constructor(questId: number) { - this._data = []; + this._data = [questId]; } public getMessageArray() diff --git a/packages/communication/src/messages/outgoing/quest/index.ts b/packages/communication/src/messages/outgoing/quest/index.ts index e9f5c992..6b0ecd87 100644 --- a/packages/communication/src/messages/outgoing/quest/index.ts +++ b/packages/communication/src/messages/outgoing/quest/index.ts @@ -14,3 +14,8 @@ export * from './OpenQuestTrackerMessageComposer'; export * from './RedeemCommunityGoalPrizeMessageComposer'; export * from './RejectQuestMessageComposer'; export * from './StartCampaignMessageComposer'; +export * from './GetDailyTasksMessageComposer'; +export * from './ClaimDailyTaskMessageComposer'; +export * from './GetRewardTracksMessageComposer'; +export * from './ClaimRewardTrackPrizeMessageComposer'; +export * from './PurchaseRewardTrackPremiumMessageComposer'; diff --git a/packages/communication/src/messages/parser/game/snowwar/SnowWarGameResultPlayerData.ts b/packages/communication/src/messages/parser/game/snowwar/SnowWarGameResultPlayerData.ts index c167842a..55e9c114 100644 --- a/packages/communication/src/messages/parser/game/snowwar/SnowWarGameResultPlayerData.ts +++ b/packages/communication/src/messages/parser/game/snowwar/SnowWarGameResultPlayerData.ts @@ -1,10 +1,22 @@ import { IMessageDataWrapper } from '@octane/api'; +/** + * One player row of the results screen. The base record is (userId, name, + * score); the official Game2TeamPlayerData extras (figure, gender and the + * Game2PlayerStatsData hits / kills plus the lobby skill level) arrive in the + * optional stats tail of SnowWarOnGameEndingParser and are merged in by + * applyStats, so they stay undefined against older servers. + */ export class SnowWarGameResultPlayerData { private _userId: number; private _name: string; private _score: number; + private _figure: string = undefined; + private _gender: string = undefined; + private _snowballHits: number = undefined; + private _kills: number = undefined; + private _skillLevel: number = undefined; constructor(wrapper: IMessageDataWrapper) { @@ -13,6 +25,15 @@ export class SnowWarGameResultPlayerData this._score = wrapper.readInt(); } + public applyStats(figure: string, gender: string, snowballHits: number, kills: number, skillLevel: number): void + { + this._figure = figure; + this._gender = gender; + this._snowballHits = snowballHits; + this._kills = kills; + this._skillLevel = skillLevel; + } + public get userId(): number { return this._userId; @@ -27,4 +48,30 @@ export class SnowWarGameResultPlayerData { return this._score; } + + public get figure(): string + { + return this._figure; + } + + public get gender(): string + { + return this._gender; + } + + public get snowballHits(): number + { + return this._snowballHits; + } + + public get kills(): number + { + return this._kills; + } + + /** AIR GameLobbyPlayerData.skillLevel (1-30). */ + public get skillLevel(): number + { + return this._skillLevel; + } } diff --git a/packages/communication/src/messages/parser/game/snowwar/SnowWarLobbyTeamsParser.ts b/packages/communication/src/messages/parser/game/snowwar/SnowWarLobbyTeamsParser.ts index e7a3167c..3ef2f296 100644 --- a/packages/communication/src/messages/parser/game/snowwar/SnowWarLobbyTeamsParser.ts +++ b/packages/communication/src/messages/parser/game/snowwar/SnowWarLobbyTeamsParser.ts @@ -13,7 +13,8 @@ export interface ISnowWarArenaData * can show the waiting players split into their teams (Red / Blue) before the * arena splash. Player records use the SnowWarPlayerData wire shape (objectId * is 0 here - the real match, and its object ids, only exists once the - * countdown ends). + * countdown ends). The AIR GameLobbyPlayerData skill level follows the arena + * list as an optional tail (count, [userId, skillLevel]) merged into players. */ export class SnowWarLobbyTeamsParser implements IMessageParser { @@ -67,6 +68,23 @@ export class SnowWarLobbyTeamsParser implements IMessageParser totalArenas--; } + if(!wrapper.bytesAvailable) return true; + + let totalSkillLevels = wrapper.readInt(); + + while(totalSkillLevels > 0) + { + const userId = wrapper.readInt(); + const skillLevel = wrapper.readInt(); + + for(const player of this._players) + { + if(player.userId === userId) player.skillLevel = skillLevel; + } + + totalSkillLevels--; + } + return true; } diff --git a/packages/communication/src/messages/parser/game/snowwar/SnowWarOnGameEndingParser.ts b/packages/communication/src/messages/parser/game/snowwar/SnowWarOnGameEndingParser.ts index 01455eed..6c580a15 100644 --- a/packages/communication/src/messages/parser/game/snowwar/SnowWarOnGameEndingParser.ts +++ b/packages/communication/src/messages/parser/game/snowwar/SnowWarOnGameEndingParser.ts @@ -1,15 +1,28 @@ import { IMessageDataWrapper, IMessageParser } from '@octane/api'; import { SnowWarGameResultTeamData } from './SnowWarGameResultTeamData'; +/** + * Final results (5022): seconds until the results screen closes and the + * teams with their players. The official Game2GameEnding extras follow as an + * optional tail so older servers still parse: playerWithMostHits, + * playerWithMostKills and per player (userId, figure, gender, snowballHits, + * kills, skillLevel) merged into the matching team player rows. + */ export class SnowWarOnGameEndingParser implements IMessageParser { private _secondsToResults: number; private _teams: SnowWarGameResultTeamData[]; + private _playerWithMostHits: number; + private _playerWithMostKills: number; + private _hasPlayerStats: boolean; public flush(): boolean { this._secondsToResults = 0; this._teams = []; + this._playerWithMostHits = 0; + this._playerWithMostKills = 0; + this._hasPlayerStats = false; return true; } @@ -29,6 +42,35 @@ export class SnowWarOnGameEndingParser implements IMessageParser totalTeams--; } + // Optional Game2GameEnding stats tail (AIR Game2SnowWarGameStats + Game2PlayerStatsData subset). + if(!wrapper.bytesAvailable) return true; + + this._playerWithMostHits = wrapper.readInt(); + this._playerWithMostKills = wrapper.readInt(); + this._hasPlayerStats = true; + + let totalStats = wrapper.readInt(); + + while(totalStats > 0) + { + const userId = wrapper.readInt(); + const figure = wrapper.readString(); + const gender = wrapper.readString(); + const snowballHits = wrapper.readInt(); + const kills = wrapper.readInt(); + const skillLevel = wrapper.readInt(); + + for(const team of this._teams) + { + for(const player of team.players) + { + if(player.userId === userId) player.applyStats(figure, gender, snowballHits, kills, skillLevel); + } + } + + totalStats--; + } + return true; } @@ -41,4 +83,21 @@ export class SnowWarOnGameEndingParser implements IMessageParser { return this._teams; } + + /** User id of the player with the most snowball hits (0 when the server sent no stats). */ + public get playerWithMostHits(): number + { + return this._playerWithMostHits; + } + + /** User id of the player with the most knock-outs (0 when the server sent no stats). */ + public get playerWithMostKills(): number + { + return this._playerWithMostKills; + } + + public get hasPlayerStats(): boolean + { + return this._hasPlayerStats; + } } diff --git a/packages/communication/src/messages/parser/game/snowwar/SnowWarPlayerData.ts b/packages/communication/src/messages/parser/game/snowwar/SnowWarPlayerData.ts index 63b0847f..84e1a4ef 100644 --- a/packages/communication/src/messages/parser/game/snowwar/SnowWarPlayerData.ts +++ b/packages/communication/src/messages/parser/game/snowwar/SnowWarPlayerData.ts @@ -8,6 +8,7 @@ export class SnowWarPlayerData private _name: string; private _figure: string; private _gender: string; + private _skillLevel: number = undefined; constructor(wrapper: IMessageDataWrapper) { @@ -48,4 +49,15 @@ export class SnowWarPlayerData { return this._gender; } + + /** AIR GameLobbyPlayerData.skillLevel (1-30); undefined when the server did not send it. */ + public get skillLevel(): number + { + return this._skillLevel; + } + + public set skillLevel(value: number) + { + this._skillLevel = value; + } } diff --git a/packages/communication/src/messages/parser/game/snowwar/SnowWarStatsTail.test.ts b/packages/communication/src/messages/parser/game/snowwar/SnowWarStatsTail.test.ts new file mode 100644 index 00000000..ef96e558 --- /dev/null +++ b/packages/communication/src/messages/parser/game/snowwar/SnowWarStatsTail.test.ts @@ -0,0 +1,116 @@ +import { IMessageDataWrapper } from '@octane/api'; +import { describe, expect, it } from 'vitest'; +import { UserCurrentBadgesParser } from '../../user/data/UserCurrentBadgesParser'; +import { SnowWarLobbyTeamsParser } from './SnowWarLobbyTeamsParser'; +import { SnowWarOnGameEndingParser } from './SnowWarOnGameEndingParser'; + +type Value = number | string | boolean; + +const wrapperOf = (values: Value[]): IMessageDataWrapper => +{ + let index = 0; + const next = () => values[index++]; + + return { + readByte: () => next() as number, + readBytes: () => null, + readBoolean: () => next() as boolean, + readShort: () => next() as number, + readInt: () => next() as number, + readFloat: () => next() as number, + readDouble: () => next() as number, + readString: () => next() as string, + header: 0, + get bytesAvailable() + { + return index < values.length; + } + }; +}; + +describe('SnowWarOnGameEndingParser', () => +{ + const teams: Value[] = [ 5, 2, 0, 12, 1, 7, 'Red', 12, 1, 3, 1, 9, 'Blue', 3 ]; + + it('parses the legacy team block without the stats tail', () => + { + const parser = new SnowWarOnGameEndingParser(); + parser.flush(); + + expect(parser.parse(wrapperOf(teams))).toBe(true); + expect(parser.secondsToResults).toBe(5); + expect(parser.teams.map(team => team.score)).toEqual([ 12, 3 ]); + expect(parser.hasPlayerStats).toBe(false); + expect(parser.playerWithMostHits).toBe(0); + expect(parser.teams[0].players[0].figure).toBeUndefined(); + expect(parser.teams[0].players[0].skillLevel).toBeUndefined(); + }); + + it('merges the official Game2GameEnding stats tail into the player rows', () => + { + const parser = new SnowWarOnGameEndingParser(); + parser.flush(); + + const tail: Value[] = [ 7, 9, 2, 9, 'hd-180-1', 'M', 1, 0, 4, 7, 'hr-100-61', 'F', 6, 2, 12 ]; + + expect(parser.parse(wrapperOf([ ...teams, ...tail ]))).toBe(true); + expect(parser.hasPlayerStats).toBe(true); + expect(parser.playerWithMostHits).toBe(7); + expect(parser.playerWithMostKills).toBe(9); + + const red = parser.teams[0].players[0]; + expect(red.userId).toBe(7); + expect(red.figure).toBe('hr-100-61'); + expect(red.gender).toBe('F'); + expect(red.snowballHits).toBe(6); + expect(red.kills).toBe(2); + expect(red.skillLevel).toBe(12); + + const blue = parser.teams[1].players[0]; + expect(blue.figure).toBe('hd-180-1'); + expect(blue.snowballHits).toBe(1); + expect(blue.kills).toBe(0); + expect(blue.skillLevel).toBe(4); + }); +}); + +describe('SnowWarLobbyTeamsParser', () => +{ + const base: Value[] = [ 2, 2, 0, 7, 0, 'Red', 'hr-1', 'F', 0, 9, 1, 'Blue', 'hd-2', 'M', 7, 1, 1, 1, 'Arctic', true ]; + + it('keeps the skill level undefined without the tail', () => + { + const parser = new SnowWarLobbyTeamsParser(); + parser.flush(); + + expect(parser.parse(wrapperOf(base))).toBe(true); + expect(parser.players.map(player => player.skillLevel)).toEqual([ undefined, undefined ]); + }); + + it('reads the skill level tail into the matching players', () => + { + const parser = new SnowWarLobbyTeamsParser(); + parser.flush(); + + expect(parser.parse(wrapperOf([ ...base, 2, 9, 3, 7, 15 ]))).toBe(true); + expect(parser.players.find(player => player.userId === 7).skillLevel).toBe(15); + expect(parser.players.find(player => player.userId === 9).skillLevel).toBe(3); + }); +}); + +describe('UserCurrentBadgesParser', () => +{ + it('reads the official (slot, code, ownerCount, rarity) layout', () => + { + const parser = new UserCurrentBadgesParser(); + parser.flush(); + + expect(parser.parse(wrapperOf([ 42, 2, 1, 'ACH_Login1', 120, 0, 2, 'HWAY1', 3, 4 ]))).toBe(true); + expect(parser.userId).toBe(42); + expect(parser.badges).toEqual([ 'ACH_Login1', 'HWAY1' ]); + expect(parser.badgeDetails).toEqual([ + { slotId: 1, badgeCode: 'ACH_Login1', ownerCount: 120, badgeRarityId: 0 }, + { slotId: 2, badgeCode: 'HWAY1', ownerCount: 3, badgeRarityId: 4 } + ]); + }); +}); diff --git a/packages/communication/src/messages/parser/group/utils/GroupMemberParser.ts b/packages/communication/src/messages/parser/group/utils/GroupMemberParser.ts index cf8317ba..920f7f2c 100644 --- a/packages/communication/src/messages/parser/group/utils/GroupMemberParser.ts +++ b/packages/communication/src/messages/parser/group/utils/GroupMemberParser.ts @@ -7,6 +7,8 @@ export class GroupRank public static readonly MEMBER: number = 2; public static readonly REQUESTED: number = 3; public static readonly DELETED: number = 4; + /** AIR 13: a member blocked by a group admin (same rank value the official client calls `blocked`). */ + public static readonly BLOCKED: number = 4; } export class GroupMemberParser diff --git a/packages/communication/src/messages/parser/hotlooks/HotLooksParser.ts b/packages/communication/src/messages/parser/hotlooks/HotLooksParser.ts new file mode 100644 index 00000000..28db3400 --- /dev/null +++ b/packages/communication/src/messages/parser/hotlooks/HotLooksParser.ts @@ -0,0 +1,42 @@ +import { IMessageDataWrapper, IMessageParser } from '@octane/api'; + +/** One ready-made outfit of the avatar editor "hot looks" tab (AIR 13 HotLookInfo). */ +export interface IHotLookInfo +{ + gender: string; + figureString: string; +} + +export class HotLooksParser implements IMessageParser +{ + private _hotLooks: IHotLookInfo[] = []; + + public flush(): boolean + { + this._hotLooks = []; + + return true; + } + + public parse(wrapper: IMessageDataWrapper): boolean + { + if(!wrapper) return false; + + const count = wrapper.readInt(); + + for(let i = 0; i < count; i++) + { + const gender = wrapper.readString(); + const figureString = wrapper.readString(); + + this._hotLooks.push({ gender, figureString }); + } + + return true; + } + + public get hotLooks(): IHotLookInfo[] + { + return this._hotLooks; + } +} diff --git a/packages/communication/src/messages/parser/hotlooks/index.ts b/packages/communication/src/messages/parser/hotlooks/index.ts new file mode 100644 index 00000000..cf78d0cd --- /dev/null +++ b/packages/communication/src/messages/parser/hotlooks/index.ts @@ -0,0 +1 @@ +export * from './HotLooksParser'; diff --git a/packages/communication/src/messages/parser/index.ts b/packages/communication/src/messages/parser/index.ts index fd21a2fa..dbd19a79 100644 --- a/packages/communication/src/messages/parser/index.ts +++ b/packages/communication/src/messages/parser/index.ts @@ -28,6 +28,7 @@ export * from './handshake'; export * from './help'; export * from './housekeeping'; export * from './rarevalues'; +export * from './hotlooks'; export * from './soundboard'; export * from './traxeditor'; export * from './wheel'; diff --git a/packages/communication/src/messages/parser/quest/ActiveDailyTasksMessageParser.ts b/packages/communication/src/messages/parser/quest/ActiveDailyTasksMessageParser.ts new file mode 100644 index 00000000..a9651986 --- /dev/null +++ b/packages/communication/src/messages/parser/quest/ActiveDailyTasksMessageParser.ts @@ -0,0 +1,31 @@ +import { IMessageDataWrapper, IMessageParser } from '@octane/api'; +import { DailyTaskData } from './DailyTaskData'; + +/** ActiveDailyTasks (2900) and DailyTasksAdded (670): a list of daily tasks. */ +export class ActiveDailyTasksMessageParser implements IMessageParser +{ + private _tasks: DailyTaskData[]; + + public flush(): boolean + { + this._tasks = []; + + return true; + } + + public parse(wrapper: IMessageDataWrapper): boolean + { + if(!wrapper) return false; + + const count = wrapper.readInt(); + + for(let i = 0; i < count; i++) this._tasks.push(new DailyTaskData(wrapper)); + + return true; + } + + public get tasks(): DailyTaskData[] + { + return this._tasks; + } +} diff --git a/packages/communication/src/messages/parser/quest/DailyTaskData.ts b/packages/communication/src/messages/parser/quest/DailyTaskData.ts new file mode 100644 index 00000000..111666b5 --- /dev/null +++ b/packages/communication/src/messages/parser/quest/DailyTaskData.ts @@ -0,0 +1,189 @@ +import { IMessageDataWrapper } from '@octane/api'; + +/** One reward of a daily task (AIR 13 class_4155). */ +export class DailyTaskRewardData +{ + private _productItemTypeId: number; + private _rewardTypeId: string; + private _extraParams: string; + private _amount: number; + + constructor(wrapper: IMessageDataWrapper) + { + this._productItemTypeId = wrapper.readShort(); + this._rewardTypeId = wrapper.readString(); + this._extraParams = wrapper.readString(); + this._amount = wrapper.readInt(); + } + + public get productItemTypeId(): number + { + return this._productItemTypeId; + } + + public get rewardTypeId(): string + { + return this._rewardTypeId; + } + + public get extraParams(): string + { + return this._extraParams; + } + + public get amount(): number + { + return this._amount; + } +} + +/** A daily task of the "daily reward" window (AIR 13 class_2786). */ +export class DailyTaskData +{ + public static readonly STATUS_IN_PROGRESS = 0; + public static readonly STATUS_COMPLETED = 1; + public static readonly STATUS_CLAIMED = 2; + + private _taskId: number; + private _taskCode: string; + private _questTypeCode: string; + private _isBonus: boolean; + private _imageVersion: string; + private _catalogName: string; + private _requiredRepeats: number; + private _repeats: number; + private _status: number; + private _secondsLeft: number; + private _receiveTime: Date; + private _rewards: DailyTaskRewardData[]; + + constructor(wrapper: IMessageDataWrapper) + { + this._receiveTime = new Date(); + this._taskId = DailyTaskData.readLong(wrapper); + this._taskCode = wrapper.readString(); + this._questTypeCode = wrapper.readString(); + this._isBonus = wrapper.readBoolean(); + this._imageVersion = wrapper.readString(); + this._catalogName = wrapper.readString(); + this._requiredRepeats = wrapper.readInt(); + this._repeats = wrapper.readInt(); + this._status = wrapper.readByte(); + this._secondsLeft = wrapper.readInt(); + this._rewards = []; + + const rewardCount = wrapper.readInt(); + + for(let i = 0; i < rewardCount; i++) this._rewards.push(new DailyTaskRewardData(wrapper)); + } + + /** The official task id travels as a 64-bit integer, two big-endian ints on the wire. */ + public static readLong(wrapper: IMessageDataWrapper): number + { + const high = wrapper.readInt(); + const low = wrapper.readInt(); + + return (high * 0x100000000) + (low >>> 0); + } + + public get taskId(): number + { + return this._taskId; + } + + public get taskCode(): string + { + return this._taskCode; + } + + public get questTypeCode(): string + { + return this._questTypeCode; + } + + public get isBonus(): boolean + { + return this._isBonus; + } + + public get imageVersion(): string + { + return this._imageVersion; + } + + public get catalogName(): string + { + return this._catalogName; + } + + public get requiredRepeats(): number + { + return this._requiredRepeats; + } + + public get repeats(): number + { + return this._repeats; + } + + public set repeats(value: number) + { + this._repeats = value; + } + + public get status(): number + { + return this._status; + } + + public set status(value: number) + { + this._status = value; + } + + public get rawSecondsLeft(): number + { + return this._secondsLeft; + } + + public set rawSecondsLeft(value: number) + { + this._secondsLeft = value; + this._receiveTime = new Date(); + } + + /** Seconds left, counted down from the moment the packet arrived; 0 once the raw value is gone. */ + public get secondsLeft(): number + { + if(this._secondsLeft <= 0) return 0; + + const elapsed = Math.floor((Date.now() - this._receiveTime.getTime()) / 1000); + + return Math.max(0, this._secondsLeft - elapsed); + } + + public get isExpired(): boolean + { + return (this._secondsLeft < 0) && (this._status !== DailyTaskData.STATUS_IN_PROGRESS); + } + + public get rewards(): DailyTaskRewardData[] + { + return this._rewards; + } + + public get nameLocalizationKey(): string + { + return `dailytask.${ this._taskCode }.name`; + } + + public get descriptionLocalizationKey(): string + { + return `dailytask.${ this._taskCode }.desc`; + } + + public get hintLocalizationKey(): string + { + return `dailytask.${ this._taskCode }.hint`; + } +} diff --git a/packages/communication/src/messages/parser/quest/DailyTaskUpdatedMessageParser.ts b/packages/communication/src/messages/parser/quest/DailyTaskUpdatedMessageParser.ts new file mode 100644 index 00000000..9d9d8591 --- /dev/null +++ b/packages/communication/src/messages/parser/quest/DailyTaskUpdatedMessageParser.ts @@ -0,0 +1,53 @@ +import { IMessageDataWrapper, IMessageParser } from '@octane/api'; +import { DailyTaskData } from './DailyTaskData'; + +/** DailyTaskUpdated (9450): the repeats and status of one task changed. */ +export class DailyTaskUpdatedMessageParser implements IMessageParser +{ + private _taskId: number; + private _repeats: number; + private _status: number; + private _secondsLeft: number; + + public flush(): boolean + { + this._taskId = 0; + this._repeats = 0; + this._status = 0; + this._secondsLeft = 0; + + return true; + } + + public parse(wrapper: IMessageDataWrapper): boolean + { + if(!wrapper) return false; + + this._taskId = DailyTaskData.readLong(wrapper); + this._repeats = wrapper.readInt(); + this._status = wrapper.readByte(); + this._secondsLeft = wrapper.readInt(); + + return true; + } + + public get taskId(): number + { + return this._taskId; + } + + public get repeats(): number + { + return this._repeats; + } + + public get status(): number + { + return this._status; + } + + public get secondsLeft(): number + { + return this._secondsLeft; + } +} diff --git a/packages/communication/src/messages/parser/quest/QuestCancelledMessageParser.ts b/packages/communication/src/messages/parser/quest/QuestCancelledMessageParser.ts index 2e6e7475..9c593936 100644 --- a/packages/communication/src/messages/parser/quest/QuestCancelledMessageParser.ts +++ b/packages/communication/src/messages/parser/quest/QuestCancelledMessageParser.ts @@ -1,11 +1,16 @@ import { IMessageDataWrapper, IMessageParser } from '@octane/api'; +import { QuestMessageData } from './QuestMessageData'; +/** QuestCancelled (3027): the tracked quest stops; AIR 13 sends the expiry flag and the quest. */ export class QuestCancelledMessageParser implements IMessageParser { private _expired: boolean; + private _quest: QuestMessageData; public flush(): boolean { + this._quest = null; + return true; } @@ -14,6 +19,8 @@ export class QuestCancelledMessageParser implements IMessageParser if(!wrapper) return false; this._expired = wrapper.readBoolean(); + this._quest = new QuestMessageData(wrapper); + return true; } @@ -21,4 +28,9 @@ export class QuestCancelledMessageParser implements IMessageParser { return this._expired; } + + public get quest(): QuestMessageData + { + return this._quest; + } } diff --git a/packages/communication/src/messages/parser/quest/QuestMessageData.ts b/packages/communication/src/messages/parser/quest/QuestMessageData.ts index 3f768099..d35164ec 100644 --- a/packages/communication/src/messages/parser/quest/QuestMessageData.ts +++ b/packages/communication/src/messages/parser/quest/QuestMessageData.ts @@ -19,6 +19,8 @@ export class QuestMessageData private _catalogPageName: string; private _chainCode: string; private _easy: boolean; + private _isSeasonal: boolean; + private _secondsLeft: number; private _receiveTime: Date; constructor(wrapper: IMessageDataWrapper) @@ -40,6 +42,31 @@ export class QuestMessageData this._catalogPageName = wrapper.readString(); this._chainCode = wrapper.readString(); this._easy = wrapper.readBoolean(); + this._isSeasonal = wrapper.readBoolean(); + this._secondsLeft = 0; + + if(this._isSeasonal) this._secondsLeft = wrapper.readInt(); + } + + /** Seasonal quests carry a countdown; it is counted down from the moment the packet arrived. */ + public get isSeasonal(): boolean + { + return this._isSeasonal; + } + + public get secondsLeft(): number + { + if(this._secondsLeft <= 0) return 0; + + const elapsed = Math.floor((Date.now() - this._receiveTime.getTime()) / 1000); + + return this._secondsLeft - elapsed; + } + + /** The key of the tracker instance: seasonal quests are tracked per chain. */ + public get campaignChainCode(): string + { + return this._isSeasonal ? `${ this._campaignCode }.${ this._chainCode }` : this._campaignCode; } public static getCampaignLocalizationKeyForCode(code: string): string diff --git a/packages/communication/src/messages/parser/quest/RewardTrackClaimResultMessageParser.ts b/packages/communication/src/messages/parser/quest/RewardTrackClaimResultMessageParser.ts new file mode 100644 index 00000000..f92dac49 --- /dev/null +++ b/packages/communication/src/messages/parser/quest/RewardTrackClaimResultMessageParser.ts @@ -0,0 +1,44 @@ +import { IMessageDataWrapper, IMessageParser } from '@octane/api'; + +/** RewardTrackClaimResult (9451): 0 is success, any other code is localized as reward_track.claim.notification.fail.N. */ +export class RewardTrackClaimResultMessageParser implements IMessageParser +{ + private _trackId: string; + private _rewardId: string; + private _resultCode: number; + + public flush(): boolean + { + this._trackId = ''; + this._rewardId = ''; + this._resultCode = 0; + + return true; + } + + public parse(wrapper: IMessageDataWrapper): boolean + { + if(!wrapper) return false; + + this._trackId = wrapper.readString(); + this._rewardId = wrapper.readString(); + this._resultCode = wrapper.readInt(); + + return true; + } + + public get trackId(): string + { + return this._trackId; + } + + public get rewardId(): string + { + return this._rewardId; + } + + public get resultCode(): number + { + return this._resultCode; + } +} diff --git a/packages/communication/src/messages/parser/quest/RewardTrackData.ts b/packages/communication/src/messages/parser/quest/RewardTrackData.ts new file mode 100644 index 00000000..9c1d95e0 --- /dev/null +++ b/packages/communication/src/messages/parser/quest/RewardTrackData.ts @@ -0,0 +1,421 @@ +import { IMessageDataWrapper } from '@octane/api'; + +/** One level of a reward-track task (AIR 13 class_4130). */ +export class RewardTrackTaskLevelData +{ + private _requiredCount: number; + private _pointsReward: number; + private _premium: boolean; + + constructor(wrapper: IMessageDataWrapper) + { + this._requiredCount = wrapper.readInt(); + this._pointsReward = wrapper.readInt(); + this._premium = wrapper.readBoolean(); + } + + public get requiredCount(): number + { + return this._requiredCount; + } + + public get pointsReward(): number + { + return this._pointsReward; + } + + public get premium(): boolean + { + return this._premium; + } +} + +/** A reward-track task (AIR 13 class_4037). */ +export class RewardTrackTaskData +{ + private _id: string; + private _actionType: string; + private _parameter: string; + private _progressCount: number; + private _premium: boolean; + private _levels: RewardTrackTaskLevelData[]; + + constructor(wrapper: IMessageDataWrapper) + { + this._id = wrapper.readString(); + this._actionType = wrapper.readString(); + this._parameter = wrapper.readString(); + this._progressCount = wrapper.readInt(); + this._premium = wrapper.readBoolean(); + this._levels = []; + + const count = wrapper.readInt(); + + for(let i = 0; i < count; i++) this._levels.push(new RewardTrackTaskLevelData(wrapper)); + } + + public get id(): string + { + return this._id; + } + + public get actionType(): string + { + return this._actionType; + } + + public get parameter(): string + { + return this._parameter; + } + + public get progressCount(): number + { + return this._progressCount; + } + + public set progressCount(value: number) + { + this._progressCount = value; + } + + public get premium(): boolean + { + return this._premium; + } + + public get levels(): RewardTrackTaskLevelData[] + { + return this._levels; + } + + public get isComplete(): boolean + { + return this._levels.every(level => this._progressCount >= level.requiredCount); + } + + public get hasProgress(): boolean + { + return this._progressCount > 0; + } + + /** The first level not reached yet, or the last one when the task is complete. */ + public get activeLevelIndex(): number + { + const index = this._levels.findIndex(level => this._progressCount < level.requiredCount); + + return (index === -1) ? Math.max(0, this._levels.length - 1) : index; + } + + public get activeLevel(): RewardTrackTaskLevelData + { + return this._levels[this.activeLevelIndex] ?? null; + } + + public progressRatioFor(level: RewardTrackTaskLevelData): number + { + if(!level || (level.requiredCount <= 0)) return 1; + + return Math.min(1, Math.max(0, this._progressCount / level.requiredCount)); + } +} + +/** A prize on the track (AIR 13 class_3945). */ +export class RewardTrackPrizeData +{ + private _id: string; + private _requiredPoints: number; + private _productItemTypeId: number; + private _rewardTypeId: string; + private _extraParams: string; + private _rewardAmount: number; + private _premium: boolean; + private _available: boolean; + private _claimed: boolean; + + constructor(wrapper: IMessageDataWrapper) + { + this._id = wrapper.readString(); + this._requiredPoints = wrapper.readInt(); + this._productItemTypeId = wrapper.readShort(); + this._rewardTypeId = wrapper.readString(); + this._extraParams = wrapper.readString(); + this._rewardAmount = wrapper.readInt(); + this._premium = wrapper.readBoolean(); + this._available = wrapper.readBoolean(); + this._claimed = wrapper.readBoolean(); + } + + public get id(): string + { + return this._id; + } + + public get requiredPoints(): number + { + return this._requiredPoints; + } + + public get productItemTypeId(): number + { + return this._productItemTypeId; + } + + public get rewardTypeId(): string + { + return this._rewardTypeId; + } + + public get extraParams(): string + { + return this._extraParams; + } + + public get rewardAmount(): number + { + return this._rewardAmount; + } + + public get premium(): boolean + { + return this._premium; + } + + public get available(): boolean + { + return this._available; + } + + public set available(value: boolean) + { + this._available = value; + } + + public get claimed(): boolean + { + return this._claimed; + } + + public set claimed(value: boolean) + { + this._claimed = value; + } + + public isPremiumLocked(track: RewardTrackData): boolean + { + return this._premium && !track.premium; + } + + public hasEnoughPoints(track: RewardTrackData): boolean + { + return track.points >= this._requiredPoints; + } + + public isAvailable(track: RewardTrackData): boolean + { + return !this.isPremiumLocked(track) && this.hasEnoughPoints(track); + } + + public isClaimable(track: RewardTrackData): boolean + { + return this.isAvailable(track) && !this._claimed; + } +} + +/** A reward track (AIR 13 class_2464). */ +export class RewardTrackData +{ + private _id: string; + private _theme: string; + private _points: number; + private _hasPremiumConfig: boolean; + private _taskPointsBoost: number; + private _instantPoints: number; + private _costDiamonds: number; + private _costCredits: number; + private _premium: boolean; + private _complete: boolean; + private _premiumComplete: boolean; + private _tasks: RewardTrackTaskData[]; + private _prizes: RewardTrackPrizeData[]; + + constructor(wrapper: IMessageDataWrapper) + { + this._id = wrapper.readString(); + this._theme = wrapper.readString(); + this._points = wrapper.readInt(); + this._hasPremiumConfig = wrapper.readBoolean(); + this._taskPointsBoost = 0; + this._instantPoints = 0; + this._costDiamonds = 0; + this._costCredits = 0; + + if(this._hasPremiumConfig) + { + this._taskPointsBoost = wrapper.readDouble(); + this._instantPoints = wrapper.readInt(); + this._costDiamonds = wrapper.readInt(); + this._costCredits = wrapper.readInt(); + } + + this._premium = wrapper.readBoolean(); + this._complete = wrapper.readBoolean(); + this._premiumComplete = wrapper.readBoolean(); + this._tasks = []; + this._prizes = []; + + const taskCount = wrapper.readInt(); + + for(let i = 0; i < taskCount; i++) this._tasks.push(new RewardTrackTaskData(wrapper)); + + const prizeCount = wrapper.readInt(); + + for(let i = 0; i < prizeCount; i++) this._prizes.push(new RewardTrackPrizeData(wrapper)); + } + + public get id(): string + { + return this._id; + } + + public get theme(): string + { + return this._theme; + } + + public get points(): number + { + return this._points; + } + + public set points(value: number) + { + this._points = value; + this.refreshDerivedState(); + } + + public get hasPremiumConfig(): boolean + { + return this._hasPremiumConfig; + } + + public get taskPointsBoost(): number + { + return this._taskPointsBoost; + } + + public get instantPoints(): number + { + return this._instantPoints; + } + + public get costDiamonds(): number + { + return this._costDiamonds; + } + + public get costCredits(): number + { + return this._costCredits; + } + + public get premium(): boolean + { + return this._premium; + } + + public get complete(): boolean + { + return this._complete; + } + + public get premiumComplete(): boolean + { + return this._premiumComplete; + } + + public get tasks(): RewardTrackTaskData[] + { + return this._tasks; + } + + public get prizes(): RewardTrackPrizeData[] + { + return this._prizes; + } + + public getTask(taskId: string): RewardTrackTaskData + { + return this._tasks.find(task => task.id === taskId) ?? null; + } + + public getPrize(prizeId: string): RewardTrackPrizeData + { + return this._prizes.find(prize => prize.id === prizeId) ?? null; + } + + public get completedTaskCount(): number + { + return this._tasks.filter(task => task.isComplete).length; + } + + public get totalTaskCount(): number + { + return this._tasks.length; + } + + public get claimedPrizeCount(): number + { + return this._prizes.filter(prize => prize.claimed).length; + } + + public get totalPrizeCount(): number + { + return this._prizes.length; + } + + public get claimablePrizeCount(): number + { + return this._prizes.filter(prize => prize.isClaimable(this)).length; + } + + public get hasPremiumPrizes(): boolean + { + return this._prizes.some(prize => prize.premium); + } + + public get hasPremiumTasks(): boolean + { + return this._tasks.some(task => task.premium); + } + + public get hasPremiumLevels(): boolean + { + return this._tasks.some(task => task.levels.some(level => level.premium)); + } + + public markPrizeClaimed(prizeId: string): void + { + const prize = this.getPrize(prizeId); + + if(prize) prize.claimed = true; + + this.refreshDerivedState(); + } + + public markPremiumPurchased(points: number): void + { + this._premium = true; + this._points = points; + this.refreshDerivedState(); + } + + /** The official rule: complete = every free prize claimed; premiumComplete adds the premium ones. */ + public refreshDerivedState(): void + { + for(const prize of this._prizes) prize.available = prize.isAvailable(this); + + this._complete = this._prizes.filter(prize => !prize.premium).every(prize => prize.claimed); + this._premiumComplete = !this._hasPremiumConfig || (this._complete && this._prizes.filter(prize => prize.premium).every(prize => prize.claimed)); + } +} diff --git a/packages/communication/src/messages/parser/quest/RewardTrackPremiumPurchaseResultMessageParser.ts b/packages/communication/src/messages/parser/quest/RewardTrackPremiumPurchaseResultMessageParser.ts new file mode 100644 index 00000000..cde6a2c6 --- /dev/null +++ b/packages/communication/src/messages/parser/quest/RewardTrackPremiumPurchaseResultMessageParser.ts @@ -0,0 +1,44 @@ +import { IMessageDataWrapper, IMessageParser } from '@octane/api'; + +/** RewardTrackPremiumPurchaseResult (2248): 0 is success, else reward_track.premium.notification.fail.N. */ +export class RewardTrackPremiumPurchaseResultMessageParser implements IMessageParser +{ + private _trackId: string; + private _resultCode: number; + private _points: number; + + public flush(): boolean + { + this._trackId = ''; + this._resultCode = 0; + this._points = 0; + + return true; + } + + public parse(wrapper: IMessageDataWrapper): boolean + { + if(!wrapper) return false; + + this._trackId = wrapper.readString(); + this._resultCode = wrapper.readInt(); + this._points = wrapper.readInt(); + + return true; + } + + public get trackId(): string + { + return this._trackId; + } + + public get resultCode(): number + { + return this._resultCode; + } + + public get points(): number + { + return this._points; + } +} diff --git a/packages/communication/src/messages/parser/quest/RewardTrackProgressMessageParser.ts b/packages/communication/src/messages/parser/quest/RewardTrackProgressMessageParser.ts new file mode 100644 index 00000000..c3f6dcb0 --- /dev/null +++ b/packages/communication/src/messages/parser/quest/RewardTrackProgressMessageParser.ts @@ -0,0 +1,52 @@ +import { IMessageDataWrapper, IMessageParser } from '@octane/api'; + +/** RewardTrackProgress (9452): a task moved and the track points it yielded. */ +export class RewardTrackProgressMessageParser implements IMessageParser +{ + private _trackId: string; + private _taskId: string; + private _progressCount: number; + private _points: number; + + public flush(): boolean + { + this._trackId = ''; + this._taskId = ''; + this._progressCount = 0; + this._points = 0; + + return true; + } + + public parse(wrapper: IMessageDataWrapper): boolean + { + if(!wrapper) return false; + + this._trackId = wrapper.readString(); + this._taskId = wrapper.readString(); + this._progressCount = wrapper.readInt(); + this._points = wrapper.readInt(); + + return true; + } + + public get trackId(): string + { + return this._trackId; + } + + public get taskId(): string + { + return this._taskId; + } + + public get progressCount(): number + { + return this._progressCount; + } + + public get points(): number + { + return this._points; + } +} diff --git a/packages/communication/src/messages/parser/quest/RewardTracksMessageParser.ts b/packages/communication/src/messages/parser/quest/RewardTracksMessageParser.ts new file mode 100644 index 00000000..809ea348 --- /dev/null +++ b/packages/communication/src/messages/parser/quest/RewardTracksMessageParser.ts @@ -0,0 +1,49 @@ +import { IMessageDataWrapper, IMessageParser } from '@octane/api'; +import { RewardTrackData } from './RewardTrackData'; + +/** RewardTracks (2327): every active track; `disabled` wipes them, `reload` rebuilds the windows. */ +export class RewardTracksMessageParser implements IMessageParser +{ + private _disabled: boolean; + private _tracks: RewardTrackData[]; + private _reload: boolean; + + public flush(): boolean + { + this._disabled = false; + this._tracks = []; + this._reload = false; + + return true; + } + + public parse(wrapper: IMessageDataWrapper): boolean + { + if(!wrapper) return false; + + this._disabled = wrapper.readBoolean(); + + const count = wrapper.readInt(); + + for(let i = 0; i < count; i++) this._tracks.push(new RewardTrackData(wrapper)); + + this._reload = wrapper.readBoolean(); + + return true; + } + + public get disabled(): boolean + { + return this._disabled; + } + + public get tracks(): RewardTrackData[] + { + return this._tracks; + } + + public get reload(): boolean + { + return this._reload; + } +} diff --git a/packages/communication/src/messages/parser/quest/index.ts b/packages/communication/src/messages/parser/quest/index.ts index 1185da4f..2446b776 100644 --- a/packages/communication/src/messages/parser/quest/index.ts +++ b/packages/communication/src/messages/parser/quest/index.ts @@ -15,3 +15,11 @@ export * from './QuestMessageData'; export * from './QuestMessageParser'; export * from './QuestsMessageParser'; export * from './SeasonalQuestsParser'; +export * from './ActiveDailyTasksMessageParser'; +export * from './DailyTaskData'; +export * from './DailyTaskUpdatedMessageParser'; +export * from './RewardTrackClaimResultMessageParser'; +export * from './RewardTrackData'; +export * from './RewardTrackPremiumPurchaseResultMessageParser'; +export * from './RewardTrackProgressMessageParser'; +export * from './RewardTracksMessageParser'; diff --git a/packages/communication/src/messages/parser/user/data/UserCurrentBadgesParser.ts b/packages/communication/src/messages/parser/user/data/UserCurrentBadgesParser.ts index d93e03ec..2cfb6708 100644 --- a/packages/communication/src/messages/parser/user/data/UserCurrentBadgesParser.ts +++ b/packages/communication/src/messages/parser/user/data/UserCurrentBadgesParser.ts @@ -1,14 +1,30 @@ import { IMessageDataWrapper, IMessageParser } from '@octane/api'; +export interface IUserCurrentBadgeData +{ + slotId: number; + badgeCode: string; + /** Users holding the badge. */ + ownerCount: number; + /** Official BadgeRarity tier id 0 (common) .. 6 (unique). */ + badgeRarityId: number; +} + +/** + * UserCurrentBadges (1087) in the official layout: per worn slot + * (badgeIndex, badgeCode, ownerCount, badgeRarityId). + */ export class UserCurrentBadgesParser implements IMessageParser { private _userId: number; private _badges: string[]; + private _badgeDetails: IUserCurrentBadgeData[]; public flush(): boolean { this._userId = null; this._badges = []; + this._badgeDetails = []; return true; } @@ -25,8 +41,11 @@ export class UserCurrentBadgesParser implements IMessageParser { const slotId = wrapper.readInt(); const badgeCode = wrapper.readString(); + const ownerCount = wrapper.readInt(); + const badgeRarityId = wrapper.readInt(); this._badges.push(badgeCode); + this._badgeDetails.push({ slotId, badgeCode, ownerCount, badgeRarityId }); totalBadges--; } @@ -43,4 +62,9 @@ export class UserCurrentBadgesParser implements IMessageParser { return this._badges; } + + public get badgeDetails(): IUserCurrentBadgeData[] + { + return this._badgeDetails; + } } diff --git a/packages/events/src/session/RoomSessionUserBadgesEvent.ts b/packages/events/src/session/RoomSessionUserBadgesEvent.ts index 51dcca25..2e9fb3dd 100644 --- a/packages/events/src/session/RoomSessionUserBadgesEvent.ts +++ b/packages/events/src/session/RoomSessionUserBadgesEvent.ts @@ -1,20 +1,31 @@ import { IRoomSession } from '@octane/api'; import { RoomSessionEvent } from './RoomSessionEvent'; +/** One worn badge of UserCurrentBadges (1087) with its official rarity data. */ +export interface IRoomSessionUserBadgeDetail +{ + slotId: number; + badgeCode: string; + ownerCount: number; + badgeRarityId: number; +} + export class RoomSessionUserBadgesEvent extends RoomSessionEvent { public static RSUBE_BADGES: string = 'RSUBE_BADGES'; private _userId: number = 0; private _badges: string[]; + private _badgeDetails: IRoomSessionUserBadgeDetail[]; - constructor(session: IRoomSession, userId: number, badges: string[]) + constructor(session: IRoomSession, userId: number, badges: string[], badgeDetails: IRoomSessionUserBadgeDetail[] = []) { super(RoomSessionUserBadgesEvent.RSUBE_BADGES, session); this._badges = []; this._userId = userId; this._badges = badges; + this._badgeDetails = badgeDetails; } public get userId(): number @@ -26,4 +37,9 @@ export class RoomSessionUserBadgesEvent extends RoomSessionEvent { return this._badges; } + + public get badgeDetails(): IRoomSessionUserBadgeDetail[] + { + return this._badgeDetails; + } } diff --git a/packages/session/src/handler/RoomUsersHandler.ts b/packages/session/src/handler/RoomUsersHandler.ts index b4a7b423..3e9b2674 100644 --- a/packages/session/src/handler/RoomUsersHandler.ts +++ b/packages/session/src/handler/RoomUsersHandler.ts @@ -162,7 +162,7 @@ export class RoomUsersHandler extends BaseHandler session.userDataManager.setUserBadges(parser.userId, parser.badges); - GetEventDispatcher().dispatchEvent(new RoomSessionUserBadgesEvent(session, parser.userId, parser.badges)); + GetEventDispatcher().dispatchEvent(new RoomSessionUserBadgesEvent(session, parser.userId, parser.badges, parser.badgeDetails)); } private onRoomDoorbellEvent(event: DoorbellMessageEvent): void diff --git a/protocol/packet-field-contracts.json b/protocol/packet-field-contracts.json index 65966704..fb81e606 100644 --- a/protocol/packet-field-contracts.json +++ b/protocol/packet-field-contracts.json @@ -1435,6 +1435,11 @@ "kind": "scalar", "type": "int", "name": "userId" + }, + { + "kind": "scalar", + "type": "boolean", + "name": "block" } ] }, @@ -2294,6 +2299,60 @@ } ] }, + { + "name": "ROOM_SETTINGS_UPDATE_ROOM_CATEGORY_AND_TRADE", + "direction": "client_to_server", + "header": 1265, + "java": { + "symbol": "UpdateRoomCategoryAndTradeSettingsEvent", + "className": "UpdateRoomCategoryAndTradeSettingsEvent", + "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/UpdateRoomCategoryAndTradeSettingsEvent.java" + }, + "typescript": { + "symbol": "ROOM_SETTINGS_UPDATE_ROOM_CATEGORY_AND_TRADE", + "className": "UpdateRoomCategoryAndTradeSettingsComposer", + "path": "packages/communication/src/messages/outgoing/roomsettings/UpdateRoomCategoryAndTradeSettingsComposer.ts" + }, + "fields": [ + { + "kind": "scalar", + "type": "int", + "name": "" + }, + { + "kind": "scalar", + "type": "int", + "name": "" + }, + { + "kind": "scalar", + "type": "int", + "name": "" + } + ] + }, + { + "name": "GET_QUIZ_QUESTIONS", + "direction": "client_to_server", + "header": 1296, + "java": { + "symbol": "GetQuizQuestionsEvent", + "className": "GetQuizQuestionsEvent", + "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/habboway/GetQuizQuestionsEvent.java" + }, + "typescript": { + "symbol": "GET_QUIZ_QUESTIONS", + "className": "GetQuizQuestionsComposer", + "path": "packages/communication/src/messages/outgoing/help/GetQuizQuestionsComposer.ts" + }, + "fields": [ + { + "kind": "scalar", + "type": "string", + "name": "" + } + ] + }, { "name": "ROOM_KICK", "direction": "client_to_server", @@ -4704,6 +4763,119 @@ } ] }, + { + "name": "RENTABLE_SPACE_STATUS", + "direction": "client_to_server", + "header": 872, + "java": { + "symbol": "GetRentableSpaceStatusEvent", + "className": "GetRentableSpaceStatusEvent", + "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/items/rentablespace/GetRentableSpaceStatusEvent.java" + }, + "typescript": { + "symbol": "RENTABLE_SPACE_STATUS", + "className": "RentableSpaceStatusMessageComposer", + "path": "packages/communication/src/messages/outgoing/room/furniture/RentableSpaceStatusMessageComposer.ts" + }, + "fields": [ + { + "kind": "scalar", + "type": "int", + "name": "itemId" + } + ] + }, + { + "name": "RENTABLE_GET_RENT_OR_BUYOUT_OFFER", + "direction": "client_to_server", + "header": 2518, + "java": { + "symbol": "GetRentOrBuyoutOfferEvent", + "className": "GetRentOrBuyoutOfferEvent", + "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/items/rentable/GetRentOrBuyoutOfferEvent.java" + }, + "typescript": { + "symbol": "RENTABLE_GET_RENT_OR_BUYOUT_OFFER", + "className": "GetRentOrBuyoutOfferMessageComposer", + "path": "packages/communication/src/messages/outgoing/room/furniture/GetRentOrBuyoutOfferMessageComposer.ts" + }, + "fields": [ + { + "kind": "scalar", + "type": "boolean", + "name": "isWallItem" + }, + { + "kind": "scalar", + "type": "string", + "name": "furniTypeName" + }, + { + "kind": "scalar", + "type": "boolean", + "name": "buyout" + } + ] + }, + { + "name": "RENTABLE_EXTEND_RENT_OR_BUYOUT_FURNI", + "direction": "client_to_server", + "header": 1071, + "java": { + "symbol": "ExtendRentOrBuyoutFurniEvent", + "className": "ExtendRentOrBuyoutFurniEvent", + "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/items/rentable/ExtendRentOrBuyoutFurniEvent.java" + }, + "typescript": { + "symbol": "RENTABLE_EXTEND_RENT_OR_BUYOUT_FURNI", + "className": "ExtendRentOrBuyoutFurniMessageComposer", + "path": "packages/communication/src/messages/outgoing/room/furniture/ExtendRentOrBuyoutFurniMessageComposer.ts" + }, + "fields": [ + { + "kind": "scalar", + "type": "boolean", + "name": "isWallItem" + }, + { + "kind": "scalar", + "type": "int", + "name": "itemId" + }, + { + "kind": "scalar", + "type": "boolean", + "name": "buyout" + } + ] + }, + { + "name": "RENTABLE_EXTEND_RENT_OR_BUYOUT_STRIP_ITEM", + "direction": "client_to_server", + "header": 2115, + "java": { + "symbol": "ExtendRentOrBuyoutStripItemEvent", + "className": "ExtendRentOrBuyoutStripItemEvent", + "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/items/rentable/ExtendRentOrBuyoutStripItemEvent.java" + }, + "typescript": { + "symbol": "RENTABLE_EXTEND_RENT_OR_BUYOUT_STRIP_ITEM", + "className": "ExtendRentOrBuyoutStripItemMessageComposer", + "path": "packages/communication/src/messages/outgoing/room/furniture/ExtendRentOrBuyoutStripItemMessageComposer.ts" + }, + "fields": [ + { + "kind": "scalar", + "type": "int", + "name": "stripId" + }, + { + "kind": "scalar", + "type": "boolean", + "name": "buyout" + } + ] + }, { "name": "USER_EFFECT_ACTIVATE", "direction": "client_to_server", @@ -9671,32 +9843,32 @@ { "kind": "scalar", "type": "boolean", - "name": "" + "name": "isWallItem" }, { "kind": "scalar", "type": "string", - "name": "" + "name": "furniTypeName" }, { "kind": "scalar", "type": "boolean", - "name": "" + "name": "buyout" }, { "kind": "scalar", "type": "int", - "name": "" + "name": "priceInCredits" }, { "kind": "scalar", "type": "int", - "name": "" + "name": "priceInActivityPoints" }, { "kind": "scalar", "type": "int", - "name": "" + "name": "activityPointType" } ] }, @@ -12735,9 +12907,9 @@ "direction": "server_to_client", "header": 1868, "java": { - "symbol": "RentableSpaceUnknown2Composer", - "className": "RentableSpaceUnknown2Composer", - "path": "Emulator/src/main/java/com/eu/habbo/messages/outgoing/rooms/items/rentablespaces/RentableSpaceUnknown2Composer.java" + "symbol": "RentableSpaceRentFailedComposer", + "className": "RentableSpaceRentFailedComposer", + "path": "Emulator/src/main/java/com/eu/habbo/messages/outgoing/rooms/items/rentablespaces/RentableSpaceRentFailedComposer.java" }, "typescript": { "symbol": "RENTABLE_SPACE_RENT_FAILED", @@ -12919,7 +13091,7 @@ { "kind": "scalar", "type": "int", - "name": "" + "name": "reason" } ] }, @@ -13041,9 +13213,9 @@ "direction": "server_to_client", "header": 2046, "java": { - "symbol": "RentableSpaceUnknownComposer", - "className": "RentableSpaceUnknownComposer", - "path": "Emulator/src/main/java/com/eu/habbo/messages/outgoing/rooms/items/rentablespaces/RentableSpaceUnknownComposer.java" + "symbol": "RentableSpaceRentOkComposer", + "className": "RentableSpaceRentOkComposer", + "path": "Emulator/src/main/java/com/eu/habbo/messages/outgoing/rooms/items/rentablespaces/RentableSpaceRentOkComposer.java" }, "typescript": { "symbol": "RENTABLE_SPACE_RENT_OK", @@ -13166,7 +13338,7 @@ { "kind": "scalar", "type": "int", - "name": "" + "name": "expiryTime" } ] }, @@ -14430,28 +14602,6 @@ } ] }, - { - "name": "QUEST_CANCELLED", - "direction": "server_to_client", - "header": 3027, - "java": { - "symbol": "QuestExpiredComposer", - "className": "QuestExpiredComposer", - "path": "Emulator/src/main/java/com/eu/habbo/messages/outgoing/quests/QuestExpiredComposer.java" - }, - "typescript": { - "symbol": "QUEST_CANCELLED", - "className": "QuestCancelledMessageParser", - "path": "packages/communication/src/messages/parser/quest/QuestCancelledMessageParser.ts" - }, - "fields": [ - { - "kind": "scalar", - "type": "boolean", - "name": "" - } - ] - }, { "name": "MESSENGER_FOLLOW_FAILED", "direction": "server_to_client", @@ -15280,32 +15430,32 @@ { "kind": "scalar", "type": "boolean", - "name": "" + "name": "rented" }, { "kind": "scalar", "type": "int", - "name": "" + "name": "canRentErrorCode" }, { "kind": "scalar", "type": "int", - "name": "" + "name": "renterId" }, { "kind": "scalar", "type": "string", - "name": "" + "name": "renterName" }, { "kind": "scalar", "type": "int", - "name": "" + "name": "timeRemaining" }, { "kind": "scalar", "type": "int", - "name": "" + "name": "price" } ] }, @@ -17256,64 +17406,496 @@ "name": "itemId" } ] - } - ], - "unpaired": [ - { - "direction": "client_to_server", - "side": "java", - "header": 295, - "symbol": "PingEvent", - "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/handshake/PingEvent.java", - "reason": "Header is registered only by the Polaris Emulator packet registry" }, { + "name": "GROUP_UNBLOCK_MEMBER", "direction": "client_to_server", - "side": "java", - "header": 1523, - "symbol": "RequestFriendsEvent", - "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/friends/RequestFriendsEvent.java", - "reason": "Header is registered only by the Polaris Emulator packet registry" + "header": 2864, + "java": { + "symbol": "GuildUnblockMemberEvent", + "className": "GuildUnblockMemberEvent", + "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/GuildUnblockMemberEvent.java" + }, + "typescript": { + "symbol": "GROUP_UNBLOCK_MEMBER", + "className": "UnblockGroupMemberMessageComposer", + "path": "packages/communication/src/messages/outgoing/user/UnblockGroupMemberMessageComposer.ts" + }, + "fields": [ + { + "kind": "scalar", + "type": "int", + "name": "guildId" + }, + { + "kind": "scalar", + "type": "int", + "name": "userId" + } + ] }, { + "name": "GET_HOT_LOOKS", "direction": "client_to_server", - "side": "java", - "header": 2313, - "symbol": "UpdateUIFlagsEvent", - "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/users/UpdateUIFlagsEvent.java", - "reason": "Header is registered only by the Polaris Emulator packet registry" + "header": 9360, + "java": { + "symbol": "GetHotLooksEvent", + "className": "GetHotLooksEvent", + "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/hotlooks/GetHotLooksEvent.java" + }, + "typescript": { + "symbol": "GET_HOT_LOOKS", + "className": "GetHotLooksComposer", + "path": "packages/communication/src/messages/outgoing/hotlooks/GetHotLooksComposer.ts" + }, + "fields": [ + { + "kind": "scalar", + "type": "int", + "name": "count" + } + ] }, { + "name": "GET_QUESTS", "direction": "client_to_server", - "side": "java", - "header": 2318, - "symbol": "HotelViewRequestBadgeRewardEvent", - "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/hotelview/HotelViewRequestBadgeRewardEvent.java", - "reason": "Header is registered only by the Polaris Emulator packet registry" + "header": 3333, + "java": { + "symbol": "GetQuestsEvent", + "className": "GetQuestsEvent", + "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/quests/GetQuestsEvent.java" + }, + "typescript": { + "symbol": "GET_QUESTS", + "className": "GetQuestsMessageComposer", + "path": "packages/communication/src/messages/outgoing/quest/GetQuestsMessageComposer.ts" + }, + "fields": [] }, { + "name": "GET_SEASONAL_QUESTS_ONLY", "direction": "client_to_server", - "side": "java", - "header": 3457, - "symbol": "UserActivityEvent", - "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/users/UserActivityEvent.java", - "reason": "Header is registered only by the Polaris Emulator packet registry" - }, - { - "direction": "server_to_client", - "side": "java", - "header": 10, - "symbol": "PongComposer", - "path": "Emulator/src/main/java/com/eu/habbo/messages/outgoing/handshake/PongComposer.java", - "reason": "Header is registered only by the Polaris Emulator packet registry" + "header": 1190, + "java": { + "symbol": "GetSeasonalQuestsOnlyEvent", + "className": "GetSeasonalQuestsOnlyEvent", + "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/quests/GetSeasonalQuestsOnlyEvent.java" + }, + "typescript": { + "symbol": "GET_SEASONAL_QUESTS_ONLY", + "className": "GetSeasonalQuestsOnlyMessageComposer", + "path": "packages/communication/src/messages/outgoing/quest/GetSeasonalQuestsOnlyMessageComposer.ts" + }, + "fields": [] }, { - "direction": "server_to_client", - "side": "java", - "header": 265, - "symbol": "GuildMemberUpdateComposer", - "path": "Emulator/src/main/java/com/eu/habbo/messages/outgoing/guilds/GuildMemberUpdateComposer.java", - "reason": "Header is registered only by the Polaris Emulator packet registry" + "name": "ACCEPT_QUEST", + "direction": "client_to_server", + "header": 3604, + "java": { + "symbol": "AcceptQuestEvent", + "className": "AcceptQuestEvent", + "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/quests/AcceptQuestEvent.java" + }, + "typescript": { + "symbol": "ACCEPT_QUEST", + "className": "AcceptQuestMessageComposer", + "path": "packages/communication/src/messages/outgoing/quest/AcceptQuestMessageComposer.ts" + }, + "fields": [ + { + "kind": "scalar", + "type": "int", + "name": "questId" + } + ] + }, + { + "name": "ACTIVATE_QUEST", + "direction": "client_to_server", + "header": 793, + "java": { + "symbol": "ActivateQuestEvent", + "className": "ActivateQuestEvent", + "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/quests/ActivateQuestEvent.java" + }, + "typescript": { + "symbol": "ACTIVATE_QUEST", + "className": "ActivateQuestMessageComposer", + "path": "packages/communication/src/messages/outgoing/quest/ActivateQuestMessageComposer.ts" + }, + "fields": [ + { + "kind": "scalar", + "type": "int", + "name": "questId" + } + ] + }, + { + "name": "REJECT_QUEST", + "direction": "client_to_server", + "header": 2397, + "java": { + "symbol": "RejectQuestEvent", + "className": "RejectQuestEvent", + "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/quests/RejectQuestEvent.java" + }, + "typescript": { + "symbol": "REJECT_QUEST", + "className": "RejectQuestMessageComposer", + "path": "packages/communication/src/messages/outgoing/quest/RejectQuestMessageComposer.ts" + }, + "fields": [ + { + "kind": "scalar", + "type": "int", + "name": "questId" + } + ] + }, + { + "name": "CANCEL_QUEST", + "direction": "client_to_server", + "header": 3133, + "java": { + "symbol": "CancelDailyQuestEvent", + "className": "CancelDailyQuestEvent", + "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/quests/CancelDailyQuestEvent.java" + }, + "typescript": { + "symbol": "CANCEL_QUEST", + "className": "CancelQuestMessageComposer", + "path": "packages/communication/src/messages/outgoing/quest/CancelQuestMessageComposer.ts" + }, + "fields": [] + }, + { + "name": "GET_DAILY_QUEST", + "direction": "client_to_server", + "header": 2486, + "java": { + "symbol": "GetDailyQuestEvent", + "className": "GetDailyQuestEvent", + "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/quests/GetDailyQuestEvent.java" + }, + "typescript": { + "symbol": "GET_DAILY_QUEST", + "className": "GetDailyQuestMessageComposer", + "path": "packages/communication/src/messages/outgoing/quest/GetDailyQuestMessageComposer.ts" + }, + "fields": [ + { + "kind": "scalar", + "type": "boolean", + "name": "easy" + }, + { + "kind": "scalar", + "type": "int", + "name": "index" + } + ] + }, + { + "name": "OPEN_QUEST_TRACKER", + "direction": "client_to_server", + "header": 2750, + "java": { + "symbol": "OpenQuestTrackerEvent", + "className": "OpenQuestTrackerEvent", + "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/quests/OpenQuestTrackerEvent.java" + }, + "typescript": { + "symbol": "OPEN_QUEST_TRACKER", + "className": "OpenQuestTrackerMessageComposer", + "path": "packages/communication/src/messages/outgoing/quest/OpenQuestTrackerMessageComposer.ts" + }, + "fields": [] + }, + { + "name": "START_CAMPAIGN", + "direction": "client_to_server", + "header": 1697, + "java": { + "symbol": "StartCampaignEvent", + "className": "StartCampaignEvent", + "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/quests/StartCampaignEvent.java" + }, + "typescript": { + "symbol": "START_CAMPAIGN", + "className": "StartCampaignMessageComposer", + "path": "packages/communication/src/messages/outgoing/quest/StartCampaignMessageComposer.ts" + }, + "fields": [ + { + "kind": "scalar", + "type": "string", + "name": "campaignCode" + } + ] + }, + { + "name": "GET_DAILY_TASKS", + "direction": "client_to_server", + "header": 4100, + "java": { + "symbol": "GetDailyTasksEvent", + "className": "GetDailyTasksEvent", + "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/quests/GetDailyTasksEvent.java" + }, + "typescript": { + "symbol": "GET_DAILY_TASKS", + "className": "GetDailyTasksMessageComposer", + "path": "packages/communication/src/messages/outgoing/quest/GetDailyTasksMessageComposer.ts" + }, + "fields": [] + }, + { + "name": "CLAIM_DAILY_TASK", + "direction": "client_to_server", + "header": 4101, + "java": { + "symbol": "ClaimDailyTaskEvent", + "className": "ClaimDailyTaskEvent", + "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/quests/ClaimDailyTaskEvent.java" + }, + "typescript": { + "symbol": "CLAIM_DAILY_TASK", + "className": "ClaimDailyTaskMessageComposer", + "path": "packages/communication/src/messages/outgoing/quest/ClaimDailyTaskMessageComposer.ts" + }, + "fields": [ + { + "kind": "scalar", + "type": "int", + "name": "taskId" + } + ] + }, + { + "name": "GET_REWARD_TRACKS", + "direction": "client_to_server", + "header": 9450, + "java": { + "symbol": "GetRewardTracksEvent", + "className": "GetRewardTracksEvent", + "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/quests/GetRewardTracksEvent.java" + }, + "typescript": { + "symbol": "GET_REWARD_TRACKS", + "className": "GetRewardTracksMessageComposer", + "path": "packages/communication/src/messages/outgoing/quest/GetRewardTracksMessageComposer.ts" + }, + "fields": [] + }, + { + "name": "CLAIM_REWARD_TRACK_PRIZE", + "direction": "client_to_server", + "header": 1111, + "java": { + "symbol": "ClaimRewardTrackPrizeEvent", + "className": "ClaimRewardTrackPrizeEvent", + "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/quests/ClaimRewardTrackPrizeEvent.java" + }, + "typescript": { + "symbol": "CLAIM_REWARD_TRACK_PRIZE", + "className": "ClaimRewardTrackPrizeMessageComposer", + "path": "packages/communication/src/messages/outgoing/quest/ClaimRewardTrackPrizeMessageComposer.ts" + }, + "fields": [ + { + "kind": "scalar", + "type": "string", + "name": "trackId" + }, + { + "kind": "scalar", + "type": "string", + "name": "rewardId" + } + ] + }, + { + "name": "PURCHASE_REWARD_TRACK_PREMIUM", + "direction": "client_to_server", + "header": 3022, + "java": { + "symbol": "PurchaseRewardTrackPremiumEvent", + "className": "PurchaseRewardTrackPremiumEvent", + "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/quests/PurchaseRewardTrackPremiumEvent.java" + }, + "typescript": { + "symbol": "PURCHASE_REWARD_TRACK_PREMIUM", + "className": "PurchaseRewardTrackPremiumMessageComposer", + "path": "packages/communication/src/messages/outgoing/quest/PurchaseRewardTrackPremiumMessageComposer.ts" + }, + "fields": [ + { + "kind": "scalar", + "type": "string", + "name": "trackId" + } + ] + }, + { + "name": "REWARD_TRACK_CLAIM_RESULT", + "direction": "server_to_client", + "header": 9451, + "java": { + "symbol": "RewardTrackClaimResultComposer", + "className": "RewardTrackClaimResultComposer", + "path": "Emulator/src/main/java/com/eu/habbo/messages/outgoing/quests/RewardTrackClaimResultComposer.java" + }, + "typescript": { + "symbol": "REWARD_TRACK_CLAIM_RESULT", + "className": "RewardTrackClaimResultMessageParser", + "path": "packages/communication/src/messages/parser/quest/RewardTrackClaimResultMessageParser.ts" + }, + "fields": [ + { + "kind": "scalar", + "type": "string", + "name": "trackId" + }, + { + "kind": "scalar", + "type": "string", + "name": "rewardId" + }, + { + "kind": "scalar", + "type": "int", + "name": "resultCode" + } + ] + }, + { + "name": "REWARD_TRACK_PROGRESS", + "direction": "server_to_client", + "header": 9452, + "java": { + "symbol": "RewardTrackProgressComposer", + "className": "RewardTrackProgressComposer", + "path": "Emulator/src/main/java/com/eu/habbo/messages/outgoing/quests/RewardTrackProgressComposer.java" + }, + "typescript": { + "symbol": "REWARD_TRACK_PROGRESS", + "className": "RewardTrackProgressMessageParser", + "path": "packages/communication/src/messages/parser/quest/RewardTrackProgressMessageParser.ts" + }, + "fields": [ + { + "kind": "scalar", + "type": "string", + "name": "trackId" + }, + { + "kind": "scalar", + "type": "string", + "name": "taskId" + }, + { + "kind": "scalar", + "type": "int", + "name": "progressCount" + }, + { + "kind": "scalar", + "type": "int", + "name": "points" + } + ] + }, + { + "name": "REWARD_TRACK_PREMIUM_PURCHASE_RESULT", + "direction": "server_to_client", + "header": 2248, + "java": { + "symbol": "RewardTrackPremiumPurchaseResultComposer", + "className": "RewardTrackPremiumPurchaseResultComposer", + "path": "Emulator/src/main/java/com/eu/habbo/messages/outgoing/quests/RewardTrackPremiumPurchaseResultComposer.java" + }, + "typescript": { + "symbol": "REWARD_TRACK_PREMIUM_PURCHASE_RESULT", + "className": "RewardTrackPremiumPurchaseResultMessageParser", + "path": "packages/communication/src/messages/parser/quest/RewardTrackPremiumPurchaseResultMessageParser.ts" + }, + "fields": [ + { + "kind": "scalar", + "type": "string", + "name": "trackId" + }, + { + "kind": "scalar", + "type": "int", + "name": "resultCode" + }, + { + "kind": "scalar", + "type": "int", + "name": "points" + } + ] + } + ], + "unpaired": [ + { + "direction": "client_to_server", + "side": "java", + "header": 295, + "symbol": "PingEvent", + "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/handshake/PingEvent.java", + "reason": "Header is registered only by the Polaris Emulator packet registry" + }, + { + "direction": "client_to_server", + "side": "java", + "header": 1523, + "symbol": "RequestFriendsEvent", + "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/friends/RequestFriendsEvent.java", + "reason": "Header is registered only by the Polaris Emulator packet registry" + }, + { + "direction": "client_to_server", + "side": "java", + "header": 2313, + "symbol": "UpdateUIFlagsEvent", + "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/users/UpdateUIFlagsEvent.java", + "reason": "Header is registered only by the Polaris Emulator packet registry" + }, + { + "direction": "client_to_server", + "side": "java", + "header": 2318, + "symbol": "HotelViewRequestBadgeRewardEvent", + "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/hotelview/HotelViewRequestBadgeRewardEvent.java", + "reason": "Header is registered only by the Polaris Emulator packet registry" + }, + { + "direction": "client_to_server", + "side": "java", + "header": 3457, + "symbol": "UserActivityEvent", + "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/users/UserActivityEvent.java", + "reason": "Header is registered only by the Polaris Emulator packet registry" + }, + { + "direction": "server_to_client", + "side": "java", + "header": 10, + "symbol": "PongComposer", + "path": "Emulator/src/main/java/com/eu/habbo/messages/outgoing/handshake/PongComposer.java", + "reason": "Header is registered only by the Polaris Emulator packet registry" + }, + { + "direction": "server_to_client", + "side": "java", + "header": 265, + "symbol": "GuildMemberUpdateComposer", + "path": "Emulator/src/main/java/com/eu/habbo/messages/outgoing/guilds/GuildMemberUpdateComposer.java", + "reason": "Header is registered only by the Polaris Emulator packet registry" }, { "direction": "server_to_client", @@ -17547,14 +18129,6 @@ "path": "packages/communication/src/messages/outgoing/gifts/TryPhoneNumberMessageComposer.ts", "reason": "Header is registered only by the Octane Renderer packet registry" }, - { - "direction": "client_to_server", - "side": "typescript", - "header": 793, - "symbol": "ACTIVATE_QUEST", - "path": "packages/communication/src/messages/outgoing/quest/ActivateQuestMessageComposer.ts", - "reason": "Header is registered only by the Octane Renderer packet registry" - }, { "direction": "client_to_server", "side": "typescript", @@ -17571,14 +18145,6 @@ "path": "packages/communication/src/messages/outgoing/competition/ForwardToRandomCompetitionRoomMessageComposer.ts", "reason": "Header is registered only by the Octane Renderer packet registry" }, - { - "direction": "client_to_server", - "side": "typescript", - "header": 872, - "symbol": "RENTABLE_SPACE_STATUS", - "path": "packages/communication/src/messages/outgoing/room/furniture/RentableSpaceStatusMessageComposer.ts", - "reason": "Header is registered only by the Octane Renderer packet registry" - }, { "direction": "client_to_server", "side": "typescript", @@ -17603,14 +18169,6 @@ "path": "packages/communication/src/messages/outgoing/handshake/VersionCheckMessageComposer.ts", "reason": "Header is registered only by the Octane Renderer packet registry" }, - { - "direction": "client_to_server", - "side": "typescript", - "header": 1071, - "symbol": "RENTABLE_EXTEND_RENT_OR_BUYOUT_FURNI", - "path": "packages/communication/src/messages/outgoing/room/furniture/ExtendRentOrBuyoutFurniMessageComposer.ts", - "reason": "Header is registered only by the Octane Renderer packet registry" - }, { "direction": "client_to_server", "side": "typescript", @@ -17643,14 +18201,6 @@ "path": "packages/communication/src/messages/outgoing/userclassification/PeerUsersClassificationMessageComposer.ts", "reason": "Header is registered only by the Octane Renderer packet registry" }, - { - "direction": "client_to_server", - "side": "typescript", - "header": 1190, - "symbol": "GET_SEASONAL_QUESTS_ONLY", - "path": "packages/communication/src/messages/outgoing/quest/GetSeasonalQuestsOnlyMessageComposer.ts", - "reason": "Header is registered only by the Octane Renderer packet registry" - }, { "direction": "client_to_server", "side": "typescript", @@ -17667,22 +18217,6 @@ "path": "packages/communication/src/messages/outgoing/game/score/Game2GetWeeklyFriendsLeaderboardComposer.ts", "reason": "Header is registered only by the Octane Renderer packet registry" }, - { - "direction": "client_to_server", - "side": "typescript", - "header": 1265, - "symbol": "ROOM_SETTINGS_UPDATE_ROOM_CATEGORY_AND_TRADE", - "path": "packages/communication/src/messages/outgoing/roomsettings/UpdateRoomCategoryAndTradeSettingsComposer.ts", - "reason": "Header is registered only by the Octane Renderer packet registry" - }, - { - "direction": "client_to_server", - "side": "typescript", - "header": 1296, - "symbol": "GET_QUIZ_QUESTIONS", - "path": "packages/communication/src/messages/outgoing/help/GetQuizQuestionsComposer.ts", - "reason": "Header is registered only by the Octane Renderer packet registry" - }, { "direction": "client_to_server", "side": "typescript", @@ -17771,14 +18305,6 @@ "path": "packages/communication/src/messages/outgoing/moderation/DefaultSanctionMessageComposer.ts", "reason": "Header is registered only by the Octane Renderer packet registry" }, - { - "direction": "client_to_server", - "side": "typescript", - "header": 1697, - "symbol": "START_CAMPAIGN", - "path": "packages/communication/src/messages/outgoing/quest/StartCampaignMessageComposer.ts", - "reason": "Header is registered only by the Octane Renderer packet registry" - }, { "direction": "client_to_server", "side": "typescript", @@ -17819,14 +18345,6 @@ "path": "packages/communication/src/messages/outgoing/competition/GetIsUserPartOfCompetitionMessageComposer.ts", "reason": "Header is registered only by the Octane Renderer packet registry" }, - { - "direction": "client_to_server", - "side": "typescript", - "header": 2115, - "symbol": "RENTABLE_EXTEND_RENT_OR_BUYOUT_STRIP_ITEM", - "path": "packages/communication/src/messages/outgoing/room/furniture/ExtendRentOrBuyoutStripItemMessageComposer.ts", - "reason": "Header is registered only by the Octane Renderer packet registry" - }, { "direction": "client_to_server", "side": "typescript", @@ -17883,14 +18401,6 @@ "path": "packages/communication/src/messages/outgoing/game/lobby/LeaveQueueMessageComposer.ts", "reason": "Header is registered only by the Octane Renderer packet registry" }, - { - "direction": "client_to_server", - "side": "typescript", - "header": 2397, - "symbol": "REJECT_QUEST", - "path": "packages/communication/src/messages/outgoing/quest/RejectQuestMessageComposer.ts", - "reason": "Header is registered only by the Octane Renderer packet registry" - }, { "direction": "client_to_server", "side": "typescript", @@ -17931,14 +18441,6 @@ "path": "packages/communication/src/messages/outgoing/navigator/UpdateRoomThumbnailMessageComposer.ts", "reason": "Header is registered only by the Octane Renderer packet registry" }, - { - "direction": "client_to_server", - "side": "typescript", - "header": 2486, - "symbol": "GET_DAILY_QUEST", - "path": "packages/communication/src/messages/outgoing/quest/GetDailyQuestMessageComposer.ts", - "reason": "Header is registered only by the Octane Renderer packet registry" - }, { "direction": "client_to_server", "side": "typescript", @@ -17955,14 +18457,6 @@ "path": "packages/communication/src/messages/outgoing/game/arena/Game2GameChatMessageComposer.ts", "reason": "Header is registered only by the Octane Renderer packet registry" }, - { - "direction": "client_to_server", - "side": "typescript", - "header": 2518, - "symbol": "RENTABLE_GET_RENT_OR_BUYOUT_OFFER", - "path": "packages/communication/src/messages/outgoing/room/furniture/GetRentOrBuyoutOfferMessageComposer.ts", - "reason": "Header is registered only by the Octane Renderer packet registry" - }, { "direction": "client_to_server", "side": "typescript", @@ -18067,14 +18561,6 @@ "path": "packages/communication/src/messages/outgoing/gifts/ResetPhoneNumberStateMessageComposer.ts", "reason": "Header is registered only by the Octane Renderer packet registry" }, - { - "direction": "client_to_server", - "side": "typescript", - "header": 2750, - "symbol": "OPEN_QUEST_TRACKER", - "path": "packages/communication/src/messages/outgoing/quest/OpenQuestTrackerMessageComposer.ts", - "reason": "Header is registered only by the Octane Renderer packet registry" - }, { "direction": "client_to_server", "side": "typescript", @@ -18099,14 +18585,6 @@ "path": "packages/communication/src/messages/outgoing/navigator/RoomAdSearchMessageComposer.ts", "reason": "Header is registered only by the Octane Renderer packet registry" }, - { - "direction": "client_to_server", - "side": "typescript", - "header": 2864, - "symbol": "GROUP_UNBLOCK_MEMBER", - "path": "packages/communication/src/messages/outgoing/user/UnblockGroupMemberMessageComposer.ts", - "reason": "Header is registered only by the Octane Renderer packet registry" - }, { "direction": "client_to_server", "side": "typescript", @@ -18139,14 +18617,6 @@ "path": "packages/communication/src/messages/outgoing/room/session/ChangeQueueMessageComposer.ts", "reason": "Header is registered only by the Octane Renderer packet registry" }, - { - "direction": "client_to_server", - "side": "typescript", - "header": 3133, - "symbol": "CANCEL_QUEST", - "path": "packages/communication/src/messages/outgoing/quest/CancelQuestMessageComposer.ts", - "reason": "Header is registered only by the Octane Renderer packet registry" - }, { "direction": "client_to_server", "side": "typescript", @@ -18211,14 +18681,6 @@ "path": "packages/communication/src/messages/outgoing/user/data/IgnoreUserIdComposer.ts", "reason": "Header is registered only by the Octane Renderer packet registry" }, - { - "direction": "client_to_server", - "side": "typescript", - "header": 3333, - "symbol": "GET_QUESTS", - "path": "packages/communication/src/messages/outgoing/quest/GetQuestsMessageComposer.ts", - "reason": "Header is registered only by the Octane Renderer packet registry" - }, { "direction": "client_to_server", "side": "typescript", @@ -18259,14 +18721,6 @@ "path": "packages/communication/src/messages/outgoing/landingview/votes/CommunityGoalVoteMessageComposer.ts", "reason": "Header is registered only by the Octane Renderer packet registry" }, - { - "direction": "client_to_server", - "side": "typescript", - "header": 3604, - "symbol": "ACCEPT_QUEST", - "path": "packages/communication/src/messages/outgoing/quest/AcceptQuestMessageComposer.ts", - "reason": "Header is registered only by the Octane Renderer packet registry" - }, { "direction": "client_to_server", "side": "typescript", @@ -18275,14 +18729,6 @@ "path": "packages/communication/src/messages/outgoing/help/DeletePendingCallsForHelpMessageComposer.ts", "reason": "Header is registered only by the Octane Renderer packet registry" }, - { - "direction": "client_to_server", - "side": "typescript", - "header": 3720, - "symbol": "POST_QUIZ_ANSWERS", - "path": "packages/communication/src/messages/outgoing/help/PostQuizAnswersComposer.ts", - "reason": "Header is registered only by the Octane Renderer packet registry" - }, { "direction": "client_to_server", "side": "typescript", @@ -18427,14 +18873,6 @@ "path": "packages/communication/src/messages/parser/game/lobby/GameInviteMessageParser.ts", "reason": "Header is registered only by the Octane Renderer packet registry" }, - { - "direction": "server_to_client", - "side": "typescript", - "header": 1122, - "symbol": "SEASONAL_QUESTS", - "path": "packages/communication/src/messages/parser/quest/SeasonalQuestsParser.ts", - "reason": "Header is registered only by the Octane Renderer packet registry" - }, { "direction": "server_to_client", "side": "typescript", @@ -18475,14 +18913,6 @@ "path": "packages/communication/src/messages/parser/game/directory/Game2JoiningGameFailedMessageParser.ts", "reason": "Header is registered only by the Octane Renderer packet registry" }, - { - "direction": "server_to_client", - "side": "typescript", - "header": 1878, - "symbol": "QUEST_DAILY", - "path": "packages/communication/src/messages/parser/quest/QuestDailyMessageParser.ts", - "reason": "Header is registered only by the Octane Renderer packet registry" - }, { "direction": "server_to_client", "side": "typescript", @@ -19925,6 +20355,22 @@ }, "reason": "Java analyzer: Data-dependent packet operations in IfStmt inside handle; TypeScript analyzer: Outgoing message array _data is mutated with push" }, + { + "name": "POST_QUIZ_ANSWERS", + "direction": "client_to_server", + "header": 3720, + "java": { + "symbol": "PostQuizAnswersEvent", + "className": "PostQuizAnswersEvent", + "path": "Emulator/src/main/java/com/eu/habbo/messages/incoming/habboway/PostQuizAnswersEvent.java" + }, + "typescript": { + "symbol": "POST_QUIZ_ANSWERS", + "className": "PostQuizAnswersComposer", + "path": "packages/communication/src/messages/outgoing/help/PostQuizAnswersComposer.ts" + }, + "reason": "Java analyzer: Data-dependent packet operations in ForStmt inside handle; TypeScript analyzer: Outgoing value answerIds.length has no statically resolvable wire type" + }, { "name": "MODTOOL_SANCTION_TRADELOCK", "direction": "client_to_server", @@ -24532,6 +24978,134 @@ "path": "packages/communication/src/messages/outgoing/roomevents/WiredMenuSettingsComposer.ts" }, "reason": "Java analyzer: Packet fields delegated to external reader in UserPreferencePackets inside handle" + }, + { + "name": "HOT_LOOKS", + "direction": "server_to_client", + "header": 9360, + "java": { + "symbol": "HotLooksComposer", + "className": "HotLooksComposer", + "path": "Emulator/src/main/java/com/eu/habbo/messages/outgoing/hotlooks/HotLooksComposer.java" + }, + "typescript": { + "symbol": "HOT_LOOKS", + "className": "HotLooksParser", + "path": "packages/communication/src/messages/parser/hotlooks/HotLooksParser.ts" + }, + "reason": "Java analyzer: Data-dependent packet operations in ForEachStmt inside composeInternal; TypeScript analyzer: Data-dependent packet operations in ForStatement inside parse" + }, + { + "name": "QUEST_CANCELLED", + "direction": "server_to_client", + "header": 3027, + "java": { + "symbol": "QuestExpiredComposer", + "className": "QuestExpiredComposer", + "path": "Emulator/src/main/java/com/eu/habbo/messages/outgoing/quests/QuestExpiredComposer.java" + }, + "typescript": { + "symbol": "QUEST_CANCELLED", + "className": "QuestCancelledMessageParser", + "path": "packages/communication/src/messages/parser/quest/QuestCancelledMessageParser.ts" + }, + "reason": "Java analyzer: Packet fields delegated to external serializer serialize inside composeInternal; TypeScript analyzer: Packet fields delegated to external constructor QuestMessageData inside parse" + }, + { + "name": "SEASONAL_QUESTS", + "direction": "server_to_client", + "header": 1122, + "java": { + "symbol": "SeasonalQuestsComposer", + "className": "SeasonalQuestsComposer", + "path": "Emulator/src/main/java/com/eu/habbo/messages/outgoing/quests/SeasonalQuestsComposer.java" + }, + "typescript": { + "symbol": "SEASONAL_QUESTS", + "className": "SeasonalQuestsParser", + "path": "packages/communication/src/messages/parser/quest/SeasonalQuestsParser.ts" + }, + "reason": "Java analyzer: Data-dependent packet operations in ForEachStmt inside composeInternal; TypeScript analyzer: Data-dependent packet operations in ForStatement inside parse" + }, + { + "name": "QUEST_DAILY", + "direction": "server_to_client", + "header": 1878, + "java": { + "symbol": "DailyQuestComposer", + "className": "DailyQuestComposer", + "path": "Emulator/src/main/java/com/eu/habbo/messages/outgoing/quests/DailyQuestComposer.java" + }, + "typescript": { + "symbol": "QUEST_DAILY", + "className": "QuestDailyMessageParser", + "path": "packages/communication/src/messages/parser/quest/QuestDailyMessageParser.ts" + }, + "reason": "Java analyzer: Packet fields delegated to external serializer serialize inside composeInternal; TypeScript analyzer: Packet fields delegated to external constructor QuestMessageData inside parse" + }, + { + "name": "ACTIVE_DAILY_TASKS", + "direction": "server_to_client", + "header": 2900, + "java": { + "symbol": "ActiveDailyTasksComposer", + "className": "ActiveDailyTasksComposer", + "path": "Emulator/src/main/java/com/eu/habbo/messages/outgoing/quests/ActiveDailyTasksComposer.java" + }, + "typescript": { + "symbol": "ACTIVE_DAILY_TASKS", + "className": "ActiveDailyTasksMessageParser", + "path": "packages/communication/src/messages/parser/quest/ActiveDailyTasksMessageParser.ts" + }, + "reason": "Java analyzer: Data-dependent packet operations in ForEachStmt inside composeInternal; TypeScript analyzer: Data-dependent packet operations in ForStatement inside parse" + }, + { + "name": "DAILY_TASKS_ADDED", + "direction": "server_to_client", + "header": 670, + "java": { + "symbol": "DailyTasksAddedComposer", + "className": "DailyTasksAddedComposer", + "path": "Emulator/src/main/java/com/eu/habbo/messages/outgoing/quests/DailyTasksAddedComposer.java" + }, + "typescript": { + "symbol": "DAILY_TASKS_ADDED", + "className": "ActiveDailyTasksMessageParser", + "path": "packages/communication/src/messages/parser/quest/ActiveDailyTasksMessageParser.ts" + }, + "reason": "Java analyzer: Data-dependent packet operations in ForEachStmt inside composeInternal; TypeScript analyzer: Data-dependent packet operations in ForStatement inside parse" + }, + { + "name": "DAILY_TASK_UPDATED", + "direction": "server_to_client", + "header": 9450, + "java": { + "symbol": "DailyTaskUpdatedComposer", + "className": "DailyTaskUpdatedComposer", + "path": "Emulator/src/main/java/com/eu/habbo/messages/outgoing/quests/DailyTaskUpdatedComposer.java" + }, + "typescript": { + "symbol": "DAILY_TASK_UPDATED", + "className": "DailyTaskUpdatedMessageParser", + "path": "packages/communication/src/messages/parser/quest/DailyTaskUpdatedMessageParser.ts" + }, + "reason": "TypeScript analyzer: the 64-bit task id is read through the external helper DailyTaskData.readLong inside parse; the Java side writes it as two ints" + }, + { + "name": "REWARD_TRACKS", + "direction": "server_to_client", + "header": 2327, + "java": { + "symbol": "RewardTracksComposer", + "className": "RewardTracksComposer", + "path": "Emulator/src/main/java/com/eu/habbo/messages/outgoing/quests/RewardTracksComposer.java" + }, + "typescript": { + "symbol": "REWARD_TRACKS", + "className": "RewardTracksMessageParser", + "path": "packages/communication/src/messages/parser/quest/RewardTracksMessageParser.ts" + }, + "reason": "Java analyzer: Data-dependent packet operations in ForEachStmt inside composeInternal; TypeScript analyzer: Data-dependent packet operations in ForStatement inside parse" } ] }