diff --git a/RELEASE.md b/RELEASE.md index a6f1d01..7a35293 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,58 +1,58 @@ -## OpenCore Framework v1.0.5-beta.1 +## OpenCore Framework v1.0.5-beta.2 ### Highlights - -- Major runtime evolution with channels, RPC/events transport, plugins, and library APIs. -- Large architecture and cleanup pass across the codebase. -- Expanded benchmark coverage and refreshed benchmark results for this beta cycle. -- Clearer separation between public API surface and runtime implementations (ports/contracts vs local/remote implementations). -- Core runtime primitives are now more explicit and reusable (`BaseEntity`, `Spatial`, `World`, library core). +- Added an explicit server adapter API for platform-specific runtimes. +- Player creation and remote hydration now support adapter-owned subclasses while preserving the public `Player` type. +- Added an explicit client adapter API and removed the built-in `ClientPlayer` singleton. +- Added client UI bridges for markers, blips, and notifications. +- Added lifecycle services for NPC and Vehicle management. +- Improved Player management with spawn, teleport, and respawn actions. ### New Features - -- Channels and chat API ecosystem: - - Added a comprehensive channel system (radio, phone, team, admin, proximity). - - Added communication controller examples and extensive JSDoc for channel APIs. - - Exported channel API ports and removed legacy channel implementation paths. -- Messaging transport and RPC/events: - - Introduced a unified messaging transport architecture with `EventsAPI` and `RpcAPI`. - - Added stronger typed runtime contexts for server/client events and RPC. - - Added/expanded RPC decorator and handler support (`@OnRPC`) with integration tests. -- Runtime surface expansion: - - Consolidated core concepts around reusable runtime primitives (`BaseEntity`, `Spatial`, `World`) exported from runtime core. - - Added Appearance API wrapper for validation/apply/reset flows. - - Added Camera and Cinematic services, cinematic builder, and typed lifecycle payloads. - - Added ped abstractions (Cfx + Node implementations) and server-side NPC lifecycle APIs. - - Added first-class runtime library factories (`createServerLibrary`, `createClientLibrary`) and dedicated library event bus/processors. -- Public API boundary and port model: - - Server public API now explicitly exports API ports (`players.api-port`, `authorization.api-port`, `channel.api-port`) through `runtime/server/api`. - - Internal runtime ports were isolated under `ports/internal` (`command-execution`, `player-session-lifecycle`) to mark non-public contracts. - - Runtime services were moved toward explicit local/remote implementations under `runtime/server/implementations/*`. -- Security and validation flow: - - Principal/authorization and command/net validation paths were tightened through contract-based security handlers and observers. - - Runtime config and validation behavior were expanded and benchmarked (including validation-heavy and error-path scenarios). -- Plugin model: - - Added server plugin kernel MVP with extensible API hooks. - - Added client-side plugin system and plugin lifecycle hook after server initialization. -- Autoload and developer experience: - - Added autoload for user server controllers. - - Improved client controller autoloading and metadata scanning error handling. -- Benchmark system: - - Added broad benchmark suites for BinaryService, SchemaGenerator, EntitySystem, AppearanceValidation, EventInterceptor, RuntimeConfig. - - Added load benchmarks for RPC concurrency, validation, and request lifecycle. +- `Server.init()` now accepts `adapter` to install a single server adapter during bootstrap. +- Added public server adapter helpers in `@open-core/framework/server` for custom adapter packages. +- Added adapter-aware Player serialization hooks for CORE/RESOURCE flows. +- `Client.init()` now accepts `adapter` to install a single client adapter during bootstrap. +- Added client runtime bridge contracts so event processors, WebView callbacks, key mappings, and ticks no longer depend directly on CFX globals. +- Added client UI bridges for markers, blips, and notifications. +- Added lifecycle services and contracts for NPC and Vehicle management. +- Added `ISpawnActions` interface and implementation for managing player spawn, teleport, and respawn actions. +- Added `ClientLoggerBridge` to abstract client-side logging from direct console calls. +- Added `playerCommand` runtime event. +- Added RedM-specific ped appearance adapter and client services for RDR3 profile appearance logic. +- Added runtime platform and game profile detection with duplicate DI registration prevention. +- Added `useAdapter()` function to pre-set the client adapter before initialization. +- Added project-level adapter injection and runtime hints for server and client adapters. +- Added WebView abstraction for client UI interactions. +- Renamed routing bucket methods to dimension. +- Added dedicated client and server contract files with updated exports and package entry points. ### Breaking Changes +- Server bootstrap now defaults to the built-in Node adapter when no explicit runtime adapter is provided. +- Platform-specific Player APIs should move into adapter packages through Player subclassing/module augmentation. +- `ClientPlayer` is no longer exported from `@open-core/framework/client`. +- Client bootstrap no longer uses `register-client-capabilities`; external adapters should be installed through `Client.init({ adapter })`. +- `WebViewBridge` is now the preferred embedded UI abstraction; `OnView` now represents WebView callbacks directly, while `NuiBridge` and `NUI` remain as deprecated compatibility aliases. -- Service-to-API/implementation migration in multiple modules (notably `*Service` naming changes). -- Channel/chat APIs were renamed and moved (`ChannelService` -> `Channels`, `ChatService` -> `Chat`, moved to `apis/`). -- Transport contracts changed from legacy net transport shape to MessagingTransport + Events/RPC APIs. -- Port/file naming was normalized (`player-directory` -> `players.api-port`, `principal.port` -> `authorization.api-port`, plus related API file renames). -- Public vs internal contracts are stricter: `api-port` exports are public surface, while `ports/internal/*` are runtime internals and should not be consumed directly. -- Deprecated methods, stale docs, and obsolete examples were removed. -- Import paths and shared types were normalized/centralized (including parallel compute types and decorator/binary file naming updates). +### Bug Fixes +- Fixed lint issues and removed unused variables. +- Fixed exportation issues. +- Added tests for lint and unused variable fixes. ### Notes - -This beta is a major milestone for OpenCore: cleaner runtime boundaries, stronger extension points, and richer communication primitives while keeping decorator-driven DX. - -Simple CLI context: OpenCore CLI now supports cleaner non-interactive build output for CI environments (for example `opencore build --output=plain`). +- Migration path for external adapters: + 1. Create an adapter with `defineServerAdapter({ name, register(ctx) { ... } })`. + 2. Register platform contracts inside `register(ctx)` with `bindSingleton`, `bindInstance`, or `bindMessagingTransport`. + 3. If you extend `Player`, provide `ctx.usePlayerAdapter({ createLocal, createRemote, serialize, hydrate })`. + 4. Pass the adapter to `Server.init({ mode, adapter })` in both CORE and RESOURCE resources. +- RESOURCE hydration now validates adapter identity before rebuilding remote `Player` instances. +- Client adapter migration path: + 1. Create an adapter with `defineClientAdapter({ name, register(ctx) { ... } })`. + 2. Register transport, appearance, hashing, and runtime bridge contracts inside `register(ctx)`. + 3. Pass the adapter to `Client.init({ mode, adapter })`. +- Client files now safe to remove from core once moved to external adapter packages: + - `src/adapters/register-client-capabilities.ts` + - `src/adapters/fivem/fivem-ped-appearance-client.ts` + - `src/adapters/redm/redm-ped-appearance-client.ts` + - `src/adapters/node/node-ped-appearance-client.ts` + - Any remaining client-only transport/runtime bindings that your external adapter reimplements. diff --git a/package.json b/package.json index a0180c3..9074b66 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@open-core/framework", - "version": "1.0.5-beta.1", + "version": "1.0.5-beta.2", "description": "Secure, event-driven TypeScript Framework & Runtime engine for CitizenFX (Cfx).", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -34,6 +34,26 @@ "import": "./dist/runtime/client/index.js", "require": "./dist/runtime/client/index.js" }, + "./contracts": { + "types": "./dist/contracts.d.ts", + "import": "./dist/contracts.js", + "require": "./dist/contracts.js" + }, + "./contracts/client": { + "types": "./dist/contracts/client.d.ts", + "import": "./dist/contracts/client.js", + "require": "./dist/contracts/client.js" + }, + "./contracts/server": { + "types": "./dist/contracts/server.d.ts", + "import": "./dist/contracts/server.js", + "require": "./dist/contracts/server.js" + }, + "./kernel": { + "types": "./dist/kernel-public.d.ts", + "import": "./dist/kernel-public.js", + "require": "./dist/kernel-public.js" + }, "./package.json": "./package.json" }, "scripts": { @@ -79,8 +99,6 @@ }, "devDependencies": { "@biomejs/biome": "^2.3.11", - "@citizenfx/client": "2.0.22443-1", - "@citizenfx/server": "2.0.22443-1", "@types/node": "^25.0.3", "@vitest/coverage-v8": "^4.0.16", "dependency-cruiser": "^17.3.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c8678cc..37b152c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,12 +24,6 @@ importers: '@biomejs/biome': specifier: ^2.3.11 version: 2.3.11 - '@citizenfx/client': - specifier: 2.0.22443-1 - version: 2.0.22443-1 - '@citizenfx/server': - specifier: 2.0.22443-1 - version: 2.0.22443-1 '@types/node': specifier: ^25.0.3 version: 25.0.3 @@ -137,12 +131,6 @@ packages: cpu: [x64] os: [win32] - '@citizenfx/client@2.0.22443-1': - resolution: {integrity: sha512-fCnYxGrFCl1iUxS1oAbmwXCuouLd+MzMjLrvVIv9Q/2/H7SyhPDHLZVOedUZmkG0gWvt4wGk1Pjcv41uzmVlhA==} - - '@citizenfx/server@2.0.22443-1': - resolution: {integrity: sha512-QbLuZQ0JnFXu59d4x0Z0qN3Bu+tb38oK4+E3DvB0zg172+EzrdGnfX66KprlM8JEjDvItTR4fSIVUWJyNYzULQ==} - '@esbuild/aix-ppc64@0.27.2': resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} engines: {node: '>=18'} @@ -1695,10 +1683,6 @@ snapshots: '@biomejs/cli-win32-x64@2.3.11': optional: true - '@citizenfx/client@2.0.22443-1': {} - - '@citizenfx/server@2.0.22443-1': {} - '@esbuild/aix-ppc64@0.27.2': optional: true diff --git a/src/adapters/cfx/cfx-capabilities.ts b/src/adapters/cfx/cfx-capabilities.ts deleted file mode 100644 index e1c7710..0000000 --- a/src/adapters/cfx/cfx-capabilities.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { injectable } from 'tsyringe' -import { IPlatformCapabilities, PlatformFeatures } from '../contracts/IPlatformCapabilities' -import { IdentifierTypes } from '../contracts/types/identifier' -import { detectCfxGameProfile } from './runtime-profile' - -@injectable() -export class CfxCapabilities extends IPlatformCapabilities { - readonly platformName = 'cfx' - readonly displayName = 'CitizenFX' - - private readonly gameProfile = detectCfxGameProfile() - - readonly supportsRoutingBuckets = true - readonly supportsStateBags = true - readonly supportsVoiceChat = true - readonly supportsServerEntities = true - - readonly identifierTypes = [ - IdentifierTypes.STEAM, - IdentifierTypes.LICENSE, - IdentifierTypes.LICENSE2, - IdentifierTypes.DISCORD, - IdentifierTypes.FIVEM, - IdentifierTypes.XBL, - IdentifierTypes.LIVE, - IdentifierTypes.IP, - IdentifierTypes.ROCKSTAR, - ] as const - - readonly maxPlayers = 1024 - - private readonly supportedFeatures = new Set([ - PlatformFeatures.ROUTING_BUCKETS, - PlatformFeatures.STATE_BAGS, - PlatformFeatures.VOICE_CHAT, - PlatformFeatures.SERVER_ENTITIES, - PlatformFeatures.BLIPS, - PlatformFeatures.MARKERS, - PlatformFeatures.TEXT_LABELS, - PlatformFeatures.CHECKPOINTS, - PlatformFeatures.COLSHAPES, - ...(this.gameProfile === 'gta5' - ? [ - PlatformFeatures.VEHICLE_MODS, - PlatformFeatures.PED_APPEARANCE, - PlatformFeatures.WEAPON_COMPONENTS, - ] - : []), - ]) - - private readonly config: Record = { - runtime: 'cfx', - gameProfile: this.gameProfile, - defaultRoutingBucket: 0, - maxRoutingBuckets: 63, - tickRate: 64, - syncRate: 10, - defaultSpawnModel: this.gameProfile === 'rdr3' ? 'mp_male' : 'mp_m_freemode_01', - enableServerVehicleCreation: this.gameProfile !== 'rdr3', - defaultVehicleType: this.gameProfile === 'rdr3' ? 'automobile' : 'automobile', - } - - isFeatureSupported(feature: string): boolean { - return this.supportedFeatures.has(feature) - } - - getConfig(key: string): T | undefined { - return this.config[key] as T | undefined - } -} diff --git a/src/adapters/cfx/cfx-platform.ts b/src/adapters/cfx/cfx-platform.ts deleted file mode 100644 index 2c3cee6..0000000 --- a/src/adapters/cfx/cfx-platform.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { DependencyContainer } from 'tsyringe' -import { IEngineEvents } from '../contracts/IEngineEvents' -import { IExports } from '../contracts/IExports' -import { IHasher } from '../contracts/IHasher' -import { IPlatformCapabilities } from '../contracts/IPlatformCapabilities' -import { IPlayerInfo } from '../contracts/IPlayerInfo' -import { IResourceInfo } from '../contracts/IResourceInfo' -import { ITick } from '../contracts/ITick' -import { IEntityServer } from '../contracts/server/IEntityServer' -import { IPedServer } from '../contracts/server/IPedServer' -import { IPedAppearanceServer } from '../contracts/server/IPedAppearanceServer' -import { IPlayerServer } from '../contracts/server/IPlayerServer' -import { IVehicleServer } from '../contracts/server/IVehicleServer' -import { EventsAPI } from '../contracts/transport/events.api' -import { MessagingTransport } from '../contracts/transport/messaging.transport' -import { RpcAPI } from '../contracts/transport/rpc.api' -import type { PlatformAdapter } from '../platform/platform-registry' -import { detectCfxGameProfile, isCfxRuntime } from './runtime-profile' - -export const CfxPlatform: PlatformAdapter = { - name: 'cfx', - priority: 100, - - detect(): boolean { - return isCfxRuntime() - }, - - async register(container: DependencyContainer): Promise { - const profile = detectCfxGameProfile() - const [ - { FiveMMessagingTransport }, - { FiveMEngineEvents }, - { FiveMExports }, - { FiveMResourceInfo }, - { FiveMTick }, - { FiveMPlayerInfo }, - { FiveMEntityServer }, - { FiveMPedServer }, - { FiveMVehicleServer }, - { FiveMPlayerServer }, - { FiveMHasher }, - { FiveMPedAppearanceServerAdapter }, - { NodePedAppearanceServer }, - { CfxCapabilities }, - ] = await Promise.all([ - import('../fivem/transport/adapter'), - import('../fivem/fivem-engine-events'), - import('../fivem/fivem-exports'), - import('../fivem/fivem-resourceinfo'), - import('../fivem/fivem-tick'), - import('../fivem/fivem-playerinfo'), - import('../fivem/fivem-entity-server'), - import('../fivem/fivem-ped-server'), - import('../fivem/fivem-vehicle-server'), - import('../fivem/fivem-player-server'), - import('../fivem/fivem-hasher'), - import('../fivem/fivem-ped-appearance-server'), - import('../node/node-ped-appearance-server'), - import('./cfx-capabilities'), - ]) - - if (!container.isRegistered(IPlatformCapabilities as any)) { - container.registerSingleton(IPlatformCapabilities as any, CfxCapabilities) - } - - if (!container.isRegistered(MessagingTransport as any)) { - const transport = new FiveMMessagingTransport() - container.registerInstance(MessagingTransport as any, transport) - container.registerInstance(EventsAPI as any, transport.events) - container.registerInstance(RpcAPI as any, transport.rpc) - } - - if (!container.isRegistered(IEngineEvents as any)) { - container.registerSingleton(IEngineEvents as any, FiveMEngineEvents) - } - if (!container.isRegistered(IExports as any)) { - container.registerSingleton(IExports as any, FiveMExports) - } - if (!container.isRegistered(IResourceInfo as any)) { - container.registerSingleton(IResourceInfo as any, FiveMResourceInfo) - } - if (!container.isRegistered(ITick as any)) { - container.registerSingleton(ITick as any, FiveMTick) - } - if (!container.isRegistered(IPlayerInfo as any)) { - container.registerSingleton(IPlayerInfo as any, FiveMPlayerInfo) - } - if (!container.isRegistered(IEntityServer as any)) { - container.registerSingleton(IEntityServer as any, FiveMEntityServer) - } - if (!container.isRegistered(IPedServer as any)) { - container.registerSingleton(IPedServer as any, FiveMPedServer) - } - if (!container.isRegistered(IVehicleServer as any)) { - container.registerSingleton(IVehicleServer as any, FiveMVehicleServer) - } - if (!container.isRegistered(IPlayerServer as any)) { - container.registerSingleton(IPlayerServer as any, FiveMPlayerServer) - } - if (!container.isRegistered(IHasher as any)) { - container.registerSingleton(IHasher as any, FiveMHasher) - } - - if (!container.isRegistered(IPedAppearanceServer as any)) { - const appearanceImpl = - profile === 'rdr3' ? NodePedAppearanceServer : FiveMPedAppearanceServerAdapter - container.registerSingleton(IPedAppearanceServer as any, appearanceImpl) - } - }, -} diff --git a/src/adapters/cfx/index.ts b/src/adapters/cfx/index.ts deleted file mode 100644 index fb9aa4f..0000000 --- a/src/adapters/cfx/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { CfxCapabilities } from './cfx-capabilities' -export { CfxPlatform } from './cfx-platform' -export { detectCfxGameProfile, isCfxRuntime } from './runtime-profile' diff --git a/src/adapters/cfx/runtime-profile.ts b/src/adapters/cfx/runtime-profile.ts deleted file mode 100644 index 6fd95d0..0000000 --- a/src/adapters/cfx/runtime-profile.ts +++ /dev/null @@ -1,41 +0,0 @@ -export type CfxGameProfile = 'gta5' | 'rdr3' | 'common' - -const RDR3_HINTS = ['rdr3', 'redm', 'rdr'] -const GTA5_HINTS = ['gta5', 'fivem', 'gta'] - -function normalizeGameName(value: unknown): string { - if (typeof value !== 'string') return '' - return value.trim().toLowerCase() -} - -function detectFromName(gameName: string): CfxGameProfile { - if (!gameName) return 'common' - if (RDR3_HINTS.some((hint) => gameName.includes(hint))) return 'rdr3' - if (GTA5_HINTS.some((hint) => gameName.includes(hint))) return 'gta5' - return 'common' -} - -export function isCfxRuntime(): boolean { - return typeof (globalThis as any).GetCurrentResourceName === 'function' -} - -export function detectCfxGameProfile(): CfxGameProfile { - const convar = (globalThis as any).GetConvar - if (typeof convar === 'function') { - const override = normalizeGameName(convar('opencore:gameProfile', '')) - const profile = detectFromName(override) - if (profile !== 'common') { - return profile - } - } - - const getGameName = (globalThis as any).GetGameName - if (typeof getGameName === 'function') { - const profile = detectFromName(normalizeGameName(getGameName())) - if (profile !== 'common') { - return profile - } - } - - return 'common' -} diff --git a/src/adapters/contracts/IEngineEvents.ts b/src/adapters/contracts/IEngineEvents.ts index 38c9efd..4d8f336 100644 --- a/src/adapters/contracts/IEngineEvents.ts +++ b/src/adapters/contracts/IEngineEvents.ts @@ -1,3 +1,5 @@ +import { DEFAULT_RUNTIME_EVENT_MAP, type RuntimeEventMap, type RuntimeEventName } from './runtime' + export abstract class IEngineEvents { /** * Registers a handler for a local (server-side) event. @@ -7,6 +9,14 @@ export abstract class IEngineEvents { */ abstract on(eventName: string, handler?: (...args: any[]) => void): void + onRuntime(eventName: RuntimeEventName, handler?: (...args: any[]) => void): void { + this.on(this.getRuntimeEventMap()[eventName] ?? eventName, handler) + } + + getRuntimeEventMap(): RuntimeEventMap { + return DEFAULT_RUNTIME_EVENT_MAP + } + /** * Emits a local event. * diff --git a/src/adapters/contracts/IPlatformCapabilities.ts b/src/adapters/contracts/IPlatformCapabilities.ts deleted file mode 100644 index 308b9aa..0000000 --- a/src/adapters/contracts/IPlatformCapabilities.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * Platform capabilities contract. - * - * @remarks - * Defines what features a platform supports, allowing runtime - * feature detection and graceful degradation across different - * game engines (CitizenFX, RageMP, alt:V, etc.) - */ -export abstract class IPlatformCapabilities { - /** - * Unique identifier for the platform. - * @example 'cfx', 'ragemp', 'altv', 'redm' - */ - abstract readonly platformName: string - - /** - * Human-readable display name. - * @example 'CitizenFX', 'RageMP', 'alt:V', 'RedM' - */ - abstract readonly displayName: string - - /** - * Whether the platform supports routing buckets (virtual worlds/dimensions). - */ - abstract readonly supportsRoutingBuckets: boolean - - /** - * Whether the platform supports state bags for entity synchronization. - */ - abstract readonly supportsStateBags: boolean - - /** - * Whether the platform has native voice chat support. - */ - abstract readonly supportsVoiceChat: boolean - - /** - * Whether the platform supports server-side entity creation. - */ - abstract readonly supportsServerEntities: boolean - - /** - * Supported identifier types for this platform. - * @example ['steam', 'license', 'discord'] for FiveM - * @example ['socialclub', 'ip'] for RageMP - */ - abstract readonly identifierTypes: readonly string[] - - /** - * Maximum number of players supported by the platform. - * Returns undefined if unlimited or unknown. - */ - abstract readonly maxPlayers: number | undefined - - /** - * Check if a specific feature is supported. - * - * @param feature - Feature identifier to check - * @returns true if the feature is supported - */ - abstract isFeatureSupported(feature: string): boolean - - /** - * Get platform-specific configuration value. - * - * @param key - Configuration key - * @returns Configuration value or undefined - */ - abstract getConfig(key: string): T | undefined -} - -/** - * Well-known feature identifiers for cross-platform compatibility. - */ -export const PlatformFeatures = { - ROUTING_BUCKETS: 'routing_buckets', - STATE_BAGS: 'state_bags', - VOICE_CHAT: 'voice_chat', - SERVER_ENTITIES: 'server_entities', - VEHICLE_MODS: 'vehicle_mods', - PED_APPEARANCE: 'ped_appearance', - WEAPON_COMPONENTS: 'weapon_components', - BLIPS: 'blips', - MARKERS: 'markers', - TEXT_LABELS: 'text_labels', - CHECKPOINTS: 'checkpoints', - COLSHAPES: 'colshapes', -} as const - -export type PlatformFeature = (typeof PlatformFeatures)[keyof typeof PlatformFeatures] diff --git a/src/adapters/contracts/IPlatformContext.ts b/src/adapters/contracts/IPlatformContext.ts new file mode 100644 index 0000000..ef97b5c --- /dev/null +++ b/src/adapters/contracts/IPlatformContext.ts @@ -0,0 +1,51 @@ +export type platforms = 'node' | 'fivem' | 'ragemp' | 'redm' + +/** + * Platform context contract. + * + * Keeps the stable runtime information the framework actually uses across + * platforms, without exposing a generic feature registry. + */ +export abstract class IPlatformContext { + /** + * Unique platform identifier. + * @example 'node', 'fivem', 'ragemp', 'redm' + */ + abstract readonly platformName: platforms | string + + /** + * Human-readable display name. + */ + abstract readonly displayName: string + + /** + * Supported identifier types for this platform. + */ + abstract readonly identifierTypes: readonly string[] + + /** + * Maximum number of players supported by the platform. + * Returns undefined if unlimited or unknown. + */ + abstract readonly maxPlayers: number | undefined + + /** + * Coarse game profile used by the framework. + */ + abstract readonly gameProfile: 'gta5' | 'rdr3' | 'common' + + /** + * Default player model used when no explicit model is provided. + */ + abstract readonly defaultSpawnModel: string + + /** + * Default vehicle type used for server-side spawning. + */ + abstract readonly defaultVehicleType: string + + /** + * Whether server-side vehicle creation should be enabled. + */ + abstract readonly enableServerVehicleCreation: boolean +} diff --git a/src/adapters/contracts/client/IClientLocalPlayerBridge.ts b/src/adapters/contracts/client/IClientLocalPlayerBridge.ts new file mode 100644 index 0000000..a9b41c9 --- /dev/null +++ b/src/adapters/contracts/client/IClientLocalPlayerBridge.ts @@ -0,0 +1,5 @@ +import type { Vector3 } from '../../../kernel/utils/vector3' + +export abstract class IClientLocalPlayerBridge { + abstract setPosition(position: Vector3, heading?: number): void +} diff --git a/src/adapters/contracts/client/IClientLogConsole.ts b/src/adapters/contracts/client/IClientLogConsole.ts new file mode 100644 index 0000000..0dc79d6 --- /dev/null +++ b/src/adapters/contracts/client/IClientLogConsole.ts @@ -0,0 +1,14 @@ +export interface ClientLogConsoleCapabilities { + supportsColors: boolean + supportsStructuredData: boolean + supportsRichFormatting: boolean +} + +export abstract class IClientLogConsole { + abstract getCapabilities(): ClientLogConsoleCapabilities + abstract trace(message: string, details?: unknown): void + abstract debug(message: string, details?: unknown): void + abstract info(message: string, details?: unknown): void + abstract warn(message: string, details?: unknown): void + abstract error(message: string, details?: unknown): void +} diff --git a/src/adapters/contracts/client/IClientPlatformBridge.ts b/src/adapters/contracts/client/IClientPlatformBridge.ts new file mode 100644 index 0000000..8972ee8 --- /dev/null +++ b/src/adapters/contracts/client/IClientPlatformBridge.ts @@ -0,0 +1,389 @@ +import type { Vector3 } from '../../../kernel/utils/vector3' + +export interface TextDrawOptions { + font?: number + scale?: number + color?: { r: number; g: number; b: number; a: number } + alignment?: number + dropShadow?: boolean + outline?: boolean + wrapStart?: number + wrapEnd?: number + center?: boolean +} + +export class IClientPlatformBridge { + getLocalPlayerPed(): number { + return 0 + } + getEntityCoords(_entity: number): Vector3 { + return { x: 0, y: 0, z: 0 } + } + getWorldPositionOfEntityBone(_entity: number, _bone: number): Vector3 { + return { x: 0, y: 0, z: 0 } + } + getGameplayCamCoords(): Vector3 { + return { x: 0, y: 0, z: 0 } + } + getDistanceBetweenCoords(a: Vector3, b: Vector3, useZ = true): number { + const dz = useZ ? a.z - b.z : 0 + return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2 + dz ** 2) + } + worldToScreen(_position: Vector3): { onScreen: boolean; x: number; y: number } { + return { onScreen: false, x: 0, y: 0 } + } + getHashKey(value: string): number { + let hash = 0 + const key = value.toLowerCase() + for (let i = 0; i < key.length; i += 1) { + hash += key.charCodeAt(i) + hash += hash << 10 + hash ^= hash >>> 6 + } + hash += hash << 3 + hash ^= hash >>> 11 + hash += hash << 15 + return hash >>> 0 + } + isModelInCdimage(_hash: number): boolean { + return false + } + isModelValid(_hash: number): boolean { + return false + } + isModelAVehicle(_hash: number): boolean { + return false + } + isModelAPed(_hash: number): boolean { + return false + } + requestModel(_hash: number): void {} + hasModelLoaded(_hash: number): boolean { + return false + } + setModelAsNoLongerNeeded(_hash: number): void {} + requestAnimDict(_dict: string): void {} + hasAnimDictLoaded(_dict: string): boolean { + return false + } + removeAnimDict(_dict: string): void {} + requestNamedPtfxAsset(_asset: string): void {} + hasNamedPtfxAssetLoaded(_asset: string): boolean { + return false + } + removeNamedPtfxAsset(_asset: string): void {} + useParticleFxAssetNextCall(_asset: string): void {} + startParticleFxLoopedAtCoord( + _effectName: string, + _position: Vector3, + _rotation: Vector3, + _scale: number, + ): number { + return 0 + } + startParticleFxNonLoopedAtCoord( + _effectName: string, + _position: Vector3, + _rotation: Vector3, + _scale: number, + ): number { + return 0 + } + stopParticleFxLooped(_handle: number, _stop: boolean): void {} + requestStreamedTextureDict(_dict: string, _persistent: boolean): void {} + hasStreamedTextureDictLoaded(_dict: string): boolean { + return false + } + setStreamedTextureDictAsNoLongerNeeded(_dict: string): void {} + requestScriptAudioBank(_bank: string, _networked: boolean): boolean { + return false + } + releaseScriptAudioBank(_bank: string): void {} + doesEntityExist(_entity: number): boolean { + return false + } + setEntityAsMissionEntity(_entity: number, _mission: boolean, _scriptHostObject: boolean): void {} + setBlockingOfNonTemporaryEvents(_ped: number, _toggle: boolean): void {} + setPedRelationshipGroupHash(_ped: number, _groupHash: number): void {} + createPed( + _pedType: number, + _modelHash: number, + _position: Vector3, + _heading: number, + _networked: boolean, + _scriptHostPed: boolean, + ): number { + return 0 + } + deletePed(_ped: number): void {} + createObject( + _modelHash: number, + _position: Vector3, + _networked: boolean, + _dynamic: boolean, + _placeOnGround: boolean, + ): number { + return 0 + } + deleteEntity(_entity: number): void {} + attachEntityToEntity( + _entity: number, + _target: number, + _boneIndex: number, + _offset: Vector3, + _rotation: Vector3, + ): void {} + getPedBoneIndex(_ped: number, bone: number): number { + return bone + } + taskPlayAnim( + _ped: number, + _dict: string, + _anim: string, + _blendInSpeed: number, + _blendOutSpeed: number, + _duration: number, + _flags: number, + _playbackRate: number, + ): void {} + stopAnimTask(_ped: number, _dict: string, _anim: string, _blendOutSpeed: number): void {} + clearPedTasks(_ped: number): void {} + clearPedTasksImmediately(_ped: number): void {} + freezeEntityPosition(_entity: number, _toggle: boolean): void {} + setEntityInvincible(_entity: number, _toggle: boolean): void {} + giveWeaponToPed( + _ped: number, + _weaponHash: number, + _ammoCount: number, + _hidden: boolean, + _forceInHand: boolean, + ): void {} + removeAllPedWeapons(_ped: number, _includeCurrentWeapon: boolean): void {} + getClosestPed(_position: Vector3, _radius: number): number | null { + return null + } + getNearbyPeds(_position: Vector3, _radius: number, _excludeEntity?: number): number[] { + return [] + } + taskLookAtEntity(_ped: number, _entity: number, _duration: number): void {} + taskLookAtCoord(_ped: number, _position: Vector3, _duration: number): void {} + taskGoStraightToCoord(_ped: number, _position: Vector3, _speed: number): void {} + setPedCombatAttributes(_ped: number, _attributeIndex: number, _enabled: boolean): void {} + createVehicle( + _modelHash: number, + _position: Vector3, + _heading: number, + _networked: boolean, + _scriptHostVehicle: boolean, + ): number { + return 0 + } + deleteVehicle(_vehicle: number): void {} + setVehicleOnGroundProperly(_vehicle: number): void {} + getVehicleColours(_vehicle: number): [number, number] { + return [0, 0] + } + setVehicleColours(_vehicle: number, _primary: number, _secondary: number): void {} + setVehicleNumberPlateText(_vehicle: number, _plateText: string): void {} + taskWarpPedIntoVehicle(_ped: number, _vehicle: number, _seatIndex: number): void {} + taskLeaveVehicle(_ped: number, _vehicle: number, _flags: number): void {} + getClosestVehicle(_position: Vector3, _radius: number): number | null { + return null + } + isPedInAnyVehicle(_ped: number): boolean { + return false + } + getVehiclePedIsIn(_ped: number, _lastVehicle: boolean): number | null { + return null + } + getPedInVehicleSeat(_vehicle: number, _seatIndex: number): number | null { + return null + } + getEntitySpeed(_entity: number): number { + return 0 + } + networkGetNetworkIdFromEntity(_entity: number): number { + return 0 + } + networkDoesEntityExistWithNetworkId(_networkId: number): boolean { + return false + } + networkGetEntityFromNetworkId(_networkId: number): number { + return 0 + } + getEntityHeading(_entity: number): number { + return 0 + } + getEntityModel(_entity: number): number { + return 0 + } + getVehicleNumberPlateText(_vehicle: number): string { + return '' + } + setVehicleModKit(_vehicle: number, _kit: number): void {} + setVehicleMod( + _vehicle: number, + _modType: number, + _modIndex: number, + _customTires: boolean, + ): void {} + toggleVehicleMod(_vehicle: number, _modType: number, _toggle: boolean): void {} + setVehicleWheelType(_vehicle: number, _wheelType: number): void {} + setVehicleWindowTint(_vehicle: number, _tint: number): void {} + setVehicleLivery(_vehicle: number, _livery: number): void {} + setVehicleNumberPlateTextIndex(_vehicle: number, _index: number): void {} + setVehicleNeonLightEnabled(_vehicle: number, _index: number, _enabled: boolean): void {} + setVehicleNeonLightsColour(_vehicle: number, _r: number, _g: number, _b: number): void {} + setVehicleExtra(_vehicle: number, _extraId: number, _disable: boolean): void {} + getVehicleExtraColours(_vehicle: number): [number, number] { + return [0, 0] + } + setVehicleExtraColours(_vehicle: number, _pearl: number, _wheel: number): void {} + setVehicleFixed(_vehicle: number): void {} + setVehicleDeformationFixed(_vehicle: number): void {} + setVehicleUndriveable(_vehicle: number, _toggle: boolean): void {} + setVehicleEngineOn( + _vehicle: number, + _value: boolean, + _instantly: boolean, + _disableAutoStart: boolean, + ): void {} + setVehicleEngineHealth(_vehicle: number, _health: number): void {} + setVehiclePetrolTankHealth(_vehicle: number, _health: number): void {} + setVehicleFuelLevel(_vehicle: number, _level: number): void {} + getVehicleFuelLevel(_vehicle: number): number { + return 0 + } + setVehicleDoorsLocked(_vehicle: number, _doorLockStatus: number): void {} + setEntityHeading(_entity: number, _heading: number): void {} + setEntityCoords(_entity: number, _position: Vector3): void {} + setEntityCoordsNoOffset(_entity: number, _position: Vector3): void {} + setEntityHealth(_entity: number, _health: number): void {} + getEntityMaxHealth(_entity: number): number { + return 200 + } + setPedArmour(_ped: number, _armour: number): void {} + isScreenFadedOut(): boolean { + return false + } + isScreenFadingOut(): boolean { + return false + } + doScreenFadeOut(_ms: number): void {} + isScreenFadedIn(): boolean { + return true + } + isScreenFadingIn(): boolean { + return false + } + doScreenFadeIn(_ms: number): void {} + networkIsSessionStarted(): boolean { + return true + } + networkResurrectLocalPlayer(_position: Vector3, _heading: number): void {} + playerId(): number { + return 0 + } + setPlayerModel(_playerId: number, _modelHash: number): void {} + requestCollisionAtCoord(_position: Vector3): void {} + hasCollisionLoadedAroundEntity(_entity: number): boolean { + return true + } + resetEntityAlpha(_entity: number): void {} + setEntityAlpha(_entity: number, _alphaLevel: number): void {} + setEntityVisible(_entity: number, _toggle: boolean): void {} + setEntityCollision(_entity: number, _toggle: boolean): void {} + shutdownLoadingScreen(): void {} + shutdownLoadingScreenNui(): void {} + addTextComponentString(_text: string): void {} + addTextComponentSubstringPlayerName(_text: string): void {} + beginTextCommandDisplayHelp(_type: string): void {} + endTextCommandDisplayHelp( + _shape: number, + _loop: boolean, + _beep: boolean, + _duration: number, + ): void {} + clearAllHelpMessages(): void {} + beginTextCommandPrint(_type: string): void {} + endTextCommandPrint(_duration: number, _drawImmediately: boolean): void {} + clearPrints(): void {} + setFloatingHelpTextWorldPosition(_style: number, _position: Vector3): void {} + setFloatingHelpTextStyle( + _style: number, + _hudColor: number, + _alpha: number, + _p3: number, + _arrowDirection: number, + _p5: number, + ): void {} + setTextFont(_fontType: number): void {} + setTextScale(_scale: number): void {} + setTextColour(_color: { r: number; g: number; b: number; a: number }): void {} + setTextJustification(_justifyType: number): void {} + setTextDropshadow(_distance: number, _r: number, _g: number, _b: number, _a: number): void {} + setTextDropShadow(): void {} + setTextOutline(): void {} + setTextWrap(_start: number, _end: number): void {} + setTextRightJustify(_toggle: boolean): void {} + beginTextCommandDisplayText(_type: string): void {} + endTextCommandDisplayText(_x: number, _y: number): void {} + setTextCentre(_toggle: boolean): void {} + beginTextCommandBusyspinnerOn(_type: string): void {} + endTextCommandBusyspinnerOn(_busySpinnerType: number): void {} + busyspinnerOff(): void {} + disableAllControlActions(_padIndex: number): void {} + disableControlAction(_padIndex: number, _control: number, _disable: boolean): void {} + isControlJustPressed(_padIndex: number, _control: number): boolean { + return false + } + drawRect( + _x: number, + _y: number, + _width: number, + _height: number, + _r: number, + _g: number, + _b: number, + _a: number, + ): void {} + displayHud(_toggle: boolean): void {} + displayRadar(_toggle: boolean): void {} + clearTimecycleModifier(): void {} + setTimecycleModifier(_modifierName: string): void {} + setTimecycleModifierStrength(_strength: number): void {} + createCam(_camName: string, _active: boolean): number { + return 0 + } + setCamActive(_cam: number, _active: boolean): void {} + renderScriptCams( + _render: boolean, + _ease: boolean, + _easeTimeMs: number, + _p3: boolean, + _p4: boolean, + ): void {} + destroyCam(_cam: number, _destroy: boolean): void {} + destroyAllCams(_destroy: boolean): void {} + setCamCoord(_cam: number, _position: Vector3): void {} + setCamRot(_cam: number, _rotation: Vector3, _rotationOrder: number): void {} + setCamFov(_cam: number, _fov: number): void {} + pointCamAtCoord(_cam: number, _position: Vector3): void {} + pointCamAtEntity(_cam: number, _entity: number, _offset: Vector3): void {} + stopCamPointing(_cam: number): void {} + setCamActiveWithInterp( + _toCam: number, + _fromCam: number, + _durationMs: number, + _easeLocation: number, + _easeRotation: number, + ): void {} + shakeCam(_cam: number, _type: string, _amplitude: number): void {} + stopCamShaking(_cam: number, _stopImmediately: boolean): void {} + onLocalPlayerStateChange(_key: string, _handler: (value: unknown) => void): () => void { + return () => {} + } + getEntityState(_entity: number, _key: string): T | undefined { + return undefined + } +} diff --git a/src/adapters/contracts/client/IClientRuntimeBridge.ts b/src/adapters/contracts/client/IClientRuntimeBridge.ts new file mode 100644 index 0000000..1258612 --- /dev/null +++ b/src/adapters/contracts/client/IClientRuntimeBridge.ts @@ -0,0 +1,46 @@ +export abstract class IClientRuntimeBridge { + abstract getCurrentResourceName(): string + abstract on(eventName: string, handler: (...args: any[]) => void | Promise): void + abstract registerCommand( + commandName: string, + handler: (...args: any[]) => void, + restricted: boolean, + ): void + abstract registerKeyMapping( + commandName: string, + description: string, + inputMapper: string, + key: string, + ): void + abstract setTick(handler: () => void | Promise): unknown + abstract clearTick(handle: unknown): void + abstract getGameTimer(): number + + registerWebViewCallback( + eventName: string, + handler: (data: any, cb: (response: unknown) => void) => void | Promise, + ): void { + this.registerNuiCallback(eventName, handler) + } + + sendWebViewMessage(message: string): void { + this.sendNuiMessage(message) + } + + setWebViewFocus(hasFocus: boolean, hasCursor: boolean): void { + this.setNuiFocus(hasFocus, hasCursor) + } + + setWebViewInputPassthrough(enabled: boolean): void { + this.setNuiFocusKeepInput(enabled) + } + + abstract registerNuiCallback( + eventName: string, + handler: (data: any, cb: (response: unknown) => void) => void | Promise, + ): void + abstract sendNuiMessage(message: string): void + abstract setNuiFocus(hasFocus: boolean, hasCursor: boolean): void + abstract setNuiFocusKeepInput(keepInput: boolean): void + abstract registerExport(exportName: string, handler: (...args: any[]) => any): void +} diff --git a/src/adapters/contracts/client/IGtaPedAppearanceBridge.ts b/src/adapters/contracts/client/IGtaPedAppearanceBridge.ts new file mode 100644 index 0000000..1bcbfa9 --- /dev/null +++ b/src/adapters/contracts/client/IGtaPedAppearanceBridge.ts @@ -0,0 +1,45 @@ +import { HeadBlendData } from '../../../kernel/shared/player-appearance.types' + +export abstract class IGtaPedAppearanceBridge { + abstract setComponentVariation( + ped: number, + componentId: number, + drawable: number, + texture: number, + palette: number, + ): void + abstract setPropIndex( + ped: number, + propId: number, + drawable: number, + texture: number, + attach: boolean, + ): void + abstract clearProp(ped: number, propId: number): void + abstract setDefaultComponentVariation(ped: number): void + abstract setHeadBlendData(ped: number, data: HeadBlendData): void + abstract setFaceFeature(ped: number, index: number, scale: number): void + abstract setHeadOverlay(ped: number, overlayId: number, index: number, opacity: number): void + abstract setHeadOverlayColor( + ped: number, + overlayId: number, + colorType: number, + colorId: number, + secondColorId: number, + ): void + abstract setHairColor(ped: number, colorId: number, highlightColorId: number): void + abstract setEyeColor(ped: number, index: number): void + abstract addDecoration(ped: number, collectionHash: number, overlayHash: number): void + abstract clearDecorations(ped: number): void + abstract getDrawableVariation(ped: number, componentId: number): number + abstract getTextureVariation(ped: number, componentId: number): number + abstract getPropIndex(ped: number, propId: number): number + abstract getPropTextureIndex(ped: number, propId: number): number + abstract getNumDrawableVariations(ped: number, componentId: number): number + abstract getNumTextureVariations(ped: number, componentId: number, drawable: number): number + abstract getNumPropDrawableVariations(ped: number, propId: number): number + abstract getNumPropTextureVariations(ped: number, propId: number, drawable: number): number + abstract getNumOverlayValues(overlayId: number): number + abstract getNumHairColors(): number + abstract getNumMakeupColors(): number +} diff --git a/src/adapters/contracts/client/IPedAppearanceClient.ts b/src/adapters/contracts/client/IPedAppearanceClient.ts deleted file mode 100644 index 8f160ff..0000000 --- a/src/adapters/contracts/client/IPedAppearanceClient.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { HeadBlendData } from '../../../kernel/shared/player-appearance.types' - -/** - * Client-side ped appearance operations adapter. - * - * @remarks - * Abstracts FiveM ped appearance natives for client-side operations. - * Allows the runtime to work without direct FiveM dependencies. - * - * Most appearance natives are client-only (headBlend, faceFeatures, overlays, tattoos). - * This adapter provides a unified interface for all appearance operations. - */ -export abstract class IPedAppearanceClient { - /** - * Sets a ped's component variation (clothing, hair, etc.). - * - * @param ped - Ped entity handle - * @param componentId - Component ID (0-11) - * @param drawable - Drawable index - * @param texture - Texture index - * @param palette - Palette ID (usually 2) - */ - abstract setComponentVariation( - ped: number, - componentId: number, - drawable: number, - texture: number, - palette: number, - ): void - - /** - * Sets a ped's prop (hat, glasses, etc.). - * - * @param ped - Ped entity handle - * @param propId - Prop ID (0-7) - * @param drawable - Drawable index - * @param texture - Texture index - * @param attach - Whether to attach the prop - */ - abstract setPropIndex( - ped: number, - propId: number, - drawable: number, - texture: number, - attach: boolean, - ): void - - /** - * Clears a ped's prop. - * - * @param ped - Ped entity handle - * @param propId - Prop ID to clear (0-7) - */ - abstract clearProp(ped: number, propId: number): void - - /** - * Sets the ped to default component variation. - * - * @param ped - Ped entity handle - */ - abstract setDefaultComponentVariation(ped: number): void - - /** - * Sets the ped's head blend data for facial structure. - * - * @remarks - * This must be called before setting face features, overlays, or overlay colors. - * - * @param ped - Ped entity handle - * @param data - Head blend configuration - */ - abstract setHeadBlendData(ped: number, data: HeadBlendData): void - - /** - * Sets a face feature morph value. - * - * @remarks - * SetPedHeadBlendData must be called before this. - * - * @param ped - Ped entity handle - * @param index - Feature index (0-19) - * @param scale - Scale value (-1.0 to 1.0) - */ - abstract setFaceFeature(ped: number, index: number, scale: number): void - - /** - * Sets a head overlay (makeup, facial hair, etc.). - * - * @remarks - * SetPedHeadBlendData must be called before this. - * - * @param ped - Ped entity handle - * @param overlayId - Overlay ID (0-12) - * @param index - Overlay variation index (255 to disable) - * @param opacity - Opacity (0.0-1.0) - */ - abstract setHeadOverlay(ped: number, overlayId: number, index: number, opacity: number): void - - /** - * Sets the color for a head overlay. - * - * @remarks - * Must be called after SetPedHeadOverlay. - * - * @param ped - Ped entity handle - * @param overlayId - Overlay ID (0-12) - * @param colorType - Color type (0: default, 1: hair, 2: makeup) - * @param colorId - Primary color ID - * @param secondColorId - Secondary color ID - */ - abstract setHeadOverlayColor( - ped: number, - overlayId: number, - colorType: number, - colorId: number, - secondColorId: number, - ): void - - /** - * Sets the ped's hair color. - * - * @param ped - Ped entity handle - * @param colorId - Primary hair color ID - * @param highlightColorId - Highlight color ID - */ - abstract setHairColor(ped: number, colorId: number, highlightColorId: number): void - - /** - * Sets the ped's eye color. - * - * @param ped - Ped entity handle - * @param index - Eye color index (0-31) - */ - abstract setEyeColor(ped: number, index: number): void - - /** - * Adds a decoration (tattoo) to the ped. - * - * @param ped - Ped entity handle - * @param collectionHash - Collection name hash - * @param overlayHash - Overlay name hash - */ - abstract addDecoration(ped: number, collectionHash: number, overlayHash: number): void - - /** - * Clears all decorations (tattoos) from the ped. - * - * @param ped - Ped entity handle - */ - abstract clearDecorations(ped: number): void - - /** - * Gets the drawable variation for a component. - * - * @param ped - Ped entity handle - * @param componentId - Component ID (0-11) - * @returns Drawable index - */ - abstract getDrawableVariation(ped: number, componentId: number): number - - /** - * Gets the texture variation for a component. - * - * @param ped - Ped entity handle - * @param componentId - Component ID (0-11) - * @returns Texture index - */ - abstract getTextureVariation(ped: number, componentId: number): number - - /** - * Gets the prop index for a prop slot. - * - * @param ped - Ped entity handle - * @param propId - Prop ID (0-7) - * @returns Prop drawable index (-1 if none) - */ - abstract getPropIndex(ped: number, propId: number): number - - /** - * Gets the prop texture index. - * - * @param ped - Ped entity handle - * @param propId - Prop ID (0-7) - * @returns Prop texture index - */ - abstract getPropTextureIndex(ped: number, propId: number): number - - /** - * Gets the number of drawable variations for a component. - * - * @param ped - Ped entity handle - * @param componentId - Component ID (0-11) - * @returns Number of available drawables - */ - abstract getNumDrawableVariations(ped: number, componentId: number): number - - /** - * Gets the number of texture variations for a component drawable. - * - * @param ped - Ped entity handle - * @param componentId - Component ID (0-11) - * @param drawable - Drawable index - * @returns Number of available textures - */ - abstract getNumTextureVariations(ped: number, componentId: number, drawable: number): number - - /** - * Gets the number of drawable variations for a prop. - * - * @param ped - Ped entity handle - * @param propId - Prop ID (0-7) - * @returns Number of available prop drawables - */ - abstract getNumPropDrawableVariations(ped: number, propId: number): number - - /** - * Gets the number of texture variations for a prop drawable. - * - * @param ped - Ped entity handle - * @param propId - Prop ID (0-7) - * @param drawable - Drawable index - * @returns Number of available textures - */ - abstract getNumPropTextureVariations(ped: number, propId: number, drawable: number): number - - /** - * Gets the number of overlay values for an overlay type. - * - * @param overlayId - Overlay ID (0-12) - * @returns Number of available overlay variations - */ - abstract getNumOverlayValues(overlayId: number): number - - /** - * Gets the number of hair colors available. - * - * @returns Number of hair colors - */ - abstract getNumHairColors(): number - - /** - * Gets the number of makeup colors available. - * - * @returns Number of makeup colors - */ - abstract getNumMakeupColors(): number -} diff --git a/src/adapters/contracts/client/index.ts b/src/adapters/contracts/client/index.ts new file mode 100644 index 0000000..9094d14 --- /dev/null +++ b/src/adapters/contracts/client/index.ts @@ -0,0 +1,7 @@ +export * from './IClientLogConsole' +export * from './IClientLocalPlayerBridge' +export * from './IClientPlatformBridge' +export * from './IClientRuntimeBridge' +export * from './IGtaPedAppearanceBridge' +export * from './spawn' +export * from './ui' diff --git a/src/adapters/contracts/client/spawn/IClientSpawnBridge.ts b/src/adapters/contracts/client/spawn/IClientSpawnBridge.ts new file mode 100644 index 0000000..bc5f6ac --- /dev/null +++ b/src/adapters/contracts/client/spawn/IClientSpawnBridge.ts @@ -0,0 +1,8 @@ +import type { RespawnRequest, SpawnRequest, TeleportRequest } from './types' + +export abstract class IClientSpawnBridge { + abstract waitUntilReady(timeoutMs?: number): Promise + abstract spawn(request: SpawnRequest): Promise + abstract respawn(request: RespawnRequest): Promise + abstract teleport(request: TeleportRequest): Promise +} diff --git a/src/adapters/contracts/client/spawn/index.ts b/src/adapters/contracts/client/spawn/index.ts new file mode 100644 index 0000000..0fb4764 --- /dev/null +++ b/src/adapters/contracts/client/spawn/index.ts @@ -0,0 +1,2 @@ +export * from './IClientSpawnBridge' +export * from './types' diff --git a/src/adapters/contracts/client/spawn/types.ts b/src/adapters/contracts/client/spawn/types.ts new file mode 100644 index 0000000..3853a0f --- /dev/null +++ b/src/adapters/contracts/client/spawn/types.ts @@ -0,0 +1,17 @@ +import type { Vector3 } from '../../../../kernel/utils/vector3' + +export interface SpawnRequest { + position: Vector3 + model: string + heading?: number +} + +export interface TeleportRequest { + position: Vector3 + heading?: number +} + +export interface RespawnRequest { + position: Vector3 + heading?: number +} diff --git a/src/adapters/contracts/client/ui/IClientBlipBridge.ts b/src/adapters/contracts/client/ui/IClientBlipBridge.ts new file mode 100644 index 0000000..6609e7c --- /dev/null +++ b/src/adapters/contracts/client/ui/IClientBlipBridge.ts @@ -0,0 +1,28 @@ +import type { Vector3 } from '../../../../kernel/utils/vector3' + +export interface ClientBlipOptions { + icon?: number + sprite?: number + color?: number + scale?: number + shortRange?: boolean + label?: string + alpha?: number + route?: boolean + routeColor?: number + visible?: boolean +} + +export interface ClientBlipDefinition extends ClientBlipOptions { + position?: Vector3 + entity?: number + radius?: number +} + +export abstract class IClientBlipBridge { + abstract create(id: string, definition: ClientBlipDefinition): void + abstract update(id: string, patch: Partial): boolean + abstract exists(id: string): boolean + abstract remove(id: string): boolean + abstract clear(): void +} diff --git a/src/adapters/contracts/client/ui/IClientMarkerBridge.ts b/src/adapters/contracts/client/ui/IClientMarkerBridge.ts new file mode 100644 index 0000000..2ca5b70 --- /dev/null +++ b/src/adapters/contracts/client/ui/IClientMarkerBridge.ts @@ -0,0 +1,29 @@ +import type { Vector3 } from '../../../../kernel/utils/vector3' + +export interface ClientMarkerOptions { + variant?: number + type?: number + size?: Vector3 + scale?: Vector3 + visible?: boolean + bob?: boolean + bobUpAndDown?: boolean + faceCamera?: boolean + rotate?: boolean + drawOnEnts?: boolean + color?: { r: number; g: number; b: number; a: number } +} + +export interface ClientMarkerDefinition extends ClientMarkerOptions { + position: Vector3 + rotation?: Vector3 +} + +export abstract class IClientMarkerBridge { + abstract create(id: string, definition: ClientMarkerDefinition): void + abstract update(id: string, patch: Partial): boolean + abstract remove(id: string): boolean + abstract exists(id: string): boolean + abstract clear(): void + abstract draw(definition: ClientMarkerDefinition): void +} diff --git a/src/adapters/contracts/client/ui/IClientNotificationBridge.ts b/src/adapters/contracts/client/ui/IClientNotificationBridge.ts new file mode 100644 index 0000000..67f4f1f --- /dev/null +++ b/src/adapters/contracts/client/ui/IClientNotificationBridge.ts @@ -0,0 +1,30 @@ +import type { Vector3 } from '../../../../kernel/utils/vector3' + +export type ClientNotificationKind = + | 'feed' + | 'typed' + | 'advanced' + | 'help' + | 'subtitle' + | 'floating' + +export interface ClientNotificationDefinition { + kind: ClientNotificationKind + message: string + title?: string + subtitle?: string + type?: 'info' | 'success' | 'warning' | 'error' + blink?: boolean + duration?: number + beep?: boolean + looped?: boolean + flash?: boolean + saveToBrief?: boolean + backgroundColor?: number + worldPosition?: Vector3 +} + +export abstract class IClientNotificationBridge { + abstract show(definition: ClientNotificationDefinition): void + abstract clear(scope?: 'help' | 'subtitle' | 'all'): void +} diff --git a/src/adapters/contracts/client/ui/index.ts b/src/adapters/contracts/client/ui/index.ts new file mode 100644 index 0000000..1b774fa --- /dev/null +++ b/src/adapters/contracts/client/ui/index.ts @@ -0,0 +1,4 @@ +export * from './IClientBlipBridge' +export * from './IClientMarkerBridge' +export * from './IClientNotificationBridge' +export * from './webview' diff --git a/src/adapters/contracts/client/ui/webview/IClientWebViewBridge.ts b/src/adapters/contracts/client/ui/webview/IClientWebViewBridge.ts new file mode 100644 index 0000000..0cb324d --- /dev/null +++ b/src/adapters/contracts/client/ui/webview/IClientWebViewBridge.ts @@ -0,0 +1,19 @@ +import type { + WebViewCapabilities, + WebViewDefinition, + WebViewFocusOptions, + WebViewMessage, +} from './types' + +export abstract class IClientWebViewBridge { + abstract getCapabilities(): WebViewCapabilities + abstract create(definition: WebViewDefinition): void + abstract destroy(viewId: string): void + abstract exists(viewId: string): boolean + abstract show(viewId: string): void + abstract hide(viewId: string): void + abstract focus(viewId: string, options?: WebViewFocusOptions): void + abstract blur(viewId: string): void + abstract send(viewId: string, event: string, payload: unknown): void + abstract onMessage(handler: (message: WebViewMessage) => void | Promise): () => void +} diff --git a/src/adapters/contracts/client/ui/webview/index.ts b/src/adapters/contracts/client/ui/webview/index.ts new file mode 100644 index 0000000..adcaaf0 --- /dev/null +++ b/src/adapters/contracts/client/ui/webview/index.ts @@ -0,0 +1,2 @@ +export * from './IClientWebViewBridge' +export * from './types' diff --git a/src/adapters/contracts/client/ui/webview/types.ts b/src/adapters/contracts/client/ui/webview/types.ts new file mode 100644 index 0000000..83c7e5d --- /dev/null +++ b/src/adapters/contracts/client/ui/webview/types.ts @@ -0,0 +1,28 @@ +export interface WebViewCapabilities { + supportsFocus: boolean + supportsCursor: boolean + supportsInputPassthrough: boolean + supportsBidirectionalMessaging: boolean + supportsExecute: boolean + supportsHeadless: boolean +} + +export interface WebViewDefinition { + id: string + url: string + visible?: boolean + focused?: boolean + cursor?: boolean + inputPassthrough?: boolean +} + +export interface WebViewFocusOptions { + cursor?: boolean + inputPassthrough?: boolean +} + +export interface WebViewMessage { + viewId: string + event: string + payload: unknown +} diff --git a/src/adapters/contracts/index.ts b/src/adapters/contracts/index.ts index 39894dc..6a2a2d2 100644 --- a/src/adapters/contracts/index.ts +++ b/src/adapters/contracts/index.ts @@ -1,3 +1,4 @@ -// Transport API -export * from './transport/events.api' -export * from './transport/rpc.api' +export * from './IHasher' +export * from './types' +export * from './transport' +export * from './runtime' diff --git a/src/adapters/contracts/runtime/index.ts b/src/adapters/contracts/runtime/index.ts new file mode 100644 index 0000000..36ea710 --- /dev/null +++ b/src/adapters/contracts/runtime/index.ts @@ -0,0 +1 @@ +export * from './runtime-events' diff --git a/src/adapters/contracts/runtime/runtime-events.ts b/src/adapters/contracts/runtime/runtime-events.ts new file mode 100644 index 0000000..ffc185b --- /dev/null +++ b/src/adapters/contracts/runtime/runtime-events.ts @@ -0,0 +1,14 @@ +export const RUNTIME_EVENTS = { + playerJoining: 'playerJoining', + playerDropped: 'playerDropped', + serverResourceStop: 'onServerResourceStop', + playerCommand: 'playerCommand', +} as const + +export type RuntimeEventName = (typeof RUNTIME_EVENTS)[keyof typeof RUNTIME_EVENTS] + +export type RuntimeEventMap = Record + +export const DEFAULT_RUNTIME_EVENT_MAP: RuntimeEventMap = Object.fromEntries( + Object.values(RUNTIME_EVENTS).map((e) => [e, e]), +) as RuntimeEventMap diff --git a/src/adapters/contracts/server/IEntityServer.ts b/src/adapters/contracts/server/IEntityServer.ts index 8bfdac0..1622ae2 100644 --- a/src/adapters/contracts/server/IEntityServer.ts +++ b/src/adapters/contracts/server/IEntityServer.ts @@ -120,27 +120,25 @@ export abstract class IEntityServer { * Sets entity routing bucket (virtual world/dimension). * * @remarks - * Not all platforms support routing buckets. - * Use IPlatformCapabilities.supportsRoutingBuckets to check support. + * Platform-specific behavior may vary. * * @param handle - Entity handle * @param bucket - Routing bucket ID */ - abstract setRoutingBucket(handle: number, bucket: number): void + abstract setDimension(handle: number, bucket: number): void /** * Gets entity routing bucket (virtual world/dimension). * @param handle - Entity handle * @returns Routing bucket ID (0 is default world) */ - abstract getRoutingBucket(handle: number): number + abstract getDimension(handle: number): number /** * Gets the state bag interface for an entity. * * @remarks - * Not all platforms support state bags. - * Use IPlatformCapabilities.supportsStateBags to check support. + * Platform-specific behavior may vary. * * @param handle - Entity handle */ @@ -173,34 +171,6 @@ export abstract class IEntityServer { * @param armor - Armor value */ abstract setArmor(handle: number, armor: number): void - - /** - * Gets entity dimension (alias for getRoutingBucket). - * - * @remarks - * This is a cross-platform alias. Some platforms call it "dimension", - * others call it "routing bucket" or "virtual world". - * - * @param handle - Entity handle - * @returns Dimension ID - */ - getDimension(handle: number): number { - return this.getRoutingBucket(handle) - } - - /** - * Sets entity dimension (alias for setRoutingBucket). - * - * @remarks - * This is a cross-platform alias. Some platforms call it "dimension", - * others call it "routing bucket" or "virtual world". - * - * @param handle - Entity handle - * @param dimension - Dimension ID - */ - setDimension(handle: number, dimension: number): void { - this.setRoutingBucket(handle, dimension) - } } /** diff --git a/src/adapters/contracts/server/IPlayerServer.ts b/src/adapters/contracts/server/IPlayerServer.ts index 29f6d99..b9ed4c1 100644 --- a/src/adapters/contracts/server/IPlayerServer.ts +++ b/src/adapters/contracts/server/IPlayerServer.ts @@ -24,6 +24,11 @@ export abstract class IPlayerServer { */ abstract drop(playerSrc: string, reason: string): void + /** + * Sets the active player model when the runtime supports it. + */ + abstract setModel(playerSrc: string, model: string): void + /** * Gets a player identifier by type. * @@ -31,7 +36,7 @@ export abstract class IPlayerServer { * @param identifierType - Identifier type (e.g., 'license', 'steam', 'discord') * @returns Identifier string or undefined */ - abstract getIdentifier(playerSrc: string, identifierType: string): string | undefined + abstract getIdentifier(playerSrc: string, identifierType: string): string | undefined // TODO: DELETE /** * Gets all identifiers for a player as structured objects. @@ -39,7 +44,7 @@ export abstract class IPlayerServer { * @param playerSrc - Player source/client ID (as string) * @returns Array of PlayerIdentifier objects */ - abstract getPlayerIdentifiers(playerSrc: string): PlayerIdentifier[] + abstract getPlayerIdentifiers(playerSrc: string): PlayerIdentifier[] // TODO: DELETE /** * Gets the number of player identifiers. @@ -47,7 +52,7 @@ export abstract class IPlayerServer { * @param playerSrc - Player source/client ID (as string) * @returns Number of identifiers */ - abstract getNumIdentifiers(playerSrc: string): number + abstract getNumIdentifiers(playerSrc: string): number // TODO: DELETE /** * Gets player display name. @@ -77,13 +82,12 @@ export abstract class IPlayerServer { * Sets player routing bucket (virtual world/dimension). * * @remarks - * Not all platforms support routing buckets. - * Use IPlatformCapabilities.supportsRoutingBuckets to check support. + * Platform-specific behavior may vary. * * @param playerSrc - Player source/client ID (as string) * @param bucket - Routing bucket ID */ - abstract setRoutingBucket(playerSrc: string, bucket: number): void + abstract setDimension(playerSrc: string, bucket: number): void /** * Gets player routing bucket (virtual world/dimension). @@ -91,7 +95,7 @@ export abstract class IPlayerServer { * @param playerSrc - Player source/client ID (as string) * @returns Routing bucket ID (0 is default world) */ - abstract getRoutingBucket(playerSrc: string): number + abstract getDimension(playerSrc: string): number /** * Gets all currently connected player sources. @@ -103,32 +107,4 @@ export abstract class IPlayerServer { * @returns Array of player source strings */ abstract getConnectedPlayers(): string[] - - /** - * Gets player dimension (alias for getRoutingBucket). - * - * @remarks - * Cross-platform alias. Some platforms call it "dimension", - * others call it "routing bucket" or "virtual world". - * - * @param playerSrc - Player source/client ID (as string) - * @returns Dimension ID - */ - getDimension(playerSrc: string): number { - return this.getRoutingBucket(playerSrc) - } - - /** - * Sets player dimension (alias for setRoutingBucket). - * - * @remarks - * Cross-platform alias. Some platforms call it "dimension", - * others call it "routing bucket" or "virtual world". - * - * @param playerSrc - Player source/client ID (as string) - * @param dimension - Dimension ID - */ - setDimension(playerSrc: string, dimension: number): void { - this.setRoutingBucket(playerSrc, dimension) - } } diff --git a/src/adapters/contracts/server/index.ts b/src/adapters/contracts/server/index.ts new file mode 100644 index 0000000..427b78e --- /dev/null +++ b/src/adapters/contracts/server/index.ts @@ -0,0 +1,10 @@ +export * from './IEntityServer' +export * from './npc-lifecycle' +export * from './IPedAppearanceServer' +export * from './IPedServer' +export * from './player-appearance' +export * from './player-state' +export * from './IPlayerServer' +export * from './IVehicleServer' +export * from './player-lifecycle' +export * from './vehicle-lifecycle' diff --git a/src/adapters/contracts/server/npc-lifecycle/INpcLifecycleServer.ts b/src/adapters/contracts/server/npc-lifecycle/INpcLifecycleServer.ts new file mode 100644 index 0000000..ba51e10 --- /dev/null +++ b/src/adapters/contracts/server/npc-lifecycle/INpcLifecycleServer.ts @@ -0,0 +1,8 @@ +import type { CreateNpcServerRequest, CreateNpcServerResult, DeleteNpcServerRequest } from './types' + +export abstract class INpcLifecycleServer { + abstract create( + request: CreateNpcServerRequest, + ): Promise | CreateNpcServerResult + abstract delete(request: DeleteNpcServerRequest): Promise | void +} diff --git a/src/adapters/contracts/server/npc-lifecycle/index.ts b/src/adapters/contracts/server/npc-lifecycle/index.ts new file mode 100644 index 0000000..41de24e --- /dev/null +++ b/src/adapters/contracts/server/npc-lifecycle/index.ts @@ -0,0 +1,2 @@ +export * from './INpcLifecycleServer' +export * from './types' diff --git a/src/adapters/contracts/server/npc-lifecycle/types.ts b/src/adapters/contracts/server/npc-lifecycle/types.ts new file mode 100644 index 0000000..6085337 --- /dev/null +++ b/src/adapters/contracts/server/npc-lifecycle/types.ts @@ -0,0 +1,20 @@ +import type { Vector3 } from '../../../../kernel/utils/vector3' + +export interface CreateNpcServerRequest { + model: string + modelHash: number + position: Vector3 + heading: number + networked: boolean + routingBucket?: number + persistent?: boolean +} + +export interface CreateNpcServerResult { + handle: number + netId?: number +} + +export interface DeleteNpcServerRequest { + handle: number +} diff --git a/src/adapters/contracts/server/player-appearance/IPlayerAppearanceLifecycleServer.ts b/src/adapters/contracts/server/player-appearance/IPlayerAppearanceLifecycleServer.ts new file mode 100644 index 0000000..9f8db3e --- /dev/null +++ b/src/adapters/contracts/server/player-appearance/IPlayerAppearanceLifecycleServer.ts @@ -0,0 +1,21 @@ +import type { PlayerAppearance } from '../../../../kernel/shared' + +export interface ApplyPlayerAppearanceResult { + success: boolean + appearance?: PlayerAppearance + errors?: string[] +} + +export abstract class IPlayerAppearanceLifecycleServer { + abstract apply( + playerSrc: string, + appearance: PlayerAppearance, + ): Promise | ApplyPlayerAppearanceResult + + abstract applyClothing( + playerSrc: string, + appearance: Pick, + ): Promise | boolean + + abstract reset(playerSrc: string): Promise | boolean +} diff --git a/src/adapters/contracts/server/player-appearance/index.ts b/src/adapters/contracts/server/player-appearance/index.ts new file mode 100644 index 0000000..e9755d9 --- /dev/null +++ b/src/adapters/contracts/server/player-appearance/index.ts @@ -0,0 +1 @@ +export * from './IPlayerAppearanceLifecycleServer' diff --git a/src/adapters/contracts/server/player-lifecycle/IPlayerLifecycleServer.ts b/src/adapters/contracts/server/player-lifecycle/IPlayerLifecycleServer.ts new file mode 100644 index 0000000..e232b83 --- /dev/null +++ b/src/adapters/contracts/server/player-lifecycle/IPlayerLifecycleServer.ts @@ -0,0 +1,7 @@ +import type { RespawnPlayerRequest, SpawnPlayerRequest, TeleportPlayerRequest } from './types' + +export abstract class IPlayerLifecycleServer { + abstract spawn(playerSrc: string, request: SpawnPlayerRequest): Promise | void + abstract teleport(playerSrc: string, request: TeleportPlayerRequest): Promise | void + abstract respawn(playerSrc: string, request: RespawnPlayerRequest): Promise | void +} diff --git a/src/adapters/contracts/server/player-lifecycle/index.ts b/src/adapters/contracts/server/player-lifecycle/index.ts new file mode 100644 index 0000000..825aed2 --- /dev/null +++ b/src/adapters/contracts/server/player-lifecycle/index.ts @@ -0,0 +1,2 @@ +export * from './IPlayerLifecycleServer' +export * from './types' diff --git a/src/adapters/contracts/server/player-lifecycle/types.ts b/src/adapters/contracts/server/player-lifecycle/types.ts new file mode 100644 index 0000000..57b9d1a --- /dev/null +++ b/src/adapters/contracts/server/player-lifecycle/types.ts @@ -0,0 +1,20 @@ +import type { PlayerAppearance } from '../../../../kernel' +import type { Vector3 } from '../../../../kernel/utils/vector3' + +export interface SpawnPlayerRequest { + position: Vector3 + model?: string + heading?: number + appearance?: PlayerAppearance +} + +export interface TeleportPlayerRequest { + position: Vector3 + heading?: number +} + +export interface RespawnPlayerRequest { + position: Vector3 + heading?: number + model?: string +} diff --git a/src/adapters/contracts/server/player-state/IPlayerStateSyncServer.ts b/src/adapters/contracts/server/player-state/IPlayerStateSyncServer.ts new file mode 100644 index 0000000..947926f --- /dev/null +++ b/src/adapters/contracts/server/player-state/IPlayerStateSyncServer.ts @@ -0,0 +1,6 @@ +export abstract class IPlayerStateSyncServer { + abstract getHealth(playerSrc: string): number + abstract setHealth(playerSrc: string, health: number): void + abstract getArmor(playerSrc: string): number + abstract setArmor(playerSrc: string, armor: number): void +} diff --git a/src/adapters/contracts/server/player-state/index.ts b/src/adapters/contracts/server/player-state/index.ts new file mode 100644 index 0000000..c9d4327 --- /dev/null +++ b/src/adapters/contracts/server/player-state/index.ts @@ -0,0 +1 @@ +export * from './IPlayerStateSyncServer' diff --git a/src/adapters/contracts/server/vehicle-lifecycle/IVehicleLifecycleServer.ts b/src/adapters/contracts/server/vehicle-lifecycle/IVehicleLifecycleServer.ts new file mode 100644 index 0000000..bc220e7 --- /dev/null +++ b/src/adapters/contracts/server/vehicle-lifecycle/IVehicleLifecycleServer.ts @@ -0,0 +1,12 @@ +import type { + CreateVehicleServerRequest, + CreateVehicleServerResult, + WarpPlayerIntoVehicleRequest, +} from './types' + +export abstract class IVehicleLifecycleServer { + abstract create( + request: CreateVehicleServerRequest, + ): Promise | CreateVehicleServerResult + abstract warpPlayerIntoVehicle(request: WarpPlayerIntoVehicleRequest): Promise | void +} diff --git a/src/adapters/contracts/server/vehicle-lifecycle/index.ts b/src/adapters/contracts/server/vehicle-lifecycle/index.ts new file mode 100644 index 0000000..a1b36fd --- /dev/null +++ b/src/adapters/contracts/server/vehicle-lifecycle/index.ts @@ -0,0 +1,2 @@ +export * from './IVehicleLifecycleServer' +export * from './types' diff --git a/src/adapters/contracts/server/vehicle-lifecycle/types.ts b/src/adapters/contracts/server/vehicle-lifecycle/types.ts new file mode 100644 index 0000000..7e68b53 --- /dev/null +++ b/src/adapters/contracts/server/vehicle-lifecycle/types.ts @@ -0,0 +1,19 @@ +import type { Vector3 } from '../../../../kernel/utils/vector3' + +export interface CreateVehicleServerRequest { + model: string + modelHash: number + position: Vector3 + heading: number +} + +export interface CreateVehicleServerResult { + handle: number + networkId: number +} + +export interface WarpPlayerIntoVehicleRequest { + playerSrc: string + networkId: number + seatIndex: number +} diff --git a/src/adapters/contracts/transport/index.ts b/src/adapters/contracts/transport/index.ts new file mode 100644 index 0000000..12edf45 --- /dev/null +++ b/src/adapters/contracts/transport/index.ts @@ -0,0 +1,4 @@ +export * from './context' +export * from './events.api' +export * from './rpc.api' +export * from './messaging.transport' diff --git a/src/adapters/fivem/fivem-capabilities.ts b/src/adapters/fivem/fivem-capabilities.ts deleted file mode 100644 index 9a50b4c..0000000 --- a/src/adapters/fivem/fivem-capabilities.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { injectable } from 'tsyringe' -import { IPlatformCapabilities, PlatformFeatures } from '../contracts/IPlatformCapabilities' -import { IdentifierTypes } from '../contracts/types/identifier' - -/** - * FiveM platform capabilities implementation. - */ -@injectable() -export class FiveMCapabilities extends IPlatformCapabilities { - readonly platformName = 'fivem' - readonly displayName = 'FiveM' - - readonly supportsRoutingBuckets = true - readonly supportsStateBags = true - readonly supportsVoiceChat = true - readonly supportsServerEntities = true - - readonly identifierTypes = [ - IdentifierTypes.STEAM, - IdentifierTypes.LICENSE, - IdentifierTypes.LICENSE2, - IdentifierTypes.DISCORD, - IdentifierTypes.FIVEM, - IdentifierTypes.XBL, - IdentifierTypes.LIVE, - IdentifierTypes.IP, - ] as const - - readonly maxPlayers = 1024 - - private readonly supportedFeatures = new Set([ - PlatformFeatures.ROUTING_BUCKETS, - PlatformFeatures.STATE_BAGS, - PlatformFeatures.VOICE_CHAT, - PlatformFeatures.SERVER_ENTITIES, - PlatformFeatures.VEHICLE_MODS, - PlatformFeatures.PED_APPEARANCE, - PlatformFeatures.WEAPON_COMPONENTS, - PlatformFeatures.BLIPS, - PlatformFeatures.MARKERS, - PlatformFeatures.TEXT_LABELS, - PlatformFeatures.CHECKPOINTS, - PlatformFeatures.COLSHAPES, - ]) - - private readonly config: Record = { - defaultRoutingBucket: 0, - maxRoutingBuckets: 63, - tickRate: 64, - syncRate: 10, - } - - isFeatureSupported(feature: string): boolean { - return this.supportedFeatures.has(feature) - } - - getConfig(key: string): T | undefined { - return this.config[key] as T | undefined - } -} diff --git a/src/adapters/fivem/fivem-engine-events.ts b/src/adapters/fivem/fivem-engine-events.ts deleted file mode 100644 index 715288e..0000000 --- a/src/adapters/fivem/fivem-engine-events.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { IEngineEvents } from '../contracts/IEngineEvents' - -export class FiveMEngineEvents extends IEngineEvents { - on(eventName: string, handler?: (...args: any[]) => void): void { - if (!handler) return - - on(eventName, (...args: any[]) => { - if (eventName === 'playerJoining') { - const clientId = Number(source) - const license = GetPlayerIdentifier(clientId.toString(), 0) ?? undefined - handler(clientId, { license }) - return - } - - if (eventName === 'playerDropped') { - const clientId = Number(source) - handler(clientId) - return - } - - handler(...args) - }) - } - - emit(eventName: string, ...args: any[]): void { - emit(eventName, ...args) - } -} diff --git a/src/adapters/fivem/fivem-entity-server.ts b/src/adapters/fivem/fivem-entity-server.ts deleted file mode 100644 index bdb3569..0000000 --- a/src/adapters/fivem/fivem-entity-server.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { injectable } from 'tsyringe' -import { Vector3 } from '../../kernel/utils/vector3' -import { - type EntityStateBag, - IEntityServer, - type SetPositionOptions, -} from '../contracts/server/IEntityServer' - -/** - * FiveM implementation of server-side entity operations. - */ -@injectable() -export class FiveMEntityServer extends IEntityServer { - doesExist(handle: number): boolean { - return DoesEntityExist(handle) - } - - getCoords(handle: number): Vector3 { - const coords = GetEntityCoords(handle) - return { x: coords[0], y: coords[1], z: coords[2] } - } - - setPosition(handle: number, position: Vector3, options?: SetPositionOptions): void { - const keepAlive = options?.keepAlive ?? false - const clearArea = options?.clearArea ?? true - const platformOpts = options?.platformOptions ?? {} - - // Extract FiveM-specific flags from platformOptions - const deadFlag = (platformOpts.deadFlag as boolean) ?? false - const ragdollFlag = (platformOpts.ragdollFlag as boolean) ?? false - - SetEntityCoords( - handle, - position.x, - position.y, - position.z, - keepAlive, - deadFlag, - ragdollFlag, - clearArea, - ) - } - - /** - * @deprecated Use setPosition() for cross-platform compatibility. - */ - setCoords( - handle: number, - x: number, - y: number, - z: number, - alive = false, - deadFlag = false, - ragdollFlag = false, - clearArea = true, - ): void { - SetEntityCoords(handle, x, y, z, alive, deadFlag, ragdollFlag, clearArea) - } - - getHeading(handle: number): number { - return GetEntityHeading(handle) - } - - setHeading(handle: number, heading: number): void { - SetEntityHeading(handle, heading) - } - - getModel(handle: number): number { - return GetEntityModel(handle) - } - - delete(handle: number): void { - DeleteEntity(handle) - } - - setOrphanMode(handle: number, mode: number): void { - SetEntityOrphanMode(handle, mode) - } - - setRoutingBucket(handle: number, bucket: number): void { - SetEntityRoutingBucket(handle, bucket) - } - - getRoutingBucket(handle: number): number { - return GetEntityRoutingBucket(handle) - } - - getStateBag(handle: number): EntityStateBag { - const stateBag = Entity(handle).state - return { - set: (key: string, value: unknown, replicated = true) => { - stateBag.set(key, value, replicated) - }, - get: (key: string) => { - return stateBag[key] - }, - } - } - - getHealth(handle: number): number { - const stateBag = Entity(handle).state - return (stateBag.health as number) ?? 200 - } - - setHealth(handle: number, health: number): void { - const stateBag = Entity(handle).state - stateBag.set('health', health, true) - } - - getArmor(handle: number): number { - const stateBag = Entity(handle).state - return (stateBag.armor as number) ?? 0 - } - - setArmor(handle: number, armor: number): void { - const stateBag = Entity(handle).state - stateBag.set('armor', armor, true) - } -} diff --git a/src/adapters/fivem/fivem-exports.ts b/src/adapters/fivem/fivem-exports.ts deleted file mode 100644 index 1619818..0000000 --- a/src/adapters/fivem/fivem-exports.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { IExports } from '../contracts/IExports' - -export class FiveMExports extends IExports { - register(exportName: string, handler: (...args: any[]) => any): void { - exports(exportName, handler) - } - - getResource(resourceName: string): T | undefined { - return (globalThis as any).exports?.[resourceName] as T | undefined - } -} diff --git a/src/adapters/fivem/fivem-hasher.ts b/src/adapters/fivem/fivem-hasher.ts deleted file mode 100644 index c3554fa..0000000 --- a/src/adapters/fivem/fivem-hasher.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { injectable } from 'tsyringe' -import { IHasher } from '../contracts/IHasher' - -/** - * FiveM implementation of hash utilities. - */ -@injectable() -export class FiveMHasher extends IHasher { - getHashKey(str: string): number { - return GetHashKey(str) - } -} diff --git a/src/adapters/fivem/fivem-net-transport.ts b/src/adapters/fivem/fivem-net-transport.ts deleted file mode 100644 index 336ce12..0000000 --- a/src/adapters/fivem/fivem-net-transport.ts +++ /dev/null @@ -1 +0,0 @@ -export {} diff --git a/src/adapters/fivem/fivem-ped-appearance-client.ts b/src/adapters/fivem/fivem-ped-appearance-client.ts deleted file mode 100644 index 662c765..0000000 --- a/src/adapters/fivem/fivem-ped-appearance-client.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { HeadBlendData } from '../../kernel/shared' -import { IPedAppearanceClient } from '../contracts/client/IPedAppearanceClient' - -/** - * FiveM implementation of client-side ped appearance adapter. - * - * @remarks - * Wraps FiveM natives for ped appearance manipulation. - * All natives are client-side only. - */ -export class FiveMPedAppearanceClientAdapter extends IPedAppearanceClient { - setComponentVariation( - ped: number, - componentId: number, - drawable: number, - texture: number, - palette: number, - ): void { - SetPedComponentVariation(ped, componentId, drawable, texture, palette) - } - - setPropIndex( - ped: number, - propId: number, - drawable: number, - texture: number, - attach: boolean, - ): void { - SetPedPropIndex(ped, propId, drawable, texture, attach) - } - - clearProp(ped: number, propId: number): void { - ClearPedProp(ped, propId) - } - - setDefaultComponentVariation(ped: number): void { - SetPedDefaultComponentVariation(ped) - } - - setHeadBlendData(ped: number, data: HeadBlendData): void { - SetPedHeadBlendData( - ped, - data.shapeFirst, - data.shapeSecond, - data.shapeThird ?? 0, - data.skinFirst, - data.skinSecond, - data.skinThird ?? 0, - data.shapeMix, - data.skinMix, - data.thirdMix ?? 0, - false, - ) - } - - setFaceFeature(ped: number, index: number, scale: number): void { - SetPedFaceFeature(ped, index, scale) - } - - setHeadOverlay(ped: number, overlayId: number, index: number, opacity: number): void { - SetPedHeadOverlay(ped, overlayId, index, opacity) - } - - setHeadOverlayColor( - ped: number, - overlayId: number, - colorType: number, - colorId: number, - secondColorId: number, - ): void { - SetPedHeadOverlayColor(ped, overlayId, colorType, colorId, secondColorId) - } - - setHairColor(ped: number, colorId: number, highlightColorId: number): void { - SetPedHairColor(ped, colorId, highlightColorId) - } - - setEyeColor(ped: number, index: number): void { - SetPedEyeColor(ped, index) - } - - addDecoration(ped: number, collectionHash: number, overlayHash: number): void { - AddPedDecorationFromHashes(ped, collectionHash, overlayHash) - } - - clearDecorations(ped: number): void { - ClearPedDecorations(ped) - } - - getDrawableVariation(ped: number, componentId: number): number { - return GetPedDrawableVariation(ped, componentId) - } - - getTextureVariation(ped: number, componentId: number): number { - return GetPedTextureVariation(ped, componentId) - } - - getPropIndex(ped: number, propId: number): number { - return GetPedPropIndex(ped, propId) - } - - getPropTextureIndex(ped: number, propId: number): number { - return GetPedPropTextureIndex(ped, propId) - } - - getNumDrawableVariations(ped: number, componentId: number): number { - return GetNumberOfPedDrawableVariations(ped, componentId) - } - - getNumTextureVariations(ped: number, componentId: number, drawable: number): number { - return GetNumberOfPedTextureVariations(ped, componentId, drawable) - } - - getNumPropDrawableVariations(ped: number, propId: number): number { - return GetNumberOfPedPropDrawableVariations(ped, propId) - } - - getNumPropTextureVariations(ped: number, propId: number, drawable: number): number { - return GetNumberOfPedPropTextureVariations(ped, propId, drawable) - } - - getNumOverlayValues(overlayId: number): number { - return GetNumHeadOverlayValues(overlayId) - } - - getNumHairColors(): number { - return GetNumHairColors() - } - - getNumMakeupColors(): number { - return GetNumMakeupColors() - } -} diff --git a/src/adapters/fivem/fivem-ped-appearance-server.ts b/src/adapters/fivem/fivem-ped-appearance-server.ts deleted file mode 100644 index 3ee854b..0000000 --- a/src/adapters/fivem/fivem-ped-appearance-server.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { IPedAppearanceServer } from '../contracts/server/IPedAppearanceServer' - -/** - * FiveM implementation of server-side ped appearance adapter. - * - * @remarks - * Wraps FiveM natives for ped appearance manipulation on server-side. - * Server-side has limited appearance control - only components and props. - */ -export class FiveMPedAppearanceServerAdapter extends IPedAppearanceServer { - setComponentVariation( - ped: number, - componentId: number, - drawable: number, - texture: number, - palette: number, - ): void { - SetPedComponentVariation(ped, componentId, drawable, texture, palette) - } - - setPropIndex( - ped: number, - propId: number, - drawable: number, - texture: number, - attach: boolean, - ): void { - SetPedPropIndex(ped, propId, drawable, texture, attach) - } - - clearProp(ped: number, propId: number): void { - ClearPedProp(ped, propId) - } - - setDefaultComponentVariation(ped: number): void { - SetPedDefaultComponentVariation(ped) - } -} diff --git a/src/adapters/fivem/fivem-ped-server.ts b/src/adapters/fivem/fivem-ped-server.ts deleted file mode 100644 index 3dcd398..0000000 --- a/src/adapters/fivem/fivem-ped-server.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { injectable } from 'tsyringe' -import { IPedServer } from '../contracts/server/IPedServer' - -/** FiveM implementation of server-side ped operations. */ -@injectable() -export class FiveMPedServer extends IPedServer { - create( - pedType: number, - modelHash: number, - x: number, - y: number, - z: number, - heading: number, - networked: boolean, - ): number { - return CreatePed(pedType, modelHash, x, y, z, heading, networked, true) - } - - delete(handle: number): void { - DeleteEntity(handle) - } - - getNetworkIdFromEntity(handle: number): number { - return NetworkGetNetworkIdFromEntity(handle) - } - - getEntityFromNetworkId(networkId: number): number { - return NetworkGetEntityFromNetworkId(networkId) - } - - networkIdExists(networkId: number): boolean { - return NetworkDoesEntityExistWithNetworkId(networkId) - } -} diff --git a/src/adapters/fivem/fivem-platform.ts b/src/adapters/fivem/fivem-platform.ts deleted file mode 100644 index 998042f..0000000 --- a/src/adapters/fivem/fivem-platform.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { DependencyContainer } from 'tsyringe' -import { IEngineEvents } from '../contracts/IEngineEvents' -import { IExports } from '../contracts/IExports' -import { IHasher } from '../contracts/IHasher' -import { IPlatformCapabilities } from '../contracts/IPlatformCapabilities' -import { IPlayerInfo } from '../contracts/IPlayerInfo' -import { IResourceInfo } from '../contracts/IResourceInfo' -import { ITick } from '../contracts/ITick' -import { IEntityServer } from '../contracts/server/IEntityServer' -import { IPedServer } from '../contracts/server/IPedServer' -import { IPedAppearanceServer } from '../contracts/server/IPedAppearanceServer' -import { IPlayerServer } from '../contracts/server/IPlayerServer' -import { IVehicleServer } from '../contracts/server/IVehicleServer' -import { EventsAPI } from '../contracts/transport/events.api' -import { MessagingTransport } from '../contracts/transport/messaging.transport' -import { RpcAPI } from '../contracts/transport/rpc.api' -import type { PlatformAdapter } from '../platform/platform-registry' - -/** - * FiveM platform adapter for automatic registration. - */ -export const FiveMPlatform: PlatformAdapter = { - name: 'fivem', - priority: 100, // High priority - check FiveM first - - detect(): boolean { - return typeof (globalThis as any).GetCurrentResourceName === 'function' - }, - - async register(container: DependencyContainer): Promise { - // Dynamically import FiveM implementations - const [ - { FiveMMessagingTransport }, - { FiveMEngineEvents }, - { FiveMExports }, - { FiveMResourceInfo }, - { FiveMTick }, - { FiveMPlayerInfo }, - { FiveMEntityServer }, - { FiveMPedServer }, - { FiveMVehicleServer }, - { FiveMPlayerServer }, - { FiveMHasher }, - { FiveMPedAppearanceServerAdapter }, - { FiveMCapabilities }, - ] = await Promise.all([ - import('./transport/adapter'), - import('./fivem-engine-events'), - import('./fivem-exports'), - import('./fivem-resourceinfo'), - import('./fivem-tick'), - import('./fivem-playerinfo'), - import('./fivem-entity-server'), - import('./fivem-ped-server'), - import('./fivem-vehicle-server'), - import('./fivem-player-server'), - import('./fivem-hasher'), - import('./fivem-ped-appearance-server'), - import('./fivem-capabilities'), - ]) - - // Register all FiveM implementations - if (!container.isRegistered(IPlatformCapabilities as any)) - container.registerSingleton(IPlatformCapabilities as any, FiveMCapabilities) - - if (!container.isRegistered(MessagingTransport as any)) { - const transport = new FiveMMessagingTransport() - container.registerInstance(MessagingTransport as any, transport) - container.registerInstance(EventsAPI as any, transport.events) - container.registerInstance(RpcAPI as any, transport.rpc) - } - if (!container.isRegistered(IEngineEvents as any)) - container.registerSingleton(IEngineEvents as any, FiveMEngineEvents) - if (!container.isRegistered(IExports as any)) - container.registerSingleton(IExports as any, FiveMExports) - if (!container.isRegistered(IResourceInfo as any)) - container.registerSingleton(IResourceInfo as any, FiveMResourceInfo) - if (!container.isRegistered(ITick as any)) container.registerSingleton(ITick as any, FiveMTick) - if (!container.isRegistered(IPlayerInfo as any)) - container.registerSingleton(IPlayerInfo as any, FiveMPlayerInfo) - if (!container.isRegistered(IEntityServer as any)) - container.registerSingleton(IEntityServer as any, FiveMEntityServer) - if (!container.isRegistered(IPedServer as any)) - container.registerSingleton(IPedServer as any, FiveMPedServer) - if (!container.isRegistered(IVehicleServer as any)) - container.registerSingleton(IVehicleServer as any, FiveMVehicleServer) - if (!container.isRegistered(IPlayerServer as any)) - container.registerSingleton(IPlayerServer as any, FiveMPlayerServer) - if (!container.isRegistered(IHasher as any)) - container.registerSingleton(IHasher as any, FiveMHasher) - if (!container.isRegistered(IPedAppearanceServer as any)) - container.registerSingleton(IPedAppearanceServer as any, FiveMPedAppearanceServerAdapter) - }, -} diff --git a/src/adapters/fivem/fivem-player-server.ts b/src/adapters/fivem/fivem-player-server.ts deleted file mode 100644 index 6ff4c86..0000000 --- a/src/adapters/fivem/fivem-player-server.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { injectable } from 'tsyringe' -import { IPlayerServer } from '../contracts/server/IPlayerServer' -import { type PlayerIdentifier, parseIdentifier } from '../contracts/types/identifier' - -/** - * FiveM implementation of server-side player operations. - */ -@injectable() -export class FiveMPlayerServer extends IPlayerServer { - getPed(playerSrc: string): number { - return GetPlayerPed(playerSrc) - } - - drop(playerSrc: string, reason: string): void { - DropPlayer(playerSrc, reason) - } - - getIdentifier(playerSrc: string, identifierType: string): string | undefined { - const numIdentifiers = this.getNumIdentifiers(playerSrc) - const prefix = `${identifierType}:` - - for (let i = 0; i < numIdentifiers; i++) { - const identifier = GetPlayerIdentifier(playerSrc, i) - if (identifier?.startsWith(prefix)) { - return identifier - } - } - - return undefined - } - - /** - * Get all identifiers registered. - * - * Use getPlayerIdentifiers() for structured identifier data. - */ - getIdentifiers(playerSrc: string): string[] { - const identifiers: string[] = [] - const numIdentifiers = this.getNumIdentifiers(playerSrc) - - for (let i = 0; i < numIdentifiers; i++) { - const identifier = GetPlayerIdentifier(playerSrc, i) - if (identifier) { - identifiers.push(identifier) - } - } - - return identifiers - } - - getPlayerIdentifiers(playerSrc: string): PlayerIdentifier[] { - const rawIdentifiers = this.getIdentifiers(playerSrc) - const identifiers: PlayerIdentifier[] = [] - - for (const raw of rawIdentifiers) { - const parsed = parseIdentifier(raw) - if (parsed) { - identifiers.push(parsed) - } - } - - return identifiers - } - - getNumIdentifiers(playerSrc: string): number { - return GetNumPlayerIdentifiers(playerSrc) - } - - getName(playerSrc: string): string { - return GetPlayerName(playerSrc) || 'Unknown' - } - - getPing(playerSrc: string): number { - return GetPlayerPing(playerSrc) - } - - getEndpoint(playerSrc: string): string { - return GetPlayerEndpoint(playerSrc) || '' - } - - setRoutingBucket(playerSrc: string, bucket: number): void { - SetPlayerRoutingBucket(playerSrc, bucket) - } - - getRoutingBucket(playerSrc: string): number { - return GetPlayerRoutingBucket(playerSrc) - } - - getConnectedPlayers(): string[] { - return getPlayers() - } -} diff --git a/src/adapters/fivem/fivem-playerinfo.ts b/src/adapters/fivem/fivem-playerinfo.ts deleted file mode 100644 index fe12ee1..0000000 --- a/src/adapters/fivem/fivem-playerinfo.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Vector3 } from '../../kernel' -import { IPlayerInfo } from '../contracts/IPlayerInfo' - -export class FiveMPlayerInfo implements IPlayerInfo { - getPlayerName(clientId: number): string | null { - return GetPlayerName(clientId) - } - getPlayerPosition(clientId: number): Vector3 { - const ped = GetPlayerPed(clientId) - const [x, y, z] = GetEntityCoords(ped, false) - return { x, y, z } - } -} diff --git a/src/adapters/fivem/fivem-resourceinfo.ts b/src/adapters/fivem/fivem-resourceinfo.ts deleted file mode 100644 index d6370b1..0000000 --- a/src/adapters/fivem/fivem-resourceinfo.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { IResourceInfo } from '../contracts/IResourceInfo' - -export class FiveMResourceInfo extends IResourceInfo { - getCurrentResourceName(): string { - const fn = GetCurrentResourceName - if (typeof fn === 'function') { - const name = fn() - if (typeof name === 'string' && name.trim()) return name - } - return 'default' - } - - getCurrentResourcePath(): string { - const fn = (globalThis as any).GetResourcePath - if (typeof fn === 'function') { - const name = this.getCurrentResourceName() - if (name) { - const path = fn(name) - if (typeof path === 'string' && path.trim()) return path - } - } - return process.cwd() - } -} diff --git a/src/adapters/fivem/fivem-tick.ts b/src/adapters/fivem/fivem-tick.ts deleted file mode 100644 index fd42f12..0000000 --- a/src/adapters/fivem/fivem-tick.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { injectable } from 'tsyringe' -import { ITick } from '../contracts/ITick' - -/** - * FiveM implementation of ITick using native setTick - */ -@injectable() -export class FiveMTick implements ITick { - setTick(handler: () => void | Promise): void { - setTick(handler) - } -} diff --git a/src/adapters/fivem/fivem-vehicle-server.ts b/src/adapters/fivem/fivem-vehicle-server.ts deleted file mode 100644 index da5bf07..0000000 --- a/src/adapters/fivem/fivem-vehicle-server.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { injectable } from 'tsyringe' -import { IVehicleServer } from '../contracts/server/IVehicleServer' - -/** - * FiveM implementation of server-side vehicle operations. - */ -@injectable() -export class FiveMVehicleServer extends IVehicleServer { - createServerSetter( - modelHash: number, - vehicleType: string, - x: number, - y: number, - z: number, - heading: number, - ): number { - return CreateVehicleServerSetter(modelHash, vehicleType, x, y, z, heading) - } - - getColours(handle: number): [number, number] { - return GetVehicleColours(handle) as [number, number] - } - - setColours(handle: number, primary: number, secondary: number): void { - SetVehicleColours(handle, primary, secondary) - } - - getNumberPlateText(handle: number): string { - return GetVehicleNumberPlateText(handle) - } - - setNumberPlateText(handle: number, text: string): void { - SetVehicleNumberPlateText(handle, text) - } - - setDoorsLocked(handle: number, state: number): void { - SetVehicleDoorsLocked(handle, state) - } - - getNetworkIdFromEntity(handle: number): number { - return NetworkGetNetworkIdFromEntity(handle) - } - - getEntityFromNetworkId(networkId: number): number { - return NetworkGetEntityFromNetworkId(networkId) - } - - networkIdExists(networkId: number): boolean { - return NetworkDoesEntityExistWithNetworkId(networkId) - } -} diff --git a/src/adapters/fivem/index.ts b/src/adapters/fivem/index.ts deleted file mode 100644 index 0fee347..0000000 --- a/src/adapters/fivem/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -// FiveM implementations -export { FiveMCapabilities } from './fivem-capabilities' -export { FiveMEngineEvents } from './fivem-engine-events' -export { FiveMEntityServer } from './fivem-entity-server' -export { FiveMExports } from './fivem-exports' -export { FiveMHasher } from './fivem-hasher' -export { FiveMPedServer } from './fivem-ped-server' -export { FiveMMessagingTransport } from './transport/adapter' -export { FiveMPedAppearanceServerAdapter } from './fivem-ped-appearance-server' -// Platform adapter -export { FiveMPlatform } from './fivem-platform' -export { FiveMPlayerServer } from './fivem-player-server' -export { FiveMPlayerInfo } from './fivem-playerinfo' -export { FiveMResourceInfo } from './fivem-resourceinfo' -export { FiveMTick } from './fivem-tick' -export { FiveMVehicleServer } from './fivem-vehicle-server' diff --git a/src/adapters/fivem/transport/adapter.ts b/src/adapters/fivem/transport/adapter.ts deleted file mode 100644 index 2f392fa..0000000 --- a/src/adapters/fivem/transport/adapter.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { MessagingTransport } from '../../contracts/transport/messaging.transport' -import { FiveMEvents } from './fivem.events' -import { FiveMRpc } from './fivem.rpc' - -export class FiveMMessagingTransport extends MessagingTransport { - readonly context = IsDuplicityVersion() ? 'server' : 'client' - - readonly events = new FiveMEvents(this.context) - readonly rpc = new FiveMRpc(this.context) -} diff --git a/src/adapters/fivem/transport/fivem.events.ts b/src/adapters/fivem/transport/fivem.events.ts deleted file mode 100644 index 71b130b..0000000 --- a/src/adapters/fivem/transport/fivem.events.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { EventsAPI } from '../../contracts/transport/events.api' -import { RuntimeContext } from '../../contracts/transport/context' -import { Player } from '../../../runtime/server/entities/player' - -export class FiveMEvents extends EventsAPI { - constructor(private readonly context: RuntimeContext) { - super() - } - - on(event: string, handler: any) { - onNet(event, (...args: any) => { - const sourceId = this.context === 'server' ? global.source : undefined - handler({ clientId: sourceId, raw: sourceId }, ...args) - }) - } - - emit(event: string, ...args: any[]): void { - if (this.context !== 'server') { - emitNet(event, ...args) - return - } - const [target, ...payload] = args - const send = (id: number) => emitNet(event, id, ...payload) - - if (target === 'all') { - send(-1) - return - } - if (Array.isArray(target)) { - target.forEach(send) - return - } - if (target instanceof Player) { - send(target.clientID) - return - } - send(target) - } -} diff --git a/src/adapters/fivem/transport/fivem.rpc.ts b/src/adapters/fivem/transport/fivem.rpc.ts deleted file mode 100644 index 5b97c7f..0000000 --- a/src/adapters/fivem/transport/fivem.rpc.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { RpcAPI, type RpcTarget } from '../../contracts/transport/rpc.api' -import type { RuntimeContext } from '../../contracts/transport/context' - -type RpcWireCall = { - kind: 'call' - id: string - name: string - args: unknown[] -} - -type RpcWireNotify = { - kind: 'notify' - id: string - name: string - args: unknown[] -} - -type RpcWireResult = { - kind: 'result' - id: string - ok: true - result: unknown -} - -type RpcWireError = { - kind: 'result' - id: string - ok: false - error: { - message: string - name?: string - } -} - -type RpcWireAck = { - kind: 'ack' - id: string -} - -type RpcWireMessage = RpcWireCall | RpcWireNotify | RpcWireResult | RpcWireError | RpcWireAck - -type PendingEntry = { - resolve: (value: TResult) => void - reject: (reason?: unknown) => void - timeout: ReturnType -} - -function getCurrentResourceNameSafe(): string { - const fn = (globalThis as any).GetCurrentResourceName - if (typeof fn === 'function') { - const name = fn() - if (typeof name === 'string' && name.trim()) return name - } - return 'default' -} - -export class FiveMRpc extends RpcAPI { - private readonly pending = new Map>() - private requestSeq = 0 - private readonly handlers = new Map< - string, - (ctx: { requestId: string; clientId?: number; raw?: unknown }, ...args: any[]) => unknown - >() - - private readonly channel = getCurrentResourceNameSafe() - private readonly requestEvent = `__oc:rpc:req:${this.channel}` - private readonly responseEvent = `__oc:rpc:res:${this.channel}` - - private readonly defaultTimeoutMs = 7_500 - - constructor(private readonly context: C) { - super() - - onNet(this.requestEvent, (msg: RpcWireMessage) => { - void this.handleRequestMessage(msg) - }) - - onNet(this.responseEvent, (msg: RpcWireMessage) => { - this.handleResponseMessage(msg) - }) - } - - on( - name: string, - handler: ( - ctx: { requestId: string; clientId?: number; raw?: unknown }, - ...args: TArgs - ) => TResult | Promise, - ): void { - this.handlers.set(name, handler as any) - } - - call(name: string, ...args: any[]): Promise { - const { target, payload } = this.normalizeInvocation(name, 'call', args) - return this.sendAndWait({ kind: 'call', name, args: payload }, target) - } - - notify(name: string, ...args: any[]): Promise { - const { target, payload } = this.normalizeInvocation(name, 'notify', args) - return this.sendAndWait({ kind: 'notify', name, args: payload }, target) - } - - private normalizeInvocation( - name: string, - kind: 'call' | 'notify', - args: any[], - ): { target?: RpcTarget; payload: any[] } { - if (this.context === 'server') { - if (args.length === 0) { - throw new Error(`FiveMRpc: missing target for '${kind}' '${name}' in server context`) - } - - const [target, ...payload] = args - if (!this.isValidTarget(target)) { - throw new Error(`FiveMRpc: invalid target for '${kind}' '${name}'`) - } - - if (kind === 'call' && target === 'all') { - throw new Error(`FiveMRpc: target=all is not supported for call '${name}'`) - } - - return { target, payload } - } - - return { target: undefined, payload: args } - } - - private isValidTarget(value: unknown): value is RpcTarget { - if (value === 'all') return true - if (typeof value === 'number') return true - if (Array.isArray(value)) return value.every((item) => typeof item === 'number') - return false - } - - private sendAndWait( - input: { kind: 'call' | 'notify'; name: string; args: unknown[] }, - target?: RpcTarget, - ): Promise { - const id = this.createRequestId() - - const msg: RpcWireMessage = { - kind: input.kind, - id, - name: input.name, - args: input.args ?? [], - } as RpcWireMessage - - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - this.pending.delete(id) - reject( - new Error( - `FiveMRpc: timeout waiting for '${input.kind}' response for '${input.name}' (${id})`, - ), - ) - }, this.defaultTimeoutMs) - - this.pending.set(id, { resolve: resolve as any, reject, timeout }) - - if (this.context === 'server') { - const resolvedTarget = this.resolveServerTarget(target, input.kind, input.name) - emitNet(this.requestEvent, resolvedTarget, msg) - } else { - emitNet(this.requestEvent, msg) - } - }) - } - - private createRequestId(): string { - this.requestSeq += 1 - const ts = Date.now().toString(36) - const seq = this.requestSeq.toString(36) - const rand = Math.floor(Math.random() * 1_000_000_000).toString(36) - return `${this.channel}:${this.context}:${ts}:${seq}:${rand}` - } - - private resolveServerTarget( - target: RpcTarget | undefined, - kind: 'call' | 'notify', - name: string, - ): number | number[] | -1 { - if (target === undefined) { - throw new Error(`FiveMRpc: missing target for '${kind}' '${name}' in server context`) - } - if (kind === 'call' && target === 'all') { - throw new Error(`FiveMRpc: target=all is not supported for call '${name}'`) - } - if (target === 'all') return -1 - return target - } - - private async handleRequestMessage(msg: RpcWireMessage): Promise { - if (msg.kind !== 'call' && msg.kind !== 'notify') return - - const handler = this.handlers.get(msg.name) - - const sourceId = this.context === 'server' ? (global as any).source : undefined - const replyTarget = this.context === 'server' ? sourceId : undefined - - if (!handler) { - if (msg.kind === 'call') { - this.emitResponse(replyTarget, { - kind: 'result', - id: msg.id, - ok: false, - error: { message: `FiveMRpc: no handler registered for '${msg.name}'` }, - }) - } else { - this.emitResponse(replyTarget, { kind: 'ack', id: msg.id }) - } - return - } - - try { - const result = await Promise.resolve( - handler( - { - requestId: msg.id, - clientId: sourceId, - raw: sourceId, - }, - ...(msg.args as any[]), - ), - ) - - if (msg.kind === 'notify') { - this.emitResponse(replyTarget, { kind: 'ack', id: msg.id }) - } else { - this.emitResponse(replyTarget, { kind: 'result', id: msg.id, ok: true, result }) - } - } catch (err: any) { - if (msg.kind === 'notify') { - this.emitResponse(replyTarget, { kind: 'ack', id: msg.id }) - return - } - - this.emitResponse(replyTarget, { - kind: 'result', - id: msg.id, - ok: false, - error: { - message: err?.message ? String(err.message) : String(err), - name: err?.name ? String(err.name) : undefined, - }, - }) - } - } - - private handleResponseMessage(msg: RpcWireMessage): void { - if (msg.kind !== 'result' && msg.kind !== 'ack') return - - const pending = this.pending.get(msg.id) - if (!pending) return - - clearTimeout(pending.timeout) - this.pending.delete(msg.id) - - if (msg.kind === 'ack') { - pending.resolve(undefined) - return - } - - if (msg.ok) { - pending.resolve(msg.result) - return - } - - const error = new Error(msg.error?.message ?? 'FiveMRpc: remote error') - ;(error as any).name = msg.error?.name ?? error.name - pending.reject(error) - } - - private emitResponse(target: number | undefined, msg: RpcWireMessage): void { - if (this.context === 'server') { - emitNet(this.responseEvent, target ?? -1, msg) - return - } - emitNet(this.responseEvent, msg) - } -} diff --git a/src/adapters/index.ts b/src/adapters/index.ts index 4ece7ba..798eabb 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -1,40 +1,19 @@ // Adapters - External world connections -// Client contracts -export * from './contracts/client/IPedAppearanceClient' - -// Core contracts +export * from './contracts' +export * from './contracts/server' export * from './contracts/IEngineEvents' export * from './contracts/IExports' -export * from './contracts/IHasher' -export * from './contracts/IPlatformCapabilities' +export * from './contracts/IPlatformContext' export * from './contracts/IPlayerInfo' export * from './contracts/IResourceInfo' export * from './contracts/ITick' -// Transport contracts -export * from './contracts/transport/context' -export * from './contracts/transport/events.api' -export * from './contracts/transport/messaging.transport' -export * from './contracts/transport/rpc.api' - -// Server contracts -export * from './contracts/server/IEntityServer' -export * from './contracts/server/IPedServer' -export * from './contracts/server/IPedAppearanceServer' -export * from './contracts/server/IPlayerServer' -export * from './contracts/server/IVehicleServer' - -// Types -export * from './contracts/types/identifier' // Platform registry export * from './platform/platform-registry' // Capability registration export * from './register-capabilities' -// CitizenFX helpers -export * from './cfx' - // CitizenFX adapters (not exported by default - registered via registerServerCapabilities) // Node adapters (not exported by default - registered via registerServerCapabilities) diff --git a/src/adapters/node/node-capabilities.ts b/src/adapters/node/node-capabilities.ts index b4814ff..083932d 100644 --- a/src/adapters/node/node-capabilities.ts +++ b/src/adapters/node/node-capabilities.ts @@ -1,21 +1,16 @@ import { injectable } from 'tsyringe' -import { IPlatformCapabilities, PlatformFeatures } from '../contracts/IPlatformCapabilities' +import { IPlatformContext } from '../contracts/IPlatformContext' import { IdentifierTypes } from '../contracts/types/identifier' /** - * Node.js mock platform capabilities implementation. + * Node.js mock platform context implementation. * Used for testing and standalone development. */ @injectable() -export class NodeCapabilities extends IPlatformCapabilities { +export class NodePlatformContext extends IPlatformContext { readonly platformName = 'node' readonly displayName = 'Node.js (Mock)' - readonly supportsRoutingBuckets = true // Mocked - readonly supportsStateBags = true // Mocked - readonly supportsVoiceChat = false - readonly supportsServerEntities = true // Mocked - readonly identifierTypes = [ IdentifierTypes.STEAM, IdentifierTypes.LICENSE, @@ -23,25 +18,14 @@ export class NodeCapabilities extends IPlatformCapabilities { IdentifierTypes.IP, ] as const - readonly maxPlayers = undefined // Unlimited in mock mode - - private readonly supportedFeatures = new Set([ - PlatformFeatures.ROUTING_BUCKETS, - PlatformFeatures.STATE_BAGS, - PlatformFeatures.SERVER_ENTITIES, - // Note: Other features are not mocked - ]) - - private readonly config: Record = { - mockMode: true, - defaultRoutingBucket: 0, - } - - isFeatureSupported(feature: string): boolean { - return this.supportedFeatures.has(feature) - } - - getConfig(key: string): T | undefined { - return this.config[key] as T | undefined - } + readonly maxPlayers = undefined + readonly gameProfile = 'common' as const + readonly defaultSpawnModel = 'mp_m_freemode_01' + readonly defaultVehicleType = 'automobile' + readonly enableServerVehicleCreation = true } + +/** + * @deprecated Use NodePlatformContext. + */ +export const NodeCapabilities = NodePlatformContext diff --git a/src/adapters/node/node-engine-events.ts b/src/adapters/node/node-engine-events.ts index 5a92a12..fdb8d87 100644 --- a/src/adapters/node/node-engine-events.ts +++ b/src/adapters/node/node-engine-events.ts @@ -10,7 +10,7 @@ import { IEngineEvents } from '../contracts/IEngineEvents' * This implementation provides a mock event system for testing purposes. */ @injectable() -export class NodeEngineEvents implements IEngineEvents { +export class NodeEngineEvents extends IEngineEvents { private eventEmitter = new EventEmitter() on(eventName: string, handler?: (...args: any[]) => void): void { diff --git a/src/adapters/node/node-entity-server.ts b/src/adapters/node/node-entity-server.ts index fd5d9b2..37bb094 100644 --- a/src/adapters/node/node-entity-server.ts +++ b/src/adapters/node/node-entity-server.ts @@ -67,14 +67,14 @@ export class NodeEntityServer extends IEntityServer { // No-op in Node } - setRoutingBucket(handle: number, bucket: number): void { + setDimension(handle: number, bucket: number): void { const entity = this.entities.get(handle) if (entity) { entity.bucket = bucket } } - getRoutingBucket(handle: number): number { + getDimension(handle: number): number { return this.entities.get(handle)?.bucket ?? 0 } diff --git a/src/adapters/node/node-ped-appearance-client.ts b/src/adapters/node/node-ped-appearance-client.ts index 5c1e9da..7406371 100644 --- a/src/adapters/node/node-ped-appearance-client.ts +++ b/src/adapters/node/node-ped-appearance-client.ts @@ -1,5 +1,5 @@ import { HeadBlendData } from '../../kernel/shared' -import { IPedAppearanceClient } from '../contracts/client/IPedAppearanceClient' +import { IGtaPedAppearanceBridge } from '../contracts/client/IGtaPedAppearanceBridge' /** * Node.js stub implementation of client-side ped appearance adapter. @@ -8,7 +8,7 @@ import { IPedAppearanceClient } from '../contracts/client/IPedAppearanceClient' * This is a no-op implementation for testing in Node.js environment. * All methods return default values or do nothing. */ -export class NodePedAppearanceClient extends IPedAppearanceClient { +export class NodePedAppearanceClient extends IGtaPedAppearanceBridge { setComponentVariation(): void {} setPropIndex(): void {} clearProp(): void {} diff --git a/src/adapters/node/node-platform.ts b/src/adapters/node/node-platform.ts index 7069b09..2c8106b 100644 --- a/src/adapters/node/node-platform.ts +++ b/src/adapters/node/node-platform.ts @@ -2,7 +2,7 @@ import type { DependencyContainer } from 'tsyringe' import { IEngineEvents } from '../contracts/IEngineEvents' import { IExports } from '../contracts/IExports' import { IHasher } from '../contracts/IHasher' -import { IPlatformCapabilities } from '../contracts/IPlatformCapabilities' +import { IPlatformContext } from '../contracts/IPlatformContext' import { IPlayerInfo } from '../contracts/IPlayerInfo' import { IResourceInfo } from '../contracts/IResourceInfo' import { ITick } from '../contracts/ITick' @@ -44,7 +44,7 @@ export const NodePlatform: PlatformAdapter = { { NodePlayerServer }, { NodeHasher }, { NodePedAppearanceServer }, - { NodeCapabilities }, + { NodePlatformContext }, ] = await Promise.all([ import('./transport/adapter'), import('./node-engine-events'), @@ -62,8 +62,8 @@ export const NodePlatform: PlatformAdapter = { ]) // Register all Node.js mock implementations - if (!container.isRegistered(IPlatformCapabilities as any)) - container.registerSingleton(IPlatformCapabilities as any, NodeCapabilities) + if (!container.isRegistered(IPlatformContext as any)) + container.registerSingleton(IPlatformContext as any, NodePlatformContext) if (!container.isRegistered(MessagingTransport as any)) { const transport = new NodeMessagingTransport('server') diff --git a/src/adapters/node/node-player-server.ts b/src/adapters/node/node-player-server.ts index 5bf8e5e..d92f2c6 100644 --- a/src/adapters/node/node-player-server.ts +++ b/src/adapters/node/node-player-server.ts @@ -17,6 +17,7 @@ export class NodePlayerServer extends IPlayerServer { ping: number endpoint: string routingBucket: number + model?: string } >() private droppedPlayers: string[] = [] @@ -30,6 +31,13 @@ export class NodePlayerServer extends IPlayerServer { this.players.delete(playerSrc) } + setModel(playerSrc: string, model: string): void { + const player = this.players.get(playerSrc) + if (player) { + player.model = model + } + } + getIdentifier(playerSrc: string, identifierType: string): string | undefined { const player = this.players.get(playerSrc) if (!player) return undefined @@ -75,14 +83,14 @@ export class NodePlayerServer extends IPlayerServer { return this.players.get(playerSrc)?.endpoint ?? '' } - setRoutingBucket(playerSrc: string, bucket: number): void { + setDimension(playerSrc: string, bucket: number): void { const player = this.players.get(playerSrc) if (player) { player.routingBucket = bucket } } - getRoutingBucket(playerSrc: string): number { + getDimension(playerSrc: string): number { return this.players.get(playerSrc)?.routingBucket ?? 0 } diff --git a/src/adapters/register-capabilities.ts b/src/adapters/register-capabilities.ts index dc0bd9c..ac9ac97 100644 --- a/src/adapters/register-capabilities.ts +++ b/src/adapters/register-capabilities.ts @@ -1,5 +1,4 @@ import { GLOBAL_CONTAINER } from '../kernel/di/container' -import { CfxPlatform } from './cfx/cfx-platform' import { NodePlatform } from './node/node-platform' import { getCurrentPlatformName, @@ -23,7 +22,6 @@ export type Platform = 'cfx' | 'node' | string // ───────────────────────────────────────────────────────────────── // Register CitizenFX platform (high priority) -registerPlatform(CfxPlatform) // Register Node.js fallback platform (low priority) registerPlatform(NodePlatform) @@ -47,7 +45,7 @@ export function detectPlatform(): Platform { * * @remarks * This function registers adapters needed by the SERVER runtime only. - * Client-side adapters are registered separately via `registerClientCapabilities`. + * Client-side adapters are now installed through `Client.init({ adapter })`. * * The function uses the Platform Registry to automatically detect and register * the appropriate platform adapters. You can also force a specific platform diff --git a/src/adapters/register-client-capabilities.ts b/src/adapters/register-client-capabilities.ts index 21c7cb3..d3de7c0 100644 --- a/src/adapters/register-client-capabilities.ts +++ b/src/adapters/register-client-capabilities.ts @@ -1,59 +1,38 @@ import { di } from '../runtime/client/client-container' -import { IPedAppearanceClient } from './contracts/client/IPedAppearanceClient' +import { IGtaPedAppearanceBridge } from './contracts/client/IGtaPedAppearanceBridge' import { IHasher } from './contracts/IHasher' import { EventsAPI } from './contracts/transport/events.api' import { MessagingTransport } from './contracts/transport/messaging.transport' import { RpcAPI } from './contracts/transport/rpc.api' -import { detectCfxGameProfile, isCfxRuntime } from './cfx/runtime-profile' /** * Registers client-side platform-specific capability implementations. * * @remarks - * This function registers adapters needed by the CLIENT runtime only. - * Should be called during client bootstrap before services that depend on these adapters. + * Deprecated in favor of `Client.init({ adapter })` and custom client adapters. + * + * This legacy helper now installs only the built-in Node fallback bindings. */ export async function registerClientCapabilities(): Promise { - const cfxRuntime = isCfxRuntime() - const gameProfile = cfxRuntime ? detectCfxGameProfile() : 'common' + const [{ NodeMessagingTransport }, { NodePedAppearanceClient }, { NodeHasher }] = + await Promise.all([ + import('./node/transport/adapter'), + import('./node/node-ped-appearance-client'), + import('./node/node-hasher'), + ]) if (!di.isRegistered(MessagingTransport as any)) { - if (cfxRuntime) { - const [{ FiveMMessagingTransport }] = await Promise.all([import('./fivem/transport/adapter')]) - const transport = new FiveMMessagingTransport() - di.registerInstance(MessagingTransport as any, transport) - di.registerInstance(EventsAPI as any, transport.events) - di.registerInstance(RpcAPI as any, transport.rpc) - } else { - const [{ NodeMessagingTransport }] = await Promise.all([import('./node/transport/adapter')]) - const transport = new NodeMessagingTransport('client') - di.registerInstance(MessagingTransport as any, transport) - di.registerInstance(EventsAPI as any, transport.events) - di.registerInstance(RpcAPI as any, transport.rpc) - } + const transport = new NodeMessagingTransport('client') + di.registerInstance(MessagingTransport as any, transport) + di.registerInstance(EventsAPI as any, transport.events) + di.registerInstance(RpcAPI as any, transport.rpc) } - if (!di.isRegistered(IPedAppearanceClient as any)) { - if (cfxRuntime && gameProfile !== 'rdr3') { - const [{ FiveMPedAppearanceClientAdapter }] = await Promise.all([ - import('./fivem/fivem-ped-appearance-client'), - ]) - di.registerSingleton(IPedAppearanceClient as any, FiveMPedAppearanceClientAdapter) - } else { - const [{ NodePedAppearanceClient }] = await Promise.all([ - import('./node/node-ped-appearance-client'), - ]) - di.registerSingleton(IPedAppearanceClient as any, NodePedAppearanceClient) - } + if (!di.isRegistered(IGtaPedAppearanceBridge as any)) { + di.registerSingleton(IGtaPedAppearanceBridge as any, NodePedAppearanceClient) } if (!di.isRegistered(IHasher as any)) { - if (cfxRuntime) { - const [{ FiveMHasher }] = await Promise.all([import('./fivem/fivem-hasher')]) - di.registerSingleton(IHasher as any, FiveMHasher) - } else { - const [{ NodeHasher }] = await Promise.all([import('./node/node-hasher')]) - di.registerSingleton(IHasher as any, NodeHasher) - } + di.registerSingleton(IHasher as any, NodeHasher) } } diff --git a/src/contracts.ts b/src/contracts.ts new file mode 100644 index 0000000..cf5c389 --- /dev/null +++ b/src/contracts.ts @@ -0,0 +1,4 @@ +export * from './adapters/contracts/IHasher' +export * from './adapters/contracts/transport' +export * from './adapters/contracts/types' +export * from './adapters/contracts/runtime' diff --git a/src/contracts/client.ts b/src/contracts/client.ts new file mode 100644 index 0000000..2c9847c --- /dev/null +++ b/src/contracts/client.ts @@ -0,0 +1,5 @@ +export * from '../adapters/contracts/IHasher' +export * from '../adapters/contracts/transport' +export * from '../adapters/contracts/types' +export * from '../adapters/contracts/runtime' +export * from '../adapters/contracts/client' diff --git a/src/contracts/server.ts b/src/contracts/server.ts new file mode 100644 index 0000000..25f9aae --- /dev/null +++ b/src/contracts/server.ts @@ -0,0 +1,19 @@ +export * from '../adapters/contracts/IEngineEvents' +export * from '../adapters/contracts/IExports' +export * from '../adapters/contracts/IHasher' +export * from '../adapters/contracts/IPlatformContext' +export * from '../adapters/contracts/IPlayerInfo' +export * from '../adapters/contracts/IResourceInfo' +export * from '../adapters/contracts/ITick' +export * from '../adapters/contracts/transport' +export * from '../adapters/contracts/types' +export * from '../adapters/contracts/runtime' +export * from '../adapters/contracts/server' +export * from '../adapters/contracts/server/player-lifecycle/IPlayerLifecycleServer' +export * from '../adapters/contracts/server/player-lifecycle/types' +export * from '../adapters/contracts/server/player-appearance/IPlayerAppearanceLifecycleServer' +export * from '../adapters/contracts/server/player-state/IPlayerStateSyncServer' +export * from '../adapters/contracts/server/npc-lifecycle/INpcLifecycleServer' +export * from '../adapters/contracts/server/npc-lifecycle/types' +export * from '../adapters/contracts/server/vehicle-lifecycle/IVehicleLifecycleServer' +export * from '../adapters/contracts/server/vehicle-lifecycle/types' diff --git a/src/index.ts b/src/index.ts index 400f96a..16be3cc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,4 +2,4 @@ import 'reflect-metadata' export * from './kernel' export * from './runtime/core' -export * from './adapters/contracts' +export * from './contracts' diff --git a/src/kernel-public.ts b/src/kernel-public.ts new file mode 100644 index 0000000..c9ffef7 --- /dev/null +++ b/src/kernel-public.ts @@ -0,0 +1 @@ +export * from './kernel' diff --git a/src/kernel/logger/client-log-console.ts b/src/kernel/logger/client-log-console.ts new file mode 100644 index 0000000..d60af6a --- /dev/null +++ b/src/kernel/logger/client-log-console.ts @@ -0,0 +1,59 @@ +import { + IClientLogConsole, + type ClientLogConsoleCapabilities, +} from '../../adapters/contracts/client/IClientLogConsole' + +const DEFAULT_CLIENT_LOG_CONSOLE_CAPABILITIES: ClientLogConsoleCapabilities = { + supportsColors: false, + supportsStructuredData: true, + supportsRichFormatting: false, +} + +class DefaultClientLogConsole extends IClientLogConsole { + getCapabilities(): ClientLogConsoleCapabilities { + return DEFAULT_CLIENT_LOG_CONSOLE_CAPABILITIES + } + + trace(message: string, details?: unknown): void { + this.write(console.debug, message, details) + } + + debug(message: string, details?: unknown): void { + this.write(console.debug, message, details) + } + + info(message: string, details?: unknown): void { + this.write(console.info, message, details) + } + + warn(message: string, details?: unknown): void { + this.write(console.warn, message, details) + } + + error(message: string, details?: unknown): void { + this.write(console.error, message, details) + } + + private write(method: (...args: unknown[]) => void, message: string, details?: unknown): void { + if (details === undefined) { + method(message) + return + } + + method(message, details) + } +} + +let activeClientLogConsole: IClientLogConsole = new DefaultClientLogConsole() + +export function getClientLogConsole(): IClientLogConsole { + return activeClientLogConsole +} + +export function setClientLogConsole(logConsole: IClientLogConsole): void { + activeClientLogConsole = logConsole +} + +export function resetClientLogConsoleForTests(): void { + activeClientLogConsole = new DefaultClientLogConsole() +} diff --git a/src/kernel/logger/core-logger.ts b/src/kernel/logger/core-logger.ts index e2308ed..098e274 100644 --- a/src/kernel/logger/core-logger.ts +++ b/src/kernel/logger/core-logger.ts @@ -65,8 +65,10 @@ export const loggers = { exports: coreLogger.framework('Exports'), /** Tick handlers (server) */ tick: coreLogger.framework('Tick'), - /** NUI callbacks (client) */ - nui: coreLogger.client('NUI'), + /** Embedded WebView callbacks (client) */ + webView: coreLogger.client('WebView'), + /** @deprecated Use webView instead. */ + nui: coreLogger.client('WebView'), /** Spawn service (client) */ spawn: coreLogger.client('Spawn'), /** */ diff --git a/src/kernel/logger/index.ts b/src/kernel/logger/index.ts index 2a78fbd..af969e0 100644 --- a/src/kernel/logger/index.ts +++ b/src/kernel/logger/index.ts @@ -1,5 +1,10 @@ // Core Logger (internal framework use) export { coreLogger, loggers } from './core-logger' +export { + getClientLogConsole, + resetClientLogConsoleForTests, + setClientLogConsole, +} from './client-log-console' export type { LoggerConfig } from './logger.config' // Config @@ -15,6 +20,7 @@ export { // Service export { ChildLogger, LoggerService } from './logger.service' +export type { ClientLogConsoleCapabilities } from '../../adapters/contracts/client/IClientLogConsole' export type { LogContext, LogEntry } from './logger.types' export { LogDomain, LogDomainLabels, LogLevel, LogLevelLabels, parseLogLevel } from './logger.types' export type { BufferedTransportOptions, LogOutputFormat } from './transports/buffered.transport' diff --git a/src/kernel/logger/transports/dev-transport.factory.ts b/src/kernel/logger/transports/dev-transport.factory.ts index 0364fff..7bacf91 100644 --- a/src/kernel/logger/transports/dev-transport.factory.ts +++ b/src/kernel/logger/transports/dev-transport.factory.ts @@ -33,7 +33,7 @@ export interface DevTransportOptions { */ export function detectEnvironment(): RuntimeEnvironment { // Check for CitizenFX globals - if (typeof GetCurrentResourceName === 'function') { + if (typeof (globalThis as any).GetCurrentResourceName === 'function') { return 'cfx' } return 'node' diff --git a/src/kernel/logger/transports/simple-console.transport.ts b/src/kernel/logger/transports/simple-console.transport.ts index aa589d5..2b2ccfd 100644 --- a/src/kernel/logger/transports/simple-console.transport.ts +++ b/src/kernel/logger/transports/simple-console.transport.ts @@ -1,4 +1,5 @@ import { LogDomainLabels, type LogEntry, LogLevel, LogLevelLabels } from '../logger.types' +import { getClientLogConsole } from '../client-log-console' import { LogTransport } from './transport.interface' export interface SimpleConsoleTransportOptions { @@ -47,6 +48,8 @@ export class SimpleConsoleTransport implements LogTransport { write(entry: LogEntry): void { const { level, domain, message, timestamp, context, error } = entry + const output = getClientLogConsole() + const capabilities = output.getCapabilities() const levelLabel = LogLevelLabels[level].padEnd(5) const domainLabel = LogDomainLabels[domain] @@ -75,24 +78,26 @@ export class SimpleConsoleTransport implements LogTransport { // Output the main log line const logLine = parts.join(' ') - // Choose the appropriate console method + // Choose the appropriate client log sink switch (level) { case LogLevel.TRACE: + output.trace(logLine) + break case LogLevel.DEBUG: - console.debug(logLine) + output.debug(logLine) break case LogLevel.INFO: - console.info(logLine) + output.info(logLine) break case LogLevel.WARN: - console.warn(logLine) + output.warn(logLine) break case LogLevel.ERROR: case LogLevel.FATAL: - console.error(logLine) + output.error(logLine) break default: - console.log(logLine) + output.info(logLine) } // Output context only if enabled and present @@ -100,13 +105,16 @@ export class SimpleConsoleTransport implements LogTransport { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { source, domain: _, ...rest } = context if (Object.keys(rest).length > 0) { - console.debug(' context:', JSON.stringify(rest)) + output.debug( + ' context:', + capabilities.supportsStructuredData ? rest : JSON.stringify(rest), + ) } } // Output error stack if present if (error?.stack) { - console.error(' stack:', error.stack) + output.error(' stack:', error.stack) } } } diff --git a/src/runtime/client/adapter/client-adapter.ts b/src/runtime/client/adapter/client-adapter.ts new file mode 100644 index 0000000..76facae --- /dev/null +++ b/src/runtime/client/adapter/client-adapter.ts @@ -0,0 +1,43 @@ +import type { DependencyContainer, InjectionToken } from 'tsyringe' +import { EventsAPI } from '../../../adapters/contracts/transport/events.api' +import { MessagingTransport } from '../../../adapters/contracts/transport/messaging.transport' +import { RpcAPI } from '../../../adapters/contracts/transport/rpc.api' +import { IClientRuntimeBridge } from './runtime-bridge' + +/** + * Public contract implemented by external client adapters. + */ +export interface OpenCoreClientAdapter { + readonly name: string + register(context: ClientAdapterContext): void | Promise +} + +/** + * Registration helpers exposed to client adapters. + */ +export interface ClientAdapterContext { + readonly adapterName: string + readonly container: DependencyContainer + isRegistered(token: InjectionToken): boolean + bindSingleton(token: InjectionToken, implementation: InjectionToken): void + bindInstance(token: InjectionToken, value: T): void + bindFactory(token: InjectionToken, factory: () => T): void + bindMessagingTransport(transport: MessagingTransport): void + useRuntimeBridge(runtime: IClientRuntimeBridge): void +} + +export function defineClientAdapter(adapter: OpenCoreClientAdapter): OpenCoreClientAdapter { + return adapter +} + +export function bindClientTransportInstances( + context: Pick, + transport: MessagingTransport, +): void { + context.bindInstance( + MessagingTransport as unknown as InjectionToken, + transport, + ) + context.bindInstance(EventsAPI as InjectionToken>, transport.events) + context.bindInstance(RpcAPI as InjectionToken>, transport.rpc) +} diff --git a/src/runtime/client/adapter/index.ts b/src/runtime/client/adapter/index.ts new file mode 100644 index 0000000..35857e3 --- /dev/null +++ b/src/runtime/client/adapter/index.ts @@ -0,0 +1,15 @@ +export * from './client-adapter' +export * from '../../../adapters/contracts/client/IClientLogConsole' +export * from '../../../adapters/contracts/client/spawn/IClientSpawnBridge' +export * from '../../../adapters/contracts/client/ui/IClientBlipBridge' +export * from '../../../adapters/contracts/client/ui/IClientMarkerBridge' +export * from '../../../adapters/contracts/client/ui/IClientNotificationBridge' +export * from '../../../adapters/contracts/client/ui/webview/IClientWebViewBridge' +export * from './local-player-bridge' +export * from './node-blip-bridge' +export * from './node-marker-bridge' +export * from './node-notification-bridge' +export * from './node-spawn-bridge' +export * from './node-webview-bridge' +export * from './platform-bridge' +export * from './runtime-bridge' diff --git a/src/runtime/client/adapter/local-player-bridge.ts b/src/runtime/client/adapter/local-player-bridge.ts new file mode 100644 index 0000000..078e8ed --- /dev/null +++ b/src/runtime/client/adapter/local-player-bridge.ts @@ -0,0 +1 @@ +export { IClientLocalPlayerBridge } from '../../../adapters/contracts/client/IClientLocalPlayerBridge' diff --git a/src/runtime/client/adapter/node-blip-bridge.ts b/src/runtime/client/adapter/node-blip-bridge.ts new file mode 100644 index 0000000..84d85e6 --- /dev/null +++ b/src/runtime/client/adapter/node-blip-bridge.ts @@ -0,0 +1,33 @@ +import { injectable } from 'tsyringe' +import { + IClientBlipBridge, + type ClientBlipDefinition, +} from '../../../adapters/contracts/client/ui/IClientBlipBridge' + +@injectable() +export class NodeClientBlipBridge extends IClientBlipBridge { + private readonly blips = new Map() + + create(id: string, definition: ClientBlipDefinition): void { + this.blips.set(id, { ...definition }) + } + + update(id: string, patch: Partial): boolean { + const existing = this.blips.get(id) + if (!existing) return false + this.blips.set(id, { ...existing, ...patch }) + return true + } + + exists(id: string): boolean { + return this.blips.has(id) + } + + remove(id: string): boolean { + return this.blips.delete(id) + } + + clear(): void { + this.blips.clear() + } +} diff --git a/src/runtime/client/adapter/node-client-adapter.ts b/src/runtime/client/adapter/node-client-adapter.ts new file mode 100644 index 0000000..ecb5786 --- /dev/null +++ b/src/runtime/client/adapter/node-client-adapter.ts @@ -0,0 +1,84 @@ +import type { InjectionToken } from 'tsyringe' +import { IGtaPedAppearanceBridge } from '../../../adapters/contracts/client/IGtaPedAppearanceBridge' +import { IHasher } from '../../../adapters/contracts/IHasher' +import { IClientLocalPlayerBridge } from './local-player-bridge' +import { NodeClientLocalPlayerBridge } from './node-local-player-bridge' +import { NodeClientNotificationBridge } from './node-notification-bridge' +import { IClientLogConsole } from '../../../adapters/contracts/client/IClientLogConsole' +import { IClientSpawnBridge } from '../../../adapters/contracts/client/spawn/IClientSpawnBridge' +import { IClientBlipBridge } from '../../../adapters/contracts/client/ui/IClientBlipBridge' +import { IClientMarkerBridge } from '../../../adapters/contracts/client/ui/IClientMarkerBridge' +import { IClientNotificationBridge } from '../../../adapters/contracts/client/ui/IClientNotificationBridge' +import { IClientWebViewBridge } from '../../../adapters/contracts/client/ui/webview/IClientWebViewBridge' +import { installNodeClientLogConsole, NodeClientLogConsole } from './node-log-console' +import { NodeClientBlipBridge } from './node-blip-bridge' +import { NodeClientMarkerBridge } from './node-marker-bridge' +import { NodeClientPlatformBridge } from './node-platform-bridge' +import { NodeClientSpawnBridge } from './node-spawn-bridge' +import { NodeClientWebViewBridge } from './node-webview-bridge' +import { IClientPlatformBridge } from './platform-bridge' +import { NodeClientRuntimeBridge } from './node-runtime-bridge' +import { defineClientAdapter, type OpenCoreClientAdapter } from './client-adapter' +import { IClientRuntimeBridge } from './runtime-bridge' + +/** + * Default client adapter used when no runtime adapter is provided. + */ +export function createNodeClientAdapter(): OpenCoreClientAdapter { + return defineClientAdapter({ + name: 'node', + async register(ctx) { + const [{ NodeMessagingTransport }, { NodePedAppearanceClient }, { NodeHasher }] = + await Promise.all([ + import('../../../adapters/node/transport/adapter'), + import('../../../adapters/node/node-ped-appearance-client'), + import('../../../adapters/node/node-hasher'), + ]) + + const transport = new NodeMessagingTransport('client') + ctx.bindMessagingTransport(transport) + ctx.bindSingleton( + IGtaPedAppearanceBridge as InjectionToken, + NodePedAppearanceClient, + ) + ctx.bindSingleton(IHasher as InjectionToken, NodeHasher) + ctx.bindSingleton( + IClientRuntimeBridge as InjectionToken, + NodeClientRuntimeBridge, + ) + ctx.bindSingleton( + IClientLocalPlayerBridge as InjectionToken, + NodeClientLocalPlayerBridge, + ) + ctx.bindSingleton( + IClientPlatformBridge as InjectionToken, + NodeClientPlatformBridge, + ) + ctx.bindSingleton( + IClientSpawnBridge as InjectionToken, + NodeClientSpawnBridge, + ) + ctx.bindSingleton( + IClientBlipBridge as InjectionToken, + NodeClientBlipBridge, + ) + ctx.bindSingleton( + IClientMarkerBridge as InjectionToken, + NodeClientMarkerBridge, + ) + ctx.bindSingleton( + IClientNotificationBridge as InjectionToken, + NodeClientNotificationBridge, + ) + ctx.bindSingleton( + IClientWebViewBridge as InjectionToken, + NodeClientWebViewBridge, + ) + ctx.bindSingleton( + IClientLogConsole as InjectionToken, + NodeClientLogConsole, + ) + installNodeClientLogConsole(new NodeClientLogConsole()) + }, + }) +} diff --git a/src/runtime/client/adapter/node-local-player-bridge.ts b/src/runtime/client/adapter/node-local-player-bridge.ts new file mode 100644 index 0000000..01b9cae --- /dev/null +++ b/src/runtime/client/adapter/node-local-player-bridge.ts @@ -0,0 +1,11 @@ +import { injectable } from 'tsyringe' +import { Vector3 } from '../../../kernel/utils/vector3' +import { IClientLocalPlayerBridge } from './local-player-bridge' + +/** + * Node fallback local player bridge. + */ +@injectable() +export class NodeClientLocalPlayerBridge extends IClientLocalPlayerBridge { + setPosition(_position: Vector3, _heading?: number): void {} +} diff --git a/src/runtime/client/adapter/node-log-console.ts b/src/runtime/client/adapter/node-log-console.ts new file mode 100644 index 0000000..9cdd87a --- /dev/null +++ b/src/runtime/client/adapter/node-log-console.ts @@ -0,0 +1,52 @@ +import { injectable } from 'tsyringe' +import { + IClientLogConsole, + type ClientLogConsoleCapabilities, +} from '../../../adapters/contracts/client/IClientLogConsole' +import { setClientLogConsole } from '../../../kernel/logger' + +const NODE_CLIENT_LOG_CAPABILITIES: ClientLogConsoleCapabilities = { + supportsColors: false, + supportsStructuredData: true, + supportsRichFormatting: false, +} + +@injectable() +export class NodeClientLogConsole extends IClientLogConsole { + getCapabilities(): ClientLogConsoleCapabilities { + return NODE_CLIENT_LOG_CAPABILITIES + } + + trace(message: string, details?: unknown): void { + this.write(console.debug, message, details) + } + + debug(message: string, details?: unknown): void { + this.write(console.debug, message, details) + } + + info(message: string, details?: unknown): void { + this.write(console.info, message, details) + } + + warn(message: string, details?: unknown): void { + this.write(console.warn, message, details) + } + + error(message: string, details?: unknown): void { + this.write(console.error, message, details) + } + + private write(method: (...args: unknown[]) => void, message: string, details?: unknown): void { + if (details === undefined) { + method(message) + return + } + + method(message, details) + } +} + +export function installNodeClientLogConsole(logConsole: IClientLogConsole): void { + setClientLogConsole(logConsole) +} diff --git a/src/runtime/client/adapter/node-marker-bridge.ts b/src/runtime/client/adapter/node-marker-bridge.ts new file mode 100644 index 0000000..9322f3b --- /dev/null +++ b/src/runtime/client/adapter/node-marker-bridge.ts @@ -0,0 +1,35 @@ +import { injectable } from 'tsyringe' +import { + IClientMarkerBridge, + type ClientMarkerDefinition, +} from '../../../adapters/contracts/client/ui/IClientMarkerBridge' + +@injectable() +export class NodeClientMarkerBridge extends IClientMarkerBridge { + private readonly markers = new Map() + + create(id: string, definition: ClientMarkerDefinition): void { + this.markers.set(id, { ...definition }) + } + + update(id: string, patch: Partial): boolean { + const existing = this.markers.get(id) + if (!existing) return false + this.markers.set(id, { ...existing, ...patch }) + return true + } + + remove(id: string): boolean { + return this.markers.delete(id) + } + + exists(id: string): boolean { + return this.markers.has(id) + } + + clear(): void { + this.markers.clear() + } + + draw(_definition: ClientMarkerDefinition): void {} +} diff --git a/src/runtime/client/adapter/node-notification-bridge.ts b/src/runtime/client/adapter/node-notification-bridge.ts new file mode 100644 index 0000000..b599ab7 --- /dev/null +++ b/src/runtime/client/adapter/node-notification-bridge.ts @@ -0,0 +1,12 @@ +import { injectable } from 'tsyringe' +import { + IClientNotificationBridge, + type ClientNotificationDefinition, +} from '../../../adapters/contracts/client/ui/IClientNotificationBridge' + +@injectable() +export class NodeClientNotificationBridge extends IClientNotificationBridge { + show(_definition: ClientNotificationDefinition): void {} + + clear(_scope?: 'help' | 'subtitle' | 'all'): void {} +} diff --git a/src/runtime/client/adapter/node-platform-bridge.ts b/src/runtime/client/adapter/node-platform-bridge.ts new file mode 100644 index 0000000..a383e0d --- /dev/null +++ b/src/runtime/client/adapter/node-platform-bridge.ts @@ -0,0 +1,5 @@ +import { injectable } from 'tsyringe' +import { IClientPlatformBridge } from './platform-bridge' + +@injectable() +export class NodeClientPlatformBridge extends IClientPlatformBridge {} diff --git a/src/runtime/client/adapter/node-runtime-bridge.ts b/src/runtime/client/adapter/node-runtime-bridge.ts new file mode 100644 index 0000000..dbfcc1a --- /dev/null +++ b/src/runtime/client/adapter/node-runtime-bridge.ts @@ -0,0 +1,74 @@ +import { EventEmitter } from 'node:events' +import { injectable } from 'tsyringe' +import { IClientRuntimeBridge } from './runtime-bridge' + +type RuntimeHandler = (...args: readonly unknown[]) => void | Promise +type WebViewCallback = (data: unknown, cb: (response: unknown) => void) => void | Promise +type RuntimeExport = (...args: readonly unknown[]) => unknown + +/** + * Node fallback runtime bridge used in tests and standalone execution. + */ +@injectable() +export class NodeClientRuntimeBridge extends IClientRuntimeBridge { + private readonly events = new EventEmitter() + private readonly tickHandles = new Set>() + + getCurrentResourceName(): string { + return process.env.RESOURCE_NAME || 'default' + } + + on(eventName: string, handler: RuntimeHandler): void { + this.events.on(eventName, (...args) => { + void handler(...args) + }) + } + + registerCommand( + _commandName: string, + _handler: (...args: readonly unknown[]) => void, + _restricted: boolean, + ): void {} + + registerKeyMapping( + _commandName: string, + _description: string, + _inputMapper: string, + _key: string, + ): void {} + + setTick(handler: () => void | Promise): unknown { + const tick = setInterval(() => { + void handler() + }, 0) + this.tickHandles.add(tick) + return tick + } + + clearTick(handle: unknown): void { + if (handle && this.tickHandles.has(handle as ReturnType)) { + clearInterval(handle as ReturnType) + this.tickHandles.delete(handle as ReturnType) + } + } + + getGameTimer(): number { + return Date.now() + } + + registerNuiCallback(eventName: string, handler: WebViewCallback): void { + this.events.on(`__nui:${eventName}`, (data, cb) => { + void handler(data, cb) + }) + } + + sendNuiMessage(_message: string): void {} + + setNuiFocus(_hasFocus: boolean, _hasCursor: boolean): void {} + + setNuiFocusKeepInput(_keepInput: boolean): void {} + + registerExport(exportName: string, handler: RuntimeExport): void { + ;(globalThis as Record)[`__client_export:${exportName}`] = handler + } +} diff --git a/src/runtime/client/adapter/node-spawn-bridge.ts b/src/runtime/client/adapter/node-spawn-bridge.ts new file mode 100644 index 0000000..81ded5c --- /dev/null +++ b/src/runtime/client/adapter/node-spawn-bridge.ts @@ -0,0 +1,18 @@ +import { injectable } from 'tsyringe' +import { IClientSpawnBridge } from '../../../adapters/contracts/client/spawn/IClientSpawnBridge' +import type { + RespawnRequest, + SpawnRequest, + TeleportRequest, +} from '../../../adapters/contracts/client/spawn/types' + +@injectable() +export class NodeClientSpawnBridge extends IClientSpawnBridge { + async waitUntilReady(_timeoutMs?: number): Promise {} + + async spawn(_request: SpawnRequest): Promise {} + + async respawn(_request: RespawnRequest): Promise {} + + async teleport(_request: TeleportRequest): Promise {} +} diff --git a/src/runtime/client/adapter/node-webview-bridge.ts b/src/runtime/client/adapter/node-webview-bridge.ts new file mode 100644 index 0000000..aec03fb --- /dev/null +++ b/src/runtime/client/adapter/node-webview-bridge.ts @@ -0,0 +1,42 @@ +import { injectable } from 'tsyringe' +import { IClientWebViewBridge } from '../../../adapters/contracts/client/ui/webview/IClientWebViewBridge' +import type { + WebViewCapabilities, + WebViewDefinition, + WebViewFocusOptions, + WebViewMessage, +} from '../../../adapters/contracts/client/ui/webview/types' + +const NODE_WEBVIEW_CAPABILITIES: WebViewCapabilities = { + supportsFocus: false, + supportsCursor: false, + supportsInputPassthrough: false, + supportsBidirectionalMessaging: false, + supportsExecute: false, + supportsHeadless: false, +} + +@injectable() +export class NodeClientWebViewBridge extends IClientWebViewBridge { + private readonly views = new Set() + getCapabilities(): WebViewCapabilities { + return NODE_WEBVIEW_CAPABILITIES + } + create(definition: WebViewDefinition): void { + this.views.add(definition.id) + } + destroy(viewId: string): void { + this.views.delete(viewId) + } + exists(viewId: string): boolean { + return this.views.has(viewId) + } + show(_viewId: string): void {} + hide(_viewId: string): void {} + focus(_viewId: string, _options?: WebViewFocusOptions): void {} + blur(_viewId: string): void {} + send(_viewId: string, _event: string, _payload: unknown): void {} + onMessage(_handler: (message: WebViewMessage) => void | Promise): () => void { + return () => {} + } +} diff --git a/src/runtime/client/adapter/platform-bridge.ts b/src/runtime/client/adapter/platform-bridge.ts new file mode 100644 index 0000000..64de216 --- /dev/null +++ b/src/runtime/client/adapter/platform-bridge.ts @@ -0,0 +1,4 @@ +export { + IClientPlatformBridge, + type TextDrawOptions, +} from '../../../adapters/contracts/client/IClientPlatformBridge' diff --git a/src/runtime/client/adapter/registry.ts b/src/runtime/client/adapter/registry.ts new file mode 100644 index 0000000..624ad9b --- /dev/null +++ b/src/runtime/client/adapter/registry.ts @@ -0,0 +1,112 @@ +import type { InjectionToken } from 'tsyringe' +import { di } from '../client-container' +import { + type OpenCoreClientAdapter, + type ClientAdapterContext, + bindClientTransportInstances, +} from './client-adapter' +import { IClientRuntimeBridge } from './runtime-bridge' + +declare const __OPENCORE_TARGET__: 'client' | 'server' | undefined + +let activeClientAdapterName: string | null = null + +async function getDefaultClientAdapter(): Promise { + if (typeof __OPENCORE_TARGET__ !== 'undefined' && __OPENCORE_TARGET__ === 'client') { + throw new Error( + '[OpenCore] No client adapter provided. Configure one in opencore.config.ts or pass adapter to Client.init().', + ) + } + + const { createNodeClientAdapter } = await import('./node-client-adapter') + return createNodeClientAdapter() +} + +function assertTokenAvailable(token: InjectionToken, adapterName: string): void { + if (di.isRegistered(token)) { + throw new Error(`[OpenCore] Adapter '${adapterName}' cannot bind an already registered token.`) + } +} + +function createAdapterContext(adapterName: string): ClientAdapterContext { + return { + adapterName, + container: di, + isRegistered(token: InjectionToken): boolean { + return di.isRegistered(token) + }, + bindSingleton(token: InjectionToken, implementation: InjectionToken): void { + assertTokenAvailable(token, adapterName) + di.registerSingleton(token, implementation) + }, + bindInstance(token: InjectionToken, value: T): void { + assertTokenAvailable(token, adapterName) + di.registerInstance(token, value) + }, + bindFactory(token: InjectionToken, factory: () => T): void { + assertTokenAvailable(token, adapterName) + di.register(token, { useFactory: factory }) + }, + bindMessagingTransport(transport) { + bindClientTransportInstances(this, transport) + }, + useRuntimeBridge(runtime: IClientRuntimeBridge): void { + this.bindInstance(IClientRuntimeBridge as InjectionToken, runtime) + }, + } +} + +/** + * Installs the active client adapter for the current bootstrap. + */ +export async function installClientAdapter(adapter?: OpenCoreClientAdapter): Promise { + const active = adapter ?? (await getDefaultClientAdapter()) + if (activeClientAdapterName) { + if (activeClientAdapterName !== active.name) { + throw new Error( + `[OpenCore] Client adapter '${active.name}' cannot be installed because '${activeClientAdapterName}' is already active.`, + ) + } + + return + } + + await active.register(createAdapterContext(active.name)) + activeClientAdapterName = active.name +} + +export function getActiveClientAdapterName(): string | undefined { + return activeClientAdapterName ?? undefined +} + +export function assertClientAdapterCompatibility(adapter?: OpenCoreClientAdapter): void { + if (!adapter || !activeClientAdapterName) { + return + } + + if (adapter.name !== activeClientAdapterName) { + throw new Error( + `[OpenCore] Client adapter '${adapter.name}' does not match active adapter '${activeClientAdapterName}'.`, + ) + } +} + +export function getCurrentClientResourceName(): string { + if (di.isRegistered(IClientRuntimeBridge as InjectionToken)) { + return di + .resolve(IClientRuntimeBridge as InjectionToken) + .getCurrentResourceName() + } + + const fn = (globalThis as Record).GetCurrentResourceName + if (typeof fn === 'function') { + const name = fn() + if (typeof name === 'string' && name.trim()) return name + } + + return 'default' +} + +export function __resetClientAdapterRegistryForTests(): void { + activeClientAdapterName = null +} diff --git a/src/runtime/client/adapter/runtime-bridge.ts b/src/runtime/client/adapter/runtime-bridge.ts new file mode 100644 index 0000000..918de22 --- /dev/null +++ b/src/runtime/client/adapter/runtime-bridge.ts @@ -0,0 +1 @@ +export { IClientRuntimeBridge } from '../../../adapters/contracts/client/IClientRuntimeBridge' diff --git a/src/runtime/client/api.ts b/src/runtime/client/api.ts index 38ed110..5887df2 100644 --- a/src/runtime/client/api.ts +++ b/src/runtime/client/api.ts @@ -1,8 +1,9 @@ export * from './client-core' export * from './client-runtime' +export * from './adapter' export * from './decorators' export * from './library' -export * from './player/player' export * from './services' export * from './types' +export * from './webview-bridge' export * from './ui-bridge' diff --git a/src/runtime/client/client-bootstrap.ts b/src/runtime/client/client-bootstrap.ts index dd5d903..59479b5 100644 --- a/src/runtime/client/client-bootstrap.ts +++ b/src/runtime/client/client-bootstrap.ts @@ -1,6 +1,11 @@ -import { registerClientCapabilities } from '../../adapters/register-client-capabilities' import { MetadataScanner } from '../../kernel/di/metadata.scanner' import { loggers } from '../../kernel/logger' +import { + assertClientAdapterCompatibility, + getActiveClientAdapterName, + getCurrentClientResourceName, + installClientAdapter, +} from './adapter/registry' import { di } from './client-container' import { type ClientInitOptions, @@ -9,7 +14,6 @@ import { setClientRuntimeContext, } from './client-runtime' import { getClientControllerRegistry } from './decorators' -import { playerClientLoader } from './player/player.loader' import { BlipService, Camera, @@ -19,6 +23,7 @@ import { NotificationService, PedService, ProgressService, + ClientSessionBridgeService, SpawnService, StreamingService, TextUIService, @@ -26,7 +31,8 @@ import { VehicleService, } from './services' import { registerSystemClient } from './system/processors.register' -import { NuiBridge } from './ui-bridge' +import { WebViewBridge } from './webview-bridge' +import { WebViewService } from './webview.service' /** * Services that have an init() method which registers global runtime event listeners. @@ -35,14 +41,18 @@ import { NuiBridge } from './ui-bridge' * - Registered in DI for ALL modes (so they can be injected and used) * - Only initialized (.init() called) in CORE mode to avoid duplicate event handlers */ -const SERVICES_WITH_GLOBAL_LISTENERS = [SpawnService] as const +const SERVICES_WITH_GLOBAL_LISTENERS: Array< + new ( + ...args: any[] + ) => { init?: () => Promise | void } +> = [SpawnService, ClientSessionBridgeService] /** * All client services that should be available in the DI container */ // const ALL_CLIENT_SERVICES = [ // SpawnService, -// NuiBridge, +// WebViewBridge, // NotificationService, // TextUIService, // ProgressService, @@ -54,18 +64,6 @@ const SERVICES_WITH_GLOBAL_LISTENERS = [SpawnService] as const // StreamingService, // ] as const -/** - * Get current resource name safely - */ -function getCurrentResourceNameSafe(): string { - const fn = (globalThis as any).GetCurrentResourceName - if (typeof fn === 'function') { - const name = fn() - if (typeof name === 'string' && name.trim()) return name - } - return 'default' -} - /** * Register singleton services in the DI container * @@ -75,20 +73,26 @@ function getCurrentResourceNameSafe(): string { */ function registerServices() { // Register all client services in DI (available in all modes) - di.registerSingleton(SpawnService, SpawnService) - di.registerSingleton(NuiBridge, NuiBridge) - di.registerSingleton(NotificationService, NotificationService) - di.registerSingleton(TextUIService, TextUIService) - di.registerSingleton(ProgressService, ProgressService) - di.registerSingleton(MarkerService, MarkerService) - di.registerSingleton(BlipService, BlipService) - di.registerSingleton(Camera, Camera) - di.registerSingleton(CameraEffectsRegistry, CameraEffectsRegistry) - di.registerSingleton(Cinematic, Cinematic) - di.registerSingleton(VehicleClientService, VehicleClientService) - di.registerSingleton(VehicleService, VehicleService) - di.registerSingleton(PedService, PedService) - di.registerSingleton(StreamingService, StreamingService) + if (!di.isRegistered(SpawnService)) di.registerSingleton(SpawnService, SpawnService) + if (!di.isRegistered(WebViewService)) di.registerSingleton(WebViewService, WebViewService) + if (!di.isRegistered(WebViewBridge)) di.registerSingleton(WebViewBridge, WebViewBridge) + if (!di.isRegistered(NotificationService)) + di.registerSingleton(NotificationService, NotificationService) + if (!di.isRegistered(TextUIService)) di.registerSingleton(TextUIService, TextUIService) + if (!di.isRegistered(ProgressService)) di.registerSingleton(ProgressService, ProgressService) + if (!di.isRegistered(ClientSessionBridgeService)) + di.registerSingleton(ClientSessionBridgeService, ClientSessionBridgeService) + if (!di.isRegistered(MarkerService)) di.registerSingleton(MarkerService, MarkerService) + if (!di.isRegistered(BlipService)) di.registerSingleton(BlipService, BlipService) + if (!di.isRegistered(Camera)) di.registerSingleton(Camera, Camera) + if (!di.isRegistered(CameraEffectsRegistry)) + di.registerSingleton(CameraEffectsRegistry, CameraEffectsRegistry) + if (!di.isRegistered(Cinematic)) di.registerSingleton(Cinematic, Cinematic) + if (!di.isRegistered(VehicleClientService)) + di.registerSingleton(VehicleClientService, VehicleClientService) + if (!di.isRegistered(VehicleService)) di.registerSingleton(VehicleService, VehicleService) + if (!di.isRegistered(PedService)) di.registerSingleton(PedService, PedService) + if (!di.isRegistered(StreamingService)) di.registerSingleton(StreamingService, StreamingService) } /** @@ -134,16 +138,17 @@ async function tryImportAutoLoad() { */ export async function initClientCore(options: ClientInitOptions = {}) { const mode: ClientMode = options.mode ?? 'CORE' - const resourceName = getCurrentResourceNameSafe() - - // Register system processors early (needed for MetadataScanner) - // These processors are safe - they just process metadata, they don't register event handlers - // Each resource bundle needs its own copy registered in its DI container - registerSystemClient() // Check if already initialized const existingContext = getClientRuntimeContext() if (existingContext?.isInitialized) { + assertClientAdapterCompatibility(options.adapter) + + // Register system processors for the active bundle if needed. + registerSystemClient() + + const resourceName = getCurrentClientResourceName() + // If already initialized, only scan controllers for this resource if (mode === 'RESOURCE' || mode === 'STANDALONE') { await tryImportAutoLoad() @@ -159,6 +164,18 @@ export async function initClientCore(options: ClientInitOptions = {}) { ) } + await installClientAdapter(options.adapter) + loggers.bootstrap.debug('Client adapter registered', { + adapter: getActiveClientAdapterName() ?? 'unknown', + }) + + const resourceName = getCurrentClientResourceName() + + // Register system processors early (needed for MetadataScanner) + // These processors are safe - they just process metadata, they don't register event handlers + // Each resource bundle needs its own copy registered in its DI container + registerSystemClient() + // Set runtime context setClientRuntimeContext({ mode, @@ -166,9 +183,6 @@ export async function initClientCore(options: ClientInitOptions = {}) { isInitialized: true, }) - // Register client-side adapters (IPedAppearanceClient, IHasher) - await registerClientCapabilities() - // Register all services in DI (available in all modes) registerServices() @@ -176,17 +190,11 @@ export async function initClientCore(options: ClientInitOptions = {}) { // This is where services that register global event handlers are initialized await bootstrapServices(mode) - // Player loader (only in CORE mode) - if (mode === 'CORE') { - playerClientLoader() - } - // Import framework controllers (only in CORE mode) // These controllers listen to global events and should only be registered once if (mode === 'CORE') { await import('./controllers/spawner.controller') await import('./controllers/appearance.controller') - await import('./controllers/player-sync.controller') } await tryImportAutoLoad() diff --git a/src/runtime/client/client-core.ts b/src/runtime/client/client-core.ts index b909c02..e8b4b8f 100644 --- a/src/runtime/client/client-core.ts +++ b/src/runtime/client/client-core.ts @@ -1,7 +1,15 @@ import { initClientCore } from './client-bootstrap' +import type { OpenCoreClientAdapter } from './adapter' +import { installClientAdapter } from './adapter/registry' import { ClientInitOptions } from './client-runtime' import { installClientPlugins } from './library/plugin/install-client-plugins' +let pendingAdapter: OpenCoreClientAdapter | undefined + +export function useAdapter(adapter: OpenCoreClientAdapter): void { + pendingAdapter = adapter +} + /** * Initialize the OpenCore client framework * @@ -20,6 +28,11 @@ import { installClientPlugins } from './library/plugin/install-client-plugins' * ``` */ export async function init(options: ClientInitOptions = {}) { + if (!options.adapter && pendingAdapter) { + options = { ...options, adapter: pendingAdapter } + } + + await installClientAdapter(options.adapter) await installClientPlugins(options.plugins ?? [], options) await initClientCore(options) } diff --git a/src/runtime/client/client-runtime.ts b/src/runtime/client/client-runtime.ts index e80542b..e3aa935 100644 --- a/src/runtime/client/client-runtime.ts +++ b/src/runtime/client/client-runtime.ts @@ -38,6 +38,9 @@ export interface ClientInitOptions { */ mode?: ClientMode + /** Optional runtime adapter for non-node client environments. */ + adapter?: import('./adapter').OpenCoreClientAdapter + /** * Optional client plugins installed before bootstrap. */ @@ -77,3 +80,7 @@ export function setClientRuntimeContext(ctx: ClientRuntimeContext): void { export function isClientInitialized(): boolean { return runtimeContext?.isInitialized ?? false } + +export function __resetClientRuntimeContextForTests(): void { + runtimeContext = null +} diff --git a/src/runtime/client/controllers/appearance.controller.ts b/src/runtime/client/controllers/appearance.controller.ts index 726e6b0..0d25e3c 100644 --- a/src/runtime/client/controllers/appearance.controller.ts +++ b/src/runtime/client/controllers/appearance.controller.ts @@ -1,21 +1,28 @@ import { inject } from 'tsyringe' import { PlayerAppearance } from '../../../kernel/shared' import { Controller, OnNet } from '../decorators' +import { IClientPlatformBridge } from '../adapter/platform-bridge' import { AppearanceService } from '../services/appearance.service' +import { loggers } from '../../../kernel/logger' @Controller() export class AppearanceTestClientController { constructor( @inject(AppearanceService as any) private readonly appearanceService: AppearanceService, + @inject(IClientPlatformBridge as any) private readonly platform: IClientPlatformBridge, ) {} @OnNet('opencore:appearance:apply') async onApply(appearance: PlayerAppearance): Promise { - const ped = PlayerPedId() + loggers.netEvent.debug('appearance:apply received', { + appearance, + }) + const ped = this.platform.getLocalPlayerPed() await this.appearanceService.applyAppearance(ped, appearance) } @OnNet('opencore:appearance:reset') onReset(): void { - const ped = PlayerPedId() + loggers.netEvent.debug('appearance:reset received') + const ped = this.platform.getLocalPlayerPed() this.appearanceService.setDefaultAppearance(ped) this.appearanceService.clearTattoos(ped) } diff --git a/src/runtime/client/controllers/player-sync.controller.ts b/src/runtime/client/controllers/player-sync.controller.ts deleted file mode 100644 index 8f6b328..0000000 --- a/src/runtime/client/controllers/player-sync.controller.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Controller } from '../decorators' - -/** - * Client-side service that syncs player health and armor from server state bags. - * - * @remarks - * Listens to state bag changes from the server and applies them to the local player ped. - * This allows the server to control player health and armor through simple state bag updates. - */ -@Controller() -export class PlayerSyncController { - constructor() { - this.registerStateBagHandlers() - } - - private registerStateBagHandlers(): void { - // Sync health from server state bag to local ped - AddStateBagChangeHandler('health', '', (bagName: string, _key: string, value: number) => { - const entity = GetEntityFromStateBagName(bagName) - if (entity === 0) return - - const playerPed = PlayerPedId() - if (entity !== playerPed) return - - // Apply health to local ped - SetEntityHealth(entity, value) - }) - - // Sync armor from server state bag to local ped - AddStateBagChangeHandler('armor', '', (bagName: string, _key: string, value: number) => { - const entity = GetEntityFromStateBagName(bagName) - if (entity === 0) return - - const playerPed = PlayerPedId() - if (entity !== playerPed) return - - // Apply armor to local ped - SetPedArmour(entity, value) - }) - } -} diff --git a/src/runtime/client/controllers/spawner.controller.ts b/src/runtime/client/controllers/spawner.controller.ts index ca10398..db6b1e6 100644 --- a/src/runtime/client/controllers/spawner.controller.ts +++ b/src/runtime/client/controllers/spawner.controller.ts @@ -1,4 +1,5 @@ import { Vector3 } from '../../../kernel/utils/vector3' +import { loggers } from '../../../kernel/logger' import { Controller, OnNet } from '../decorators' import { SpawnService } from '../services' @@ -8,6 +9,7 @@ export class SpawnerController { @OnNet('opencore:spawner:spawn') async handleSpawn(data: { position: Vector3; model: string }) { + loggers.spawn.debug('Spawn event received', data) await this.spawnService.spawn(data.position, data.model) } diff --git a/src/runtime/client/decorators/controller.ts b/src/runtime/client/decorators/controller.ts index f8425a3..3c7b04f 100644 --- a/src/runtime/client/decorators/controller.ts +++ b/src/runtime/client/decorators/controller.ts @@ -1,19 +1,11 @@ import { injectable } from 'tsyringe' import { ClassConstructor } from '../../../kernel/di/class-constructor' +import { getCurrentClientResourceName } from '../adapter/registry' import { METADATA_KEYS } from '../system/metadata-client.keys' import { getClientRuntimeContext } from '../client-runtime' const clientControllerRegistryByResource = new Map>() -function getCurrentResourceNameSafe(): string { - const fn = (globalThis as any).GetCurrentResourceName - if (typeof fn === 'function') { - const name = fn() - if (typeof name === 'string' && name.trim()) return name - } - return 'default' -} - function resolveRegistryKey(resourceName?: string): string { if (resourceName?.trim()) { return resourceName @@ -24,7 +16,7 @@ function resolveRegistryKey(resourceName?: string): string { return runtime.resourceName } - return getCurrentResourceNameSafe() + return getCurrentClientResourceName() } export function getClientControllerRegistry(resourceName?: string): ClassConstructor[] { diff --git a/src/runtime/client/decorators/onView.ts b/src/runtime/client/decorators/onView.ts index 191abbc..bc2d921 100644 --- a/src/runtime/client/decorators/onView.ts +++ b/src/runtime/client/decorators/onView.ts @@ -1,13 +1,13 @@ import { METADATA_KEYS } from '../system/metadata-client.keys' /** - * Registers a method as an onView callback handler. View are equal to NUI + * Registers a method as a WebView callback handler. * * @remarks * This decorator only stores metadata. During bootstrap, the framework binds the decorated method - * to the NUI callback event name. + * to the active WebView runtime callback. * - * @param eventName - onView callback name. + * @param eventName - Callback name. * * @example * ```ts diff --git a/src/runtime/client/index.ts b/src/runtime/client/index.ts index 1e39337..b6c7168 100644 --- a/src/runtime/client/index.ts +++ b/src/runtime/client/index.ts @@ -1,4 +1,5 @@ export * from './api' +export * from './adapter' export { Client, createClientRuntime } from './client-api-runtime' export type { ClientApi } from './client-api-runtime' export type { diff --git a/src/runtime/client/library/create-client-library.ts b/src/runtime/client/library/create-client-library.ts index b99248a..5248379 100644 --- a/src/runtime/client/library/create-client-library.ts +++ b/src/runtime/client/library/create-client-library.ts @@ -1,3 +1,5 @@ +import { EventsAPI } from '../../../adapters/contracts/transport/events.api' +import { di } from '../client-container' import { coreLogger } from '../../../kernel/logger' import { buildLibraryEventId, @@ -25,6 +27,9 @@ export function createClientLibrary( const base = createLibraryBase(name, opts) const logger = coreLogger.client(`Library:${base.name}`) const emitInternal = base.emit + const events = di.isRegistered(EventsAPI as any) + ? (di.resolve(EventsAPI as any) as EventsAPI<'client'>) + : null return { ...base, @@ -47,7 +52,11 @@ export function createClientLibrary( emitLibraryEvent(eventId, envelope) }, emitServer(eventName, payload) { - emitNet(base.buildEventName(eventName), payload) + if (!events) { + throw new Error('[OpenCore] Client events transport is not registered.') + } + + events.emit(base.buildEventName(eventName), payload) }, getLogger() { return logger diff --git a/src/runtime/client/player/player.loader.ts b/src/runtime/client/player/player.loader.ts deleted file mode 100644 index bace5d9..0000000 --- a/src/runtime/client/player/player.loader.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { coreLogger, LogDomain } from '../../../kernel/logger' -import { Vec3 } from '../../../kernel/utils/vector3' -import { ClientPlayer } from './player' - -const clientSession = coreLogger.child('Session', LogDomain.CLIENT) - -export const playerClientLoader = () => { - on('onClientResourceStart', (resourceName: string) => { - if (resourceName !== GetCurrentResourceName()) return - clientSession.debug('Client player loader initialized') - }) - onNet('core:playerSessionInit', (data: { playerId: string }) => { - ClientPlayer.setMeta('playerId', data.playerId) - clientSession.info('Player session initialized', { playerId: data.playerId }) - }) - onNet('core:teleportTo', (x: number, y: number, z: number, heading?: number) => { - ClientPlayer.setCoords(Vec3.create(x, y, z), heading) - }) -} diff --git a/src/runtime/client/player/player.ts b/src/runtime/client/player/player.ts deleted file mode 100644 index 1153548..0000000 --- a/src/runtime/client/player/player.ts +++ /dev/null @@ -1,567 +0,0 @@ -import { Vector3 } from '../../../kernel/utils/vector3' - -interface PlayerSessionMeta { - playerId?: string - [key: string]: unknown -} - -/** - * Client-side player representation with convenient accessors and methods. - */ -class Player { - private meta: PlayerSessionMeta = {} - - // ───────────────────────────────────────────────────────────────────────────── - // Core Getters - // ───────────────────────────────────────────────────────────────────────────── - - /** Get the player's ped handle */ - get ped(): number { - return PlayerPedId() - } - - /** Get the player ID */ - get id(): number { - return PlayerId() - } - - /** Get the server ID (source) */ - get serverId(): number { - return GetPlayerServerId(this.id) - } - - /** Get the player's current coordinates */ - get coords(): Vector3 { - const [x, y, z] = GetEntityCoords(this.ped, false) - return { x, y, z } - } - - /** Get the player's current heading (rotation) */ - get heading(): number { - return GetEntityHeading(this.ped) - } - - // ───────────────────────────────────────────────────────────────────────────── - // Health & Status - // ───────────────────────────────────────────────────────────────────────────── - - /** Get current health (0-200, 100 = dead) */ - get health(): number { - return GetEntityHealth(this.ped) - } - - /** Get max health */ - get maxHealth(): number { - return GetEntityMaxHealth(this.ped) - } - - /** Get current armor (0-100) */ - get armor(): number { - return GetPedArmour(this.ped) - } - - /** Check if the player is dead */ - get isDead(): boolean { - return IsEntityDead(this.ped) - } - - /** Check if player is in water */ - get isInWater(): boolean { - return IsEntityInWater(this.ped) - } - - /** Check if player is swimming */ - get isSwimming(): boolean { - return IsPedSwimming(this.ped) - } - - /** Check if player is falling */ - get isFalling(): boolean { - return IsPedFalling(this.ped) - } - - /** Check if player is climbing */ - get isClimbing(): boolean { - return IsPedClimbing(this.ped) - } - - /** Check if player is ragdolling */ - get isRagdoll(): boolean { - return IsPedRagdoll(this.ped) - } - - /** Check if player is parachuting */ - get isParachuting(): boolean { - return IsPedInParachuteFreeFall(this.ped) - } - - // ───────────────────────────────────────────────────────────────────────────── - // Vehicle State - // ───────────────────────────────────────────────────────────────────────────── - - /** Check if player is in any vehicle */ - get isInVehicle(): boolean { - return IsPedInAnyVehicle(this.ped, false) - } - - /** Get current vehicle handle (or null if not in vehicle) */ - get currentVehicle(): number | null { - if (!this.isInVehicle) return null - return GetVehiclePedIsIn(this.ped, false) - } - - /** Get last vehicle the player was in */ - get lastVehicle(): number | null { - const vehicle = GetVehiclePedIsIn(this.ped, true) - return vehicle !== 0 ? vehicle : null - } - - /** Check if player is the driver of current vehicle */ - get isDriver(): boolean { - const vehicle = this.currentVehicle - if (!vehicle) return false - return GetPedInVehicleSeat(vehicle, -1) === this.ped - } - - /** Get current vehicle seat index (-1 = driver, 0+ = passengers) */ - get vehicleSeat(): number | null { - const vehicle = this.currentVehicle - if (!vehicle) return null - - for (let seat = -1; seat < GetVehicleMaxNumberOfPassengers(vehicle); seat++) { - if (GetPedInVehicleSeat(vehicle, seat) === this.ped) { - return seat - } - } - return null - } - - // ───────────────────────────────────────────────────────────────────────────── - // Combat State - // ───────────────────────────────────────────────────────────────────────────── - - /** Check if player is shooting */ - get isShooting(): boolean { - return IsPedShooting(this.ped) - } - - /** Check if player is aiming */ - get isAiming(): boolean { - return IsPlayerFreeAiming(this.id) - } - - /** Check if player is reloading */ - get isReloading(): boolean { - return IsPedReloading(this.ped) - } - - /** Check if player is in cover */ - get isInCover(): boolean { - return IsPedInCover(this.ped, false) - } - - /** Check if player is in melee combat */ - get isInMeleeCombat(): boolean { - return IsPedInMeleeCombat(this.ped) - } - - /** Get currently equipped weapon hash */ - get currentWeapon(): number { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [_, weaponHash] = GetCurrentPedWeapon(this.ped, true) - return weaponHash - } - - /** Get ammo count for current weapon */ - get currentWeaponAmmo(): number { - return GetAmmoInPedWeapon(this.ped, this.currentWeapon) - } - - // ───────────────────────────────────────────────────────────────────────────── - // Movement State - // ───────────────────────────────────────────────────────────────────────────── - - /** Check if player is walking */ - get isWalking(): boolean { - return IsPedWalking(this.ped) - } - - /** Check if player is running */ - get isRunning(): boolean { - return IsPedRunning(this.ped) - } - - /** Check if player is sprinting */ - get isSprinting(): boolean { - return IsPedSprinting(this.ped) - } - - /** Check if player is on foot */ - get isOnFoot(): boolean { - return IsPedOnFoot(this.ped) - } - - /** Check if player is stationary */ - get isStill(): boolean { - return IsPedStill(this.ped) - } - - /** Get movement speed in m/s */ - get speed(): number { - return GetEntitySpeed(this.ped) - } - - // ───────────────────────────────────────────────────────────────────────────── - // Health & Armor Setters - // ───────────────────────────────────────────────────────────────────────────── - - /** - * Set player health. - * @param value - Health value (100 = dead, 200 = full) - */ - setHealth(value: number): void { - SetEntityHealth(this.ped, value) - } - - /** - * Set player armor. - * @param value - Armor value (0-100) - */ - setArmor(value: number): void { - SetPedArmour(this.ped, Math.max(0, Math.min(100, value))) - } - - /** - * Heal the player to full health and armor. - */ - heal(): void { - this.setHealth(this.maxHealth) - this.setArmor(100) - } - - /** - * Revive the player at current position. - */ - revive(): void { - const coords = this.coords - NetworkResurrectLocalPlayer(coords.x, coords.y, coords.z, this.heading, 1, false) - this.heal() - } - - // ───────────────────────────────────────────────────────────────────────────── - // Position & Movement - // ───────────────────────────────────────────────────────────────────────────── - - /** - * Set player coordinates. - * @param vector3 - Target position - * @param heading - Optional heading - */ - setCoords(vector3: Vector3, heading?: number): void { - SetEntityCoordsNoOffset(this.ped, vector3.x, vector3.y, vector3.z, false, false, false) - if (heading !== undefined) { - SetEntityHeading(this.ped, heading) - } - } - - /** - * Set player heading/rotation. - * @param heading - Heading in degrees - */ - setHeading(heading: number): void { - SetEntityHeading(this.ped, heading) - } - - /** - * Freeze/unfreeze the player in place. - * @param freeze - Whether to freeze - */ - freeze(freeze: boolean): void { - FreezeEntityPosition(this.ped, freeze) - } - - /** - * Set player invincibility. - * @param invincible - Whether invincible - */ - setInvincible(invincible: boolean): void { - SetEntityInvincible(this.ped, invincible) - } - - /** - * Set player visibility. - * @param visible - Whether visible - */ - setVisible(visible: boolean): void { - SetEntityVisible(this.ped, visible, false) - } - - /** - * Set player alpha/transparency. - * @param alpha - Alpha value (0-255) - */ - setAlpha(alpha: number): void { - SetEntityAlpha(this.ped, alpha, false) - } - - // ───────────────────────────────────────────────────────────────────────────── - // Weapons - // ───────────────────────────────────────────────────────────────────────────── - - /** - * Give a weapon to the player. - * @param weapon - Weapon name (e.g., 'WEAPON_PISTOL') - * @param ammo - Ammo count - * @param equipNow - Whether to equip immediately - */ - giveWeapon(weapon: string, ammo = 100, equipNow = true): void { - const weaponHash = GetHashKey(weapon) - GiveWeaponToPed(this.ped, weaponHash, ammo, false, equipNow) - } - - /** - * Remove a weapon from the player. - * @param weapon - Weapon name - */ - removeWeapon(weapon: string): void { - const weaponHash = GetHashKey(weapon) - RemoveWeaponFromPed(this.ped, weaponHash) - } - - /** - * Remove all weapons from the player. - */ - removeAllWeapons(): void { - RemoveAllPedWeapons(this.ped, true) - } - - /** - * Set current weapon ammo. - * @param weapon - Weapon name - * @param ammo - Ammo count - */ - setWeaponAmmo(weapon: string, ammo: number): void { - const weaponHash = GetHashKey(weapon) - SetPedAmmo(this.ped, weaponHash, ammo) - } - - /** - * Check if player has a specific weapon. - * @param weapon - Weapon name - */ - hasWeapon(weapon: string): boolean { - const weaponHash = GetHashKey(weapon) - return HasPedGotWeapon(this.ped, weaponHash, false) - } - - // ───────────────────────────────────────────────────────────────────────────── - // Animations & Tasks - // ───────────────────────────────────────────────────────────────────────────── - - /** - * Play an animation on the player. - * @param dict - Animation dictionary - * @param name - Animation name - * @param duration - Duration (-1 for looped) - * @param flags - Animation flags - */ - async playAnimation(dict: string, name: string, duration = -1, flags = 1): Promise { - RequestAnimDict(dict) - while (!HasAnimDictLoaded(dict)) { - await new Promise((r) => setTimeout(r, 0)) - } - - TaskPlayAnim(this.ped, dict, name, 8.0, -8.0, duration, flags, 0.0, false, false, false) - } - - /** - * Stop current animation. - */ - stopAnimation(): void { - ClearPedTasks(this.ped) - } - - /** - * Stop animation immediately. - */ - stopAnimationImmediately(): void { - ClearPedTasksImmediately(this.ped) - } - - /** - * Check if player is playing a specific animation. - * @param dict - Animation dictionary - * @param name - Animation name - */ - isPlayingAnimation(dict: string, name: string): boolean { - return IsEntityPlayingAnim(this.ped, dict, name, 3) - } - - // ───────────────────────────────────────────────────────────────────────────── - // Vehicle Interaction - // ───────────────────────────────────────────────────────────────────────────── - - /** - * Warp player into a vehicle. - * @param vehicle - Vehicle handle - * @param seat - Seat index (-1 = driver) - */ - warpIntoVehicle(vehicle: number, seat = -1): void { - TaskWarpPedIntoVehicle(this.ped, vehicle, seat) - } - - /** - * Task player to exit current vehicle. - * @param flags - Exit flags (0 = normal, 16 = immediately) - */ - exitVehicle(flags = 0): void { - const vehicle = this.currentVehicle - if (vehicle) { - TaskLeaveVehicle(this.ped, vehicle, flags) - } - } - - // ───────────────────────────────────────────────────────────────────────────── - // Ped Flags & Properties - // ───────────────────────────────────────────────────────────────────────────── - - /** - * Set can ragdoll. - * @param canRagdoll - Whether player can ragdoll - */ - setCanRagdoll(canRagdoll: boolean): void { - SetPedCanRagdoll(this.ped, canRagdoll) - } - - /** - * Set ped config flag. - * @param flag - Flag ID - * @param value - Flag value - */ - setConfigFlag(flag: number, value: boolean): void { - SetPedConfigFlag(this.ped, flag, value) - } - - /** - * Get ped config flag. - * @param flag - Flag ID - */ - getConfigFlag(flag: number): boolean { - return GetPedConfigFlag(this.ped, flag, true) - } - - // ───────────────────────────────────────────────────────────────────────────── - // Meta Storage - // ───────────────────────────────────────────────────────────────────────────── - - /** - * Set a metadata value. - * @param key - Meta key - * @param value - Meta value - */ - setMeta(key: string, value: unknown): void { - this.meta[key] = value - } - - /** - * Get a metadata value. - * @param key - Meta key - */ - getMeta(key: string): T | undefined { - return this.meta[key] as T | undefined - } - - /** - * Delete a metadata value. - * @param key - Meta key - */ - deleteMeta(key: string): void { - delete this.meta[key] - } - - /** - * Get all metadata. - */ - getAllMeta(): PlayerSessionMeta { - return { ...this.meta } - } - - /** - * Clear all metadata. - */ - clearMeta(): void { - this.meta = {} - } - - // ───────────────────────────────────────────────────────────────────────────── - // Utility Methods - // ───────────────────────────────────────────────────────────────────────────── - - /** - * Get distance to a position. - * @param position - Target position - */ - distanceTo(position: Vector3): number { - const coords = this.coords - return Math.sqrt( - (coords.x - position.x) ** 2 + (coords.y - position.y) ** 2 + (coords.z - position.z) ** 2, - ) - } - - /** - * Check if player is within range of a position. - * @param position - Target position - * @param range - Maximum range - */ - isNearPosition(position: Vector3, range: number): boolean { - return this.distanceTo(position) <= range - } - - /** - * Get the entity the player is looking at. - * @param maxDistance - Maximum detection distance - */ - getEntityLookingAt(maxDistance = 10.0): number | null { - const [hit, entity] = GetEntityPlayerIsFreeAimingAt(this.id) - if (!hit || !entity || entity === 0) return null - - const coords = this.coords - const entityCoords = GetEntityCoords(entity, true) - const distance = Math.sqrt( - (coords.x - entityCoords[0]) ** 2 + - (coords.y - entityCoords[1]) ** 2 + - (coords.z - entityCoords[2]) ** 2, - ) - - return distance <= maxDistance ? entity : null - } - - /** - * Disable a control action. - * @param control - Control ID - * @param padIndex - Pad index (usually 0) - */ - disableControl(control: number, padIndex = 0): void { - DisableControlAction(padIndex, control, true) - } - - /** - * Check if a control is pressed. - * @param control - Control ID - * @param padIndex - Pad index (usually 0) - */ - isControlPressed(control: number, padIndex = 0): boolean { - return IsControlPressed(padIndex, control) - } - - /** - * Check if a control was just pressed. - * @param control - Control ID - * @param padIndex - Pad index (usually 0) - */ - isControlJustPressed(control: number, padIndex = 0): boolean { - return IsControlJustPressed(padIndex, control) - } -} - -export const ClientPlayer = new Player() diff --git a/src/runtime/client/services/appearance.service.ts b/src/runtime/client/services/appearance.service.ts index cbd60c4..032d3d6 100644 --- a/src/runtime/client/services/appearance.service.ts +++ b/src/runtime/client/services/appearance.service.ts @@ -1,5 +1,5 @@ import { inject, injectable } from 'tsyringe' -import { IPedAppearanceClient } from '../../../adapters/contracts/client/IPedAppearanceClient' +import { IGtaPedAppearanceBridge } from '../../../adapters/contracts/client/IGtaPedAppearanceBridge' import { IHasher } from '../../../adapters/contracts/IHasher' import { AppearanceValidationResult, PlayerAppearance } from '../../../kernel/shared' @@ -25,7 +25,7 @@ import { AppearanceValidationResult, PlayerAppearance } from '../../../kernel/sh @injectable() export class AppearanceService { constructor( - @inject(IPedAppearanceClient as any) private pedAdapter: IPedAppearanceClient, + @inject(IGtaPedAppearanceBridge as any) private pedAdapter: IGtaPedAppearanceBridge, @inject(IHasher as any) private hasher: IHasher, ) {} @@ -338,7 +338,10 @@ export class AppearanceService { * @param ped - Ped entity handle */ setDefaultAppearance(ped: number): void { - this.pedAdapter.setDefaultComponentVariation(ped) + const adapter = this.pedAdapter as any + if (typeof adapter?.setDefaultComponentVariation === 'function') { + adapter.setDefaultComponentVariation(ped) + } } /** diff --git a/src/runtime/client/services/blip.service.ts b/src/runtime/client/services/blip.service.ts index a302a5d..6ead0b0 100644 --- a/src/runtime/client/services/blip.service.ts +++ b/src/runtime/client/services/blip.service.ts @@ -1,255 +1,116 @@ -import { injectable } from 'tsyringe' +import { inject, injectable } from 'tsyringe' import { Vector3 } from '../../../kernel/utils/vector3' - -export interface BlipOptions { - /** Blip sprite ID (icon) */ - sprite?: number - /** Blip color ID */ - color?: number - /** Blip scale */ - scale?: number - /** Short range blip (only visible nearby) */ - shortRange?: boolean - /** Blip label/name */ - label?: string - /** Display category (affects visibility on map) */ - display?: number - /** Blip category */ - category?: number - /** Flash the blip */ - flash?: boolean - /** Blip alpha (0-255) */ - alpha?: number - /** Route to this blip */ - route?: boolean - /** Route color */ - routeColor?: number -} +import { + IClientBlipBridge, + type ClientBlipDefinition, + type ClientBlipOptions as BlipOptions, +} from '../../../adapters/contracts/client/ui/IClientBlipBridge' export interface ManagedBlip { id: string - handle: number - position: Vector3 - options: BlipOptions + definition: ClientBlipDefinition } const DEFAULT_OPTIONS: BlipOptions = { - sprite: 1, + icon: 1, color: 1, scale: 1.0, shortRange: true, - display: 4, - category: 0, - flash: false, alpha: 255, route: false, + visible: true, } -/** - * Service for creating and managing map blips. - */ @injectable() export class BlipService { private blips: Map = new Map() private idCounter = 0 - /** - * Create a blip at a world position. - * - * @param position - World position - * @param options - Blip options - * @returns The blip ID - */ + constructor(@inject(IClientBlipBridge as any) private readonly bridge: IClientBlipBridge) {} + create(position: Vector3, options: BlipOptions = {}): string { const id = `blip_${++this.idCounter}` - const opts = { ...DEFAULT_OPTIONS, ...options } - - const handle = AddBlipForCoord(position.x, position.y, position.z) - this.applyOptions(handle, opts) - - this.blips.set(id, { id, handle, position, options: opts }) + const definition = this.buildDefinition({ position }, options) + this.bridge.create(id, definition) + this.blips.set(id, { id, definition }) return id } - /** - * Create a blip attached to an entity. - * - * @param entity - Entity handle - * @param options - Blip options - * @returns The blip ID - */ createForEntity(entity: number, options: BlipOptions = {}): string { const id = `blip_${++this.idCounter}` - const opts = { ...DEFAULT_OPTIONS, ...options } - - const handle = AddBlipForEntity(entity) - this.applyOptions(handle, opts) - - const [x, y, z] = GetEntityCoords(entity, true) - this.blips.set(id, { id, handle, position: { x, y, z }, options: opts }) + const definition = this.buildDefinition({ entity }, options) + this.bridge.create(id, definition) + this.blips.set(id, { id, definition }) return id } - /** - * Create a blip for a radius/area. - * - * @param position - Center position - * @param radius - Radius of the area - * @param options - Blip options - * @returns The blip ID - */ createForRadius(position: Vector3, radius: number, options: BlipOptions = {}): string { const id = `blip_${++this.idCounter}` - const opts = { ...DEFAULT_OPTIONS, ...options } - - const handle = AddBlipForRadius(position.x, position.y, position.z, radius) - this.applyOptions(handle, opts) - - this.blips.set(id, { id, handle, position, options: opts }) + const definition = this.buildDefinition({ position, radius }, options) + this.bridge.create(id, definition) + this.blips.set(id, { id, definition }) return id } - /** - * Remove a blip by ID. - * - * @param id - The blip ID - */ remove(id: string): boolean { - const blip = this.blips.get(id) - if (!blip) return false - - if (DoesBlipExist(blip.handle)) { - RemoveBlip(blip.handle) - } - - this.blips.delete(id) - return true + const removed = this.bridge.remove(id) + if (removed) this.blips.delete(id) + return removed } - /** - * Remove all managed blips. - */ removeAll(): void { - for (const blip of this.blips.values()) { - if (DoesBlipExist(blip.handle)) { - RemoveBlip(blip.handle) - } - } + this.bridge.clear() this.blips.clear() } - /** - * Update blip options. - * - * @param id - The blip ID - * @param options - New options to apply - */ update(id: string, options: Partial): boolean { const blip = this.blips.get(id) - if (!blip || !DoesBlipExist(blip.handle)) return false - - blip.options = { ...blip.options, ...options } - this.applyOptions(blip.handle, blip.options) - return true + if (!blip) return false + const patch = this.normalizeOptions(options) + const updated = this.bridge.update(id, patch) + if (!updated) return false + blip.definition = { ...blip.definition, ...patch } + return updated } - /** - * Set blip position. - * - * @param id - The blip ID - * @param position - New position - */ setPosition(id: string, position: Vector3): boolean { const blip = this.blips.get(id) - if (!blip || !DoesBlipExist(blip.handle)) return false - - SetBlipCoords(blip.handle, position.x, position.y, position.z) - blip.position = position - return true + if (!blip) return false + const updated = this.bridge.update(id, { position, entity: undefined, radius: undefined }) + if (!updated) return false + blip.definition = { ...blip.definition, position, entity: undefined, radius: undefined } + return updated } - /** - * Set whether a route should be drawn to the blip. - * - * @param id - The blip ID - * @param enabled - Whether to show the route - * @param color - Optional route color - */ setRoute(id: string, enabled: boolean, color?: number): boolean { - const blip = this.blips.get(id) - if (!blip || !DoesBlipExist(blip.handle)) return false - - SetBlipRoute(blip.handle, enabled) - if (color !== undefined) { - SetBlipRouteColour(blip.handle, color) - } - return true + return this.update(id, { route: enabled, routeColor: color }) } - /** - * Get blip by ID. - */ get(id: string): ManagedBlip | undefined { return this.blips.get(id) } - - /** - * Get all managed blips. - */ getAll(): ManagedBlip[] { return Array.from(this.blips.values()) } - - /** - * Get the native blip handle by ID. - */ - getHandle(id: string): number | undefined { - return this.blips.get(id)?.handle - } - - /** - * Check if a blip still exists in the game world. - */ exists(id: string): boolean { - const blip = this.blips.get(id) - return blip ? DoesBlipExist(blip.handle) : false + return this.bridge.exists(id) } - private applyOptions(handle: number, options: BlipOptions): void { - if (options.sprite !== undefined) { - SetBlipSprite(handle, options.sprite) - } - if (options.color !== undefined) { - SetBlipColour(handle, options.color) + private buildDefinition( + base: Partial, + options: BlipOptions, + ): ClientBlipDefinition { + return { + ...base, + ...this.normalizeOptions({ ...DEFAULT_OPTIONS, ...options }), } - if (options.scale !== undefined) { - SetBlipScale(handle, options.scale) - } - if (options.shortRange !== undefined) { - SetBlipAsShortRange(handle, options.shortRange) - } - if (options.label) { - BeginTextCommandSetBlipName('STRING') - AddTextComponentString(options.label) - EndTextCommandSetBlipName(handle) - } - if (options.display !== undefined) { - SetBlipDisplay(handle, options.display) - } - if (options.category !== undefined) { - SetBlipCategory(handle, options.category) - } - if (options.flash !== undefined) { - SetBlipFlashes(handle, options.flash) - } - if (options.alpha !== undefined) { - SetBlipAlpha(handle, options.alpha) - } - if (options.route !== undefined) { - SetBlipRoute(handle, options.route) - if (options.routeColor !== undefined) { - SetBlipRouteColour(handle, options.routeColor) - } + } + + private normalizeOptions(options: Partial): Partial { + const { sprite, icon, ...rest } = options + return { + ...rest, + icon: icon ?? sprite, } } } diff --git a/src/runtime/client/services/camera-effects.registry.ts b/src/runtime/client/services/camera-effects.registry.ts index f3825ec..f287c91 100644 --- a/src/runtime/client/services/camera-effects.registry.ts +++ b/src/runtime/client/services/camera-effects.registry.ts @@ -1,4 +1,5 @@ -import { injectable } from 'tsyringe' +import { inject, injectable } from 'tsyringe' +import { IClientPlatformBridge } from '../adapter/platform-bridge' import { Camera } from './camera' /** @@ -76,6 +77,10 @@ export class CameraEffectsRegistry { private effects = new Map() private presets = new Map() + constructor( + @inject(IClientPlatformBridge as any) private readonly platform: IClientPlatformBridge, + ) {} + /** * Registers a single effect definition. */ @@ -129,7 +134,7 @@ export class CameraEffectsRegistry { defaults: { ms: 500 }, setup: (_ctx, params) => { const ms = Number((params as Record).ms ?? 500) - DoScreenFadeIn(ms) + this.platform.doScreenFadeIn(ms) }, }) @@ -138,7 +143,7 @@ export class CameraEffectsRegistry { defaults: { ms: 500 }, setup: (_ctx, params) => { const ms = Number((params as Record).ms ?? 500) - DoScreenFadeOut(ms) + this.platform.doScreenFadeOut(ms) }, }) @@ -163,11 +168,11 @@ export class CameraEffectsRegistry { const p = params as Record const name = String(p.name ?? 'default') const strength = Number(p.strength ?? 1) - SetTimecycleModifier(name) - SetTimecycleModifierStrength(strength) + this.platform.setTimecycleModifier(name) + this.platform.setTimecycleModifierStrength(strength) }, teardown: () => { - ClearTimecycleModifier() + this.platform.clearTimecycleModifier() }, }) diff --git a/src/runtime/client/services/camera.ts b/src/runtime/client/services/camera.ts index 8647035..dac9639 100644 --- a/src/runtime/client/services/camera.ts +++ b/src/runtime/client/services/camera.ts @@ -1,197 +1,120 @@ -import { injectable } from 'tsyringe' +import { inject, injectable } from 'tsyringe' import { Vector3 } from '../../../kernel/utils/vector3' +import { IClientPlatformBridge } from '../adapter/platform-bridge' -/** - * Camera rotation represented in degrees. - */ export interface CameraRotation { x: number y: number z: number } -/** - * Full camera transform in world space. - */ export interface CameraTransform { position: Vector3 rotation?: CameraRotation fov?: number } -/** - * Configuration used when creating and activating a scripted camera. - */ export interface CameraCreateOptions { - /** Native camera name, defaults to DEFAULT_SCRIPTED_CAMERA. */ camName?: string - /** Whether the created camera should become active immediately. */ active?: boolean - /** Optional initial transform. */ transform?: CameraTransform } -/** - * Render options used when enabling/disabling scripted camera rendering. - */ export interface CameraRenderOptions { - /** Smooth transition in or out. */ ease?: boolean - /** Transition duration in milliseconds. */ easeTimeMs?: number } -/** - * Shake configuration for scripted camera effects. - */ export interface CameraShakeOptions { - /** Native shake type name, e.g. HAND_SHAKE. */ type: string - /** Shake amplitude. */ amplitude: number } -/** - * Injectable camera API that wraps FiveM scripted camera natives. - * - * @remarks - * This class intentionally exposes low-level camera primitives so higher-level - * systems can build cinematic workflows on top of it. - */ @injectable() export class Camera { private activeCam: number | null = null private rendering = false - /** - * Creates a scripted camera and optionally initializes its transform. - */ - create(options: CameraCreateOptions = {}): number { - const cam = CreateCam(options.camName ?? 'DEFAULT_SCRIPTED_CAMERA', options.active ?? false) - - if (options.transform) { - this.setTransform(cam, options.transform) - } + constructor( + @inject(IClientPlatformBridge as any) private readonly platform: IClientPlatformBridge, + ) {} - if (options.active) { - this.activeCam = cam - } + create(options: CameraCreateOptions = {}): number { + const cam = this.platform.createCam( + options.camName ?? 'DEFAULT_SCRIPTED_CAMERA', + options.active ?? false, + ) + if (options.transform) this.setTransform(cam, options.transform) + if (options.active) this.activeCam = cam return cam } - /** - * Returns the currently tracked active camera handle. - */ getActiveCam(): number | null { return this.activeCam } - /** - * Sets camera active state and tracks active handle. - */ setActive(cam: number, active: boolean): void { - SetCamActive(cam, active) - if (active) { - this.activeCam = cam - } else if (this.activeCam === cam) { - this.activeCam = null - } + this.platform.setCamActive(cam, active) + if (active) this.activeCam = cam + else if (this.activeCam === cam) this.activeCam = null } - /** - * Enables or disables scripted camera rendering. - */ render(enable: boolean, options: CameraRenderOptions = {}): void { - RenderScriptCams(enable, options.ease ?? false, options.easeTimeMs ?? 0, true, true) + this.platform.renderScriptCams( + enable, + options.ease ?? false, + options.easeTimeMs ?? 0, + true, + true, + ) this.rendering = enable } - /** - * Returns whether scripted camera rendering is currently enabled. - */ isRendering(): boolean { return this.rendering } - /** - * Destroys a single camera. - */ destroy(cam: number, destroyActiveCam = false): void { - DestroyCam(cam, destroyActiveCam) - if (this.activeCam === cam) { - this.activeCam = null - } + this.platform.destroyCam(cam, destroyActiveCam) + if (this.activeCam === cam) this.activeCam = null } - /** - * Destroys all scripted cameras managed by the game runtime. - */ destroyAll(destroyActiveCam = false): void { - DestroyAllCams(destroyActiveCam) + this.platform.destroyAllCams(destroyActiveCam) this.activeCam = null } - /** - * Sets camera world position. - */ setPosition(cam: number, position: Vector3): void { - SetCamCoord(cam, position.x, position.y, position.z) + this.platform.setCamCoord(cam, position) } - /** - * Sets camera world rotation. - */ setRotation(cam: number, rotation: CameraRotation, rotationOrder = 2): void { - SetCamRot(cam, rotation.x, rotation.y, rotation.z, rotationOrder) + this.platform.setCamRot(cam, rotation, rotationOrder) } - /** - * Sets camera field of view. - */ setFov(cam: number, fov: number): void { - SetCamFov(cam, fov) + this.platform.setCamFov(cam, fov) } - /** - * Applies a full transform to a camera in a single call path. - */ setTransform(cam: number, transform: CameraTransform): void { this.setPosition(cam, transform.position) - - if (transform.rotation) { - this.setRotation(cam, transform.rotation) - } - - if (typeof transform.fov === 'number') { - this.setFov(cam, transform.fov) - } + if (transform.rotation) this.setRotation(cam, transform.rotation) + if (typeof transform.fov === 'number') this.setFov(cam, transform.fov) } - /** - * Points a camera at world coordinates. - */ pointAtCoords(cam: number, position: Vector3): void { - PointCamAtCoord(cam, position.x, position.y, position.z) + this.platform.pointCamAtCoord(cam, position) } - /** - * Points a camera at an entity with an optional offset. - */ pointAtEntity(cam: number, entity: number, offset: Vector3 = { x: 0, y: 0, z: 0 }): void { - PointCamAtEntity(cam, entity, offset.x, offset.y, offset.z, true) + this.platform.pointCamAtEntity(cam, entity, offset) } - /** - * Removes point-at target from the camera. - */ stopPointing(cam: number): void { - StopCamPointing(cam) + this.platform.stopCamPointing(cam) } - /** - * Interpolates from one camera to another using native interpolation. - */ interpolate( fromCam: number, toCam: number, @@ -199,31 +122,26 @@ export class Camera { easeLocation = true, easeRotation = true, ): void { - SetCamActiveWithInterp(toCam, fromCam, durationMs, easeLocation ? 1 : 0, easeRotation ? 1 : 0) + this.platform.setCamActiveWithInterp( + toCam, + fromCam, + durationMs, + easeLocation ? 1 : 0, + easeRotation ? 1 : 0, + ) this.activeCam = toCam } - /** - * Starts a camera shake effect. - */ shake(cam: number, options: CameraShakeOptions): void { - ShakeCam(cam, options.type, options.amplitude) + this.platform.shakeCam(cam, options.type, options.amplitude) } - /** - * Stops camera shake for a camera. - */ stopShaking(cam: number, stopImmediately = true): void { - StopCamShaking(cam, stopImmediately) + this.platform.stopCamShaking(cam, stopImmediately) } - /** - * Fully resets camera rendering and internal camera tracking. - */ reset(options: CameraRenderOptions = {}): void { - if (this.rendering) { - this.render(false, options) - } + if (this.rendering) this.render(false, options) this.destroyAll(false) } } diff --git a/src/runtime/client/services/cinematic.ts b/src/runtime/client/services/cinematic.ts index 8a1cf60..9d967ca 100644 --- a/src/runtime/client/services/cinematic.ts +++ b/src/runtime/client/services/cinematic.ts @@ -1,5 +1,7 @@ -import { injectable } from 'tsyringe' +import { inject, injectable } from 'tsyringe' import { Vector3 } from '../../../kernel/utils/vector3' +import { IClientPlatformBridge } from '../adapter/platform-bridge' +import { IClientRuntimeBridge } from '../adapter/runtime-bridge' import { type CameraEffectContext, type CameraEffectDefinition, @@ -225,7 +227,7 @@ export class CinematicHandle { pause(): void { if (this.runtime.paused) return this.runtime.paused = true - this.runtime.pauseStartedAt = GetGameTimer() + this.runtime.pauseStartedAt = Date.now() this.emit('paused', undefined) } @@ -347,14 +349,16 @@ export class Cinematic { constructor( private readonly camera: Camera, effectsRegistry: CameraEffectsRegistry, + @inject(IClientPlatformBridge as any) private readonly platform: IClientPlatformBridge, + @inject(IClientRuntimeBridge as any) private readonly runtimeBridge: IClientRuntimeBridge, ) { this.effects = effectsRegistry if (!this.effects.has('fadeIn')) { this.effects.registerBuiltins() } - const currentResource = GetCurrentResourceName() - on('onClientResourceStop', (resourceName: string) => { + const currentResource = this.runtimeBridge.getCurrentResourceName() + this.runtimeBridge.on('onClientResourceStop', (resourceName: string) => { if (resourceName !== currentResource) return this.cancel() this.camera.reset({ ease: false, easeTimeMs: 0 }) @@ -467,7 +471,7 @@ export class Cinematic { runtime: CinematicRuntimeState, handle: CinematicHandle, ): Promise { - const ped = PlayerPedId() + const ped = this.platform.getLocalPlayerPed() try { this.applyRuntimeFlags(runtime.definition, ped) @@ -540,16 +544,16 @@ export class Cinematic { private applyRuntimeFlags(definition: CinematicDefinition, ped: number): void { if (definition.freezePlayer) { - FreezeEntityPosition(ped, true) + this.platform.freezeEntityPosition(ped, true) } if (definition.invinciblePlayer) { - SetEntityInvincible(ped, true) + this.platform.setEntityInvincible(ped, true) } if (definition.hideHud) { - DisplayHud(false) + this.platform.displayHud(false) } if (definition.hideRadar) { - DisplayRadar(false) + this.platform.displayRadar(false) } } @@ -560,19 +564,19 @@ export class Cinematic { this.camera.destroy(runtime.camHandle, false) if (runtime.definition.freezePlayer) { - FreezeEntityPosition(ped, false) + this.platform.freezeEntityPosition(ped, false) } if (runtime.definition.invinciblePlayer) { - SetEntityInvincible(ped, false) + this.platform.setEntityInvincible(ped, false) } if (runtime.definition.hideHud) { - DisplayHud(true) + this.platform.displayHud(true) } if (runtime.definition.hideRadar) { - DisplayRadar(true) + this.platform.displayRadar(true) } - ClearTimecycleModifier() + this.platform.clearTimecycleModifier() } private resolveGlobalEffects(definition: CinematicDefinition): CameraEffectReference[] { @@ -613,7 +617,7 @@ export class Cinematic { effects: RuntimeEffect[], durationMs: number, ): Promise { - const start = GetGameTimer() + const start = this.runtimeBridge.getGameTimer() let previousTime = start while (true) { @@ -623,19 +627,22 @@ export class Cinematic { } if (runtime.paused) { - previousTime = GetGameTimer() + previousTime = this.runtimeBridge.getGameTimer() await delay(0) continue } - if (runtime.definition.skippable && IsControlJustPressed(0, runtime.options.skipControlId)) { + if ( + runtime.definition.skippable && + this.platform.isControlJustPressed(0, runtime.options.skipControlId) + ) { runtime.cancelled = true runtime.interruptStatus = 'cancelled' await this.finalizeEffects(runtime, effects, 'cancelled', durationMs) return } - const now = GetGameTimer() + const now = this.runtimeBridge.getGameTimer() const elapsedMs = now - start const deltaMs = now - previousTime previousTime = now @@ -680,8 +687,8 @@ export class Cinematic { } private async waitStep(runtime: CinematicRuntimeState, waitMs: number): Promise { - const started = GetGameTimer() - while (GetGameTimer() - started < waitMs) { + const started = this.runtimeBridge.getGameTimer() + while (this.runtimeBridge.getGameTimer() - started < waitMs) { if (runtime.cancelled) return if (runtime.paused) { @@ -689,7 +696,10 @@ export class Cinematic { continue } - if (runtime.definition.skippable && IsControlJustPressed(0, runtime.options.skipControlId)) { + if ( + runtime.definition.skippable && + this.platform.isControlJustPressed(0, runtime.options.skipControlId) + ) { runtime.cancelled = true runtime.interruptStatus = 'cancelled' return @@ -765,15 +775,15 @@ export class Cinematic { normalized, deltaMs, drawSubtitle: (text: string) => { - BeginTextCommandDisplayText('STRING') - AddTextComponentSubstringPlayerName(text) - EndTextCommandDisplayText(0.5, 0.92) + this.platform.beginTextCommandDisplayText('STRING') + this.platform.addTextComponentSubstringPlayerName(text) + this.platform.endTextCommandDisplayText(0.5, 0.92) }, drawLetterbox: (top: number, bottom: number, alpha = 230) => { const topHeight = Math.max(0, Math.min(0.5, top)) const bottomHeight = Math.max(0, Math.min(0.5, bottom)) - DrawRect(0.5, topHeight / 2, 1.0, topHeight, 0, 0, 0, alpha) - DrawRect(0.5, 1 - bottomHeight / 2, 1.0, bottomHeight, 0, 0, 0, alpha) + this.platform.drawRect(0.5, topHeight / 2, 1.0, topHeight, 0, 0, 0, alpha) + this.platform.drawRect(0.5, 1 - bottomHeight / 2, 1.0, bottomHeight, 0, 0, 0, alpha) }, } } @@ -849,7 +859,7 @@ export class Cinematic { case 'coords': return { x: input.x, y: input.y, z: input.z } case 'entity': { - const [x, y, z] = GetEntityCoords(input.entity, true) + const { x, y, z } = this.platform.getEntityCoords(input.entity) return { x: x + (input.offset?.x ?? 0), y: y + (input.offset?.y ?? 0), @@ -857,7 +867,7 @@ export class Cinematic { } } case 'entityBone': { - const [x, y, z] = GetWorldPositionOfEntityBone(input.entity, input.bone) + const { x, y, z } = this.platform.getWorldPositionOfEntityBone(input.entity, input.bone) return { x: x + (input.offset?.x ?? 0), y: y + (input.offset?.y ?? 0), diff --git a/src/runtime/client/services/index.ts b/src/runtime/client/services/index.ts index 3fdb09f..3503390 100644 --- a/src/runtime/client/services/index.ts +++ b/src/runtime/client/services/index.ts @@ -7,6 +7,7 @@ export * from './marker.service' export * from './notification.service' export * from './ped.service' export * from './progress.service' +export * from './session-bridge.service' export * from './spawn.service' export * from './streaming.service' export * from './textui.service' diff --git a/src/runtime/client/services/marker.service.ts b/src/runtime/client/services/marker.service.ts index d84bd97..e6b5780 100644 --- a/src/runtime/client/services/marker.service.ts +++ b/src/runtime/client/services/marker.service.ts @@ -1,229 +1,126 @@ -import { injectable } from 'tsyringe' +import { inject, injectable } from 'tsyringe' import { Vector3 } from '../../../kernel/utils/vector3' +import { + IClientMarkerBridge, + type ClientMarkerDefinition, +} from '../../../adapters/contracts/client/ui/IClientMarkerBridge' export interface MarkerOptions { - /** Marker type (0-43) */ + variant?: number type?: number - /** Scale of the marker */ + size?: Vector3 scale?: Vector3 - /** Rotation of the marker */ rotation?: Vector3 - /** Color (RGBA) */ color?: { r: number; g: number; b: number; a: number } - /** Whether the marker should bob up and down */ + bob?: boolean bobUpAndDown?: boolean - /** Whether the marker should face the camera */ faceCamera?: boolean - /** Whether the marker should rotate */ rotate?: boolean - /** Draw on entities */ drawOnEnts?: boolean + visible?: boolean } export interface ManagedMarker { id: string - position: Vector3 - options: Required - visible: boolean + definition: ClientMarkerDefinition } const DEFAULT_OPTIONS: Required = { + variant: 1, type: 1, + size: { x: 1.0, y: 1.0, z: 1.0 }, scale: { x: 1.0, y: 1.0, z: 1.0 }, rotation: { x: 0, y: 0, z: 0 }, color: { r: 255, g: 0, b: 0, a: 200 }, + bob: false, bobUpAndDown: false, faceCamera: false, rotate: false, drawOnEnts: false, + visible: true, } -/** - * Service for managing and rendering markers in the game world. - * Handles automatic rendering via tick. - */ @injectable() export class MarkerService { - private markers: Map = new Map() - private tickHandle: number | null = null + private activeMarkers: Map = new Map() private idCounter = 0 - /** - * Create a new managed marker. - * - * @param position - World position for the marker - * @param options - Marker appearance options - * @returns The marker ID - */ + constructor(@inject(IClientMarkerBridge as any) private readonly markers: IClientMarkerBridge) {} + create(position: Vector3, options: MarkerOptions = {}): string { const id = `marker_${++this.idCounter}` - const marker: ManagedMarker = { + const definition = this.buildDefinition(position, options) + this.markers.create(id, definition) + this.activeMarkers.set(id, { id, - position, - options: { ...DEFAULT_OPTIONS, ...options }, - visible: true, - } - - this.markers.set(id, marker) - this.ensureTickRunning() - + definition, + }) return id } - /** - * Remove a marker by ID. - * - * @param id - The marker ID to remove - */ remove(id: string): boolean { - const deleted = this.markers.delete(id) - this.checkTickNeeded() - return deleted + const removed = this.markers.remove(id) + if (removed) this.activeMarkers.delete(id) + return removed } - /** - * Remove all markers. - */ removeAll(): void { this.markers.clear() - this.stopTick() + this.activeMarkers.clear() } - /** - * Update a marker's position. - * - * @param id - The marker ID - * @param position - New position - */ setPosition(id: string, position: Vector3): boolean { - const marker = this.markers.get(id) + const marker = this.activeMarkers.get(id) if (!marker) return false - marker.position = position - return true + const updated = this.markers.update(id, { position }) + if (!updated) return false + marker.definition = { ...marker.definition, position } + return updated } - /** - * Update marker options. - * - * @param id - The marker ID - * @param options - Options to update - */ setOptions(id: string, options: Partial): boolean { - const marker = this.markers.get(id) + const marker = this.activeMarkers.get(id) if (!marker) return false - marker.options = { ...marker.options, ...options } - return true + const patch = this.normalizeOptions(options) + const updated = this.markers.update(id, patch) + if (!updated) return false + marker.definition = { ...marker.definition, ...patch } + return updated } - /** - * Set marker visibility. - * - * @param id - The marker ID - * @param visible - Whether the marker should be visible - */ setVisible(id: string, visible: boolean): boolean { - const marker = this.markers.get(id) - if (!marker) return false - marker.visible = visible - return true + return this.setOptions(id, { visible }) } - /** - * Get a marker by ID. - */ get(id: string): ManagedMarker | undefined { - return this.markers.get(id) + return this.activeMarkers.get(id) } - - /** - * Get all managed markers. - */ getAll(): ManagedMarker[] { - return Array.from(this.markers.values()) + return Array.from(this.activeMarkers.values()) } - /** - * Draw a marker immediately (one frame only). - * For persistent markers, use create() instead. - */ drawOnce(position: Vector3, options: MarkerOptions = {}): void { - const opts = { ...DEFAULT_OPTIONS, ...options } - DrawMarker( - opts.type, - position.x, - position.y, - position.z, - 0, - 0, - 0, - opts.rotation.x, - opts.rotation.y, - opts.rotation.z, - opts.scale.x, - opts.scale.y, - opts.scale.z, - opts.color.r, - opts.color.g, - opts.color.b, - opts.color.a, - opts.bobUpAndDown, - opts.faceCamera, - 2, - opts.rotate, - null as unknown as string, - null as unknown as string, - opts.drawOnEnts, - ) + this.markers.draw(this.buildDefinition(position, options)) } - private ensureTickRunning(): void { - if (this.tickHandle !== null) return - - this.tickHandle = setTick(() => { - for (const marker of this.markers.values()) { - if (!marker.visible) continue - - const { position, options } = marker - DrawMarker( - options.type, - position.x, - position.y, - position.z, - 0, - 0, - 0, - options.rotation.x, - options.rotation.y, - options.rotation.z, - options.scale.x, - options.scale.y, - options.scale.z, - options.color.r, - options.color.g, - options.color.b, - options.color.a, - options.bobUpAndDown, - options.faceCamera, - 2, - options.rotate, - null as unknown as string, - null as unknown as string, - options.drawOnEnts, - ) - } - }) + exists(id: string): boolean { + return this.markers.exists(id) } - private stopTick(): void { - if (this.tickHandle !== null) { - clearTick(this.tickHandle) - this.tickHandle = null + private buildDefinition(position: Vector3, options: MarkerOptions): ClientMarkerDefinition { + return { + position, + ...this.normalizeOptions({ ...DEFAULT_OPTIONS, ...options }), } } - private checkTickNeeded(): void { - if (this.markers.size === 0) { - this.stopTick() + private normalizeOptions(options: Partial): Partial { + const { type, variant, scale, size, bob, bobUpAndDown, ...rest } = options + return { + ...rest, + variant: variant ?? type, + size: size ?? scale, + bob: bob ?? bobUpAndDown, } } } diff --git a/src/runtime/client/services/notification.service.ts b/src/runtime/client/services/notification.service.ts index a5db794..c77250a 100644 --- a/src/runtime/client/services/notification.service.ts +++ b/src/runtime/client/services/notification.service.ts @@ -1,151 +1,74 @@ -import { injectable } from 'tsyringe' +import { inject, injectable } from 'tsyringe' +import { + IClientNotificationBridge, + type ClientNotificationDefinition, +} from '../../../adapters/contracts/client/ui/IClientNotificationBridge' +import { IClientPlatformBridge } from '../adapter/platform-bridge' export type NotificationType = 'info' | 'success' | 'warning' | 'error' export interface AdvancedNotificationOptions { - /** Notification title */ title: string - /** Notification subtitle */ subtitle?: string - /** Message text */ message: string - /** Texture dictionary for the icon */ - textureDict?: string - /** Texture name for the icon */ - textureName?: string - /** Icon type (1-7) */ - iconType?: number - /** Flash the notification */ flash?: boolean - /** Save to brief (map menu) */ saveToBrief?: boolean - /** Background color index */ backgroundColor?: number } -/** - * Service for displaying native GTA V notifications. - */ @injectable() export class NotificationService { - /** - * Show a simple notification on screen. - * - * @param message - The message to display - * @param blink - Whether the notification should blink - */ + constructor( + @inject(IClientNotificationBridge as any) + private readonly notifications: IClientNotificationBridge, + @inject(IClientPlatformBridge as any) private readonly platform: IClientPlatformBridge, + ) {} + show(message: string, blink = false): void { - SetNotificationTextEntry('STRING') - AddTextComponentString(message) - DrawNotification(blink, true) + this.notifications.show({ kind: 'feed', message, blink, saveToBrief: true }) } - /** - * Show a notification with a type indicator using throbber icons. - * - * @param message - The message to display - * @param type - The notification type - */ showWithType(message: string, type: NotificationType = 'info'): void { - const iconMap: Record = { - info: 1, - success: 2, - warning: 3, - error: 4, - } - - BeginTextCommandThefeedPost('STRING') - AddTextComponentString(message) - EndTextCommandThefeedPostMessagetext( - 'CHAR_SOCIAL_CLUB', - 'CHAR_SOCIAL_CLUB', - true, - iconMap[type], - '', - message, - ) + this.notifications.show({ kind: 'typed', message, type }) } - /** - * Show an advanced notification with picture/icon. - * - * @param options - Advanced notification options - */ showAdvanced(options: AdvancedNotificationOptions): void { - const { - title, - subtitle = '', - message, - textureDict = 'CHAR_HUMANDEFAULT', - textureName = 'CHAR_HUMANDEFAULT', - iconType = 1, - flash = false, - saveToBrief = true, - backgroundColor, - } = options - - SetNotificationTextEntry('STRING') - AddTextComponentString(message) - - if (backgroundColor !== undefined) { - SetNotificationBackgroundColor(backgroundColor) - } - - SetNotificationMessage(textureDict, textureName, flash, iconType, title, subtitle) - DrawNotification(flash, saveToBrief) + this.notifications.show({ + kind: 'advanced', + title: options.title, + subtitle: options.subtitle, + message: options.message, + flash: options.flash, + saveToBrief: options.saveToBrief, + backgroundColor: options.backgroundColor, + }) } - /** - * Show a help notification (appears at top-left). - * - * @param message - The help message - * @param duration - How long to show in milliseconds (-1 for indefinite) - * @param beep - Play a beep sound - * @param looped - Keep showing until cleared - */ showHelp(message: string, duration = 5000, beep = true, looped = false): void { - BeginTextCommandDisplayHelp('STRING') - AddTextComponentSubstringPlayerName(message) - EndTextCommandDisplayHelp(0, looped, beep, duration) + this.notifications.show({ kind: 'help', message, duration, beep, looped }) } - /** - * Clear all help messages. - */ clearHelp(): void { - ClearAllHelpMessages() + this.notifications.clear('help') } - /** - * Show a subtitle (centered at bottom of screen). - * - * @param message - The subtitle text - * @param duration - Duration in milliseconds - */ showSubtitle(message: string, duration = 2500): void { - BeginTextCommandPrint('STRING') - AddTextComponentSubstringPlayerName(message) - EndTextCommandPrint(duration, true) + this.notifications.show({ kind: 'subtitle', message, duration }) } - /** - * Clear the current subtitle. - */ clearSubtitle(): void { - ClearPrints() + this.notifications.clear('subtitle') } - /** - * Show a floating help text above the player's head. - * - * @param message - The message to display - */ showFloatingHelp(message: string): void { - const [x, y, z] = GetEntityCoords(PlayerPedId(), true) - SetFloatingHelpTextWorldPosition(1, x, y, z) - SetFloatingHelpTextStyle(1, 1, 2, -1, 3, 0) - BeginTextCommandDisplayHelp('STRING') - AddTextComponentSubstringPlayerName(message) - EndTextCommandDisplayHelp(2, false, false, -1) + this.notifications.show({ + kind: 'floating', + message, + worldPosition: this.platform.getEntityCoords(this.platform.getLocalPlayerPed()), + }) + } + + showRaw(definition: ClientNotificationDefinition): void { + this.notifications.show(definition) } } diff --git a/src/runtime/client/services/ped.service.ts b/src/runtime/client/services/ped.service.ts index 421c21b..ffcb592 100644 --- a/src/runtime/client/services/ped.service.ts +++ b/src/runtime/client/services/ped.service.ts @@ -1,37 +1,24 @@ -import { injectable } from 'tsyringe' +import { inject, injectable } from 'tsyringe' import { Vector3 } from '../../../kernel/utils/vector3' +import { IClientPlatformBridge } from '../adapter/platform-bridge' export interface PedSpawnOptions { - /** Model name or hash */ model: string - /** Spawn position */ position: Vector3 - /** Heading/rotation */ heading?: number - /** Network the ped */ networked?: boolean - /** Make the ped a mission entity */ missionEntity?: boolean - /** Relationship group */ relationshipGroup?: string - /** Whether to block non-temporary events */ blockEvents?: boolean } export interface PedAnimationOptions { - /** Animation dictionary */ dict: string - /** Animation name */ anim: string - /** Blend in speed */ blendInSpeed?: number - /** Blend out speed */ blendOutSpeed?: number - /** Duration (-1 for looped) */ duration?: number - /** Animation flags */ flags?: number - /** Playback rate */ playbackRate?: number } @@ -42,20 +29,15 @@ export interface ManagedPed { position: Vector3 } -/** - * Service for ped (NPC) operations and management. - */ @injectable() export class PedService { private peds: Map = new Map() private idCounter = 0 - /** - * Spawn a ped at a position. - * - * @param options - Spawn options - * @returns The ped ID and handle - */ + constructor( + @inject(IClientPlatformBridge as any) private readonly platform: IClientPlatformBridge, + ) {} + async spawn(options: PedSpawnOptions): Promise<{ id: string; handle: number }> { const { model, @@ -66,85 +48,45 @@ export class PedService { blockEvents = true, } = options - const modelHash = GetHashKey(model) - - // Load the model - if (!IsModelInCdimage(modelHash) || !IsModelValid(modelHash)) { + const modelHash = this.platform.getHashKey(model) + if (!this.platform.isModelInCdimage(modelHash) || !this.platform.isModelValid(modelHash)) { throw new Error(`Invalid ped model: ${model}`) } - RequestModel(modelHash) - while (!HasModelLoaded(modelHash)) { + this.platform.requestModel(modelHash) + while (!this.platform.hasModelLoaded(modelHash)) { await new Promise((r) => setTimeout(r, 0)) } - // Create the ped - const ped = CreatePed( - 4, - modelHash, - position.x, - position.y, - position.z, - heading, - networked, - true, - ) - - SetModelAsNoLongerNeeded(modelHash) - - if (!ped || ped === 0) { - throw new Error('Failed to create ped') - } - - // Configure the ped - if (missionEntity) { - SetEntityAsMissionEntity(ped, true, true) - } - - if (blockEvents) { - SetBlockingOfNonTemporaryEvents(ped, true) - } + const ped = this.platform.createPed(4, modelHash, position, heading, networked, true) + this.platform.setModelAsNoLongerNeeded(modelHash) + if (!ped || ped === 0) throw new Error('Failed to create ped') - // Set default relationship (neutral) - SetPedRelationshipGroupHash(ped, GetHashKey('CIVMALE')) + if (missionEntity) this.platform.setEntityAsMissionEntity(ped, true, true) + if (blockEvents) this.platform.setBlockingOfNonTemporaryEvents(ped, true) + this.platform.setPedRelationshipGroupHash(ped, this.platform.getHashKey('CIVMALE')) - // Register in our map const id = `ped_${++this.idCounter}` this.peds.set(id, { id, handle: ped, model, position }) - return { id, handle: ped } } - /** - * Delete a ped by ID. - * - * @param id - The ped ID - */ delete(id: string): boolean { const ped = this.peds.get(id) if (!ped) return false - - if (DoesEntityExist(ped.handle)) { - SetEntityAsMissionEntity(ped.handle, true, true) - DeletePed(ped.handle) + if (this.platform.doesEntityExist(ped.handle)) { + this.platform.setEntityAsMissionEntity(ped.handle, true, true) + this.platform.deletePed(ped.handle) } - this.peds.delete(id) return true } - /** - * Delete a ped by handle. - * - * @param handle - The ped handle - */ deleteByHandle(handle: number): void { - if (DoesEntityExist(handle)) { - SetEntityAsMissionEntity(handle, true, true) - DeletePed(handle) + if (this.platform.doesEntityExist(handle)) { + this.platform.setEntityAsMissionEntity(handle, true, true) + this.platform.deletePed(handle) } - - // Remove from our map if tracked for (const [id, ped] of this.peds) { if (ped.handle === handle) { this.peds.delete(id) @@ -153,25 +95,16 @@ export class PedService { } } - /** - * Delete all managed peds. - */ deleteAll(): void { for (const ped of this.peds.values()) { - if (DoesEntityExist(ped.handle)) { - SetEntityAsMissionEntity(ped.handle, true, true) - DeletePed(ped.handle) + if (this.platform.doesEntityExist(ped.handle)) { + this.platform.setEntityAsMissionEntity(ped.handle, true, true) + this.platform.deletePed(ped.handle) } } this.peds.clear() } - /** - * Play an animation on a ped. - * - * @param handle - Ped handle - * @param options - Animation options - */ async playAnimation(handle: number, options: PedAnimationOptions): Promise { const { dict, @@ -182,16 +115,12 @@ export class PedService { flags = 1, playbackRate = 0.0, } = options - - if (!DoesEntityExist(handle)) return - - // Load anim dict - RequestAnimDict(dict) - while (!HasAnimDictLoaded(dict)) { + if (!this.platform.doesEntityExist(handle)) return + this.platform.requestAnimDict(dict) + while (!this.platform.hasAnimDictLoaded(dict)) { await new Promise((r) => setTimeout(r, 0)) } - - TaskPlayAnim( + this.platform.taskPlayAnim( handle, dict, anim, @@ -200,200 +129,90 @@ export class PedService { duration, flags, playbackRate, - false, - false, - false, ) } - /** - * Stop all animations on a ped. - * - * @param handle - Ped handle - */ stopAnimation(handle: number): void { - if (!DoesEntityExist(handle)) return - ClearPedTasks(handle) + if (!this.platform.doesEntityExist(handle)) return + this.platform.clearPedTasks(handle) } - /** - * Stop animation immediately on a ped. - * - * @param handle - Ped handle - */ stopAnimationImmediately(handle: number): void { - if (!DoesEntityExist(handle)) return - ClearPedTasksImmediately(handle) + if (!this.platform.doesEntityExist(handle)) return + this.platform.clearPedTasksImmediately(handle) } - /** - * Freeze a ped in place. - * - * @param handle - Ped handle - * @param freeze - Whether to freeze - */ freeze(handle: number, freeze: boolean): void { - if (!DoesEntityExist(handle)) return - FreezeEntityPosition(handle, freeze) + if (!this.platform.doesEntityExist(handle)) return + this.platform.freezeEntityPosition(handle, freeze) } - /** - * Set ped invincibility. - * - * @param handle - Ped handle - * @param invincible - Whether invincible - */ setInvincible(handle: number, invincible: boolean): void { - if (!DoesEntityExist(handle)) return - SetEntityInvincible(handle, invincible) + if (!this.platform.doesEntityExist(handle)) return + this.platform.setEntityInvincible(handle, invincible) } - /** - * Give a weapon to a ped. - * - * @param handle - Ped handle - * @param weapon - Weapon name/hash - * @param ammo - Ammo count - * @param hidden - Whether to hide the weapon - * @param forceInHand - Whether to force weapon in hand - */ giveWeapon(handle: number, weapon: string, ammo = 100, hidden = false, forceInHand = true): void { - if (!DoesEntityExist(handle)) return - const weaponHash = GetHashKey(weapon) - GiveWeaponToPed(handle, weaponHash, ammo, hidden, forceInHand) + if (!this.platform.doesEntityExist(handle)) return + this.platform.giveWeaponToPed( + handle, + this.platform.getHashKey(weapon), + ammo, + hidden, + forceInHand, + ) } - /** - * Remove all weapons from a ped. - * - * @param handle - Ped handle - */ removeAllWeapons(handle: number): void { - if (!DoesEntityExist(handle)) return - RemoveAllPedWeapons(handle, true) + if (!this.platform.doesEntityExist(handle)) return + this.platform.removeAllPedWeapons(handle, true) } - /** - * Get the closest ped to the player. - * - * @param radius - Search radius - * @param excludePlayer - Exclude the player ped - * @returns Ped handle or null - */ getClosest(radius = 10.0, excludePlayer = true): number | null { - const playerPed = PlayerPedId() - const [px, py, pz] = GetEntityCoords(playerPed, true) - - const [found, handle] = GetClosestPed(px, py, pz, radius, true, true, true, false, -1) - - if (!found || handle === 0) return null + const playerPed = this.platform.getLocalPlayerPed() + const handle = this.platform.getClosestPed(this.platform.getEntityCoords(playerPed), radius) + if (!handle) return null if (excludePlayer && handle === playerPed) return null - return handle } - /** - * Get all peds in a radius. - * - * @param position - Center position - * @param radius - Search radius - * @param excludePlayer - Exclude the player ped - * @returns Array of ped handles - */ getNearby(position: Vector3, radius: number, excludePlayer = true): number[] { - const peds: number[] = [] - const playerPed = PlayerPedId() - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [handle, _] = FindFirstPed(0) - const ped = handle - - do { - if (!DoesEntityExist(ped)) continue - if (excludePlayer && ped === playerPed) continue - - const [ex, ey, ez] = GetEntityCoords(ped, true) - const dist = Math.sqrt( - (ex - position.x) ** 2 + (ey - position.y) ** 2 + (ez - position.z) ** 2, - ) - - if (dist <= radius) { - peds.push(ped) - } - } while (FindNextPed(handle, ped)) - - EndFindPed(handle) - return peds + return this.platform.getNearbyPeds( + position, + radius, + excludePlayer ? this.platform.getLocalPlayerPed() : undefined, + ) } - /** - * Make ped look at entity. - * - * @param handle - Ped handle - * @param entity - Entity to look at - * @param duration - Duration in ms (-1 for infinite) - */ lookAtEntity(handle: number, entity: number, duration = -1): void { - if (!DoesEntityExist(handle)) return - TaskLookAtEntity(handle, entity, duration, 2048, 3) + if (!this.platform.doesEntityExist(handle)) return + this.platform.taskLookAtEntity(handle, entity, duration) } - /** - * Make ped look at position. - * - * @param handle - Ped handle - * @param position - Position to look at - * @param duration - Duration in ms (-1 for infinite) - */ lookAtCoords(handle: number, position: Vector3, duration = -1): void { - if (!DoesEntityExist(handle)) return - TaskLookAtCoord(handle, position.x, position.y, position.z, duration, 2048, 3) + if (!this.platform.doesEntityExist(handle)) return + this.platform.taskLookAtCoord(handle, position, duration) } - /** - * Make ped walk to position. - * - * @param handle - Ped handle - * @param position - Target position - * @param speed - Walking speed (1.0 = walk, 2.0 = run) - */ walkTo(handle: number, position: Vector3, speed = 1.0): void { - if (!DoesEntityExist(handle)) return - TaskGoStraightToCoord(handle, position.x, position.y, position.z, speed, -1, 0.0, 0.0) + if (!this.platform.doesEntityExist(handle)) return + this.platform.taskGoStraightToCoord(handle, position, speed) } - /** - * Set ped combat attributes. - * - * @param handle - Ped handle - * @param canFight - Whether the ped can fight - * @param canUseCover - Whether the ped can use cover - */ setCombatAttributes(handle: number, canFight: boolean, canUseCover = true): void { - if (!DoesEntityExist(handle)) return - SetPedCombatAttributes(handle, 46, canFight) // Can fight - SetPedCombatAttributes(handle, 0, canUseCover) // Can use cover + if (!this.platform.doesEntityExist(handle)) return + this.platform.setPedCombatAttributes(handle, 46, canFight) + this.platform.setPedCombatAttributes(handle, 0, canUseCover) } - /** - * Get a managed ped by ID. - */ get(id: string): ManagedPed | undefined { return this.peds.get(id) } - - /** - * Get all managed peds. - */ getAll(): ManagedPed[] { return Array.from(this.peds.values()) } - - /** - * Check if a managed ped still exists. - */ exists(id: string): boolean { const ped = this.peds.get(id) - return ped ? DoesEntityExist(ped.handle) : false + return ped ? this.platform.doesEntityExist(ped.handle) : false } } diff --git a/src/runtime/client/services/progress.service.ts b/src/runtime/client/services/progress.service.ts index e19d405..12608f3 100644 --- a/src/runtime/client/services/progress.service.ts +++ b/src/runtime/client/services/progress.service.ts @@ -1,27 +1,20 @@ -import { injectable } from 'tsyringe' +import { inject, injectable } from 'tsyringe' +import { IClientPlatformBridge } from '../adapter/platform-bridge' +import { IClientRuntimeBridge } from '../adapter/runtime-bridge' export interface ProgressOptions { - /** Progress label/title */ label: string - /** Duration in milliseconds */ duration: number - /** Whether to use a circular indicator */ useCircular?: boolean - /** Whether the player can cancel (usually with a key) */ canCancel?: boolean - /** Disable player controls during progress */ disableControls?: boolean - /** Disable player movement */ disableMovement?: boolean - /** Disable combat actions */ disableCombat?: boolean - /** Animation to play during progress */ animation?: { dict: string anim: string flags?: number } - /** Prop to attach during progress */ prop?: { model: string bone: number @@ -41,82 +34,57 @@ export interface ProgressState { type ProgressCallback = (completed: boolean) => void -/** - * Service for displaying progress bars/indicators. - */ @injectable() export class ProgressService { private state: ProgressState | null = null - private tickHandle: number | null = null + private tickHandle: unknown = null private callback: ProgressCallback | null = null private propHandle: number | null = null - /** - * Start a progress action. - * - * @param options - Progress options - * @returns Promise that resolves with true if completed, false if cancelled - */ - async start(options: ProgressOptions): Promise { - if (this.state?.active) { - return false - } + constructor( + @inject(IClientPlatformBridge as any) private readonly platform: IClientPlatformBridge, + @inject(IClientRuntimeBridge as any) private readonly runtime: IClientRuntimeBridge, + ) {} + async start(options: ProgressOptions): Promise { + if (this.state?.active) return false return new Promise((resolve) => { this.state = { active: true, progress: 0, label: options.label, - startTime: GetGameTimer(), + startTime: this.runtime.getGameTimer(), duration: options.duration, options, } - this.callback = resolve - this.startProgress() + void this.startProgress() }) } - /** - * Cancel the current progress. - */ cancel(): void { if (!this.state?.active) return - this.cleanup(false) } - /** - * Check if a progress is currently active. - */ isActive(): boolean { return this.state?.active ?? false } - - /** - * Get current progress percentage (0-100). - */ getProgress(): number { return this.state?.progress ?? 0 } - - /** - * Get current progress state. - */ getState(): ProgressState | null { return this.state } private async startProgress(): Promise { if (!this.state) return - const { options } = this.state - const ped = PlayerPedId() + const ped = this.platform.getLocalPlayerPed() - // Load and play animation if specified if (options.animation) { await this.loadAnimDict(options.animation.dict) - TaskPlayAnim( + this.platform.taskPlayAnim( ped, options.animation.dict, options.animation.anim, @@ -125,145 +93,115 @@ export class ProgressService { options.duration, options.animation.flags ?? 1, 0.0, - false, - false, - false, ) } - // Attach prop if specified if (options.prop) { await this.loadModel(options.prop.model) - const propHash = GetHashKey(options.prop.model) - const coords = GetEntityCoords(ped, true) - this.propHandle = CreateObject(propHash, coords[0], coords[1], coords[2], true, true, true) - AttachEntityToEntity( + const propHash = this.platform.getHashKey(options.prop.model) + const coords = this.platform.getEntityCoords(ped) + this.propHandle = this.platform.createObject(propHash, coords, true, true, true) + this.platform.attachEntityToEntity( this.propHandle, ped, - GetPedBoneIndex(ped, options.prop.bone), - options.prop.offset.x, - options.prop.offset.y, - options.prop.offset.z, - options.prop.rotation.x, - options.prop.rotation.y, - options.prop.rotation.z, - true, - true, - false, - true, - 1, - true, + this.platform.getPedBoneIndex(ped, options.prop.bone), + options.prop.offset, + options.prop.rotation, ) } - // Start the tick - this.tickHandle = setTick(() => { + this.tickHandle = this.runtime.setTick(() => { if (!this.state) return - const elapsed = GetGameTimer() - this.state.startTime + const elapsed = this.runtime.getGameTimer() - this.state.startTime this.state.progress = Math.min((elapsed / this.state.duration) * 100, 100) - // Handle controls if (options.disableControls) { - DisableAllControlActions(0) + this.platform.disableAllControlActions(0) } else { if (options.disableMovement) { - DisableControlAction(0, 30, true) // Move LR - DisableControlAction(0, 31, true) // Move UD - DisableControlAction(0, 21, true) // Sprint - DisableControlAction(0, 22, true) // Jump + this.platform.disableControlAction(0, 30, true) + this.platform.disableControlAction(0, 31, true) + this.platform.disableControlAction(0, 21, true) + this.platform.disableControlAction(0, 22, true) } if (options.disableCombat) { - DisableControlAction(0, 24, true) // Attack - DisableControlAction(0, 25, true) // Aim - DisableControlAction(0, 47, true) // Weapon - DisableControlAction(0, 58, true) // Weapon - DisableControlAction(0, 263, true) // Melee - DisableControlAction(0, 264, true) // Melee + this.platform.disableControlAction(0, 24, true) + this.platform.disableControlAction(0, 25, true) + this.platform.disableControlAction(0, 47, true) + this.platform.disableControlAction(0, 58, true) + this.platform.disableControlAction(0, 263, true) + this.platform.disableControlAction(0, 264, true) } } - // Check for cancel - if (options.canCancel && IsControlJustPressed(0, 200)) { - // ESC key + if (options.canCancel && this.platform.isControlJustPressed(0, 200)) { this.cancel() return } - // Draw progress bar (native style) this.drawProgressBar() - // Check completion - if (elapsed >= this.state.duration) { - this.cleanup(true) - } + if (elapsed >= this.state.duration) this.cleanup(true) }) } private drawProgressBar(): void { if (!this.state) return - const { label, progress, options } = this.state if (options.useCircular) { - // Circular progress indicator - BeginTextCommandBusyspinnerOn('STRING') - AddTextComponentString(label) - EndTextCommandBusyspinnerOn(4) - } else { - // Bar style progress - const barWidth = 0.15 - const barHeight = 0.015 - const x = 0.5 - barWidth / 2 - const y = 0.88 - - // Background - DrawRect(0.5, y + barHeight / 2, barWidth, barHeight, 0, 0, 0, 180) - - // Progress fill - const fillWidth = (barWidth * progress) / 100 - DrawRect(x + fillWidth / 2, y + barHeight / 2, fillWidth, barHeight, 255, 255, 255, 255) - - // Label - SetTextFont(4) - SetTextScale(0.35, 0.35) - SetTextColour(255, 255, 255, 255) - SetTextCentre(true) - BeginTextCommandDisplayText('STRING') - AddTextComponentString(`${label} (${Math.floor(progress)}%)`) - EndTextCommandDisplayText(0.5, y - 0.03) + this.platform.beginTextCommandBusyspinnerOn('STRING') + this.platform.addTextComponentString(label) + this.platform.endTextCommandBusyspinnerOn(4) + return } + + const barWidth = 0.15 + const barHeight = 0.015 + const x = 0.5 - barWidth / 2 + const y = 0.88 + this.platform.drawRect(0.5, y + barHeight / 2, barWidth, barHeight, 0, 0, 0, 180) + const fillWidth = (barWidth * progress) / 100 + this.platform.drawRect( + x + fillWidth / 2, + y + barHeight / 2, + fillWidth, + barHeight, + 255, + 255, + 255, + 255, + ) + this.platform.setTextFont(4) + this.platform.setTextScale(0.35) + this.platform.setTextColour({ r: 255, g: 255, b: 255, a: 255 }) + this.platform.setTextCentre(true) + this.platform.beginTextCommandDisplayText('STRING') + this.platform.addTextComponentString(`${label} (${Math.floor(progress)}%)`) + this.platform.endTextCommandDisplayText(0.5, y - 0.03) } private cleanup(completed: boolean): void { - const ped = PlayerPedId() - - // Stop animation + const ped = this.platform.getLocalPlayerPed() if (this.state?.options.animation) { - StopAnimTask(ped, this.state.options.animation.dict, this.state.options.animation.anim, 1.0) + this.platform.stopAnimTask( + ped, + this.state.options.animation.dict, + this.state.options.animation.anim, + 1.0, + ) } - - // Remove prop if (this.propHandle) { - DeleteEntity(this.propHandle) + this.platform.deleteEntity(this.propHandle) this.propHandle = null } - - // Clear tick if (this.tickHandle !== null) { - clearTick(this.tickHandle) + this.runtime.clearTick(this.tickHandle) this.tickHandle = null } - - // Clear busy spinner - if (this.state?.options.useCircular) { - BusyspinnerOff() - } - - // Reset state + if (this.state?.options.useCircular) this.platform.busyspinnerOff() this.state = null - - // Invoke callback if (this.callback) { this.callback(completed) this.callback = null @@ -271,16 +209,16 @@ export class ProgressService { } private async loadAnimDict(dict: string): Promise { - RequestAnimDict(dict) - while (!HasAnimDictLoaded(dict)) { + this.platform.requestAnimDict(dict) + while (!this.platform.hasAnimDictLoaded(dict)) { await new Promise((r) => setTimeout(r, 0)) } } private async loadModel(model: string): Promise { - const hash = GetHashKey(model) - RequestModel(hash) - while (!HasModelLoaded(hash)) { + const hash = this.platform.getHashKey(model) + this.platform.requestModel(hash) + while (!this.platform.hasModelLoaded(hash)) { await new Promise((r) => setTimeout(r, 0)) } } diff --git a/src/runtime/client/services/session-bridge.service.ts b/src/runtime/client/services/session-bridge.service.ts new file mode 100644 index 0000000..32c3750 --- /dev/null +++ b/src/runtime/client/services/session-bridge.service.ts @@ -0,0 +1,45 @@ +import { inject, injectable } from 'tsyringe' +import { EventsAPI } from '../../../adapters/contracts/transport/events.api' +import { coreLogger, LogDomain } from '../../../kernel/logger' +import { Vec3 } from '../../../kernel/utils/vector3' +import { IClientLocalPlayerBridge } from '../adapter/local-player-bridge' +import { IClientRuntimeBridge } from '../adapter/runtime-bridge' + +const clientSession = coreLogger.child('Session', LogDomain.CLIENT) + +/** + * Registers lightweight client session listeners owned by the active adapter. + */ +@injectable() +export class ClientSessionBridgeService { + private playerId: string | undefined + + constructor( + @inject(EventsAPI as any) private readonly events: EventsAPI<'client'>, + @inject(IClientRuntimeBridge as any) private readonly runtime: IClientRuntimeBridge, + @inject(IClientLocalPlayerBridge as any) + private readonly localPlayer: IClientLocalPlayerBridge, + ) {} + + init(): void { + const currentResource = this.runtime.getCurrentResourceName() + + this.runtime.on('onClientResourceStart', (resourceName: string) => { + if (resourceName !== currentResource) return + clientSession.debug('Client session bridge initialized') + }) + + this.events.on('core:playerSessionInit', (_ctx, data: { playerId: string }) => { + this.playerId = data.playerId + clientSession.info('Player session initialized', { playerId: data.playerId }) + }) + + this.events.on('core:teleportTo', (_ctx, x: number, y: number, z: number, heading?: number) => { + this.localPlayer.setPosition(Vec3.create(x, y, z), heading) + }) + } + + getPlayerId(): string | undefined { + return this.playerId + } +} diff --git a/src/runtime/client/services/spawn.service.ts b/src/runtime/client/services/spawn.service.ts index 94e177e..94bd1cf 100644 --- a/src/runtime/client/services/spawn.service.ts +++ b/src/runtime/client/services/spawn.service.ts @@ -1,59 +1,29 @@ -import { injectable } from 'tsyringe' +import { inject, injectable } from 'tsyringe' import { loggers, PlayerAppearance } from '../../../kernel' import { Vector3 } from '../../../kernel/utils/vector3' +import { IClientPlatformBridge } from '../adapter/platform-bridge' import { AppearanceService } from './appearance.service' - -const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) +import { IClientSpawnBridge } from '../../../adapters/contracts/client/spawn/IClientSpawnBridge' interface SpawnOptions { - /** Optional: Apply complete character appearance (RP clothing, face, props, tattoos...) */ appearance?: PlayerAppearance } -const NETWORK_TIMEOUT_MS = 15_000 -const PED_TIMEOUT_MS = 10_000 -const COLLISION_TIMEOUT_MS = 7_000 - -/** - * Handles all player spawning logic on the client. - * - * This service manages the complete lifecycle of a player spawn: - * - Waiting for the network session - * - Loading and applying the player model - * - Ensuring collision and world data is ready - * - Resurrecting the player cleanly - * - Applying default ped components for freemode models - * - Fading the screen in/out during transitions - * - * The service is designed to be robust, predictable, and safe for any gamemode. - */ @injectable() export class SpawnService { private spawned = false private spawning = false - constructor(private appearanceService: AppearanceService) {} + constructor( + private readonly appearanceService: AppearanceService, + @inject(IClientSpawnBridge as any) private readonly spawnBridge: IClientSpawnBridge, + @inject(IClientPlatformBridge as any) private readonly platform: IClientPlatformBridge, + ) {} async init(): Promise { loggers.spawn.debug('SpawnService initialized') } - /** - * Performs the first spawn of the player. - * - * This method handles: - * - Fade out - * - Closing loading screens - * - Setting the player model - * - Ensuring the ped exists - * - Ensuring collision is loaded - * - Resurrecting the player - * - Preparing the ped for gameplay - * - Placing the player at the desired position - * - Fade in - * - * It should only be called once when the player joins. - */ async spawn( position: Vector3, model: string, @@ -64,37 +34,27 @@ export class SpawnService { loggers.spawn.warn('Spawn requested while a spawn is already in progress') return } + this.spawning = true try { - await this.ensureNetworkReady() - - if (!IsScreenFadedOut() && !IsScreenFadingOut()) { - DoScreenFadeOut(500) - while (!IsScreenFadedOut()) { - await delay(0) + loggers.spawn.debug('Waiting for spawn bridge readiness') + await this.spawnBridge.waitUntilReady() + loggers.spawn.debug('Spawn bridge ready, executing spawn', { position, model, heading }) + await this.spawnBridge.spawn({ position, model, heading }) + + const ped = this.platform.getLocalPlayerPed() + if (ped !== 0) { + if (options?.appearance) { + loggers.spawn.debug('Applying post-spawn appearance', { ped }) + await this.appearanceService.applyAppearance(ped, options.appearance) + } else { + this.appearanceService.setDefaultAppearance(ped) } } - this.closeLoadingScreens() - await this.setPlayerModel(model) - const ped = await this.ensurePed() - await this.applyAppearanceIfNeeded(ped, options?.appearance) - - await this.ensureCollisionAt(position, ped) - NetworkResurrectLocalPlayer(position.x, position.y, position.z, heading, 0, false) - const finalPed = await this.ensurePed() - - await this.setupPedForGameplay(finalPed) - await this.placePed(finalPed, position, heading) - this.spawned = true - - if (!IsScreenFadedIn() && !IsScreenFadingIn()) { - DoScreenFadeIn(500) - } - - loggers.spawn.info('Player spawned successfully (first spawn)', { + loggers.spawn.info('Player spawned successfully', { position: { x: position.x, y: position.y, z: position.z }, model, }) @@ -108,188 +68,27 @@ export class SpawnService { } } - /** - * Teleports the player instantly to a new position. - * Does not change the model or resurrect the player. - * Safe for gameplay use. - */ async teleportTo(position: Vector3, heading?: number): Promise { - const ped = await this.ensurePed() - - await this.ensureCollisionAt(position, ped) - - FreezeEntityPosition(ped, true) - SetEntityCoordsNoOffset(ped, position.x, position.y, position.z, false, false, false) - - if (heading !== undefined) { - SetEntityHeading(ped, heading) - } - - FreezeEntityPosition(ped, false) + await this.spawnBridge.teleport({ position, heading }) } - /** - * Respawns the player after death or a gameplay event. - * Restores health, resurrects the player, loads collision, - * prepares the ped and teleports them to the desired location. - */ async respawn(position: Vector3, heading = 0.0): Promise { - const ped = await this.ensurePed() - - await this.ensureCollisionAt(position, ped) - - ClearPedTasksImmediately(ped) - SetEntityHealth(ped, GetEntityMaxHealth(ped)) - - NetworkResurrectLocalPlayer(position.x, position.y, position.z, heading, 0, false) - - const finalPed = await this.ensurePed() - await this.setupPedForGameplay(finalPed) - await this.teleportTo(position, heading) - + await this.spawnBridge.waitUntilReady() + await this.spawnBridge.respawn({ position, heading }) + this.spawned = true loggers.spawn.info('Player respawned', { position: { x: position.x, y: position.y, z: position.z }, heading, }) } - /** - * Returns whether the player has completed their first spawn. - */ isSpawned(): boolean { return this.spawned } - /** - * Allows other systems to wait until the player is fully spawned. - */ async waitUntilSpawned(): Promise { while (!this.spawned) { - await delay(0) - } - } - - private async ensureNetworkReady(): Promise { - const start = GetGameTimer() - - while (!NetworkIsSessionStarted()) { - if (GetGameTimer() - start > NETWORK_TIMEOUT_MS) { - loggers.spawn.error('Network session did not start in time') - throw new Error('NETWORK_TIMEOUT') - } - await delay(0) - } - } - - private closeLoadingScreens(): void { - try { - ShutdownLoadingScreen() - } catch {} - try { - ShutdownLoadingScreenNui() - } catch {} - } - - private async setPlayerModel(model: string): Promise { - const modelHash = GetHashKey(model) - - if (!IsModelInCdimage(modelHash) || !IsModelValid(modelHash)) { - loggers.spawn.error('Invalid model requested', { model }) - throw new Error('MODEL_INVALID') - } - - RequestModel(modelHash) - while (!HasModelLoaded(modelHash)) { - await delay(0) - } - - SetPlayerModel(PlayerId(), modelHash) - SetModelAsNoLongerNeeded(modelHash) - - const ped = PlayerPedId() - if (ped !== 0) { - SetPedDefaultComponentVariation(ped) - } - } - - private async ensurePed(): Promise { - const start = GetGameTimer() - let ped = PlayerPedId() - - while (ped === 0) { - if (GetGameTimer() - start > PED_TIMEOUT_MS) { - loggers.spawn.error('PlayerPedId() did not become valid in time') - throw new Error('PED_TIMEOUT') - } - await delay(0) - ped = PlayerPedId() - } - - return ped - } - - private async ensureCollisionAt(position: Vector3, ped: number): Promise { - RequestCollisionAtCoord(position.x, position.y, position.z) - - const start = GetGameTimer() - while (!HasCollisionLoadedAroundEntity(ped)) { - if (GetGameTimer() - start > COLLISION_TIMEOUT_MS) { - loggers.spawn.warn('Collision did not fully load around entity in time', { - x: position.x, - y: position.y, - z: position.z, - }) - break - } - await delay(0) - } - } - - private async setupPedForGameplay(ped: number): Promise { - SetEntityAsMissionEntity(ped, true, true) - - ClearPedTasksImmediately(ped) - RemoveAllPedWeapons(ped, true) - - ResetEntityAlpha(ped) - await delay(0) - SetEntityAlpha(ped, 255, false) - SetEntityVisible(ped, true, false) - SetEntityCollision(ped, true, true) - SetEntityInvincible(ped, false) - } - - private async placePed(ped: number, position: Vector3, heading: number): Promise { - FreezeEntityPosition(ped, true) - - SetEntityCoordsNoOffset(ped, position.x, position.y, position.z, false, false, false) - SetEntityHeading(ped, heading) - - await delay(0) - - FreezeEntityPosition(ped, false) - } - - private async applyAppearanceIfNeeded(ped: number, appearance?: PlayerAppearance): Promise { - if (!appearance) { - SetPedDefaultComponentVariation(ped) - return - } - - const validation = this.appearanceService.validateAppearance(appearance) - if (!validation.valid) { - loggers.spawn.warn('Invalid appearance data', { errors: validation.errors }) - SetPedDefaultComponentVariation(ped) - return - } - - try { - await this.appearanceService.applyAppearance(ped, appearance) - } catch (error) { - loggers.spawn.error('Failed to apply appearance, using default variation', { - error: error instanceof Error ? error.message : String(error), - }) - SetPedDefaultComponentVariation(ped) + await new Promise((resolve) => setTimeout(resolve, 0)) } } } diff --git a/src/runtime/client/services/streaming.service.ts b/src/runtime/client/services/streaming.service.ts index 526a2dd..d44a619 100644 --- a/src/runtime/client/services/streaming.service.ts +++ b/src/runtime/client/services/streaming.service.ts @@ -1,4 +1,6 @@ -import { injectable } from 'tsyringe' +import { inject, injectable } from 'tsyringe' +import { IClientPlatformBridge } from '../adapter/platform-bridge' +import { IClientRuntimeBridge } from '../adapter/runtime-bridge' export interface StreamingRequest { type: 'model' | 'animDict' | 'ptfx' | 'texture' | 'audio' @@ -7,45 +9,26 @@ export interface StreamingRequest { hash?: number } -/** - * Service for managing asset streaming (models, animations, particles, etc.). - */ @injectable() export class StreamingService { private loadedAssets: Map = new Map() - // ───────────────────────────────────────────────────────────────────────────── - // Model Loading - // ───────────────────────────────────────────────────────────────────────────── - - /** - * Request and load a model. - * - * @param model - Model name or hash - * @param timeout - Maximum wait time in ms - * @returns Whether the model was loaded successfully - */ + constructor( + @inject(IClientPlatformBridge as any) private readonly platform: IClientPlatformBridge, + @inject(IClientRuntimeBridge as any) private readonly runtime: IClientRuntimeBridge, + ) {} + async requestModel(model: string | number, timeout = 10000): Promise { - const hash = typeof model === 'string' ? GetHashKey(model) : model + const hash = typeof model === 'string' ? this.platform.getHashKey(model) : model const key = `model:${hash}` - // Already loaded - if (this.loadedAssets.get(key)?.loaded) { - return true - } - - // Check if valid - if (!IsModelInCdimage(hash) || !IsModelValid(hash)) { - return false - } - - RequestModel(hash) + if (this.loadedAssets.get(key)?.loaded) return true + if (!this.platform.isModelInCdimage(hash) || !this.platform.isModelValid(hash)) return false - const startTime = GetGameTimer() - while (!HasModelLoaded(hash)) { - if (GetGameTimer() - startTime > timeout) { - return false - } + this.platform.requestModel(hash) + const startTime = this.runtime.getGameTimer() + while (!this.platform.hasModelLoaded(hash)) { + if (this.runtime.getGameTimer() - startTime > timeout) return false await new Promise((r) => setTimeout(r, 0)) } @@ -53,83 +36,40 @@ export class StreamingService { return true } - /** - * Check if a model is loaded. - * - * @param model - Model name or hash - */ isModelLoaded(model: string | number): boolean { - const hash = typeof model === 'string' ? GetHashKey(model) : model - return HasModelLoaded(hash) + const hash = typeof model === 'string' ? this.platform.getHashKey(model) : model + return this.platform.hasModelLoaded(hash) } - /** - * Release a loaded model. - * - * @param model - Model name or hash - */ releaseModel(model: string | number): void { - const hash = typeof model === 'string' ? GetHashKey(model) : model - SetModelAsNoLongerNeeded(hash) + const hash = typeof model === 'string' ? this.platform.getHashKey(model) : model + this.platform.setModelAsNoLongerNeeded(hash) this.loadedAssets.delete(`model:${hash}`) } - /** - * Check if a model is valid and exists in the game files. - * - * @param model - Model name or hash - */ isModelValid(model: string | number): boolean { - const hash = typeof model === 'string' ? GetHashKey(model) : model - return IsModelInCdimage(hash) && IsModelValid(hash) + const hash = typeof model === 'string' ? this.platform.getHashKey(model) : model + return this.platform.isModelInCdimage(hash) && this.platform.isModelValid(hash) } - /** - * Check if a model is a vehicle. - * - * @param model - Model name or hash - */ isModelVehicle(model: string | number): boolean { - const hash = typeof model === 'string' ? GetHashKey(model) : model - return IsModelAVehicle(hash) + const hash = typeof model === 'string' ? this.platform.getHashKey(model) : model + return this.platform.isModelAVehicle(hash) } - /** - * Check if a model is a ped. - * - * @param model - Model name or hash - */ isModelPed(model: string | number): boolean { - const hash = typeof model === 'string' ? GetHashKey(model) : model - return IsModelAPed(hash) + const hash = typeof model === 'string' ? this.platform.getHashKey(model) : model + return this.platform.isModelAPed(hash) } - // ───────────────────────────────────────────────────────────────────────────── - // Animation Dictionary Loading - // ───────────────────────────────────────────────────────────────────────────── - - /** - * Request and load an animation dictionary. - * - * @param dict - Animation dictionary name - * @param timeout - Maximum wait time in ms - * @returns Whether the dictionary was loaded successfully - */ async requestAnimDict(dict: string, timeout = 10000): Promise { const key = `anim:${dict}` + if (this.loadedAssets.get(key)?.loaded) return true - // Already loaded - if (this.loadedAssets.get(key)?.loaded) { - return true - } - - RequestAnimDict(dict) - - const startTime = GetGameTimer() - while (!HasAnimDictLoaded(dict)) { - if (GetGameTimer() - startTime > timeout) { - return false - } + this.platform.requestAnimDict(dict) + const startTime = this.runtime.getGameTimer() + while (!this.platform.hasAnimDictLoaded(dict)) { + if (this.runtime.getGameTimer() - startTime > timeout) return false await new Promise((r) => setTimeout(r, 0)) } @@ -137,51 +77,23 @@ export class StreamingService { return true } - /** - * Check if an animation dictionary is loaded. - * - * @param dict - Animation dictionary name - */ isAnimDictLoaded(dict: string): boolean { - return HasAnimDictLoaded(dict) + return this.platform.hasAnimDictLoaded(dict) } - /** - * Release a loaded animation dictionary. - * - * @param dict - Animation dictionary name - */ releaseAnimDict(dict: string): void { - RemoveAnimDict(dict) + this.platform.removeAnimDict(dict) this.loadedAssets.delete(`anim:${dict}`) } - // ───────────────────────────────────────────────────────────────────────────── - // Particle Effects (PTFX) Loading - // ───────────────────────────────────────────────────────────────────────────── - - /** - * Request and load a particle effect asset. - * - * @param asset - PTFX asset name - * @param timeout - Maximum wait time in ms - * @returns Whether the asset was loaded successfully - */ async requestPtfxAsset(asset: string, timeout = 10000): Promise { const key = `ptfx:${asset}` + if (this.loadedAssets.get(key)?.loaded) return true - // Already loaded - if (this.loadedAssets.get(key)?.loaded) { - return true - } - - RequestNamedPtfxAsset(asset) - - const startTime = GetGameTimer() - while (!HasNamedPtfxAssetLoaded(asset)) { - if (GetGameTimer() - startTime > timeout) { - return false - } + this.platform.requestNamedPtfxAsset(asset) + const startTime = this.runtime.getGameTimer() + while (!this.platform.hasNamedPtfxAssetLoaded(asset)) { + if (this.runtime.getGameTimer() - startTime > timeout) return false await new Promise((r) => setTimeout(r, 0)) } @@ -189,36 +101,15 @@ export class StreamingService { return true } - /** - * Check if a PTFX asset is loaded. - * - * @param asset - PTFX asset name - */ isPtfxAssetLoaded(asset: string): boolean { - return HasNamedPtfxAssetLoaded(asset) + return this.platform.hasNamedPtfxAssetLoaded(asset) } - /** - * Release a loaded PTFX asset. - * - * @param asset - PTFX asset name - */ releasePtfxAsset(asset: string): void { - RemoveNamedPtfxAsset(asset) + this.platform.removeNamedPtfxAsset(asset) this.loadedAssets.delete(`ptfx:${asset}`) } - /** - * Start a particle effect at a position. - * - * @param asset - PTFX asset name - * @param effectName - Effect name within the asset - * @param position - World position - * @param rotation - Rotation - * @param scale - Scale - * @param looped - Whether to loop - * @returns The particle effect handle - */ async startParticleEffect( asset: string, effectName: string, @@ -228,76 +119,24 @@ export class StreamingService { looped = false, ): Promise { await this.requestPtfxAsset(asset) - - UseParticleFxAssetNextCall(asset) - - if (looped) { - return StartParticleFxLoopedAtCoord( - effectName, - position.x, - position.y, - position.z, - rotation.x, - rotation.y, - rotation.z, - scale, - false, - false, - false, - false, - ) - } else { - return StartParticleFxNonLoopedAtCoord( - effectName, - position.x, - position.y, - position.z, - rotation.x, - rotation.y, - rotation.z, - scale, - false, - false, - false, - ) - } + this.platform.useParticleFxAssetNextCall(asset) + return looped + ? this.platform.startParticleFxLoopedAtCoord(effectName, position, rotation, scale) + : this.platform.startParticleFxNonLoopedAtCoord(effectName, position, rotation, scale) } - /** - * Stop a looped particle effect. - * - * @param handle - Particle effect handle - */ stopParticleEffect(handle: number): void { - StopParticleFxLooped(handle, false) + this.platform.stopParticleFxLooped(handle, false) } - // ───────────────────────────────────────────────────────────────────────────── - // Texture Dictionary Loading - // ───────────────────────────────────────────────────────────────────────────── - - /** - * Request and load a texture dictionary. - * - * @param dict - Texture dictionary name - * @param timeout - Maximum wait time in ms - * @returns Whether the dictionary was loaded successfully - */ async requestTextureDict(dict: string, timeout = 10000): Promise { const key = `texture:${dict}` + if (this.loadedAssets.get(key)?.loaded) return true - // Already loaded - if (this.loadedAssets.get(key)?.loaded) { - return true - } - - RequestStreamedTextureDict(dict, true) - - const startTime = GetGameTimer() - while (!HasStreamedTextureDictLoaded(dict)) { - if (GetGameTimer() - startTime > timeout) { - return false - } + this.platform.requestStreamedTextureDict(dict, true) + const startTime = this.runtime.getGameTimer() + while (!this.platform.hasStreamedTextureDictLoaded(dict)) { + if (this.runtime.getGameTimer() - startTime > timeout) return false await new Promise((r) => setTimeout(r, 0)) } @@ -305,105 +144,59 @@ export class StreamingService { return true } - /** - * Check if a texture dictionary is loaded. - * - * @param dict - Texture dictionary name - */ isTextureDictLoaded(dict: string): boolean { - return HasStreamedTextureDictLoaded(dict) + return this.platform.hasStreamedTextureDictLoaded(dict) } - /** - * Release a loaded texture dictionary. - * - * @param dict - Texture dictionary name - */ releaseTextureDict(dict: string): void { - SetStreamedTextureDictAsNoLongerNeeded(dict) + this.platform.setStreamedTextureDictAsNoLongerNeeded(dict) this.loadedAssets.delete(`texture:${dict}`) } - // ───────────────────────────────────────────────────────────────────────────── - // Audio Loading - // ───────────────────────────────────────────────────────────────────────────── - - /** - * Request and load a script audio bank. - * - * @param audioBank - Audio bank name - * @param networked - Whether the audio should be networked - * @param timeout - Maximum wait time in ms - * @returns Whether the audio bank was loaded successfully - */ async requestAudioBank(audioBank: string, networked = false, _timeout = 10000): Promise { const key = `audio:${audioBank}` + if (this.loadedAssets.get(key)?.loaded) return true - // Already loaded - if (this.loadedAssets.get(key)?.loaded) { - return true - } - - const success = RequestScriptAudioBank(audioBank, networked) + const success = this.platform.requestScriptAudioBank(audioBank, networked) if (!success) return false this.loadedAssets.set(key, { type: 'audio', asset: audioBank, loaded: true }) return true } - /** - * Release a loaded audio bank. - * - * @param audioBank - Audio bank name - */ releaseAudioBank(audioBank: string): void { - ReleaseScriptAudioBank() + this.platform.releaseScriptAudioBank(audioBank) this.loadedAssets.delete(`audio:${audioBank}`) } - // ───────────────────────────────────────────────────────────────────────────── - // Utility Methods - // ───────────────────────────────────────────────────────────────────────────── - - /** - * Get all currently loaded assets. - */ getLoadedAssets(): StreamingRequest[] { return Array.from(this.loadedAssets.values()) } - /** - * Release all loaded assets. - */ releaseAll(): void { for (const asset of this.loadedAssets.values()) { switch (asset.type) { case 'model': - if (asset.hash) SetModelAsNoLongerNeeded(asset.hash) + if (asset.hash) this.platform.setModelAsNoLongerNeeded(asset.hash) break case 'animDict': - RemoveAnimDict(asset.asset) + this.platform.removeAnimDict(asset.asset) break case 'ptfx': - RemoveNamedPtfxAsset(asset.asset) + this.platform.removeNamedPtfxAsset(asset.asset) break case 'texture': - SetStreamedTextureDictAsNoLongerNeeded(asset.asset) + this.platform.setStreamedTextureDictAsNoLongerNeeded(asset.asset) break case 'audio': - ReleaseScriptAudioBank() + this.platform.releaseScriptAudioBank(asset.asset) break } } this.loadedAssets.clear() } - /** - * Get hash key for a string. - * - * @param str - String to hash - */ getHash(str: string): number { - return GetHashKey(str) + return this.platform.getHashKey(str) } } diff --git a/src/runtime/client/services/textui.service.ts b/src/runtime/client/services/textui.service.ts index 0496e76..ef677db 100644 --- a/src/runtime/client/services/textui.service.ts +++ b/src/runtime/client/services/textui.service.ts @@ -1,29 +1,21 @@ -import { injectable } from 'tsyringe' +import { inject, injectable } from 'tsyringe' import { Vector3 } from '../../../kernel/utils/vector3' +import { IClientPlatformBridge } from '../adapter/platform-bridge' +import { IClientRuntimeBridge } from '../adapter/runtime-bridge' export interface TextUIOptions { - /** Font (0-8) */ font?: number - /** Scale (0.0-1.0+) */ scale?: number - /** Color */ color?: { r: number; g: number; b: number; a: number } - /** Text alignment (0=center, 1=left, 2=right) */ alignment?: number - /** Drop shadow */ dropShadow?: boolean - /** Text outline */ outline?: boolean - /** Word wrap width (0 = no wrap) */ wrapWidth?: number } export interface Text3DOptions extends TextUIOptions { - /** Whether to draw a background behind text */ background?: boolean - /** Background color */ backgroundColor?: { r: number; g: number; b: number; a: number } - /** Background padding */ backgroundPadding?: number } @@ -45,117 +37,71 @@ const DEFAULT_3D_OPTIONS: Required = { backgroundPadding: 0.002, } -/** - * Service for rendering text UI elements on screen and in 3D world. - */ @injectable() export class TextUIService { private activeTextUI: { text: string; options: Required } | null = null - private tickHandle: number | null = null - - /** - * Show persistent text UI at the bottom-right of the screen. - * - * @param text - The text to display - * @param options - Text options - */ + private tickHandle: unknown = null + + constructor( + @inject(IClientPlatformBridge as any) private readonly platform: IClientPlatformBridge, + @inject(IClientRuntimeBridge as any) private readonly runtime: IClientRuntimeBridge, + ) {} + show(text: string, options: TextUIOptions = {}): void { - this.activeTextUI = { - text, - options: { ...DEFAULT_OPTIONS, ...options }, - } + this.activeTextUI = { text, options: { ...DEFAULT_OPTIONS, ...options } } this.ensureTickRunning() } - /** - * Hide the persistent text UI. - */ hide(): void { this.activeTextUI = null this.stopTick() } - /** - * Check if text UI is currently visible. - */ isVisible(): boolean { return this.activeTextUI !== null } - /** - * Draw text on screen for one frame. - * Call this every frame for persistent display. - * - * @param text - The text to draw - * @param x - Screen X position (0.0-1.0) - * @param y - Screen Y position (0.0-1.0) - * @param options - Text options - */ drawText(text: string, x: number, y: number, options: TextUIOptions = {}): void { const opts = { ...DEFAULT_OPTIONS, ...options } - - SetTextFont(opts.font) - SetTextScale(opts.scale, opts.scale) - SetTextColour(opts.color.r, opts.color.g, opts.color.b, opts.color.a) - SetTextJustification(opts.alignment) + this.platform.setTextFont(opts.font) + this.platform.setTextScale(opts.scale) + this.platform.setTextColour(opts.color) + this.platform.setTextJustification(opts.alignment) if (opts.dropShadow) { - SetTextDropshadow(0, 0, 0, 0, 255) - SetTextDropShadow() - } - - if (opts.outline) { - SetTextOutline() - } - - if (opts.wrapWidth > 0) { - SetTextWrap(x, x + opts.wrapWidth) + this.platform.setTextDropshadow(0, 0, 0, 0, 255) + this.platform.setTextDropShadow() } - + if (opts.outline) this.platform.setTextOutline() + if (opts.wrapWidth > 0) this.platform.setTextWrap(x, x + opts.wrapWidth) if (opts.alignment === 2) { - SetTextRightJustify(true) - SetTextWrap(0.0, x) + this.platform.setTextRightJustify(true) + this.platform.setTextWrap(0.0, x) } - BeginTextCommandDisplayText('STRING') - AddTextComponentString(text) - EndTextCommandDisplayText(x, y) + this.platform.beginTextCommandDisplayText('STRING') + this.platform.addTextComponentString(text) + this.platform.endTextCommandDisplayText(x, y) } - /** - * Draw 3D text in the game world. - * - * @param position - World position - * @param text - The text to draw - * @param options - Text options - */ drawText3D(position: Vector3, text: string, options: Text3DOptions = {}): void { const opts = { ...DEFAULT_3D_OPTIONS, ...options } + const screen = this.platform.worldToScreen(position) + if (!screen.onScreen) return - const [onScreen, screenX, screenY] = World3dToScreen2d(position.x, position.y, position.z) - if (!onScreen) return - - // Calculate distance-based scale - const camCoords = GetGameplayCamCoords() - const distance = GetDistanceBetweenCoords( - camCoords[0], - camCoords[1], - camCoords[2], - position.x, - position.y, - position.z, + const distance = this.platform.getDistanceBetweenCoords( + this.platform.getGameplayCamCoords(), + position, true, ) - - const scale = opts.scale * (1 / distance) * 2 + const scale = opts.scale * (1 / Math.max(distance, 0.001)) * 2 const scaledScale = Math.max(scale, 0.15) - // Draw background if enabled if (opts.background) { const factor = text.length / 300 - DrawRect( - screenX, - screenY + opts.backgroundPadding, + this.platform.drawRect( + screen.x, + screen.y + opts.backgroundPadding, factor + opts.backgroundPadding * 2, 0.03 + opts.backgroundPadding * 2, opts.backgroundColor.r, @@ -165,55 +111,37 @@ export class TextUIService { ) } - // Draw text - SetTextScale(scaledScale, scaledScale) - SetTextFont(opts.font) - SetTextColour(opts.color.r, opts.color.g, opts.color.b, opts.color.a) - SetTextCentre(true) - + this.platform.setTextScale(scaledScale) + this.platform.setTextFont(opts.font) + this.platform.setTextColour(opts.color) + this.platform.setTextCentre(true) if (opts.dropShadow) { - SetTextDropshadow(0, 0, 0, 0, 255) - SetTextDropShadow() - } - - if (opts.outline) { - SetTextOutline() + this.platform.setTextDropshadow(0, 0, 0, 0, 255) + this.platform.setTextDropShadow() } + if (opts.outline) this.platform.setTextOutline() - BeginTextCommandDisplayText('STRING') - AddTextComponentString(text) - EndTextCommandDisplayText(screenX, screenY) + this.platform.beginTextCommandDisplayText('STRING') + this.platform.addTextComponentString(text) + this.platform.endTextCommandDisplayText(screen.x, screen.y) } - /** - * Get text width for layout calculations. - * Note: This is an approximation based on character count and scale. - */ getTextWidth(text: string, _font: number, scale: number): number { - // Approximate text width based on character count and scale - // Average character width at scale 1.0 is approximately 0.01 return text.length * 0.01 * scale } private ensureTickRunning(): void { if (this.tickHandle !== null) return - - this.tickHandle = setTick(() => { + this.tickHandle = this.runtime.setTick(() => { if (!this.activeTextUI) return - const { text, options } = this.activeTextUI - - // Draw at bottom-right - this.drawText(text, 0.985, 0.93, { - ...options, - alignment: 2, - }) + this.drawText(text, 0.985, 0.93, { ...options, alignment: 2 }) }) } private stopTick(): void { if (this.tickHandle !== null) { - clearTick(this.tickHandle) + this.runtime.clearTick(this.tickHandle) this.tickHandle = null } } diff --git a/src/runtime/client/services/vehicle-client.service.ts b/src/runtime/client/services/vehicle-client.service.ts index a773f52..c1c9cc1 100644 --- a/src/runtime/client/services/vehicle-client.service.ts +++ b/src/runtime/client/services/vehicle-client.service.ts @@ -1,393 +1,228 @@ -import { injectable } from 'tsyringe' +import { inject, injectable } from 'tsyringe' +import { EventsAPI } from '../../../adapters/contracts/transport/events.api' import { Vector3 } from '../../../kernel/utils/vector3' +import { IClientPlatformBridge } from '../adapter/platform-bridge' +import { IClientRuntimeBridge } from '../adapter/runtime-bridge' import { SerializedVehicleData, VehicleCreateOptions, VehicleSpawnResult, } from '../../server/types/vehicle.types' -/** - * Client-side vehicle service. - * - * This service provides a simplified interface for vehicle operations on the client. - * Most operations delegate to the server for security and synchronization. - * - * @remarks - * - Vehicle creation is server-authoritative (prevents spawning exploits) - * - Modifications require server validation - * - Local operations (queries, visual changes) are safe to perform client-side - */ @injectable() export class VehicleClientService { private pendingCreations = new Map void>() + private pendingDeletes = new Map void>() + private pendingRepairs = new Map void>() + private pendingData = new Map void>() + private pendingPlayerVehicles: ((vehicles: SerializedVehicleData[]) => void) | null = null private requestIdCounter = 0 - constructor() { + constructor( + @inject(EventsAPI as any) private readonly events: EventsAPI<'client'>, + @inject(IClientPlatformBridge as any) private readonly platform: IClientPlatformBridge, + @inject(IClientRuntimeBridge as any) private readonly runtime: IClientRuntimeBridge, + ) { this.registerEventHandlers() } - /** - * Requests vehicle creation from the server. - * - * @param options - Vehicle creation options - * @returns Promise resolving to spawn result - */ async createVehicle( options: Omit, ): Promise { return new Promise((resolve) => { const requestId = this.requestIdCounter++ this.pendingCreations.set(requestId, resolve) - - emitNet('opencore:vehicle:create', { - ...options, - _requestId: requestId, - }) - + this.events.emit('opencore:vehicle:create', { ...options, _requestId: requestId }) setTimeout(() => { - if (this.pendingCreations.has(requestId)) { - this.pendingCreations.delete(requestId) - resolve({ - networkId: 0, - handle: 0, - success: false, - error: 'Request timeout', - }) - } + if (!this.pendingCreations.has(requestId)) return + this.pendingCreations.delete(requestId) + resolve({ networkId: 0, handle: 0, success: false, error: 'Request timeout' }) }, 5000) }) } - /** - * Requests vehicle deletion from the server. - * - * @param networkId - Network ID of the vehicle - * @returns Promise resolving to success status - */ async deleteVehicle(networkId: number): Promise { return new Promise((resolve) => { - const handler = (result: { networkId: number; success: boolean }) => { - if (result.networkId === networkId) { - resolve(result.success) - } - } - - const eventName = 'opencore:vehicle:deleteResult' - onNet(eventName, handler) - - emitNet('opencore:vehicle:delete', networkId) - + this.pendingDeletes.set(networkId, resolve) + this.events.emit('opencore:vehicle:delete', networkId) setTimeout(() => { - removeEventListener(eventName, handler) + if (!this.pendingDeletes.has(networkId)) return + this.pendingDeletes.delete(networkId) resolve(false) }, 5000) }) } - /** - * Requests vehicle repair from the server. - * - * @param networkId - Network ID of the vehicle - * @returns Promise resolving to success status - */ async repairVehicle(networkId: number): Promise { return new Promise((resolve) => { - const handler = (result: { networkId: number; success: boolean }) => { - if (result.networkId === networkId) { - resolve(result.success) - } - } - - const eventName = 'opencore:vehicle:repairResult' - onNet(eventName, handler) - - emitNet('opencore:vehicle:repair', networkId) - + this.pendingRepairs.set(networkId, resolve) + this.events.emit('opencore:vehicle:repair', networkId) setTimeout(() => { - removeEventListener(eventName, handler) + if (!this.pendingRepairs.has(networkId)) return + this.pendingRepairs.delete(networkId) resolve(false) }, 5000) }) } - /** - * Gets the closest vehicle to the player. - * - * @param radius - Search radius - * @returns Vehicle handle or null - */ getClosestVehicle(radius = 10.0): number | null { - const playerPed = PlayerPedId() - const [px, py, pz] = GetEntityCoords(playerPed, true) - - const vehicle = GetClosestVehicle(px, py, pz, radius, 0, 71) - return vehicle !== 0 ? vehicle : null + const playerPed = this.platform.getLocalPlayerPed() + return this.platform.getClosestVehicle(this.platform.getEntityCoords(playerPed), radius) } - /** - * Checks if the player is in a vehicle. - */ isPlayerInVehicle(): boolean { - return IsPedInAnyVehicle(PlayerPedId(), false) + return this.platform.isPedInAnyVehicle(this.platform.getLocalPlayerPed()) } - /** - * Gets the vehicle the player is currently in. - * - * @returns Vehicle handle or null - */ getCurrentVehicle(): number | null { - const ped = PlayerPedId() - if (!IsPedInAnyVehicle(ped, false)) return null - return GetVehiclePedIsIn(ped, false) + const ped = this.platform.getLocalPlayerPed() + if (!this.platform.isPedInAnyVehicle(ped)) return null + return this.platform.getVehiclePedIsIn(ped, false) } - /** - * Gets the last vehicle the player was in. - * - * @returns Vehicle handle or null - */ getLastVehicle(): number | null { - const vehicle = GetVehiclePedIsIn(PlayerPedId(), true) - return vehicle !== 0 ? vehicle : null + return this.platform.getVehiclePedIsIn(this.platform.getLocalPlayerPed(), true) } - /** - * Checks if player is the driver of their current vehicle. - */ isPlayerDriver(): boolean { const vehicle = this.getCurrentVehicle() if (!vehicle) return false - return GetPedInVehicleSeat(vehicle, -1) === PlayerPedId() + return this.platform.getPedInVehicleSeat(vehicle, -1) === this.platform.getLocalPlayerPed() } - /** - * Gets vehicle speed in km/h. - * - * @param vehicle - Vehicle handle - */ getSpeed(vehicle: number): number { - if (!DoesEntityExist(vehicle)) return 0 - return GetEntitySpeed(vehicle) * 3.6 + if (!this.platform.doesEntityExist(vehicle)) return 0 + return this.platform.getEntitySpeed(vehicle) * 3.6 } - /** - * Gets the network ID from a vehicle handle. - * - * @param vehicle - Vehicle handle - * @returns Network ID or 0 if invalid - */ getNetworkId(vehicle: number): number { - if (!DoesEntityExist(vehicle)) return 0 - return NetworkGetNetworkIdFromEntity(vehicle) + if (!this.platform.doesEntityExist(vehicle)) return 0 + return this.platform.networkGetNetworkIdFromEntity(vehicle) } - /** - * Gets the vehicle handle from a network ID. - * - * @param networkId - Network ID - * @returns Vehicle handle or 0 if not found - */ getVehicleFromNetworkId(networkId: number): number { - if (!NetworkDoesEntityExistWithNetworkId(networkId)) return 0 - return NetworkGetEntityFromNetworkId(networkId) + if (!this.platform.networkDoesEntityExistWithNetworkId(networkId)) return 0 + return this.platform.networkGetEntityFromNetworkId(networkId) } - /** - * Gets vehicle data from state bag. - * - * @param vehicle - Vehicle handle - * @param key - State bag key - * @returns State value or undefined - */ getVehicleState(vehicle: number, key: string): T | undefined { - if (!DoesEntityExist(vehicle)) return undefined - const stateBag = Entity(vehicle).state - return stateBag[key] as T | undefined + if (!this.platform.doesEntityExist(vehicle)) return undefined + return this.platform.getEntityState(vehicle, key) } - /** - * Requests to lock/unlock vehicle doors. - * - * @param networkId - Network ID of the vehicle - * @param locked - Whether to lock or unlock - */ setDoorsLocked(networkId: number, locked: boolean): void { - emitNet('opencore:vehicle:setLocked', networkId, locked) + this.events.emit('opencore:vehicle:setLocked', networkId, locked) } - /** - * Requests vehicle data from the server. - * - * @param networkId - Network ID of the vehicle - * @returns Promise resolving to vehicle data - */ async getVehicleData(networkId: number): Promise { return new Promise((resolve) => { - const handler = (data: SerializedVehicleData | null) => { - resolve(data) - } - - const eventName = 'opencore:vehicle:dataResult' - onNet(eventName, handler) - - emitNet('opencore:vehicle:getData', networkId) - + this.pendingData.set(networkId, resolve) + this.events.emit('opencore:vehicle:getData', networkId) setTimeout(() => { - removeEventListener(eventName, handler) + if (!this.pendingData.has(networkId)) return + this.pendingData.delete(networkId) resolve(null) }, 5000) }) } - /** - * Requests all player vehicles from the server. - * - * @returns Promise resolving to array of vehicle data - */ async getPlayerVehicles(): Promise { return new Promise((resolve) => { - const handler = (vehicles: SerializedVehicleData[]) => { - resolve(vehicles) - } - - const eventName = 'opencore:vehicle:playerVehiclesResult' - onNet(eventName, handler) - - emitNet('opencore:vehicle:getPlayerVehicles') - + this.pendingPlayerVehicles = resolve + this.events.emit('opencore:vehicle:getPlayerVehicles') setTimeout(() => { - removeEventListener(eventName, handler) + if (!this.pendingPlayerVehicles) return + this.pendingPlayerVehicles = null resolve([]) }, 5000) }) } - /** - * Local-only: Warps player into a vehicle. - * - * @param vehicle - Vehicle handle - * @param seatIndex - Seat index (-1 = driver) - */ warpIntoVehicle(vehicle: number, seatIndex: number = -1): void { - if (!DoesEntityExist(vehicle)) return - TaskWarpPedIntoVehicle(PlayerPedId(), vehicle, seatIndex) + if (!this.platform.doesEntityExist(vehicle)) return + this.platform.taskWarpPedIntoVehicle(this.platform.getLocalPlayerPed(), vehicle, seatIndex) } - /** - * Local-only: Sets vehicle heading. - * - * @param vehicle - Vehicle handle - * @param heading - Heading in degrees - */ setHeading(vehicle: number, heading: number): void { - if (!DoesEntityExist(vehicle)) return - SetEntityHeading(vehicle, heading) + if (!this.platform.doesEntityExist(vehicle)) return + this.platform.setEntityHeading(vehicle, heading) } - /** - * Local-only: Gets vehicle position. - * - * @param vehicle - Vehicle handle - * @returns Position vector - */ getPosition(vehicle: number): Vector3 | null { - if (!DoesEntityExist(vehicle)) return null - const coords = GetEntityCoords(vehicle, true) - return { x: coords[0], y: coords[1], z: coords[2] } + if (!this.platform.doesEntityExist(vehicle)) return null + return this.platform.getEntityCoords(vehicle) } - /** - * Local-only: Gets vehicle heading. - * - * @param vehicle - Vehicle handle - * @returns Heading in degrees - */ getHeading(vehicle: number): number { - if (!DoesEntityExist(vehicle)) return 0 - return GetEntityHeading(vehicle) + if (!this.platform.doesEntityExist(vehicle)) return 0 + return this.platform.getEntityHeading(vehicle) } - /** - * Local-only: Gets vehicle model hash. - * - * @param vehicle - Vehicle handle - * @returns Model hash - */ getModel(vehicle: number): number { - if (!DoesEntityExist(vehicle)) return 0 - return GetEntityModel(vehicle) + if (!this.platform.doesEntityExist(vehicle)) return 0 + return this.platform.getEntityModel(vehicle) } - /** - * Local-only: Gets vehicle license plate. - * - * @param vehicle - Vehicle handle - * @returns License plate text - */ getPlate(vehicle: number): string { - if (!DoesEntityExist(vehicle)) return '' - return GetVehicleNumberPlateText(vehicle) + if (!this.platform.doesEntityExist(vehicle)) return '' + return this.platform.getVehicleNumberPlateText(vehicle) } - /** - * Applies vehicle mods from state bag data. - * - * @param vehicle - Vehicle handle - * @param mods - Mods object from state bag - */ applyMods(vehicle: number, mods: Record): void { - if (!DoesEntityExist(vehicle)) return - - SetVehicleModKit(vehicle, 0) - - if (mods.spoiler !== undefined) SetVehicleMod(vehicle, 0, mods.spoiler, false) - if (mods.frontBumper !== undefined) SetVehicleMod(vehicle, 1, mods.frontBumper, false) - if (mods.rearBumper !== undefined) SetVehicleMod(vehicle, 2, mods.rearBumper, false) - if (mods.sideSkirt !== undefined) SetVehicleMod(vehicle, 3, mods.sideSkirt, false) - if (mods.exhaust !== undefined) SetVehicleMod(vehicle, 4, mods.exhaust, false) - if (mods.frame !== undefined) SetVehicleMod(vehicle, 5, mods.frame, false) - if (mods.grille !== undefined) SetVehicleMod(vehicle, 6, mods.grille, false) - if (mods.hood !== undefined) SetVehicleMod(vehicle, 7, mods.hood, false) - if (mods.fender !== undefined) SetVehicleMod(vehicle, 8, mods.fender, false) - if (mods.rightFender !== undefined) SetVehicleMod(vehicle, 9, mods.rightFender, false) - if (mods.roof !== undefined) SetVehicleMod(vehicle, 10, mods.roof, false) - if (mods.engine !== undefined) SetVehicleMod(vehicle, 11, mods.engine, false) - if (mods.brakes !== undefined) SetVehicleMod(vehicle, 12, mods.brakes, false) - if (mods.transmission !== undefined) SetVehicleMod(vehicle, 13, mods.transmission, false) - if (mods.horns !== undefined) SetVehicleMod(vehicle, 14, mods.horns, false) - if (mods.suspension !== undefined) SetVehicleMod(vehicle, 15, mods.suspension, false) - if (mods.armor !== undefined) SetVehicleMod(vehicle, 16, mods.armor, false) - - if (mods.turbo !== undefined) ToggleVehicleMod(vehicle, 18, mods.turbo) - if (mods.xenon !== undefined) ToggleVehicleMod(vehicle, 22, mods.xenon) - - if (mods.wheelType !== undefined) SetVehicleWheelType(vehicle, mods.wheelType) - if (mods.wheels !== undefined) SetVehicleMod(vehicle, 23, mods.wheels, false) - if (mods.windowTint !== undefined) SetVehicleWindowTint(vehicle, mods.windowTint) - if (mods.livery !== undefined) SetVehicleLivery(vehicle, mods.livery) - if (mods.plateStyle !== undefined) SetVehicleNumberPlateTextIndex(vehicle, mods.plateStyle) - + if (!this.platform.doesEntityExist(vehicle)) return + this.platform.setVehicleModKit(vehicle, 0) + if (mods.spoiler !== undefined) this.platform.setVehicleMod(vehicle, 0, mods.spoiler, false) + if (mods.frontBumper !== undefined) + this.platform.setVehicleMod(vehicle, 1, mods.frontBumper, false) + if (mods.rearBumper !== undefined) + this.platform.setVehicleMod(vehicle, 2, mods.rearBumper, false) + if (mods.sideSkirt !== undefined) this.platform.setVehicleMod(vehicle, 3, mods.sideSkirt, false) + if (mods.exhaust !== undefined) this.platform.setVehicleMod(vehicle, 4, mods.exhaust, false) + if (mods.frame !== undefined) this.platform.setVehicleMod(vehicle, 5, mods.frame, false) + if (mods.grille !== undefined) this.platform.setVehicleMod(vehicle, 6, mods.grille, false) + if (mods.hood !== undefined) this.platform.setVehicleMod(vehicle, 7, mods.hood, false) + if (mods.fender !== undefined) this.platform.setVehicleMod(vehicle, 8, mods.fender, false) + if (mods.rightFender !== undefined) + this.platform.setVehicleMod(vehicle, 9, mods.rightFender, false) + if (mods.roof !== undefined) this.platform.setVehicleMod(vehicle, 10, mods.roof, false) + if (mods.engine !== undefined) this.platform.setVehicleMod(vehicle, 11, mods.engine, false) + if (mods.brakes !== undefined) this.platform.setVehicleMod(vehicle, 12, mods.brakes, false) + if (mods.transmission !== undefined) + this.platform.setVehicleMod(vehicle, 13, mods.transmission, false) + if (mods.horns !== undefined) this.platform.setVehicleMod(vehicle, 14, mods.horns, false) + if (mods.suspension !== undefined) + this.platform.setVehicleMod(vehicle, 15, mods.suspension, false) + if (mods.armor !== undefined) this.platform.setVehicleMod(vehicle, 16, mods.armor, false) + if (mods.turbo !== undefined) this.platform.toggleVehicleMod(vehicle, 18, mods.turbo) + if (mods.xenon !== undefined) this.platform.toggleVehicleMod(vehicle, 22, mods.xenon) + if (mods.wheelType !== undefined) this.platform.setVehicleWheelType(vehicle, mods.wheelType) + if (mods.wheels !== undefined) this.platform.setVehicleMod(vehicle, 23, mods.wheels, false) + if (mods.windowTint !== undefined) this.platform.setVehicleWindowTint(vehicle, mods.windowTint) + if (mods.livery !== undefined) this.platform.setVehicleLivery(vehicle, mods.livery) + if (mods.plateStyle !== undefined) + this.platform.setVehicleNumberPlateTextIndex(vehicle, mods.plateStyle) if (mods.neonEnabled !== undefined) { - SetVehicleNeonLightEnabled(vehicle, 0, mods.neonEnabled[0]) - SetVehicleNeonLightEnabled(vehicle, 1, mods.neonEnabled[1]) - SetVehicleNeonLightEnabled(vehicle, 2, mods.neonEnabled[2]) - SetVehicleNeonLightEnabled(vehicle, 3, mods.neonEnabled[3]) + this.platform.setVehicleNeonLightEnabled(vehicle, 0, mods.neonEnabled[0]) + this.platform.setVehicleNeonLightEnabled(vehicle, 1, mods.neonEnabled[1]) + this.platform.setVehicleNeonLightEnabled(vehicle, 2, mods.neonEnabled[2]) + this.platform.setVehicleNeonLightEnabled(vehicle, 3, mods.neonEnabled[3]) } - if (mods.neonColor !== undefined) { - SetVehicleNeonLightsColour(vehicle, mods.neonColor[0], mods.neonColor[1], mods.neonColor[2]) + this.platform.setVehicleNeonLightsColour( + vehicle, + mods.neonColor[0], + mods.neonColor[1], + mods.neonColor[2], + ) } - if (mods.extras) { for (const [extraId, enabled] of Object.entries(mods.extras)) { - SetVehicleExtra(vehicle, Number(extraId), !enabled) + this.platform.setVehicleExtra(vehicle, Number(extraId), !enabled) } } - if (mods.pearlescentColor !== undefined || mods.wheelColor !== undefined) { - const [currentPearl, currentWheel] = GetVehicleExtraColours(vehicle) - SetVehicleExtraColours( + const [currentPearl, currentWheel] = this.platform.getVehicleExtraColours(vehicle) + this.platform.setVehicleExtraColours( vehicle, mods.pearlescentColor ?? currentPearl, mods.wheelColor ?? currentWheel, @@ -395,111 +230,102 @@ export class VehicleClientService { } } - /** - * Repairs a vehicle completely (client-side). - * - * @param vehicle - Vehicle handle - */ repair(vehicle: number): void { - if (!DoesEntityExist(vehicle)) return - - SetVehicleFixed(vehicle) - SetVehicleDeformationFixed(vehicle) - SetVehicleUndriveable(vehicle, false) - SetVehicleEngineOn(vehicle, true, true, false) - SetVehicleEngineHealth(vehicle, 1000.0) - SetVehiclePetrolTankHealth(vehicle, 1000.0) + if (!this.platform.doesEntityExist(vehicle)) return + this.platform.setVehicleFixed(vehicle) + this.platform.setVehicleDeformationFixed(vehicle) + this.platform.setVehicleUndriveable(vehicle, false) + this.platform.setVehicleEngineOn(vehicle, true, true, false) + this.platform.setVehicleEngineHealth(vehicle, 1000.0) + this.platform.setVehiclePetrolTankHealth(vehicle, 1000.0) } - /** - * Sets fuel level on a vehicle (client-side). - * - * @param vehicle - Vehicle handle - * @param level - Fuel level (0-100) - */ setFuel(vehicle: number, level: number): void { - if (!DoesEntityExist(vehicle)) return - SetVehicleFuelLevel(vehicle, Math.max(0, Math.min(100, level))) + if (!this.platform.doesEntityExist(vehicle)) return + this.platform.setVehicleFuelLevel(vehicle, Math.max(0, Math.min(100, level))) } - /** - * Registers event handlers for server responses. - */ private registerEventHandlers(): void { - onNet( + this.events.on( 'opencore:vehicle:createResult', - (result: VehicleSpawnResult & { _requestId?: number }) => { - if (result._requestId !== undefined) { - const callback = this.pendingCreations.get(result._requestId) - if (callback) { - callback(result) - this.pendingCreations.delete(result._requestId) - } - } + (_ctx, result: VehicleSpawnResult & { _requestId?: number }) => { + if (result._requestId === undefined) return + const callback = this.pendingCreations.get(result._requestId) + if (!callback) return + callback(result) + this.pendingCreations.delete(result._requestId) }, ) - onNet('opencore:vehicle:created', async (data: SerializedVehicleData) => { - // Wait for vehicle to exist locally - const started = GetGameTimer() - let veh = 0 - - while (GetGameTimer() - started < 5000) { - veh = this.getVehicleFromNetworkId(data.networkId) - if (veh && DoesEntityExist(veh)) break - await new Promise((r) => setTimeout(r, 0)) - } + this.events.on( + 'opencore:vehicle:deleteResult', + (_ctx, result: { networkId: number; success: boolean }) => { + const callback = this.pendingDeletes.get(result.networkId) + if (!callback) return + callback(result.success) + this.pendingDeletes.delete(result.networkId) + }, + ) - if (veh && DoesEntityExist(veh)) { - // Apply mods from server data - if (data.mods && Object.keys(data.mods).length > 0) { - this.applyMods(veh, data.mods) - } + this.events.on( + 'opencore:vehicle:repairResult', + (_ctx, result: { networkId: number; success: boolean }) => { + const callback = this.pendingRepairs.get(result.networkId) + if (!callback) return + callback(result.success) + this.pendingRepairs.delete(result.networkId) + }, + ) - // Apply fuel from metadata - if (data.metadata?.fuel !== undefined) { - this.setFuel(veh, data.metadata.fuel) - } - } + this.events.on('opencore:vehicle:dataResult', (_ctx, data: SerializedVehicleData | null) => { + if (!data) return + const callback = this.pendingData.get(data.networkId) + if (!callback) return + callback(data) + this.pendingData.delete(data.networkId) }) - onNet('opencore:vehicle:deleted', (networkId: number) => { - console.log('[VehicleClient] Vehicle deleted:', networkId) + this.events.on( + 'opencore:vehicle:playerVehiclesResult', + (_ctx, vehicles: SerializedVehicleData[]) => { + this.pendingPlayerVehicles?.(vehicles) + this.pendingPlayerVehicles = null + }, + ) + + this.events.on('opencore:vehicle:created', async (_ctx, data: SerializedVehicleData) => { + const veh = await this.waitForVehicle(data.networkId) + if (!veh) return + if (data.mods && Object.keys(data.mods).length > 0) this.applyMods(veh, data.mods) + if (data.metadata?.fuel !== undefined) this.setFuel(veh, data.metadata.fuel) }) - onNet('opencore:vehicle:modified', (data: { networkId: number; mods: any }) => { + this.events.on('opencore:vehicle:modified', (_ctx, data: { networkId: number; mods: any }) => { const veh = this.getVehicleFromNetworkId(data.networkId) - if (veh && DoesEntityExist(veh)) { - this.applyMods(veh, data.mods) - } + if (veh && this.platform.doesEntityExist(veh)) this.applyMods(veh, data.mods) }) - onNet('opencore:vehicle:repaired', (networkId: number) => { + this.events.on('opencore:vehicle:repaired', (_ctx, networkId: number) => { const veh = this.getVehicleFromNetworkId(networkId) - if (veh && DoesEntityExist(veh)) { - this.repair(veh) - } - }) - - onNet('opencore:vehicle:lockedChanged', (data: { networkId: number; locked: boolean }) => { - console.log('[VehicleClient] Vehicle lock changed:', data.networkId, data.locked) + if (veh && this.platform.doesEntityExist(veh)) this.repair(veh) }) - onNet('opencore:vehicle:warpInto', async (networkId: number, seatIndex: number = -1) => { - const started = GetGameTimer() - let veh = 0 - - while (GetGameTimer() - started < 5000) { - veh = this.getVehicleFromNetworkId(networkId) - if (veh && DoesEntityExist(veh)) break - await new Promise((r) => setTimeout(r, 0)) - } + this.events.on( + 'opencore:vehicle:warpInto', + async (_ctx, networkId: number, seatIndex: number = -1) => { + const veh = await this.waitForVehicle(networkId) + if (veh) this.warpIntoVehicle(veh, seatIndex) + }, + ) + } - if (veh && DoesEntityExist(veh)) { - this.warpIntoVehicle(veh, seatIndex) - } else { - console.error('[VehicleClient] Failed to warp into vehicle:', networkId) - } - }) + private async waitForVehicle(networkId: number): Promise { + const started = this.runtime.getGameTimer() + while (this.runtime.getGameTimer() - started < 5000) { + const veh = this.getVehicleFromNetworkId(networkId) + if (veh && this.platform.doesEntityExist(veh)) return veh + await new Promise((r) => setTimeout(r, 0)) + } + return null } } diff --git a/src/runtime/client/services/vehicle.service.ts b/src/runtime/client/services/vehicle.service.ts index c85a004..1e64de0 100644 --- a/src/runtime/client/services/vehicle.service.ts +++ b/src/runtime/client/services/vehicle.service.ts @@ -1,26 +1,17 @@ -import { injectable } from 'tsyringe' +import { inject, injectable } from 'tsyringe' import { Vector3 } from '../../../kernel/utils/vector3' +import { IClientPlatformBridge } from '../adapter/platform-bridge' export interface VehicleSpawnOptions { - /** Model name or hash */ model: string - /** Spawn position */ position: Vector3 - /** Heading/rotation */ heading?: number - /** Whether to place on ground */ placeOnGround?: boolean - /** Whether to warp the player into the vehicle */ warpIntoVehicle?: boolean - /** Seat index to warp into (-1 = driver) */ seatIndex?: number - /** Primary color */ primaryColor?: number - /** Secondary color */ secondaryColor?: number - /** License plate text */ plate?: string - /** Network the vehicle */ networked?: boolean } @@ -51,17 +42,12 @@ export interface VehicleMods { plateStyle?: number } -/** - * Service for vehicle operations and management. - */ @injectable() export class VehicleService { - /** - * Spawn a vehicle at a position. - * - * @param options - Spawn options - * @returns The vehicle handle - */ + constructor( + @inject(IClientPlatformBridge as any) private readonly platform: IClientPlatformBridge, + ) {} + async spawn(options: VehicleSpawnOptions): Promise { const { model, @@ -76,279 +62,159 @@ export class VehicleService { networked = true, } = options - const modelHash = GetHashKey(model) - - // Load the model - if (!IsModelInCdimage(modelHash) || !IsModelAVehicle(modelHash)) { + const modelHash = this.platform.getHashKey(model) + if (!this.platform.isModelInCdimage(modelHash) || !this.platform.isModelAVehicle(modelHash)) { throw new Error(`Invalid vehicle model: ${model}`) } - RequestModel(modelHash) - while (!HasModelLoaded(modelHash)) { + this.platform.requestModel(modelHash) + while (!this.platform.hasModelLoaded(modelHash)) { await new Promise((r) => setTimeout(r, 0)) } - // Create the vehicle - const vehicle = CreateVehicle( - modelHash, - position.x, - position.y, - position.z, - heading, - networked, - false, - ) - - SetModelAsNoLongerNeeded(modelHash) - - if (!vehicle || vehicle === 0) { - throw new Error('Failed to create vehicle') - } - - // Place on ground - if (placeOnGround) { - SetVehicleOnGroundProperly(vehicle) - } + const vehicle = this.platform.createVehicle(modelHash, position, heading, networked, false) + this.platform.setModelAsNoLongerNeeded(modelHash) + if (!vehicle || vehicle === 0) throw new Error('Failed to create vehicle') - // Set colors + if (placeOnGround) this.platform.setVehicleOnGroundProperly(vehicle) if (primaryColor !== undefined || secondaryColor !== undefined) { - const [currentPrimary, currentSecondary] = GetVehicleColours(vehicle) - SetVehicleColours(vehicle, primaryColor ?? currentPrimary, secondaryColor ?? currentSecondary) - } - - // Set plate - if (plate) { - SetVehicleNumberPlateText(vehicle, plate) - } - - // Warp player into vehicle - if (warpIntoVehicle) { - TaskWarpPedIntoVehicle(PlayerPedId(), vehicle, seatIndex) + const [currentPrimary, currentSecondary] = this.platform.getVehicleColours(vehicle) + this.platform.setVehicleColours( + vehicle, + primaryColor ?? currentPrimary, + secondaryColor ?? currentSecondary, + ) } - + if (plate) this.platform.setVehicleNumberPlateText(vehicle, plate) + if (warpIntoVehicle) + this.platform.taskWarpPedIntoVehicle(this.platform.getLocalPlayerPed(), vehicle, seatIndex) return vehicle } - /** - * Delete a vehicle. - * - * @param vehicle - Vehicle handle - */ delete(vehicle: number): void { - if (DoesEntityExist(vehicle)) { - SetEntityAsMissionEntity(vehicle, true, true) - DeleteVehicle(vehicle) + if (this.platform.doesEntityExist(vehicle)) { + this.platform.setEntityAsMissionEntity(vehicle, true, true) + this.platform.deleteVehicle(vehicle) } } - /** - * Delete the vehicle the player is currently in. - */ deleteCurrentVehicle(): void { const vehicle = this.getCurrentVehicle() if (vehicle) { - TaskLeaveVehicle(PlayerPedId(), vehicle, 16) + this.platform.taskLeaveVehicle(this.platform.getLocalPlayerPed(), vehicle, 16) setTimeout(() => this.delete(vehicle), 1000) } } - /** - * Repair a vehicle completely. - * - * @param vehicle - Vehicle handle - */ repair(vehicle: number): void { - if (!DoesEntityExist(vehicle)) return - - SetVehicleFixed(vehicle) - SetVehicleDeformationFixed(vehicle) - SetVehicleUndriveable(vehicle, false) - SetVehicleEngineOn(vehicle, true, true, false) - SetVehicleEngineHealth(vehicle, 1000.0) - SetVehiclePetrolTankHealth(vehicle, 1000.0) + if (!this.platform.doesEntityExist(vehicle)) return + this.platform.setVehicleFixed(vehicle) + this.platform.setVehicleDeformationFixed(vehicle) + this.platform.setVehicleUndriveable(vehicle, false) + this.platform.setVehicleEngineOn(vehicle, true, true, false) + this.platform.setVehicleEngineHealth(vehicle, 1000.0) + this.platform.setVehiclePetrolTankHealth(vehicle, 1000.0) } - /** - * Set vehicle fuel level. - * - * @param vehicle - Vehicle handle - * @param level - Fuel level (0.0-100.0) - */ setFuel(vehicle: number, level: number): void { - if (!DoesEntityExist(vehicle)) return - SetVehicleFuelLevel(vehicle, Math.max(0, Math.min(100, level * 100))) + if (!this.platform.doesEntityExist(vehicle)) return + this.platform.setVehicleFuelLevel(vehicle, Math.max(0, Math.min(100, level * 100))) } - /** - * Get vehicle fuel level. - * - * @param vehicle - Vehicle handle - * @returns Fuel level (0.0-1.0) - */ getFuel(vehicle: number): number { - if (!DoesEntityExist(vehicle)) return 0 - return GetVehicleFuelLevel(vehicle) / 100 + if (!this.platform.doesEntityExist(vehicle)) return 0 + return this.platform.getVehicleFuelLevel(vehicle) / 100 } - /** - * Get the closest vehicle to the player. - * - * @param radius - Search radius - * @returns Vehicle handle or null - */ getClosest(radius = 10.0): number | null { - const playerPed = PlayerPedId() - const [px, py, pz] = GetEntityCoords(playerPed, true) - - const vehicle = GetClosestVehicle(px, py, pz, radius, 0, 71) - return vehicle !== 0 ? vehicle : null + const playerPed = this.platform.getLocalPlayerPed() + return this.platform.getClosestVehicle(this.platform.getEntityCoords(playerPed), radius) } - /** - * Check if the player is in a vehicle. - */ isPlayerInVehicle(): boolean { - return IsPedInAnyVehicle(PlayerPedId(), false) + return this.platform.isPedInAnyVehicle(this.platform.getLocalPlayerPed()) } - /** - * Get the vehicle the player is currently in. - * - * @returns Vehicle handle or null - */ getCurrentVehicle(): number | null { - const ped = PlayerPedId() - if (!IsPedInAnyVehicle(ped, false)) return null - return GetVehiclePedIsIn(ped, false) + const ped = this.platform.getLocalPlayerPed() + if (!this.platform.isPedInAnyVehicle(ped)) return null + return this.platform.getVehiclePedIsIn(ped, false) } - /** - * Get the last vehicle the player was in. - * - * @returns Vehicle handle or null - */ getLastVehicle(): number | null { - const vehicle = GetVehiclePedIsIn(PlayerPedId(), true) - return vehicle !== 0 ? vehicle : null + return this.platform.getVehiclePedIsIn(this.platform.getLocalPlayerPed(), true) } - /** - * Check if player is the driver of their current vehicle. - */ isPlayerDriver(): boolean { const vehicle = this.getCurrentVehicle() if (!vehicle) return false - return GetPedInVehicleSeat(vehicle, -1) === PlayerPedId() + return this.platform.getPedInVehicleSeat(vehicle, -1) === this.platform.getLocalPlayerPed() } - /** - * Apply modifications to a vehicle. - * - * @param vehicle - Vehicle handle - * @param mods - Modifications to apply - */ setMods(vehicle: number, mods: VehicleMods): void { - if (!DoesEntityExist(vehicle)) return - - SetVehicleModKit(vehicle, 0) - - if (mods.spoiler !== undefined) SetVehicleMod(vehicle, 0, mods.spoiler, false) - if (mods.frontBumper !== undefined) SetVehicleMod(vehicle, 1, mods.frontBumper, false) - if (mods.rearBumper !== undefined) SetVehicleMod(vehicle, 2, mods.rearBumper, false) - if (mods.sideSkirt !== undefined) SetVehicleMod(vehicle, 3, mods.sideSkirt, false) - if (mods.exhaust !== undefined) SetVehicleMod(vehicle, 4, mods.exhaust, false) - if (mods.frame !== undefined) SetVehicleMod(vehicle, 5, mods.frame, false) - if (mods.grille !== undefined) SetVehicleMod(vehicle, 6, mods.grille, false) - if (mods.hood !== undefined) SetVehicleMod(vehicle, 7, mods.hood, false) - if (mods.fender !== undefined) SetVehicleMod(vehicle, 8, mods.fender, false) - if (mods.rightFender !== undefined) SetVehicleMod(vehicle, 9, mods.rightFender, false) - if (mods.roof !== undefined) SetVehicleMod(vehicle, 10, mods.roof, false) - if (mods.engine !== undefined) SetVehicleMod(vehicle, 11, mods.engine, false) - if (mods.brakes !== undefined) SetVehicleMod(vehicle, 12, mods.brakes, false) - if (mods.transmission !== undefined) SetVehicleMod(vehicle, 13, mods.transmission, false) - if (mods.horns !== undefined) SetVehicleMod(vehicle, 14, mods.horns, false) - if (mods.suspension !== undefined) SetVehicleMod(vehicle, 15, mods.suspension, false) - if (mods.armor !== undefined) SetVehicleMod(vehicle, 16, mods.armor, false) - - if (mods.turbo !== undefined) ToggleVehicleMod(vehicle, 18, mods.turbo) - if (mods.xenon !== undefined) ToggleVehicleMod(vehicle, 22, mods.xenon) - - if (mods.wheelType !== undefined) SetVehicleWheelType(vehicle, mods.wheelType) - if (mods.wheels !== undefined) SetVehicleMod(vehicle, 23, mods.wheels, false) - if (mods.windowTint !== undefined) SetVehicleWindowTint(vehicle, mods.windowTint) - if (mods.livery !== undefined) SetVehicleLivery(vehicle, mods.livery) - if (mods.plateStyle !== undefined) SetVehicleNumberPlateTextIndex(vehicle, mods.plateStyle) + if (!this.platform.doesEntityExist(vehicle)) return + this.platform.setVehicleModKit(vehicle, 0) + if (mods.spoiler !== undefined) this.platform.setVehicleMod(vehicle, 0, mods.spoiler, false) + if (mods.frontBumper !== undefined) + this.platform.setVehicleMod(vehicle, 1, mods.frontBumper, false) + if (mods.rearBumper !== undefined) + this.platform.setVehicleMod(vehicle, 2, mods.rearBumper, false) + if (mods.sideSkirt !== undefined) this.platform.setVehicleMod(vehicle, 3, mods.sideSkirt, false) + if (mods.exhaust !== undefined) this.platform.setVehicleMod(vehicle, 4, mods.exhaust, false) + if (mods.frame !== undefined) this.platform.setVehicleMod(vehicle, 5, mods.frame, false) + if (mods.grille !== undefined) this.platform.setVehicleMod(vehicle, 6, mods.grille, false) + if (mods.hood !== undefined) this.platform.setVehicleMod(vehicle, 7, mods.hood, false) + if (mods.fender !== undefined) this.platform.setVehicleMod(vehicle, 8, mods.fender, false) + if (mods.rightFender !== undefined) + this.platform.setVehicleMod(vehicle, 9, mods.rightFender, false) + if (mods.roof !== undefined) this.platform.setVehicleMod(vehicle, 10, mods.roof, false) + if (mods.engine !== undefined) this.platform.setVehicleMod(vehicle, 11, mods.engine, false) + if (mods.brakes !== undefined) this.platform.setVehicleMod(vehicle, 12, mods.brakes, false) + if (mods.transmission !== undefined) + this.platform.setVehicleMod(vehicle, 13, mods.transmission, false) + if (mods.horns !== undefined) this.platform.setVehicleMod(vehicle, 14, mods.horns, false) + if (mods.suspension !== undefined) + this.platform.setVehicleMod(vehicle, 15, mods.suspension, false) + if (mods.armor !== undefined) this.platform.setVehicleMod(vehicle, 16, mods.armor, false) + if (mods.turbo !== undefined) this.platform.toggleVehicleMod(vehicle, 18, mods.turbo) + if (mods.xenon !== undefined) this.platform.toggleVehicleMod(vehicle, 22, mods.xenon) + if (mods.wheelType !== undefined) this.platform.setVehicleWheelType(vehicle, mods.wheelType) + if (mods.wheels !== undefined) this.platform.setVehicleMod(vehicle, 23, mods.wheels, false) + if (mods.windowTint !== undefined) this.platform.setVehicleWindowTint(vehicle, mods.windowTint) + if (mods.livery !== undefined) this.platform.setVehicleLivery(vehicle, mods.livery) + if (mods.plateStyle !== undefined) + this.platform.setVehicleNumberPlateTextIndex(vehicle, mods.plateStyle) } - /** - * Set vehicle doors locked state. - * - * @param vehicle - Vehicle handle - * @param locked - Whether doors should be locked - */ setDoorsLocked(vehicle: number, locked: boolean): void { - if (!DoesEntityExist(vehicle)) return - SetVehicleDoorsLocked(vehicle, locked ? 2 : 0) + if (!this.platform.doesEntityExist(vehicle)) return + this.platform.setVehicleDoorsLocked(vehicle, locked ? 2 : 0) } - /** - * Set vehicle engine state. - * - * @param vehicle - Vehicle handle - * @param running - Whether engine should be running - * @param instant - Whether to start instantly - */ setEngineRunning(vehicle: number, running: boolean, instant = false): void { - if (!DoesEntityExist(vehicle)) return - SetVehicleEngineOn(vehicle, running, instant, true) + if (!this.platform.doesEntityExist(vehicle)) return + this.platform.setVehicleEngineOn(vehicle, running, instant, true) } - /** - * Set vehicle invincibility. - * - * @param vehicle - Vehicle handle - * @param invincible - Whether vehicle should be invincible - */ setInvincible(vehicle: number, invincible: boolean): void { - if (!DoesEntityExist(vehicle)) return - SetEntityInvincible(vehicle, invincible) + if (!this.platform.doesEntityExist(vehicle)) return + this.platform.setEntityInvincible(vehicle, invincible) } - /** - * Get vehicle speed in km/h. - * - * @param vehicle - Vehicle handle - */ getSpeed(vehicle: number): number { - if (!DoesEntityExist(vehicle)) return 0 - return GetEntitySpeed(vehicle) * 3.6 // Convert m/s to km/h + if (!this.platform.doesEntityExist(vehicle)) return 0 + return this.platform.getEntitySpeed(vehicle) * 3.6 } - /** - * Set vehicle heading/rotation. - * - * @param vehicle - Vehicle handle - * @param heading - Heading in degrees - */ setHeading(vehicle: number, heading: number): void { - if (!DoesEntityExist(vehicle)) return - SetEntityHeading(vehicle, heading) + if (!this.platform.doesEntityExist(vehicle)) return + this.platform.setEntityHeading(vehicle, heading) } - /** - * Teleport a vehicle to a position. - * - * @param vehicle - Vehicle handle - * @param position - Target position - * @param heading - Optional heading - */ teleport(vehicle: number, position: Vector3, heading?: number): void { - if (!DoesEntityExist(vehicle)) return - - SetEntityCoords(vehicle, position.x, position.y, position.z, false, false, false, true) - if (heading !== undefined) { - SetEntityHeading(vehicle, heading) - } - SetVehicleOnGroundProperly(vehicle) + if (!this.platform.doesEntityExist(vehicle)) return + this.platform.setEntityCoords(vehicle, position) + if (heading !== undefined) this.platform.setEntityHeading(vehicle, heading) + this.platform.setVehicleOnGroundProperly(vehicle) } } diff --git a/src/runtime/client/system/processors.register.ts b/src/runtime/client/system/processors.register.ts index 8e9b424..ca24262 100644 --- a/src/runtime/client/system/processors.register.ts +++ b/src/runtime/client/system/processors.register.ts @@ -14,7 +14,13 @@ import { } from './processors/resourceLifecycle.processor' import { TickProcessor } from './processors/tick.processor' +let processorsRegistered = false + export function registerSystemClient() { + if (processorsRegistered) { + return + } + // Core processors di.register('DecoratorProcessor', { useClass: KeyMappingProcessor }) di.register('DecoratorProcessor', { useClass: TickProcessor }) @@ -30,4 +36,10 @@ export function registerSystemClient() { di.register('DecoratorProcessor', { useClass: ResourceStartProcessor }) di.register('DecoratorProcessor', { useClass: ResourceStopProcessor }) di.register('DecoratorProcessor', { useClass: GameEventProcessor }) + + processorsRegistered = true +} + +export function __resetClientProcessorRegistrationForTests(): void { + processorsRegistered = false } diff --git a/src/runtime/client/system/processors/export.processor.ts b/src/runtime/client/system/processors/export.processor.ts index 973f4f2..8d98894 100644 --- a/src/runtime/client/system/processors/export.processor.ts +++ b/src/runtime/client/system/processors/export.processor.ts @@ -1,6 +1,7 @@ -import { injectable } from 'tsyringe' +import { inject, injectable } from 'tsyringe' import { DecoratorProcessor } from '../../../../kernel/di/decorator-processor' import { coreLogger, LogDomain } from '../../../../kernel/logger' +import { IClientRuntimeBridge } from '../../adapter/runtime-bridge' import { METADATA_KEYS } from '../metadata-client.keys' const clientExport = coreLogger.child('Export', LogDomain.CLIENT) @@ -9,11 +10,15 @@ const clientExport = coreLogger.child('Export', LogDomain.CLIENT) export class ClientExportProcessor implements DecoratorProcessor { readonly metadataKey = METADATA_KEYS.EXPORT + constructor( + @inject(IClientRuntimeBridge as any) private readonly runtime: IClientRuntimeBridge, + ) {} + process(target: any, methodName: string, metadata: { exportName: string }) { const handler = target[methodName].bind(target) const handlerName = `${target.constructor.name}.${methodName}` - ;(globalThis as any).exports(metadata.exportName, async (...args: any[]) => { + this.runtime.registerExport(metadata.exportName, async (...args: any[]) => { try { return await handler(...args) } catch (error) { diff --git a/src/runtime/client/system/processors/gameEvent.processor.ts b/src/runtime/client/system/processors/gameEvent.processor.ts index 457a811..b2718eb 100644 --- a/src/runtime/client/system/processors/gameEvent.processor.ts +++ b/src/runtime/client/system/processors/gameEvent.processor.ts @@ -1,6 +1,7 @@ -import { injectable } from 'tsyringe' +import { inject, injectable } from 'tsyringe' import { DecoratorProcessor } from '../../../../kernel/di/decorator-processor' import { coreLogger, LogDomain } from '../../../../kernel/logger' +import { IClientRuntimeBridge } from '../../adapter/runtime-bridge' import { GameEventParsers } from '../../types/game-events' import { METADATA_KEYS } from '../metadata-client.keys' @@ -25,12 +26,16 @@ interface GameEventMetadata { export class GameEventProcessor implements DecoratorProcessor { readonly metadataKey = METADATA_KEYS.GAME_EVENT + constructor( + @inject(IClientRuntimeBridge as any) private readonly runtime: IClientRuntimeBridge, + ) {} + process(target: any, methodName: string, metadata: GameEventMetadata) { const handler = target[methodName].bind(target) const handlerName = `${target.constructor.name}.${methodName}` const { eventName, autoParse } = metadata - on('gameEventTriggered', async (name: string, args: number[]) => { + this.runtime.on('gameEventTriggered', async (name: string, args: number[]) => { if (name !== eventName) return try { diff --git a/src/runtime/client/system/processors/interval.processor.ts b/src/runtime/client/system/processors/interval.processor.ts index 865cc6b..f1cd620 100644 --- a/src/runtime/client/system/processors/interval.processor.ts +++ b/src/runtime/client/system/processors/interval.processor.ts @@ -1,6 +1,7 @@ -import { injectable } from 'tsyringe' +import { inject, injectable } from 'tsyringe' import { DecoratorProcessor } from '../../../../kernel/di/decorator-processor' import { coreLogger, LogDomain } from '../../../../kernel/logger' +import { IClientRuntimeBridge } from '../../adapter/runtime-bridge' import { METADATA_KEYS } from '../metadata-client.keys' const clientInterval = coreLogger.child('Interval', LogDomain.CLIENT) @@ -9,14 +10,18 @@ const clientInterval = coreLogger.child('Interval', LogDomain.CLIENT) export class IntervalProcessor implements DecoratorProcessor { readonly metadataKey = METADATA_KEYS.INTERVAL + constructor( + @inject(IClientRuntimeBridge as any) private readonly runtime: IClientRuntimeBridge, + ) {} + process(target: any, methodName: string, metadata: { interval: number }) { const handler = target[methodName].bind(target) const handlerName = `${target.constructor.name}.${methodName}` let lastRun = 0 - setTick(async () => { - const now = GetGameTimer() + this.runtime.setTick(async () => { + const now = this.runtime.getGameTimer() if (now - lastRun < metadata.interval) return lastRun = now diff --git a/src/runtime/client/system/processors/key.processor.ts b/src/runtime/client/system/processors/key.processor.ts index 1005319..b3d07e7 100644 --- a/src/runtime/client/system/processors/key.processor.ts +++ b/src/runtime/client/system/processors/key.processor.ts @@ -1,18 +1,28 @@ -import { injectable } from 'tsyringe' +import { inject, injectable } from 'tsyringe' import { DecoratorProcessor } from '../../../../kernel/di/decorator-processor' +import { IClientRuntimeBridge } from '../../adapter/runtime-bridge' import { METADATA_KEYS } from '../metadata-client.keys' @injectable() export class KeyMappingProcessor implements DecoratorProcessor { readonly metadataKey = METADATA_KEYS.KEY + constructor( + @inject(IClientRuntimeBridge as any) private readonly runtime: IClientRuntimeBridge, + ) {} + process(target: any, methodName: string, metadata: { key: string; description?: string }) { const handler = target[methodName].bind(target) const commandName = `+${methodName}` - RegisterCommand(commandName, handler, false) - RegisterKeyMapping(commandName, metadata.description ?? 'none', 'keyboard', metadata.key) + this.runtime.registerCommand(commandName, handler, false) + this.runtime.registerKeyMapping( + commandName, + metadata.description ?? 'none', + 'keyboard', + metadata.key, + ) - RegisterCommand(`-${methodName}`, () => {}, false) + this.runtime.registerCommand(`-${methodName}`, () => {}, false) } } diff --git a/src/runtime/client/system/processors/localEvent.processor.ts b/src/runtime/client/system/processors/localEvent.processor.ts index 46229fa..a8b929b 100644 --- a/src/runtime/client/system/processors/localEvent.processor.ts +++ b/src/runtime/client/system/processors/localEvent.processor.ts @@ -1,6 +1,7 @@ -import { injectable } from 'tsyringe' +import { inject, injectable } from 'tsyringe' import { DecoratorProcessor } from '../../../../kernel/di/decorator-processor' import { coreLogger, LogDomain } from '../../../../kernel/logger' +import { IClientRuntimeBridge } from '../../adapter/runtime-bridge' import { METADATA_KEYS } from '../metadata-client.keys' const clientLocalEvent = coreLogger.child('LocalEvent', LogDomain.CLIENT) @@ -9,11 +10,15 @@ const clientLocalEvent = coreLogger.child('LocalEvent', LogDomain.CLIENT) export class LocalEventProcessor implements DecoratorProcessor { readonly metadataKey = METADATA_KEYS.LOCAL_EVENT + constructor( + @inject(IClientRuntimeBridge as any) private readonly runtime: IClientRuntimeBridge, + ) {} + process(target: any, methodName: string, metadata: { eventName: string }) { const handler = target[methodName].bind(target) const handlerName = `${target.constructor.name}.${methodName}` - on(metadata.eventName, async (...args: any[]) => { + this.runtime.on(metadata.eventName, async (...args: any[]) => { try { await handler(...args) } catch (error) { diff --git a/src/runtime/client/system/processors/netEvent.processor.ts b/src/runtime/client/system/processors/netEvent.processor.ts index cbf1ffc..ca3ca37 100644 --- a/src/runtime/client/system/processors/netEvent.processor.ts +++ b/src/runtime/client/system/processors/netEvent.processor.ts @@ -1,5 +1,6 @@ -import { injectable } from 'tsyringe' +import { inject, injectable } from 'tsyringe' import { DecoratorProcessor } from '../../../../kernel/di/decorator-processor' +import { EventsAPI } from '../../../../adapters/contracts/transport/events.api' import { coreLogger, LogDomain } from '../../../../kernel/logger' import { METADATA_KEYS } from '../metadata-client.keys' @@ -9,11 +10,13 @@ const clientNetEvent = coreLogger.child('NetEvent', LogDomain.CLIENT) export class ClientNetEventProcessor implements DecoratorProcessor { readonly metadataKey = METADATA_KEYS.NET_EVENT + constructor(@inject(EventsAPI as any) private readonly events: EventsAPI<'client'>) {} + process(target: any, methodName: string, metadata: { eventName: string }) { const handler = target[methodName].bind(target) const handlerName = `${target.constructor.name}.${methodName}` - onNet(metadata.eventName, async (...args: any[]) => { + this.events.on(metadata.eventName, async (_ctx, ...args: any[]) => { try { await handler(...args) } catch (error) { diff --git a/src/runtime/client/system/processors/onRpc.processor.ts b/src/runtime/client/system/processors/onRpc.processor.ts index 35f32fd..08864f3 100644 --- a/src/runtime/client/system/processors/onRpc.processor.ts +++ b/src/runtime/client/system/processors/onRpc.processor.ts @@ -3,7 +3,7 @@ import z from 'zod' import { RpcAPI } from '../../../../adapters/contracts/transport/rpc.api' import { type DecoratorProcessor } from '../../../../kernel/di/decorator-processor' import { coreLogger, LogDomain } from '../../../../kernel/logger' -import { processTupleSchema } from '../../../server/helpers/process-tuple-schema' +import { processTupleSchema } from '../../../shared/helpers/process-tuple-schema' import type { ClientRpcHandlerOptions } from '../../decorators/onRPC' import { METADATA_KEYS } from '../metadata-client.keys' diff --git a/src/runtime/client/system/processors/resourceLifecycle.processor.ts b/src/runtime/client/system/processors/resourceLifecycle.processor.ts index b9ce02e..c71aab0 100644 --- a/src/runtime/client/system/processors/resourceLifecycle.processor.ts +++ b/src/runtime/client/system/processors/resourceLifecycle.processor.ts @@ -1,6 +1,7 @@ -import { injectable } from 'tsyringe' +import { inject, injectable } from 'tsyringe' import { DecoratorProcessor } from '../../../../kernel/di/decorator-processor' import { coreLogger, LogDomain } from '../../../../kernel/logger' +import { IClientRuntimeBridge } from '../../adapter/runtime-bridge' import { METADATA_KEYS } from '../metadata-client.keys' const clientLifecycle = coreLogger.child('Lifecycle', LogDomain.CLIENT) @@ -9,12 +10,16 @@ const clientLifecycle = coreLogger.child('Lifecycle', LogDomain.CLIENT) export class ResourceStartProcessor implements DecoratorProcessor { readonly metadataKey = METADATA_KEYS.RESOURCE_START + constructor( + @inject(IClientRuntimeBridge as any) private readonly runtime: IClientRuntimeBridge, + ) {} + process(target: any, methodName: string) { const handler = target[methodName].bind(target) const handlerName = `${target.constructor.name}.${methodName}` - const currentResource = GetCurrentResourceName() + const currentResource = this.runtime.getCurrentResourceName() - on('onClientResourceStart', async (resourceName: string) => { + this.runtime.on('onClientResourceStart', async (resourceName: string) => { if (resourceName !== currentResource) return try { @@ -39,12 +44,16 @@ export class ResourceStartProcessor implements DecoratorProcessor { export class ResourceStopProcessor implements DecoratorProcessor { readonly metadataKey = METADATA_KEYS.RESOURCE_STOP + constructor( + @inject(IClientRuntimeBridge as any) private readonly runtime: IClientRuntimeBridge, + ) {} + process(target: any, methodName: string) { const handler = target[methodName].bind(target) const handlerName = `${target.constructor.name}.${methodName}` - const currentResource = GetCurrentResourceName() + const currentResource = this.runtime.getCurrentResourceName() - on('onClientResourceStop', async (resourceName: string) => { + this.runtime.on('onClientResourceStop', async (resourceName: string) => { if (resourceName !== currentResource) return try { diff --git a/src/runtime/client/system/processors/tick.processor.ts b/src/runtime/client/system/processors/tick.processor.ts index cddc0fd..5d645de 100644 --- a/src/runtime/client/system/processors/tick.processor.ts +++ b/src/runtime/client/system/processors/tick.processor.ts @@ -1,6 +1,7 @@ -import { injectable } from 'tsyringe' +import { inject, injectable } from 'tsyringe' import { DecoratorProcessor } from '../../../../kernel/di/decorator-processor' import { coreLogger, LogDomain } from '../../../../kernel/logger' +import { IClientRuntimeBridge } from '../../adapter/runtime-bridge' import { METADATA_KEYS } from '../metadata-client.keys' const clientTick = coreLogger.child('Tick', LogDomain.CLIENT) @@ -9,11 +10,15 @@ const clientTick = coreLogger.child('Tick', LogDomain.CLIENT) export class TickProcessor implements DecoratorProcessor { readonly metadataKey = METADATA_KEYS.TICK + constructor( + @inject(IClientRuntimeBridge as any) private readonly runtime: IClientRuntimeBridge, + ) {} + process(target: any, methodName: string) { const handler = target[methodName].bind(target) const handlerName = `${target.constructor.name}.${methodName}` - setTick(async () => { + this.runtime.setTick(async () => { try { await handler() } catch (error) { diff --git a/src/runtime/client/system/processors/view.processor.ts b/src/runtime/client/system/processors/view.processor.ts index 7881e20..9a025ac 100644 --- a/src/runtime/client/system/processors/view.processor.ts +++ b/src/runtime/client/system/processors/view.processor.ts @@ -1,36 +1,35 @@ -import { injectable } from 'tsyringe' +import { inject, injectable } from 'tsyringe' import { DecoratorProcessor } from '../../../../kernel/di/decorator-processor' import { loggers } from '../../../../kernel/logger' +import { WebViewService } from '../../webview.service' import { METADATA_KEYS } from '../metadata-client.keys' @injectable() export class ViewProcessor implements DecoratorProcessor { readonly metadataKey = METADATA_KEYS.VIEW + constructor(@inject(WebViewService as any) private readonly webviews: WebViewService) {} + process(target: any, methodName: string, metadata: { eventName: string }) { const handler = target[methodName].bind(target) const handlerName = `${target.constructor.name}.${methodName}` - RegisterNuiCallbackType(metadata.eventName) - - on(`__cfx_nui:${metadata.eventName}`, async (data: any, cb: (response: unknown) => void) => { + this.webviews.onMessage(async (message) => { + if (message.event !== metadata.eventName) return try { - const result = await handler(data) - cb({ ok: true, data: result }) + await handler(message.payload) } catch (error) { - loggers.nui.error( - `NUI callback error`, + loggers.webView.error( + `WebView callback error`, { event: metadata.eventName, handler: handlerName, }, error as Error, ) - const message = error instanceof Error ? error.message : String(error) - cb({ ok: false, error: message }) } }) - loggers.nui.debug(`Registered: ${metadata.eventName} -> ${handlerName}`) + loggers.webView.debug(`Registered WebView callback: ${metadata.eventName} -> ${handlerName}`) } } diff --git a/src/runtime/client/ui-bridge.ts b/src/runtime/client/ui-bridge.ts index f98a0a9..5a8e4e5 100644 --- a/src/runtime/client/ui-bridge.ts +++ b/src/runtime/client/ui-bridge.ts @@ -1,214 +1,4 @@ -import { injectable } from 'tsyringe' -import { coreLogger, LogDomain } from '../../kernel/logger' - -const nuiLogger = coreLogger.child('NUI', LogDomain.CLIENT) - /** - * Type-safe NUI (Native UI) Bridge for client-server communication. - * - * Generic types allow for full type safety when sending/receiving messages. - * Define your event maps and pass them as type parameters. - * - * @example - * ```typescript - * interface ClientToUI { - * 'showMenu': { items: string[] } - * 'hideMenu': void - * } - * - * interface UIToClient { - * 'menuItemSelected': { index: number } - * 'menuClosed': void - * } - * - * const nui = new NuiBridge() - * nui.send('showMenu', { items: ['Option 1', 'Option 2'] }) - * nui.on('menuItemSelected', async (data) => console.log(data.index)) - * ``` + * @deprecated Import from `./webview-bridge` instead. */ -@injectable() -export class NuiBridge< - TSend extends Record = Record, - TReceive extends Record = Record, -> { - private _isVisible = false - private _hasFocus = false - private _hasCursor = false - - /** - * Whether the NUI frame is currently visible - */ - get isVisible(): boolean { - return this._isVisible - } - - /** - * Whether the NUI has input focus - */ - get hasFocus(): boolean { - return this._hasFocus - } - - /** - * Whether the cursor is visible - */ - get hasCursor(): boolean { - return this._hasCursor - } - - /** - * Send a message to the NUI (UI frame). - * - * @param action - The action/event name - * @param data - The data payload - */ - send(action: K, data: TSend[K]): void { - SendNuiMessage(JSON.stringify({ action, data })) - nuiLogger.debug(`Sent message: ${action}`) - } - - /** - * Send a raw message to the NUI without type checking. - * - * @param action - The action/event name - * @param data - The data payload - */ - sendRaw(action: string, data: any): void { - SendNuiMessage(JSON.stringify({ action, data })) - nuiLogger.debug(`Sent raw message: ${action}`) - } - - /** - * Register a callback for NUI events from the UI. - * - * @param action - The action/event name to listen for - * @param handler - The callback handler - * @returns Cleanup function to unregister the callback - */ - on( - action: K, - handler: (data: TReceive[K]) => void | Promise, - ): void { - RegisterNuiCallbackType(action) - - on(`__cfx_nui:${action}`, async (data: TReceive[K], cb: (resp: any) => void) => { - try { - await handler(data) - cb({ ok: true }) - } catch (error) { - nuiLogger.error(`NUI callback error`, { action }, error as Error) - cb({ ok: false, error: String(error) }) - } - }) - - nuiLogger.debug(`Registered callback: ${action}`) - } - - /** - * Register a callback that expects a return value. - * - * @param action - The action/event name to listen for - * @param handler - The callback handler that returns data - */ - onWithResponse( - action: K, - handler: (data: TReceive[K]) => R | Promise, - ): void { - RegisterNuiCallbackType(action) - - on(`__cfx_nui:${action}`, async (data: TReceive[K], cb: (resp: any) => void) => { - try { - const result = await handler(data) - cb({ ok: true, data: result }) - } catch (error) { - nuiLogger.error(`NUI callback error`, { action }, error as Error) - cb({ ok: false, error: String(error) }) - } - }) - - nuiLogger.debug(`Registered callback with response: ${action}`) - } - - /** - * Set NUI focus state. - * - * @param hasFocus - Whether the NUI should have input focus - * @param hasCursor - Whether to show the cursor (defaults to hasFocus value) - */ - focus(hasFocus: boolean, hasCursor?: boolean): void { - this._hasFocus = hasFocus - this._hasCursor = hasCursor ?? hasFocus - SetNuiFocus(this._hasFocus, this._hasCursor) - nuiLogger.debug(`Focus set: focus=${this._hasFocus}, cursor=${this._hasCursor}`) - } - - /** - * Remove NUI focus (convenience method). - */ - blur(): void { - this.focus(false, false) - } - - /** - * Set NUI visibility state. - * Note: This only tracks state, you need to handle actual visibility in your UI. - * - * @param visible - Whether the NUI should be visible - */ - setVisible(visible: boolean): void { - this._isVisible = visible - this.send('setVisible' as any, { visible } as any) - nuiLogger.debug(`Visibility set: ${visible}`) - } - - /** - * Show the NUI and optionally set focus. - * - * @param withFocus - Whether to also set focus - * @param withCursor - Whether to show cursor (defaults to withFocus) - */ - show(withFocus = true, withCursor?: boolean): void { - this.setVisible(true) - if (withFocus) { - this.focus(true, withCursor) - } - } - - /** - * Hide the NUI and remove focus. - */ - hide(): void { - this.setVisible(false) - this.blur() - } - - /** - * Toggle NUI visibility. - * - * @param withFocus - Whether to set focus when showing - */ - toggle(withFocus = true): void { - if (this._isVisible) { - this.hide() - } else { - this.show(withFocus) - } - } - - /** - * Keep input focus but allow game input. - * Useful for HUDs that need to capture some keys but not all. - * - * @param keepInput - Whether to keep game input enabled - */ - setKeepInput(keepInput: boolean): void { - SetNuiFocusKeepInput(keepInput) - nuiLogger.debug(`Keep input set: ${keepInput}`) - } -} - -/** - * Default untyped NUI instance for quick usage. - * For type-safe usage, create your own instance with proper generics. - */ -export const NUI = new NuiBridge() +export { NUI, NuiBridge, WebView, WebViewBridge } from './webview-bridge' diff --git a/src/runtime/client/webview-bridge.ts b/src/runtime/client/webview-bridge.ts new file mode 100644 index 0000000..78f6bc5 --- /dev/null +++ b/src/runtime/client/webview-bridge.ts @@ -0,0 +1,111 @@ +import { injectable } from 'tsyringe' +import { WebViewService } from './webview.service' +import type { WebViewFocusOptions } from '../../adapters/contracts/client/ui/webview/types' +import { di } from './client-container' + +@injectable() +export class WebViewBridge< + TSend extends Record = Record, + TReceive extends Record = Record, +> { + constructor( + private readonly serviceResolver: WebViewService | (() => WebViewService), + private readonly viewId = 'default', + ) {} + + private get service(): WebViewService { + return typeof this.serviceResolver === 'function' + ? this.serviceResolver() + : this.serviceResolver + } + + create( + url: string, + options: { + visible?: boolean + focused?: boolean + cursor?: boolean + inputPassthrough?: boolean + } = {}, + ): void { + this.service.create({ id: this.viewId, url, ...options }) + } + + destroy(): void { + this.service.destroy(this.viewId) + } + exists(): boolean { + return this.service.exists(this.viewId) + } + + send(action: K, data: TSend[K]): void { + this.service.send(this.viewId, action, data) + } + + sendRaw(action: string, data: unknown): void { + this.service.send(this.viewId, action, data) + } + + on( + action: K, + handler: (data: TReceive[K]) => void | Promise, + ): () => void { + return this.service.onMessage(async (message) => { + if (message.viewId !== this.viewId || message.event !== action) return + await handler(message.payload as TReceive[K]) + }) + } + + onWithResponse( + action: K, + handler: (data: TReceive[K]) => R | Promise, + ): () => void { + return this.on(action, handler as (data: TReceive[K]) => void | Promise) + } + + focus(hasFocus: boolean, hasCursor?: boolean): void { + if (hasFocus) { + const options: WebViewFocusOptions = { cursor: hasCursor ?? true } + this.service.focus(this.viewId, options) + return + } + this.service.blur(this.viewId) + } + + blur(): void { + this.service.blur(this.viewId) + } + setVisible(visible: boolean): void { + visible ? this.service.show(this.viewId) : this.service.hide(this.viewId) + } + show(withFocus = true, withCursor?: boolean): void { + this.service.show(this.viewId) + if (withFocus) this.focus(true, withCursor) + } + hide(): void { + this.service.hide(this.viewId) + this.blur() + } + toggle(withFocus = true): void { + if (this.exists()) this.hide() + else this.show(withFocus) + } + setInputPassthrough(enabled: boolean): void { + this.service.focus(this.viewId, { inputPassthrough: enabled, cursor: true }) + } + setKeepInput(keepInput: boolean): void { + this.setInputPassthrough(keepInput) + } +} + +export class NuiBridge< + TSend extends Record = Record, + TReceive extends Record = Record, +> extends WebViewBridge {} + +function resolveWebViewService(): WebViewService { + return di.resolve(WebViewService) +} + +export const WebView = new WebViewBridge(resolveWebViewService) +export const NUI = WebView diff --git a/src/runtime/client/webview.service.ts b/src/runtime/client/webview.service.ts new file mode 100644 index 0000000..adb26c7 --- /dev/null +++ b/src/runtime/client/webview.service.ts @@ -0,0 +1,107 @@ +import { injectable } from 'tsyringe' +import { coreLogger, LogDomain } from '../../kernel/logger' +import { IClientWebViewBridge } from '../../adapters/contracts/client/ui/webview/IClientWebViewBridge' +import { IClientRuntimeBridge } from './adapter/runtime-bridge' +import type { + WebViewCapabilities, + WebViewDefinition, + WebViewFocusOptions, + WebViewMessage, +} from '../../adapters/contracts/client/ui/webview/types' +import { di } from './client-container' + +const webViewLogger = coreLogger.child('WebView', LogDomain.CLIENT) + +const FALLBACK_CAPABILITIES: WebViewCapabilities = { + supportsFocus: true, + supportsCursor: true, + supportsInputPassthrough: true, + supportsBidirectionalMessaging: true, + supportsExecute: false, + supportsHeadless: false, +} + +function createFallbackBridge(): IClientWebViewBridge { + const runtime = di.resolve(IClientRuntimeBridge as any) as IClientRuntimeBridge + const handlers = new Set<(message: WebViewMessage) => void | Promise>() + let registered = false + + return { + getCapabilities: () => FALLBACK_CAPABILITIES, + create: () => {}, + destroy: () => {}, + exists: () => true, + show: () => {}, + hide: () => {}, + focus: (_viewId, options) => { + runtime.setWebViewFocus(true, options?.cursor ?? true) + runtime.setWebViewInputPassthrough(options?.inputPassthrough ?? false) + }, + blur: () => runtime.setWebViewFocus(false, false), + send: (viewId, event, payload) => { + runtime.sendWebViewMessage( + JSON.stringify({ __opencoreWebView: true, viewId, action: event, data: payload }), + ) + }, + onMessage: (handler) => { + if (!registered) { + registered = true + runtime.registerWebViewCallback('__opencore:webview:message', async (data: unknown, cb) => { + const message = data as WebViewMessage + for (const item of handlers) await item(message) + cb({ ok: true }) + }) + } + handlers.add(handler) + return () => handlers.delete(handler) + }, + } +} + +@injectable() +export class WebViewService { + private get bridge(): IClientWebViewBridge { + if (di.isRegistered(IClientWebViewBridge as any)) { + return di.resolve(IClientWebViewBridge as any) as IClientWebViewBridge + } + + return createFallbackBridge() + } + + getCapabilities(): WebViewCapabilities { + return this.bridge.getCapabilities() + } + + create(definition: WebViewDefinition): void { + this.bridge.create(definition) + webViewLogger.debug('Created webview', { id: definition.id, url: definition.url }) + } + + destroy(viewId: string): void { + this.bridge.destroy(viewId) + webViewLogger.debug('Destroyed webview', { id: viewId }) + } + + exists(viewId: string): boolean { + return this.bridge.exists(viewId) + } + + show(viewId: string): void { + this.bridge.show(viewId) + } + hide(viewId: string): void { + this.bridge.hide(viewId) + } + focus(viewId: string, options?: WebViewFocusOptions): void { + this.bridge.focus(viewId, options) + } + blur(viewId: string): void { + this.bridge.blur(viewId) + } + send(viewId: string, event: string, payload: unknown): void { + this.bridge.send(viewId, event, payload) + } + onMessage(handler: (message: WebViewMessage) => void | Promise): () => void { + return this.bridge.onMessage(handler) + } +} diff --git a/src/runtime/server/adapter/index.ts b/src/runtime/server/adapter/index.ts new file mode 100644 index 0000000..4dc44ae --- /dev/null +++ b/src/runtime/server/adapter/index.ts @@ -0,0 +1,5 @@ +export * from './node-server-adapter' +export * from './player-adapter' +export * from './registry' +export * from './serialization' +export * from './server-adapter' diff --git a/src/runtime/server/adapter/node-npc-lifecycle-server.ts b/src/runtime/server/adapter/node-npc-lifecycle-server.ts new file mode 100644 index 0000000..2a7a5d7 --- /dev/null +++ b/src/runtime/server/adapter/node-npc-lifecycle-server.ts @@ -0,0 +1,50 @@ +import { inject, injectable } from 'tsyringe' +import { IEntityServer } from '../../../adapters/contracts/server/IEntityServer' +import { INpcLifecycleServer } from '../../../adapters/contracts/server/npc-lifecycle/INpcLifecycleServer' +import type { + CreateNpcServerRequest, + CreateNpcServerResult, + DeleteNpcServerRequest, +} from '../../../adapters/contracts/server/npc-lifecycle/types' +import { IPedServer } from '../../../adapters/contracts/server/IPedServer' + +@injectable() +export class NodeNpcLifecycleServer extends INpcLifecycleServer { + constructor( + @inject(IPedServer as any) private readonly pedServer: IPedServer, + @inject(IEntityServer as any) private readonly entityServer: IEntityServer, + ) { + super() + } + + create(request: CreateNpcServerRequest): CreateNpcServerResult { + const handle = this.pedServer.create( + 4, + request.modelHash, + request.position.x, + request.position.y, + request.position.z, + request.heading, + request.networked, + ) + + if (!handle || handle <= 0) { + throw new Error('Failed to create NPC ped entity') + } + + return { + handle, + netId: request.networked ? this.resolveNetId(handle) : undefined, + } + } + + delete(request: DeleteNpcServerRequest): void { + if (!this.entityServer.doesExist(request.handle)) return + this.pedServer.delete(request.handle) + } + + private resolveNetId(handle: number): number | undefined { + const netId = this.pedServer.getNetworkIdFromEntity(handle) + return netId > 0 ? netId : undefined + } +} diff --git a/src/runtime/server/adapter/node-player-appearance-lifecycle-server.ts b/src/runtime/server/adapter/node-player-appearance-lifecycle-server.ts new file mode 100644 index 0000000..240977b --- /dev/null +++ b/src/runtime/server/adapter/node-player-appearance-lifecycle-server.ts @@ -0,0 +1,89 @@ +import { inject, injectable } from 'tsyringe' +import { EventsAPI } from '../../../adapters/contracts/transport/events.api' +import { IPedAppearanceServer } from '../../../adapters/contracts/server/IPedAppearanceServer' +import { IPlayerAppearanceLifecycleServer } from '../../../adapters/contracts/server/player-appearance/IPlayerAppearanceLifecycleServer' +import { IPlayerServer } from '../../../adapters/contracts/server/IPlayerServer' +import { Players } from '../ports/players.api-port' +import { PlayerAppearance } from '../../../kernel/shared' + +@injectable() +export class NodePlayerAppearanceLifecycleServer extends IPlayerAppearanceLifecycleServer { + constructor( + @inject(IPedAppearanceServer as any) private readonly pedAdapter: IPedAppearanceServer, + @inject(IPlayerServer as any) private readonly playerServer: IPlayerServer, + @inject(EventsAPI as any) private readonly events: EventsAPI<'server'>, + @inject(Players as any) private readonly players: Players, + ) { + super() + } + + async apply( + playerSrc: string, + appearance: PlayerAppearance, + ): Promise<{ success: boolean; appearance?: PlayerAppearance; errors?: string[] }> { + const ped = this.playerServer.getPed(playerSrc) + if (ped === 0) { + return { success: false, errors: ['Player ped not found'] } + } + + this.applyServerSideAppearance(ped, appearance) + const target = this.resolveTarget(playerSrc) + if (!target) { + return { success: false, errors: ['Player not found'] } + } + + this.events.emit('opencore:appearance:apply', target, appearance) + return { success: true, appearance } + } + + applyClothing( + playerSrc: string, + appearance: Pick, + ): boolean { + const ped = this.playerServer.getPed(playerSrc) + if (ped === 0) return false + this.applyServerSideAppearance(ped, appearance) + return true + } + + reset(playerSrc: string): boolean { + const ped = this.playerServer.getPed(playerSrc) + if (ped === 0) return false + this.pedAdapter.setDefaultComponentVariation(ped) + const target = this.resolveTarget(playerSrc) + if (!target) return false + this.events.emit('opencore:appearance:reset', target) + return true + } + + private resolveTarget(playerSrc: string) { + return this.players.getByClient(Number(playerSrc)) + } + + private applyServerSideAppearance( + ped: number, + appearance: Pick, + ): void { + if (appearance.components) { + for (const [componentId, data] of Object.entries(appearance.components)) { + this.pedAdapter.setComponentVariation( + ped, + parseInt(componentId, 10), + data.drawable, + data.texture, + 2, + ) + } + } + + if (appearance.props) { + for (const [propId, data] of Object.entries(appearance.props)) { + if (data.drawable === -1) { + this.pedAdapter.clearProp(ped, parseInt(propId, 10)) + } else { + this.pedAdapter.setPropIndex(ped, parseInt(propId, 10), data.drawable, data.texture, true) + } + } + } + } +} diff --git a/src/runtime/server/adapter/node-player-lifecycle-server.ts b/src/runtime/server/adapter/node-player-lifecycle-server.ts new file mode 100644 index 0000000..945e3a2 --- /dev/null +++ b/src/runtime/server/adapter/node-player-lifecycle-server.ts @@ -0,0 +1,46 @@ +import { inject, injectable } from 'tsyringe' +import { EventsAPI } from '../../../adapters/contracts/transport/events.api' +import { IPlayerLifecycleServer } from '../../../adapters/contracts/server/player-lifecycle/IPlayerLifecycleServer' +import type { + RespawnPlayerRequest, + SpawnPlayerRequest, + TeleportPlayerRequest, +} from '../../../adapters/contracts/server/player-lifecycle/types' + +@injectable() +export class NodePlayerLifecycleServer extends IPlayerLifecycleServer { + constructor(@inject(EventsAPI as any) private readonly events: EventsAPI<'server'>) { + super() + } + + spawn(playerSrc: string, request: SpawnPlayerRequest): void { + const target = this.resolveTarget(playerSrc) + if (!target) return + + this.events.emit('opencore:spawner:spawn', target, { + position: request.position, + model: request.model, + heading: request.heading, + appearance: request.appearance, + }) + } + + teleport(playerSrc: string, request: TeleportPlayerRequest): void { + const target = this.resolveTarget(playerSrc) + if (!target) return + + this.events.emit('opencore:spawner:teleport', target, request.position, request.heading) + } + + respawn(playerSrc: string, request: RespawnPlayerRequest): void { + const target = this.resolveTarget(playerSrc) + if (!target) return + + this.events.emit('opencore:spawner:respawn', target, request.position, request.heading) + } + + private resolveTarget(playerSrc: string) { + const clientId = Number(playerSrc) + return Number.isFinite(clientId) && clientId > 0 ? clientId : undefined + } +} diff --git a/src/runtime/server/adapter/node-player-state-sync-server.ts b/src/runtime/server/adapter/node-player-state-sync-server.ts new file mode 100644 index 0000000..0f79862 --- /dev/null +++ b/src/runtime/server/adapter/node-player-state-sync-server.ts @@ -0,0 +1,34 @@ +import { inject, injectable } from 'tsyringe' +import { IEntityServer } from '../../../adapters/contracts/server/IEntityServer' +import { IPlayerServer } from '../../../adapters/contracts/server/IPlayerServer' +import { IPlayerStateSyncServer } from '../../../adapters/contracts/server/player-state/IPlayerStateSyncServer' + +@injectable() +export class NodePlayerStateSyncServer extends IPlayerStateSyncServer { + constructor( + @inject(IPlayerServer as any) private readonly playerServer: IPlayerServer, + @inject(IEntityServer as any) private readonly entityServer: IEntityServer, + ) { + super() + } + + getHealth(playerSrc: string): number { + return this.entityServer.getHealth(this.playerServer.getPed(playerSrc)) + } + + setHealth(playerSrc: string, health: number): void { + const ped = this.playerServer.getPed(playerSrc) + this.entityServer.setHealth(ped, health) + this.entityServer.getStateBag(ped).set('health', health, true) + } + + getArmor(playerSrc: string): number { + return this.entityServer.getArmor(this.playerServer.getPed(playerSrc)) + } + + setArmor(playerSrc: string, armor: number): void { + const ped = this.playerServer.getPed(playerSrc) + this.entityServer.setArmor(ped, armor) + this.entityServer.getStateBag(ped).set('armor', armor, true) + } +} diff --git a/src/runtime/server/adapter/node-server-adapter.ts b/src/runtime/server/adapter/node-server-adapter.ts new file mode 100644 index 0000000..ec01f4c --- /dev/null +++ b/src/runtime/server/adapter/node-server-adapter.ts @@ -0,0 +1,109 @@ +import type { InjectionToken } from 'tsyringe' +import { IEngineEvents } from '../../../adapters/contracts/IEngineEvents' +import { IExports } from '../../../adapters/contracts/IExports' +import { IHasher } from '../../../adapters/contracts/IHasher' +import { IPlatformContext } from '../../../adapters/contracts/IPlatformContext' +import { IPlayerInfo } from '../../../adapters/contracts/IPlayerInfo' +import { IResourceInfo } from '../../../adapters/contracts/IResourceInfo' +import { ITick } from '../../../adapters/contracts/ITick' +import { IEntityServer } from '../../../adapters/contracts/server/IEntityServer' +import { INpcLifecycleServer } from '../../../adapters/contracts/server/npc-lifecycle/INpcLifecycleServer' +import { IPedAppearanceServer } from '../../../adapters/contracts/server/IPedAppearanceServer' +import { IPedServer } from '../../../adapters/contracts/server/IPedServer' +import { IPlayerAppearanceLifecycleServer } from '../../../adapters/contracts/server/player-appearance/IPlayerAppearanceLifecycleServer' +import { IPlayerLifecycleServer } from '../../../adapters/contracts/server/player-lifecycle/IPlayerLifecycleServer' +import { IPlayerStateSyncServer } from '../../../adapters/contracts/server/player-state/IPlayerStateSyncServer' +import { IPlayerServer } from '../../../adapters/contracts/server/IPlayerServer' +import { IVehicleLifecycleServer } from '../../../adapters/contracts/server/vehicle-lifecycle/IVehicleLifecycleServer' +import { IVehicleServer } from '../../../adapters/contracts/server/IVehicleServer' +import { defineServerAdapter, type OpenCoreServerAdapter } from './server-adapter' + +/** + * Default server adapter used when no runtime adapter is provided. + */ +export function createNodeServerAdapter(): OpenCoreServerAdapter { + return defineServerAdapter({ + name: 'node', + async register(ctx) { + const [ + { NodeMessagingTransport }, + { NodeEngineEvents }, + { NodeExports }, + { NodeResourceInfo }, + { NodeTick }, + { NodePlayerInfo }, + { NodeEntityServer }, + { NodePedServer }, + { NodeVehicleServer }, + { NodePlayerServer }, + { NodeHasher }, + { NodePedAppearanceServer }, + { NodePlatformContext }, + { NodePlayerLifecycleServer }, + { NodeVehicleLifecycleServer }, + { NodeNpcLifecycleServer }, + { NodePlayerAppearanceLifecycleServer }, + { NodePlayerStateSyncServer }, + ] = await Promise.all([ + import('../../../adapters/node/transport/adapter'), + import('../../../adapters/node/node-engine-events'), + import('../../../adapters/node/node-exports'), + import('../../../adapters/node/node-resourceinfo'), + import('../../../adapters/node/node-tick'), + import('../../../adapters/node/node-playerinfo'), + import('../../../adapters/node/node-entity-server'), + import('../../../adapters/node/node-ped-server'), + import('../../../adapters/node/node-vehicle-server'), + import('../../../adapters/node/node-player-server'), + import('../../../adapters/node/node-hasher'), + import('../../../adapters/node/node-ped-appearance-server'), + import('../../../adapters/node/node-capabilities'), + import('./node-player-lifecycle-server'), + import('./node-vehicle-lifecycle-server'), + import('./node-npc-lifecycle-server'), + import('./node-player-appearance-lifecycle-server'), + import('./node-player-state-sync-server'), + ]) + + ctx.bindSingleton(IPlatformContext as InjectionToken, NodePlatformContext) + + const transport = new NodeMessagingTransport('server') + ctx.bindMessagingTransport(transport) + + ctx.bindSingleton(IEngineEvents as InjectionToken, NodeEngineEvents) + ctx.bindSingleton(IExports as InjectionToken, NodeExports) + ctx.bindSingleton(IResourceInfo as InjectionToken, NodeResourceInfo) + ctx.bindSingleton(ITick as InjectionToken, NodeTick) + ctx.bindSingleton(IPlayerInfo as InjectionToken, NodePlayerInfo) + ctx.bindSingleton(IEntityServer as InjectionToken, NodeEntityServer) + ctx.bindSingleton( + INpcLifecycleServer as InjectionToken, + NodeNpcLifecycleServer, + ) + ctx.bindSingleton(IPedServer as InjectionToken, NodePedServer) + ctx.bindSingleton(IVehicleServer as InjectionToken, NodeVehicleServer) + ctx.bindSingleton( + IVehicleLifecycleServer as InjectionToken, + NodeVehicleLifecycleServer, + ) + ctx.bindSingleton(IPlayerServer as InjectionToken, NodePlayerServer) + ctx.bindSingleton( + IPlayerAppearanceLifecycleServer as InjectionToken, + NodePlayerAppearanceLifecycleServer, + ) + ctx.bindSingleton( + IPlayerStateSyncServer as InjectionToken, + NodePlayerStateSyncServer, + ) + ctx.bindSingleton( + IPlayerLifecycleServer as InjectionToken, + NodePlayerLifecycleServer, + ) + ctx.bindSingleton(IHasher as InjectionToken, NodeHasher) + ctx.bindSingleton( + IPedAppearanceServer as InjectionToken, + NodePedAppearanceServer, + ) + }, + }) +} diff --git a/src/runtime/server/adapter/node-vehicle-lifecycle-server.ts b/src/runtime/server/adapter/node-vehicle-lifecycle-server.ts new file mode 100644 index 0000000..8fe38fb --- /dev/null +++ b/src/runtime/server/adapter/node-vehicle-lifecycle-server.ts @@ -0,0 +1,53 @@ +import { inject, injectable } from 'tsyringe' +import { IVehicleLifecycleServer } from '../../../adapters/contracts/server/vehicle-lifecycle/IVehicleLifecycleServer' +import type { + CreateVehicleServerRequest, + CreateVehicleServerResult, + WarpPlayerIntoVehicleRequest, +} from '../../../adapters/contracts/server/vehicle-lifecycle/types' +import { IPlatformContext } from '../../../adapters/contracts/IPlatformContext' +import { IVehicleServer } from '../../../adapters/contracts/server/IVehicleServer' +import { EventsAPI } from '../../../adapters/contracts/transport/events.api' + +@injectable() +export class NodeVehicleLifecycleServer extends IVehicleLifecycleServer { + constructor( + @inject(IVehicleServer as any) private readonly vehicleServer: IVehicleServer, + @inject(IPlatformContext as any) private readonly platformContext: IPlatformContext, + @inject(EventsAPI as any) private readonly events: EventsAPI<'server'>, + ) { + super() + } + + create(request: CreateVehicleServerRequest): CreateVehicleServerResult { + if (!this.platformContext.enableServerVehicleCreation) { + throw new Error( + `Server vehicle creation is disabled for profile '${this.platformContext.gameProfile}'`, + ) + } + + const handle = this.vehicleServer.createServerSetter( + request.modelHash, + this.platformContext.defaultVehicleType, + request.position.x, + request.position.y, + request.position.z, + request.heading, + ) + + if (!handle || handle === 0) { + throw new Error('Failed to create vehicle entity') + } + + return { + handle, + networkId: this.vehicleServer.getNetworkIdFromEntity(handle), + } + } + + warpPlayerIntoVehicle(request: WarpPlayerIntoVehicleRequest): void { + const clientId = Number(request.playerSrc) + if (Number.isNaN(clientId)) return + this.events.emit('opencore:vehicle:warpInto', clientId, request.networkId, request.seatIndex) + } +} diff --git a/src/runtime/server/adapter/player-adapter.ts b/src/runtime/server/adapter/player-adapter.ts new file mode 100644 index 0000000..74e08c8 --- /dev/null +++ b/src/runtime/server/adapter/player-adapter.ts @@ -0,0 +1,56 @@ +import { PlayerAdapters, Player } from '../entities/player' +import { PlayerSession } from '../types/player-session.types' +import { SerializedPlayerData } from '../types/core-exports.types' + +/** + * Dependencies required to build server-side player instances. + */ +export type PlayerFactoryDeps = PlayerAdapters + +/** + * Adapter hook for creating and hydrating Player instances. + */ +export interface ServerPlayerAdapter { + createLocal(session: PlayerSession, deps: PlayerFactoryDeps): Player + createRemote(data: SerializedPlayerData, deps: PlayerFactoryDeps): Player + serialize?(player: Player): Record | undefined + hydrate?(player: Player, payload: Record | undefined): void +} + +/** + * Restores base state shared by all Player instances. + */ +export function hydrateBasePlayerState(player: Player, data: SerializedPlayerData): Player { + for (const state of data.states) { + player.addState(state) + } + + return player +} + +/** + * Creates the framework default local Player instance. + */ +export function createDefaultLocalPlayer(session: PlayerSession, deps: PlayerFactoryDeps): Player { + return new Player(session, deps) +} + +/** + * Creates the framework default remote Player instance. + */ +export function createDefaultRemotePlayer( + data: SerializedPlayerData, + deps: PlayerFactoryDeps, +): Player { + const player = new Player( + { + clientID: data.clientID, + accountID: data.accountID, + identifiers: data.identifiers, + meta: data.meta, + }, + deps, + ) + + return hydrateBasePlayerState(player, data) +} diff --git a/src/runtime/server/adapter/registry.ts b/src/runtime/server/adapter/registry.ts new file mode 100644 index 0000000..fa1e608 --- /dev/null +++ b/src/runtime/server/adapter/registry.ts @@ -0,0 +1,146 @@ +import { DependencyContainer, type InjectionToken } from 'tsyringe' +import { GLOBAL_CONTAINER } from '../../../kernel/di/container' +import { Player } from '../entities/player' +import { SerializedPlayerData } from '../types/core-exports.types' +import { PlayerSession } from '../types/player-session.types' +import { + createDefaultLocalPlayer, + createDefaultRemotePlayer, + type PlayerFactoryDeps, + type ServerPlayerAdapter, +} from './player-adapter' +import { + bindTransportInstances, + type OpenCoreServerAdapter, + type ServerAdapterContext, +} from './server-adapter' + +const DEFAULT_PLAYER_ADAPTER: ServerPlayerAdapter = { + createLocal: createDefaultLocalPlayer, + createRemote: createDefaultRemotePlayer, +} + +interface ActiveServerAdapterState { + name: string + playerAdapter: ServerPlayerAdapter +} + +let activeServerAdapter: ActiveServerAdapterState | null = null + +function assertTokenAvailable( + container: DependencyContainer, + token: InjectionToken, + adapterName: string, +): void { + if (container.isRegistered(token)) { + throw new Error(`[OpenCore] Adapter '${adapterName}' cannot bind an already registered token.`) + } +} + +function createAdapterContext(adapterName: string): ServerAdapterContext { + let playerAdapterConfigured = false + + return { + adapterName, + container: GLOBAL_CONTAINER, + isRegistered(token: InjectionToken): boolean { + return GLOBAL_CONTAINER.isRegistered(token) + }, + bindSingleton(token: InjectionToken, implementation: InjectionToken): void { + assertTokenAvailable(GLOBAL_CONTAINER, token, adapterName) + GLOBAL_CONTAINER.registerSingleton(token, implementation) + }, + bindInstance(token: InjectionToken, value: T): void { + assertTokenAvailable(GLOBAL_CONTAINER, token, adapterName) + GLOBAL_CONTAINER.registerInstance(token, value) + }, + bindFactory(token: InjectionToken, factory: () => T): void { + assertTokenAvailable(GLOBAL_CONTAINER, token, adapterName) + GLOBAL_CONTAINER.register(token, { useFactory: factory }) + }, + bindMessagingTransport(transport) { + bindTransportInstances(this, transport) + }, + usePlayerAdapter(adapter: ServerPlayerAdapter): void { + if (playerAdapterConfigured) { + throw new Error(`[OpenCore] Adapter '${adapterName}' already configured a Player adapter.`) + } + activeServerAdapter = { + name: adapterName, + playerAdapter: adapter, + } + playerAdapterConfigured = true + }, + } +} + +/** + * Installs the active server adapter for the current bootstrap. + */ +export async function installServerAdapter(adapter: OpenCoreServerAdapter): Promise { + activeServerAdapter = { + name: adapter.name, + playerAdapter: DEFAULT_PLAYER_ADAPTER, + } + + const context = createAdapterContext(adapter.name) + await adapter.register(context) +} + +/** + * Returns the currently active server adapter name. + */ +export function getActiveServerAdapterName(): string | undefined { + return activeServerAdapter?.name +} + +/** + * Builds a local Player through the active adapter. + */ +export function createLocalServerPlayer(session: PlayerSession, deps: PlayerFactoryDeps): Player { + return (activeServerAdapter?.playerAdapter ?? DEFAULT_PLAYER_ADAPTER).createLocal(session, deps) +} + +/** + * Builds a remote Player through the active adapter. + */ +export function createRemoteServerPlayer( + data: SerializedPlayerData, + deps: PlayerFactoryDeps, +): Player { + if ( + data.adapter?.name && + activeServerAdapter?.name && + data.adapter.name !== activeServerAdapter.name + ) { + throw new Error( + `[OpenCore] Cannot hydrate Player for adapter '${data.adapter.name}' with active adapter '${activeServerAdapter.name}'.`, + ) + } + + const playerAdapter = activeServerAdapter?.playerAdapter ?? DEFAULT_PLAYER_ADAPTER + const player = playerAdapter.createRemote(data, deps) + playerAdapter.hydrate?.(player, data.adapter?.payload) + return player +} + +/** + * Serializes adapter-specific player payload. + */ +export function serializeServerPlayerAdapterPayload( + player: Player, +): SerializedPlayerData['adapter'] | undefined { + const payload = activeServerAdapter?.playerAdapter.serialize?.(player) + if (!activeServerAdapter || payload === undefined) { + return undefined + } + + return { + name: activeServerAdapter.name, + payload, + } +} + +export function __resetServerAdapterRegistryForTests(): void { + activeServerAdapter = null +} diff --git a/src/runtime/server/adapter/serialization.ts b/src/runtime/server/adapter/serialization.ts new file mode 100644 index 0000000..b2b3526 --- /dev/null +++ b/src/runtime/server/adapter/serialization.ts @@ -0,0 +1,13 @@ +import type { Player } from '../entities/player' +import type { SerializedPlayerData } from '../types/core-exports.types' +import { serializeServerPlayerAdapterPayload } from './registry' + +/** + * Serializes a Player using the active server adapter payload hooks. + */ +export function serializeServerPlayerData(player: Player): SerializedPlayerData { + const base = player.serialize() + const adapter = serializeServerPlayerAdapterPayload(player) + + return adapter ? { ...base, adapter } : base +} diff --git a/src/runtime/server/adapter/server-adapter.ts b/src/runtime/server/adapter/server-adapter.ts new file mode 100644 index 0000000..7b07e98 --- /dev/null +++ b/src/runtime/server/adapter/server-adapter.ts @@ -0,0 +1,46 @@ +import type { DependencyContainer, InjectionToken } from 'tsyringe' +import { EventsAPI } from '../../../adapters/contracts/transport/events.api' +import { MessagingTransport } from '../../../adapters/contracts/transport/messaging.transport' +import { RpcAPI } from '../../../adapters/contracts/transport/rpc.api' +import { ServerPlayerAdapter } from './player-adapter' + +/** + * Public contract implemented by external server adapters. + */ +export interface OpenCoreServerAdapter { + readonly name: string + register(context: ServerAdapterContext): void | Promise +} + +/** + * Registration helpers exposed to server adapters. + */ +export interface ServerAdapterContext { + readonly adapterName: string + readonly container: DependencyContainer + isRegistered(token: InjectionToken): boolean + bindSingleton(token: InjectionToken, implementation: InjectionToken): void + bindInstance(token: InjectionToken, value: T): void + bindFactory(token: InjectionToken, factory: () => T): void + bindMessagingTransport(transport: MessagingTransport): void + usePlayerAdapter(adapter: ServerPlayerAdapter): void +} + +/** + * Helper for strongly typed adapter declarations. + */ +export function defineServerAdapter(adapter: OpenCoreServerAdapter): OpenCoreServerAdapter { + return adapter +} + +export function bindTransportInstances( + context: Pick, + transport: MessagingTransport, +): void { + context.bindInstance( + MessagingTransport as unknown as InjectionToken, + transport, + ) + context.bindInstance(EventsAPI as InjectionToken>, transport.events) + context.bindInstance(RpcAPI as InjectionToken>, transport.rpc) +} diff --git a/src/runtime/server/api.ts b/src/runtime/server/api.ts index 1400402..d8c47e6 100644 --- a/src/runtime/server/api.ts +++ b/src/runtime/server/api.ts @@ -1,11 +1,12 @@ // Framework functions export { onFrameworkEvent } from './bus/internal-event.bus' -export { init } from './core' +export { init, useAdapter } from './core' // API export * from './apis' export * from './decorators' export * from './library' +export * from './adapter' export * from './contracts' export * from './ports/players.api-port' export * from './ports/authorization.api-port' diff --git a/src/runtime/server/apis/appearance.api.ts b/src/runtime/server/apis/appearance.api.ts index e8f86e3..4fa42e9 100644 --- a/src/runtime/server/apis/appearance.api.ts +++ b/src/runtime/server/apis/appearance.api.ts @@ -1,5 +1,7 @@ import { injectable } from 'tsyringe' +import { inject } from 'tsyringe' import { AppearanceService } from '../services' +import { IPlayerAppearanceLifecycleServer } from '../../../adapters/contracts/server/player-appearance/IPlayerAppearanceLifecycleServer' import { AppearanceValidationResult, PlayerAppearance } from '../../..' import { Player } from '../entities/player' @@ -8,7 +10,11 @@ type Clothes = Pick @injectable() export class Appearance { - constructor(private readonly appearance: AppearanceService) {} + constructor( + private readonly appearance: AppearanceService, + @inject(IPlayerAppearanceLifecycleServer as any) + private readonly lifecycle: IPlayerAppearanceLifecycleServer, + ) {} /** * Apply full appearance to a player. @@ -23,8 +29,12 @@ export class Appearance { player: PlayerRef, appearance: PlayerAppearance, ): Promise<{ success: boolean; appearance?: PlayerAppearance; errors?: string[] }> { + const validation = this.appearance.validateAppearance(appearance) + if (!validation.valid) { + return { success: false, errors: validation.errors } + } const src = this.resolveSource(player) - return this.appearance.applyAppearance(src, appearance) + return this.lifecycle.apply(src, appearance) } /** @@ -32,17 +42,17 @@ export class Appearance { * * Useful for quick outfit swaps without touching face / tattoos. */ - applyClothing(player: PlayerRef, appearance: Clothes): boolean { + async applyClothing(player: PlayerRef, appearance: Clothes): Promise { const src = this.resolveSource(player) - return this.appearance.applyClothing(src, appearance) + return await Promise.resolve(this.lifecycle.applyClothing(src, appearance)) } /** * Reset player appearance to default. */ - reset(player: PlayerRef): boolean { + async reset(player: PlayerRef): Promise { const src = this.resolveSource(player) - return this.appearance.resetAppearance(src) + return await Promise.resolve(this.lifecycle.reset(src)) } /** diff --git a/src/runtime/server/apis/npcs.api.ts b/src/runtime/server/apis/npcs.api.ts index 9977b91..45432a7 100644 --- a/src/runtime/server/apis/npcs.api.ts +++ b/src/runtime/server/apis/npcs.api.ts @@ -3,6 +3,7 @@ import { v4 as uuid } from 'uuid' import { IHasher } from '../../../adapters/contracts/IHasher' import { EventsAPI } from '../../../adapters/contracts/transport/events.api' import { IEntityServer } from '../../../adapters/contracts/server/IEntityServer' +import { INpcLifecycleServer } from '../../../adapters/contracts/server/npc-lifecycle/INpcLifecycleServer' import { IPedServer } from '../../../adapters/contracts/server/IPedServer' import { coreLogger } from '../../../kernel/logger' import { Vector3 } from '../../../kernel/utils/vector3' @@ -10,9 +11,6 @@ import { WorldContext } from '../../core/world' import { NPC, NpcAdapters, NpcSession } from '../entities/npc' import { NpcSpawnOptions, NpcSpawnResult, SerializedNpcData } from '../types/npc.types' -const DEFAULT_PED_TYPE = 4 -const DEFAULT_SPAWN_TIMEOUT_MS = 2000 - /** * Server-side API responsible for the full NPC (ped) lifecycle: * spawn, registry, queries, spatial search, serialization and deletion. @@ -34,6 +32,7 @@ export class Npcs { constructor( @inject(WorldContext) private readonly world: WorldContext, @inject(IEntityServer as any) private readonly entityServer: IEntityServer, + @inject(INpcLifecycleServer as any) private readonly npcLifecycle: INpcLifecycleServer, @inject(IPedServer as any) private readonly pedServer: IPedServer, @inject(IHasher as any) private readonly hasher: IHasher, @inject(EventsAPI as any) private readonly events: EventsAPI<'server'>, @@ -41,6 +40,7 @@ export class Npcs { this.adapters = { entityServer: this.entityServer, pedServer: this.pedServer, + npcLifecycle: this.npcLifecycle, } } @@ -64,7 +64,6 @@ export class Npcs { position, heading = 0, networked = true, - pedType = DEFAULT_PED_TYPE, routingBucket = 0, persistent = false, metadata, @@ -81,38 +80,31 @@ export class Npcs { } const modelHash = typeof model === 'string' ? this.hasher.getHashKey(model) : model - const handle = this.pedServer.create( - pedType, - modelHash, - position.x, - position.y, - position.z, - heading, - networked, - ) - - if (!handle || handle <= 0) { - return { - result: { - success: false, - error: 'Failed to create NPC ped entity', - }, - } - } - - const spawnOk = await this.waitForSpawn(handle) - if (!spawnOk) { - this.safeDeleteHandle(handle) + let lifecycleResult: { handle: number; netId?: number } + try { + lifecycleResult = await Promise.resolve( + this.npcLifecycle.create({ + model: typeof model === 'string' ? model : String(model), + modelHash, + position, + heading, + networked, + routingBucket, + persistent, + }), + ) + } catch (error: unknown) { return { result: { success: false, - error: `NPC spawn timed out after ${DEFAULT_SPAWN_TIMEOUT_MS}ms`, + error: error instanceof Error ? error.message : 'Failed to create NPC ped entity', }, } } + const handle = lifecycleResult.handle const resolvedModel = typeof model === 'string' ? model : modelHash.toString() - const netId = networked ? this.resolveNetId(handle) : undefined + const netId = lifecycleResult.netId const session: NpcSession = { id: npcId, handle, @@ -125,13 +117,6 @@ export class Npcs { } const npc = new NPC(session, this.adapters) - if (routingBucket !== 0) { - npc.setRoutingBucket(routingBucket) - } - if (persistent) { - this.entityServer.setOrphanMode(handle, 2) - } - if (metadata) { for (const [key, value] of Object.entries(metadata)) { npc.setMeta(key, value) @@ -455,11 +440,6 @@ export class Npcs { return Array.from(this.npcById.values()).map((npc) => npc.serialize()) } - private resolveNetId(handle: number): number | undefined { - const netId = this.pedServer.getNetworkIdFromEntity(handle) - return netId > 0 ? netId : undefined - } - private removeFromRegistry(npc: NPC): void { this.npcById.delete(npc.npcId) this.idByHandle.delete(npc.getHandle()) @@ -468,35 +448,4 @@ export class Npcs { } this.world.remove(npc.id) } - - private safeDeleteHandle(handle: number): void { - try { - if (this.entityServer.doesExist(handle)) { - this.pedServer.delete(handle) - } - } catch (error: unknown) { - coreLogger.warn('Failed to cleanup NPC handle after spawn failure', { - handle, - error: error instanceof Error ? error.message : String(error), - }) - } - } - - private async waitForSpawn( - handle: number, - timeoutMs: number = DEFAULT_SPAWN_TIMEOUT_MS, - ): Promise { - const startedAt = Date.now() - while (!this.entityServer.doesExist(handle)) { - if (Date.now() - startedAt > timeoutMs) { - return false - } - await sleep(0) - } - return true - } -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) } diff --git a/src/runtime/server/apis/vehicle-modification.api.ts b/src/runtime/server/apis/vehicle-modification.api.ts index af948ab..c276c46 100644 --- a/src/runtime/server/apis/vehicle-modification.api.ts +++ b/src/runtime/server/apis/vehicle-modification.api.ts @@ -1,4 +1,5 @@ import { inject, injectable } from 'tsyringe' +import { EventsAPI } from '../../../adapters/contracts/transport/events.api' import { coreLogger } from '../../../kernel/logger' import { VehicleModificationOptions, VehicleMods } from '../types/vehicle.types' import { Vehicles } from './vehicles.api' @@ -17,7 +18,10 @@ import { Vehicles } from './vehicles.api' */ @injectable() export class VehicleModification { - constructor(@inject(Vehicles) private readonly vehicleService: Vehicles) {} + constructor( + @inject(Vehicles) private readonly vehicleService: Vehicles, + @inject(EventsAPI as any) private readonly events: EventsAPI<'server'>, + ) {} /** * Applies modifications to a vehicle with validation. @@ -66,7 +70,7 @@ export class VehicleModification { mods: Object.keys(validatedMods), }) - emitNet('opencore:vehicle:modified', -1, { + this.events.emit('opencore:vehicle:modified', 'all', { networkId, mods: validatedMods, }) @@ -213,7 +217,7 @@ export class VehicleModification { coreLogger.info('Vehicle modifications reset', { networkId, requestedBy }) - emitNet('opencore:vehicle:modified', -1, { + this.events.emit('opencore:vehicle:modified', 'all', { networkId, mods: defaultMods, }) diff --git a/src/runtime/server/apis/vehicles.api.ts b/src/runtime/server/apis/vehicles.api.ts index c9ca54b..4ec19fb 100644 --- a/src/runtime/server/apis/vehicles.api.ts +++ b/src/runtime/server/apis/vehicles.api.ts @@ -1,14 +1,12 @@ import { inject, injectable } from 'tsyringe' import { IHasher } from '../../../adapters/contracts/IHasher' -import { - IPlatformCapabilities, - PlatformFeatures, -} from '../../../adapters/contracts/IPlatformCapabilities' import { EventsAPI } from '../../../adapters/contracts/transport/events.api' import { IEntityServer } from '../../../adapters/contracts/server/IEntityServer' -import { IPlayerServer } from '../../../adapters/contracts/server/IPlayerServer' +import { IVehicleLifecycleServer } from '../../../adapters/contracts/server/vehicle-lifecycle/IVehicleLifecycleServer' import { IVehicleServer } from '../../../adapters/contracts/server/IVehicleServer' import { coreLogger } from '../../../kernel/logger' +import { Vector3 } from '../../../kernel/utils/vector3' +import { Player } from '../entities/player' import { Vehicle, type VehicleAdapters } from '../entities/vehicle' import { SerializedVehicleData, @@ -17,6 +15,14 @@ import { } from '../types/vehicle.types' import { Players } from '../ports/players.api-port' +export interface VehicleCreateForPlayerOptions + extends Omit { + offset?: Vector3 + seatIndex?: number +} + +const DEFAULT_PLAYER_VEHICLE_OFFSET: Vector3 = { x: 0, y: 3, z: 0 } + /** * Server-side service for managing vehicle entities. * @@ -46,10 +52,9 @@ export class Vehicles { @inject(Players as any) private readonly playerDirectory: Players, @inject(IEntityServer as any) private readonly entityServer: IEntityServer, @inject(IVehicleServer as any) private readonly vehicleServer: IVehicleServer, - @inject(IPlatformCapabilities as any) - private readonly platformCapabilities: IPlatformCapabilities, + @inject(IVehicleLifecycleServer as any) + private readonly vehicleLifecycle: IVehicleLifecycleServer, @inject(IHasher as any) private readonly hasher: IHasher, - @inject(IPlayerServer as any) private readonly playerServer: IPlayerServer, @inject(EventsAPI as any) private readonly events: EventsAPI<'server'>, ) { this.vehicleAdapters = { @@ -87,32 +92,14 @@ export class Vehicles { const modelHash = typeof model === 'string' ? this.hasher.getHashKey(model) : model - const serverVehicleCreationEnabled = - this.platformCapabilities.getConfig('enableServerVehicleCreation') ?? - this.platformCapabilities.isFeatureSupported(PlatformFeatures.SERVER_ENTITIES) - - if (!serverVehicleCreationEnabled) { - const profile = this.platformCapabilities.getConfig('gameProfile') ?? 'unknown' - return { - networkId: 0, - handle: 0, - success: false, - error: `Server vehicle creation is disabled for profile '${profile}'`, - } - } - - const vehicleType = - this.platformCapabilities.getConfig('defaultVehicleType') ?? 'automobile' - const handle = this.vehicleServer.createServerSetter( + const { handle, networkId } = await this.vehicleLifecycle.create({ + model: typeof model === 'string' ? model : String(model), modelHash, - vehicleType, - position.x, - position.y, - position.z, + position, heading, - ) + }) - if (!handle || handle === 0) { + if (!Number.isFinite(handle) || handle < 0) { coreLogger.error('Failed to create vehicle', { model, position }) return { networkId: 0, @@ -126,8 +113,6 @@ export class Vehicles { await new Promise((resolve) => setTimeout(resolve, 0)) } - const networkId = this.vehicleServer.getNetworkIdFromEntity(handle) - const vehicleOwnership = { clientID: ownership?.clientID, accountID: ownership?.accountID, @@ -173,11 +158,16 @@ export class Vehicles { } this.vehiclesByNetworkId.set(networkId, vehicle) + let ownershipComp: string = vehicleOwnership.type + + if (vehicleOwnership.type === 'player') { + ownershipComp = `${vehicleOwnership.type}:${ownership?.clientID}` + } coreLogger.info('Vehicle created', { networkId, model, - ownership: vehicleOwnership.type, + ownership: ownershipComp, persistent, totalVehicles: this.vehiclesByNetworkId.size, }) @@ -213,10 +203,10 @@ export class Vehicles { * @returns Spawn result */ async createForPlayer( - clientID: number, - options: VehicleCreateOptions, + playerOrClientID: Player | number, + options: VehicleCreateForPlayerOptions, ): Promise { - const player = this.playerDirectory.getByClient(clientID) + const player = this.resolvePlayer(playerOrClientID) if (!player) { return { networkId: 0, @@ -227,21 +217,53 @@ export class Vehicles { } const ownership = { - clientID, + clientID: player.clientID, accountID: player.accountID, type: 'player' as const, } + const spawnPosition = this.getSpawnPositionForPlayer(player, options.offset) + const spawnHeading = options.heading ?? player.getHeading() + const result = await this.create({ ...options, + position: spawnPosition, + heading: spawnHeading, ownership, }) if (result.success) { - this.events.emit('opencore:vehicle:warpInto', clientID, result.networkId, -1) + const seatIndex = options.seatIndex ?? -1 + await Promise.resolve( + this.vehicleLifecycle.warpPlayerIntoVehicle({ + playerSrc: player.clientID.toString(), + networkId: result.networkId, + seatIndex, + }), + ) } return result } + private resolvePlayer(playerOrClientID: Player | number): Player | undefined { + return typeof playerOrClientID === 'number' + ? this.playerDirectory.getByClient(playerOrClientID) + : playerOrClientID + } + + private getSpawnPositionForPlayer( + player: Player, + offset: Vector3 = DEFAULT_PLAYER_VEHICLE_OFFSET, + ): Vector3 { + const position = player.getPosition() + const headingRadians = (player.getHeading() * Math.PI) / 180 + + return { + x: position.x + Math.sin(headingRadians) * offset.y + Math.cos(headingRadians) * offset.x, + y: position.y + Math.cos(headingRadians) * offset.y - Math.sin(headingRadians) * offset.x, + z: position.z + offset.z, + } + } + /** * Retrieves a vehicle by its Network ID. * @@ -387,10 +409,7 @@ export class Vehicles { const player = this.playerDirectory.getByClient(clientID) if (!player) return false - const playerPed = this.playerServer.getPed(player.clientID.toString()) - if (!playerPed || playerPed === 0) return false - - const playerPos = this.entityServer.getCoords(playerPed) + const playerPos = player.getPosition() const vehiclePos = vehicle.position const distance = Math.sqrt( diff --git a/src/runtime/server/bootstrap.ts b/src/runtime/server/bootstrap.ts index f277af6..87d0319 100644 --- a/src/runtime/server/bootstrap.ts +++ b/src/runtime/server/bootstrap.ts @@ -1,8 +1,10 @@ -import { IEngineEvents } from '../../adapters' -import { registerServerCapabilities } from '../../adapters/register-capabilities' +import { IEngineEvents, IPlatformContext } from '../../adapters' +import { IExports } from '../../adapters/contracts/IExports' import { EventsAPI } from '../../adapters/contracts/transport/events.api' import { GLOBAL_CONTAINER, MetadataScanner } from '../../kernel/di/index' import { getLogLevel, LogLevelLabels, loggers } from '../../kernel/logger' +import { createNodeServerAdapter } from './adapter/node-server-adapter' +import { installServerAdapter } from './adapter/registry' import { PrincipalProviderContract } from './contracts/index' import { BinaryServiceMetadata, getServerBinaryServiceRegistry } from './decorators/binaryService' import { getServerControllerRegistry } from './decorators/controller' @@ -109,9 +111,14 @@ export async function initServer( scope: getFrameworkModeScope(ctx.mode), }) - // Register platform-specific capabilities (adapters) - await registerServerCapabilities() - loggers.bootstrap.debug('Platform capabilities registered') + // Register platform-specific capabilities through the selected server adapter. + await installServerAdapter(options.adapter ?? createNodeServerAdapter()) + + const platformContext = GLOBAL_CONTAINER.resolve(IPlatformContext as any) as IPlatformContext + loggers.bootstrap.debug('Loading server Adapter ', { + adapter: options.adapter?.name ?? 'node', + game: platformContext.gameProfile, + }) const dependenciesToWaitFor: Promise[] = [] if (ctx.mode === 'RESOURCE') { @@ -230,6 +237,8 @@ function createCoreDependency(coreName: string): Promise { } // 1. Register listener FIRST (before any requests) + const exportsService = GLOBAL_CONTAINER.resolve(IExports as any) as IExports + const onReady = () => { if (!resolved) { loggers.bootstrap.debug(`Core '${coreName}' detected via 'core:ready' event!`) @@ -244,8 +253,9 @@ function createCoreDependency(coreName: string): Promise { const checkReady = () => { if (resolved) return try { - const globalExports = (globalThis as any).exports - const isReady = globalExports?.[coreName]?.isCoreReady?.() + const isReady = exportsService + .getResource<{ isCoreReady?: () => boolean }>(coreName) + ?.isCoreReady?.() loggers.bootstrap.debug(`Polling isCoreReady export: ${isReady}`) if (isReady === true) { loggers.bootstrap.debug(`Core '${coreName}' detected via isCoreReady export!`) diff --git a/src/runtime/server/controllers/channel.controller.ts b/src/runtime/server/controllers/channel.controller.ts index 12b18d7..726147d 100644 --- a/src/runtime/server/controllers/channel.controller.ts +++ b/src/runtime/server/controllers/channel.controller.ts @@ -318,7 +318,7 @@ export class ChannelExportController { /** * Cleans up channels owned by a resource when it stops. */ - @OnRuntimeEvent('onServerResourceStop') + @OnRuntimeEvent(RUNTIME_EVENTS.serverResourceStop) onResourceStop(resourceName: string) { const channelsToDelete: string[] = [] @@ -340,3 +340,4 @@ export class ChannelExportController { } } } +import { RUNTIME_EVENTS } from '../../../adapters/contracts/runtime' diff --git a/src/runtime/server/controllers/command-export.controller.ts b/src/runtime/server/controllers/command-export.controller.ts index 437f164..e206886 100644 --- a/src/runtime/server/controllers/command-export.controller.ts +++ b/src/runtime/server/controllers/command-export.controller.ts @@ -307,7 +307,7 @@ export class CommandExportController implements InternalCommandsExports { if (!allowed) { const errorMessage = message || `Rate limit exceeded for command: ${commandName}` if (onExceed === 'KICK') { - DropPlayer(player.clientID.toString(), errorMessage) + player.kick(errorMessage) } throw new SecurityError(onExceed || 'LOG', errorMessage, { clientID: player.clientID }) } diff --git a/src/runtime/server/controllers/player-export.controller.ts b/src/runtime/server/controllers/player-export.controller.ts index 4f77cbc..d409c8e 100644 --- a/src/runtime/server/controllers/player-export.controller.ts +++ b/src/runtime/server/controllers/player-export.controller.ts @@ -1,6 +1,7 @@ import { inject } from 'tsyringe' import { Controller } from '../decorators/controller' import { Export } from '../decorators/export' +import { serializeServerPlayerData } from '../adapter/serialization' import { Players } from '../ports/players.api-port' import { InternalPlayerExports, SerializedPlayerData } from '../types/core-exports.types' import { LinkedID } from '../services' @@ -31,24 +32,24 @@ export class PlayerExportController implements InternalPlayerExports { @Export() getPlayerData(clientID: number): SerializedPlayerData | null { const player = this.playerService.getByClient(clientID) - return player?.serialize() ?? null + return player ? serializeServerPlayerData(player) : null } @Export() getManyData(clientIds: number[]): SerializedPlayerData[] { - return this.playerService.getMany(clientIds).map((p) => p.serialize()) + return this.playerService.getMany(clientIds).map((player) => serializeServerPlayerData(player)) } @Export() getAllPlayersData(): SerializedPlayerData[] { - return this.playerService.getAll().map((p) => p.serialize()) + return this.playerService.getAll().map((player) => serializeServerPlayerData(player)) } @Export() getPlayerByAccountId(accountId: string): SerializedPlayerData | null { const players = this.playerService.getAll() const player = players.find((p) => p.accountID === accountId) - return player?.serialize() ?? null + return player ? serializeServerPlayerData(player) : null } @Export() diff --git a/src/runtime/server/controllers/session.controller.ts b/src/runtime/server/controllers/session.controller.ts index 279f339..e5d8c20 100644 --- a/src/runtime/server/controllers/session.controller.ts +++ b/src/runtime/server/controllers/session.controller.ts @@ -1,3 +1,4 @@ +import { RUNTIME_EVENTS } from '../../../adapters/contracts/runtime' import { inject } from 'tsyringe' import { loggers } from '../../../kernel/logger' import { emitFrameworkEvent } from '../bus/internal-event.bus' @@ -19,7 +20,7 @@ export class SessionController { private readonly persistance: PlayerPersistenceService, ) {} - @OnRuntimeEvent('playerJoining') + @OnRuntimeEvent(RUNTIME_EVENTS.playerJoining) public async onPlayerJoining( clientId: number, identifiers?: Record, @@ -49,7 +50,7 @@ export class SessionController { }) } - @OnRuntimeEvent('playerDropped') + @OnRuntimeEvent(RUNTIME_EVENTS.playerDropped) public async onPlayerDropped(clientId: number): Promise { const player = this.playerDirectory.getByClient(clientId) diff --git a/src/runtime/server/controllers/vehicle.controller.ts b/src/runtime/server/controllers/vehicle.controller.ts index 7e6deed..084ab69 100644 --- a/src/runtime/server/controllers/vehicle.controller.ts +++ b/src/runtime/server/controllers/vehicle.controller.ts @@ -1,4 +1,5 @@ import { inject } from 'tsyringe' +import { EventsAPI } from '../../../adapters/contracts/transport/events.api' import { Controller, OnNet } from '../decorators' import { Player } from '../entities/player' import { Vehicles } from '../apis/vehicles.api' @@ -11,7 +12,10 @@ import { Vehicles } from '../apis/vehicles.api' */ @Controller() export class VehicleController { - constructor(@inject(Vehicles) private readonly vehicleService: Vehicles) {} + constructor( + @inject(Vehicles) private readonly vehicleService: Vehicles, + @inject(EventsAPI as any) private readonly events: EventsAPI<'server'>, + ) {} /** * Handles client request to get vehicle data. */ @@ -19,12 +23,12 @@ export class VehicleController { handleGetData(player: Player, networkId: number) { const vehicle = this.vehicleService.getByNetworkId(networkId) if (!vehicle) { - emitNet('opencore:vehicle:dataResult', player.clientID, null) + this.events.emit('opencore:vehicle:dataResult', player.clientID, null) return null } const data = vehicle.serialize() - emitNet('opencore:vehicle:dataResult', player.clientID, data) + this.events.emit('opencore:vehicle:dataResult', player.clientID, data) return data } @@ -37,7 +41,7 @@ export class VehicleController { const vehicles = this.vehicleService.getPlayerVehicles(player.clientID) const serialized = vehicles.map((v) => v.serialize()) - emitNet('opencore:vehicle:playerVehiclesResult', player.clientID, serialized) + this.events.emit('opencore:vehicle:playerVehiclesResult', player.clientID, serialized) return serialized } diff --git a/src/runtime/server/core.ts b/src/runtime/server/core.ts index cea9e0a..39e031c 100644 --- a/src/runtime/server/core.ts +++ b/src/runtime/server/core.ts @@ -8,6 +8,7 @@ import { type ServerInitOptions, type ServerRuntimeOptions, } from './runtime' +import { OpenCoreServerAdapter } from './adapter' export let _mode: FrameworkMode @@ -15,11 +16,19 @@ export interface OpenCoreInitOptions extends ServerInitOptions { plugins?: OpenCorePlugin[] } +let _pendingAdapter: OpenCoreServerAdapter | undefined + +export function useAdapter(adapter: OpenCoreServerAdapter): void { + _pendingAdapter = adapter +} + function createConfigAccessor(options: ServerRuntimeOptions) { + const runtimeOptions = { ...options } + return { get(key: string): T | undefined { const segments = key.split('.').filter(Boolean) - let current: unknown = options + let current: unknown = runtimeOptions for (const segment of segments) { if (typeof current !== 'object' || current === null) { @@ -34,6 +43,10 @@ function createConfigAccessor(options: ServerRuntimeOptions) { } export async function init(options: OpenCoreInitOptions) { + if (!options.adapter && _pendingAdapter) { + options = { ...options, adapter: _pendingAdapter } + } + const resolved: ServerRuntimeOptions = resolveRuntimeOptions(options) _mode = resolved.mode diff --git a/src/runtime/server/decorators/onRuntimeEvent.ts b/src/runtime/server/decorators/onRuntimeEvent.ts index 58cc540..7f21ca8 100644 --- a/src/runtime/server/decorators/onRuntimeEvent.ts +++ b/src/runtime/server/decorators/onRuntimeEvent.ts @@ -1,3 +1,4 @@ +import type { RuntimeEventName } from '../../../adapters/contracts/runtime' import { METADATA_KEYS } from '../system/metadata-server.keys' /** @@ -24,7 +25,7 @@ import { METADATA_KEYS } from '../system/metadata-server.keys' * } * ``` */ -export function OnRuntimeEvent(event: string) { +export function OnRuntimeEvent(event: RuntimeEventName) { return (target: any, propertyKey: string) => { Reflect.defineMetadata(METADATA_KEYS.RUNTIME_EVENT, { event }, target, propertyKey) } diff --git a/src/runtime/server/entities/npc.ts b/src/runtime/server/entities/npc.ts index a615120..99ade35 100644 --- a/src/runtime/server/entities/npc.ts +++ b/src/runtime/server/entities/npc.ts @@ -1,5 +1,6 @@ import { NativeHandle } from 'src/runtime/core/nativehandle' import { IEntityServer } from '../../../adapters/contracts/server/IEntityServer' +import { INpcLifecycleServer } from '../../../adapters/contracts/server/npc-lifecycle/INpcLifecycleServer' import { IPedServer } from '../../../adapters/contracts/server/IPedServer' import { Vector3 } from '../../../kernel/utils/vector3' import { BaseEntity } from '../../core/entity' @@ -9,6 +10,7 @@ import { SerializedNpcData } from '../types/npc.types' export interface NpcAdapters { entityServer: IEntityServer pedServer: IPedServer + npcLifecycle: INpcLifecycleServer } /** @@ -184,7 +186,7 @@ export class NPC extends BaseEntity implements Spatial, NativeHandle { */ setRoutingBucket(bucket: number): void { if (!this.exists) return - this.adapters.entityServer.setRoutingBucket(this.session.handle, bucket) + this.adapters.entityServer.setDimension(this.session.handle, bucket) this.session.routingBucket = bucket this._dimension = bucket } @@ -195,7 +197,7 @@ export class NPC extends BaseEntity implements Spatial, NativeHandle { * @returns Routing bucket. */ getRoutingBucket(): number { - return this.adapters.entityServer.getRoutingBucket(this.session.handle) + return this.adapters.entityServer.getDimension(this.session.handle) } /** @@ -326,7 +328,7 @@ export class NPC extends BaseEntity implements Spatial, NativeHandle { */ delete(): void { if (!this.exists) return - this.adapters.pedServer.delete(this.session.handle) + void this.adapters.npcLifecycle.delete({ handle: this.session.handle }) } /** diff --git a/src/runtime/server/entities/player.ts b/src/runtime/server/entities/player.ts index 19a4af3..99b30fb 100644 --- a/src/runtime/server/entities/player.ts +++ b/src/runtime/server/entities/player.ts @@ -1,8 +1,16 @@ import { IPlayerInfo } from '../../../adapters' import { EventsAPI } from '../../../adapters/contracts/transport/events.api' import { IEntityServer } from '../../../adapters/contracts/server/IEntityServer' +import { IPlayerLifecycleServer } from '../../../adapters/contracts/server/player-lifecycle/IPlayerLifecycleServer' +import { IPlayerStateSyncServer } from '../../../adapters/contracts/server/player-state/IPlayerStateSyncServer' import { IPlayerServer } from '../../../adapters/contracts/server/IPlayerServer' +import type { + RespawnPlayerRequest, + SpawnPlayerRequest, + TeleportPlayerRequest, +} from '../../../adapters/contracts/server/player-lifecycle/types' import type { PlayerIdentifier } from '../../../adapters/contracts/types/identifier' +import { loggers } from '../../../kernel/logger' import { Vector3 } from '../../../kernel/utils/vector3' import { BaseEntity } from '../../core/entity' import { Spatial } from '../../core/spatial' @@ -18,6 +26,8 @@ import { NativeHandle } from 'src/runtime/core/nativehandle' export interface PlayerAdapters { playerInfo: IPlayerInfo playerServer: IPlayerServer + playerLifecycle: IPlayerLifecycleServer + playerStateSync: IPlayerStateSyncServer entityServer: IEntityServer events: EventsAPI<'server'> defaultSpawnModel: string @@ -36,6 +46,7 @@ export interface PlayerAdapters { */ export class Player extends BaseEntity implements Spatial, NativeHandle { private _position: Vector3 + private _model: string | undefined /** * Creates a new Player entity instance. @@ -174,7 +185,15 @@ export class Player extends BaseEntity implements Spatial, NativeHandle { * @param vector - The target coordinates (x, y, z). */ teleport(vector: Vector3): void { - this.emit('opencore:spawner:teleport', vector) + const request: TeleportPlayerRequest = { position: vector } + void Promise.resolve( + this.adapters.playerLifecycle.teleport(this.clientID.toString(), request), + ).catch((error: unknown) => { + loggers.spawn.error('Failed to teleport player', { + clientID: this.clientID, + error: error instanceof Error ? error.message : String(error), + }) + }) } /** @@ -184,7 +203,27 @@ export class Player extends BaseEntity implements Spatial, NativeHandle { * @param model - The ped model to use (default from platform capabilities) */ spawn(vector: Vector3, model = this.adapters.defaultSpawnModel): void { - this.emit('opencore:spawner:spawn', { position: vector, model }) + const request: SpawnPlayerRequest = { position: vector, model } + void Promise.resolve( + this.adapters.playerLifecycle.spawn(this.clientID.toString(), request), + ).catch((error: unknown) => { + loggers.spawn.error('Failed to spawn player', { + clientID: this.clientID, + error: error instanceof Error ? error.message : String(error), + }) + }) + } + + respawn(vector: Vector3, model = this.adapters.defaultSpawnModel): void { + const request: RespawnPlayerRequest = { position: vector, model } + void Promise.resolve( + this.adapters.playerLifecycle.respawn(this.clientID.toString(), request), + ).catch((error: unknown) => { + loggers.spawn.error('Failed to respawn player', { + clientID: this.clientID, + error: error instanceof Error ? error.message : String(error), + }) + }) } // ───────────────────────────────────────────────────────────────── @@ -204,36 +243,19 @@ export class Player extends BaseEntity implements Spatial, NativeHandle { // Dimension / Routing Bucket // ───────────────────────────────────────────────────────────────── - /** - * Sets the routing bucket (virtual world/dimension) for the player. - * Players in different buckets cannot see or interact with each other. - * - * @param bucket - The bucket ID (0 is the default shared world). - */ - setRoutingBucket(bucket: number): void { - this.adapters.playerServer.setRoutingBucket(this.clientID.toString(), bucket) - this._dimension = bucket - } - - /** - * Gets the current routing bucket. - */ - getRoutingBucket(): number { - return this.adapters.playerServer.getRoutingBucket(this.clientID.toString()) - } - /** * Sets the player dimension (alias for setRoutingBucket). */ override set dimension(value: number) { - this.setRoutingBucket(value) + this.adapters.playerServer.setDimension(this.clientID.toString(), value) + this._dimension = value } /** * Gets the player dimension (alias for getRoutingBucket). */ override get dimension(): number { - return this.getRoutingBucket() + return this.adapters.playerServer.getDimension(this.clientID.toString()) } // ───────────────────────────────────────────────────────────────── @@ -339,8 +361,7 @@ export class Player extends BaseEntity implements Spatial, NativeHandle { * Gets the current health of the player's ped. */ getHealth(): number { - const ped = this.adapters.playerServer.getPed(this.clientID.toString()) - return this.adapters.entityServer.getHealth(ped) + return this.adapters.playerStateSync.getHealth(this.clientID.toString()) } /** @@ -349,16 +370,14 @@ export class Player extends BaseEntity implements Spatial, NativeHandle { * @param health - Health value to set (platform-specific range). */ setHealth(health: number): void { - const ped = this.adapters.playerServer.getPed(this.clientID.toString()) - this.adapters.entityServer.setHealth(ped, health) + this.adapters.playerStateSync.setHealth(this.clientID.toString(), health) } /** * Gets the current armor of the player's ped. */ getArmor(): number { - const ped = this.adapters.playerServer.getPed(this.clientID.toString()) - return this.adapters.entityServer.getArmor(ped) + return this.adapters.playerStateSync.getArmor(this.clientID.toString()) } /** @@ -367,8 +386,16 @@ export class Player extends BaseEntity implements Spatial, NativeHandle { * @param armor - Armor value to set (typically 0-100). */ setArmor(armor: number): void { - const ped = this.adapters.playerServer.getPed(this.clientID.toString()) - this.adapters.entityServer.setArmor(ped, armor) + this.adapters.playerStateSync.setArmor(this.clientID.toString(), armor) + } + + get model(): string | undefined { + return this._model + } + + set model(model: string) { + this.adapters.playerServer.setModel(this.clientID.toString(), model) + this._model = model } /** diff --git a/src/runtime/server/entities/vehicle.ts b/src/runtime/server/entities/vehicle.ts index 980260b..bb329e7 100644 --- a/src/runtime/server/entities/vehicle.ts +++ b/src/runtime/server/entities/vehicle.ts @@ -220,13 +220,13 @@ export class Vehicle extends BaseEntity implements Spatial, NativeHandle { if (!this.exists) { return this.session.routingBucket } - return this.adapters.entityServer.getRoutingBucket(this.session.handle) + return this.adapters.entityServer.getDimension(this.session.handle) } /** Sets routing bucket and updates local dimension snapshot. */ setRoutingBucket(bucket: number): void { if (!this.exists) return - this.adapters.entityServer.setRoutingBucket(this.session.handle, bucket) + this.adapters.entityServer.setDimension(this.session.handle, bucket) this.session.routingBucket = bucket this._dimension = bucket } diff --git a/src/runtime/server/error-handler.ts b/src/runtime/server/error-handler.ts index e49f4b0..feb9fd1 100644 --- a/src/runtime/server/error-handler.ts +++ b/src/runtime/server/error-handler.ts @@ -1,9 +1,19 @@ +import { GLOBAL_CONTAINER } from '../../kernel/di/container' import { AppError, isAppError } from '../../kernel/error/app.error' import { ErrorOrigin } from '../../kernel/error/common.error-codes' import { loggers } from '../../kernel/logger' +import { EventsAPI } from '../../adapters/contracts/transport/events.api' import { CommandMetadata } from './decorators/command' +function getServerEvents(): EventsAPI<'server'> | null { + if (!GLOBAL_CONTAINER.isRegistered(EventsAPI as any)) { + return null + } + + return GLOBAL_CONTAINER.resolve(EventsAPI as any) as EventsAPI<'server'> +} + function normalizeError(error: unknown, origin: ErrorOrigin): AppError { if (isAppError(error)) { return error @@ -30,16 +40,19 @@ export function handleCommandError(error: unknown, meta: CommandMetadata, player }) if (playerId !== null) { + const events = getServerEvents() + if (!events) return + switch (appError.code) { case 'ECONOMY:INSUFFICIENT_FUNDS': case 'AUTH:PERMISSION_DENIED': case 'AUTH:UNAUTHORIZED': - emitNet('chat:addMessage', playerId, { + events.emit('chat:addMessage', playerId, { args: ['^1Error', appError.message], }) break default: - emitNet('chat:addMessage', playerId, { + events.emit('chat:addMessage', playerId, { args: ['^1Error', 'Ha ocurrido un error interno.'], }) break diff --git a/src/runtime/server/helpers/command-validation.helper.ts b/src/runtime/server/helpers/command-validation.helper.ts index 516c29a..ad8976d 100644 --- a/src/runtime/server/helpers/command-validation.helper.ts +++ b/src/runtime/server/helpers/command-validation.helper.ts @@ -3,7 +3,7 @@ import { AppError } from '../../../kernel' import { CommandMetadata } from '../decorators/command' import { Player } from '../entities' import { generateSchemaFromTypes } from '../system/schema-generator' -import { processTupleSchema } from './process-tuple-schema' +import { processTupleSchema } from '../../shared/helpers/process-tuple-schema' export async function validateAndExecuteCommand( meta: CommandMetadata, diff --git a/src/runtime/server/helpers/process-tuple-schema.ts b/src/runtime/server/helpers/process-tuple-schema.ts deleted file mode 100644 index ecf239f..0000000 --- a/src/runtime/server/helpers/process-tuple-schema.ts +++ /dev/null @@ -1,63 +0,0 @@ -import z from 'zod' - -/** - * Processes tuple schema validation with greedy handling for rest parameters. - * - * This function handles two cases: - * 1. If last parameter is ZodArray and there are MORE args than schema items, - * collect the extra args into the array position. - * 2. If last parameter is ZodString and there are MORE args than schema items, - * join the extra args into a single string. - * - * Examples: - * - handler(player, action: string, ...rest: string[]) with args ["hello", "world", "!"] - * → schema is [z.string(), z.array(z.string())] (2 items) - * → args has 3 items, so we group extra: ["hello", ["world", "!"]] - * - * - handler(player, command: string, args: string[]) with args ["vida", ["arg1"]] - * → schema is [z.string(), z.array(z.string())] (2 items) - * → args has 2 items, matches schema, no processing needed - */ -export function processTupleSchema(schema: z.ZodTuple, args: any[]): any[] { - const items = schema.description ? [] : ((schema as any)._def.items as z.ZodTypeAny[]) - - if (items.length === 0) { - return args - } - - const lastItem = items[items.length - 1] - const positionalCount = items.length - 1 - - // Case: More args than items (Greedy grouping) - if (args.length > items.length) { - // If last parameter is a string, join extra args with space - if (lastItem instanceof z.ZodString) { - const positional = args.slice(0, positionalCount) - const restString = args.slice(positionalCount).join(' ') - return [...positional, restString] - } - - // If last parameter is an array, we keep them as individual elements - // for the handler's spread operator (...args) or just as the array itself - // if ZodTuple is being used to parse. - // However, to avoid nesting [arg1, [arg2, arg3]], we return them flat - // if the handler expects a spread, OR we return the array if it's a single param. - if (lastItem instanceof z.ZodArray) { - // For ZodTuple.parse() to work with a ZodArray at the end, - // it actually expects the array as a single element in that position. - const positional = args.slice(0, positionalCount) - const restArray = args.slice(positionalCount) - return [...positional, restArray] - } - } - - // Case: Exact match but last is array - if (args.length === items.length) { - if (lastItem instanceof z.ZodArray && !Array.isArray(args[positionalCount])) { - const positional = args.slice(0, positionalCount) - return [...positional, [args[positionalCount]]] - } - } - - return args -} diff --git a/src/runtime/server/implementations/local/player.local.ts b/src/runtime/server/implementations/local/player.local.ts index f65e873..f952f68 100644 --- a/src/runtime/server/implementations/local/player.local.ts +++ b/src/runtime/server/implementations/local/player.local.ts @@ -1,12 +1,15 @@ import { inject, injectable } from 'tsyringe' import { IPlayerInfo } from '../../../../adapters' -import { IPlatformCapabilities } from '../../../../adapters/contracts/IPlatformCapabilities' +import { IPlatformContext } from '../../../../adapters/contracts/IPlatformContext' import { EventsAPI } from '../../../../adapters/contracts/transport/events.api' import { IEntityServer } from '../../../../adapters/contracts/server/IEntityServer' +import { IPlayerLifecycleServer } from '../../../../adapters/contracts/server/player-lifecycle/IPlayerLifecycleServer' +import { IPlayerStateSyncServer } from '../../../../adapters/contracts/server/player-state/IPlayerStateSyncServer' import { IPlayerServer } from '../../../../adapters/contracts/server/IPlayerServer' import { loggers } from '../../../../kernel/logger' import { BaseEntity } from '../../../core/entity' import { WorldContext } from '../../../core/world' +import { createLocalServerPlayer } from '../../adapter/registry' import { Player, type PlayerAdapters } from '../../entities' import { Players } from '../../ports/players.api-port' import { PlayerSessionLifecyclePort } from '../../ports/internal/player-session-lifecycle.port' @@ -30,17 +33,22 @@ export class LocalPlayerImplementation implements Players, PlayerSessionLifecycl @inject(WorldContext) private readonly world: WorldContext, @inject(IPlayerInfo as any) private readonly playerInfo: IPlayerInfo, @inject(IPlayerServer as any) private readonly playerServer: IPlayerServer, + @inject(IPlayerLifecycleServer as any) + private readonly playerLifecycle: IPlayerLifecycleServer, + @inject(IPlayerStateSyncServer as any) + private readonly playerStateSync: IPlayerStateSyncServer, @inject(IEntityServer as any) private readonly entityServer: IEntityServer, @inject(EventsAPI as any) private readonly events: EventsAPI<'server'>, - @inject(IPlatformCapabilities as any) - private readonly platformCapabilities: IPlatformCapabilities, + @inject(IPlatformContext as any) + private readonly platformContext: IPlatformContext, ) { - const defaultSpawnModel = - this.platformCapabilities.getConfig('defaultSpawnModel') ?? 'mp_m_freemode_01' + const defaultSpawnModel = this.platformContext.defaultSpawnModel this.playerAdapters = { playerInfo: this.playerInfo, playerServer: this.playerServer, + playerLifecycle: this.playerLifecycle, + playerStateSync: this.playerStateSync, entityServer: this.entityServer, events: this.events, defaultSpawnModel, @@ -68,7 +76,7 @@ export class LocalPlayerImplementation implements Players, PlayerSessionLifecycl meta: {}, } - const player = new Player(session, this.playerAdapters) + const player = createLocalServerPlayer(session, this.playerAdapters) this.world.add(player) loggers.session.debug('Player session bound', { clientID, diff --git a/src/runtime/server/implementations/remote/channel.remote.ts b/src/runtime/server/implementations/remote/channel.remote.ts index 309143b..551123f 100644 --- a/src/runtime/server/implementations/remote/channel.remote.ts +++ b/src/runtime/server/implementations/remote/channel.remote.ts @@ -1,5 +1,6 @@ import { inject, injectable } from 'tsyringe' import { IExports } from '../../../../adapters/contracts/IExports' +import { IResourceInfo } from '../../../../adapters/contracts/IResourceInfo' import { loggers } from '../../../../kernel/logger' import { RGB } from '../../../../kernel/utils/rgb' import { Channel } from '../../concepts/channel' @@ -68,10 +69,11 @@ export class RemoteChannelImplementation extends Channels { constructor( @inject(IExports as any) private exportsService: IExports, + @inject(IResourceInfo as any) resourceInfo: IResourceInfo, private readonly players: Players, ) { super() - this.resourceName = GetCurrentResourceName() + this.resourceName = resourceInfo.getCurrentResourceName() } /** diff --git a/src/runtime/server/implementations/remote/command.remote.ts b/src/runtime/server/implementations/remote/command.remote.ts index d48d848..a5c6080 100644 --- a/src/runtime/server/implementations/remote/command.remote.ts +++ b/src/runtime/server/implementations/remote/command.remote.ts @@ -1,5 +1,6 @@ import { inject, injectable } from 'tsyringe' import { IExports } from '../../../../adapters/contracts/IExports' +import { IResourceInfo } from '../../../../adapters/contracts/IResourceInfo' import { AppError } from '../../../../kernel/error/app.error' import { loggers } from '../../../../kernel/logger' import { CommandMetadata } from '../../decorators/command' @@ -44,7 +45,10 @@ export class RemoteCommandImplementation extends CommandExecutionPort { return this.commands.get(commandName.toLowerCase())?.meta } - constructor(@inject(IExports as any) private exportsService: IExports) { + constructor( + @inject(IExports as any) private exportsService: IExports, + @inject(IResourceInfo as any) private readonly resourceInfo: IResourceInfo, + ) { super() } @@ -75,7 +79,7 @@ export class RemoteCommandImplementation extends CommandExecutionPort { */ register(metadata: CommandMetadata, handler: (...args: any[]) => any): void { const commandKey = metadata.command.toLowerCase() - const resourceName = GetCurrentResourceName() + const resourceName = this.resourceInfo.getCurrentResourceName() loggers.command.debug(`Registering command locally`, { command: metadata.command, @@ -143,7 +147,7 @@ export class RemoteCommandImplementation extends CommandExecutionPort { if (!entry) { loggers.command.error(`Handler not found for remote command: ${commandName}`, { command: commandName, - resource: GetCurrentResourceName(), + resource: this.resourceInfo.getCurrentResourceName(), }) throw new AppError('COMMAND:NOT_FOUND', `Command not found: ${commandName}`, 'server') } diff --git a/src/runtime/server/implementations/remote/player.remote.ts b/src/runtime/server/implementations/remote/player.remote.ts index f133e77..2ca47fa 100644 --- a/src/runtime/server/implementations/remote/player.remote.ts +++ b/src/runtime/server/implementations/remote/player.remote.ts @@ -1,10 +1,13 @@ import { inject, injectable } from 'tsyringe' import { IExports, IPlayerInfo } from '../../../../adapters' -import { IPlatformCapabilities } from '../../../../adapters/contracts/IPlatformCapabilities' +import { IPlatformContext } from '../../../../adapters/contracts/IPlatformContext' import { EventsAPI } from '../../../../adapters/contracts/transport/events.api' import { IEntityServer } from '../../../../adapters/contracts/server/IEntityServer' +import { IPlayerLifecycleServer } from '../../../../adapters/contracts/server/player-lifecycle/IPlayerLifecycleServer' +import { IPlayerStateSyncServer } from '../../../../adapters/contracts/server/player-state/IPlayerStateSyncServer' import { IPlayerServer } from '../../../../adapters/contracts/server/IPlayerServer' import { loggers } from '../../../../kernel/logger' +import { createLocalServerPlayer, createRemoteServerPlayer } from '../../adapter/registry' import { Player, type PlayerAdapters } from '../../entities' import { getRuntimeContext } from '../../runtime' import { InternalPlayerExports, SerializedPlayerData } from '../../types/core-exports.types' @@ -30,18 +33,23 @@ export class RemotePlayerImplementation extends Players { @inject(IPlayerInfo as any) private readonly playerInfo: IPlayerInfo, @inject(IExports as any) private readonly exportsService: IExports, @inject(IPlayerServer as any) private readonly playerServer: IPlayerServer, + @inject(IPlayerLifecycleServer as any) + private readonly playerLifecycle: IPlayerLifecycleServer, + @inject(IPlayerStateSyncServer as any) + private readonly playerStateSync: IPlayerStateSyncServer, @inject(IEntityServer as any) private readonly entityServer: IEntityServer, @inject(EventsAPI as any) private readonly events: EventsAPI<'server'>, - @inject(IPlatformCapabilities as any) - private readonly platformCapabilities: IPlatformCapabilities, + @inject(IPlatformContext as any) + private readonly platformContext: IPlatformContext, ) { super() - const defaultSpawnModel = - this.platformCapabilities.getConfig('defaultSpawnModel') ?? 'mp_m_freemode_01' + const defaultSpawnModel = this.platformContext.defaultSpawnModel this.playerAdapters = { playerInfo: this.playerInfo, playerServer: this.playerServer, + playerLifecycle: this.playerLifecycle, + playerStateSync: this.playerStateSync, entityServer: this.entityServer, events: this.events, defaultSpawnModel, @@ -65,30 +73,8 @@ export class RemotePlayerImplementation extends Players { return coreExports } - /** - * Creates a local Player instance from serialized data. - * - * @remarks - * The returned Player is hydrated with session data from CORE, - * including accountID, identifiers, metadata, and states. - */ private createPlayerFromData(data: SerializedPlayerData): Player { - const player = new Player( - { - clientID: data.clientID, - accountID: data.accountID, - identifiers: data.identifiers, - meta: data.meta, - }, - this.playerAdapters, - ) - - // Restore state flags - for (const state of data.states) { - player.addState(state) - } - - return player + return createRemoteServerPlayer(data, this.playerAdapters) } /** @@ -105,7 +91,7 @@ export class RemotePlayerImplementation extends Players { error: error instanceof Error ? error.message : String(error), }) // Fallback to basic player - return new Player({ clientID, meta: {} }, this.playerAdapters) + return createLocalServerPlayer({ clientID, meta: {} }, this.playerAdapters) } } diff --git a/src/runtime/server/library/create-server-library.ts b/src/runtime/server/library/create-server-library.ts index 48a4f7d..d94f297 100644 --- a/src/runtime/server/library/create-server-library.ts +++ b/src/runtime/server/library/create-server-library.ts @@ -1,4 +1,7 @@ +import { GLOBAL_CONTAINER } from '../../../kernel/di/container' import { coreLogger } from '../../../kernel/logger' +import { IEngineEvents } from '../../../adapters/contracts/IEngineEvents' +import { EventsAPI } from '../../../adapters/contracts/transport/events.api' import { buildLibraryEventId, createLibraryBase, @@ -25,6 +28,12 @@ export function createServerLibrary( const base = createLibraryBase(name, opts) const logger = coreLogger.server(`Library:${base.name}`) const emitInternal = base.emit + const engineEvents = GLOBAL_CONTAINER.isRegistered(IEngineEvents as any) + ? (GLOBAL_CONTAINER.resolve(IEngineEvents as any) as IEngineEvents) + : null + const netEvents = GLOBAL_CONTAINER.isRegistered(EventsAPI as any) + ? (GLOBAL_CONTAINER.resolve(EventsAPI as any) as EventsAPI<'server'>) + : null return { ...base, @@ -47,10 +56,17 @@ export function createServerLibrary( emitLibraryEvent(eventId, envelope) }, emitExternal(eventName, payload) { - emit(base.buildEventName(eventName), payload) + if (engineEvents) { + engineEvents.emit(base.buildEventName(eventName), payload) + return + } }, emitNetExternal(eventName, target, payload) { - emitNet(base.buildEventName(eventName), target, payload) + if (!netEvents) { + return + } + + netEvents.emit(base.buildEventName(eventName), target, payload) }, getLogger() { return logger diff --git a/src/runtime/server/runtime.ts b/src/runtime/server/runtime.ts index ad7a6f3..ea77ee4 100644 --- a/src/runtime/server/runtime.ts +++ b/src/runtime/server/runtime.ts @@ -162,6 +162,7 @@ export interface ServerRuntimeOptions { mode: FrameworkMode features: FrameworkFeatures coreResourceName: string + adapter?: import('./adapter').OpenCoreServerAdapter /** Development mode configuration (disabled in production) */ devMode?: DevModeConfig onDependency?: Hooks @@ -229,6 +230,9 @@ export interface ServerInitOptions { /** Runtime mode determining feature availability and provider sources */ mode: FrameworkMode + /** Optional runtime adapter for non-node server environments. */ + adapter?: import('./adapter').OpenCoreServerAdapter + /** * Feature configuration. * @@ -342,6 +346,7 @@ export function resolveRuntimeOptions(options: ServerInitOptions): ServerRuntime mode: options.mode, features, coreResourceName: options.coreResourceName ?? 'core', + adapter: options.adapter, devMode: options.devMode, onDependency: options.onDependency, } @@ -369,27 +374,6 @@ export function validateRuntimeOptions(options: ServerRuntimeOptions): void { throw new Error('[OpenCore] RESOURCE mode requires coreResourceName to be specified') } - // Determine which features need CORE exports in RESOURCE mode - const needsCoreExports = - mode === 'RESOURCE' && - (features.players.provider === 'core' || - features.commands.provider === 'core' || - features.principal.provider === 'core') - - // Validate coreResourceName exists if needed - if (mode === 'RESOURCE') { - const { coreResourceName } = options - - if (needsCoreExports) { - const core = (globalThis as any).exports?.[coreResourceName] - if (!core) { - throw new Error( - `[OpenCore] CORE resource '${coreResourceName}' not found. Ensure it is started before RESOURCE mode resources.`, - ) - } - } - } - const scope = getFrameworkModeScope(mode) for (const name of FEATURE_NAMES) { diff --git a/src/runtime/server/services/appearance.service.ts b/src/runtime/server/services/appearance.service.ts index 00db7d4..aa0033d 100644 --- a/src/runtime/server/services/appearance.service.ts +++ b/src/runtime/server/services/appearance.service.ts @@ -1,141 +1,8 @@ -import { inject, injectable } from 'tsyringe' -import { EventsAPI } from '../../../adapters/contracts/transport/events.api' -import { IPedAppearanceServer } from '../../../adapters/contracts/server/IPedAppearanceServer' -import { IPlayerServer } from '../../../adapters/contracts/server/IPlayerServer' +import { injectable } from 'tsyringe' import { AppearanceValidationResult, PlayerAppearance } from '../../../kernel/shared' -/** - * Server-side appearance management service. - * - * @remarks - * Handles validating and applying ped appearance data on the server. - * Provides security validation and server-authoritative appearance control. - * - * **Security Model:** - * - All appearance changes should be validated server-side - * - Server applies components/props directly (available natives) - * - Server emits events to client for client-only natives (headBlend, overlays, tattoos) - * - Client never sends appearance data directly to other clients - * - * **Persistence:** - * The framework does NOT handle persistence internally. - * After calling `applyAppearance`, you receive the validated data back. - * You decide when and where to save it (persistent storage, file, etc.). - * - * @example - * ```typescript - * // Apply appearance to a player - * const result = await appearanceService.applyAppearance(playerSrc, appearanceData) - * if (result.success) { - * // Save to your storage - * await myStorage.saveAppearance(playerId, result.appearance) - * } - * - * // Validate appearance without applying - * const validation = appearanceService.validateAppearance(appearanceData) - * if (!validation.valid) { - * console.log('Errors:', validation.errors) - * } - * ``` - */ @injectable() export class AppearanceService { - constructor( - @inject(IPedAppearanceServer as any) private readonly pedAdapter: IPedAppearanceServer, - @inject(IPlayerServer as any) private readonly playerServer: IPlayerServer, - @inject(EventsAPI as any) private readonly events: EventsAPI<'server'>, - ) {} - - /** - * Applies validated appearance to a player. - * - * @remarks - * This method: - * 1. Validates the appearance data - * 2. Applies server-side natives (components, props) - * 3. Emits event to client for client-only natives - * 4. Returns the validated appearance for persistence - * - * @param playerSrc - Player source/client ID - * @param appearance - Appearance data to apply - * @returns Result with success status and validated appearance - */ - async applyAppearance( - playerSrc: string, - appearance: PlayerAppearance, - ): Promise<{ success: boolean; appearance?: PlayerAppearance; errors?: string[] }> { - const validation = this.validateAppearance(appearance) - if (!validation.valid) { - return { success: false, errors: validation.errors } - } - - const ped = this.playerServer.getPed(playerSrc) - if (ped === 0) { - return { success: false, errors: ['Player ped not found'] } - } - - // Apply server-side natives (components and props) - this.applyServerSideAppearance(ped, appearance) - - // Emit event to client for client-only natives - this.events.emit('opencore:appearance:apply', parseInt(playerSrc, 10), appearance) - - return { success: true, appearance } - } - - /** - * Applies only components and props (server-side available natives). - * - * @remarks - * Use this for quick clothing changes without full appearance update. - * - * @param playerSrc - Player source/client ID - * @param appearance - Partial appearance with components/props only - * @returns Success status - */ - applyClothing( - playerSrc: string, - appearance: Pick, - ): boolean { - const ped = this.playerServer.getPed(playerSrc) - if (ped === 0) { - return false - } - - this.applyServerSideAppearance(ped, appearance) - return true - } - - /** - * Resets a player's appearance to default. - * - * @param playerSrc - Player source/client ID - * @returns Success status - */ - resetAppearance(playerSrc: string): boolean { - const ped = this.playerServer.getPed(playerSrc) - if (ped === 0) { - return false - } - - this.pedAdapter.setDefaultComponentVariation(ped) - - // Notify client to reset client-only appearance elements - this.events.emit('opencore:appearance:reset', parseInt(playerSrc, 10)) - - return true - } - - /** - * Validates appearance data without applying it. - * - * @remarks - * Use this to validate appearance data before storing or applying. - * All validation rules are enforced to prevent invalid/malicious data. - * - * @param appearance - Appearance data to validate - * @returns Validation result with errors if any - */ validateAppearance(appearance: Partial): AppearanceValidationResult { const errors: string[] = [] @@ -143,7 +10,6 @@ export class AppearanceService { return { valid: false, errors: ['Appearance data is null or undefined'] } } - // Validate components (0-11) if (appearance.components) { for (const [id, data] of Object.entries(appearance.components)) { const componentId = parseInt(id, 10) @@ -159,7 +25,6 @@ export class AppearanceService { } } - // Validate props (0-7) if (appearance.props) { for (const [id, data] of Object.entries(appearance.props)) { const propId = parseInt(id, 10) @@ -175,7 +40,6 @@ export class AppearanceService { } } - // Validate faceFeatures (0-19, values -1.0 to 1.0) if (appearance.faceFeatures) { for (const [id, value] of Object.entries(appearance.faceFeatures)) { const index = parseInt(id, 10) @@ -188,48 +52,36 @@ export class AppearanceService { } } - // Validate headBlend if (appearance.headBlend) { const hb = appearance.headBlend - if (typeof hb.shapeFirst !== 'number' || hb.shapeFirst < 0 || hb.shapeFirst > 45) { + if (typeof hb.shapeFirst !== 'number' || hb.shapeFirst < 0 || hb.shapeFirst > 45) errors.push('Invalid shapeFirst (must be 0-45)') - } - if (typeof hb.shapeSecond !== 'number' || hb.shapeSecond < 0 || hb.shapeSecond > 45) { + if (typeof hb.shapeSecond !== 'number' || hb.shapeSecond < 0 || hb.shapeSecond > 45) errors.push('Invalid shapeSecond (must be 0-45)') - } - if (typeof hb.skinFirst !== 'number' || hb.skinFirst < 0 || hb.skinFirst > 45) { + if (typeof hb.skinFirst !== 'number' || hb.skinFirst < 0 || hb.skinFirst > 45) errors.push('Invalid skinFirst (must be 0-45)') - } - if (typeof hb.skinSecond !== 'number' || hb.skinSecond < 0 || hb.skinSecond > 45) { + if (typeof hb.skinSecond !== 'number' || hb.skinSecond < 0 || hb.skinSecond > 45) errors.push('Invalid skinSecond (must be 0-45)') - } - if (typeof hb.shapeMix !== 'number' || hb.shapeMix < 0 || hb.shapeMix > 1) { + if (typeof hb.shapeMix !== 'number' || hb.shapeMix < 0 || hb.shapeMix > 1) errors.push('Invalid shapeMix (must be 0.0-1.0)') - } - if (typeof hb.skinMix !== 'number' || hb.skinMix < 0 || hb.skinMix > 1) { + if (typeof hb.skinMix !== 'number' || hb.skinMix < 0 || hb.skinMix > 1) errors.push('Invalid skinMix (must be 0.0-1.0)') - } - if (hb.shapeThird !== undefined && (hb.shapeThird < 0 || hb.shapeThird > 45)) { + if (hb.shapeThird !== undefined && (hb.shapeThird < 0 || hb.shapeThird > 45)) errors.push('Invalid shapeThird (must be 0-45)') - } - if (hb.skinThird !== undefined && (hb.skinThird < 0 || hb.skinThird > 45)) { + if (hb.skinThird !== undefined && (hb.skinThird < 0 || hb.skinThird > 45)) errors.push('Invalid skinThird (must be 0-45)') - } - if (hb.thirdMix !== undefined && (hb.thirdMix < 0 || hb.thirdMix > 1)) { + if (hb.thirdMix !== undefined && (hb.thirdMix < 0 || hb.thirdMix > 1)) errors.push('Invalid thirdMix (must be 0.0-1.0)') - } } - // Validate headOverlays (0-12) if (appearance.headOverlays) { for (const [id, overlay] of Object.entries(appearance.headOverlays)) { const overlayId = parseInt(id, 10) if (Number.isNaN(overlayId) || overlayId < 0 || overlayId > 12) { errors.push(`Invalid overlay ID: ${id} (must be 0-12)`) } - if (typeof overlay.index !== 'number' || overlay.index < 0) { + if (typeof overlay.index !== 'number' || overlay.index < 0) errors.push(`Invalid overlay index for ID ${id}`) - } if (typeof overlay.opacity !== 'number' || overlay.opacity < 0 || overlay.opacity > 1) { errors.push(`Invalid overlay opacity for ID ${id} (must be 0.0-1.0)`) } @@ -239,11 +91,9 @@ export class AppearanceService { } } - // Validate hairColor if (appearance.hairColor) { - if (typeof appearance.hairColor.colorId !== 'number' || appearance.hairColor.colorId < 0) { + if (typeof appearance.hairColor.colorId !== 'number' || appearance.hairColor.colorId < 0) errors.push('Invalid hair colorId') - } if ( typeof appearance.hairColor.highlightColorId !== 'number' || appearance.hairColor.highlightColorId < 0 @@ -252,7 +102,6 @@ export class AppearanceService { } } - // Validate eyeColor (0-31) if (appearance.eyeColor !== undefined) { if ( typeof appearance.eyeColor !== 'number' || @@ -263,24 +112,20 @@ export class AppearanceService { } } - // Validate tattoos if (appearance.tattoos) { if (!Array.isArray(appearance.tattoos)) { errors.push('Tattoos must be an array') } else { for (let i = 0; i < appearance.tattoos.length; i++) { const tattoo = appearance.tattoos[i] - if (!tattoo.collection || typeof tattoo.collection !== 'string') { + if (!tattoo.collection || typeof tattoo.collection !== 'string') errors.push(`Invalid tattoo collection at index ${i}`) - } - if (!tattoo.overlay || typeof tattoo.overlay !== 'string') { + if (!tattoo.overlay || typeof tattoo.overlay !== 'string') errors.push(`Invalid tattoo overlay at index ${i}`) - } } } } - // Validate model if (appearance.model !== undefined) { if (typeof appearance.model !== 'string' || appearance.model.length === 0) { errors.push('Invalid model (must be a non-empty string)') @@ -289,39 +134,4 @@ export class AppearanceService { return { valid: errors.length === 0, errors } } - - /** - * Applies server-side appearance natives (components and props only). - * - * @param ped - Ped entity handle - * @param appearance - Appearance data - */ - private applyServerSideAppearance( - ped: number, - appearance: Pick, - ): void { - // Apply components - if (appearance.components) { - for (const [componentId, data] of Object.entries(appearance.components)) { - this.pedAdapter.setComponentVariation( - ped, - parseInt(componentId, 10), - data.drawable, - data.texture, - 2, - ) - } - } - - // Apply props - if (appearance.props) { - for (const [propId, data] of Object.entries(appearance.props)) { - if (data.drawable === -1) { - this.pedAdapter.clearProp(ped, parseInt(propId, 10)) - } else { - this.pedAdapter.setPropIndex(ped, parseInt(propId, 10), data.drawable, data.texture, true) - } - } - } - } } diff --git a/src/runtime/server/services/services.register.ts b/src/runtime/server/services/services.register.ts index 01a48af..52702b0 100644 --- a/src/runtime/server/services/services.register.ts +++ b/src/runtime/server/services/services.register.ts @@ -45,27 +45,41 @@ export function registerServicesServer(ctx: RuntimeContext) { if (features.players.enabled) { if (features.players.provider === 'local' || mode === 'CORE') { - GLOBAL_CONTAINER.registerSingleton(LocalPlayerImplementation) - GLOBAL_CONTAINER.register(Players as any, { useToken: LocalPlayerImplementation }) - GLOBAL_CONTAINER.register(PlayerSessionLifecyclePort as any, { - useToken: LocalPlayerImplementation, - }) + if (!GLOBAL_CONTAINER.isRegistered(LocalPlayerImplementation)) { + GLOBAL_CONTAINER.registerSingleton(LocalPlayerImplementation) + } + if (!GLOBAL_CONTAINER.isRegistered(Players as any)) { + GLOBAL_CONTAINER.register(Players as any, { useToken: LocalPlayerImplementation }) + } + if (!GLOBAL_CONTAINER.isRegistered(PlayerSessionLifecyclePort as any)) { + GLOBAL_CONTAINER.register(PlayerSessionLifecyclePort as any, { + useToken: LocalPlayerImplementation, + }) + } } else { - GLOBAL_CONTAINER.registerSingleton(Players as any, RemotePlayerImplementation) + if (!GLOBAL_CONTAINER.isRegistered(Players as any)) { + GLOBAL_CONTAINER.registerSingleton(Players as any, RemotePlayerImplementation) + } } } if (mode === 'RESOURCE' && features.players.enabled) { - GLOBAL_CONTAINER.register(PlayerSessionLifecyclePort as any, { - useFactory: () => { - throw new Error('[OpenCore] PlayerSessionLifecyclePort is not available in RESOURCE mode') - }, - }) + if (!GLOBAL_CONTAINER.isRegistered(PlayerSessionLifecyclePort as any)) { + GLOBAL_CONTAINER.register(PlayerSessionLifecyclePort as any, { + useFactory: () => { + throw new Error('[OpenCore] PlayerSessionLifecyclePort is not available in RESOURCE mode') + }, + }) + } } if (features.sessionLifecycle.enabled && mode !== 'RESOURCE') { - GLOBAL_CONTAINER.registerSingleton(PlayerPersistenceService, PlayerPersistenceService) - GLOBAL_CONTAINER.registerSingleton(SessionRecoveryService, SessionRecoveryService) + if (!GLOBAL_CONTAINER.isRegistered(PlayerPersistenceService)) { + GLOBAL_CONTAINER.registerSingleton(PlayerPersistenceService, PlayerPersistenceService) + } + if (!GLOBAL_CONTAINER.isRegistered(SessionRecoveryService)) { + GLOBAL_CONTAINER.registerSingleton(SessionRecoveryService, SessionRecoveryService) + } } if (features.principal.enabled) { @@ -77,36 +91,54 @@ export function registerServicesServer(ctx: RuntimeContext) { DefaultPrincipalProvider, ) } - GLOBAL_CONTAINER.registerSingleton(LocalPrincipalService) - GLOBAL_CONTAINER.register(Authorization as any, { useToken: LocalPrincipalService }) + if (!GLOBAL_CONTAINER.isRegistered(LocalPrincipalService)) { + GLOBAL_CONTAINER.registerSingleton(LocalPrincipalService) + } + if (!GLOBAL_CONTAINER.isRegistered(Authorization as any)) { + GLOBAL_CONTAINER.register(Authorization as any, { useToken: LocalPrincipalService }) + } } else { // RESOURCE: Remote principal service delegates to CORE - GLOBAL_CONTAINER.registerSingleton(Authorization as any, RemotePrincipalImplementation) + if (!GLOBAL_CONTAINER.isRegistered(Authorization as any)) { + GLOBAL_CONTAINER.registerSingleton(Authorization as any, RemotePrincipalImplementation) + } } } if (features.commands.enabled) { if (features.commands.provider === 'local' || mode === 'CORE') { // CORE/STANDALONE: local command execution - GLOBAL_CONTAINER.registerSingleton(LocalCommandImplementation) - GLOBAL_CONTAINER.register(CommandExecutionPort as any, { - useToken: LocalCommandImplementation, - }) + if (!GLOBAL_CONTAINER.isRegistered(LocalCommandImplementation)) { + GLOBAL_CONTAINER.registerSingleton(LocalCommandImplementation) + } + if (!GLOBAL_CONTAINER.isRegistered(CommandExecutionPort as any)) { + GLOBAL_CONTAINER.register(CommandExecutionPort as any, { + useToken: LocalCommandImplementation, + }) + } } else { // RESOURCE: remote command execution (delegates to CORE) - GLOBAL_CONTAINER.registerSingleton(CommandExecutionPort as any, RemoteCommandImplementation) + if (!GLOBAL_CONTAINER.isRegistered(CommandExecutionPort as any)) { + GLOBAL_CONTAINER.registerSingleton(CommandExecutionPort as any, RemoteCommandImplementation) + } } } if (features.chat.enabled) { if (mode === 'RESOURCE') { // RESOURCE: remote channel management (delegates to CORE) - GLOBAL_CONTAINER.registerSingleton(Channels as any, RemoteChannelImplementation) + if (!GLOBAL_CONTAINER.isRegistered(Channels as any)) { + GLOBAL_CONTAINER.registerSingleton(Channels as any, RemoteChannelImplementation) + } } else { // CORE/STANDALONE: local channel management - GLOBAL_CONTAINER.registerSingleton(Channels as any, LocalChannelImplementation) + if (!GLOBAL_CONTAINER.isRegistered(Channels as any)) { + GLOBAL_CONTAINER.registerSingleton(Channels as any, LocalChannelImplementation) + } + } + if (!GLOBAL_CONTAINER.isRegistered(Chat)) { + GLOBAL_CONTAINER.registerSingleton(Chat) } - GLOBAL_CONTAINER.registerSingleton(Chat) } if (!GLOBAL_CONTAINER.isRegistered(Npcs)) { diff --git a/src/runtime/server/system/processors/netEvent.processor.ts b/src/runtime/server/system/processors/netEvent.processor.ts index f4042c9..b900900 100644 --- a/src/runtime/server/system/processors/netEvent.processor.ts +++ b/src/runtime/server/system/processors/netEvent.processor.ts @@ -12,7 +12,7 @@ import { import { SecurityHandlerContract } from '../../contracts/security/security-handler.contract' import { NetEventOptions } from '../../decorators' import { Player } from '../../entities' -import { processTupleSchema } from '../../helpers/process-tuple-schema' +import { processTupleSchema } from '../../../shared/helpers/process-tuple-schema' import { resolveMethod } from '../../helpers/resolve-method' import { Players } from '../../ports/players.api-port' import { METADATA_KEYS } from '../metadata-server.keys' diff --git a/src/runtime/server/system/processors/onRpc.processor.ts b/src/runtime/server/system/processors/onRpc.processor.ts index 81c3564..27715fe 100644 --- a/src/runtime/server/system/processors/onRpc.processor.ts +++ b/src/runtime/server/system/processors/onRpc.processor.ts @@ -5,7 +5,7 @@ import { RpcAPI } from '../../../../adapters/contracts/transport/rpc.api' import { type DecoratorProcessor } from '../../../../kernel/di/index' import { loggers } from '../../../../kernel/logger' import { Player } from '../../entities/player' -import { processTupleSchema } from '../../helpers/process-tuple-schema' +import { processTupleSchema } from '../../../shared/helpers/process-tuple-schema' import { resolveMethod } from '../../helpers/resolve-method' import { Players } from '../../ports/players.api-port' import { RpcHandlerOptions } from '../../decorators/onRPC' diff --git a/src/runtime/server/system/processors/runtimeEvent.processor.ts b/src/runtime/server/system/processors/runtimeEvent.processor.ts index 17f9d47..b709888 100644 --- a/src/runtime/server/system/processors/runtimeEvent.processor.ts +++ b/src/runtime/server/system/processors/runtimeEvent.processor.ts @@ -1,5 +1,6 @@ import { inject, injectable } from 'tsyringe' import { IEngineEvents } from '../../../../adapters/contracts/IEngineEvents' +import type { RuntimeEventName } from '../../../../adapters/contracts/runtime' import { type DecoratorProcessor } from '../../../../kernel/di/index' import { loggers } from '../../../../kernel/logger' import { resolveMethod } from '../../helpers/resolve-method' @@ -10,7 +11,7 @@ export class RuntimeEventProcessor implements DecoratorProcessor { readonly metadataKey = METADATA_KEYS.RUNTIME_EVENT constructor(@inject(IEngineEvents as any) private readonly engineEvents: IEngineEvents) {} - process(instance: any, methodName: string, metadata: { event: string }) { + process(instance: any, methodName: string, metadata: { event: RuntimeEventName }) { const result = resolveMethod( instance, methodName, @@ -20,7 +21,7 @@ export class RuntimeEventProcessor implements DecoratorProcessor { const { handler, handlerName } = result - this.engineEvents.on(metadata.event, (...args: any[]) => { + this.engineEvents.onRuntime(metadata.event, (...args: any[]) => { try { handler(...args) } catch (error) { diff --git a/src/runtime/server/types/core-exports.types.ts b/src/runtime/server/types/core-exports.types.ts index e8fea97..aee52a1 100644 --- a/src/runtime/server/types/core-exports.types.ts +++ b/src/runtime/server/types/core-exports.types.ts @@ -36,6 +36,12 @@ export interface SerializedPlayerData { /** Active state flags (dead, cuffed, etc.) */ states: string[] + + /** Optional adapter-owned payload used to hydrate extended Player instances. */ + adapter?: { + name: string + payload?: Record + } } /** diff --git a/src/runtime/shared/helpers/process-tuple-schema.ts b/src/runtime/shared/helpers/process-tuple-schema.ts new file mode 100644 index 0000000..d55f230 --- /dev/null +++ b/src/runtime/shared/helpers/process-tuple-schema.ts @@ -0,0 +1,36 @@ +import z from 'zod' + +export function processTupleSchema(schema: z.ZodTuple, args: unknown[]): unknown[] { + const tupleDef = schema._def as unknown as { items?: readonly z.ZodTypeAny[] } + const items = schema.description ? [] : [...(tupleDef.items ?? [])] + + if (items.length === 0) { + return args + } + + const lastItem = items[items.length - 1] + const positionalCount = items.length - 1 + + if (args.length > items.length) { + if (lastItem instanceof z.ZodString) { + const positional = args.slice(0, positionalCount) + const restString = args.slice(positionalCount).map(String).join(' ') + return [...positional, restString] + } + + if (lastItem instanceof z.ZodArray) { + const positional = args.slice(0, positionalCount) + const restArray = args.slice(positionalCount) + return [...positional, restArray] + } + } + + if (args.length === items.length) { + if (lastItem instanceof z.ZodArray && !Array.isArray(args[positionalCount])) { + const positional = args.slice(0, positionalCount) + return [...positional, [args[positionalCount]]] + } + } + + return args +} diff --git a/tests/helpers/di.helper.ts b/tests/helpers/di.helper.ts index 0950cd8..8358b93 100644 --- a/tests/helpers/di.helper.ts +++ b/tests/helpers/di.helper.ts @@ -1,5 +1,9 @@ import type { DependencyContainer } from 'tsyringe' import { container } from 'tsyringe' +import { __resetClientAdapterRegistryForTests } from '../../src/runtime/client/adapter/registry' +import { __resetClientProcessorRegistrationForTests } from '../../src/runtime/client/system/processors.register' +import { __resetClientRuntimeContextForTests } from '../../src/runtime/client/client-runtime' +import { __resetServerAdapterRegistryForTests } from '../../src/runtime/server/adapter/registry' /** * Resets the global DI container to a clean state. @@ -7,6 +11,10 @@ import { container } from 'tsyringe' */ export function resetContainer(): void { container.reset() + __resetClientAdapterRegistryForTests() + __resetClientProcessorRegistrationForTests() + __resetClientRuntimeContextForTests() + __resetServerAdapterRegistryForTests() } /** diff --git a/tests/helpers/player.helper.ts b/tests/helpers/player.helper.ts index a642622..78967ee 100644 --- a/tests/helpers/player.helper.ts +++ b/tests/helpers/player.helper.ts @@ -6,6 +6,8 @@ import { IEntityServer, type SetPositionOptions, } from '../../src/adapters/contracts/server/IEntityServer' +import { IPlayerLifecycleServer } from '../../src/adapters/contracts/server/player-lifecycle/IPlayerLifecycleServer' +import { IPlayerStateSyncServer } from '../../src/adapters/contracts/server/player-state/IPlayerStateSyncServer' import { IPlayerServer } from '../../src/adapters/contracts/server/IPlayerServer' import type { PlayerIdentifier } from '../../src/adapters/contracts/types/identifier' import { Vector3 } from '../../src/kernel' @@ -39,6 +41,8 @@ export class MockPlayerServer extends IPlayerServer { drop(_playerSrc: string, _reason: string): void {} + setModel(_playerSrc: string, _model: string): void {} + getIdentifier(playerSrc: string, identifierType: string): string | undefined { const identifiers = this.playerIdentifiers.get(playerSrc) return identifiers?.[identifierType] @@ -77,9 +81,9 @@ export class MockPlayerServer extends IPlayerServer { return '127.0.0.1:30120' } - setRoutingBucket(_playerSrc: string, _bucket: number): void {} + setDimension(_playerSrc: string, _bucket: number): void {} - getRoutingBucket(playerSrc: string): number { + getDimension(playerSrc: string): number { return this.routingBuckets.get(playerSrc) ?? 0 } @@ -146,9 +150,9 @@ export class MockEntityServer extends IEntityServer { setOrphanMode(_handle: number, _mode: number): void {} - setRoutingBucket(_handle: number, _bucket: number): void {} + setDimension(_handle: number, _bucket: number): void {} - getRoutingBucket(_handle: number): number { + getDimension(_handle: number): number { return 0 } @@ -182,11 +186,30 @@ export class MockEventsAPI extends EventsAPI<'server'> { emit(_event: string, _targetOrArg?: number | number[] | 'all' | any, ..._args: any[]): void {} } +export class MockPlayerLifecycleServer extends IPlayerLifecycleServer { + spawn(): void {} + teleport(): void {} + respawn(): void {} +} + +export class MockPlayerStateSyncServer extends IPlayerStateSyncServer { + getHealth(): number { + return 200 + } + setHealth(): void {} + getArmor(): number { + return 0 + } + setArmor(): void {} +} + /** * Shared mock instances for tests. */ export const mockPlayerInfo = new MockPlayerInfo() export const mockPlayerServer = new MockPlayerServer() +export const mockPlayerLifecycle = new MockPlayerLifecycleServer() +export const mockPlayerStateSync = new MockPlayerStateSyncServer() export const mockEntityServer = new MockEntityServer() export const mockEventsAPI = new MockEventsAPI() @@ -197,6 +220,8 @@ export function createMockPlayerAdapters(): PlayerAdapters { return { playerInfo: mockPlayerInfo, playerServer: mockPlayerServer, + playerLifecycle: mockPlayerLifecycle, + playerStateSync: mockPlayerStateSync, entityServer: mockEntityServer, events: mockEventsAPI, defaultSpawnModel: 'mp_m_freemode_01', diff --git a/tests/unit/runtime/client/adapter-bootstrap.test.ts b/tests/unit/runtime/client/adapter-bootstrap.test.ts new file mode 100644 index 0000000..677c2ff --- /dev/null +++ b/tests/unit/runtime/client/adapter-bootstrap.test.ts @@ -0,0 +1,124 @@ +import 'reflect-metadata' +import { beforeEach, describe, expect, it } from 'vitest' +import { Vector3 } from '../../../../src/kernel/utils/vector3' +import { IClientLocalPlayerBridge } from '../../../../src/runtime/client/adapter/local-player-bridge' +import { defineClientAdapter } from '../../../../src/runtime/client/adapter/client-adapter' +import { di } from '../../../../src/runtime/client/client-container' +import { initClientCore } from '../../../../src/runtime/client/client-bootstrap' +import { IClientRuntimeBridge } from '../../../../src/runtime/client/adapter/runtime-bridge' +import { getActiveClientAdapterName } from '../../../../src/runtime/client/adapter/registry' +import { getClientRuntimeContext } from '../../../../src/runtime/client/client-runtime' +import { WebView } from '../../../../src/runtime/client/webview-bridge' +import { resetContainer } from '../../../helpers/di.helper' + +class CustomRuntimeBridge extends IClientRuntimeBridge { + public readonly messages: string[] = [] + + getCurrentResourceName(): string { + return 'custom-resource' + } + + on(_eventName: string, _handler: (...args: any[]) => void | Promise): void {} + + registerCommand( + _commandName: string, + _handler: (...args: any[]) => void, + _restricted: boolean, + ): void {} + + registerKeyMapping( + _commandName: string, + _description: string, + _inputMapper: string, + _key: string, + ): void {} + + setTick(_handler: () => void | Promise): unknown { + return 0 + } + + clearTick(_handle: unknown): void {} + + getGameTimer(): number { + return 0 + } + + registerNuiCallback( + _eventName: string, + _handler: (data: any, cb: (response: unknown) => void) => void | Promise, + ): void {} + + sendNuiMessage(message: string): void { + this.messages.push(message) + } + + setNuiFocus(_hasFocus: boolean, _hasCursor: boolean): void {} + + setNuiFocusKeepInput(_keepInput: boolean): void {} + + registerExport(_exportName: string, _handler: (...args: any[]) => any): void {} +} + +class NoopLocalPlayerBridge extends IClientLocalPlayerBridge { + setPosition(_position: Vector3, _heading?: number): void {} +} + +describe('client adapter bootstrap', () => { + beforeEach(() => { + resetContainer() + }) + + it('installs the default node client adapter', async () => { + await initClientCore({ mode: 'STANDALONE' }) + + expect(getActiveClientAdapterName()).toBe('node') + expect(di.isRegistered(IClientRuntimeBridge as any)).toBe(true) + + const runtime = di.resolve(IClientRuntimeBridge as any) as IClientRuntimeBridge + expect(runtime.getCurrentResourceName()).toBe('default') + }) + + it('uses the active runtime bridge even when WebView is imported before init', async () => { + const runtime = new CustomRuntimeBridge() + const adapter = defineClientAdapter({ + name: 'custom-webview', + async register(ctx) { + const { NodeMessagingTransport } = await import( + '../../../../src/adapters/node/transport/adapter' + ) + ctx.bindMessagingTransport(new NodeMessagingTransport('client')) + ctx.bindInstance(IClientRuntimeBridge as any, runtime) + ctx.bindInstance(IClientLocalPlayerBridge as any, new NoopLocalPlayerBridge()) + }, + }) + + await initClientCore({ mode: 'STANDALONE', adapter }) + + WebView.send('open', { ok: true }) + + expect(runtime.messages).toHaveLength(1) + expect(runtime.messages[0]).toContain('"action":"open"') + expect(getClientRuntimeContext()?.resourceName).toBe('custom-resource') + }) + + it('throws when re-initialized with a different adapter', async () => { + const makeAdapter = (name: string) => + defineClientAdapter({ + name, + async register(ctx) { + const { NodeMessagingTransport } = await import( + '../../../../src/adapters/node/transport/adapter' + ) + ctx.bindMessagingTransport(new NodeMessagingTransport('client')) + ctx.bindInstance(IClientRuntimeBridge as any, new CustomRuntimeBridge()) + ctx.bindInstance(IClientLocalPlayerBridge as any, new NoopLocalPlayerBridge()) + }, + }) + + await initClientCore({ mode: 'STANDALONE', adapter: makeAdapter('alpha') }) + + await expect( + initClientCore({ mode: 'STANDALONE', adapter: makeAdapter('beta') }), + ).rejects.toThrow("does not match active adapter 'alpha'") + }) +}) diff --git a/tests/unit/runtime/library-api.example.test.ts b/tests/unit/runtime/library-api.example.test.ts index daa4612..7ffa025 100644 --- a/tests/unit/runtime/library-api.example.test.ts +++ b/tests/unit/runtime/library-api.example.test.ts @@ -1,16 +1,33 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { EventsAPI, IEngineEvents } from '../../../src/adapters' +import { GLOBAL_CONTAINER } from '../../../src/kernel/di/container' import { createClientLibrary } from '../../../src/runtime/client/library' +import { di } from '../../../src/runtime/client/client-container' import { createServerLibrary } from '../../../src/runtime/server/library' describe('library API wrappers', () => { beforeEach(() => { - vi.stubGlobal('emit', vi.fn()) - vi.stubGlobal('emitNet', vi.fn()) + GLOBAL_CONTAINER.reset() + di.reset() }) it('provides server wrapper with internal and external APIs', () => { - const characters = createServerLibrary('characters') + const emitEngine = vi.fn() + const emitNet = vi.fn() + + GLOBAL_CONTAINER.registerInstance( + IEngineEvents as any, + { emit: emitEngine, on: vi.fn() } as unknown as IEngineEvents, + ) + GLOBAL_CONTAINER.registerInstance( + EventsAPI as any, + { + on: vi.fn(), + emit: emitNet, + } as unknown as EventsAPI<'server'>, + ) + const characters = createServerLibrary('characters') const internalHandler = vi.fn() characters.on('created', internalHandler) characters.emit('created', { id: 'x' }) @@ -18,17 +35,22 @@ describe('library API wrappers', () => { characters.emitNetExternal('created', 1, { id: 'x' }) expect(internalHandler).toHaveBeenCalledWith({ id: 'x' }) - expect(emit).toHaveBeenCalledWith('opencore:characters:created', { id: 'x' }) + expect(emitEngine).toHaveBeenCalledWith('opencore:characters:created', { id: 'x' }) expect(emitNet).toHaveBeenCalledWith('opencore:characters:created', 1, { id: 'x' }) - - const logger = characters.getLogger() - logger.debug('server logger is available') expect(characters.side).toBe('server') }) it('provides client wrapper for namespaced server emission', () => { - const characters = createClientLibrary('characters') + const emitNet = vi.fn() + di.registerInstance( + EventsAPI as any, + { + on: vi.fn(), + emit: emitNet, + } as unknown as EventsAPI<'client'>, + ) + const characters = createClientLibrary('characters') characters.emitServer('select', { characterId: 'x' }) expect(emitNet).toHaveBeenCalledWith('opencore:characters:select', { characterId: 'x' }) diff --git a/tests/unit/server/adapter/server-adapter.test.ts b/tests/unit/server/adapter/server-adapter.test.ts new file mode 100644 index 0000000..4cd3cd4 --- /dev/null +++ b/tests/unit/server/adapter/server-adapter.test.ts @@ -0,0 +1,99 @@ +import 'reflect-metadata' +import { beforeEach, describe, expect, it } from 'vitest' +import { createMockPlayerAdapters } from '../../../helpers/player.helper' +import { resetContainer } from '../../../helpers/di.helper' +import { + createLocalServerPlayer, + createRemoteServerPlayer, + defineServerAdapter, + installServerAdapter, + serializeServerPlayerData, +} from '../../../../src/runtime/server/adapter' +import { Player } from '../../../../src/runtime/server/entities/player' +import type { PlayerSession } from '../../../../src/runtime/server/types/player-session.types' +import type { SerializedPlayerData } from '../../../../src/runtime/server/types/core-exports.types' + +class ExtendedPlayer extends Player { + get adapterKind(): string | undefined { + return this.getMeta('adapterKind') + } +} + +describe('server adapter registry', () => { + beforeEach(() => { + resetContainer() + }) + + it('creates and hydrates Player subclasses through the active adapter', async () => { + const adapter = defineServerAdapter({ + name: 'custom', + register(ctx) { + ctx.usePlayerAdapter({ + createLocal(session, deps) { + const player = new ExtendedPlayer(session, deps) + player.setMeta('adapterKind', 'local') + return player + }, + createRemote(data, deps) { + const player = new ExtendedPlayer( + { + clientID: data.clientID, + accountID: data.accountID, + identifiers: data.identifiers, + meta: data.meta, + }, + deps, + ) + + for (const state of data.states) { + player.addState(state) + } + + return player + }, + serialize(player) { + return { + adapterKind: (player as ExtendedPlayer).adapterKind, + } + }, + hydrate(player, payload) { + if (payload?.adapterKind) { + player.setMeta('adapterKind', payload.adapterKind) + } + }, + }) + }, + }) + + await installServerAdapter(adapter) + + const session: PlayerSession = { + clientID: 10, + meta: {}, + } + + const localPlayer = createLocalServerPlayer(session, createMockPlayerAdapters()) + expect(localPlayer).toBeInstanceOf(Player) + expect(localPlayer).toBeInstanceOf(ExtendedPlayer) + expect((localPlayer as ExtendedPlayer).adapterKind).toBe('local') + + const serialized = serializeServerPlayerData(localPlayer) + expect(serialized.adapter).toEqual({ + name: 'custom', + payload: { adapterKind: 'local' }, + }) + + const remoteData: SerializedPlayerData = { + clientID: 10, + meta: {}, + states: ['ready'], + adapter: serialized.adapter, + } + + const remotePlayer = createRemoteServerPlayer(remoteData, createMockPlayerAdapters()) + expect(remotePlayer).toBeInstanceOf(Player) + expect(remotePlayer).toBeInstanceOf(ExtendedPlayer) + expect(remotePlayer.hasState('ready')).toBe(true) + expect((remotePlayer as ExtendedPlayer).adapterKind).toBe('local') + }) +}) diff --git a/tests/unit/server/system/netEventProcessor.invalidPayloads.test.ts b/tests/unit/server/system/netEventProcessor.invalidPayloads.test.ts index 7c352ce..c9e06b8 100644 --- a/tests/unit/server/system/netEventProcessor.invalidPayloads.test.ts +++ b/tests/unit/server/system/netEventProcessor.invalidPayloads.test.ts @@ -8,6 +8,8 @@ import { NodeCapabilities } from '../../../../src/adapters/node/node-capabilitie import { NodeEvents } from '../../../../src/adapters/node/transport/node.events' import { NodePlayerServer } from '../../../../src/adapters/node/node-player-server' import { NodePlayerInfo } from '../../../../src/adapters/node/node-playerinfo' +import { NodePlayerLifecycleServer } from '../../../../src/runtime/server/adapter/node-player-lifecycle-server' +import { NodePlayerStateSyncServer } from '../../../../src/runtime/server/adapter/node-player-state-sync-server' import { WorldContext } from '../../../../src/runtime/core/world' import type { NetEventSecurityObserverContract } from '../../../../src/runtime/server/contracts/security/net-event-security-observer.contract' import type { SecurityHandlerContract } from '../../../../src/runtime/server/contracts/security/security-handler.contract' @@ -34,10 +36,12 @@ const eventsAbstract: EventsAPI<'server'> = { emit: vi.fn(), } as any +const nodeEvents = new NodeEvents() const playerInfo = new NodePlayerInfo() const playerServer = new NodePlayerServer() +const playerLifecycle = new NodePlayerLifecycleServer(nodeEvents as unknown as EventsAPI<'server'>) const entityServer = new NodeEntityServer() -const nodeEvents = new NodeEvents() +const playerStateSync = new NodePlayerStateSyncServer(playerServer, entityServer) const nodeCapabilities = new NodeCapabilities() describe('NetEventProcessor invalid payload resilience', () => { @@ -50,6 +54,8 @@ describe('NetEventProcessor invalid payload resilience', () => { new WorldContext(), playerInfo, playerServer, + playerLifecycle, + playerStateSync, entityServer, nodeEvents, nodeCapabilities, @@ -103,6 +109,8 @@ describe('NetEventProcessor invalid payload resilience', () => { new WorldContext(), playerInfo, playerServer, + playerLifecycle, + playerStateSync, entityServer, nodeEvents, nodeCapabilities, diff --git a/tsconfig.json b/tsconfig.json index bfe36ce..48071c0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,7 +15,7 @@ "baseUrl": ".", - "types": ["@citizenfx/server", "@citizenfx/client", "@types/node"], + "types": ["@types/node"], "noEmit": true }, "include": ["src", "tests", "examples"], diff --git a/tsconfig.test.json b/tsconfig.test.json index 70c0b16..f42353a 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "types": ["@citizenfx/server", "@citizenfx/client", "node", "vitest/globals"], + "types": ["node", "vitest/globals"], "noEmit": true, "experimentalDecorators": true, "emitDecoratorMetadata": true,