Merge pull request #74 from duckietm/Dev

Dev
This commit is contained in:
DuckieTM
2026-05-20 13:10:13 +02:00
committed by GitHub
68 changed files with 1499 additions and 188 deletions
+279
View File
@@ -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<IUserDataSnapshot>` | `SESSION_DATA_UPDATED` |
| `RoomSessionManager` | `getActiveRoomSessionSnapshot(): Readonly<IRoomSessionSnapshot> \| null` | `ROOM_SESSION_UPDATED` |
| `IgnoredUsersManager` | `getIgnoredUsersSnapshot(): ReadonlyArray<string>` | `IGNORED_USERS_UPDATED` |
| `GroupInformationManager` | `getGroupBadgesSnapshot(): ReadonlyMap<number, string>` | `GROUP_BADGES_UPDATED` (only on real changes — no-op refresh stays quiet) |
| `UserDataManager` | `getRoomUserListSnapshot(): ReadonlyArray<IRoomUserData>` | `ROOM_USER_LIST_UPDATED` (inner IRoomUserData kept mutable — don't deep-clone) |
| `SoundManager` | `getVolumesSnapshot(): Readonly<ISoundVolumesSnapshot>` | `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<string, unknown>` 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
+5 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "@nitrots/nitro-renderer", "name": "@nitrots/nitro-renderer",
"description": "Javascript library for rendering Nitro in the browser using PixiJS", "description": "Javascript library for rendering Nitro in the browser using PixiJS",
"version": "2.0.0", "version": "2.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"workspaces": [ "workspaces": [
@@ -22,6 +22,7 @@
"scripts": { "scripts": {
"build": "vite build", "build": "vite build",
"compile": "tsc --project ./tsconfig.json --noEmit false", "compile": "tsc --project ./tsconfig.json --noEmit false",
"compile:fast": "tsgo --project ./tsconfig.json --noEmit",
"eslint": "eslint ./src ./packages/*/src", "eslint": "eslint ./src ./packages/*/src",
"eslint-fix": "eslint ./src --fix", "eslint-fix": "eslint ./src --fix",
"test": "vitest run", "test": "vitest run",
@@ -30,7 +31,7 @@
}, },
"main": "./index", "main": "./index",
"dependencies": { "dependencies": {
"@thumbmarkjs/thumbmarkjs": "^1.8.1", "@thumbmarkjs/thumbmarkjs": "^1.9.0",
"gifuct-js": "^2.1.2", "gifuct-js": "^2.1.2",
"howler": "^2.2.4", "howler": "^2.2.4",
"json5": "^2.2.3", "json5": "^2.2.3",
@@ -47,8 +48,9 @@
"@vitest/coverage-v8": "^4.0.18", "@vitest/coverage-v8": "^4.0.18",
"eslint": "^9.8.0", "eslint": "^9.8.0",
"jsdom": "^27.4.0", "jsdom": "^27.4.0",
"@typescript/native-preview": "^7.0.0-dev.20260510.1",
"tslib": "^2.6.3", "tslib": "^2.6.3",
"typescript": "~5.8.2", "typescript": "^6.0.3",
"typescript-eslint": "^8.26.1", "typescript-eslint": "^8.26.1",
"vite": "^8.0.10", "vite": "^8.0.10",
"vitest": "^4.1.5" "vitest": "^4.1.5"
+1 -1
View File
@@ -15,6 +15,6 @@
"pixi.js": "^8.8.1" "pixi.js": "^8.8.1"
}, },
"devDependencies": { "devDependencies": {
"typescript": "~5.8.2" "typescript": "^6.0.3"
} }
} }
@@ -7,4 +7,5 @@ export interface IEventDispatcher
removeEventListener(type: string, callback: Function): void; removeEventListener(type: string, callback: Function): void;
removeAllListeners(): void; removeAllListeners(): void;
dispatchEvent<T extends INitroEvent>(event: T): boolean; dispatchEvent<T extends INitroEvent>(event: T): boolean;
subscribe<T extends INitroEvent>(type: string | string[], callback: (event: T) => void): () => void;
} }
@@ -6,5 +6,6 @@ export interface ICommunicationManager
init(): Promise<void>; init(): Promise<void>;
registerMessageEvent(event: IMessageEvent): IMessageEvent; registerMessageEvent(event: IMessageEvent): IMessageEvent;
removeMessageEvent(event: IMessageEvent): void; removeMessageEvent(event: IMessageEvent): void;
subscribeMessage<T extends IMessageEvent>(eventCtor: new (callback: (event: T) => void) => T, handler: (event: T) => void): () => void;
connection: IConnection; connection: IConnection;
} }
@@ -2,4 +2,13 @@ export interface IGroupInformationManager
{ {
init(): void; init(): void;
getGroupBadge(groupId: number): string; 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<number, string>;
} }
@@ -6,4 +6,14 @@ export interface IIgnoredUsersManager
ignoreUser(name: string): void; ignoreUser(name: string): void;
unignoreUser(name: string): void; unignoreUser(name: string): void;
isIgnored(name: string): boolean; 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<string>;
} }
@@ -9,10 +9,11 @@ export interface IRoomSession
setRoomOwner(): void; setRoomOwner(): void;
start(): boolean; start(): boolean;
reset(roomId: number): void; reset(roomId: number): void;
sendChatMessage(text: string, styleId: number, chatColour: string): void; sendChatMessage(text: string, styleId: number, chatColour?: string): void;
sendShoutMessage(text: string, styleId: number, chatColour: string): void; sendShoutMessage(text: string, styleId: number, chatColour?: string): void;
sendWhisperMessage(recipientName: string, text: string, styleId: number): void; sendWhisperMessage(recipientName: string, text: string, styleId: number): void;
sendChatTypingMessage(isTyping: boolean): void; sendChatTypingMessage(isTyping: boolean): void;
sendBackgroundMessage(backgroundImage: number, backgroundStand: number, backgroundOverlay: number, backgroundCard?: number, backgroundBorder?: number): void;
sendMottoMessage(motto: string): void; sendMottoMessage(motto: string): void;
sendDanceMessage(danceId: number): void; sendDanceMessage(danceId: number): void;
sendExpressionMessage(expression: number): void; sendExpressionMessage(expression: number): void;
@@ -20,7 +21,6 @@ export interface IRoomSession
sendPostureMessage(posture: number): void; sendPostureMessage(posture: number): void;
sendDoorbellApprovalMessage(userName: string, flag: boolean): void; sendDoorbellApprovalMessage(userName: string, flag: boolean): void;
sendAmbassadorAlertMessage(userId: number): void; sendAmbassadorAlertMessage(userId: number): void;
sendWhisperGroupMessage(userId: number): void;
sendKickMessage(userId: number): void; sendKickMessage(userId: number): void;
sendMuteMessage(userId: number, minutes: number): void; sendMuteMessage(userId: number, minutes: number): void;
sendBanMessage(userId: number, type: string): void; sendBanMessage(userId: number, type: string): void;
@@ -50,6 +50,7 @@ export interface IRoomSession
sendScriptProceed(): void; sendScriptProceed(): void;
userDataManager: IUserDataManager; userDataManager: IUserDataManager;
roomId: number; roomId: number;
password: string;
state: string; state: string;
tradeMode: number; tradeMode: number;
isPrivateRoom: boolean; isPrivateRoom: boolean;
@@ -1,4 +1,5 @@
import { IRoomSession } from './IRoomSession'; import { IRoomSession } from './IRoomSession';
import { IRoomSessionSnapshot } from './IRoomSessionSnapshot';
export interface IRoomSessionManager export interface IRoomSessionManager
{ {
@@ -8,5 +9,6 @@ export interface IRoomSessionManager
startSession(session: IRoomSession): boolean; startSession(session: IRoomSession): boolean;
removeSession(id: number, openLandingView?: boolean): void; removeSession(id: number, openLandingView?: boolean): void;
tryRestoreSession(): boolean; tryRestoreSession(): boolean;
getActiveRoomSessionSnapshot(): Readonly<IRoomSessionSnapshot> | null;
viewerSession: IRoomSession; viewerSession: IRoomSession;
} }
@@ -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;
}
@@ -3,6 +3,7 @@ import { IFurnitureData } from './IFurnitureData';
import { IGroupInformationManager } from './IGroupInformationManager'; import { IGroupInformationManager } from './IGroupInformationManager';
import { IIgnoredUsersManager } from './IIgnoredUsersManager'; import { IIgnoredUsersManager } from './IIgnoredUsersManager';
import { IProductData } from './IProductData'; import { IProductData } from './IProductData';
import { IUserDataSnapshot } from './IUserDataSnapshot';
export interface ISessionDataManager export interface ISessionDataManager
{ {
@@ -53,4 +54,12 @@ export interface ISessionDataManager
isCameraFollowDisabled: boolean; isCameraFollowDisabled: boolean;
uiFlags: number; uiFlags: number;
tags: string[]; tags: string[];
getUserDataSnapshot(): Readonly<IUserDataSnapshot>;
/**
* 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<string, number>;
} }
@@ -23,4 +23,20 @@ export interface IUserDataManager
updatePetLevel(roomIndex: number, level: number): void; updatePetLevel(roomIndex: number, level: number): void;
updatePetBreedingStatus(roomIndex: number, canBreed: boolean, canHarvest: boolean, canRevive: boolean, hasBreedingPermission: boolean): void; updatePetBreedingStatus(roomIndex: number, canBreed: boolean, canHarvest: boolean, canRevive: boolean, hasBreedingPermission: boolean): void;
requestPetInfo(id: number): 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<IRoomUserData>;
} }
@@ -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<string>;
// 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;
}
+2
View File
@@ -18,6 +18,8 @@ export * from './IRoomSessionManager';
export * from './IRoomUserData'; export * from './IRoomUserData';
export * from './ISessionDataManager'; export * from './ISessionDataManager';
export * from './IUserDataManager'; export * from './IUserDataManager';
export * from './IUserDataSnapshot';
export * from './IRoomSessionSnapshot';
export * from './PetBreedingResultData'; export * from './PetBreedingResultData';
export * from './PetCustomPart'; export * from './PetCustomPart';
export * from './PetFigureData'; export * from './PetFigureData';
@@ -1,8 +1,22 @@
import { IMusicController } from './IMusicController'; import { IMusicController } from './IMusicController';
import { ISoundVolumesSnapshot } from './ISoundVolumesSnapshot';
export interface ISoundManager export interface ISoundManager
{ {
init(): Promise<void>; init(): Promise<void>;
musicController: IMusicController; musicController: IMusicController;
traxVolume: number; 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<ISoundVolumesSnapshot>;
} }
@@ -0,0 +1,6 @@
export interface ISoundVolumesSnapshot
{
system: number;
furni: number;
trax: number;
}
+1
View File
@@ -2,3 +2,4 @@ export * from './IMusicController';
export * from './IPlaylistController'; export * from './IPlaylistController';
export * from './ISongInfo'; export * from './ISongInfo';
export * from './ISoundManager'; export * from './ISoundManager';
export * from './ISoundVolumesSnapshot';
+3 -3
View File
@@ -14,10 +14,10 @@
"dependencies": { "dependencies": {
"@nitrots/api": "1.0.0", "@nitrots/api": "1.0.0",
"@nitrots/utils": "1.0.0", "@nitrots/utils": "1.0.0",
"@pixi/gif": "^3.0.1", "@pixi/gif": "^3.0.1",
"pixi.js": "^8.8.1" "pixi.js": "^8.8.1"
}, },
"devDependencies": { "devDependencies": {
"typescript": "~5.8.2" "typescript": "^6.0.3"
} }
} }
+2 -2
View File
@@ -227,7 +227,7 @@ export class AssetManager implements IAssetManager
for(const path in merged) for(const path in merged)
{ {
const mod = merged[path]; 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 file = path.split('/').pop()!;
const rawName = file.replace(/\.png$/i, ''); const rawName = file.replace(/\.png$/i, '');
@@ -296,7 +296,7 @@ export class AssetManager implements IAssetManager
if(!path.startsWith(prefix)) continue; if(!path.startsWith(prefix)) continue;
const mod = allImages[path]; 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 file = path.split('/').pop()!;
const rawName = file.replace(/\.png$/i, ''); const rawName = file.replace(/\.png$/i, '');
+1 -1
View File
@@ -15,6 +15,6 @@
"@nitrots/utils": "1.0.0" "@nitrots/utils": "1.0.0"
}, },
"devDependencies": { "devDependencies": {
"typescript": "~5.8.2" "typescript": "^6.0.3"
} }
} }
+5 -8
View File
@@ -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 { 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 { AvatarFigureContainer } from './AvatarFigureContainer';
import { AvatarStructure } from './AvatarStructure'; import { AvatarStructure } from './AvatarStructure';
import { EffectAssetDownloadManager } from './EffectAssetDownloadManager'; import { EffectAssetDownloadManager } from './EffectAssetDownloadManager';
@@ -243,8 +243,7 @@ export class AvatarImage implements IAvatarImage, IAvatarEffectListener
if(this._avatarSpriteData.colorTransform) if(this._avatarSpriteData.colorTransform)
{ {
if(container.filters === undefined || container.filters === null) container.filters = [ 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 as readonly Filter[]), this._avatarSpriteData.colorTransform ];
else container.filters = [ container.filters, this._avatarSpriteData.colorTransform ];
} }
if(this._avatarSpriteData.paletteIsGrayscale) if(this._avatarSpriteData.paletteIsGrayscale)
@@ -257,8 +256,7 @@ export class AvatarImage implements IAvatarImage, IAvatarEffectListener
}); });
if(container.filters === undefined || container.filters === null) container.filters = [ paletteMapFilter ]; 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 as readonly Filter[]), paletteMapFilter ];
else container.filters = [ container.filters, paletteMapFilter ];
} }
} }
@@ -766,8 +764,7 @@ export class AvatarImage implements IAvatarImage, IAvatarEffectListener
]; ];
if(container.filters === undefined || container.filters === null) container.filters = [ filter ]; 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 as readonly Filter[]), filter ];
else container.filters = [ container.filters, filter ];
return container; return container;
} }
+1 -1
View File
@@ -17,6 +17,6 @@
"pixi.js": "^8.8.1" "pixi.js": "^8.8.1"
}, },
"devDependencies": { "devDependencies": {
"typescript": "~5.8.2" "typescript": "^6.0.3"
} }
} }
+2 -2
View File
@@ -12,9 +12,9 @@
"@nitrots/api": "1.0.0", "@nitrots/api": "1.0.0",
"@nitrots/events": "1.0.0", "@nitrots/events": "1.0.0",
"@nitrots/utils": "1.0.0", "@nitrots/utils": "1.0.0",
"@thumbmarkjs/thumbmarkjs": "^1.8.1" "@thumbmarkjs/thumbmarkjs": "^1.9.0"
}, },
"devDependencies": { "devDependencies": {
"typescript": "~5.8.2" "typescript": "^6.0.3"
} }
} }
@@ -203,6 +203,17 @@ export class CommunicationManager implements ICommunicationManager
this._connection.removeMessageEvent(event); this._connection.removeMessageEvent(event);
} }
public subscribeMessage<T extends IMessageEvent>(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 public get connection(): IConnection
{ {
return this._connection; return this._connection;
@@ -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 { GetConfiguration } from '@nitrots/configuration';
import { GetEventDispatcher, NitroEvent, NitroEventType, ReconnectEvent } from '@nitrots/events'; import { GetEventDispatcher, NitroEvent, NitroEventType, ReconnectEvent } from '@nitrots/events';
import { NitroLogger } from '@nitrots/utils'; import { NitroLogger } from '@nitrots/utils';
@@ -509,7 +509,7 @@ export class SocketConnection implements IConnection
try 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; if(!parser || !parser.flush() || !parser.parse(wrapper)) return null;
@@ -38,17 +38,17 @@ export async function deriveAesKey(sharedSecret: ArrayBuffer): Promise<CryptoKey
); );
} }
export async function aesGcmEncrypt(key: CryptoKey, nonce: Uint8Array, plaintext: ArrayBuffer): Promise<ArrayBuffer> export async function aesGcmEncrypt(key: CryptoKey, nonce: Uint8Array<ArrayBuffer>, plaintext: ArrayBuffer): Promise<ArrayBuffer>
{ {
return window.crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce, tagLength: GCM_TAG_LEN * 8 }, key, plaintext); 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<ArrayBuffer> export async function aesGcmDecrypt(key: CryptoKey, nonce: Uint8Array<ArrayBuffer>, ciphertextWithTag: ArrayBuffer): Promise<ArrayBuffer>
{ {
return window.crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce, tagLength: GCM_TAG_LEN * 8 }, key, ciphertextWithTag); 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<ArrayBuffer>
{ {
const n = new Uint8Array(NONCE_LEN); const n = new Uint8Array(NONCE_LEN);
window.crypto.getRandomValues(n); window.crypto.getRandomValues(n);
@@ -1,12 +1,23 @@
import { IMessageComposer } from '@nitrots/api'; import { IMessageComposer } from '@nitrots/api';
export class RoomEnterComposer implements IMessageComposer<ConstructorParameters<typeof RoomEnterComposer>> type RoomEnterPayload = [ number, string, number?, number? ];
{
private _data: ConstructorParameters<typeof RoomEnterComposer>;
constructor(roomId: number, password: string = null) export class RoomEnterComposer implements IMessageComposer<RoomEnterPayload>
{
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() public getMessageArray()
@@ -32,7 +32,8 @@ implements
chatBubbleWeight: number, chatBubbleWeight: number,
chatBubbleSpeed: number, chatBubbleSpeed: number,
chatDistance: number, chatDistance: number,
chatFloodProtection: number chatFloodProtection: number,
allowUnderpass?: boolean
) )
{ {
//@ts-ignore //@ts-ignore
@@ -67,6 +68,8 @@ implements
chatDistance, chatDistance,
chatFloodProtection chatFloodProtection
); );
if(allowUnderpass !== undefined) this._data.push(allowUnderpass);
} }
public getMessageArray() public getMessageArray()
@@ -1,8 +1,8 @@
import { IMessageComposer } from '@nitrots/api'; import { IMessageComposer } from '@nitrots/api';
export class WiredRoomSettingsRequestComposer implements IMessageComposer<ConstructorParameters<typeof WiredRoomSettingsRequestComposer>> export class WiredRoomSettingsRequestComposer implements IMessageComposer<[]>
{ {
public getMessageArray() public getMessageArray(): []
{ {
return []; return [];
} }
@@ -1,8 +1,8 @@
import { IMessageComposer } from '@nitrots/api'; import { IMessageComposer } from '@nitrots/api';
export class WiredUserVariablesRequestComposer implements IMessageComposer<ConstructorParameters<typeof WiredUserVariablesRequestComposer>> export class WiredUserVariablesRequestComposer implements IMessageComposer<[]>
{ {
public getMessageArray() public getMessageArray(): []
{ {
return []; return [];
} }
@@ -19,17 +19,16 @@ export class PetBreedingMessageParser implements IMessageParser
return true; return true;
} }
public parse(wrapper: IMessageDataWrapper): boolean { public parse(wrapper: IMessageDataWrapper): boolean
if (!wrapper || wrapper.bytesAvailable < 12) { {
return false; if(!wrapper || !wrapper.bytesAvailable) return false;
}
this._state = wrapper.readInt(); this._state = wrapper.readInt();
this._ownPetId = wrapper.readInt(); this._ownPetId = wrapper.readInt();
this._otherPetId = wrapper.readInt(); this._otherPetId = wrapper.readInt();
return true; return true;
} }
public get state(): number public get state(): number
{ {
@@ -45,12 +45,19 @@ export class GetGuestRoomResultMessageParser implements IMessageParser
this.data.canMute = wrapper.readBoolean(); this.data.canMute = wrapper.readBoolean();
this._chat = new RoomChatSettings(wrapper); this._chat = new RoomChatSettings(wrapper);
if(wrapper.bytesAvailable) // Optional trailing blocks, one tier per emulator release:
{ // block 1: hotel timezone id + current time ms (2 strings)
this._hotelTimeZoneId = wrapper.readString(); // block 2: room item limit (1 int)
this._hotelCurrentTimeMs = Number(wrapper.readString()) || 0; // Flat early-return chain so an older server stops cleanly at
if(wrapper.bytesAvailable) this._roomItemLimit = wrapper.readInt(); // 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; return true;
} }
@@ -146,7 +146,17 @@ export class RoomUnitParser implements IMessageParser
user.roomEntryMethod = wrapper.readString(); user.roomEntryMethod = wrapper.readString();
user.roomEntryTeleportId = wrapper.readInt(); 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++; i++;
} }
@@ -37,6 +37,7 @@ export class RoomSettingsData
private _roomModerationSettings: RoomModerationSettings = null; private _roomModerationSettings: RoomModerationSettings = null;
private _chatSettings: RoomChatSettings = null; private _chatSettings: RoomChatSettings = null;
private _allowNavigatorDynamicCats: boolean = false; private _allowNavigatorDynamicCats: boolean = false;
private _allowUnderpass: boolean = false;
public static from(settings: RoomSettingsData) public static from(settings: RoomSettingsData)
{ {
@@ -65,6 +66,7 @@ export class RoomSettingsData
instance._roomModerationSettings = settings._roomModerationSettings; instance._roomModerationSettings = settings._roomModerationSettings;
instance._chatSettings = settings._chatSettings; instance._chatSettings = settings._chatSettings;
instance._allowNavigatorDynamicCats = settings._allowNavigatorDynamicCats; instance._allowNavigatorDynamicCats = settings._allowNavigatorDynamicCats;
instance._allowUnderpass = settings._allowUnderpass;
return instance; return instance;
} }
@@ -329,4 +331,14 @@ export class RoomSettingsData
{ {
this._allowNavigatorDynamicCats = flag; this._allowNavigatorDynamicCats = flag;
} }
public get allowUnderpass(): boolean
{
return this._allowUnderpass;
}
public set allowUnderpass(flag: boolean)
{
this._allowUnderpass = flag;
}
} }
@@ -49,6 +49,10 @@ export class RoomSettingsDataParser implements IMessageParser
this._roomSettingsData.allowNavigatorDynamicCats = wrapper.readBoolean(); this._roomSettingsData.allowNavigatorDynamicCats = wrapper.readBoolean();
this._roomSettingsData.roomModerationSettings = new RoomModerationSettings(wrapper); 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; return true;
} }
@@ -5,12 +5,24 @@ export class UserPermissionsParser implements IMessageParser
private _clubLevel: number; private _clubLevel: number;
private _securityLevel: number; private _securityLevel: number;
private _isAmbassador: boolean; private _isAmbassador: boolean;
private _rankId: number;
private _rankName: string;
private _rankBadge: string;
private _rankPrefix: string;
private _rankPrefixColor: string;
private _permissions: Map<string, number> = new Map();
public flush(): boolean public flush(): boolean
{ {
this._clubLevel = 0; this._clubLevel = 0;
this._securityLevel = 0; this._securityLevel = 0;
this._isAmbassador = false; this._isAmbassador = false;
this._rankId = 0;
this._rankName = '';
this._rankBadge = '';
this._rankPrefix = '';
this._rankPrefixColor = '';
this._permissions = new Map();
return true; return true;
} }
@@ -23,6 +35,37 @@ export class UserPermissionsParser implements IMessageParser
this._securityLevel = wrapper.readInt(); this._securityLevel = wrapper.readInt();
this._isAmbassador = wrapper.readBoolean(); 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<string, number>();
for(let i = 0; i < count; i++)
{
const key = wrapper.readString();
const value = wrapper.readInt();
permissions.set(key, value);
}
this._permissions = permissions;
return true; return true;
} }
@@ -40,4 +83,34 @@ export class UserPermissionsParser implements IMessageParser
{ {
return this._isAmbassador; 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<string, number>
{
return this._permissions;
}
} }
@@ -82,29 +82,35 @@ export class UserProfileParser implements IMessageParser
this._secondsSinceLastVisit = wrapper.readInt(); this._secondsSinceLastVisit = wrapper.readInt();
this._openProfileWindow = wrapper.readBoolean(); this._openProfileWindow = wrapper.readBoolean();
if(wrapper.bytesAvailable) // Optional trailing blocks, one tier per emulator release:
{ // block 1: background / stand / overlay (3 ints)
this._backgroundId = wrapper.readInt(); // block 2: card background (1 int)
this._standId = wrapper.readInt(); // block 3: nick icon (1 string)
this._overlayId = wrapper.readInt(); // 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) if(!wrapper.bytesAvailable) return true;
{
this._nickIcon = wrapper.readString();
if(wrapper.bytesAvailable) this._cardBackgroundId = wrapper.readInt();
{
this._prefixText = wrapper.readString(); if(!wrapper.bytesAvailable) return true;
this._prefixColor = wrapper.readString();
this._prefixIcon = wrapper.readString(); this._nickIcon = wrapper.readString();
this._prefixEffect = wrapper.readString();
this._prefixFont = wrapper.readString(); if(!wrapper.bytesAvailable) return true;
this._displayOrder = wrapper.readString();
} 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; return true;
} }
+1 -1
View File
@@ -13,6 +13,6 @@
"@nitrots/utils": "1.0.0" "@nitrots/utils": "1.0.0"
}, },
"devDependencies": { "devDependencies": {
"typescript": "~5.8.2" "typescript": "^6.0.3"
} }
} }
+1 -1
View File
@@ -13,6 +13,6 @@
"@nitrots/utils": "1.0.0" "@nitrots/utils": "1.0.0"
}, },
"devDependencies": { "devDependencies": {
"typescript": "~5.8.2" "typescript": "^6.0.3"
} }
} }
+19
View File
@@ -101,4 +101,23 @@ export class EventDispatcher implements IEventDispatcher
{ {
this._listeners.clear(); this._listeners.clear();
} }
public subscribe<T extends INitroEvent>(type: string | string[], callback: (event: T) => void): () => void
{
if(!type || !callback) return () => {};
if(Array.isArray(type))
{
for(const t of type) this.addEventListener<T>(t, callback);
return () =>
{
for(const t of type) this.removeEventListener(t, callback);
};
}
this.addEventListener<T>(type, callback);
return () => this.removeEventListener(type, callback);
}
} }
+7
View File
@@ -17,4 +17,11 @@ export class NitroEventType
public static readonly AVATAR_EFFECT_DOWNLOADED = 'AVATAR_EFFECT_DOWNLOADED'; public static readonly AVATAR_EFFECT_DOWNLOADED = 'AVATAR_EFFECT_DOWNLOADED';
public static readonly AVATAR_EFFECT_LOADED = 'AVATAR_EFFECT_LOADED'; public static readonly AVATAR_EFFECT_LOADED = 'AVATAR_EFFECT_LOADED';
public static readonly FURNITURE_DATA_LOADED = 'FURNITURE_DATA_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';
} }
+1 -1
View File
@@ -17,6 +17,6 @@
"pixi.js": "^8.8.1" "pixi.js": "^8.8.1"
}, },
"devDependencies": { "devDependencies": {
"typescript": "~5.8.2" "typescript": "^6.0.3"
} }
} }
+1 -1
View File
@@ -19,6 +19,6 @@
"pixi.js": "^8.8.1" "pixi.js": "^8.8.1"
}, },
"devDependencies": { "devDependencies": {
"typescript": "~5.8.2" "typescript": "^6.0.3"
} }
} }
@@ -170,14 +170,18 @@ export class FurnitureBadgeDisplayVisualization extends FurnitureAnimatedVisuali
return assetName; 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); const tag = this.getLayerTag(scale, this.direction, layerId);
if(tag === FurnitureBadgeDisplayVisualization.BADGE_TAG) if(tag === FurnitureBadgeDisplayVisualization.BADGE_TAG)
{ {
const sprite = this.getSprite(layerId);
if(!sprite) return;
sprite.visible = true; sprite.visible = true;
sprite.alpha = 255; sprite.alpha = 255;
sprite.color = 0xFFFFFF; sprite.color = 0xFFFFFF;
@@ -1,6 +1,6 @@
import { AlphaTolerance } from '@nitrots/api'; import { AlphaTolerance } from '@nitrots/api';
import { GetRenderer, TextureUtils } from '@nitrots/utils'; 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; const BYTES_PER_PIXEL = 4;
@@ -97,10 +97,11 @@ export class ExtendedSprite extends Sprite
{ {
pixels = new Uint8ClampedArray(BYTES_PER_PIXEL * width * height); pixels = new Uint8ClampedArray(BYTES_PER_PIXEL * width * height);
const renderTarget = renderer.renderTarget.getRenderTarget(textureSource); const webglRenderer = renderer as WebGLRenderer;
const glRenderTarget = renderer.renderTarget.getGpuRenderTarget(renderTarget); 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); gl.bindFramebuffer(gl.FRAMEBUFFER, glRenderTarget.resolveTargetFramebuffer);
+1 -1
View File
@@ -19,6 +19,6 @@
"pixi.js": "^8.8.1" "pixi.js": "^8.8.1"
}, },
"devDependencies": { "devDependencies": {
"typescript": "~5.8.2" "typescript": "^6.0.3"
} }
} }
@@ -1,9 +1,11 @@
import { IGroupInformationManager } from '@nitrots/api'; import { IGroupInformationManager } from '@nitrots/api';
import { GetCommunication, GetHabboGroupBadgesMessageComposer, HabboGroupBadgesMessageEvent, RoomReadyMessageEvent } from '@nitrots/communication'; import { GetCommunication, GetHabboGroupBadgesMessageComposer, HabboGroupBadgesMessageEvent, RoomReadyMessageEvent } from '@nitrots/communication';
import { GetEventDispatcher, NitroEvent, NitroEventType } from '@nitrots/events';
export class GroupInformationManager implements IGroupInformationManager export class GroupInformationManager implements IGroupInformationManager
{ {
private _groupBadges: Map<number, string> = new Map(); private _groupBadges: Map<number, string> = new Map();
private _groupBadgesSnapshot: ReadonlyMap<number, string> | null = null;
public init(): void public init(): void
{ {
@@ -20,11 +22,37 @@ export class GroupInformationManager implements IGroupInformationManager
{ {
const parser = event.getParser(); 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 public getGroupBadge(groupId: number): string
{ {
return this._groupBadges.get(groupId) ?? ''; return this._groupBadges.get(groupId) ?? '';
} }
public getGroupBadgesSnapshot(): ReadonlyMap<number, string>
{
if(this._groupBadgesSnapshot) return this._groupBadgesSnapshot;
this._groupBadgesSnapshot = new Map(this._groupBadges) as ReadonlyMap<number, string>;
return this._groupBadgesSnapshot;
}
} }
+28 -2
View File
@@ -1,9 +1,27 @@
import { IIgnoredUsersManager } from '@nitrots/api'; import { IIgnoredUsersManager } from '@nitrots/api';
import { GetCommunication, GetIgnoredUsersComposer, IgnoreResultEvent, IgnoreUserComposer, IgnoreUserIdComposer, IgnoredUsersEvent, UnignoreUserComposer } from '@nitrots/communication'; import { GetCommunication, GetIgnoredUsersComposer, IgnoreResultEvent, IgnoreUserComposer, IgnoreUserIdComposer, IgnoredUsersEvent, UnignoreUserComposer } from '@nitrots/communication';
import { GetEventDispatcher, NitroEvent, NitroEventType } from '@nitrots/events';
export class IgnoredUsersManager implements IIgnoredUsersManager export class IgnoredUsersManager implements IIgnoredUsersManager
{ {
private _ignoredUsers: string[] = []; private _ignoredUsers: string[] = [];
private _ignoredUsersSnapshot: ReadonlyArray<string> | null = null;
private invalidateIgnoredUsersSnapshot(): void
{
this._ignoredUsersSnapshot = null;
GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.IGNORED_USERS_UPDATED));
}
public getIgnoredUsersSnapshot(): ReadonlyArray<string>
{
if(this._ignoredUsersSnapshot) return this._ignoredUsersSnapshot;
this._ignoredUsersSnapshot = Object.freeze<string[]>([ ...this._ignoredUsers ]) as ReadonlyArray<string>;
return this._ignoredUsersSnapshot;
}
public init(): void public init(): void
{ {
@@ -25,6 +43,7 @@ export class IgnoredUsersManager implements IIgnoredUsersManager
if(!parser) return; if(!parser) return;
this._ignoredUsers = parser.ignoredUsers; this._ignoredUsers = parser.ignoredUsers;
this.invalidateIgnoredUsersSnapshot();
} }
private onIgnoreResultEvent(event: IgnoreResultEvent): void private onIgnoreResultEvent(event: IgnoreResultEvent): void
@@ -47,6 +66,7 @@ export class IgnoredUsersManager implements IIgnoredUsersManager
case 2: case 2:
this.addUserToIgnoreList(name); this.addUserToIgnoreList(name);
this._ignoredUsers.shift(); this._ignoredUsers.shift();
this.invalidateIgnoredUsersSnapshot();
return; return;
case 3: case 3:
this.removeUserFromIgnoreList(name); this.removeUserFromIgnoreList(name);
@@ -56,14 +76,20 @@ export class IgnoredUsersManager implements IIgnoredUsersManager
private addUserToIgnoreList(name: string): void 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 private removeUserFromIgnoreList(name: string): void
{ {
const index = this._ignoredUsers.indexOf(name); 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 public ignoreUserId(id: number): void
-5
View File
@@ -138,11 +138,6 @@ export class RoomSession implements IRoomSession
{ {
GetCommunication().connection.send(new RoomAmbassadorAlertComposer(userId)); GetCommunication().connection.send(new RoomAmbassadorAlertComposer(userId));
} }
public sendWhisperGroupMessage(userId: number): void
{
GetCommunication().connection.send(new ChatWhisperGroupComposer(userId));
}
public sendKickMessage(userId: number): void public sendKickMessage(userId: number): void
{ {
+46 -2
View File
@@ -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 { 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 { NitroLogger } from '@nitrots/utils';
import { RoomSession } from './RoomSession'; import { RoomSession } from './RoomSession';
import { BaseHandler, GenericErrorHandler, PetPackageHandler, PollHandler, RoomChatHandler, RoomDataHandler, RoomDimmerPresetsHandler, RoomPermissionsHandler, RoomPresentHandler, RoomSessionHandler, RoomUsersHandler, WordQuizHandler } from './handler'; 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<typeof setTimeout> = null; private _pendingRoomClear: ReturnType<typeof setTimeout> = null;
private _savedPosX: number = -1; private _savedPosX: number = -1;
private _savedPosY: number = -1; private _savedPosY: number = -1;
private _activeRoomSessionSnapshot: Readonly<IRoomSessionSnapshot> | null = null;
private invalidateRoomSessionSnapshot(): void
{
this._activeRoomSessionSnapshot = null;
GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.ROOM_SESSION_UPDATED));
}
public getActiveRoomSessionSnapshot(): Readonly<IRoomSessionSnapshot> | null
{
const session = this._viewerSession;
if(!session) return null;
if(this._activeRoomSessionSnapshot && this._activeRoomSessionSnapshot.session === session) return this._activeRoomSessionSnapshot;
this._activeRoomSessionSnapshot = Object.freeze<IRoomSessionSnapshot>({
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<void> public async init(): Promise<void>
{ {
@@ -196,6 +231,7 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList
this._sessions.clear(); this._sessions.clear();
this._viewerSession = null; this._viewerSession = null;
this.invalidateRoomSessionSnapshot();
this.createSession(roomId, password, this._savedPosX, this._savedPosY); this.createSession(roomId, password, this._savedPosX, this._savedPosY);
this.clearGuardTimer(); this.clearGuardTimer();
this._reconnectGuardTimer = setTimeout(() => this._reconnectGuardTimer = setTimeout(() =>
@@ -384,6 +420,8 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList
this._lastRoomPassword = roomSession.password; this._lastRoomPassword = roomSession.password;
this.persistRoom(roomSession.roomId, roomSession.password); this.persistRoom(roomSession.roomId, roomSession.password);
this.invalidateRoomSessionSnapshot();
this.startSession(this._viewerSession); this.startSession(this._viewerSession);
return true; return true;
@@ -406,6 +444,8 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList
this.setHandlers(session); this.setHandlers(session);
this.invalidateRoomSessionSnapshot();
return true; return true;
} }
@@ -429,6 +469,10 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList
} }
GetEventDispatcher().dispatchEvent(new RoomSessionEvent(RoomSessionEvent.ENDED, session, openLandingView)); GetEventDispatcher().dispatchEvent(new RoomSessionEvent(RoomSessionEvent.ENDED, session, openLandingView));
if(this._viewerSession === session) this._viewerSession = null;
this.invalidateRoomSessionSnapshot();
} }
public sessionUpdate(id: number, type: string): void public sessionUpdate(id: number, type: string): void
+131 -5
View File
@@ -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 { 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 { GetConfiguration } from '@nitrots/configuration';
import { GetLocalizationManager } from '@nitrots/localization'; 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 { CreateLinkEvent, HabboWebTools, parseConfigJsonFromResponse } from '@nitrots/utils';
import { Texture } from 'pixi.js'; import { Texture } from 'pixi.js';
import { GroupInformationManager } from './GroupInformationManager'; import { GroupInformationManager } from './GroupInformationManager';
@@ -32,6 +32,11 @@ export class SessionDataManager implements ISessionDataManager
private _clubLevel: number = 0; private _clubLevel: number = 0;
private _securityLevel: number = 0; private _securityLevel: number = 0;
private _isAmbassador: boolean = false; 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 _noobnessLevel: number = -1;
private _isEmailVerified: boolean = false; private _isEmailVerified: boolean = false;
@@ -52,11 +57,87 @@ export class SessionDataManager implements ISessionDataManager
private _badgeImageManager: BadgeImageManager = new BadgeImageManager(); private _badgeImageManager: BadgeImageManager = new BadgeImageManager();
private _userDataSnapshot: Readonly<IUserDataSnapshot> | null = null;
private _permissions: Map<string, number> = new Map();
private _permissionsSnapshot: ReadonlyMap<string, number> | null = null;
constructor() constructor()
{ {
this.resetUserInfo(); 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<string, number>
{
if(this._permissionsSnapshot) return this._permissionsSnapshot;
this._permissionsSnapshot = new Map(this._permissions) as ReadonlyMap<string, number>;
return this._permissionsSnapshot;
}
public getUserDataSnapshot(): Readonly<IUserDataSnapshot>
{
if(this._userDataSnapshot) return this._userDataSnapshot;
this._userDataSnapshot = Object.freeze<IUserDataSnapshot>({
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<string[]>([...this._tags]) as ReadonlyArray<string>,
rankId: this._rankId,
rankName: this._rankName,
rankBadge: this._rankBadge,
rankPrefix: this._rankPrefix,
rankPrefixColor: this._rankPrefixColor
});
return this._userDataSnapshot;
}
public async init(): Promise<void> public async init(): Promise<void>
{ {
await Promise.all([ await Promise.all([
@@ -75,6 +156,8 @@ export class SessionDataManager implements ISessionDataManager
this._gender = event.getParser().gender; this._gender = event.getParser().gender;
HabboWebTools.updateFigure(this._figure); HabboWebTools.updateFigure(this._figure);
this.invalidateUserDataSnapshot();
})), })),
GetCommunication().registerMessageEvent(new UserInfoEvent(this.onUserInfoEvent.bind(this))), GetCommunication().registerMessageEvent(new UserInfoEvent(this.onUserInfoEvent.bind(this))),
GetCommunication().registerMessageEvent(new UserPermissionsEvent(this.onUserPermissionsEvent.bind(this))), GetCommunication().registerMessageEvent(new UserPermissionsEvent(this.onUserPermissionsEvent.bind(this))),
@@ -98,6 +181,8 @@ export class SessionDataManager implements ISessionDataManager
this._uiFlags = event.flags; this._uiFlags = event.flags;
GetEventDispatcher().dispatchEvent(new SessionDataPreferencesEvent(this._uiFlags)); GetEventDispatcher().dispatchEvent(new SessionDataPreferencesEvent(this._uiFlags));
this.invalidateUserDataSnapshot();
}; };
GetEventDispatcher().addEventListener<NitroSettingsEvent>(NitroSettingsEvent.SETTINGS_UPDATED, this._settingsEventCallback); GetEventDispatcher().addEventListener<NitroSettingsEvent>(NitroSettingsEvent.SETTINGS_UPDATED, this._settingsEventCallback);
@@ -189,15 +274,38 @@ export class SessionDataManager implements ISessionDataManager
this._safetyLocked = userInfo.safetyLocked; this._safetyLocked = userInfo.safetyLocked;
this._ignoredUsersManager.requestIgnoredUsers(userInfo.username); this._ignoredUsersManager.requestIgnoredUsers(userInfo.username);
this.invalidateUserDataSnapshot();
} }
private onUserPermissionsEvent(event: UserPermissionsEvent): void private onUserPermissionsEvent(event: UserPermissionsEvent): void
{ {
if(!event || !event.connection) return; if(!event || !event.connection) return;
this._clubLevel = event.getParser().clubLevel; const parser = event.getParser();
this._securityLevel = event.getParser().securityLevel;
this._isAmbassador = event.getParser().isAmbassador; 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 private onAvailabilityStatusMessageEvent(event: AvailabilityStatusMessageEvent): void
@@ -211,6 +319,8 @@ export class SessionDataManager implements ISessionDataManager
this._systemOpen = parser.isOpen; this._systemOpen = parser.isOpen;
this._systemShutdown = parser.onShutdown; this._systemShutdown = parser.onShutdown;
this._isAuthenticHabbo = parser.isAuthenticUser; this._isAuthenticHabbo = parser.isAuthenticUser;
this.invalidateUserDataSnapshot();
} }
private onPetRespectFailed(event: PetScratchFailedMessageEvent): void private onPetRespectFailed(event: PetScratchFailedMessageEvent): void
@@ -218,6 +328,8 @@ export class SessionDataManager implements ISessionDataManager
if(!event || !event.connection) return; if(!event || !event.connection) return;
this._respectsPetLeft++; this._respectsPetLeft++;
this.invalidateUserDataSnapshot();
} }
private onChangeNameUpdateEvent(event: ChangeUserNameResultMessageEvent): void private onChangeNameUpdateEvent(event: ChangeUserNameResultMessageEvent): void
@@ -233,6 +345,8 @@ export class SessionDataManager implements ISessionDataManager
this._canChangeName = false; this._canChangeName = false;
GetEventDispatcher().dispatchEvent(new UserNameUpdateEvent(parser.name)); GetEventDispatcher().dispatchEvent(new UserNameUpdateEvent(parser.name));
this.invalidateUserDataSnapshot();
} }
private onUserNameChangeMessageEvent(event: UserNameChangeMessageEvent): void private onUserNameChangeMessageEvent(event: UserNameChangeMessageEvent): void
@@ -249,6 +363,8 @@ export class SessionDataManager implements ISessionDataManager
this._canChangeName = false; this._canChangeName = false;
GetEventDispatcher().dispatchEvent(new UserNameUpdateEvent(this._name)); GetEventDispatcher().dispatchEvent(new UserNameUpdateEvent(this._name));
this.invalidateUserDataSnapshot();
} }
private onUserTags(event: UserTagsMessageEvent): void private onUserTags(event: UserTagsMessageEvent): void
@@ -260,6 +376,8 @@ export class SessionDataManager implements ISessionDataManager
if(!parser) return; if(!parser) return;
this._tags = parser.tags; this._tags = parser.tags;
this.invalidateUserDataSnapshot();
} }
private onRoomModelNameEvent(event: RoomReadyMessageEvent): void private onRoomModelNameEvent(event: RoomReadyMessageEvent): void
@@ -300,6 +418,8 @@ export class SessionDataManager implements ISessionDataManager
this._noobnessLevel = event.getParser().noobnessLevel; this._noobnessLevel = event.getParser().noobnessLevel;
if(this._noobnessLevel !== NoobnessLevelEnum.OLD_IDENTITY) GetConfiguration().setValue<number>('new.identity', 1); if(this._noobnessLevel !== NoobnessLevelEnum.OLD_IDENTITY) GetConfiguration().setValue<number>('new.identity', 1);
this.invalidateUserDataSnapshot();
} }
private onAccountSafetyLockStatusChangeMessageEvent(event: AccountSafetyLockStatusChangeMessageEvent): void private onAccountSafetyLockStatusChangeMessageEvent(event: AccountSafetyLockStatusChangeMessageEvent): void
@@ -316,6 +436,8 @@ export class SessionDataManager implements ISessionDataManager
private onEmailStatus(event: EmailStatusResultEvent): void private onEmailStatus(event: EmailStatusResultEvent): void
{ {
this._isEmailVerified = event?.getParser()?.isVerified ?? false; this._isEmailVerified = event?.getParser()?.isVerified ?? false;
this.invalidateUserDataSnapshot();
} }
public getFloorItemData(id: number): IFurnitureData public getFloorItemData(id: number): IFurnitureData
@@ -476,6 +598,8 @@ export class SessionDataManager implements ISessionDataManager
this.send(new UserRespectComposer(userId)); this.send(new UserRespectComposer(userId));
this._respectsLeft--; this._respectsLeft--;
this.invalidateUserDataSnapshot();
} }
public givePetRespect(petId: number): void public givePetRespect(petId: number): void
@@ -485,6 +609,8 @@ export class SessionDataManager implements ISessionDataManager
this.send(new PetRespectComposer(petId)); this.send(new PetRespectComposer(petId));
this._respectsPetLeft--; this._respectsPetLeft--;
this.invalidateUserDataSnapshot();
} }
public sendSpecialCommandMessage(text: string, styleId: number = 0): void public sendSpecialCommandMessage(text: string, styleId: number = 0): void
+45 -3
View File
@@ -1,5 +1,6 @@
import { IRoomUserData, IUserDataManager } from '@nitrots/api'; import { IRoomUserData, IUserDataManager } from '@nitrots/api';
import { GetCommunication, RequestPetInfoComposer, UserCurrentBadgesComposer } from '@nitrots/communication'; import { GetCommunication, RequestPetInfoComposer, UserCurrentBadgesComposer } from '@nitrots/communication';
import { GetEventDispatcher, NitroEvent, NitroEventType } from '@nitrots/events';
export class UserDataManager implements IUserDataManager export class UserDataManager implements IUserDataManager
{ {
@@ -11,6 +12,23 @@ export class UserDataManager implements IUserDataManager
private _userDataByType: Map<number, Map<number, IRoomUserData>> = new Map(); private _userDataByType: Map<number, Map<number, IRoomUserData>> = new Map();
private _userDataByRoomIndex: Map<number, IRoomUserData> = new Map(); private _userDataByRoomIndex: Map<number, IRoomUserData> = new Map();
private _userBadges: Map<number, string[]> = new Map(); private _userBadges: Map<number, string[]> = new Map();
private _roomUserListSnapshot: ReadonlyArray<IRoomUserData> | null = null;
private invalidateRoomUserListSnapshot(): void
{
this._roomUserListSnapshot = null;
GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.ROOM_USER_LIST_UPDATED));
}
public getRoomUserListSnapshot(): ReadonlyArray<IRoomUserData>
{
if(this._roomUserListSnapshot) return this._roomUserListSnapshot;
this._roomUserListSnapshot = Object.freeze<IRoomUserData[]>([ ...this._userDataByRoomIndex.values() ]) as ReadonlyArray<IRoomUserData>;
return this._roomUserListSnapshot;
}
public getUserData(webID: number): IRoomUserData public getUserData(webID: number): IRoomUserData
{ {
@@ -84,6 +102,8 @@ export class UserDataManager implements IUserDataManager
existingType.set(data.webID, data); existingType.set(data.webID, data);
this._userDataByRoomIndex.set(data.roomIndex, data); this._userDataByRoomIndex.set(data.roomIndex, data);
this.invalidateRoomUserListSnapshot();
} }
public removeUserData(roomIndex: number): void public removeUserData(roomIndex: number): void
@@ -97,6 +117,8 @@ export class UserDataManager implements IUserDataManager
const existingType = this._userDataByType.get(existing.type); const existingType = this._userDataByType.get(existing.type);
if(existingType) existingType.delete(existing.webID); if(existingType) existingType.delete(existing.webID);
this.invalidateRoomUserListSnapshot();
} }
public getUserBadges(userId: number): string[] public getUserBadges(userId: number): string[]
@@ -125,6 +147,8 @@ export class UserDataManager implements IUserDataManager
userData.sex = sex; userData.sex = sex;
userData.hasSaddle = hasSaddle; userData.hasSaddle = hasSaddle;
userData.isRiding = isRiding; userData.isRiding = isRiding;
this.invalidateRoomUserListSnapshot();
} }
public updateName(roomIndex: number, name: string): void public updateName(roomIndex: number, name: string): void
@@ -134,6 +158,8 @@ export class UserDataManager implements IUserDataManager
if(!userData) return; if(!userData) return;
userData.name = name; userData.name = name;
this.invalidateRoomUserListSnapshot();
} }
public updateMotto(roomIndex: number, custom: string): void public updateMotto(roomIndex: number, custom: string): void
@@ -143,6 +169,8 @@ export class UserDataManager implements IUserDataManager
if(!userData) return; if(!userData) return;
userData.custom = custom; userData.custom = custom;
this.invalidateRoomUserListSnapshot();
} }
public updateNickIcon(roomIndex: number, nickIcon: string): void public updateNickIcon(roomIndex: number, nickIcon: string): void
@@ -152,6 +180,8 @@ export class UserDataManager implements IUserDataManager
if(!userData) return; if(!userData) return;
userData.nickIcon = nickIcon; userData.nickIcon = nickIcon;
this.invalidateRoomUserListSnapshot();
} }
public updateCustomization(roomIndex: number, nickIcon: string, prefixText: string, prefixColor: string, prefixIcon: string, prefixEffect: string, prefixFont: string, displayOrder: string): void 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.prefixEffect = prefixEffect;
userData.prefixFont = prefixFont; userData.prefixFont = prefixFont;
userData.displayOrder = displayOrder; 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); const userData = this.getUserDataByIndex(roomIndex);
@@ -180,6 +212,8 @@ export class UserDataManager implements IUserDataManager
userData.overlay = overlay; userData.overlay = overlay;
userData.cardBackground = cardBackground; userData.cardBackground = cardBackground;
userData.borderId = borderId; userData.borderId = borderId;
this.invalidateRoomUserListSnapshot();
} }
public updateAchievementScore(roomIndex: number, score: number): void public updateAchievementScore(roomIndex: number, score: number): void
@@ -189,13 +223,19 @@ export class UserDataManager implements IUserDataManager
if(!userData) return; if(!userData) return;
userData.activityPoints = score; userData.activityPoints = score;
this.invalidateRoomUserListSnapshot();
} }
public updatePetLevel(roomIndex: number, level: number): void public updatePetLevel(roomIndex: number, level: number): void
{ {
const userData = this.getUserDataByIndex(roomIndex); 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 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.canHarvest = canHarvest;
userData.canRevive = canRevive; userData.canRevive = canRevive;
userData.hasBreedingPermission = hasBreedingPermission; userData.hasBreedingPermission = hasBreedingPermission;
this.invalidateRoomUserListSnapshot();
} }
public requestPetInfo(id: number): void public requestPetInfo(id: number): void
@@ -168,6 +168,6 @@ export class RoomChatHandler extends BaseHandler
if(!parser) return; 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));
} }
} }
+1 -1
View File
@@ -14,6 +14,6 @@
"pixi.js": "^8.8.1" "pixi.js": "^8.8.1"
}, },
"devDependencies": { "devDependencies": {
"typescript": "~5.5.4" "typescript": "^6.0.3"
} }
} }
+45 -7
View File
@@ -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 { 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 { AdvancedMap, NitroLogger } from '@nitrots/utils';
import { MusicController } from './music/MusicController'; import { MusicController } from './music/MusicController';
@@ -9,6 +9,7 @@ export class SoundManager implements ISoundManager
private _volumeSystem: number = 0.5; private _volumeSystem: number = 0.5;
private _volumeFurni: number = 0.5; private _volumeFurni: number = 0.5;
private _volumeTrax: number = 0.5; private _volumeTrax: number = 0.5;
private _volumesSnapshot: Readonly<ISoundVolumesSnapshot> | null = null;
private _internalSamples: IAdvancedMap<string, HTMLAudioElement> = new AdvancedMap(); private _internalSamples: IAdvancedMap<string, HTMLAudioElement> = new AdvancedMap();
private _furniSamples: IAdvancedMap<number, HTMLAudioElement> = new AdvancedMap(); private _furniSamples: IAdvancedMap<number, HTMLAudioElement> = new AdvancedMap();
@@ -81,17 +82,24 @@ export class SoundManager implements ISoundManager
case NitroSettingsEvent.SETTINGS_UPDATED: { case NitroSettingsEvent.SETTINGS_UPDATED: {
const castedEvent = (event as NitroSettingsEvent); const castedEvent = (event as NitroSettingsEvent);
const volumeFurniUpdated = castedEvent.volumeFurni !== this._volumeFurni; const nextSystem = (castedEvent.volumeSystem / 100);
const volumeTraxUpdated = castedEvent.volumeTrax !== this._volumeTrax; const nextFurni = (castedEvent.volumeFurni / 100);
const nextTrax = (castedEvent.volumeTrax / 100);
this._volumeSystem = (castedEvent.volumeSystem / 100); const volumeSystemUpdated = nextSystem !== this._volumeSystem;
this._volumeFurni = (castedEvent.volumeFurni / 100); const volumeFurniUpdated = nextFurni !== this._volumeFurni;
this._volumeTrax = (castedEvent.volumeTrax / 100); const volumeTraxUpdated = nextTrax !== this._volumeTrax;
this._volumeSystem = nextSystem;
this._volumeFurni = nextFurni;
this._volumeTrax = nextTrax;
if(volumeFurniUpdated) this.updateFurniSamplesVolume(this._volumeFurni); if(volumeFurniUpdated) this.updateFurniSamplesVolume(this._volumeFurni);
if(volumeTraxUpdated) this._musicController?.updateVolume(this._volumeTrax); if(volumeTraxUpdated) this._musicController?.updateVolume(this._volumeTrax);
if(volumeSystemUpdated || volumeFurniUpdated || volumeTraxUpdated) this.invalidateVolumesSnapshot();
return; return;
} }
case NitroSoundEvent.PLAY_SOUND: { case NitroSoundEvent.PLAY_SOUND: {
@@ -215,8 +223,38 @@ export class SoundManager implements ISoundManager
return this._volumeTrax; return this._volumeTrax;
} }
public get systemVolume(): number
{
return this._volumeSystem;
}
public get furniVolume(): number
{
return this._volumeFurni;
}
public get musicController(): IMusicController public get musicController(): IMusicController
{ {
return this._musicController; return this._musicController;
} }
private invalidateVolumesSnapshot(): void
{
this._volumesSnapshot = null;
GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.SOUND_VOLUMES_UPDATED));
}
public getVolumesSnapshot(): Readonly<ISoundVolumesSnapshot>
{
if(this._volumesSnapshot) return this._volumesSnapshot;
this._volumesSnapshot = Object.freeze<ISoundVolumesSnapshot>({
system: this._volumeSystem,
furni: this._volumeFurni,
trax: this._volumeTrax
});
return this._volumesSnapshot;
}
} }
+1 -1
View File
@@ -16,6 +16,6 @@
}, },
"devDependencies": { "devDependencies": {
"@types/pako": "^2.0.3", "@types/pako": "^2.0.3",
"typescript": "~5.5.4" "typescript": "^6.0.3"
} }
} }
+2 -2
View File
@@ -1,8 +1,8 @@
export const ArrayBufferToBase64 = (buffer: ArrayBuffer) => export const ArrayBufferToBase64 = (buffer: ArrayBufferLike | Uint8Array) =>
{ {
let binary = ''; let binary = '';
const bytes = new Uint8Array(buffer); const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
const len = bytes.byteLength; const len = bytes.byteLength;
for(let i = 0; i < len; i++) (binary += String.fromCharCode(bytes[i])); for(let i = 0; i < len; i++) (binary += String.fromCharCode(bytes[i]));
+2 -2
View File
@@ -13,7 +13,7 @@ export class BinaryReader implements IBinaryReader
public readBytes(length: number): 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; this._position += length;
@@ -77,6 +77,6 @@ export class BinaryReader implements IBinaryReader
public toArrayBuffer(): ArrayBuffer public toArrayBuffer(): ArrayBuffer
{ {
return this._dataView.buffer; return this._dataView.buffer as ArrayBuffer;
} }
} }
+1 -1
View File
@@ -89,7 +89,7 @@ export class BinaryWriter implements IBinaryWriter
public getBuffer(): ArrayBuffer public getBuffer(): ArrayBuffer
{ {
return this._buffer.buffer; return this._buffer.buffer as ArrayBuffer;
} }
public get position(): number public get position(): number
+69 -40
View File
@@ -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 const DEFAULT_TIERS = [ 'core', 'custom', 'seasonal' ] as const;
export type GamedataTier = typeof DEFAULT_TIERS[number] | string; export type GamedataTier = typeof DEFAULT_TIERS[number] | string;
@@ -28,51 +29,69 @@ const joinUrl = (base: string, path: string): string =>
return `${ cleanBase }${ cleanPath }`; return `${ cleanBase }${ cleanPath }`;
}; };
const tryFetchOrNull = async <T = any>(url: string): Promise<T | null> => // 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 <T = any>(url: string): Promise<T | null> =>
{ {
try try
{ {
return await fetchConfigJson<T>(url); return await fetchConfigJson<T>(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 <T = any>(baseUrl: string, name: string): Promise<T | null> =>
{
const json5 = await tryFetchManifest<T>(joinUrl(baseUrl, `${ name }.json5`));
if(json5 !== null) return json5;
return await tryFetchManifest<T>(joinUrl(baseUrl, `${ name }.json`));
};
const isPlainObject = (value: any): value is Record<string, any> => !!value && typeof value === 'object' && !Array.isArray(value); const isPlainObject = (value: any): value is Record<string, any> => !!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; if(!arr.length) return null;
for(const key of idKeys) for(const key of idKeys)
{ {
let allHave = true; let have = 0;
for(const item of arr) for(const item of arr)
{ {
if(!isPlainObject(item) || item[key] === undefined || item[key] === null) if(isPlainObject(item) && item[key] !== undefined && item[key] !== null) have++;
{
allHave = false;
break;
}
} }
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; 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(b === undefined) return a;
if(a === undefined) return b; if(a === undefined) return b;
if(Array.isArray(a) && Array.isArray(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); if(!idKey) return a.concat(b);
@@ -92,7 +111,7 @@ export const mergeGamedata = (a: any, b: any, idKeys: readonly string[] = DEFAUL
if(at !== undefined) if(at !== undefined)
{ {
out[at] = mergeGamedata(out[at], item, idKeys); out[at] = mergeGamedata(out[at], item, idKeys, sourceLabel);
} }
else else
{ {
@@ -110,7 +129,7 @@ export const mergeGamedata = (a: any, b: any, idKeys: readonly string[] = DEFAUL
for(const k of Object.keys(b)) 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; return out;
@@ -130,6 +149,11 @@ interface RootManifest
files?: string[]; 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<any[]> =>
Promise.all(files.map(file => fetchConfigJson(joinUrl(baseUrl, file))));
export const loadGamedata = async <T = any>(url: string, options: GamedataLoadOptions = {}): Promise<T> => export const loadGamedata = async <T = any>(url: string, options: GamedataLoadOptions = {}): Promise<T> =>
{ {
if(!url) throw new Error('loadGamedata: empty URL'); if(!url) throw new Error('loadGamedata: empty URL');
@@ -140,42 +164,47 @@ export const loadGamedata = async <T = any>(url: string, options: GamedataLoadOp
} }
const idKeys = options.mergeArrayIdKeys ?? DEFAULT_ID_KEYS; const idKeys = options.mergeArrayIdKeys ?? DEFAULT_ID_KEYS;
const rootManifest = await tryFetchOrNull<RootManifest>(joinUrl(url, 'manifest.json5')) const rootManifest = await tryFetchManifestPair<RootManifest>(url, 'manifest');
?? await tryFetchOrNull<RootManifest>(joinUrl(url, 'manifest.json'));
const tiers = (rootManifest?.tiers && rootManifest.tiers.length) const tiers = (rootManifest?.tiers && rootManifest.tiers.length)
? rootManifest.tiers ? rootManifest.tiers
: (options.tiers ?? DEFAULT_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<TierManifest>(tierUrl, 'manifest');
return { tier, tierUrl, manifest };
}))
]);
let merged: any = undefined; 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); merged = (merged === undefined) ? part : mergeGamedata(merged, part, idKeys, `${ url } (${ tier })`);
const part = await fetchConfigJson(fileUrl);
merged = (merged === undefined) ? part : mergeGamedata(merged, part, idKeys);
} }
} }
for(const tier of tiers) 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);
{
const tierUrl = joinUrl(url, `${ tier }/`);
const tierManifest = await tryFetchOrNull<TierManifest>(joinUrl(tierUrl, 'manifest.json5'))
?? await tryFetchOrNull<TierManifest>(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`);
return merged as T; return merged as T;
}; };
+43 -9
View File
@@ -5,6 +5,28 @@ declare const __NITRO_JSON_MODE__: 'legacy' | 'json5' | 'auto' | undefined;
const JSON5_EXTENSION = /\.json5(?:[?#]|$)/i; const JSON5_EXTENSION = /\.json5(?:[?#]|$)/i;
const JSON5_MIME = /(?:application|text)\/(?:json5|x-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' => const resolveJsonMode = (): 'legacy' | 'json5' | 'auto' =>
{ {
try try
@@ -44,9 +66,7 @@ const formatStrictError = (sourceUrl: string, err: unknown): string =>
export const parseConfigJson = <T = any>(text: string, sourceUrl: string = ''): T => export const parseConfigJson = <T = any>(text: string, sourceUrl: string = ''): T =>
{ {
if(text === null || text === undefined) throw new Error(`Empty response${ sourceUrl ? ` for "${ sourceUrl }"` : '' }`); const trimmed = text ?? '';
const trimmed = text.length > 0 ? text : '';
const mode = resolveJsonMode(); const mode = resolveJsonMode();
if(mode === 'legacy') if(mode === 'legacy')
@@ -57,7 +77,7 @@ export const parseConfigJson = <T = any>(text: string, sourceUrl: string = ''):
} }
catch(err) 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 = <T = any>(text: string, sourceUrl: string = ''):
} }
catch(err) 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 = <T = any>(text: string, sourceUrl: string = ''):
} }
catch(json5Error) 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 <T = any>(response: Response, s
} }
catch(err) 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 <T = any>(response: Response, s
export const fetchConfigJson = async <T = any>(url: string, init?: RequestInit): Promise<T> => export const fetchConfigJson = async <T = any>(url: string, init?: RequestInit): Promise<T> =>
{ {
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<T>(response, url); return parseConfigJsonFromResponse<T>(response, url);
}; };
+4 -4
View File
@@ -2,8 +2,8 @@ export { };
declare global declare global
{ {
interface Window interface Window
{ {
NitroConfig?: { [index: string]: any }; NitroConfig?: Record<string, unknown>;
} }
} }
+2 -2
View File
@@ -1,7 +1,7 @@
export class NitroVersion export class NitroVersion
{ {
public static RENDERER_VERSION: string = '3.0.0'; public static RENDERER_VERSION: string = '3.5.0';
public static UI_VERSION: string = '3.0.4'; public static UI_VERSION: string = '3.5.0';
public static sayHello(): void public static sayHello(): void
{ {
+1 -1
View File
@@ -28,7 +28,7 @@ export class TextureUtils
try try
{ {
return await this.getExtractor().image(options); return await this.getExtractor().image(options) as HTMLImageElement;
} }
catch(e) catch(e)
{ {
@@ -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);
});
});
});
+17
View File
@@ -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: <url> }`; 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: <T = { default: string }>(pattern: string, options?: { eager?: boolean; import?: string }) => Record<string, T>;
}
+1 -3
View File
@@ -1,17 +1,15 @@
{ {
"compileOnSave": false, "compileOnSave": false,
"compilerOptions": { "compilerOptions": {
"baseUrl": "./src",
"outDir": "./dist", "outDir": "./dist",
"sourceMap": false, "sourceMap": false,
"declaration": true, "declaration": true,
"experimentalDecorators": true, "experimentalDecorators": true,
"moduleResolution": "Node", "moduleResolution": "bundler",
"esModuleInterop": true, "esModuleInterop": true,
"importHelpers": true, "importHelpers": true,
"isolatedModules": true, "isolatedModules": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"downlevelIteration": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
+53 -10
View File
@@ -369,7 +369,7 @@
resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8" resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8"
integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==
"@thumbmarkjs/thumbmarkjs@^1.8.1": "@thumbmarkjs/thumbmarkjs@^1.9.0":
version "1.9.0" version "1.9.0"
resolved "https://registry.yarnpkg.com/@thumbmarkjs/thumbmarkjs/-/thumbmarkjs-1.9.0.tgz#a6444ac1f924f061cfc1507a21dcaf83ee705cab" resolved "https://registry.yarnpkg.com/@thumbmarkjs/thumbmarkjs/-/thumbmarkjs-1.9.0.tgz#a6444ac1f924f061cfc1507a21dcaf83ee705cab"
integrity sha512-6LooyYk8i5L2zEZgDMLE6m2sGDcIHHBiZfxdFp0A16Q4ZXafEmhHmt+zCqQEBMiQHi+08e/v5q77IY2KhvAJwg== integrity sha512-6LooyYk8i5L2zEZgDMLE6m2sGDcIHHBiZfxdFp0A16Q4ZXafEmhHmt+zCqQEBMiQHi+08e/v5q77IY2KhvAJwg==
@@ -527,6 +527,54 @@
"@typescript-eslint/types" "8.59.3" "@typescript-eslint/types" "8.59.3"
eslint-visitor-keys "^5.0.0" 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": "@vitest/coverage-v8@^4.0.18":
version "4.1.6" version "4.1.6"
resolved "https://registry.yarnpkg.com/@vitest/coverage-v8/-/coverage-v8-4.1.6.tgz#1eacee5def68dfcb08c3ed5355edbad2a4c869b3" 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/typescript-estree" "8.59.3"
"@typescript-eslint/utils" "8.59.3" "@typescript-eslint/utils" "8.59.3"
typescript@~5.5.4: typescript@^6.0.3:
version "5.5.4" version "6.0.3"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.4.tgz#d9852d6c82bad2d2eda4fd74a5762a8f5909e9ba" resolved "https://registry.yarnpkg.com/typescript/-/typescript-6.0.3.tgz#90251dc007916e972786cb94d74d15b185577d21"
integrity sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q== integrity sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==
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==
undici-types@~6.21.0: undici-types@~6.21.0:
version "6.21.0" version "6.21.0"