Merge branch 'Dev' into merge-duckie-main-2026-05-06

This commit is contained in:
DuckieTM
2026-05-25 18:48:34 +02:00
committed by GitHub
77 changed files with 1559 additions and 201 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",
"description": "Javascript library for rendering Nitro in the browser using PixiJS",
"version": "2.0.0",
"version": "2.1.0",
"private": true,
"type": "module",
"workspaces": [
@@ -22,6 +22,7 @@
"scripts": {
"build": "vite build",
"compile": "tsc --project ./tsconfig.json --noEmit false",
"compile:fast": "tsgo --project ./tsconfig.json --noEmit",
"eslint": "eslint ./src ./packages/*/src",
"eslint-fix": "eslint ./src --fix",
"test": "vitest run",
@@ -30,7 +31,7 @@
},
"main": "./index",
"dependencies": {
"@thumbmarkjs/thumbmarkjs": "^1.8.1",
"@thumbmarkjs/thumbmarkjs": "^1.9.0",
"gifuct-js": "^2.1.2",
"howler": "^2.2.4",
"json5": "^2.2.3",
@@ -47,8 +48,9 @@
"@vitest/coverage-v8": "^4.0.18",
"eslint": "^9.8.0",
"jsdom": "^27.4.0",
"@typescript/native-preview": "^7.0.0-dev.20260510.1",
"tslib": "^2.6.3",
"typescript": "~5.8.2",
"typescript": "^6.0.3",
"typescript-eslint": "^8.26.1",
"vite": "^8.0.10",
"vitest": "^4.1.5"
+1 -1
View File
@@ -15,6 +15,6 @@
"pixi.js": "^8.8.1"
},
"devDependencies": {
"typescript": "~5.8.2"
"typescript": "^6.0.3"
}
}
@@ -7,4 +7,5 @@ export interface IEventDispatcher
removeEventListener(type: string, callback: Function): void;
removeAllListeners(): void;
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>;
registerMessageEvent(event: IMessageEvent): IMessageEvent;
removeMessageEvent(event: IMessageEvent): void;
subscribeMessage<T extends IMessageEvent>(eventCtor: new (callback: (event: T) => void) => T, handler: (event: T) => void): () => void;
connection: IConnection;
}
@@ -2,4 +2,13 @@ export interface IGroupInformationManager
{
init(): void;
getGroupBadge(groupId: number): string;
/**
* Returns the current `groupId -> badgeId` map as a frozen,
* referentially stable ReadonlyMap. The same reference is returned
* across reads until the underlying badges change; mutations
* dispatch `NitroEventType.GROUP_BADGES_UPDATED` to signal
* invalidation.
*/
getGroupBadgesSnapshot(): ReadonlyMap<number, string>;
}
@@ -6,4 +6,14 @@ export interface IIgnoredUsersManager
ignoreUser(name: string): void;
unignoreUser(name: string): void;
isIgnored(name: string): boolean;
/**
* Returns the current ignored-users list as a frozen, referentially
* stable array. The same reference is returned across reads until
* the list is mutated; mutations dispatch
* `NitroEventType.IGNORED_USERS_UPDATED` to signal invalidation.
*
* Pairs with `useSyncExternalStore` on the React client.
*/
getIgnoredUsersSnapshot(): ReadonlyArray<string>;
}
@@ -9,10 +9,11 @@ export interface IRoomSession
setRoomOwner(): void;
start(): boolean;
reset(roomId: number): void;
sendChatMessage(text: string, styleId: number, chatColour: string): void;
sendShoutMessage(text: string, styleId: number, chatColour: string): void;
sendChatMessage(text: string, styleId: number, chatColour?: string): void;
sendShoutMessage(text: string, styleId: number, chatColour?: string): void;
sendWhisperMessage(recipientName: string, text: string, styleId: number): void;
sendChatTypingMessage(isTyping: boolean): void;
sendBackgroundMessage(backgroundImage: number, backgroundStand: number, backgroundOverlay: number, backgroundCard?: number, backgroundBorder?: number): void;
sendMottoMessage(motto: string): void;
sendDanceMessage(danceId: number): void;
sendExpressionMessage(expression: number): void;
@@ -20,7 +21,6 @@ export interface IRoomSession
sendPostureMessage(posture: number): void;
sendDoorbellApprovalMessage(userName: string, flag: boolean): void;
sendAmbassadorAlertMessage(userId: number): void;
sendWhisperGroupMessage(userId: number): void;
sendKickMessage(userId: number): void;
sendMuteMessage(userId: number, minutes: number): void;
sendBanMessage(userId: number, type: string): void;
@@ -50,6 +50,7 @@ export interface IRoomSession
sendScriptProceed(): void;
userDataManager: IUserDataManager;
roomId: number;
password: string;
state: string;
tradeMode: number;
isPrivateRoom: boolean;
@@ -1,4 +1,5 @@
import { IRoomSession } from './IRoomSession';
import { IRoomSessionSnapshot } from './IRoomSessionSnapshot';
export interface IRoomSessionManager
{
@@ -8,5 +9,6 @@ export interface IRoomSessionManager
startSession(session: IRoomSession): boolean;
removeSession(id: number, openLandingView?: boolean): void;
tryRestoreSession(): boolean;
getActiveRoomSessionSnapshot(): Readonly<IRoomSessionSnapshot> | null;
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;
}
@@ -6,6 +6,7 @@ export interface IRoomUserData
stand: number;
overlay: number;
cardBackground: number;
borderId: number;
name: string;
type: number;
sex: string;
@@ -3,6 +3,7 @@ import { IFurnitureData } from './IFurnitureData';
import { IGroupInformationManager } from './IGroupInformationManager';
import { IIgnoredUsersManager } from './IIgnoredUsersManager';
import { IProductData } from './IProductData';
import { IUserDataSnapshot } from './IUserDataSnapshot';
export interface ISessionDataManager
{
@@ -53,4 +54,12 @@ export interface ISessionDataManager
isCameraFollowDisabled: boolean;
uiFlags: number;
tags: string[];
getUserDataSnapshot(): Readonly<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>;
}
@@ -18,9 +18,25 @@ export interface IUserDataManager
updateMotto(roomIndex: number, custom: string): void;
updateNickIcon(roomIndex: number, nickIcon: string): void;
updateCustomization(roomIndex: number, nickIcon: string, prefixText: string, prefixColor: string, prefixIcon: string, prefixEffect: string, prefixFont: string, displayOrder: string): void;
updateBackground(roomIndex: number, background: number, stand: number, overlay: number, cardBackground?: number): void;
updateBackground(roomIndex: number, background: number, stand: number, overlay: number, cardBackground?: number, borderId?: number): void;
updateAchievementScore(roomIndex: number, score: number): void;
updatePetLevel(roomIndex: number, level: number): void;
updatePetBreedingStatus(roomIndex: number, canBreed: boolean, canHarvest: boolean, canRevive: boolean, hasBreedingPermission: boolean): void;
requestPetInfo(id: number): void;
/**
* Returns the current room's user list as a referentially-stable
* ReadonlyArray. The same array reference is returned across reads
* until any user is added, removed, or has a tracked field updated
* (figure / name / motto / nick icon / customization / background /
* achievement score / pet level / breeding status). Mutations
* dispatch `NitroEventType.ROOM_USER_LIST_UPDATED` to signal
* invalidation.
*
* The inner IRoomUserData objects keep the existing in-place
* mutation semantics — they are NOT deep-cloned. Treat them as
* snapshots-at-time-of-read; consumers should not retain individual
* entries across invalidations.
*/
getRoomUserListSnapshot(): ReadonlyArray<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 './ISessionDataManager';
export * from './IUserDataManager';
export * from './IUserDataSnapshot';
export * from './IRoomSessionSnapshot';
export * from './PetBreedingResultData';
export * from './PetCustomPart';
export * from './PetFigureData';
@@ -1,8 +1,22 @@
import { IMusicController } from './IMusicController';
import { ISoundVolumesSnapshot } from './ISoundVolumesSnapshot';
export interface ISoundManager
{
init(): Promise<void>;
musicController: IMusicController;
traxVolume: number;
systemVolume: number;
furniVolume: number;
/**
* Returns a referentially-stable snapshot of the three volume
* levels (system / furni / trax). The same reference is returned
* across reads until a volume changes; mutations dispatch
* `NitroEventType.SOUND_VOLUMES_UPDATED` to signal invalidation.
*
* Pairs with `useSyncExternalStore` on the React client for
* volume-slider widgets.
*/
getVolumesSnapshot(): Readonly<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 './ISongInfo';
export * from './ISoundManager';
export * from './ISoundVolumesSnapshot';
+1 -1
View File
@@ -18,6 +18,6 @@
"pixi.js": "^8.8.1"
},
"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)
{
const mod = merged[path];
const imageUrl = (mod.default ?? mod) as string;
const imageUrl = ((mod as { default?: string }).default ?? mod) as string;
const file = path.split('/').pop()!;
const rawName = file.replace(/\.png$/i, '');
@@ -296,7 +296,7 @@ export class AssetManager implements IAssetManager
if(!path.startsWith(prefix)) continue;
const mod = allImages[path];
const imageUrl = (mod.default ?? mod) as string;
const imageUrl = ((mod as { default?: string }).default ?? mod) as string;
const file = path.split('/').pop()!;
const rawName = file.replace(/\.png$/i, '');
+1 -1
View File
@@ -15,6 +15,6 @@
"@nitrots/utils": "1.0.0"
},
"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 { ColorMatrixFilter, Container, RenderTexture, Sprite, Texture } from 'pixi.js';
import { ColorMatrixFilter, Container, Filter, RenderTexture, Sprite, Texture } from 'pixi.js';
import { AvatarFigureContainer } from './AvatarFigureContainer';
import { AvatarStructure } from './AvatarStructure';
import { EffectAssetDownloadManager } from './EffectAssetDownloadManager';
@@ -243,8 +243,7 @@ export class AvatarImage implements IAvatarImage, IAvatarEffectListener
if(this._avatarSpriteData.colorTransform)
{
if(container.filters === undefined || container.filters === null) container.filters = [ this._avatarSpriteData.colorTransform ];
else if(Array.isArray(container.filters)) container.filters = [ ...container.filters, this._avatarSpriteData.colorTransform ];
else container.filters = [ container.filters, this._avatarSpriteData.colorTransform ];
else container.filters = [ ...(container.filters as readonly Filter[]), this._avatarSpriteData.colorTransform ];
}
if(this._avatarSpriteData.paletteIsGrayscale)
@@ -257,8 +256,7 @@ export class AvatarImage implements IAvatarImage, IAvatarEffectListener
});
if(container.filters === undefined || container.filters === null) container.filters = [ paletteMapFilter ];
else if(Array.isArray(container.filters)) container.filters = [ ...container.filters, paletteMapFilter ];
else container.filters = [ container.filters, paletteMapFilter ];
else container.filters = [ ...(container.filters as readonly Filter[]), paletteMapFilter ];
}
}
@@ -766,8 +764,7 @@ export class AvatarImage implements IAvatarImage, IAvatarEffectListener
];
if(container.filters === undefined || container.filters === null) container.filters = [ filter ];
else if(Array.isArray(container.filters)) container.filters = [ ...container.filters, filter ];
else container.filters = [ container.filters, filter ];
else container.filters = [ ...(container.filters as readonly Filter[]), filter ];
return container;
}
+1 -1
View File
@@ -17,6 +17,6 @@
"pixi.js": "^8.8.1"
},
"devDependencies": {
"typescript": "~5.8.2"
"typescript": "^6.0.3"
}
}
+2 -2
View File
@@ -12,9 +12,9 @@
"@nitrots/api": "1.0.0",
"@nitrots/events": "1.0.0",
"@nitrots/utils": "1.0.0",
"@thumbmarkjs/thumbmarkjs": "^1.8.1"
"@thumbmarkjs/thumbmarkjs": "^1.9.0"
},
"devDependencies": {
"typescript": "~5.8.2"
"typescript": "^6.0.3"
}
}
@@ -203,6 +203,17 @@ export class CommunicationManager implements ICommunicationManager
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
{
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 { GetEventDispatcher, NitroEvent, NitroEventType, ReconnectEvent } from '@nitrots/events';
import { NitroLogger } from '@nitrots/utils';
@@ -509,7 +509,7 @@ export class SocketConnection implements IConnection
try
{
const parser = new events[0].parserClass();
const parser = new (events[0].parserClass as new () => IMessageParser)();
if(!parser || !parser.flush() || !parser.parse(wrapper)) return null;
@@ -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);
}
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);
}
export function randomNonce(): Uint8Array
export function randomNonce(): Uint8Array<ArrayBuffer>
{
const n = new Uint8Array(NONCE_LEN);
window.crypto.getRandomValues(n);
@@ -4,9 +4,9 @@ export class CatalogAdminSavePageComposer implements IMessageComposer<Constructo
{
private _data: ConstructorParameters<typeof CatalogAdminSavePageComposer>;
constructor(pageId: number, caption: string, caption2: string, layout: string, iconType: number, minRank: number, visible: boolean, enabled: boolean, orderNum: number, parentId: number, headline: string, teaser: string, textDetails: string, targetCatalogType: string, catalogMode: string = 'NORMAL')
constructor(pageId: number, caption: string, caption2: string, layout: string, iconType: number, minRank: number, visible: boolean, enabled: boolean, orderNum: number, parentId: number, headline: string, teaser: string, textDetails: string, targetCatalogType: string, catalogMode: string = 'NORMAL', pageText1: string = '')
{
this._data = [ pageId, caption, caption2, layout, iconType, minRank, visible, enabled, orderNum, parentId, headline, teaser, textDetails, targetCatalogType, catalogMode ];
this._data = [ pageId, caption, caption2, layout, iconType, minRank, visible, enabled, orderNum, parentId, headline, teaser, textDetails, targetCatalogType, catalogMode, pageText1 ];
}
dispose(): void
@@ -1,12 +1,23 @@
import { IMessageComposer } from '@nitrots/api';
export class RoomEnterComposer implements IMessageComposer<ConstructorParameters<typeof RoomEnterComposer>>
{
private _data: ConstructorParameters<typeof RoomEnterComposer>;
type RoomEnterPayload = [ number, string, number?, number? ];
constructor(roomId: number, password: string = null)
export class RoomEnterComposer implements IMessageComposer<RoomEnterPayload>
{
this._data = [roomId, password];
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 = (spawnX !== undefined && spawnY !== undefined)
? [ roomId, password, spawnX, spawnY ]
: [ roomId, password ];
}
public getMessageArray()
@@ -32,7 +32,8 @@ implements
chatBubbleWeight: number,
chatBubbleSpeed: number,
chatDistance: number,
chatFloodProtection: number
chatFloodProtection: number,
allowUnderpass?: boolean
)
{
//@ts-ignore
@@ -67,6 +68,8 @@ implements
chatDistance,
chatFloodProtection
);
if(allowUnderpass !== undefined) this._data.push(allowUnderpass);
}
public getMessageArray()
@@ -4,9 +4,9 @@ export class RoomUnitBackgroundComposer implements IMessageComposer<ConstructorP
{
private _data: ConstructorParameters<typeof RoomUnitBackgroundComposer>;
constructor(backgroundImage: number, backgroundStand: number, backgroundOverlay: number, backgroundCard: number = 0)
constructor(backgroundImage: number, backgroundStand: number, backgroundOverlay: number, backgroundCard: number = 0, backgroundBorder: number = 0)
{
this._data = [ backgroundImage, backgroundStand, backgroundOverlay, backgroundCard ];
this._data = [ backgroundImage, backgroundStand, backgroundOverlay, backgroundCard, backgroundBorder ];
}
public getMessageArray()
@@ -1,8 +1,8 @@
import { IMessageComposer } from '@nitrots/api';
export class WiredRoomSettingsRequestComposer implements IMessageComposer<ConstructorParameters<typeof WiredRoomSettingsRequestComposer>>
export class WiredRoomSettingsRequestComposer implements IMessageComposer<[]>
{
public getMessageArray()
public getMessageArray(): []
{
return [];
}
@@ -1,8 +1,8 @@
import { IMessageComposer } from '@nitrots/api';
export class WiredUserVariablesRequestComposer implements IMessageComposer<ConstructorParameters<typeof WiredUserVariablesRequestComposer>>
export class WiredUserVariablesRequestComposer implements IMessageComposer<[]>
{
public getMessageArray()
public getMessageArray(): []
{
return [];
}
@@ -5,6 +5,7 @@ export class NodeData
private _visible: boolean;
private _icon: number;
private _pageId: number;
private _parentId: number;
private _pageName: string;
private _localization: string;
private _children: NodeData[];
@@ -23,6 +24,7 @@ export class NodeData
this._visible = false;
this._icon = 0;
this._pageId = -1;
this._parentId = -1;
this._pageName = null;
this._localization = null;
this._children = [];
@@ -43,6 +45,7 @@ export class NodeData
this._visible = wrapper.readBoolean();
this._icon = wrapper.readInt();
this._pageId = wrapper.readInt();
this._parentId = wrapper.readInt();
this._pageName = wrapper.readString();
this._localization = wrapper.readString();
@@ -92,6 +95,11 @@ export class NodeData
return this._pageId;
}
public get parentId(): number
{
return this._parentId;
}
public get pageName(): string
{
return this._pageName;
@@ -19,10 +19,9 @@ export class PetBreedingMessageParser implements IMessageParser
return true;
}
public parse(wrapper: IMessageDataWrapper): boolean {
if (!wrapper || wrapper.bytesAvailable < 12) {
return false;
}
public parse(wrapper: IMessageDataWrapper): boolean
{
if(!wrapper || !wrapper.bytesAvailable) return false;
this._state = wrapper.readInt();
this._ownPetId = wrapper.readInt();
@@ -45,12 +45,19 @@ export class GetGuestRoomResultMessageParser implements IMessageParser
this.data.canMute = wrapper.readBoolean();
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)
// block 2: room item limit (1 int)
// Flat early-return chain so an older server stops cleanly at
// whichever block it doesn't ship. Defaults from flush().
if(!wrapper.bytesAvailable) return true;
this._hotelTimeZoneId = wrapper.readString();
this._hotelCurrentTimeMs = Number(wrapper.readString()) || 0;
if(wrapper.bytesAvailable) this._roomItemLimit = wrapper.readInt();
}
if(!wrapper.bytesAvailable) return true;
this._roomItemLimit = wrapper.readInt();
return true;
}
@@ -11,6 +11,7 @@ export class RoomUnitInfoParser implements IMessageParser
private _standId: number;
private _overlayId: number;
private _cardBackgroundId: number;
private _borderId: number;
private _nickIcon: string;
private _prefixText: string;
private _prefixColor: string;
@@ -30,6 +31,7 @@ export class RoomUnitInfoParser implements IMessageParser
this._standId = 0;
this._overlayId = 0;
this._cardBackgroundId = 0;
this._borderId = 0;
this._nickIcon = '';
this._prefixText = '';
this._prefixColor = '';
@@ -61,6 +63,7 @@ export class RoomUnitInfoParser implements IMessageParser
this._prefixEffect = (wrapper.bytesAvailable ? wrapper.readString() : '');
this._prefixFont = (wrapper.bytesAvailable ? wrapper.readString() : '');
this._displayOrder = (wrapper.bytesAvailable ? wrapper.readString() : 'icon-prefix-name');
this._borderId = (wrapper.bytesAvailable ? wrapper.readInt() : 0);
return true;
}
@@ -110,6 +113,11 @@ export class RoomUnitInfoParser implements IMessageParser
return this._cardBackgroundId;
}
public get borderId(): number
{
return this._borderId;
}
public get nickIcon(): string
{
return this._nickIcon;
@@ -146,6 +146,17 @@ export class RoomUnitParser implements IMessageParser
user.roomEntryMethod = wrapper.readString();
user.roomEntryTeleportId = wrapper.readInt();
// 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++;
}
@@ -45,6 +45,7 @@ export class UserMessageData
private _isModerator: boolean = false;
private _roomEntryMethod: string = 'unknown';
private _roomEntryTeleportId: number = 0;
private _borderId: number = 0;
private _isReadOnly: boolean = false;
constructor(k: number)
@@ -579,4 +580,17 @@ export class UserMessageData
this._roomEntryTeleportId = k;
}
}
public get borderId(): number
{
return this._borderId;
}
public set borderId(k: number)
{
if(!this._isReadOnly)
{
this._borderId = k;
}
}
}
@@ -37,6 +37,7 @@ export class RoomSettingsData
private _roomModerationSettings: RoomModerationSettings = null;
private _chatSettings: RoomChatSettings = null;
private _allowNavigatorDynamicCats: boolean = false;
private _allowUnderpass: boolean = false;
public static from(settings: RoomSettingsData)
{
@@ -65,6 +66,7 @@ export class RoomSettingsData
instance._roomModerationSettings = settings._roomModerationSettings;
instance._chatSettings = settings._chatSettings;
instance._allowNavigatorDynamicCats = settings._allowNavigatorDynamicCats;
instance._allowUnderpass = settings._allowUnderpass;
return instance;
}
@@ -329,4 +331,14 @@ export class RoomSettingsData
{
this._allowNavigatorDynamicCats = flag;
}
public get allowUnderpass(): boolean
{
return this._allowUnderpass;
}
public set allowUnderpass(flag: boolean)
{
this._allowUnderpass = flag;
}
}
@@ -49,6 +49,10 @@ export class RoomSettingsDataParser implements IMessageParser
this._roomSettingsData.allowNavigatorDynamicCats = wrapper.readBoolean();
this._roomSettingsData.roomModerationSettings = new RoomModerationSettings(wrapper);
// Custom Arcturus extension: trailing int (0/1) for the underpass toggle.
// Older servers may not emit it; default stays false when absent.
if(wrapper.bytesAvailable) this._roomSettingsData.allowUnderpass = (wrapper.readInt() === 1);
return true;
}
@@ -5,12 +5,24 @@ export class UserPermissionsParser implements IMessageParser
private _clubLevel: number;
private _securityLevel: number;
private _isAmbassador: boolean;
private _rankId: number;
private _rankName: string;
private _rankBadge: string;
private _rankPrefix: string;
private _rankPrefixColor: string;
private _permissions: Map<string, number> = new Map();
public flush(): boolean
{
this._clubLevel = 0;
this._securityLevel = 0;
this._isAmbassador = false;
this._rankId = 0;
this._rankName = '';
this._rankBadge = '';
this._rankPrefix = '';
this._rankPrefixColor = '';
this._permissions = new Map();
return true;
}
@@ -23,6 +35,37 @@ export class UserPermissionsParser implements IMessageParser
this._securityLevel = wrapper.readInt();
this._isAmbassador = wrapper.readBoolean();
// Optional trailing block (Arcturus-Morningstar-Extended ≥ 4.2.10):
// rank metadata + resolved permission map appended in a
// backward-compatible way. Older emulators don't write these
// bytes so we keep the defaults from flush().
if(!wrapper.bytesAvailable) return true;
this._rankId = wrapper.readInt();
this._rankName = wrapper.readString();
this._rankBadge = wrapper.readString();
this._rankPrefix = wrapper.readString();
this._rankPrefixColor = wrapper.readString();
if(!wrapper.bytesAvailable) return true;
// Resolved permission map: int count + (string key, int value)*.
// value 1 = ALLOWED, 2 = ROOM_OWNER. Only entries with
// PermissionSetting != DISALLOWED are sent; absence on the client
// means "no" (useHasPermission(key) returns false).
const count = wrapper.readInt();
const permissions = new Map<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;
}
@@ -40,4 +83,34 @@ export class UserPermissionsParser implements IMessageParser
{
return this._isAmbassador;
}
public get rankId(): number
{
return this._rankId;
}
public get rankName(): string
{
return this._rankName;
}
public get rankBadge(): string
{
return this._rankBadge;
}
public get rankPrefix(): string
{
return this._rankPrefix;
}
public get rankPrefixColor(): string
{
return this._rankPrefixColor;
}
public get permissions(): ReadonlyMap<string, number>
{
return this._permissions;
}
}
@@ -84,20 +84,29 @@ export class UserProfileParser implements IMessageParser
this._secondsSinceLastVisit = wrapper.readInt();
this._openProfileWindow = wrapper.readBoolean();
if(wrapper.bytesAvailable)
{
// Optional trailing blocks, one tier per emulator release:
// block 1: background / stand / overlay (3 ints)
// block 2: card background (1 int)
// block 3: nick icon (1 string)
// block 4: prefix decoration set (6 strings)
// Each tier early-returns to keep the parser tolerant of older
// servers that don't ship the later blocks. Defaults set by flush().
if(!wrapper.bytesAvailable) return true;
this._backgroundId = wrapper.readInt();
this._standId = wrapper.readInt();
this._overlayId = wrapper.readInt();
this._cardBackgroundId = (wrapper.bytesAvailable ? wrapper.readInt() : 0);
if(!wrapper.bytesAvailable) return true;
this._cardBackgroundId = wrapper.readInt();
if(!wrapper.bytesAvailable) return true;
if(wrapper.bytesAvailable)
{
this._nickIcon = wrapper.readString();
if(wrapper.bytesAvailable)
{
if(!wrapper.bytesAvailable) return true;
this._prefixText = wrapper.readString();
this._prefixColor = wrapper.readString();
this._prefixIcon = wrapper.readString();
@@ -105,14 +114,6 @@ export class UserProfileParser implements IMessageParser
this._prefixFont = wrapper.readString();
this._displayOrder = wrapper.readString();
if(wrapper.bytesAvailable)
{
this._totalBadges = wrapper.readInt();
}
}
}
}
return true;
}
+1 -1
View File
@@ -13,6 +13,6 @@
"@nitrots/utils": "1.0.0"
},
"devDependencies": {
"typescript": "~5.8.2"
"typescript": "^6.0.3"
}
}
+1 -1
View File
@@ -13,6 +13,6 @@
"@nitrots/utils": "1.0.0"
},
"devDependencies": {
"typescript": "~5.8.2"
"typescript": "^6.0.3"
}
}
+19
View File
@@ -101,4 +101,23 @@ export class EventDispatcher implements IEventDispatcher
{
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_LOADED = 'AVATAR_EFFECT_LOADED';
public static readonly FURNITURE_DATA_LOADED = 'FURNITURE_DATA_LOADED';
public static readonly SESSION_DATA_UPDATED = 'SESSION_DATA_UPDATED';
public static readonly ROOM_SESSION_UPDATED = 'ROOM_SESSION_UPDATED';
public static readonly IGNORED_USERS_UPDATED = 'IGNORED_USERS_UPDATED';
public static readonly GROUP_BADGES_UPDATED = 'GROUP_BADGES_UPDATED';
public static readonly ROOM_USER_LIST_UPDATED = 'ROOM_USER_LIST_UPDATED';
public static readonly SOUND_VOLUMES_UPDATED = 'SOUND_VOLUMES_UPDATED';
public static readonly USER_PERMISSIONS_UPDATED = 'USER_PERMISSIONS_UPDATED';
}
@@ -20,6 +20,7 @@ export class RoomSessionUserFigureUpdateEvent extends RoomSessionEvent {
private _prefixEffect: string;
private _prefixFont: string;
private _displayOrder: string;
private _borderId: number | null;
constructor(
session: IRoomSession,
@@ -38,7 +39,8 @@ export class RoomSessionUserFigureUpdateEvent extends RoomSessionEvent {
prefixIcon: string = '',
prefixEffect: string = '',
prefixFont: string = '',
displayOrder: string = 'icon-prefix-name'
displayOrder: string = 'icon-prefix-name',
borderId: number | null = 0
) {
super(RoomSessionUserFigureUpdateEvent.USER_FIGURE, session);
@@ -58,6 +60,7 @@ export class RoomSessionUserFigureUpdateEvent extends RoomSessionEvent {
this._prefixEffect = prefixEffect;
this._prefixFont = prefixFont;
this._displayOrder = displayOrder;
this._borderId = borderId;
}
public get roomIndex(): number {
@@ -123,4 +126,8 @@ export class RoomSessionUserFigureUpdateEvent extends RoomSessionEvent {
public get displayOrder(): string {
return this._displayOrder;
}
public get borderId(): number | null {
return this._borderId;
}
}
+1 -1
View File
@@ -17,6 +17,6 @@
"pixi.js": "^8.8.1"
},
"devDependencies": {
"typescript": "~5.8.2"
"typescript": "^6.0.3"
}
}
+1 -1
View File
@@ -19,6 +19,6 @@
"pixi.js": "^8.8.1"
},
"devDependencies": {
"typescript": "~5.8.2"
"typescript": "^6.0.3"
}
}
@@ -170,14 +170,18 @@ export class FurnitureBadgeDisplayVisualization extends FurnitureAnimatedVisuali
return assetName;
}
protected updateSprite(sprite: IRoomObjectSprite, asset: IGraphicAsset, scale: number, layerId: number): void
protected updateSprite(scale: number, layerId: number): void
{
super.updateSprite(sprite, asset, scale, layerId);
super.updateSprite(scale, layerId);
const tag = this.getLayerTag(scale, this.direction, layerId);
if(tag === FurnitureBadgeDisplayVisualization.BADGE_TAG)
{
const sprite = this.getSprite(layerId);
if(!sprite) return;
sprite.visible = true;
sprite.alpha = 255;
sprite.color = 0xFFFFFF;
@@ -1,6 +1,6 @@
import { AlphaTolerance } from '@nitrots/api';
import { GetRenderer, TextureUtils } from '@nitrots/utils';
import { Point, RendererType, Sprite, Texture, TextureSource, WebGPURenderer } from 'pixi.js';
import { GlRenderTarget, Point, RendererType, Sprite, Texture, TextureSource, WebGLRenderer, WebGPURenderer } from 'pixi.js';
const BYTES_PER_PIXEL = 4;
@@ -97,10 +97,11 @@ export class ExtendedSprite extends Sprite
{
pixels = new Uint8ClampedArray(BYTES_PER_PIXEL * width * height);
const renderTarget = renderer.renderTarget.getRenderTarget(textureSource);
const glRenderTarget = renderer.renderTarget.getGpuRenderTarget(renderTarget);
const webglRenderer = renderer as WebGLRenderer;
const renderTarget = webglRenderer.renderTarget.getRenderTarget(textureSource);
const glRenderTarget = webglRenderer.renderTarget.getGpuRenderTarget(renderTarget) as GlRenderTarget;
const gl = renderer.gl;
const gl = webglRenderer.gl;
gl.bindFramebuffer(gl.FRAMEBUFFER, glRenderTarget.resolveTargetFramebuffer);
+1 -1
View File
@@ -19,6 +19,6 @@
"pixi.js": "^8.8.1"
},
"devDependencies": {
"typescript": "~5.8.2"
"typescript": "^6.0.3"
}
}
@@ -1,9 +1,11 @@
import { IGroupInformationManager } from '@nitrots/api';
import { GetCommunication, GetHabboGroupBadgesMessageComposer, HabboGroupBadgesMessageEvent, RoomReadyMessageEvent } from '@nitrots/communication';
import { GetEventDispatcher, NitroEvent, NitroEventType } from '@nitrots/events';
export class GroupInformationManager implements IGroupInformationManager
{
private _groupBadges: Map<number, string> = new Map();
private _groupBadgesSnapshot: ReadonlyMap<number, string> | null = null;
public init(): void
{
@@ -20,11 +22,37 @@ export class GroupInformationManager implements IGroupInformationManager
{
const parser = event.getParser();
for(const [groupId, badgeId] of parser.badges.entries()) this._groupBadges.set(groupId, badgeId);
let didChange = false;
for(const [ groupId, badgeId ] of parser.badges.entries())
{
if(this._groupBadges.get(groupId) === badgeId) continue;
this._groupBadges.set(groupId, badgeId);
didChange = true;
}
if(didChange) this.invalidateGroupBadgesSnapshot();
}
private invalidateGroupBadgesSnapshot(): void
{
this._groupBadgesSnapshot = null;
GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.GROUP_BADGES_UPDATED));
}
public getGroupBadge(groupId: number): string
{
return this._groupBadges.get(groupId) ?? '';
}
public getGroupBadgesSnapshot(): ReadonlyMap<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 { GetCommunication, GetIgnoredUsersComposer, IgnoreResultEvent, IgnoreUserComposer, IgnoreUserIdComposer, IgnoredUsersEvent, UnignoreUserComposer } from '@nitrots/communication';
import { GetEventDispatcher, NitroEvent, NitroEventType } from '@nitrots/events';
export class IgnoredUsersManager implements IIgnoredUsersManager
{
private _ignoredUsers: string[] = [];
private _ignoredUsersSnapshot: ReadonlyArray<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
{
@@ -25,6 +43,7 @@ export class IgnoredUsersManager implements IIgnoredUsersManager
if(!parser) return;
this._ignoredUsers = parser.ignoredUsers;
this.invalidateIgnoredUsersSnapshot();
}
private onIgnoreResultEvent(event: IgnoreResultEvent): void
@@ -47,6 +66,7 @@ export class IgnoredUsersManager implements IIgnoredUsersManager
case 2:
this.addUserToIgnoreList(name);
this._ignoredUsers.shift();
this.invalidateIgnoredUsersSnapshot();
return;
case 3:
this.removeUserFromIgnoreList(name);
@@ -56,14 +76,20 @@ export class IgnoredUsersManager implements IIgnoredUsersManager
private addUserToIgnoreList(name: string): void
{
if(this._ignoredUsers.indexOf(name) < 0) this._ignoredUsers.push(name);
if(this._ignoredUsers.indexOf(name) >= 0) return;
this._ignoredUsers.push(name);
this.invalidateIgnoredUsersSnapshot();
}
private removeUserFromIgnoreList(name: string): void
{
const index = this._ignoredUsers.indexOf(name);
if(index >= 0) this._ignoredUsers.splice(index, 1);
if(index < 0) return;
this._ignoredUsers.splice(index, 1);
this.invalidateIgnoredUsersSnapshot();
}
public ignoreUserId(id: number): void
+2 -7
View File
@@ -97,9 +97,9 @@ export class RoomSession implements IRoomSession
else GetCommunication().connection.send(new RoomUnitTypingStopComposer());
}
public sendBackgroundMessage(backgroundImage: number, backgroundStand: number, backgroundOverlay: number, backgroundCard: number = 0): void
public sendBackgroundMessage(backgroundImage: number, backgroundStand: number, backgroundOverlay: number, backgroundCard: number = 0, backgroundBorder: number = 0): void
{
GetCommunication().connection.send(new RoomUnitBackgroundComposer(backgroundImage, backgroundStand, backgroundOverlay, backgroundCard));
GetCommunication().connection.send(new RoomUnitBackgroundComposer(backgroundImage, backgroundStand, backgroundOverlay, backgroundCard, backgroundBorder));
}
public sendMottoMessage(motto: string): void
@@ -139,11 +139,6 @@ export class RoomSession implements IRoomSession
GetCommunication().connection.send(new RoomAmbassadorAlertComposer(userId));
}
public sendWhisperGroupMessage(userId: number): void
{
GetCommunication().connection.send(new ChatWhisperGroupComposer(userId));
}
public sendKickMessage(userId: number): void
{
GetCommunication().connection.send(new RoomKickUserComposer(userId));
+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 { GetEventDispatcher, NitroEventType, RoomSessionEvent } from '@nitrots/events';
import { GetEventDispatcher, NitroEvent, NitroEventType, RoomSessionEvent } from '@nitrots/events';
import { NitroLogger } from '@nitrots/utils';
import { RoomSession } from './RoomSession';
import { BaseHandler, GenericErrorHandler, PetPackageHandler, PollHandler, RoomChatHandler, RoomDataHandler, RoomDimmerPresetsHandler, RoomPermissionsHandler, RoomPresentHandler, RoomSessionHandler, RoomUsersHandler, WordQuizHandler } from './handler';
@@ -26,6 +26,41 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList
private _pendingRoomClear: ReturnType<typeof setTimeout> = null;
private _savedPosX: 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>
{
@@ -196,6 +231,7 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList
this._sessions.clear();
this._viewerSession = null;
this.invalidateRoomSessionSnapshot();
this.createSession(roomId, password, this._savedPosX, this._savedPosY);
this.clearGuardTimer();
this._reconnectGuardTimer = setTimeout(() =>
@@ -384,6 +420,8 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList
this._lastRoomPassword = roomSession.password;
this.persistRoom(roomSession.roomId, roomSession.password);
this.invalidateRoomSessionSnapshot();
this.startSession(this._viewerSession);
return true;
@@ -406,6 +444,8 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList
this.setHandlers(session);
this.invalidateRoomSessionSnapshot();
return true;
}
@@ -429,6 +469,10 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList
}
GetEventDispatcher().dispatchEvent(new RoomSessionEvent(RoomSessionEvent.ENDED, session, openLandingView));
if(this._viewerSession === session) this._viewerSession = null;
this.invalidateRoomSessionSnapshot();
}
public sessionUpdate(id: number, type: string): void
+11
View File
@@ -20,6 +20,7 @@ export class RoomUserData implements IRoomUserData
private _stand: number;
private _overlay: number;
private _cardBackground: number;
private _borderId: number = 0;
private _webID: number = 0;
private _groupID: number = 0;
private _groupStatus: number = 0;
@@ -99,6 +100,16 @@ export class RoomUserData implements IRoomUserData
this._cardBackground = k;
}
public get borderId(): number
{
return this._borderId;
}
public set borderId(k: number)
{
this._borderId = k;
}
public get name(): string
{
return this._name;
+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 { GetConfiguration } from '@nitrots/configuration';
import { GetLocalizationManager } from '@nitrots/localization';
import { GetEventDispatcher, MysteryBoxKeysUpdateEvent, NitroSettingsEvent, SessionDataPreferencesEvent, UserNameUpdateEvent } from '@nitrots/events';
import { GetEventDispatcher, MysteryBoxKeysUpdateEvent, NitroEvent, NitroEventType, NitroSettingsEvent, SessionDataPreferencesEvent, UserNameUpdateEvent } from '@nitrots/events';
import { CreateLinkEvent, HabboWebTools, parseConfigJsonFromResponse } from '@nitrots/utils';
import { Texture } from 'pixi.js';
import { GroupInformationManager } from './GroupInformationManager';
@@ -32,6 +32,11 @@ export class SessionDataManager implements ISessionDataManager
private _clubLevel: number = 0;
private _securityLevel: number = 0;
private _isAmbassador: boolean = false;
private _rankId: number = 0;
private _rankName: string = '';
private _rankBadge: string = '';
private _rankPrefix: string = '';
private _rankPrefixColor: string = '';
private _noobnessLevel: number = -1;
private _isEmailVerified: boolean = false;
@@ -52,11 +57,87 @@ export class SessionDataManager implements ISessionDataManager
private _badgeImageManager: BadgeImageManager = new BadgeImageManager();
private _userDataSnapshot: Readonly<IUserDataSnapshot> | null = null;
private _permissions: Map<string, number> = new Map();
private _permissionsSnapshot: ReadonlyMap<string, number> | null = null;
constructor()
{
this.resetUserInfo();
}
private invalidateUserDataSnapshot(): void
{
this._userDataSnapshot = null;
GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.SESSION_DATA_UPDATED));
}
private invalidatePermissionsSnapshot(): void
{
this._permissionsSnapshot = null;
GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.USER_PERMISSIONS_UPDATED));
}
/**
* Resolved permission map for the current user — mirror of
* `permission_definitions` for the user's rank, filtered to keys
* with `PermissionSetting != DISALLOWED`. Wire-fed by
* `UserPermissionsMapEvent` (Arcturus ≥ 4.2.10). Older emulators
* that don't ship the new packet leave the snapshot empty; React
* consumers via `useHasPermission(key)` then degrade gracefully
* (every gate returns false → mod UI hidden, which is the safe
* default).
*
* Referentially stable until the next
* `UserPermissionsMapEvent` arrives (e.g. after
* `HabboManager.setRank`).
*/
public getPermissionsSnapshot(): ReadonlyMap<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>
{
await Promise.all([
@@ -75,6 +156,8 @@ export class SessionDataManager implements ISessionDataManager
this._gender = event.getParser().gender;
HabboWebTools.updateFigure(this._figure);
this.invalidateUserDataSnapshot();
})),
GetCommunication().registerMessageEvent(new UserInfoEvent(this.onUserInfoEvent.bind(this))),
GetCommunication().registerMessageEvent(new UserPermissionsEvent(this.onUserPermissionsEvent.bind(this))),
@@ -98,6 +181,8 @@ export class SessionDataManager implements ISessionDataManager
this._uiFlags = event.flags;
GetEventDispatcher().dispatchEvent(new SessionDataPreferencesEvent(this._uiFlags));
this.invalidateUserDataSnapshot();
};
GetEventDispatcher().addEventListener<NitroSettingsEvent>(NitroSettingsEvent.SETTINGS_UPDATED, this._settingsEventCallback);
@@ -189,15 +274,38 @@ export class SessionDataManager implements ISessionDataManager
this._safetyLocked = userInfo.safetyLocked;
this._ignoredUsersManager.requestIgnoredUsers(userInfo.username);
this.invalidateUserDataSnapshot();
}
private onUserPermissionsEvent(event: UserPermissionsEvent): void
{
if(!event || !event.connection) return;
this._clubLevel = event.getParser().clubLevel;
this._securityLevel = event.getParser().securityLevel;
this._isAmbassador = event.getParser().isAmbassador;
const parser = event.getParser();
this._clubLevel = parser.clubLevel;
this._securityLevel = parser.securityLevel;
this._isAmbassador = parser.isAmbassador;
this._rankId = parser.rankId;
this._rankName = parser.rankName;
this._rankBadge = parser.rankBadge;
this._rankPrefix = parser.rankPrefix;
this._rankPrefixColor = parser.rankPrefixColor;
// Copy into our local mutable Map so the parser's reference
// (which is overwritten on every parse() call) can't leak back
// to consumers.
this._permissions = new Map(parser.permissions);
// Invalidate BOTH snapshots: a UserPermissionsComposer push from
// the emulator refreshes user-data fields (clubLevel/securityLevel
// /rank metadata) AND the resolved permission map. Keep the two
// invalidation events distinct so React consumers can subscribe
// to just one (e.g. a widget that only cares about
// useHasPermission re-renders only when the map actually
// changes, not on every snapshot bump).
this.invalidateUserDataSnapshot();
this.invalidatePermissionsSnapshot();
}
private onAvailabilityStatusMessageEvent(event: AvailabilityStatusMessageEvent): void
@@ -211,6 +319,8 @@ export class SessionDataManager implements ISessionDataManager
this._systemOpen = parser.isOpen;
this._systemShutdown = parser.onShutdown;
this._isAuthenticHabbo = parser.isAuthenticUser;
this.invalidateUserDataSnapshot();
}
private onPetRespectFailed(event: PetScratchFailedMessageEvent): void
@@ -218,6 +328,8 @@ export class SessionDataManager implements ISessionDataManager
if(!event || !event.connection) return;
this._respectsPetLeft++;
this.invalidateUserDataSnapshot();
}
private onChangeNameUpdateEvent(event: ChangeUserNameResultMessageEvent): void
@@ -233,6 +345,8 @@ export class SessionDataManager implements ISessionDataManager
this._canChangeName = false;
GetEventDispatcher().dispatchEvent(new UserNameUpdateEvent(parser.name));
this.invalidateUserDataSnapshot();
}
private onUserNameChangeMessageEvent(event: UserNameChangeMessageEvent): void
@@ -249,6 +363,8 @@ export class SessionDataManager implements ISessionDataManager
this._canChangeName = false;
GetEventDispatcher().dispatchEvent(new UserNameUpdateEvent(this._name));
this.invalidateUserDataSnapshot();
}
private onUserTags(event: UserTagsMessageEvent): void
@@ -260,6 +376,8 @@ export class SessionDataManager implements ISessionDataManager
if(!parser) return;
this._tags = parser.tags;
this.invalidateUserDataSnapshot();
}
private onRoomModelNameEvent(event: RoomReadyMessageEvent): void
@@ -300,6 +418,8 @@ export class SessionDataManager implements ISessionDataManager
this._noobnessLevel = event.getParser().noobnessLevel;
if(this._noobnessLevel !== NoobnessLevelEnum.OLD_IDENTITY) GetConfiguration().setValue<number>('new.identity', 1);
this.invalidateUserDataSnapshot();
}
private onAccountSafetyLockStatusChangeMessageEvent(event: AccountSafetyLockStatusChangeMessageEvent): void
@@ -316,6 +436,8 @@ export class SessionDataManager implements ISessionDataManager
private onEmailStatus(event: EmailStatusResultEvent): void
{
this._isEmailVerified = event?.getParser()?.isVerified ?? false;
this.invalidateUserDataSnapshot();
}
public getFloorItemData(id: number): IFurnitureData
@@ -476,6 +598,8 @@ export class SessionDataManager implements ISessionDataManager
this.send(new UserRespectComposer(userId));
this._respectsLeft--;
this.invalidateUserDataSnapshot();
}
public givePetRespect(petId: number): void
@@ -485,6 +609,8 @@ export class SessionDataManager implements ISessionDataManager
this.send(new PetRespectComposer(petId));
this._respectsPetLeft--;
this.invalidateUserDataSnapshot();
}
public sendSpecialCommandMessage(text: string, styleId: number = 0): void
+45 -2
View File
@@ -1,5 +1,6 @@
import { IRoomUserData, IUserDataManager } from '@nitrots/api';
import { GetCommunication, RequestPetInfoComposer, UserCurrentBadgesComposer } from '@nitrots/communication';
import { GetEventDispatcher, NitroEvent, NitroEventType } from '@nitrots/events';
export class UserDataManager implements IUserDataManager
{
@@ -11,6 +12,23 @@ export class UserDataManager implements IUserDataManager
private _userDataByType: Map<number, Map<number, IRoomUserData>> = new Map();
private _userDataByRoomIndex: Map<number, IRoomUserData> = 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
{
@@ -84,6 +102,8 @@ export class UserDataManager implements IUserDataManager
existingType.set(data.webID, data);
this._userDataByRoomIndex.set(data.roomIndex, data);
this.invalidateRoomUserListSnapshot();
}
public removeUserData(roomIndex: number): void
@@ -97,6 +117,8 @@ export class UserDataManager implements IUserDataManager
const existingType = this._userDataByType.get(existing.type);
if(existingType) existingType.delete(existing.webID);
this.invalidateRoomUserListSnapshot();
}
public getUserBadges(userId: number): string[]
@@ -125,6 +147,8 @@ export class UserDataManager implements IUserDataManager
userData.sex = sex;
userData.hasSaddle = hasSaddle;
userData.isRiding = isRiding;
this.invalidateRoomUserListSnapshot();
}
public updateName(roomIndex: number, name: string): void
@@ -134,6 +158,8 @@ export class UserDataManager implements IUserDataManager
if(!userData) return;
userData.name = name;
this.invalidateRoomUserListSnapshot();
}
public updateMotto(roomIndex: number, custom: string): void
@@ -143,6 +169,8 @@ export class UserDataManager implements IUserDataManager
if(!userData) return;
userData.custom = custom;
this.invalidateRoomUserListSnapshot();
}
public updateNickIcon(roomIndex: number, nickIcon: string): void
@@ -152,6 +180,8 @@ export class UserDataManager implements IUserDataManager
if(!userData) return;
userData.nickIcon = nickIcon;
this.invalidateRoomUserListSnapshot();
}
public updateCustomization(roomIndex: number, nickIcon: string, prefixText: string, prefixColor: string, prefixIcon: string, prefixEffect: string, prefixFont: string, displayOrder: string): void
@@ -167,9 +197,11 @@ export class UserDataManager implements IUserDataManager
userData.prefixEffect = prefixEffect;
userData.prefixFont = prefixFont;
userData.displayOrder = displayOrder;
this.invalidateRoomUserListSnapshot();
}
public updateBackground(roomIndex: number, background: number, stand: number, overlay: number, cardBackground: number = 0): void
public updateBackground(roomIndex: number, background: number, stand: number, overlay: number, cardBackground: number = 0, borderId: number = 0): void
{
const userData = this.getUserDataByIndex(roomIndex);
@@ -179,6 +211,9 @@ export class UserDataManager implements IUserDataManager
userData.stand = stand;
userData.overlay = overlay;
userData.cardBackground = cardBackground;
userData.borderId = borderId;
this.invalidateRoomUserListSnapshot();
}
public updateAchievementScore(roomIndex: number, score: number): void
@@ -188,13 +223,19 @@ export class UserDataManager implements IUserDataManager
if(!userData) return;
userData.activityPoints = score;
this.invalidateRoomUserListSnapshot();
}
public updatePetLevel(roomIndex: number, level: number): void
{
const userData = this.getUserDataByIndex(roomIndex);
if(userData) userData.petLevel = level;
if(!userData) return;
userData.petLevel = level;
this.invalidateRoomUserListSnapshot();
}
public updatePetBreedingStatus(roomIndex: number, canBreed: boolean, canHarvest: boolean, canRevive: boolean, hasBreedingPermission: boolean): void
@@ -207,6 +248,8 @@ export class UserDataManager implements IUserDataManager
userData.canHarvest = canHarvest;
userData.canRevive = canRevive;
userData.hasBreedingPermission = hasBreedingPermission;
this.invalidateRoomUserListSnapshot();
}
public requestPetInfo(id: number): void
@@ -168,6 +168,6 @@ export class RoomChatHandler extends BaseHandler
if(!parser) return;
GetEventDispatcher().dispatchEvent(new RoomSessionChatEvent(RoomSessionChatEvent.CHAT_EVENT, session, session.ownRoomIndex, '', RoomSessionChatEvent.CHAT_TYPE_MUTE_REMAINING, SystemChatStyleEnum.GENERIC, [], null, parser.seconds));
GetEventDispatcher().dispatchEvent(new RoomSessionChatEvent(RoomSessionChatEvent.CHAT_EVENT, session, session.ownRoomIndex, '', RoomSessionChatEvent.CHAT_TYPE_MUTE_REMAINING, SystemChatStyleEnum.GENERIC, '', [], parser.seconds));
}
}
@@ -66,6 +66,7 @@ export class RoomUsersHandler extends BaseHandler
userData.stand = user.stand;
userData.overlay = user.overlay;
userData.cardBackground = user.cardBackground;
userData.borderId = user.borderId;
userData.activityPoints = user.activityPoints;
userData.figure = user.figure;
userData.type = user.userType;
@@ -115,9 +116,9 @@ export class RoomUsersHandler extends BaseHandler
session.userDataManager.updateCustomization(parser.unitId, parser.nickIcon || '', parser.prefixText || '', parser.prefixColor || '', parser.prefixIcon || '', parser.prefixEffect || '', parser.prefixFont || '', parser.displayOrder || 'icon-prefix-name');
session.userDataManager.updateAchievementScore(parser.unitId, parser.achievementScore);
session.userDataManager.updateBackground(parser.unitId, parser.backgroundId, parser.standId, parser.overlayId, parser.cardBackgroundId);
session.userDataManager.updateBackground(parser.unitId, parser.backgroundId, parser.standId, parser.overlayId, parser.cardBackgroundId, parser.borderId);
GetEventDispatcher().dispatchEvent(new RoomSessionUserFigureUpdateEvent(session, parser.unitId, parser.figure, parser.gender, parser.motto, parser.achievementScore, parser.backgroundId, parser.standId, parser.overlayId, parser.cardBackgroundId, parser.nickIcon || '', parser.prefixText || '', parser.prefixColor || '', parser.prefixIcon || '', parser.prefixEffect || '', parser.prefixFont || '', parser.displayOrder || 'icon-prefix-name'));
GetEventDispatcher().dispatchEvent(new RoomSessionUserFigureUpdateEvent(session, parser.unitId, parser.figure, parser.gender, parser.motto, parser.achievementScore, parser.backgroundId, parser.standId, parser.overlayId, parser.cardBackgroundId, parser.nickIcon || '', parser.prefixText || '', parser.prefixColor || '', parser.prefixIcon || '', parser.prefixEffect || '', parser.prefixFont || '', parser.displayOrder || 'icon-prefix-name', parser.borderId));
}
+1 -1
View File
@@ -14,6 +14,6 @@
"pixi.js": "^8.8.1"
},
"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 { GetEventDispatcher, NitroSettingsEvent, NitroSoundEvent, RoomEngineEvent, RoomEngineObjectEvent, RoomEngineSamplePlaybackEvent } from '@nitrots/events';
import { GetEventDispatcher, NitroEvent, NitroEventType, NitroSettingsEvent, NitroSoundEvent, RoomEngineEvent, RoomEngineObjectEvent, RoomEngineSamplePlaybackEvent } from '@nitrots/events';
import { AdvancedMap, NitroLogger } from '@nitrots/utils';
import { MusicController } from './music/MusicController';
@@ -9,6 +9,7 @@ export class SoundManager implements ISoundManager
private _volumeSystem: number = 0.5;
private _volumeFurni: number = 0.5;
private _volumeTrax: number = 0.5;
private _volumesSnapshot: Readonly<ISoundVolumesSnapshot> | null = null;
private _internalSamples: IAdvancedMap<string, HTMLAudioElement> = new AdvancedMap();
private _furniSamples: IAdvancedMap<number, HTMLAudioElement> = new AdvancedMap();
@@ -81,17 +82,24 @@ export class SoundManager implements ISoundManager
case NitroSettingsEvent.SETTINGS_UPDATED: {
const castedEvent = (event as NitroSettingsEvent);
const volumeFurniUpdated = castedEvent.volumeFurni !== this._volumeFurni;
const volumeTraxUpdated = castedEvent.volumeTrax !== this._volumeTrax;
const nextSystem = (castedEvent.volumeSystem / 100);
const nextFurni = (castedEvent.volumeFurni / 100);
const nextTrax = (castedEvent.volumeTrax / 100);
this._volumeSystem = (castedEvent.volumeSystem / 100);
this._volumeFurni = (castedEvent.volumeFurni / 100);
this._volumeTrax = (castedEvent.volumeTrax / 100);
const volumeSystemUpdated = nextSystem !== this._volumeSystem;
const volumeFurniUpdated = nextFurni !== this._volumeFurni;
const volumeTraxUpdated = nextTrax !== this._volumeTrax;
this._volumeSystem = nextSystem;
this._volumeFurni = nextFurni;
this._volumeTrax = nextTrax;
if(volumeFurniUpdated) this.updateFurniSamplesVolume(this._volumeFurni);
if(volumeTraxUpdated) this._musicController?.updateVolume(this._volumeTrax);
if(volumeSystemUpdated || volumeFurniUpdated || volumeTraxUpdated) this.invalidateVolumesSnapshot();
return;
}
case NitroSoundEvent.PLAY_SOUND: {
@@ -215,8 +223,38 @@ export class SoundManager implements ISoundManager
return this._volumeTrax;
}
public get systemVolume(): number
{
return this._volumeSystem;
}
public get furniVolume(): number
{
return this._volumeFurni;
}
public get musicController(): IMusicController
{
return this._musicController;
}
private invalidateVolumesSnapshot(): void
{
this._volumesSnapshot = null;
GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.SOUND_VOLUMES_UPDATED));
}
public getVolumesSnapshot(): Readonly<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": {
"@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 = '';
const bytes = new Uint8Array(buffer);
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
const len = bytes.byteLength;
for(let i = 0; i < len; i++) (binary += String.fromCharCode(bytes[i]));
+2 -2
View File
@@ -13,7 +13,7 @@ export class BinaryReader implements IBinaryReader
public readBytes(length: number): IBinaryReader
{
const buffer = new BinaryReader(this._dataView.buffer.slice(this._position, this._position + length));
const buffer = new BinaryReader(this._dataView.buffer.slice(this._position, this._position + length) as ArrayBuffer);
this._position += length;
@@ -77,6 +77,6 @@ export class BinaryReader implements IBinaryReader
public toArrayBuffer(): ArrayBuffer
{
return this._dataView.buffer;
return this._dataView.buffer as ArrayBuffer;
}
}
+1 -1
View File
@@ -89,7 +89,7 @@ export class BinaryWriter implements IBinaryWriter
public getBuffer(): ArrayBuffer
{
return this._buffer.buffer;
return this._buffer.buffer as ArrayBuffer;
}
public get position(): number
+68 -39
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 type GamedataTier = typeof DEFAULT_TIERS[number] | string;
@@ -28,51 +29,69 @@ const joinUrl = (base: string, path: string): string =>
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
{
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 arrayItemsLookKeyed = (arr: any[], idKeys: readonly string[]): string | null =>
const arrayItemsLookKeyed = (arr: any[], idKeys: readonly string[], sourceLabel?: string): string | null =>
{
if(!arr.length) return null;
for(const key of idKeys)
{
let allHave = true;
let have = 0;
for(const item of arr)
{
if(!isPlainObject(item) || item[key] === undefined || item[key] === null)
{
allHave = false;
break;
}
if(isPlainObject(item) && item[key] !== undefined && item[key] !== null) have++;
}
if(allHave) return key;
if(have === arr.length) return key;
// Heuristic: if most items are keyed but a few are not, the data is
// probably keyed and the outliers are bugs in the source data.
// Surface this so operators don't get silent duplicates after merge.
if(have > 0 && have / arr.length >= 0.8)
{
NitroLogger.warn(`mergeGamedata: ${ sourceLabel ? `${ sourceLabel }: ` : '' }array looks keyed by "${ key }" (${ have }/${ arr.length } items) but some entries are missing it — falling back to concat which may produce duplicates`);
}
}
return null;
};
export const mergeGamedata = (a: any, b: any, idKeys: readonly string[] = DEFAULT_ID_KEYS): any =>
export const mergeGamedata = (a: any, b: any, idKeys: readonly string[] = DEFAULT_ID_KEYS, sourceLabel?: string): any =>
{
if(b === undefined) return a;
if(a === undefined) return b;
if(Array.isArray(a) && Array.isArray(b))
{
const idKey = arrayItemsLookKeyed(a, idKeys) || arrayItemsLookKeyed(b, idKeys);
const idKey = arrayItemsLookKeyed(a, idKeys, sourceLabel) || arrayItemsLookKeyed(b, idKeys, sourceLabel);
if(!idKey) return a.concat(b);
@@ -92,7 +111,7 @@ export const mergeGamedata = (a: any, b: any, idKeys: readonly string[] = DEFAUL
if(at !== undefined)
{
out[at] = mergeGamedata(out[at], item, idKeys);
out[at] = mergeGamedata(out[at], item, idKeys, sourceLabel);
}
else
{
@@ -110,7 +129,7 @@ export const mergeGamedata = (a: any, b: any, idKeys: readonly string[] = DEFAUL
for(const k of Object.keys(b))
{
out[k] = mergeGamedata(a[k], b[k], idKeys);
out[k] = mergeGamedata(a[k], b[k], idKeys, sourceLabel);
}
return out;
@@ -130,6 +149,11 @@ interface RootManifest
files?: string[];
}
// Load every file in `files` concurrently, return them in the original
// declared order so the merge step preserves override semantics.
const fetchFilesInOrder = async (baseUrl: string, files: readonly string[]): Promise<any[]> =>
Promise.all(files.map(file => fetchConfigJson(joinUrl(baseUrl, file))));
export const loadGamedata = async <T = any>(url: string, options: GamedataLoadOptions = {}): Promise<T> =>
{
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 rootManifest = await tryFetchOrNull<RootManifest>(joinUrl(url, 'manifest.json5'))
?? await tryFetchOrNull<RootManifest>(joinUrl(url, 'manifest.json'));
const rootManifest = await tryFetchManifestPair<RootManifest>(url, 'manifest');
const tiers = (rootManifest?.tiers && rootManifest.tiers.length)
? rootManifest.tiers
: (options.tiers ?? DEFAULT_TIERS);
let merged: any = undefined;
if(rootManifest?.files?.length)
{
for(const file of rootManifest.files)
{
const fileUrl = joinUrl(url, file);
const part = await fetchConfigJson(fileUrl);
merged = (merged === undefined) ? part : mergeGamedata(merged, part, idKeys);
}
}
for(const tier of 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 tierManifest = await tryFetchOrNull<TierManifest>(joinUrl(tierUrl, 'manifest.json5'))
?? await tryFetchOrNull<TierManifest>(joinUrl(tierUrl, 'manifest.json'));
const manifest = await tryFetchManifestPair<TierManifest>(tierUrl, 'manifest');
if(!tierManifest?.files?.length) continue;
return { tier, tierUrl, manifest };
}))
]);
for(const file of tierManifest.files)
let merged: any = undefined;
for(const part of rootParts)
{
const fileUrl = joinUrl(tierUrl, file);
const part = await fetchConfigJson(fileUrl);
merged = (merged === undefined) ? part : mergeGamedata(merged, part, idKeys);
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)
{
merged = (merged === undefined) ? part : mergeGamedata(merged, part, idKeys, `${ url } (${ tier })`);
}
}
if(merged === undefined) throw new Error(`loadGamedata: directory mode at "${ url }" produced no data — make sure at least one tier (core/custom/seasonal) has a manifest.json5 with a 'files' array`);
if(merged === undefined) throw new ConfigJsonError(`loadGamedata: directory mode at "${ url }" produced no data — make sure at least one tier (core/custom/seasonal) has a manifest.json5 with a 'files' array`, 'fetch', url);
return merged as T;
};
+43 -9
View File
@@ -5,6 +5,28 @@ declare const __NITRO_JSON_MODE__: 'legacy' | 'json5' | 'auto' | undefined;
const JSON5_EXTENSION = /\.json5(?:[?#]|$)/i;
const JSON5_MIME = /(?:application|text)\/(?:json5|x-json5)/i;
export type ConfigJsonErrorPhase = 'fetch' | 'parse';
export class ConfigJsonError extends Error
{
public readonly phase: ConfigJsonErrorPhase;
public readonly sourceUrl: string;
public readonly httpStatus?: number;
constructor(message: string, phase: ConfigJsonErrorPhase, sourceUrl: string, httpStatus?: number, cause?: unknown)
{
super(message);
this.name = 'ConfigJsonError';
this.phase = phase;
this.sourceUrl = sourceUrl;
this.httpStatus = httpStatus;
if(cause !== undefined) (this as any).cause = cause;
}
}
export const isMissingResource = (err: unknown): boolean =>
err instanceof ConfigJsonError && err.phase === 'fetch' && err.httpStatus === 404;
const resolveJsonMode = (): 'legacy' | 'json5' | 'auto' =>
{
try
@@ -44,9 +66,7 @@ const formatStrictError = (sourceUrl: string, err: unknown): string =>
export const parseConfigJson = <T = any>(text: string, sourceUrl: string = ''): T =>
{
if(text === null || text === undefined) throw new Error(`Empty response${ sourceUrl ? ` for "${ sourceUrl }"` : '' }`);
const trimmed = text.length > 0 ? text : '';
const trimmed = text ?? '';
const mode = resolveJsonMode();
if(mode === 'legacy')
@@ -57,7 +77,7 @@ export const parseConfigJson = <T = any>(text: string, sourceUrl: string = ''):
}
catch(err)
{
throw new Error(formatStrictError(sourceUrl, err));
throw new ConfigJsonError(formatStrictError(sourceUrl, err), 'parse', sourceUrl, undefined, err);
}
}
@@ -69,7 +89,7 @@ export const parseConfigJson = <T = any>(text: string, sourceUrl: string = ''):
}
catch(err)
{
throw new Error(formatParseError(sourceUrl, err, err));
throw new ConfigJsonError(formatParseError(sourceUrl, err, err), 'parse', sourceUrl, undefined, err);
}
}
@@ -90,7 +110,7 @@ export const parseConfigJson = <T = any>(text: string, sourceUrl: string = ''):
}
catch(json5Error)
{
throw new Error(formatParseError(sourceUrl, strictError, json5Error));
throw new ConfigJsonError(formatParseError(sourceUrl, strictError, json5Error), 'parse', sourceUrl, undefined, json5Error);
}
};
@@ -109,7 +129,7 @@ export const parseConfigJsonFromResponse = async <T = any>(response: Response, s
}
catch(err)
{
throw new Error(formatParseError(url, err, err));
throw new ConfigJsonError(formatParseError(url, err, err), 'parse', url, undefined, err);
}
}
@@ -118,9 +138,23 @@ export const parseConfigJsonFromResponse = async <T = any>(response: Response, s
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);
};
+1 -1
View File
@@ -4,6 +4,6 @@ declare global
{
interface Window
{
NitroConfig?: { [index: string]: any };
NitroConfig?: Record<string, unknown>;
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
export class NitroVersion
{
public static RENDERER_VERSION: string = '3.0.0';
public static UI_VERSION: string = '3.0.4';
public static RENDERER_VERSION: string = '3.5.0';
public static UI_VERSION: string = '3.5.0';
public static sayHello(): void
{
+1 -1
View File
@@ -28,7 +28,7 @@ export class TextureUtils
try
{
return await this.getExtractor().image(options);
return await this.getExtractor().image(options) as HTMLImageElement;
}
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,
"compilerOptions": {
"baseUrl": "./src",
"outDir": "./dist",
"sourceMap": false,
"declaration": true,
"experimentalDecorators": true,
"moduleResolution": "Node",
"moduleResolution": "bundler",
"esModuleInterop": true,
"importHelpers": true,
"isolatedModules": true,
"resolveJsonModule": true,
"downlevelIteration": true,
"allowSyntheticDefaultImports": true,
"allowJs": true,
"skipLibCheck": true,
+53 -10
View File
@@ -369,7 +369,7 @@
resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8"
integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==
"@thumbmarkjs/thumbmarkjs@^1.8.1":
"@thumbmarkjs/thumbmarkjs@^1.9.0":
version "1.9.0"
resolved "https://registry.yarnpkg.com/@thumbmarkjs/thumbmarkjs/-/thumbmarkjs-1.9.0.tgz#a6444ac1f924f061cfc1507a21dcaf83ee705cab"
integrity sha512-6LooyYk8i5L2zEZgDMLE6m2sGDcIHHBiZfxdFp0A16Q4ZXafEmhHmt+zCqQEBMiQHi+08e/v5q77IY2KhvAJwg==
@@ -527,6 +527,54 @@
"@typescript-eslint/types" "8.59.3"
eslint-visitor-keys "^5.0.0"
"@typescript/native-preview-darwin-arm64@7.0.0-dev.20260519.1":
version "7.0.0-dev.20260519.1"
resolved "https://registry.yarnpkg.com/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20260519.1.tgz#deaa14299366a79917a6ec19fc7f240304c36afe"
integrity sha512-c9zdG6sGJf25Jpz04JgE23zhYeprqFypDGuqiX94yMTvR8IWXjq3R2oMnim66YLBDon/V1nCEy6cFixeSd/4fg==
"@typescript/native-preview-darwin-x64@7.0.0-dev.20260519.1":
version "7.0.0-dev.20260519.1"
resolved "https://registry.yarnpkg.com/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20260519.1.tgz#a6348c134204afdcdfa8f9a0925091788fc994d7"
integrity sha512-N16V3wiM0tsNmSSA7nZrxqXXt5OCJxBwiCVn35rnA7fr4WzJw6rJmwf9heNNhZ6Gh4ne3+Pexajf5akzuHR75Q==
"@typescript/native-preview-linux-arm64@7.0.0-dev.20260519.1":
version "7.0.0-dev.20260519.1"
resolved "https://registry.yarnpkg.com/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20260519.1.tgz#04f2f0b314e646a6921cc818db6bac92364bab24"
integrity sha512-ltf91vAwKdbu0SlRQbFgi1h5ZrLLrBn6a4qIeN2VILGbtYrCXnARHRznLBv81yUETQ7aVr/LSQcmsWo1ejCK0w==
"@typescript/native-preview-linux-arm@7.0.0-dev.20260519.1":
version "7.0.0-dev.20260519.1"
resolved "https://registry.yarnpkg.com/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20260519.1.tgz#ad10403fb8ae6e2ed120cb9f81a029328a00e995"
integrity sha512-8v4BExeeuCTrhaSGfeIJqm3qQkTzlZix/Qd/FkPlWoz9f7d7COvXb3Z4qhbaVolL0MMnUvQ7m005Z4kYsZ645A==
"@typescript/native-preview-linux-x64@7.0.0-dev.20260519.1":
version "7.0.0-dev.20260519.1"
resolved "https://registry.yarnpkg.com/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20260519.1.tgz#dde79208611da3855f995e595b9b790c622f726f"
integrity sha512-AVD0tczTtFCHNa4RQRVPvu8Hnw4P3hQ+OlUAjnz/lHowvc6o1pYB46elMqfDuaoWqIpv+EAkAPP4ipFCofJ5IA==
"@typescript/native-preview-win32-arm64@7.0.0-dev.20260519.1":
version "7.0.0-dev.20260519.1"
resolved "https://registry.yarnpkg.com/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20260519.1.tgz#74d0cf98c75e2df20772ae2fa4c8139066e2cf7f"
integrity sha512-TM+qatljyejqjHevCta3WIH53i0oGC7K8SoJ6t+mf4cGMTpZTyd7NhC1ts7e6/aydZnG53Bsta2iQi1SMIlQEw==
"@typescript/native-preview-win32-x64@7.0.0-dev.20260519.1":
version "7.0.0-dev.20260519.1"
resolved "https://registry.yarnpkg.com/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20260519.1.tgz#a8cfe9f730399cdc21a20c40a59aee15eef75a18"
integrity sha512-r9LEsoY7JC/82gXo8hlOmpQaUXcqmngCVOv+mUx1UeMt9f+1S6oNO0W48o75mlBqqC7jfcMHqw8YS4LfVxPRGw==
"@typescript/native-preview@^7.0.0-dev.20260510.1":
version "7.0.0-dev.20260519.1"
resolved "https://registry.yarnpkg.com/@typescript/native-preview/-/native-preview-7.0.0-dev.20260519.1.tgz#0d865f5cc6d376d896fc031dfbb38ed0004153c6"
integrity sha512-VVER7vFUDdfm5k3jbH5765tVEJa7+0rTUkFeXyGYrXPxpw9BIjA0QDxdtdlRyaU8MCZV9IKZUo6doxeAQRAjPg==
optionalDependencies:
"@typescript/native-preview-darwin-arm64" "7.0.0-dev.20260519.1"
"@typescript/native-preview-darwin-x64" "7.0.0-dev.20260519.1"
"@typescript/native-preview-linux-arm" "7.0.0-dev.20260519.1"
"@typescript/native-preview-linux-arm64" "7.0.0-dev.20260519.1"
"@typescript/native-preview-linux-x64" "7.0.0-dev.20260519.1"
"@typescript/native-preview-win32-arm64" "7.0.0-dev.20260519.1"
"@typescript/native-preview-win32-x64" "7.0.0-dev.20260519.1"
"@vitest/coverage-v8@^4.0.18":
version "4.1.6"
resolved "https://registry.yarnpkg.com/@vitest/coverage-v8/-/coverage-v8-4.1.6.tgz#1eacee5def68dfcb08c3ed5355edbad2a4c869b3"
@@ -1681,15 +1729,10 @@ typescript-eslint@^8.26.1:
"@typescript-eslint/typescript-estree" "8.59.3"
"@typescript-eslint/utils" "8.59.3"
typescript@~5.5.4:
version "5.5.4"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.4.tgz#d9852d6c82bad2d2eda4fd74a5762a8f5909e9ba"
integrity sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==
typescript@~5.8.2:
version "5.8.3"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.3.tgz#92f8a3e5e3cf497356f4178c34cd65a7f5e8440e"
integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==
typescript@^6.0.3:
version "6.0.3"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-6.0.3.tgz#90251dc007916e972786cb94d74d15b185577d21"
integrity sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==
undici-types@~6.21.0:
version "6.21.0"