You've already forked Nitro_Render_V3
mirror of
https://github.com/duckietm/Nitro_Render_V3.git
synced 2026-06-19 15:06:20 +00:00
Merge pull request #72 from simoleo89/feat/react19-event-bus
feat(renderer): React-friendly subscribe API + snapshot getters + permission map
This commit is contained in:
@@ -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
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -23,4 +23,20 @@ export interface IUserDataManager
|
||||
updatePetLevel(roomIndex: number, level: number): void;
|
||||
updatePetBreedingStatus(roomIndex: number, canBreed: boolean, canHarvest: boolean, canRevive: boolean, hasBreedingPermission: boolean): void;
|
||||
requestPetInfo(id: number): void;
|
||||
|
||||
/**
|
||||
* Returns the current room's user list as a referentially-stable
|
||||
* ReadonlyArray. The same array reference is returned across reads
|
||||
* until any user is added, removed, or has a tracked field updated
|
||||
* (figure / name / motto / nick icon / customization / background /
|
||||
* achievement score / pet level / breeding status). Mutations
|
||||
* dispatch `NitroEventType.ROOM_USER_LIST_UPDATED` to signal
|
||||
* invalidation.
|
||||
*
|
||||
* The inner IRoomUserData objects keep the existing in-place
|
||||
* mutation semantics — they are NOT deep-cloned. Treat them as
|
||||
* snapshots-at-time-of-read; consumers should not retain individual
|
||||
* entries across invalidations.
|
||||
*/
|
||||
getRoomUserListSnapshot(): ReadonlyArray<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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -2,3 +2,4 @@ export * from './IMusicController';
|
||||
export * from './IPlaylistController';
|
||||
export * from './ISongInfo';
|
||||
export * from './ISoundManager';
|
||||
export * from './ISoundVolumesSnapshot';
|
||||
|
||||
@@ -18,6 +18,6 @@
|
||||
"pixi.js": "^8.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~5.8.2"
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, '');
|
||||
|
||||
@@ -15,6 +15,6 @@
|
||||
"@nitrots/utils": "1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~5.8.2"
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,6 @@
|
||||
"pixi.js": "^8.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~5.8.2"
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
{
|
||||
private _data: RoomEnterPayload;
|
||||
|
||||
/**
|
||||
* Optional spawnX/spawnY let the server resume the avatar at a
|
||||
* specific tile when re-entering the same room — used by the
|
||||
* reconnect flow. Arcturus' RequestRoomLoadEvent reads both ints
|
||||
* only if `packet.remaining >= 8`, so omitting them keeps the
|
||||
* legacy enter-via-door behavior.
|
||||
*/
|
||||
constructor(roomId: number, password: string = null, spawnX?: number, spawnY?: number)
|
||||
{
|
||||
this._data = [roomId, password];
|
||||
this._data = (spawnX !== undefined && spawnY !== undefined)
|
||||
? [ roomId, password, spawnX, spawnY ]
|
||||
: [ roomId, password ];
|
||||
}
|
||||
|
||||
public getMessageArray()
|
||||
|
||||
@@ -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()
|
||||
|
||||
+2
-2
@@ -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 [];
|
||||
}
|
||||
|
||||
+2
-2
@@ -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 [];
|
||||
}
|
||||
|
||||
+3
-4
@@ -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();
|
||||
|
||||
+11
-4
@@ -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;
|
||||
}
|
||||
|
||||
@@ -146,7 +146,17 @@ export class RoomUnitParser implements IMessageParser
|
||||
|
||||
user.roomEntryMethod = wrapper.readString();
|
||||
user.roomEntryTeleportId = wrapper.readInt();
|
||||
user.borderId = (wrapper.bytesAvailable ? wrapper.readInt() : 0);
|
||||
// Arcturus appends a trailing borderId int per user
|
||||
// (RoomUsersComposer, after the Infostand Borders feature)
|
||||
// for every record — habbo, bot, rentable bot — using 0 as
|
||||
// the constant for the records that have no border. The
|
||||
// read MUST be unconditional: a bytesAvailable guard would
|
||||
// be semantically wrong here (the guard answers "any byte
|
||||
// left in the whole packet?" not "any byte left for THIS
|
||||
// user"), and skipping the read would leave 4 bytes per
|
||||
// record and cascade-corrupt every subsequent user in the
|
||||
// roster.
|
||||
user.borderId = wrapper.readInt();
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,29 +82,35 @@ 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();
|
||||
this._prefixEffect = wrapper.readString();
|
||||
this._prefixFont = wrapper.readString();
|
||||
this._displayOrder = wrapper.readString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,6 @@
|
||||
"@nitrots/utils": "1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~5.8.2"
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,6 @@
|
||||
"@nitrots/utils": "1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~5.8.2"
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -17,6 +17,6 @@
|
||||
"pixi.js": "^8.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~5.8.2"
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@
|
||||
"pixi.js": "^8.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~5.8.2"
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,6 +197,8 @@ export class UserDataManager implements IUserDataManager
|
||||
userData.prefixEffect = prefixEffect;
|
||||
userData.prefixFont = prefixFont;
|
||||
userData.displayOrder = displayOrder;
|
||||
|
||||
this.invalidateRoomUserListSnapshot();
|
||||
}
|
||||
|
||||
public updateBackground(roomIndex: number, background: number, stand: number, overlay: number, cardBackground: number = 0, borderId: number = 0): void
|
||||
@@ -180,6 +212,8 @@ export class UserDataManager implements IUserDataManager
|
||||
userData.overlay = overlay;
|
||||
userData.cardBackground = cardBackground;
|
||||
userData.borderId = borderId;
|
||||
|
||||
this.invalidateRoomUserListSnapshot();
|
||||
}
|
||||
|
||||
public updateAchievementScore(roomIndex: number, score: number): void
|
||||
@@ -189,13 +223,19 @@ export class UserDataManager implements IUserDataManager
|
||||
if(!userData) return;
|
||||
|
||||
userData.activityPoints = score;
|
||||
|
||||
this.invalidateRoomUserListSnapshot();
|
||||
}
|
||||
|
||||
public updatePetLevel(roomIndex: number, level: number): void
|
||||
{
|
||||
const userData = this.getUserDataByIndex(roomIndex);
|
||||
|
||||
if(userData) userData.petLevel = level;
|
||||
if(!userData) return;
|
||||
|
||||
userData.petLevel = level;
|
||||
|
||||
this.invalidateRoomUserListSnapshot();
|
||||
}
|
||||
|
||||
public updatePetBreedingStatus(roomIndex: number, canBreed: boolean, canHarvest: boolean, canRevive: boolean, hasBreedingPermission: boolean): void
|
||||
@@ -208,6 +248,8 @@ export class UserDataManager implements IUserDataManager
|
||||
userData.canHarvest = canHarvest;
|
||||
userData.canRevive = canRevive;
|
||||
userData.hasBreedingPermission = hasBreedingPermission;
|
||||
|
||||
this.invalidateRoomUserListSnapshot();
|
||||
}
|
||||
|
||||
public requestPetInfo(id: number): void
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,6 @@
|
||||
"pixi.js": "^8.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~5.5.4"
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pako": "^2.0.3",
|
||||
"typescript": "~5.5.4"
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,6 @@ declare global
|
||||
{
|
||||
interface Window
|
||||
{
|
||||
NitroConfig?: { [index: string]: any };
|
||||
NitroConfig?: Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Vendored
+17
@@ -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
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user