diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f7fe301 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,279 @@ +# Nitro_Render_V3 — Claude project context + +Pure-TypeScript renderer library for the Nitro retro Habbo client. +Wraps **PixiJS v8** for room/avatar rendering and provides the WebSocket ++ event-bus infrastructure that the React client (`../Nitro-V3`) sits on +top of. + +## Stack + +- **TypeScript 6.0** (root) + **tsgo** (`@typescript/native-preview`, + TS 7 preview compiler — used by `yarn compile:fast`, ~7× faster on + this codebase) +- **PixiJS v8** (`pixi.js@8.18`) +- **Vite 8** for build + bundling +- **Vitest 4** for unit tests +- **Yarn 1.22 workspaces** (`packages/*`) — note: yarn 1, NOT yarn 4 like + the client. The two repos use different package managers on purpose. +- **No React** — this is a pure TS library; React lives in `../Nitro-V3`. + +## Workspace layout + +Twelve internal packages under `packages/*/src/`, each pinning +`typescript: ^6.0.3` in its own `devDependencies`: + +``` +packages/ + api public interfaces (IEventDispatcher, ISessionDataManager, ...) + assets asset loading + caching + avatar avatar rendering / figure resolution + camera in-room camera widget + communication WebSocket + composer/parser pipeline + configuration runtime config loader + events EventDispatcher + NitroEventType + per-domain events + localization LocalizationManager + room RoomEngine + RoomVisualization + session SessionDataManager + RoomSessionManager + handlers + sound SoundManager (howler-based) + utils shared utilities (BinaryReader, Logger, …) +``` + +Root `index.ts` re-exports everything from `@nitrots/*` so the React +client gets a flat `import { … } from '@nitrots/nitro-renderer'`. + +## React-friendly API additions (v2.1.0) + +Three additions matter for the React client integration. Keep these +backwards-compatible: + +### `EventDispatcher.subscribe(type, callback): () => void` + +Signature matches what `useSyncExternalStore` expects — returns an +unsubscriber, no need to juggle callback identity. Implemented in +`packages/events/src/EventDispatcher.ts`. The legacy +`addEventListener` / `removeEventListener` still work. + +### `CommunicationManager.subscribeMessage(eventCtor, handler): () => void` + +Equivalent for packet streams. Implemented in +`packages/communication/src/CommunicationManager.ts`. + +### Snapshot getters (referentially stable, lazy-frozen, invalidated on mutation) + +Pattern: `getXxxSnapshot()` returns a frozen value cached internally; +mutators call `invalidateXxxSnapshot()` which drops the cache AND +dispatches an invalidation event. The React side reads via +`useSyncExternalStore`. + +| Manager | Getter | Invalidation event | +|---|---|---| +| `SessionDataManager` | `getUserDataSnapshot(): Readonly` | `SESSION_DATA_UPDATED` | +| `RoomSessionManager` | `getActiveRoomSessionSnapshot(): Readonly \| null` | `ROOM_SESSION_UPDATED` | +| `IgnoredUsersManager` | `getIgnoredUsersSnapshot(): ReadonlyArray` | `IGNORED_USERS_UPDATED` | +| `GroupInformationManager` | `getGroupBadgesSnapshot(): ReadonlyMap` | `GROUP_BADGES_UPDATED` (only on real changes — no-op refresh stays quiet) | +| `UserDataManager` | `getRoomUserListSnapshot(): ReadonlyArray` | `ROOM_USER_LIST_UPDATED` (inner IRoomUserData kept mutable — don't deep-clone) | +| `SoundManager` | `getVolumesSnapshot(): Readonly` | `SOUND_VOLUMES_UPDATED` (only when a volume actually changes) | + +Snapshot interface contracts live under `packages/api/src/nitro/session/` +and `packages/api/src/nitro/sound/`. When adding a new snapshot, the +checklist is: +1. Define the `Ixxx Snapshot` interface in `packages/api/src/nitro/...` + and export it from the matching `index.ts`. +2. Add a `XXX_UPDATED` member to `packages/events/src/NitroEventType.ts`. +3. Add `getXxxSnapshot()` to the interface AND impl; cache + invalidate + on every mutation path (don't forget batch operations like queue + truncation — invalidate AFTER the full batch, not mid-way). + +Adding snapshots here is the preferred way to unblock new React +widgets — prefer it over exposing raw event-listener APIs on the +client side. + +## Recent renderer changes (`feat/react19-event-bus`) + +Tracked separately from the v2.1.0 batch above; all are +non-breaking additions or align-with-Arcturus fixes: + +### RoomEnterComposer: optional `spawnX` / `spawnY` + +`new RoomEnterComposer(roomId, password?, spawnX?, spawnY?)`. The +Arcturus `RequestRoomLoadEvent` handler reads the two extra ints only +when `packet.remaining >= 8`, so the same composer header serves both +the legacy 2-arg form (door spawn) and the 4-arg form (reconnect / +respawn at a specific tile). RoomSession + RoomSessionManager use the +4-arg variant in their `enterRoom` / reconnect paths. + +### RoomSettingsData: `allowUnderpass` field + +`RoomSettingsData` (and its parser) now exposes `allowUnderpass: +boolean`. Arcturus' `RoomSettingsComposer` already appends one +trailing int for this flag, and the new parser reads it via +`if(wrapper.bytesAvailable) … readInt() === 1` so older servers that +don't emit the field still parse cleanly. `SaveRoomSettingsComposer` +accepts an optional `allowUnderpass` arg at the end of its parameter +list; the server-side `RoomSettingsSaveEvent` reads it under +`packet.bytesAvailable() > 0`. + +### `RoomUnitParser` per-user `borderId` (Infostand Borders wire contract) + +`RoomUsersComposer` on Arcturus (post `54259f8` / fork commit `8f8f568` +"Infostand Borders") appends `appendInt(getInfostandBorder())` at the +end of EVERY user record — habbo, bot, rentable bot — using `0` as the +constant for records without a real border. To stay wire-aligned with +that, `RoomUnitParser` reads `user.borderId = wrapper.readInt();` +unconditionally inside the per-user loop, after +`roomEntryMethod` / `roomEntryTeleportId`. + +DO NOT wrap this in a `wrapper.bytesAvailable ? readInt() : 0` guard. +`bytesAvailable` is a boolean meaning "any bytes left in the WHOLE +packet?" — not "any bytes left for THIS user". For any non-last user +the guard evaluates `true` (next user's bytes follow) and reads, which +is fine ONLY by coincidence when the server emits borderId per user. +On a server that doesn't emit it, the guard steals 4 bytes from the +next record and cascade-corrupts the whole roster (symptom: users not +seeing each other on room enter). On a server that DOES emit it, +skipping the read leaves 4 unconsumed bytes per record and produces +the same corruption. Both shapes are wrong in a loop; unconditional +read paired with a server contract that always emits is the only +correct combination. + +If you ever need to pair this parser with an older Arcturus that +doesn't emit per-user borderId, the fix is on the server side (add +the cherry-pick), not the parser side. Document any future +trailing-int extension in this same place so the next reader doesn't +re-introduce a bytesAvailable guard "for safety". + +### Dropped dead code: `sendWhisperGroupMessage` + +`IRoomSession.sendWhisperGroupMessage(userId)` referenced a +`ChatWhisperGroupComposer` that never existed in the codebase and had +zero call sites in the React client. Both the interface declaration +and the broken impl are removed. The real whisper path is +`RoomUnitChatWhisperComposer(recipientName, message, styleId)` — +unchanged. + +### TS 5.7+ and Pixi v8 alignment + +- `ArrayBufferLike` drift handled with explicit casts in `BinaryReader` + / `BinaryWriter` / `WsSessionCrypto.randomNonce()` / + `ArrayBufferToBase64`. The renderer never uses SharedArrayBuffer, so + these are type-level narrowings only. +- `Container.filters` in Pixi v8 is `Filter[] | readonly Filter[] | null`; + the AvatarImage filter-stack mutation always goes through the + spread-array branch now (no single-Filter fallback). `Filter` is + imported explicitly from pixi.js. +- `ExtendedSprite` casts the renderer to `WebGLRenderer` inside the + `RendererType.WEBGL` branch so `renderer.gl` / + `glRenderTarget.resolveTargetFramebuffer` resolve. +- `FurnitureBadgeDisplayVisualization.updateSprite` signature realigned + to the parent's 2-arg `(scale, layerId)` shape (was a custom 4-arg + override that broke base-class assignability). +- `TextureUtils.generateImage` casts the extractor's `ImageLike` + union return to `HTMLImageElement` (the default backend produces + one). +- `Window.NitroConfig` declaration in `NitroConfig.ts` realigned to + the client's `Record` type so the merged decls + agree. +- Empty-tuple composers (`WiredRoomSettingsRequestComposer`, + `WiredUserVariablesRequestComposer`) annotate the return type + `(): []` explicitly so `IMessageComposer<[]>` lines up. + +### Optional-trailing-field parsers: flat early-return chain + +Parsers that read "one tier of optional trailing fields per emulator +release" (UserProfileParser, GetGuestRoomResultMessageParser, +RoomSettingsDataParser, ModeratorUserInfoData, UserSubscriptionParser +…) all use a flat chain: + +```ts +if(!wrapper.bytesAvailable) return true; +// block N reads +if(!wrapper.bytesAvailable) return true; +// block N+1 reads +… +``` + +Defaults come from `flush()`. When the next emulator release ships a +new trailing block, append `if(!wrapper.bytesAvailable) return true;` ++ the new reads. Do NOT nest with `if(wrapper.bytesAvailable) { … }` +— the nested form re-indents the whole chain on every new tier and +is the historical source of brittle reads. + +### Bug fix: `SoundManager` volume diff comparison + +`onEvent(SETTINGS_UPDATED)` cached `volumeFurniUpdated` / +`volumeTraxUpdated` by comparing `castedEvent.volumeFurni` (percent, +e.g. 75) against `this._volumeFurni` (fraction, e.g. 0.75) — so the +change check almost always reported "updated" for a real settings push +and only reported "unchanged" if the percent matched the fraction by +coincidence (0 / 100 only). Fixed: divide first, compare divided +values, then write. Also tracks `volumeSystemUpdated` for the new +`SOUND_VOLUMES_UPDATED` snapshot invalidation. + +### Bug fix: `PetBreedingMessageParser.bytesAvailable < 12` + +`bytesAvailable` is a boolean (the wrapper just answers "is there +anything left?"). The pet-breeding parser used to compare it against +`12` as if it were a byte count, which TS 6 caught and which was +also semantically wrong. Replaced with the standard +`if(!wrapper || !wrapper.bytesAvailable) return false;` guard. + +## Scripts + +``` +yarn build # vite build +yarn compile # tsc --project ./tsconfig.json --noEmit false +yarn compile:fast # tsgo (~7× faster, TS 7 preview) +yarn eslint # lint src + packages/*/src +yarn test # vitest run +yarn test:watch # vitest watch +yarn test:coverage # vitest with v8 coverage +``` + +## Consumed by + +`../Nitro-V3` consumes this library via `link:../Nitro_Render_V3` +(yarn 4 node-modules linker). DO NOT use `yarn link` — it confuses +vite's asset resolution. The client's `vite.config.js` then maps each +`@nitrots/*` package directly to its source `index.ts` so there's no +build step needed for development. + +When making changes to renderer APIs the React client uses, the +client's `feat/react19-*` branches contain consumers — check +`Nitro-V3/src/hooks/events/` and `Nitro-V3/src/hooks/{session,rooms}/` +for the React-side bridge code. + +## Gotchas + +- **`SessionDataManager.getUserData(id)` does NOT exist.** Some legacy + code in the React client used it under a `getUserData ?` truthy guard; + the branch was always dead. Only `getUserDataSnapshot()` exists. +- **`bytesAvailable` is a boolean.** The codebase historically had one + parser (`PetBreedingMessageParser`) that compared it against a + number — fixed. The wrapper returns "any bytes left?", not a count. + Use it as a truthy guard or follow with `try {} catch` if you need + optional reads. +- **Composer `getMessageArray()` return type must match the type + argument.** `IMessageComposer<[]>` means the function returns `[]`, + not `any[]`. The two `Wired*RequestComposer`s that ship empty + payloads each annotate `getMessageArray(): []` explicitly. +- `IRoomSession.sendChatMessage` / `sendShoutMessage` accept an optional + `chatColour` 3rd arg (was required pre-2.1.1, now optional to match + the historical call sites in the React client). The implementation + forwards `undefined` to the composer just fine; pass a value only when + you need a specific bubble colour. +- `IRoomSession.password` and `IRoomSession.sendBackgroundMessage` are + now part of the public interface (they always existed on the + implementation class — interface caught up). +- The renderer is **synchronous**: `EventDispatcher.dispatchEvent` is a + synchronous loop over listeners. Don't add `await` inside the + `processEvent` loop — it would change ordering guarantees that + consumers rely on. +- Workspace package devDeps pin TS at `^6.0.3` so `yarn compile` inside + any single package keeps working. The root TS 6 is the source of + truth. + +## Sister projects in the same DEV folder + +- `../Nitro-V3` — React 19 client (consumes this lib via link) +- `../Arcturus-Morningstar-Extended` — Java emulator (server side) +- `../NitroV3-Housekeeping` — Next.js + Prisma admin CMS diff --git a/package.json b/package.json index ef672d7..60ecedc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@nitrots/nitro-renderer", "description": "Javascript library for rendering Nitro in the browser using PixiJS", - "version": "2.0.0", + "version": "2.1.0", "private": true, "type": "module", "workspaces": [ @@ -22,6 +22,7 @@ "scripts": { "build": "vite build", "compile": "tsc --project ./tsconfig.json --noEmit false", + "compile:fast": "tsgo --project ./tsconfig.json --noEmit", "eslint": "eslint ./src ./packages/*/src", "eslint-fix": "eslint ./src --fix", "test": "vitest run", @@ -30,7 +31,7 @@ }, "main": "./index", "dependencies": { - "@thumbmarkjs/thumbmarkjs": "^1.8.1", + "@thumbmarkjs/thumbmarkjs": "^1.9.0", "gifuct-js": "^2.1.2", "howler": "^2.2.4", "json5": "^2.2.3", @@ -47,8 +48,9 @@ "@vitest/coverage-v8": "^4.0.18", "eslint": "^9.8.0", "jsdom": "^27.4.0", + "@typescript/native-preview": "^7.0.0-dev.20260510.1", "tslib": "^2.6.3", - "typescript": "~5.8.2", + "typescript": "^6.0.3", "typescript-eslint": "^8.26.1", "vite": "^8.0.10", "vitest": "^4.1.5" diff --git a/packages/api/package.json b/packages/api/package.json index ac5ffdb..db9f80d 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -15,6 +15,6 @@ "pixi.js": "^8.8.1" }, "devDependencies": { - "typescript": "~5.8.2" + "typescript": "^6.0.3" } } diff --git a/packages/api/src/common/IEventDispatcher.ts b/packages/api/src/common/IEventDispatcher.ts index 2e4ebdb..9217b0c 100644 --- a/packages/api/src/common/IEventDispatcher.ts +++ b/packages/api/src/common/IEventDispatcher.ts @@ -7,4 +7,5 @@ export interface IEventDispatcher removeEventListener(type: string, callback: Function): void; removeAllListeners(): void; dispatchEvent(event: T): boolean; + subscribe(type: string | string[], callback: (event: T) => void): () => void; } diff --git a/packages/api/src/communication/ICommunicationManager.ts b/packages/api/src/communication/ICommunicationManager.ts index 7c15877..76e17e0 100644 --- a/packages/api/src/communication/ICommunicationManager.ts +++ b/packages/api/src/communication/ICommunicationManager.ts @@ -6,5 +6,6 @@ export interface ICommunicationManager init(): Promise; registerMessageEvent(event: IMessageEvent): IMessageEvent; removeMessageEvent(event: IMessageEvent): void; + subscribeMessage(eventCtor: new (callback: (event: T) => void) => T, handler: (event: T) => void): () => void; connection: IConnection; } diff --git a/packages/api/src/nitro/session/IGroupInformationManager.ts b/packages/api/src/nitro/session/IGroupInformationManager.ts index 4304483..1fc8411 100644 --- a/packages/api/src/nitro/session/IGroupInformationManager.ts +++ b/packages/api/src/nitro/session/IGroupInformationManager.ts @@ -2,4 +2,13 @@ export interface IGroupInformationManager { init(): void; getGroupBadge(groupId: number): string; + + /** + * Returns the current `groupId -> badgeId` map as a frozen, + * referentially stable ReadonlyMap. The same reference is returned + * across reads until the underlying badges change; mutations + * dispatch `NitroEventType.GROUP_BADGES_UPDATED` to signal + * invalidation. + */ + getGroupBadgesSnapshot(): ReadonlyMap; } diff --git a/packages/api/src/nitro/session/IIgnoredUsersManager.ts b/packages/api/src/nitro/session/IIgnoredUsersManager.ts index aa065b3..f4b032e 100644 --- a/packages/api/src/nitro/session/IIgnoredUsersManager.ts +++ b/packages/api/src/nitro/session/IIgnoredUsersManager.ts @@ -6,4 +6,14 @@ export interface IIgnoredUsersManager ignoreUser(name: string): void; unignoreUser(name: string): void; isIgnored(name: string): boolean; + + /** + * Returns the current ignored-users list as a frozen, referentially + * stable array. The same reference is returned across reads until + * the list is mutated; mutations dispatch + * `NitroEventType.IGNORED_USERS_UPDATED` to signal invalidation. + * + * Pairs with `useSyncExternalStore` on the React client. + */ + getIgnoredUsersSnapshot(): ReadonlyArray; } diff --git a/packages/api/src/nitro/session/IRoomSession.ts b/packages/api/src/nitro/session/IRoomSession.ts index 02a905e..5323a9b 100644 --- a/packages/api/src/nitro/session/IRoomSession.ts +++ b/packages/api/src/nitro/session/IRoomSession.ts @@ -9,10 +9,11 @@ export interface IRoomSession setRoomOwner(): void; start(): boolean; reset(roomId: number): void; - sendChatMessage(text: string, styleId: number, chatColour: string): void; - sendShoutMessage(text: string, styleId: number, chatColour: string): void; + sendChatMessage(text: string, styleId: number, chatColour?: string): void; + sendShoutMessage(text: string, styleId: number, chatColour?: string): void; sendWhisperMessage(recipientName: string, text: string, styleId: number): void; sendChatTypingMessage(isTyping: boolean): void; + sendBackgroundMessage(backgroundImage: number, backgroundStand: number, backgroundOverlay: number, backgroundCard?: number, backgroundBorder?: number): void; sendMottoMessage(motto: string): void; sendDanceMessage(danceId: number): void; sendExpressionMessage(expression: number): void; @@ -20,7 +21,6 @@ export interface IRoomSession sendPostureMessage(posture: number): void; sendDoorbellApprovalMessage(userName: string, flag: boolean): void; sendAmbassadorAlertMessage(userId: number): void; - sendWhisperGroupMessage(userId: number): void; sendKickMessage(userId: number): void; sendMuteMessage(userId: number, minutes: number): void; sendBanMessage(userId: number, type: string): void; @@ -50,6 +50,7 @@ export interface IRoomSession sendScriptProceed(): void; userDataManager: IUserDataManager; roomId: number; + password: string; state: string; tradeMode: number; isPrivateRoom: boolean; diff --git a/packages/api/src/nitro/session/IRoomSessionManager.ts b/packages/api/src/nitro/session/IRoomSessionManager.ts index 974c98a..777345e 100644 --- a/packages/api/src/nitro/session/IRoomSessionManager.ts +++ b/packages/api/src/nitro/session/IRoomSessionManager.ts @@ -1,4 +1,5 @@ import { IRoomSession } from './IRoomSession'; +import { IRoomSessionSnapshot } from './IRoomSessionSnapshot'; export interface IRoomSessionManager { @@ -8,5 +9,6 @@ export interface IRoomSessionManager startSession(session: IRoomSession): boolean; removeSession(id: number, openLandingView?: boolean): void; tryRestoreSession(): boolean; + getActiveRoomSessionSnapshot(): Readonly | null; viewerSession: IRoomSession; } diff --git a/packages/api/src/nitro/session/IRoomSessionSnapshot.ts b/packages/api/src/nitro/session/IRoomSessionSnapshot.ts new file mode 100644 index 0000000..5b1b7e2 --- /dev/null +++ b/packages/api/src/nitro/session/IRoomSessionSnapshot.ts @@ -0,0 +1,18 @@ +import { IRoomSession } from './IRoomSession'; + +export interface IRoomSessionSnapshot +{ + roomId: number; + state: string; + isRoomOwner: boolean; + isSpectator: boolean; + isDecorating: boolean; + isGuildRoom: boolean; + isPrivateRoom: boolean; + controllerLevel: number; + doorMode: number; + tradeMode: number; + allowPets: boolean; + groupId: number; + session: IRoomSession; +} diff --git a/packages/api/src/nitro/session/ISessionDataManager.ts b/packages/api/src/nitro/session/ISessionDataManager.ts index 26947e6..3c4ba6f 100644 --- a/packages/api/src/nitro/session/ISessionDataManager.ts +++ b/packages/api/src/nitro/session/ISessionDataManager.ts @@ -3,6 +3,7 @@ import { IFurnitureData } from './IFurnitureData'; import { IGroupInformationManager } from './IGroupInformationManager'; import { IIgnoredUsersManager } from './IIgnoredUsersManager'; import { IProductData } from './IProductData'; +import { IUserDataSnapshot } from './IUserDataSnapshot'; export interface ISessionDataManager { @@ -53,4 +54,12 @@ export interface ISessionDataManager isCameraFollowDisabled: boolean; uiFlags: number; tags: string[]; + getUserDataSnapshot(): Readonly; + /** + * Referentially-stable view of the resolved permission map for + * the current user. Invalidated by `USER_PERMISSIONS_UPDATED`. + * Empty when the connected emulator doesn't ship the extended + * `UserPermissionsMapComposer` (Arcturus ≥ 4.2.10). + */ + getPermissionsSnapshot(): ReadonlyMap; } diff --git a/packages/api/src/nitro/session/IUserDataManager.ts b/packages/api/src/nitro/session/IUserDataManager.ts index 960dac2..a01e710 100644 --- a/packages/api/src/nitro/session/IUserDataManager.ts +++ b/packages/api/src/nitro/session/IUserDataManager.ts @@ -23,4 +23,20 @@ export interface IUserDataManager updatePetLevel(roomIndex: number, level: number): void; updatePetBreedingStatus(roomIndex: number, canBreed: boolean, canHarvest: boolean, canRevive: boolean, hasBreedingPermission: boolean): void; requestPetInfo(id: number): void; + + /** + * Returns the current room's user list as a referentially-stable + * ReadonlyArray. The same array reference is returned across reads + * until any user is added, removed, or has a tracked field updated + * (figure / name / motto / nick icon / customization / background / + * achievement score / pet level / breeding status). Mutations + * dispatch `NitroEventType.ROOM_USER_LIST_UPDATED` to signal + * invalidation. + * + * The inner IRoomUserData objects keep the existing in-place + * mutation semantics — they are NOT deep-cloned. Treat them as + * snapshots-at-time-of-read; consumers should not retain individual + * entries across invalidations. + */ + getRoomUserListSnapshot(): ReadonlyArray; } diff --git a/packages/api/src/nitro/session/IUserDataSnapshot.ts b/packages/api/src/nitro/session/IUserDataSnapshot.ts new file mode 100644 index 0000000..9d7a24f --- /dev/null +++ b/packages/api/src/nitro/session/IUserDataSnapshot.ts @@ -0,0 +1,31 @@ +export interface IUserDataSnapshot +{ + userId: number; + userName: string; + figure: string; + gender: string; + realName: string; + respectsReceived: number; + respectsLeft: number; + respectsPetLeft: number; + canChangeName: boolean; + clubLevel: number; + securityLevel: number; + isAmbassador: boolean; + isEmailVerified: boolean; + isNoob: boolean; + isAuthenticHabbo: boolean; + isSystemOpen: boolean; + isSystemShutdown: boolean; + uiFlags: number; + tags: ReadonlyArray; + // Rank metadata mirrored from `permission_ranks` (Arcturus emulator + // ≥ 4.2.10 ships these via `UserPermissionsComposer`). Older + // emulators leave them at the defaults (rankId=0, empty strings) + // because the renderer-side parser short-circuits on bytesAvailable. + rankId: number; + rankName: string; + rankBadge: string; + rankPrefix: string; + rankPrefixColor: string; +} diff --git a/packages/api/src/nitro/session/index.ts b/packages/api/src/nitro/session/index.ts index 1fe774d..c93302b 100644 --- a/packages/api/src/nitro/session/index.ts +++ b/packages/api/src/nitro/session/index.ts @@ -18,6 +18,8 @@ export * from './IRoomSessionManager'; export * from './IRoomUserData'; export * from './ISessionDataManager'; export * from './IUserDataManager'; +export * from './IUserDataSnapshot'; +export * from './IRoomSessionSnapshot'; export * from './PetBreedingResultData'; export * from './PetCustomPart'; export * from './PetFigureData'; diff --git a/packages/api/src/nitro/sound/ISoundManager.ts b/packages/api/src/nitro/sound/ISoundManager.ts index 87714b5..8db15ba 100644 --- a/packages/api/src/nitro/sound/ISoundManager.ts +++ b/packages/api/src/nitro/sound/ISoundManager.ts @@ -1,8 +1,22 @@ import { IMusicController } from './IMusicController'; +import { ISoundVolumesSnapshot } from './ISoundVolumesSnapshot'; export interface ISoundManager { init(): Promise; musicController: IMusicController; traxVolume: number; + systemVolume: number; + furniVolume: number; + + /** + * Returns a referentially-stable snapshot of the three volume + * levels (system / furni / trax). The same reference is returned + * across reads until a volume changes; mutations dispatch + * `NitroEventType.SOUND_VOLUMES_UPDATED` to signal invalidation. + * + * Pairs with `useSyncExternalStore` on the React client for + * volume-slider widgets. + */ + getVolumesSnapshot(): Readonly; } diff --git a/packages/api/src/nitro/sound/ISoundVolumesSnapshot.ts b/packages/api/src/nitro/sound/ISoundVolumesSnapshot.ts new file mode 100644 index 0000000..f61c1e1 --- /dev/null +++ b/packages/api/src/nitro/sound/ISoundVolumesSnapshot.ts @@ -0,0 +1,6 @@ +export interface ISoundVolumesSnapshot +{ + system: number; + furni: number; + trax: number; +} diff --git a/packages/api/src/nitro/sound/index.ts b/packages/api/src/nitro/sound/index.ts index be4bfe4..f1e1c21 100644 --- a/packages/api/src/nitro/sound/index.ts +++ b/packages/api/src/nitro/sound/index.ts @@ -2,3 +2,4 @@ export * from './IMusicController'; export * from './IPlaylistController'; export * from './ISongInfo'; export * from './ISoundManager'; +export * from './ISoundVolumesSnapshot'; diff --git a/packages/assets/package.json b/packages/assets/package.json index 00a013b..3fe5a7a 100644 --- a/packages/assets/package.json +++ b/packages/assets/package.json @@ -14,10 +14,10 @@ "dependencies": { "@nitrots/api": "1.0.0", "@nitrots/utils": "1.0.0", - "@pixi/gif": "^3.0.1", - "pixi.js": "^8.8.1" + "@pixi/gif": "^3.0.1", + "pixi.js": "^8.8.1" }, "devDependencies": { - "typescript": "~5.8.2" + "typescript": "^6.0.3" } } diff --git a/packages/assets/src/AssetManager.ts b/packages/assets/src/AssetManager.ts index 7fe20de..a9d652a 100644 --- a/packages/assets/src/AssetManager.ts +++ b/packages/assets/src/AssetManager.ts @@ -227,7 +227,7 @@ export class AssetManager implements IAssetManager for(const path in merged) { const mod = merged[path]; - const imageUrl = (mod.default ?? mod) as string; + const imageUrl = ((mod as { default?: string }).default ?? mod) as string; const file = path.split('/').pop()!; const rawName = file.replace(/\.png$/i, ''); @@ -296,7 +296,7 @@ export class AssetManager implements IAssetManager if(!path.startsWith(prefix)) continue; const mod = allImages[path]; - const imageUrl = (mod.default ?? mod) as string; + const imageUrl = ((mod as { default?: string }).default ?? mod) as string; const file = path.split('/').pop()!; const rawName = file.replace(/\.png$/i, ''); diff --git a/packages/avatar/package.json b/packages/avatar/package.json index 3f70573..ee629be 100644 --- a/packages/avatar/package.json +++ b/packages/avatar/package.json @@ -15,6 +15,6 @@ "@nitrots/utils": "1.0.0" }, "devDependencies": { - "typescript": "~5.8.2" + "typescript": "^6.0.3" } } diff --git a/packages/avatar/src/AvatarImage.ts b/packages/avatar/src/AvatarImage.ts index b34d1e5..5d23c84 100644 --- a/packages/avatar/src/AvatarImage.ts +++ b/packages/avatar/src/AvatarImage.ts @@ -1,6 +1,6 @@ -import { AvatarAction, AvatarDirectionAngle, AvatarScaleType, AvatarSetType, IActiveActionData, IAnimationLayerData, IAvatarDataContainer, IAvatarEffectListener, IAvatarFigureContainer, IAvatarImage, IPartColor, ISpriteDataContainer } from '@nitrots/api'; +import { AvatarAction, AvatarDirectionAngle, AvatarScaleType, AvatarSetType, IActiveActionData, IAnimationLayerData, IAvatarDataContainer, IAvatarEffectListener, IAvatarFigureContainer, IAvatarImage, IGraphicAsset, IPartColor, ISpriteDataContainer } from '@nitrots/api'; import { GetRenderer, GetTexturePool, GetTickerTime, PaletteMapFilter, TextureUtils } from '@nitrots/utils'; -import { ColorMatrixFilter, Container, RenderTexture, Sprite, Texture } from 'pixi.js'; +import { ColorMatrixFilter, Container, Filter, RenderTexture, Sprite, Texture } from 'pixi.js'; import { AvatarFigureContainer } from './AvatarFigureContainer'; import { AvatarStructure } from './AvatarStructure'; import { EffectAssetDownloadManager } from './EffectAssetDownloadManager'; @@ -243,8 +243,7 @@ export class AvatarImage implements IAvatarImage, IAvatarEffectListener if(this._avatarSpriteData.colorTransform) { if(container.filters === undefined || container.filters === null) container.filters = [ this._avatarSpriteData.colorTransform ]; - else if(Array.isArray(container.filters)) container.filters = [ ...container.filters, this._avatarSpriteData.colorTransform ]; - else container.filters = [ container.filters, this._avatarSpriteData.colorTransform ]; + else container.filters = [ ...(container.filters as readonly Filter[]), this._avatarSpriteData.colorTransform ]; } if(this._avatarSpriteData.paletteIsGrayscale) @@ -257,8 +256,7 @@ export class AvatarImage implements IAvatarImage, IAvatarEffectListener }); if(container.filters === undefined || container.filters === null) container.filters = [ paletteMapFilter ]; - else if(Array.isArray(container.filters)) container.filters = [ ...container.filters, paletteMapFilter ]; - else container.filters = [ container.filters, paletteMapFilter ]; + else container.filters = [ ...(container.filters as readonly Filter[]), paletteMapFilter ]; } } @@ -766,8 +764,7 @@ export class AvatarImage implements IAvatarImage, IAvatarEffectListener ]; if(container.filters === undefined || container.filters === null) container.filters = [ filter ]; - else if(Array.isArray(container.filters)) container.filters = [ ...container.filters, filter ]; - else container.filters = [ container.filters, filter ]; + else container.filters = [ ...(container.filters as readonly Filter[]), filter ]; return container; } diff --git a/packages/camera/package.json b/packages/camera/package.json index d189993..cb6cc73 100644 --- a/packages/camera/package.json +++ b/packages/camera/package.json @@ -17,6 +17,6 @@ "pixi.js": "^8.8.1" }, "devDependencies": { - "typescript": "~5.8.2" + "typescript": "^6.0.3" } } diff --git a/packages/communication/package.json b/packages/communication/package.json index 7d05ed6..fc402e8 100644 --- a/packages/communication/package.json +++ b/packages/communication/package.json @@ -12,9 +12,9 @@ "@nitrots/api": "1.0.0", "@nitrots/events": "1.0.0", "@nitrots/utils": "1.0.0", - "@thumbmarkjs/thumbmarkjs": "^1.8.1" + "@thumbmarkjs/thumbmarkjs": "^1.9.0" }, "devDependencies": { - "typescript": "~5.8.2" + "typescript": "^6.0.3" } } diff --git a/packages/communication/src/CommunicationManager.ts b/packages/communication/src/CommunicationManager.ts index a9d2cad..c783183 100644 --- a/packages/communication/src/CommunicationManager.ts +++ b/packages/communication/src/CommunicationManager.ts @@ -203,6 +203,17 @@ export class CommunicationManager implements ICommunicationManager this._connection.removeMessageEvent(event); } + public subscribeMessage(eventCtor: new (callback: (event: T) => void) => T, handler: (event: T) => void): () => void + { + if(!eventCtor || !handler) return () => {}; + + const event = new eventCtor(handler); + + this.registerMessageEvent(event); + + return () => this.removeMessageEvent(event); + } + public get connection(): IConnection { return this._connection; diff --git a/packages/communication/src/SocketConnection.ts b/packages/communication/src/SocketConnection.ts index f0027fe..87ab694 100644 --- a/packages/communication/src/SocketConnection.ts +++ b/packages/communication/src/SocketConnection.ts @@ -1,4 +1,4 @@ -import { ICodec, IConnection, IMessageComposer, IMessageConfiguration, IMessageDataWrapper, IMessageEvent, WebSocketEventEnum } from '@nitrots/api'; +import { ICodec, IConnection, IMessageComposer, IMessageConfiguration, IMessageDataWrapper, IMessageEvent, IMessageParser, WebSocketEventEnum } from '@nitrots/api'; import { GetConfiguration } from '@nitrots/configuration'; import { GetEventDispatcher, NitroEvent, NitroEventType, ReconnectEvent } from '@nitrots/events'; import { NitroLogger } from '@nitrots/utils'; @@ -509,7 +509,7 @@ export class SocketConnection implements IConnection try { - const parser = new events[0].parserClass(); + const parser = new (events[0].parserClass as new () => IMessageParser)(); if(!parser || !parser.flush() || !parser.parse(wrapper)) return null; diff --git a/packages/communication/src/crypto/WsSessionCrypto.ts b/packages/communication/src/crypto/WsSessionCrypto.ts index e92078c..ed7f814 100644 --- a/packages/communication/src/crypto/WsSessionCrypto.ts +++ b/packages/communication/src/crypto/WsSessionCrypto.ts @@ -38,17 +38,17 @@ export async function deriveAesKey(sharedSecret: ArrayBuffer): Promise +export async function aesGcmEncrypt(key: CryptoKey, nonce: Uint8Array, plaintext: ArrayBuffer): Promise { return window.crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce, tagLength: GCM_TAG_LEN * 8 }, key, plaintext); } -export async function aesGcmDecrypt(key: CryptoKey, nonce: Uint8Array, ciphertextWithTag: ArrayBuffer): Promise +export async function aesGcmDecrypt(key: CryptoKey, nonce: Uint8Array, ciphertextWithTag: ArrayBuffer): Promise { return window.crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce, tagLength: GCM_TAG_LEN * 8 }, key, ciphertextWithTag); } -export function randomNonce(): Uint8Array +export function randomNonce(): Uint8Array { const n = new Uint8Array(NONCE_LEN); window.crypto.getRandomValues(n); diff --git a/packages/communication/src/messages/outgoing/room/access/RoomEnterComposer.ts b/packages/communication/src/messages/outgoing/room/access/RoomEnterComposer.ts index a195223..3c29e4b 100644 --- a/packages/communication/src/messages/outgoing/room/access/RoomEnterComposer.ts +++ b/packages/communication/src/messages/outgoing/room/access/RoomEnterComposer.ts @@ -1,12 +1,23 @@ import { IMessageComposer } from '@nitrots/api'; -export class RoomEnterComposer implements IMessageComposer> -{ - private _data: ConstructorParameters; +type RoomEnterPayload = [ number, string, number?, number? ]; - constructor(roomId: number, password: string = null) +export class RoomEnterComposer implements IMessageComposer +{ + private _data: RoomEnterPayload; + + /** + * Optional spawnX/spawnY let the server resume the avatar at a + * specific tile when re-entering the same room — used by the + * reconnect flow. Arcturus' RequestRoomLoadEvent reads both ints + * only if `packet.remaining >= 8`, so omitting them keeps the + * legacy enter-via-door behavior. + */ + constructor(roomId: number, password: string = null, spawnX?: number, spawnY?: number) { - this._data = [roomId, password]; + this._data = (spawnX !== undefined && spawnY !== undefined) + ? [ roomId, password, spawnX, spawnY ] + : [ roomId, password ]; } public getMessageArray() diff --git a/packages/communication/src/messages/outgoing/room/data/SaveRoomSettingsComposer.ts b/packages/communication/src/messages/outgoing/room/data/SaveRoomSettingsComposer.ts index 8b69492..a941b60 100644 --- a/packages/communication/src/messages/outgoing/room/data/SaveRoomSettingsComposer.ts +++ b/packages/communication/src/messages/outgoing/room/data/SaveRoomSettingsComposer.ts @@ -32,7 +32,8 @@ implements chatBubbleWeight: number, chatBubbleSpeed: number, chatDistance: number, - chatFloodProtection: number + chatFloodProtection: number, + allowUnderpass?: boolean ) { //@ts-ignore @@ -67,6 +68,8 @@ implements chatDistance, chatFloodProtection ); + + if(allowUnderpass !== undefined) this._data.push(allowUnderpass); } public getMessageArray() diff --git a/packages/communication/src/messages/outgoing/roomevents/WiredRoomSettingsRequestComposer.ts b/packages/communication/src/messages/outgoing/roomevents/WiredRoomSettingsRequestComposer.ts index c9f6948..7c67242 100644 --- a/packages/communication/src/messages/outgoing/roomevents/WiredRoomSettingsRequestComposer.ts +++ b/packages/communication/src/messages/outgoing/roomevents/WiredRoomSettingsRequestComposer.ts @@ -1,8 +1,8 @@ import { IMessageComposer } from '@nitrots/api'; -export class WiredRoomSettingsRequestComposer implements IMessageComposer> +export class WiredRoomSettingsRequestComposer implements IMessageComposer<[]> { - public getMessageArray() + public getMessageArray(): [] { return []; } diff --git a/packages/communication/src/messages/outgoing/roomevents/WiredUserVariablesRequestComposer.ts b/packages/communication/src/messages/outgoing/roomevents/WiredUserVariablesRequestComposer.ts index 83c9733..db6ab76 100644 --- a/packages/communication/src/messages/outgoing/roomevents/WiredUserVariablesRequestComposer.ts +++ b/packages/communication/src/messages/outgoing/roomevents/WiredUserVariablesRequestComposer.ts @@ -1,8 +1,8 @@ import { IMessageComposer } from '@nitrots/api'; -export class WiredUserVariablesRequestComposer implements IMessageComposer> +export class WiredUserVariablesRequestComposer implements IMessageComposer<[]> { - public getMessageArray() + public getMessageArray(): [] { return []; } diff --git a/packages/communication/src/messages/parser/inventory/pets/PetBreedingMessageParser.ts b/packages/communication/src/messages/parser/inventory/pets/PetBreedingMessageParser.ts index 7a2a576..8eed7d3 100644 --- a/packages/communication/src/messages/parser/inventory/pets/PetBreedingMessageParser.ts +++ b/packages/communication/src/messages/parser/inventory/pets/PetBreedingMessageParser.ts @@ -19,17 +19,16 @@ export class PetBreedingMessageParser implements IMessageParser return true; } - public parse(wrapper: IMessageDataWrapper): boolean { - if (!wrapper || wrapper.bytesAvailable < 12) { - return false; - } + public parse(wrapper: IMessageDataWrapper): boolean + { + if(!wrapper || !wrapper.bytesAvailable) return false; - this._state = wrapper.readInt(); - this._ownPetId = wrapper.readInt(); - this._otherPetId = wrapper.readInt(); + this._state = wrapper.readInt(); + this._ownPetId = wrapper.readInt(); + this._otherPetId = wrapper.readInt(); - return true; - } + return true; + } public get state(): number { diff --git a/packages/communication/src/messages/parser/navigator/GetGuestRoomResultMessageParser.ts b/packages/communication/src/messages/parser/navigator/GetGuestRoomResultMessageParser.ts index 0595de2..91e5654 100644 --- a/packages/communication/src/messages/parser/navigator/GetGuestRoomResultMessageParser.ts +++ b/packages/communication/src/messages/parser/navigator/GetGuestRoomResultMessageParser.ts @@ -45,12 +45,19 @@ export class GetGuestRoomResultMessageParser implements IMessageParser this.data.canMute = wrapper.readBoolean(); this._chat = new RoomChatSettings(wrapper); - if(wrapper.bytesAvailable) - { - this._hotelTimeZoneId = wrapper.readString(); - this._hotelCurrentTimeMs = Number(wrapper.readString()) || 0; - if(wrapper.bytesAvailable) this._roomItemLimit = wrapper.readInt(); - } + // Optional trailing blocks, one tier per emulator release: + // block 1: hotel timezone id + current time ms (2 strings) + // block 2: room item limit (1 int) + // Flat early-return chain so an older server stops cleanly at + // whichever block it doesn't ship. Defaults from flush(). + if(!wrapper.bytesAvailable) return true; + + this._hotelTimeZoneId = wrapper.readString(); + this._hotelCurrentTimeMs = Number(wrapper.readString()) || 0; + + if(!wrapper.bytesAvailable) return true; + + this._roomItemLimit = wrapper.readInt(); return true; } diff --git a/packages/communication/src/messages/parser/room/unit/RoomUnitParser.ts b/packages/communication/src/messages/parser/room/unit/RoomUnitParser.ts index de703ae..00ad5cd 100644 --- a/packages/communication/src/messages/parser/room/unit/RoomUnitParser.ts +++ b/packages/communication/src/messages/parser/room/unit/RoomUnitParser.ts @@ -146,7 +146,17 @@ export class RoomUnitParser implements IMessageParser user.roomEntryMethod = wrapper.readString(); user.roomEntryTeleportId = wrapper.readInt(); - user.borderId = (wrapper.bytesAvailable ? wrapper.readInt() : 0); + // Arcturus appends a trailing borderId int per user + // (RoomUsersComposer, after the Infostand Borders feature) + // for every record — habbo, bot, rentable bot — using 0 as + // the constant for the records that have no border. The + // read MUST be unconditional: a bytesAvailable guard would + // be semantically wrong here (the guard answers "any byte + // left in the whole packet?" not "any byte left for THIS + // user"), and skipping the read would leave 4 bytes per + // record and cascade-corrupt every subsequent user in the + // roster. + user.borderId = wrapper.readInt(); i++; } diff --git a/packages/communication/src/messages/parser/roomsettings/RoomSettingsData.ts b/packages/communication/src/messages/parser/roomsettings/RoomSettingsData.ts index 4384e5e..c558d17 100644 --- a/packages/communication/src/messages/parser/roomsettings/RoomSettingsData.ts +++ b/packages/communication/src/messages/parser/roomsettings/RoomSettingsData.ts @@ -37,6 +37,7 @@ export class RoomSettingsData private _roomModerationSettings: RoomModerationSettings = null; private _chatSettings: RoomChatSettings = null; private _allowNavigatorDynamicCats: boolean = false; + private _allowUnderpass: boolean = false; public static from(settings: RoomSettingsData) { @@ -65,6 +66,7 @@ export class RoomSettingsData instance._roomModerationSettings = settings._roomModerationSettings; instance._chatSettings = settings._chatSettings; instance._allowNavigatorDynamicCats = settings._allowNavigatorDynamicCats; + instance._allowUnderpass = settings._allowUnderpass; return instance; } @@ -329,4 +331,14 @@ export class RoomSettingsData { this._allowNavigatorDynamicCats = flag; } + + public get allowUnderpass(): boolean + { + return this._allowUnderpass; + } + + public set allowUnderpass(flag: boolean) + { + this._allowUnderpass = flag; + } } diff --git a/packages/communication/src/messages/parser/roomsettings/RoomSettingsDataParser.ts b/packages/communication/src/messages/parser/roomsettings/RoomSettingsDataParser.ts index ed02587..45a9650 100644 --- a/packages/communication/src/messages/parser/roomsettings/RoomSettingsDataParser.ts +++ b/packages/communication/src/messages/parser/roomsettings/RoomSettingsDataParser.ts @@ -49,6 +49,10 @@ export class RoomSettingsDataParser implements IMessageParser this._roomSettingsData.allowNavigatorDynamicCats = wrapper.readBoolean(); this._roomSettingsData.roomModerationSettings = new RoomModerationSettings(wrapper); + // Custom Arcturus extension: trailing int (0/1) for the underpass toggle. + // Older servers may not emit it; default stays false when absent. + if(wrapper.bytesAvailable) this._roomSettingsData.allowUnderpass = (wrapper.readInt() === 1); + return true; } diff --git a/packages/communication/src/messages/parser/user/access/UserPermissionsParser.ts b/packages/communication/src/messages/parser/user/access/UserPermissionsParser.ts index 4f40f45..369ba41 100644 --- a/packages/communication/src/messages/parser/user/access/UserPermissionsParser.ts +++ b/packages/communication/src/messages/parser/user/access/UserPermissionsParser.ts @@ -5,12 +5,24 @@ export class UserPermissionsParser implements IMessageParser private _clubLevel: number; private _securityLevel: number; private _isAmbassador: boolean; + private _rankId: number; + private _rankName: string; + private _rankBadge: string; + private _rankPrefix: string; + private _rankPrefixColor: string; + private _permissions: Map = new Map(); public flush(): boolean { this._clubLevel = 0; this._securityLevel = 0; this._isAmbassador = false; + this._rankId = 0; + this._rankName = ''; + this._rankBadge = ''; + this._rankPrefix = ''; + this._rankPrefixColor = ''; + this._permissions = new Map(); return true; } @@ -23,6 +35,37 @@ export class UserPermissionsParser implements IMessageParser this._securityLevel = wrapper.readInt(); this._isAmbassador = wrapper.readBoolean(); + // Optional trailing block (Arcturus-Morningstar-Extended ≥ 4.2.10): + // rank metadata + resolved permission map appended in a + // backward-compatible way. Older emulators don't write these + // bytes so we keep the defaults from flush(). + if(!wrapper.bytesAvailable) return true; + + this._rankId = wrapper.readInt(); + this._rankName = wrapper.readString(); + this._rankBadge = wrapper.readString(); + this._rankPrefix = wrapper.readString(); + this._rankPrefixColor = wrapper.readString(); + + if(!wrapper.bytesAvailable) return true; + + // Resolved permission map: int count + (string key, int value)*. + // value 1 = ALLOWED, 2 = ROOM_OWNER. Only entries with + // PermissionSetting != DISALLOWED are sent; absence on the client + // means "no" (useHasPermission(key) returns false). + const count = wrapper.readInt(); + const permissions = new Map(); + + for(let i = 0; i < count; i++) + { + const key = wrapper.readString(); + const value = wrapper.readInt(); + + permissions.set(key, value); + } + + this._permissions = permissions; + return true; } @@ -40,4 +83,34 @@ export class UserPermissionsParser implements IMessageParser { return this._isAmbassador; } + + public get rankId(): number + { + return this._rankId; + } + + public get rankName(): string + { + return this._rankName; + } + + public get rankBadge(): string + { + return this._rankBadge; + } + + public get rankPrefix(): string + { + return this._rankPrefix; + } + + public get rankPrefixColor(): string + { + return this._rankPrefixColor; + } + + public get permissions(): ReadonlyMap + { + return this._permissions; + } } diff --git a/packages/communication/src/messages/parser/user/data/UserProfileParser.ts b/packages/communication/src/messages/parser/user/data/UserProfileParser.ts index ef07255..4c24ab2 100644 --- a/packages/communication/src/messages/parser/user/data/UserProfileParser.ts +++ b/packages/communication/src/messages/parser/user/data/UserProfileParser.ts @@ -82,29 +82,35 @@ export class UserProfileParser implements IMessageParser this._secondsSinceLastVisit = wrapper.readInt(); this._openProfileWindow = wrapper.readBoolean(); - if(wrapper.bytesAvailable) - { - this._backgroundId = wrapper.readInt(); - this._standId = wrapper.readInt(); - this._overlayId = wrapper.readInt(); + // Optional trailing blocks, one tier per emulator release: + // block 1: background / stand / overlay (3 ints) + // block 2: card background (1 int) + // block 3: nick icon (1 string) + // block 4: prefix decoration set (6 strings) + // Each tier early-returns to keep the parser tolerant of older + // servers that don't ship the later blocks. Defaults set by flush(). + if(!wrapper.bytesAvailable) return true; - this._cardBackgroundId = (wrapper.bytesAvailable ? wrapper.readInt() : 0); + this._backgroundId = wrapper.readInt(); + this._standId = wrapper.readInt(); + this._overlayId = wrapper.readInt(); - if(wrapper.bytesAvailable) - { - this._nickIcon = wrapper.readString(); + if(!wrapper.bytesAvailable) return true; - if(wrapper.bytesAvailable) - { - this._prefixText = wrapper.readString(); - this._prefixColor = wrapper.readString(); - this._prefixIcon = wrapper.readString(); - this._prefixEffect = wrapper.readString(); - this._prefixFont = wrapper.readString(); - this._displayOrder = wrapper.readString(); - } - } - } + this._cardBackgroundId = wrapper.readInt(); + + if(!wrapper.bytesAvailable) return true; + + this._nickIcon = wrapper.readString(); + + if(!wrapper.bytesAvailable) return true; + + this._prefixText = wrapper.readString(); + this._prefixColor = wrapper.readString(); + this._prefixIcon = wrapper.readString(); + this._prefixEffect = wrapper.readString(); + this._prefixFont = wrapper.readString(); + this._displayOrder = wrapper.readString(); return true; } diff --git a/packages/configuration/package.json b/packages/configuration/package.json index 337c839..24d9f30 100644 --- a/packages/configuration/package.json +++ b/packages/configuration/package.json @@ -13,6 +13,6 @@ "@nitrots/utils": "1.0.0" }, "devDependencies": { - "typescript": "~5.8.2" + "typescript": "^6.0.3" } } diff --git a/packages/events/package.json b/packages/events/package.json index 85c4b86..3b445e6 100644 --- a/packages/events/package.json +++ b/packages/events/package.json @@ -13,6 +13,6 @@ "@nitrots/utils": "1.0.0" }, "devDependencies": { - "typescript": "~5.8.2" + "typescript": "^6.0.3" } } diff --git a/packages/events/src/EventDispatcher.ts b/packages/events/src/EventDispatcher.ts index f95aa3c..5c21b00 100644 --- a/packages/events/src/EventDispatcher.ts +++ b/packages/events/src/EventDispatcher.ts @@ -101,4 +101,23 @@ export class EventDispatcher implements IEventDispatcher { this._listeners.clear(); } + + public subscribe(type: string | string[], callback: (event: T) => void): () => void + { + if(!type || !callback) return () => {}; + + if(Array.isArray(type)) + { + for(const t of type) this.addEventListener(t, callback); + + return () => + { + for(const t of type) this.removeEventListener(t, callback); + }; + } + + this.addEventListener(type, callback); + + return () => this.removeEventListener(type, callback); + } } diff --git a/packages/events/src/NitroEventType.ts b/packages/events/src/NitroEventType.ts index 9e575ac..af4e26c 100644 --- a/packages/events/src/NitroEventType.ts +++ b/packages/events/src/NitroEventType.ts @@ -17,4 +17,11 @@ export class NitroEventType public static readonly AVATAR_EFFECT_DOWNLOADED = 'AVATAR_EFFECT_DOWNLOADED'; public static readonly AVATAR_EFFECT_LOADED = 'AVATAR_EFFECT_LOADED'; public static readonly FURNITURE_DATA_LOADED = 'FURNITURE_DATA_LOADED'; + public static readonly SESSION_DATA_UPDATED = 'SESSION_DATA_UPDATED'; + public static readonly ROOM_SESSION_UPDATED = 'ROOM_SESSION_UPDATED'; + public static readonly IGNORED_USERS_UPDATED = 'IGNORED_USERS_UPDATED'; + public static readonly GROUP_BADGES_UPDATED = 'GROUP_BADGES_UPDATED'; + public static readonly ROOM_USER_LIST_UPDATED = 'ROOM_USER_LIST_UPDATED'; + public static readonly SOUND_VOLUMES_UPDATED = 'SOUND_VOLUMES_UPDATED'; + public static readonly USER_PERMISSIONS_UPDATED = 'USER_PERMISSIONS_UPDATED'; } diff --git a/packages/localization/package.json b/packages/localization/package.json index f185083..95e92b6 100644 --- a/packages/localization/package.json +++ b/packages/localization/package.json @@ -17,6 +17,6 @@ "pixi.js": "^8.8.1" }, "devDependencies": { - "typescript": "~5.8.2" + "typescript": "^6.0.3" } } diff --git a/packages/room/package.json b/packages/room/package.json index 71e36d4..095815a 100644 --- a/packages/room/package.json +++ b/packages/room/package.json @@ -19,6 +19,6 @@ "pixi.js": "^8.8.1" }, "devDependencies": { - "typescript": "~5.8.2" + "typescript": "^6.0.3" } } diff --git a/packages/room/src/object/visualization/furniture/FurnitureBadgeDisplayVisualization.ts b/packages/room/src/object/visualization/furniture/FurnitureBadgeDisplayVisualization.ts index 7e344e8..87c6e2e 100644 --- a/packages/room/src/object/visualization/furniture/FurnitureBadgeDisplayVisualization.ts +++ b/packages/room/src/object/visualization/furniture/FurnitureBadgeDisplayVisualization.ts @@ -170,14 +170,18 @@ export class FurnitureBadgeDisplayVisualization extends FurnitureAnimatedVisuali return assetName; } - protected updateSprite(sprite: IRoomObjectSprite, asset: IGraphicAsset, scale: number, layerId: number): void + protected updateSprite(scale: number, layerId: number): void { - super.updateSprite(sprite, asset, scale, layerId); + super.updateSprite(scale, layerId); const tag = this.getLayerTag(scale, this.direction, layerId); if(tag === FurnitureBadgeDisplayVisualization.BADGE_TAG) { + const sprite = this.getSprite(layerId); + + if(!sprite) return; + sprite.visible = true; sprite.alpha = 255; sprite.color = 0xFFFFFF; diff --git a/packages/room/src/renderer/utils/ExtendedSprite.ts b/packages/room/src/renderer/utils/ExtendedSprite.ts index f3b08be..3c08ae0 100644 --- a/packages/room/src/renderer/utils/ExtendedSprite.ts +++ b/packages/room/src/renderer/utils/ExtendedSprite.ts @@ -1,6 +1,6 @@ import { AlphaTolerance } from '@nitrots/api'; import { GetRenderer, TextureUtils } from '@nitrots/utils'; -import { Point, RendererType, Sprite, Texture, TextureSource, WebGPURenderer } from 'pixi.js'; +import { GlRenderTarget, Point, RendererType, Sprite, Texture, TextureSource, WebGLRenderer, WebGPURenderer } from 'pixi.js'; const BYTES_PER_PIXEL = 4; @@ -97,10 +97,11 @@ export class ExtendedSprite extends Sprite { pixels = new Uint8ClampedArray(BYTES_PER_PIXEL * width * height); - const renderTarget = renderer.renderTarget.getRenderTarget(textureSource); - const glRenderTarget = renderer.renderTarget.getGpuRenderTarget(renderTarget); + const webglRenderer = renderer as WebGLRenderer; + const renderTarget = webglRenderer.renderTarget.getRenderTarget(textureSource); + const glRenderTarget = webglRenderer.renderTarget.getGpuRenderTarget(renderTarget) as GlRenderTarget; - const gl = renderer.gl; + const gl = webglRenderer.gl; gl.bindFramebuffer(gl.FRAMEBUFFER, glRenderTarget.resolveTargetFramebuffer); diff --git a/packages/session/package.json b/packages/session/package.json index 03b38c1..6cc8eb1 100644 --- a/packages/session/package.json +++ b/packages/session/package.json @@ -19,6 +19,6 @@ "pixi.js": "^8.8.1" }, "devDependencies": { - "typescript": "~5.8.2" + "typescript": "^6.0.3" } } diff --git a/packages/session/src/GroupInformationManager.ts b/packages/session/src/GroupInformationManager.ts index fe93aeb..f323112 100644 --- a/packages/session/src/GroupInformationManager.ts +++ b/packages/session/src/GroupInformationManager.ts @@ -1,9 +1,11 @@ import { IGroupInformationManager } from '@nitrots/api'; import { GetCommunication, GetHabboGroupBadgesMessageComposer, HabboGroupBadgesMessageEvent, RoomReadyMessageEvent } from '@nitrots/communication'; +import { GetEventDispatcher, NitroEvent, NitroEventType } from '@nitrots/events'; export class GroupInformationManager implements IGroupInformationManager { private _groupBadges: Map = new Map(); + private _groupBadgesSnapshot: ReadonlyMap | null = null; public init(): void { @@ -20,11 +22,37 @@ export class GroupInformationManager implements IGroupInformationManager { const parser = event.getParser(); - for(const [groupId, badgeId] of parser.badges.entries()) this._groupBadges.set(groupId, badgeId); + let didChange = false; + + for(const [ groupId, badgeId ] of parser.badges.entries()) + { + if(this._groupBadges.get(groupId) === badgeId) continue; + + this._groupBadges.set(groupId, badgeId); + didChange = true; + } + + if(didChange) this.invalidateGroupBadgesSnapshot(); + } + + private invalidateGroupBadgesSnapshot(): void + { + this._groupBadgesSnapshot = null; + + GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.GROUP_BADGES_UPDATED)); } public getGroupBadge(groupId: number): string { return this._groupBadges.get(groupId) ?? ''; } + + public getGroupBadgesSnapshot(): ReadonlyMap + { + if(this._groupBadgesSnapshot) return this._groupBadgesSnapshot; + + this._groupBadgesSnapshot = new Map(this._groupBadges) as ReadonlyMap; + + return this._groupBadgesSnapshot; + } } diff --git a/packages/session/src/IgnoredUsersManager.ts b/packages/session/src/IgnoredUsersManager.ts index 77f1cd0..929bddc 100644 --- a/packages/session/src/IgnoredUsersManager.ts +++ b/packages/session/src/IgnoredUsersManager.ts @@ -1,9 +1,27 @@ import { IIgnoredUsersManager } from '@nitrots/api'; import { GetCommunication, GetIgnoredUsersComposer, IgnoreResultEvent, IgnoreUserComposer, IgnoreUserIdComposer, IgnoredUsersEvent, UnignoreUserComposer } from '@nitrots/communication'; +import { GetEventDispatcher, NitroEvent, NitroEventType } from '@nitrots/events'; export class IgnoredUsersManager implements IIgnoredUsersManager { private _ignoredUsers: string[] = []; + private _ignoredUsersSnapshot: ReadonlyArray | null = null; + + private invalidateIgnoredUsersSnapshot(): void + { + this._ignoredUsersSnapshot = null; + + GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.IGNORED_USERS_UPDATED)); + } + + public getIgnoredUsersSnapshot(): ReadonlyArray + { + if(this._ignoredUsersSnapshot) return this._ignoredUsersSnapshot; + + this._ignoredUsersSnapshot = Object.freeze([ ...this._ignoredUsers ]) as ReadonlyArray; + + return this._ignoredUsersSnapshot; + } public init(): void { @@ -25,6 +43,7 @@ export class IgnoredUsersManager implements IIgnoredUsersManager if(!parser) return; this._ignoredUsers = parser.ignoredUsers; + this.invalidateIgnoredUsersSnapshot(); } private onIgnoreResultEvent(event: IgnoreResultEvent): void @@ -47,6 +66,7 @@ export class IgnoredUsersManager implements IIgnoredUsersManager case 2: this.addUserToIgnoreList(name); this._ignoredUsers.shift(); + this.invalidateIgnoredUsersSnapshot(); return; case 3: this.removeUserFromIgnoreList(name); @@ -56,14 +76,20 @@ export class IgnoredUsersManager implements IIgnoredUsersManager private addUserToIgnoreList(name: string): void { - if(this._ignoredUsers.indexOf(name) < 0) this._ignoredUsers.push(name); + if(this._ignoredUsers.indexOf(name) >= 0) return; + + this._ignoredUsers.push(name); + this.invalidateIgnoredUsersSnapshot(); } private removeUserFromIgnoreList(name: string): void { const index = this._ignoredUsers.indexOf(name); - if(index >= 0) this._ignoredUsers.splice(index, 1); + if(index < 0) return; + + this._ignoredUsers.splice(index, 1); + this.invalidateIgnoredUsersSnapshot(); } public ignoreUserId(id: number): void diff --git a/packages/session/src/RoomSession.ts b/packages/session/src/RoomSession.ts index ae99b1d..b50a0ea 100644 --- a/packages/session/src/RoomSession.ts +++ b/packages/session/src/RoomSession.ts @@ -138,11 +138,6 @@ export class RoomSession implements IRoomSession { GetCommunication().connection.send(new RoomAmbassadorAlertComposer(userId)); } - - public sendWhisperGroupMessage(userId: number): void - { - GetCommunication().connection.send(new ChatWhisperGroupComposer(userId)); - } public sendKickMessage(userId: number): void { diff --git a/packages/session/src/RoomSessionManager.ts b/packages/session/src/RoomSessionManager.ts index 0ffd0eb..52b206f 100644 --- a/packages/session/src/RoomSessionManager.ts +++ b/packages/session/src/RoomSessionManager.ts @@ -1,6 +1,6 @@ -import { IRoomHandlerListener, IRoomSession, IRoomSessionManager } from '@nitrots/api'; +import { IRoomHandlerListener, IRoomSession, IRoomSessionManager, IRoomSessionSnapshot } from '@nitrots/api'; import { GetCommunication, RoomEnterComposer, RoomUnitWalkComposer } from '@nitrots/communication'; -import { GetEventDispatcher, NitroEventType, RoomSessionEvent } from '@nitrots/events'; +import { GetEventDispatcher, NitroEvent, NitroEventType, RoomSessionEvent } from '@nitrots/events'; import { NitroLogger } from '@nitrots/utils'; import { RoomSession } from './RoomSession'; import { BaseHandler, GenericErrorHandler, PetPackageHandler, PollHandler, RoomChatHandler, RoomDataHandler, RoomDimmerPresetsHandler, RoomPermissionsHandler, RoomPresentHandler, RoomSessionHandler, RoomUsersHandler, WordQuizHandler } from './handler'; @@ -26,6 +26,41 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList private _pendingRoomClear: ReturnType = null; private _savedPosX: number = -1; private _savedPosY: number = -1; + private _activeRoomSessionSnapshot: Readonly | null = null; + + private invalidateRoomSessionSnapshot(): void + { + this._activeRoomSessionSnapshot = null; + + GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.ROOM_SESSION_UPDATED)); + } + + public getActiveRoomSessionSnapshot(): Readonly | null + { + const session = this._viewerSession; + + if(!session) return null; + + if(this._activeRoomSessionSnapshot && this._activeRoomSessionSnapshot.session === session) return this._activeRoomSessionSnapshot; + + this._activeRoomSessionSnapshot = Object.freeze({ + roomId: session.roomId, + state: session.state, + isRoomOwner: session.isRoomOwner, + isSpectator: session.isSpectator, + isDecorating: session.isDecorating, + isGuildRoom: session.isGuildRoom, + isPrivateRoom: session.isPrivateRoom, + controllerLevel: session.controllerLevel, + doorMode: session.doorMode, + tradeMode: session.tradeMode, + allowPets: session.allowPets, + groupId: session.groupId, + session + }); + + return this._activeRoomSessionSnapshot; + } public async init(): Promise { @@ -196,6 +231,7 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList this._sessions.clear(); this._viewerSession = null; + this.invalidateRoomSessionSnapshot(); this.createSession(roomId, password, this._savedPosX, this._savedPosY); this.clearGuardTimer(); this._reconnectGuardTimer = setTimeout(() => @@ -384,6 +420,8 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList this._lastRoomPassword = roomSession.password; this.persistRoom(roomSession.roomId, roomSession.password); + this.invalidateRoomSessionSnapshot(); + this.startSession(this._viewerSession); return true; @@ -406,6 +444,8 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList this.setHandlers(session); + this.invalidateRoomSessionSnapshot(); + return true; } @@ -429,6 +469,10 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList } GetEventDispatcher().dispatchEvent(new RoomSessionEvent(RoomSessionEvent.ENDED, session, openLandingView)); + + if(this._viewerSession === session) this._viewerSession = null; + + this.invalidateRoomSessionSnapshot(); } public sessionUpdate(id: number, type: string): void diff --git a/packages/session/src/SessionDataManager.ts b/packages/session/src/SessionDataManager.ts index d527c5f..64e62fa 100644 --- a/packages/session/src/SessionDataManager.ts +++ b/packages/session/src/SessionDataManager.ts @@ -1,8 +1,8 @@ -import { IFurnitureData, IGroupInformationManager, IMessageComposer, IMessageEvent, IProductData, ISessionDataManager, NoobnessLevelEnum, SecurityLevel } from '@nitrots/api'; +import { IFurnitureData, IGroupInformationManager, IMessageComposer, IMessageEvent, IProductData, ISessionDataManager, IUserDataSnapshot, NoobnessLevelEnum, SecurityLevel } from '@nitrots/api'; import { AccountSafetyLockStatusChangeMessageEvent, AccountSafetyLockStatusChangeParser, AvailabilityStatusMessageEvent, ChangeUserNameResultMessageEvent, EmailStatusResultEvent, FigureUpdateEvent, GetCommunication, GetUserTagsComposer, InClientLinkEvent, MysteryBoxKeysEvent, NoobnessLevelMessageEvent, PetRespectComposer, PetScratchFailedMessageEvent, RoomReadyMessageEvent, RoomUnitChatComposer, UserInfoEvent, UserNameChangeMessageEvent, UserPermissionsEvent, UserRespectComposer, UserTagsMessageEvent } from '@nitrots/communication'; import { GetConfiguration } from '@nitrots/configuration'; import { GetLocalizationManager } from '@nitrots/localization'; -import { GetEventDispatcher, MysteryBoxKeysUpdateEvent, NitroSettingsEvent, SessionDataPreferencesEvent, UserNameUpdateEvent } from '@nitrots/events'; +import { GetEventDispatcher, MysteryBoxKeysUpdateEvent, NitroEvent, NitroEventType, NitroSettingsEvent, SessionDataPreferencesEvent, UserNameUpdateEvent } from '@nitrots/events'; import { CreateLinkEvent, HabboWebTools, parseConfigJsonFromResponse } from '@nitrots/utils'; import { Texture } from 'pixi.js'; import { GroupInformationManager } from './GroupInformationManager'; @@ -32,6 +32,11 @@ export class SessionDataManager implements ISessionDataManager private _clubLevel: number = 0; private _securityLevel: number = 0; private _isAmbassador: boolean = false; + private _rankId: number = 0; + private _rankName: string = ''; + private _rankBadge: string = ''; + private _rankPrefix: string = ''; + private _rankPrefixColor: string = ''; private _noobnessLevel: number = -1; private _isEmailVerified: boolean = false; @@ -52,11 +57,87 @@ export class SessionDataManager implements ISessionDataManager private _badgeImageManager: BadgeImageManager = new BadgeImageManager(); + private _userDataSnapshot: Readonly | null = null; + + private _permissions: Map = new Map(); + private _permissionsSnapshot: ReadonlyMap | null = null; + constructor() { this.resetUserInfo(); } + private invalidateUserDataSnapshot(): void + { + this._userDataSnapshot = null; + + GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.SESSION_DATA_UPDATED)); + } + + private invalidatePermissionsSnapshot(): void + { + this._permissionsSnapshot = null; + + GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.USER_PERMISSIONS_UPDATED)); + } + + /** + * Resolved permission map for the current user — mirror of + * `permission_definitions` for the user's rank, filtered to keys + * with `PermissionSetting != DISALLOWED`. Wire-fed by + * `UserPermissionsMapEvent` (Arcturus ≥ 4.2.10). Older emulators + * that don't ship the new packet leave the snapshot empty; React + * consumers via `useHasPermission(key)` then degrade gracefully + * (every gate returns false → mod UI hidden, which is the safe + * default). + * + * Referentially stable until the next + * `UserPermissionsMapEvent` arrives (e.g. after + * `HabboManager.setRank`). + */ + public getPermissionsSnapshot(): ReadonlyMap + { + if(this._permissionsSnapshot) return this._permissionsSnapshot; + + this._permissionsSnapshot = new Map(this._permissions) as ReadonlyMap; + + return this._permissionsSnapshot; + } + + public getUserDataSnapshot(): Readonly + { + if(this._userDataSnapshot) return this._userDataSnapshot; + + this._userDataSnapshot = Object.freeze({ + userId: this._userId, + userName: this._name, + figure: this._figure, + gender: this._gender, + realName: this._realName, + respectsReceived: this._respectsReceived, + respectsLeft: this._respectsLeft, + respectsPetLeft: this._respectsPetLeft, + canChangeName: this._canChangeName, + clubLevel: this._clubLevel, + securityLevel: this._securityLevel, + isAmbassador: this._isAmbassador, + isEmailVerified: this._isEmailVerified, + isNoob: (this._noobnessLevel !== NoobnessLevelEnum.OLD_IDENTITY), + isAuthenticHabbo: this._isAuthenticHabbo, + isSystemOpen: this._systemOpen, + isSystemShutdown: this._systemShutdown, + uiFlags: this._uiFlags, + tags: Object.freeze([...this._tags]) as ReadonlyArray, + rankId: this._rankId, + rankName: this._rankName, + rankBadge: this._rankBadge, + rankPrefix: this._rankPrefix, + rankPrefixColor: this._rankPrefixColor + }); + + return this._userDataSnapshot; + } + public async init(): Promise { await Promise.all([ @@ -75,6 +156,8 @@ export class SessionDataManager implements ISessionDataManager this._gender = event.getParser().gender; HabboWebTools.updateFigure(this._figure); + + this.invalidateUserDataSnapshot(); })), GetCommunication().registerMessageEvent(new UserInfoEvent(this.onUserInfoEvent.bind(this))), GetCommunication().registerMessageEvent(new UserPermissionsEvent(this.onUserPermissionsEvent.bind(this))), @@ -98,6 +181,8 @@ export class SessionDataManager implements ISessionDataManager this._uiFlags = event.flags; GetEventDispatcher().dispatchEvent(new SessionDataPreferencesEvent(this._uiFlags)); + + this.invalidateUserDataSnapshot(); }; GetEventDispatcher().addEventListener(NitroSettingsEvent.SETTINGS_UPDATED, this._settingsEventCallback); @@ -189,15 +274,38 @@ export class SessionDataManager implements ISessionDataManager this._safetyLocked = userInfo.safetyLocked; this._ignoredUsersManager.requestIgnoredUsers(userInfo.username); + + this.invalidateUserDataSnapshot(); } private onUserPermissionsEvent(event: UserPermissionsEvent): void { if(!event || !event.connection) return; - this._clubLevel = event.getParser().clubLevel; - this._securityLevel = event.getParser().securityLevel; - this._isAmbassador = event.getParser().isAmbassador; + const parser = event.getParser(); + + this._clubLevel = parser.clubLevel; + this._securityLevel = parser.securityLevel; + this._isAmbassador = parser.isAmbassador; + this._rankId = parser.rankId; + this._rankName = parser.rankName; + this._rankBadge = parser.rankBadge; + this._rankPrefix = parser.rankPrefix; + this._rankPrefixColor = parser.rankPrefixColor; + // Copy into our local mutable Map so the parser's reference + // (which is overwritten on every parse() call) can't leak back + // to consumers. + this._permissions = new Map(parser.permissions); + + // Invalidate BOTH snapshots: a UserPermissionsComposer push from + // the emulator refreshes user-data fields (clubLevel/securityLevel + // /rank metadata) AND the resolved permission map. Keep the two + // invalidation events distinct so React consumers can subscribe + // to just one (e.g. a widget that only cares about + // useHasPermission re-renders only when the map actually + // changes, not on every snapshot bump). + this.invalidateUserDataSnapshot(); + this.invalidatePermissionsSnapshot(); } private onAvailabilityStatusMessageEvent(event: AvailabilityStatusMessageEvent): void @@ -211,6 +319,8 @@ export class SessionDataManager implements ISessionDataManager this._systemOpen = parser.isOpen; this._systemShutdown = parser.onShutdown; this._isAuthenticHabbo = parser.isAuthenticUser; + + this.invalidateUserDataSnapshot(); } private onPetRespectFailed(event: PetScratchFailedMessageEvent): void @@ -218,6 +328,8 @@ export class SessionDataManager implements ISessionDataManager if(!event || !event.connection) return; this._respectsPetLeft++; + + this.invalidateUserDataSnapshot(); } private onChangeNameUpdateEvent(event: ChangeUserNameResultMessageEvent): void @@ -233,6 +345,8 @@ export class SessionDataManager implements ISessionDataManager this._canChangeName = false; GetEventDispatcher().dispatchEvent(new UserNameUpdateEvent(parser.name)); + + this.invalidateUserDataSnapshot(); } private onUserNameChangeMessageEvent(event: UserNameChangeMessageEvent): void @@ -249,6 +363,8 @@ export class SessionDataManager implements ISessionDataManager this._canChangeName = false; GetEventDispatcher().dispatchEvent(new UserNameUpdateEvent(this._name)); + + this.invalidateUserDataSnapshot(); } private onUserTags(event: UserTagsMessageEvent): void @@ -260,6 +376,8 @@ export class SessionDataManager implements ISessionDataManager if(!parser) return; this._tags = parser.tags; + + this.invalidateUserDataSnapshot(); } private onRoomModelNameEvent(event: RoomReadyMessageEvent): void @@ -300,6 +418,8 @@ export class SessionDataManager implements ISessionDataManager this._noobnessLevel = event.getParser().noobnessLevel; if(this._noobnessLevel !== NoobnessLevelEnum.OLD_IDENTITY) GetConfiguration().setValue('new.identity', 1); + + this.invalidateUserDataSnapshot(); } private onAccountSafetyLockStatusChangeMessageEvent(event: AccountSafetyLockStatusChangeMessageEvent): void @@ -316,6 +436,8 @@ export class SessionDataManager implements ISessionDataManager private onEmailStatus(event: EmailStatusResultEvent): void { this._isEmailVerified = event?.getParser()?.isVerified ?? false; + + this.invalidateUserDataSnapshot(); } public getFloorItemData(id: number): IFurnitureData @@ -476,6 +598,8 @@ export class SessionDataManager implements ISessionDataManager this.send(new UserRespectComposer(userId)); this._respectsLeft--; + + this.invalidateUserDataSnapshot(); } public givePetRespect(petId: number): void @@ -485,6 +609,8 @@ export class SessionDataManager implements ISessionDataManager this.send(new PetRespectComposer(petId)); this._respectsPetLeft--; + + this.invalidateUserDataSnapshot(); } public sendSpecialCommandMessage(text: string, styleId: number = 0): void diff --git a/packages/session/src/UserDataManager.ts b/packages/session/src/UserDataManager.ts index b9864e2..6d3bc72 100644 --- a/packages/session/src/UserDataManager.ts +++ b/packages/session/src/UserDataManager.ts @@ -1,5 +1,6 @@ import { IRoomUserData, IUserDataManager } from '@nitrots/api'; import { GetCommunication, RequestPetInfoComposer, UserCurrentBadgesComposer } from '@nitrots/communication'; +import { GetEventDispatcher, NitroEvent, NitroEventType } from '@nitrots/events'; export class UserDataManager implements IUserDataManager { @@ -11,6 +12,23 @@ export class UserDataManager implements IUserDataManager private _userDataByType: Map> = new Map(); private _userDataByRoomIndex: Map = new Map(); private _userBadges: Map = new Map(); + private _roomUserListSnapshot: ReadonlyArray | null = null; + + private invalidateRoomUserListSnapshot(): void + { + this._roomUserListSnapshot = null; + + GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.ROOM_USER_LIST_UPDATED)); + } + + public getRoomUserListSnapshot(): ReadonlyArray + { + if(this._roomUserListSnapshot) return this._roomUserListSnapshot; + + this._roomUserListSnapshot = Object.freeze([ ...this._userDataByRoomIndex.values() ]) as ReadonlyArray; + + return this._roomUserListSnapshot; + } public getUserData(webID: number): IRoomUserData { @@ -84,6 +102,8 @@ export class UserDataManager implements IUserDataManager existingType.set(data.webID, data); this._userDataByRoomIndex.set(data.roomIndex, data); + + this.invalidateRoomUserListSnapshot(); } public removeUserData(roomIndex: number): void @@ -97,6 +117,8 @@ export class UserDataManager implements IUserDataManager const existingType = this._userDataByType.get(existing.type); if(existingType) existingType.delete(existing.webID); + + this.invalidateRoomUserListSnapshot(); } public getUserBadges(userId: number): string[] @@ -125,6 +147,8 @@ export class UserDataManager implements IUserDataManager userData.sex = sex; userData.hasSaddle = hasSaddle; userData.isRiding = isRiding; + + this.invalidateRoomUserListSnapshot(); } public updateName(roomIndex: number, name: string): void @@ -134,6 +158,8 @@ export class UserDataManager implements IUserDataManager if(!userData) return; userData.name = name; + + this.invalidateRoomUserListSnapshot(); } public updateMotto(roomIndex: number, custom: string): void @@ -143,6 +169,8 @@ export class UserDataManager implements IUserDataManager if(!userData) return; userData.custom = custom; + + this.invalidateRoomUserListSnapshot(); } public updateNickIcon(roomIndex: number, nickIcon: string): void @@ -152,6 +180,8 @@ export class UserDataManager implements IUserDataManager if(!userData) return; userData.nickIcon = nickIcon; + + this.invalidateRoomUserListSnapshot(); } public updateCustomization(roomIndex: number, nickIcon: string, prefixText: string, prefixColor: string, prefixIcon: string, prefixEffect: string, prefixFont: string, displayOrder: string): void @@ -167,9 +197,11 @@ export class UserDataManager implements IUserDataManager userData.prefixEffect = prefixEffect; userData.prefixFont = prefixFont; userData.displayOrder = displayOrder; + + this.invalidateRoomUserListSnapshot(); } - - public updateBackground(roomIndex: number, background: number, stand: number, overlay: number, cardBackground: number = 0, borderId: number = 0): void + + public updateBackground(roomIndex: number, background: number, stand: number, overlay: number, cardBackground: number = 0, borderId: number = 0): void { const userData = this.getUserDataByIndex(roomIndex); @@ -180,6 +212,8 @@ export class UserDataManager implements IUserDataManager userData.overlay = overlay; userData.cardBackground = cardBackground; userData.borderId = borderId; + + this.invalidateRoomUserListSnapshot(); } public updateAchievementScore(roomIndex: number, score: number): void @@ -189,13 +223,19 @@ export class UserDataManager implements IUserDataManager if(!userData) return; userData.activityPoints = score; + + this.invalidateRoomUserListSnapshot(); } public updatePetLevel(roomIndex: number, level: number): void { const userData = this.getUserDataByIndex(roomIndex); - if(userData) userData.petLevel = level; + if(!userData) return; + + userData.petLevel = level; + + this.invalidateRoomUserListSnapshot(); } public updatePetBreedingStatus(roomIndex: number, canBreed: boolean, canHarvest: boolean, canRevive: boolean, hasBreedingPermission: boolean): void @@ -208,6 +248,8 @@ export class UserDataManager implements IUserDataManager userData.canHarvest = canHarvest; userData.canRevive = canRevive; userData.hasBreedingPermission = hasBreedingPermission; + + this.invalidateRoomUserListSnapshot(); } public requestPetInfo(id: number): void diff --git a/packages/session/src/handler/RoomChatHandler.ts b/packages/session/src/handler/RoomChatHandler.ts index f901ef3..c7b9278 100644 --- a/packages/session/src/handler/RoomChatHandler.ts +++ b/packages/session/src/handler/RoomChatHandler.ts @@ -168,6 +168,6 @@ export class RoomChatHandler extends BaseHandler if(!parser) return; - GetEventDispatcher().dispatchEvent(new RoomSessionChatEvent(RoomSessionChatEvent.CHAT_EVENT, session, session.ownRoomIndex, '', RoomSessionChatEvent.CHAT_TYPE_MUTE_REMAINING, SystemChatStyleEnum.GENERIC, [], null, parser.seconds)); + GetEventDispatcher().dispatchEvent(new RoomSessionChatEvent(RoomSessionChatEvent.CHAT_EVENT, session, session.ownRoomIndex, '', RoomSessionChatEvent.CHAT_TYPE_MUTE_REMAINING, SystemChatStyleEnum.GENERIC, '', [], parser.seconds)); } } diff --git a/packages/sound/package.json b/packages/sound/package.json index 53bbf1b..f3b0661 100644 --- a/packages/sound/package.json +++ b/packages/sound/package.json @@ -14,6 +14,6 @@ "pixi.js": "^8.8.1" }, "devDependencies": { - "typescript": "~5.5.4" + "typescript": "^6.0.3" } } diff --git a/packages/sound/src/SoundManager.ts b/packages/sound/src/SoundManager.ts index e736fd4..db73f2c 100644 --- a/packages/sound/src/SoundManager.ts +++ b/packages/sound/src/SoundManager.ts @@ -1,6 +1,6 @@ -import { IAdvancedMap, IMusicController, INitroEvent, ISoundManager } from '@nitrots/api'; +import { IAdvancedMap, IMusicController, INitroEvent, ISoundManager, ISoundVolumesSnapshot } from '@nitrots/api'; import { GetConfiguration } from '@nitrots/configuration'; -import { GetEventDispatcher, NitroSettingsEvent, NitroSoundEvent, RoomEngineEvent, RoomEngineObjectEvent, RoomEngineSamplePlaybackEvent } from '@nitrots/events'; +import { GetEventDispatcher, NitroEvent, NitroEventType, NitroSettingsEvent, NitroSoundEvent, RoomEngineEvent, RoomEngineObjectEvent, RoomEngineSamplePlaybackEvent } from '@nitrots/events'; import { AdvancedMap, NitroLogger } from '@nitrots/utils'; import { MusicController } from './music/MusicController'; @@ -9,6 +9,7 @@ export class SoundManager implements ISoundManager private _volumeSystem: number = 0.5; private _volumeFurni: number = 0.5; private _volumeTrax: number = 0.5; + private _volumesSnapshot: Readonly | null = null; private _internalSamples: IAdvancedMap = new AdvancedMap(); private _furniSamples: IAdvancedMap = new AdvancedMap(); @@ -81,17 +82,24 @@ export class SoundManager implements ISoundManager case NitroSettingsEvent.SETTINGS_UPDATED: { const castedEvent = (event as NitroSettingsEvent); - const volumeFurniUpdated = castedEvent.volumeFurni !== this._volumeFurni; - const volumeTraxUpdated = castedEvent.volumeTrax !== this._volumeTrax; + const nextSystem = (castedEvent.volumeSystem / 100); + const nextFurni = (castedEvent.volumeFurni / 100); + const nextTrax = (castedEvent.volumeTrax / 100); - this._volumeSystem = (castedEvent.volumeSystem / 100); - this._volumeFurni = (castedEvent.volumeFurni / 100); - this._volumeTrax = (castedEvent.volumeTrax / 100); + const volumeSystemUpdated = nextSystem !== this._volumeSystem; + const volumeFurniUpdated = nextFurni !== this._volumeFurni; + const volumeTraxUpdated = nextTrax !== this._volumeTrax; + + this._volumeSystem = nextSystem; + this._volumeFurni = nextFurni; + this._volumeTrax = nextTrax; if(volumeFurniUpdated) this.updateFurniSamplesVolume(this._volumeFurni); if(volumeTraxUpdated) this._musicController?.updateVolume(this._volumeTrax); + if(volumeSystemUpdated || volumeFurniUpdated || volumeTraxUpdated) this.invalidateVolumesSnapshot(); + return; } case NitroSoundEvent.PLAY_SOUND: { @@ -215,8 +223,38 @@ export class SoundManager implements ISoundManager return this._volumeTrax; } + public get systemVolume(): number + { + return this._volumeSystem; + } + + public get furniVolume(): number + { + return this._volumeFurni; + } + public get musicController(): IMusicController { return this._musicController; } + + private invalidateVolumesSnapshot(): void + { + this._volumesSnapshot = null; + + GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.SOUND_VOLUMES_UPDATED)); + } + + public getVolumesSnapshot(): Readonly + { + if(this._volumesSnapshot) return this._volumesSnapshot; + + this._volumesSnapshot = Object.freeze({ + system: this._volumeSystem, + furni: this._volumeFurni, + trax: this._volumeTrax + }); + + return this._volumesSnapshot; + } } diff --git a/packages/utils/package.json b/packages/utils/package.json index 7da9884..9309805 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -16,6 +16,6 @@ }, "devDependencies": { "@types/pako": "^2.0.3", - "typescript": "~5.5.4" + "typescript": "^6.0.3" } } diff --git a/packages/utils/src/ArrayBufferToBase64.ts b/packages/utils/src/ArrayBufferToBase64.ts index aa8eb74..409bca0 100644 --- a/packages/utils/src/ArrayBufferToBase64.ts +++ b/packages/utils/src/ArrayBufferToBase64.ts @@ -1,8 +1,8 @@ -export const ArrayBufferToBase64 = (buffer: ArrayBuffer) => +export const ArrayBufferToBase64 = (buffer: ArrayBufferLike | Uint8Array) => { let binary = ''; - const bytes = new Uint8Array(buffer); + const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); const len = bytes.byteLength; for(let i = 0; i < len; i++) (binary += String.fromCharCode(bytes[i])); diff --git a/packages/utils/src/BinaryReader.ts b/packages/utils/src/BinaryReader.ts index b0c68ac..f42b25f 100644 --- a/packages/utils/src/BinaryReader.ts +++ b/packages/utils/src/BinaryReader.ts @@ -13,7 +13,7 @@ export class BinaryReader implements IBinaryReader public readBytes(length: number): IBinaryReader { - const buffer = new BinaryReader(this._dataView.buffer.slice(this._position, this._position + length)); + const buffer = new BinaryReader(this._dataView.buffer.slice(this._position, this._position + length) as ArrayBuffer); this._position += length; @@ -77,6 +77,6 @@ export class BinaryReader implements IBinaryReader public toArrayBuffer(): ArrayBuffer { - return this._dataView.buffer; + return this._dataView.buffer as ArrayBuffer; } } diff --git a/packages/utils/src/BinaryWriter.ts b/packages/utils/src/BinaryWriter.ts index dd20ef5..554a823 100644 --- a/packages/utils/src/BinaryWriter.ts +++ b/packages/utils/src/BinaryWriter.ts @@ -89,7 +89,7 @@ export class BinaryWriter implements IBinaryWriter public getBuffer(): ArrayBuffer { - return this._buffer.buffer; + return this._buffer.buffer as ArrayBuffer; } public get position(): number diff --git a/packages/utils/src/GamedataLoader.ts b/packages/utils/src/GamedataLoader.ts index cf4dadc..cf2d794 100644 --- a/packages/utils/src/GamedataLoader.ts +++ b/packages/utils/src/GamedataLoader.ts @@ -1,4 +1,5 @@ -import { fetchConfigJson } from './JsonParser'; +import { ConfigJsonError, fetchConfigJson, isMissingResource } from './JsonParser'; +import { NitroLogger } from './NitroLogger'; export const DEFAULT_TIERS = [ 'core', 'custom', 'seasonal' ] as const; export type GamedataTier = typeof DEFAULT_TIERS[number] | string; @@ -28,51 +29,69 @@ const joinUrl = (base: string, path: string): string => return `${ cleanBase }${ cleanPath }`; }; -const tryFetchOrNull = async (url: string): Promise => +// Returns the parsed payload when the manifest exists, null on a clean 404. +// Re-throws on any other error (network failure, 5xx, parse error) so callers +// don't silently skip a tier because of a typo in manifest.json5. +const tryFetchManifest = async (url: string): Promise => { try { return await fetchConfigJson(url); } - catch + catch(err) { - return null; + if(isMissingResource(err)) return null; + throw err; } }; +// Try .json5 first, then .json — both treated as optional. Anything other +// than 404 on either bubbles up. +const tryFetchManifestPair = async (baseUrl: string, name: string): Promise => +{ + const json5 = await tryFetchManifest(joinUrl(baseUrl, `${ name }.json5`)); + if(json5 !== null) return json5; + + return await tryFetchManifest(joinUrl(baseUrl, `${ name }.json`)); +}; + const isPlainObject = (value: any): value is Record => !!value && typeof value === 'object' && !Array.isArray(value); -const arrayItemsLookKeyed = (arr: any[], idKeys: readonly string[]): string | null => +const arrayItemsLookKeyed = (arr: any[], idKeys: readonly string[], sourceLabel?: string): string | null => { if(!arr.length) return null; for(const key of idKeys) { - let allHave = true; + let have = 0; for(const item of arr) { - if(!isPlainObject(item) || item[key] === undefined || item[key] === null) - { - allHave = false; - break; - } + if(isPlainObject(item) && item[key] !== undefined && item[key] !== null) have++; } - if(allHave) return key; + if(have === arr.length) return key; + + // Heuristic: if most items are keyed but a few are not, the data is + // probably keyed and the outliers are bugs in the source data. + // Surface this so operators don't get silent duplicates after merge. + if(have > 0 && have / arr.length >= 0.8) + { + NitroLogger.warn(`mergeGamedata: ${ sourceLabel ? `${ sourceLabel }: ` : '' }array looks keyed by "${ key }" (${ have }/${ arr.length } items) but some entries are missing it — falling back to concat which may produce duplicates`); + } } return null; }; -export const mergeGamedata = (a: any, b: any, idKeys: readonly string[] = DEFAULT_ID_KEYS): any => +export const mergeGamedata = (a: any, b: any, idKeys: readonly string[] = DEFAULT_ID_KEYS, sourceLabel?: string): any => { if(b === undefined) return a; if(a === undefined) return b; if(Array.isArray(a) && Array.isArray(b)) { - const idKey = arrayItemsLookKeyed(a, idKeys) || arrayItemsLookKeyed(b, idKeys); + const idKey = arrayItemsLookKeyed(a, idKeys, sourceLabel) || arrayItemsLookKeyed(b, idKeys, sourceLabel); if(!idKey) return a.concat(b); @@ -92,7 +111,7 @@ export const mergeGamedata = (a: any, b: any, idKeys: readonly string[] = DEFAUL if(at !== undefined) { - out[at] = mergeGamedata(out[at], item, idKeys); + out[at] = mergeGamedata(out[at], item, idKeys, sourceLabel); } else { @@ -110,7 +129,7 @@ export const mergeGamedata = (a: any, b: any, idKeys: readonly string[] = DEFAUL for(const k of Object.keys(b)) { - out[k] = mergeGamedata(a[k], b[k], idKeys); + out[k] = mergeGamedata(a[k], b[k], idKeys, sourceLabel); } return out; @@ -130,6 +149,11 @@ interface RootManifest files?: string[]; } +// Load every file in `files` concurrently, return them in the original +// declared order so the merge step preserves override semantics. +const fetchFilesInOrder = async (baseUrl: string, files: readonly string[]): Promise => + Promise.all(files.map(file => fetchConfigJson(joinUrl(baseUrl, file)))); + export const loadGamedata = async (url: string, options: GamedataLoadOptions = {}): Promise => { if(!url) throw new Error('loadGamedata: empty URL'); @@ -140,42 +164,47 @@ export const loadGamedata = async (url: string, options: GamedataLoadOp } const idKeys = options.mergeArrayIdKeys ?? DEFAULT_ID_KEYS; - const rootManifest = await tryFetchOrNull(joinUrl(url, 'manifest.json5')) - ?? await tryFetchOrNull(joinUrl(url, 'manifest.json')); + const rootManifest = await tryFetchManifestPair(url, 'manifest'); const tiers = (rootManifest?.tiers && rootManifest.tiers.length) ? rootManifest.tiers : (options.tiers ?? DEFAULT_TIERS); + // Fetch root-level files in parallel with discovering each tier's + // manifest. Per-tier file batches stay sequenced relative to each other + // so override order (core → custom → seasonal) is preserved during + // merge, but fetches inside a tier batch run concurrently. + const [ rootParts, tierManifests ] = await Promise.all([ + rootManifest?.files?.length ? fetchFilesInOrder(url, rootManifest.files) : Promise.resolve([] as any[]), + Promise.all(tiers.map(async tier => + { + const tierUrl = joinUrl(url, `${ tier }/`); + const manifest = await tryFetchManifestPair(tierUrl, 'manifest'); + + return { tier, tierUrl, manifest }; + })) + ]); + let merged: any = undefined; - if(rootManifest?.files?.length) + for(const part of rootParts) { - for(const file of rootManifest.files) + merged = (merged === undefined) ? part : mergeGamedata(merged, part, idKeys, url); + } + + for(const { tier, tierUrl, manifest } of tierManifests) + { + if(!manifest?.files?.length) continue; + + const parts = await fetchFilesInOrder(tierUrl, manifest.files); + + for(const part of parts) { - const fileUrl = joinUrl(url, file); - const part = await fetchConfigJson(fileUrl); - merged = (merged === undefined) ? part : mergeGamedata(merged, part, idKeys); + merged = (merged === undefined) ? part : mergeGamedata(merged, part, idKeys, `${ url } (${ tier })`); } } - for(const tier of tiers) - { - const tierUrl = joinUrl(url, `${ tier }/`); - const tierManifest = await tryFetchOrNull(joinUrl(tierUrl, 'manifest.json5')) - ?? await tryFetchOrNull(joinUrl(tierUrl, 'manifest.json')); - - if(!tierManifest?.files?.length) continue; - - for(const file of tierManifest.files) - { - const fileUrl = joinUrl(tierUrl, file); - const part = await fetchConfigJson(fileUrl); - merged = (merged === undefined) ? part : mergeGamedata(merged, part, idKeys); - } - } - - if(merged === undefined) throw new Error(`loadGamedata: directory mode at "${ url }" produced no data — make sure at least one tier (core/custom/seasonal) has a manifest.json5 with a 'files' array`); + if(merged === undefined) throw new ConfigJsonError(`loadGamedata: directory mode at "${ url }" produced no data — make sure at least one tier (core/custom/seasonal) has a manifest.json5 with a 'files' array`, 'fetch', url); return merged as T; }; diff --git a/packages/utils/src/JsonParser.ts b/packages/utils/src/JsonParser.ts index c812d63..46f3631 100644 --- a/packages/utils/src/JsonParser.ts +++ b/packages/utils/src/JsonParser.ts @@ -5,6 +5,28 @@ declare const __NITRO_JSON_MODE__: 'legacy' | 'json5' | 'auto' | undefined; const JSON5_EXTENSION = /\.json5(?:[?#]|$)/i; const JSON5_MIME = /(?:application|text)\/(?:json5|x-json5)/i; +export type ConfigJsonErrorPhase = 'fetch' | 'parse'; + +export class ConfigJsonError extends Error +{ + public readonly phase: ConfigJsonErrorPhase; + public readonly sourceUrl: string; + public readonly httpStatus?: number; + + constructor(message: string, phase: ConfigJsonErrorPhase, sourceUrl: string, httpStatus?: number, cause?: unknown) + { + super(message); + this.name = 'ConfigJsonError'; + this.phase = phase; + this.sourceUrl = sourceUrl; + this.httpStatus = httpStatus; + if(cause !== undefined) (this as any).cause = cause; + } +} + +export const isMissingResource = (err: unknown): boolean => + err instanceof ConfigJsonError && err.phase === 'fetch' && err.httpStatus === 404; + const resolveJsonMode = (): 'legacy' | 'json5' | 'auto' => { try @@ -44,9 +66,7 @@ const formatStrictError = (sourceUrl: string, err: unknown): string => export const parseConfigJson = (text: string, sourceUrl: string = ''): T => { - if(text === null || text === undefined) throw new Error(`Empty response${ sourceUrl ? ` for "${ sourceUrl }"` : '' }`); - - const trimmed = text.length > 0 ? text : ''; + const trimmed = text ?? ''; const mode = resolveJsonMode(); if(mode === 'legacy') @@ -57,7 +77,7 @@ export const parseConfigJson = (text: string, sourceUrl: string = ''): } catch(err) { - throw new Error(formatStrictError(sourceUrl, err)); + throw new ConfigJsonError(formatStrictError(sourceUrl, err), 'parse', sourceUrl, undefined, err); } } @@ -69,7 +89,7 @@ export const parseConfigJson = (text: string, sourceUrl: string = ''): } catch(err) { - throw new Error(formatParseError(sourceUrl, err, err)); + throw new ConfigJsonError(formatParseError(sourceUrl, err, err), 'parse', sourceUrl, undefined, err); } } @@ -90,7 +110,7 @@ export const parseConfigJson = (text: string, sourceUrl: string = ''): } catch(json5Error) { - throw new Error(formatParseError(sourceUrl, strictError, json5Error)); + throw new ConfigJsonError(formatParseError(sourceUrl, strictError, json5Error), 'parse', sourceUrl, undefined, json5Error); } }; @@ -109,7 +129,7 @@ export const parseConfigJsonFromResponse = async (response: Response, s } catch(err) { - throw new Error(formatParseError(url, err, err)); + throw new ConfigJsonError(formatParseError(url, err, err), 'parse', url, undefined, err); } } @@ -118,9 +138,23 @@ export const parseConfigJsonFromResponse = async (response: Response, s export const fetchConfigJson = async (url: string, init?: RequestInit): Promise => { - const response = await fetch(url, init); + let response: Response | undefined; - if(!response || response.status !== 200) throw new Error(`Failed to fetch "${ url }" — server returned HTTP ${ response?.status ?? 'no response' }`); + try + { + response = await fetch(url, init); + } + catch(networkErr) + { + const message = (networkErr as Error)?.message || String(networkErr); + throw new ConfigJsonError(`Network error fetching "${ url }" — ${ message }`, 'fetch', url, undefined, networkErr); + } + + if(!response || response.status !== 200) + { + const status = response?.status; + throw new ConfigJsonError(`Failed to fetch "${ url }" — server returned HTTP ${ status ?? 'no response' }`, 'fetch', url, status); + } return parseConfigJsonFromResponse(response, url); }; diff --git a/packages/utils/src/NitroConfig.ts b/packages/utils/src/NitroConfig.ts index aa905eb..aca544a 100644 --- a/packages/utils/src/NitroConfig.ts +++ b/packages/utils/src/NitroConfig.ts @@ -2,8 +2,8 @@ export { }; declare global { - interface Window - { - NitroConfig?: { [index: string]: any }; - } + interface Window + { + NitroConfig?: Record; + } } diff --git a/packages/utils/src/TextureUtils.ts b/packages/utils/src/TextureUtils.ts index 0b12996..4af952c 100644 --- a/packages/utils/src/TextureUtils.ts +++ b/packages/utils/src/TextureUtils.ts @@ -28,7 +28,7 @@ export class TextureUtils try { - return await this.getExtractor().image(options); + return await this.getExtractor().image(options) as HTMLImageElement; } catch(e) { diff --git a/packages/utils/src/__tests__/BinaryReader.test.ts b/packages/utils/src/__tests__/BinaryReader.test.ts new file mode 100644 index 0000000..e4ddd4a --- /dev/null +++ b/packages/utils/src/__tests__/BinaryReader.test.ts @@ -0,0 +1,325 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { BinaryReader } from '../BinaryReader'; +import { BinaryWriter } from '../BinaryWriter'; + +const concatBuffers = (...parts: ArrayBuffer[]): ArrayBuffer => +{ + const total = parts.reduce((sum, part) => sum + part.byteLength, 0); + const out = new Uint8Array(total); + let offset = 0; + + for(const part of parts) + { + out.set(new Uint8Array(part), offset); + offset += part.byteLength; + } + + return out.buffer; +}; + +describe('BinaryReader / BinaryWriter', () => +{ + let writer: BinaryWriter; + + beforeEach(() => + { + writer = new BinaryWriter(); + }); + + describe('byte round-trip', () => + { + it('writes and reads a single byte', () => + { + writer.writeByte(0x42); + + const reader = new BinaryReader(writer.getBuffer()); + + expect(reader.readByte()).toBe(0x42); + expect(reader.remaining()).toBe(0); + }); + + it('readByte returns a signed int8 (values above 127 wrap negative)', () => + { + writer.writeByte(0xFF); + + const reader = new BinaryReader(writer.getBuffer()); + + expect(reader.readByte()).toBe(-1); + }); + + it('writeByte chains', () => + { + writer.writeByte(1).writeByte(2).writeByte(3); + + const reader = new BinaryReader(writer.getBuffer()); + + expect(reader.readByte()).toBe(1); + expect(reader.readByte()).toBe(2); + expect(reader.readByte()).toBe(3); + }); + }); + + describe('short round-trip (16-bit big-endian)', () => + { + it('writes and reads a positive short', () => + { + writer.writeShort(0x1234); + + const reader = new BinaryReader(writer.getBuffer()); + + expect(reader.readShort()).toBe(0x1234); + }); + + it('round-trips the int16 boundary values', () => + { + writer.writeShort(32767).writeShort(-1); + + const reader = new BinaryReader(writer.getBuffer()); + + expect(reader.readShort()).toBe(32767); + expect(reader.readShort()).toBe(-1); + }); + + it('emits big-endian byte order', () => + { + writer.writeShort(0x0102); + + const bytes = new Uint8Array(writer.getBuffer()); + + expect(bytes[0]).toBe(0x01); + expect(bytes[1]).toBe(0x02); + }); + }); + + describe('int round-trip (32-bit big-endian)', () => + { + it('writes and reads a positive int', () => + { + writer.writeInt(123456789); + + const reader = new BinaryReader(writer.getBuffer()); + + expect(reader.readInt()).toBe(123456789); + }); + + it('round-trips the int32 boundaries (max / min / -1)', () => + { + writer.writeInt(2147483647).writeInt(-2147483648).writeInt(-1); + + const reader = new BinaryReader(writer.getBuffer()); + + expect(reader.readInt()).toBe(2147483647); + expect(reader.readInt()).toBe(-2147483648); + expect(reader.readInt()).toBe(-1); + }); + + it('emits big-endian byte order', () => + { + writer.writeInt(0x01020304); + + const bytes = new Uint8Array(writer.getBuffer()); + + expect(bytes[0]).toBe(0x01); + expect(bytes[1]).toBe(0x02); + expect(bytes[2]).toBe(0x03); + expect(bytes[3]).toBe(0x04); + }); + }); + + describe('string round-trip', () => + { + it('writes a length-prefixed string and decodes it back via readShort + readBytes', () => + { + writer.writeString('hello'); + + const reader = new BinaryReader(writer.getBuffer()); + const length = reader.readShort(); + + expect(length).toBe(5); + expect(reader.readBytes(length).toString()).toBe('hello'); + }); + + it('round-trips UTF-8 multibyte characters with correct byte length', () => + { + // 'café' = 5 bytes UTF-8 (c, a, 0xC3 0xA9, ASCII finale) + writer.writeString('café'); + + const reader = new BinaryReader(writer.getBuffer()); + const length = reader.readShort(); + + expect(length).toBe(5); + expect(reader.readBytes(length).toString()).toBe('café'); + }); + + it('writeString with includeLength=false omits the length prefix', () => + { + writer.writeString('xy', false); + + const buf = writer.getBuffer(); + + expect(buf.byteLength).toBe(2); + expect(new Uint8Array(buf)[0]).toBe(0x78); // 'x' + expect(new Uint8Array(buf)[1]).toBe(0x79); // 'y' + }); + + it('round-trips the empty string', () => + { + writer.writeString(''); + + const reader = new BinaryReader(writer.getBuffer()); + + expect(reader.readShort()).toBe(0); + expect(reader.remaining()).toBe(0); + }); + }); + + describe('writeBytes', () => + { + it('appends a number[] payload', () => + { + writer.writeBytes([ 0x10, 0x20, 0x30 ]); + + const reader = new BinaryReader(writer.getBuffer()); + + expect(reader.readByte()).toBe(0x10); + expect(reader.readByte()).toBe(0x20); + expect(reader.readByte()).toBe(0x30); + }); + + it('appends an ArrayBuffer payload', () => + { + const payload = new Uint8Array([ 0xAA, 0xBB ]).buffer; + + writer.writeBytes(payload); + + const reader = new BinaryReader(writer.getBuffer()); + + expect(reader.readByte()).toBe(-86); // 0xAA as int8 + expect(reader.readByte()).toBe(-69); // 0xBB as int8 + }); + }); + + describe('readBytes slice', () => + { + it('returns an independent reader over the requested slice', () => + { + writer.writeInt(0xCAFEBABE | 0).writeInt(0xDEADBEEF | 0); + + const reader = new BinaryReader(writer.getBuffer()); + const sliced = reader.readBytes(4); + + // The slice's position is independent of the outer reader. + expect(sliced.readInt()).toBe(0xCAFEBABE | 0); + // The outer reader advanced by 4 and can still read the second int. + expect(reader.readInt()).toBe(0xDEADBEEF | 0); + }); + }); + + describe('remaining accounting', () => + { + it('decrements by the read size and reaches 0 at the end of the buffer', () => + { + writer.writeByte(1).writeShort(2).writeInt(3); + + const reader = new BinaryReader(writer.getBuffer()); + + expect(reader.remaining()).toBe(7); + + reader.readByte(); + expect(reader.remaining()).toBe(6); + + reader.readShort(); + expect(reader.remaining()).toBe(4); + + reader.readInt(); + expect(reader.remaining()).toBe(0); + }); + }); + + describe('float / double read', () => + { + // BinaryWriter has no write counterparts for float/double — build the + // buffer by hand via DataView and check the reader decodes correctly. + + it('readFloat decodes an IEEE-754 single-precision big-endian value', () => + { + const buf = new ArrayBuffer(4); + new DataView(buf).setFloat32(0, 3.5, false); + + const reader = new BinaryReader(buf); + + expect(reader.readFloat()).toBeCloseTo(3.5, 5); + expect(reader.remaining()).toBe(0); + }); + + it('readDouble decodes an IEEE-754 double-precision big-endian value', () => + { + const buf = new ArrayBuffer(8); + new DataView(buf).setFloat64(0, Math.PI, false); + + const reader = new BinaryReader(buf); + + expect(reader.readDouble()).toBeCloseTo(Math.PI, 12); + expect(reader.remaining()).toBe(0); + }); + }); + + describe('writer position getter/setter', () => + { + it('reports the position after writes', () => + { + writer.writeInt(0).writeShort(0); + + expect(writer.position).toBe(6); + }); + + it('position can be set explicitly (caller-managed reposition)', () => + { + writer.writeInt(0); + writer.position = 0; + + expect(writer.position).toBe(0); + }); + }); + + describe('typical packet round-trip (header + payload)', () => + { + it('encodes and decodes a header + mixed payload (short + int + string)', () => + { + const header = 1234; + const userId = 99999; + const username = 'simoleo'; + + writer + .writeShort(header) + .writeInt(userId) + .writeString(username); + + const reader = new BinaryReader(writer.getBuffer()); + + expect(reader.readShort()).toBe(header); + expect(reader.readInt()).toBe(userId); + + const nameLength = reader.readShort(); + const name = reader.readBytes(nameLength).toString(); + + expect(name).toBe(username); + expect(reader.remaining()).toBe(0); + }); + + it('concatenated buffers round-trip across independent writer instances', () => + { + const a = new BinaryWriter(); + const b = new BinaryWriter(); + + a.writeInt(11); + b.writeInt(22); + + const reader = new BinaryReader(concatBuffers(a.getBuffer(), b.getBuffer())); + + expect(reader.readInt()).toBe(11); + expect(reader.readInt()).toBe(22); + expect(reader.remaining()).toBe(0); + }); + }); +}); diff --git a/src/globals.d.ts b/src/globals.d.ts new file mode 100644 index 0000000..3c288ac --- /dev/null +++ b/src/globals.d.ts @@ -0,0 +1,17 @@ +/** + * Vite injects `import.meta.glob(pattern, options)` at runtime but TS + * doesn't see it without `vite/client` types — and we don't want to pull + * the full `vite/client` because it overrides asset module declarations + * the consumer (`../Nitro-V3`) owns. Augment `ImportMeta` with just the + * glob signature. + * + * For eager image globs (the only flavor `AssetManager` uses) Vite + * returns `{ default: }`; the call sites then narrow with + * `mod.default ?? mod` for back-compat. The return type below covers + * the eager case directly. Default generic is typed loosely to allow + * `(mod.default ?? mod) as string` patterns. + */ +interface ImportMeta +{ + glob: (pattern: string, options?: { eager?: boolean; import?: string }) => Record; +} diff --git a/tsconfig.json b/tsconfig.json index c6b52f8..0463a3c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,17 +1,15 @@ { "compileOnSave": false, "compilerOptions": { - "baseUrl": "./src", "outDir": "./dist", "sourceMap": false, "declaration": true, "experimentalDecorators": true, - "moduleResolution": "Node", + "moduleResolution": "bundler", "esModuleInterop": true, "importHelpers": true, "isolatedModules": true, "resolveJsonModule": true, - "downlevelIteration": true, "allowSyntheticDefaultImports": true, "allowJs": true, "skipLibCheck": true, diff --git a/yarn.lock b/yarn.lock index 288c306..aaeae8d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -369,7 +369,7 @@ resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8" integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== -"@thumbmarkjs/thumbmarkjs@^1.8.1": +"@thumbmarkjs/thumbmarkjs@^1.9.0": version "1.9.0" resolved "https://registry.yarnpkg.com/@thumbmarkjs/thumbmarkjs/-/thumbmarkjs-1.9.0.tgz#a6444ac1f924f061cfc1507a21dcaf83ee705cab" integrity sha512-6LooyYk8i5L2zEZgDMLE6m2sGDcIHHBiZfxdFp0A16Q4ZXafEmhHmt+zCqQEBMiQHi+08e/v5q77IY2KhvAJwg== @@ -527,6 +527,54 @@ "@typescript-eslint/types" "8.59.3" eslint-visitor-keys "^5.0.0" +"@typescript/native-preview-darwin-arm64@7.0.0-dev.20260519.1": + version "7.0.0-dev.20260519.1" + resolved "https://registry.yarnpkg.com/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20260519.1.tgz#deaa14299366a79917a6ec19fc7f240304c36afe" + integrity sha512-c9zdG6sGJf25Jpz04JgE23zhYeprqFypDGuqiX94yMTvR8IWXjq3R2oMnim66YLBDon/V1nCEy6cFixeSd/4fg== + +"@typescript/native-preview-darwin-x64@7.0.0-dev.20260519.1": + version "7.0.0-dev.20260519.1" + resolved "https://registry.yarnpkg.com/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20260519.1.tgz#a6348c134204afdcdfa8f9a0925091788fc994d7" + integrity sha512-N16V3wiM0tsNmSSA7nZrxqXXt5OCJxBwiCVn35rnA7fr4WzJw6rJmwf9heNNhZ6Gh4ne3+Pexajf5akzuHR75Q== + +"@typescript/native-preview-linux-arm64@7.0.0-dev.20260519.1": + version "7.0.0-dev.20260519.1" + resolved "https://registry.yarnpkg.com/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20260519.1.tgz#04f2f0b314e646a6921cc818db6bac92364bab24" + integrity sha512-ltf91vAwKdbu0SlRQbFgi1h5ZrLLrBn6a4qIeN2VILGbtYrCXnARHRznLBv81yUETQ7aVr/LSQcmsWo1ejCK0w== + +"@typescript/native-preview-linux-arm@7.0.0-dev.20260519.1": + version "7.0.0-dev.20260519.1" + resolved "https://registry.yarnpkg.com/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20260519.1.tgz#ad10403fb8ae6e2ed120cb9f81a029328a00e995" + integrity sha512-8v4BExeeuCTrhaSGfeIJqm3qQkTzlZix/Qd/FkPlWoz9f7d7COvXb3Z4qhbaVolL0MMnUvQ7m005Z4kYsZ645A== + +"@typescript/native-preview-linux-x64@7.0.0-dev.20260519.1": + version "7.0.0-dev.20260519.1" + resolved "https://registry.yarnpkg.com/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20260519.1.tgz#dde79208611da3855f995e595b9b790c622f726f" + integrity sha512-AVD0tczTtFCHNa4RQRVPvu8Hnw4P3hQ+OlUAjnz/lHowvc6o1pYB46elMqfDuaoWqIpv+EAkAPP4ipFCofJ5IA== + +"@typescript/native-preview-win32-arm64@7.0.0-dev.20260519.1": + version "7.0.0-dev.20260519.1" + resolved "https://registry.yarnpkg.com/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20260519.1.tgz#74d0cf98c75e2df20772ae2fa4c8139066e2cf7f" + integrity sha512-TM+qatljyejqjHevCta3WIH53i0oGC7K8SoJ6t+mf4cGMTpZTyd7NhC1ts7e6/aydZnG53Bsta2iQi1SMIlQEw== + +"@typescript/native-preview-win32-x64@7.0.0-dev.20260519.1": + version "7.0.0-dev.20260519.1" + resolved "https://registry.yarnpkg.com/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20260519.1.tgz#a8cfe9f730399cdc21a20c40a59aee15eef75a18" + integrity sha512-r9LEsoY7JC/82gXo8hlOmpQaUXcqmngCVOv+mUx1UeMt9f+1S6oNO0W48o75mlBqqC7jfcMHqw8YS4LfVxPRGw== + +"@typescript/native-preview@^7.0.0-dev.20260510.1": + version "7.0.0-dev.20260519.1" + resolved "https://registry.yarnpkg.com/@typescript/native-preview/-/native-preview-7.0.0-dev.20260519.1.tgz#0d865f5cc6d376d896fc031dfbb38ed0004153c6" + integrity sha512-VVER7vFUDdfm5k3jbH5765tVEJa7+0rTUkFeXyGYrXPxpw9BIjA0QDxdtdlRyaU8MCZV9IKZUo6doxeAQRAjPg== + optionalDependencies: + "@typescript/native-preview-darwin-arm64" "7.0.0-dev.20260519.1" + "@typescript/native-preview-darwin-x64" "7.0.0-dev.20260519.1" + "@typescript/native-preview-linux-arm" "7.0.0-dev.20260519.1" + "@typescript/native-preview-linux-arm64" "7.0.0-dev.20260519.1" + "@typescript/native-preview-linux-x64" "7.0.0-dev.20260519.1" + "@typescript/native-preview-win32-arm64" "7.0.0-dev.20260519.1" + "@typescript/native-preview-win32-x64" "7.0.0-dev.20260519.1" + "@vitest/coverage-v8@^4.0.18": version "4.1.6" resolved "https://registry.yarnpkg.com/@vitest/coverage-v8/-/coverage-v8-4.1.6.tgz#1eacee5def68dfcb08c3ed5355edbad2a4c869b3" @@ -1681,15 +1729,10 @@ typescript-eslint@^8.26.1: "@typescript-eslint/typescript-estree" "8.59.3" "@typescript-eslint/utils" "8.59.3" -typescript@~5.5.4: - version "5.5.4" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.4.tgz#d9852d6c82bad2d2eda4fd74a5762a8f5909e9ba" - integrity sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q== - -typescript@~5.8.2: - version "5.8.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.3.tgz#92f8a3e5e3cf497356f4178c34cd65a7f5e8440e" - integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ== +typescript@^6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-6.0.3.tgz#90251dc007916e972786cb94d74d15b185577d21" + integrity sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw== undici-types@~6.21.0: version "6.21.0"