diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f539f52..98fc807 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,8 +23,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4.2.0 - with: - version: 10.13.1 - name: Setup Node uses: actions/setup-node@v4 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4f48c17..09ed777 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -23,8 +23,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4.2.0 - with: - version: 10.13.1 - name: Setup Node uses: actions/setup-node@v4 diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..cb2c84d --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +pnpm lint-staged diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 0000000..10bed33 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1 @@ +pnpm typecheck && pnpm test diff --git a/RELEASE.md b/RELEASE.md index 2bc79c5..2e3090b 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,58 +1,22 @@ -## OpenCore Framework v1.0.0-beta.1 - -### 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). - -### 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. - -### Breaking Changes - -- 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). +## OpenCore Framework v1.0.5 + +### Added +- Added new client adapter ports for camera, ped, vehicle, progress, spawn, local player, runtime bridge, and WebView integration, with matching node runtime implementations. +- Added support for WebView chat mode, richer client UI/runtime abstractions, and cleaner adapter-facing contracts/exports. +- Added server-side improvements for command handling, including command validation, default function parameter support, and standardized system event names. +- Added more coverage around parallel compute, vehicle modification, vehicle sync state, player state sync, adapters, and command execution flows. +- Added Husky pre-commit and pre-push hooks for local quality checks. + +### Changed +- Refactored client services to rely on explicit adapter ports instead of direct runtime assumptions, especially for camera, ped, progress, spawn, and vehicle flows. +- Refactored logging so logger writes use string log levels and runtime log domain labels are derived dynamically from the active resource. +- Refactored worker execution to use inline worker scripts with performance tracking in the parallel compute pipeline. +- Updated package/tooling setup to TypeScript 6 and refreshed package exports, scripts, and dependency configuration. + +### Fixed +- Fixed command schema handling so exported/remote commands support default parameters more reliably. +- Fixed transport/event contract alignment across node events and RPC layers. +- Fixed several test, lint, and export consistency issues while expanding automated coverage. ### 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`). +- This release covers the full `master...v1` delta and keeps the release notes compact by grouping related adapter/runtime refactors instead of listing each port separately. diff --git a/biome.json b/biome.json index baac75f..120a343 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.8/schema.json", "root": true, "files": { diff --git a/package.json b/package.json index da67d8e..ac18ae4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@open-core/framework", - "version": "1.0.0-beta.1", + "version": "1.0.5", "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": { @@ -55,12 +75,15 @@ "bench": "npx tsx benchmark/index.ts", "bench:core": "npx tsx benchmark/index.ts --core", "bench:load": "npx vitest run --project benchmark", - "bench:all": "npx tsx benchmark/index.ts --all" + "bench:all": "npx tsx benchmark/index.ts --all", + "validate": "pnpm check && pnpm typecheck && pnpm test", + "lint-staged": "lint-staged", + "prepare": "husky" }, "keywords": [ "framework", "opencore", - "cfx", + "ragemp", "citizenfx", "redm", "typescript", @@ -68,7 +91,7 @@ ], "author": "OpenCore Team", "license": "MPL-2.0", - "packageManager": "pnpm@10.13.1", + "packageManager": "pnpm@10.33.0", "peerDependencies": { "reflect-metadata": "^0.2.2", "tsyringe": "^4.10.0", @@ -78,18 +101,23 @@ "uuid": "^13.0.0" }, "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", + "@biomejs/biome": "^2.4.8", + "@types/node": "^25.5.0", + "@vitest/coverage-v8": "^4.1.1", + "dependency-cruiser": "^17.3.9", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", "graphviz": "^0.0.9", - "tinybench": "^2.9.0", + "husky": "^9.1.7", + "lint-staged": "^16.2.6", + "tinybench": "^6.0.0", "tsx": "^4.21.0", - "typescript": "^5.9.3", - "vitest": "^4.0.16" + "typescript": "^6.0.2", + "vitest": "^4.1.1" + }, + "lint-staged": { + "*.{js,cjs,mjs,ts,tsx,json,md}": [ + "biome check --write --no-errors-on-unmatched" + ] } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c8678cc..87caa04 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,26 +19,20 @@ importers: version: 13.0.0 zod: specifier: ^4.3.5 - version: 4.3.5 + version: 4.3.6 devDependencies: '@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 + specifier: ^2.4.8 + version: 2.4.8 '@types/node': - specifier: ^25.0.3 - version: 25.0.3 + specifier: ^25.5.0 + version: 25.5.0 '@vitest/coverage-v8': - specifier: ^4.0.16 - version: 4.0.16(vitest@4.0.16(@types/node@25.0.3)(tsx@4.21.0)) + specifier: ^4.1.1 + version: 4.1.1(vitest@4.1.1(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3))) dependency-cruiser: - specifier: ^17.3.6 - version: 17.3.6 + specifier: ^17.3.9 + version: 17.3.9 eslint-config-prettier: specifier: ^10.1.8 version: 10.1.8(eslint@9.39.1) @@ -48,18 +42,24 @@ importers: graphviz: specifier: ^0.0.9 version: 0.0.9 + husky: + specifier: ^9.1.7 + version: 9.1.7 + lint-staged: + specifier: ^16.2.6 + version: 16.4.0 tinybench: - specifier: ^2.9.0 - version: 2.9.0 + specifier: ^6.0.0 + version: 6.0.0 tsx: specifier: ^4.21.0 version: 4.21.0 typescript: - specifier: ^5.9.3 - version: 5.9.3 + specifier: ^6.0.2 + version: 6.0.2 vitest: - specifier: ^4.0.16 - version: 4.0.16(@types/node@25.0.3)(tsx@4.21.0) + specifier: ^4.1.1 + version: 4.1.1(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) packages: @@ -71,230 +71,228 @@ packages: resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} - '@babel/parser@7.28.5': - resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} engines: {node: '>=6.0.0'} hasBin: true - '@babel/types@7.28.5': - resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} '@bcoe/v8-coverage@1.0.2': resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} - '@biomejs/biome@2.3.11': - resolution: {integrity: sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ==} + '@biomejs/biome@2.4.8': + resolution: {integrity: sha512-ponn0oKOky1oRXBV+rlSaUlixUxf1aZvWC19Z41zBfUOUesthrQqL3OtiAlSB1EjFjyWpn98Q64DHelhA6jNlA==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@2.3.11': - resolution: {integrity: sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA==} + '@biomejs/cli-darwin-arm64@2.4.8': + resolution: {integrity: sha512-ARx0tECE8I7S2C2yjnWYLNbBdDoPdq3oyNLhMglmuctThwUsuzFWRKrHmIGwIRWKz0Mat9DuzLEDp52hGnrxGQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@2.3.11': - resolution: {integrity: sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg==} + '@biomejs/cli-darwin-x64@2.4.8': + resolution: {integrity: sha512-Jg9/PsB9vDCJlANE8uhG7qDhb5w0Ix69D7XIIc8IfZPUoiPrbLm33k2Ig3NOJ/7nb3UbesFz3D1aDKm9DvzjhQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@2.3.11': - resolution: {integrity: sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg==} + '@biomejs/cli-linux-arm64-musl@2.4.8': + resolution: {integrity: sha512-Zo9OhBQDJ3IBGPlqHiTISloo5H0+FBIpemqIJdW/0edJ+gEcLR+MZeZozcUyz3o1nXkVA7++DdRKQT0599j9jA==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [musl] - '@biomejs/cli-linux-arm64@2.3.11': - resolution: {integrity: sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g==} + '@biomejs/cli-linux-arm64@2.4.8': + resolution: {integrity: sha512-5CdrsJct76XG2hpKFwXnEtlT1p+4g4yV+XvvwBpzKsTNLO9c6iLlAxwcae2BJ7ekPGWjNGw9j09T5KGPKKxQig==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [glibc] - '@biomejs/cli-linux-x64-musl@2.3.11': - resolution: {integrity: sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw==} + '@biomejs/cli-linux-x64-musl@2.4.8': + resolution: {integrity: sha512-Gi8quv8MEuDdKaPFtS2XjEnMqODPsRg6POT6KhoP+VrkNb+T2ywunVB+TvOU0LX1jAZzfBr+3V1mIbBhzAMKvw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [musl] - '@biomejs/cli-linux-x64@2.3.11': - resolution: {integrity: sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg==} + '@biomejs/cli-linux-x64@2.4.8': + resolution: {integrity: sha512-PdKXspVEaMCQLjtZCn6vfSck/li4KX9KGwSDbZdgIqlrizJ2MnMcE3TvHa2tVfXNmbjMikzcfJpuPWH695yJrw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [glibc] - '@biomejs/cli-win32-arm64@2.3.11': - resolution: {integrity: sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw==} + '@biomejs/cli-win32-arm64@2.4.8': + resolution: {integrity: sha512-LoFatS0tnHv6KkCVpIy3qZCih+MxUMvdYiPWLHRri7mhi2vyOOs8OrbZBcLTUEWCS+ktO72nZMy4F96oMhkOHQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@2.3.11': - resolution: {integrity: sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg==} + '@biomejs/cli-win32-x64@2.4.8': + resolution: {integrity: sha512-vAn7iXDoUbqFXqVocuq1sMYAd33p8+mmurqJkWl6CtIhobd/O6moe4rY5AJvzbunn/qZCdiDVcveqtkFh1e7Hg==} engines: {node: '>=14.21.3'} 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==} + '@esbuild/aix-ppc64@0.27.4': + resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.27.2': - resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} + '@esbuild/android-arm64@0.27.4': + resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.27.2': - resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} + '@esbuild/android-arm@0.27.4': + resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.27.2': - resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} + '@esbuild/android-x64@0.27.4': + resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.27.2': - resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} + '@esbuild/darwin-arm64@0.27.4': + resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.27.2': - resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} + '@esbuild/darwin-x64@0.27.4': + resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.27.2': - resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} + '@esbuild/freebsd-arm64@0.27.4': + resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.2': - resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} + '@esbuild/freebsd-x64@0.27.4': + resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.27.2': - resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} + '@esbuild/linux-arm64@0.27.4': + resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.27.2': - resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} + '@esbuild/linux-arm@0.27.4': + resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.27.2': - resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} + '@esbuild/linux-ia32@0.27.4': + resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.27.2': - resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} + '@esbuild/linux-loong64@0.27.4': + resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.27.2': - resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} + '@esbuild/linux-mips64el@0.27.4': + resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.27.2': - resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} + '@esbuild/linux-ppc64@0.27.4': + resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.27.2': - resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} + '@esbuild/linux-riscv64@0.27.4': + resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.27.2': - resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} + '@esbuild/linux-s390x@0.27.4': + resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.27.2': - resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} + '@esbuild/linux-x64@0.27.4': + resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.27.2': - resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} + '@esbuild/netbsd-arm64@0.27.4': + resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.2': - resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} + '@esbuild/netbsd-x64@0.27.4': + resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.27.2': - resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} + '@esbuild/openbsd-arm64@0.27.4': + resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.2': - resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} + '@esbuild/openbsd-x64@0.27.4': + resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.27.2': - resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} + '@esbuild/openharmony-arm64@0.27.4': + resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.27.2': - resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} + '@esbuild/sunos-x64@0.27.4': + resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.27.2': - resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} + '@esbuild/win32-arm64@0.27.4': + resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.27.2': - resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} + '@esbuild/win32-ia32@0.27.4': + resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.27.2': - resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} + '@esbuild/win32-x64@0.27.4': + resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -309,8 +307,8 @@ packages: resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/config-array@0.21.1': - resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/config-helpers@0.4.2': @@ -321,8 +319,8 @@ packages: resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/eslintrc@3.3.3': - resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/js@9.39.1': @@ -363,128 +361,141 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@rollup/rollup-android-arm-eabi@4.55.1': - resolution: {integrity: sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==} + '@rollup/rollup-android-arm-eabi@4.60.0': + resolution: {integrity: sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.55.1': - resolution: {integrity: sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==} + '@rollup/rollup-android-arm64@4.60.0': + resolution: {integrity: sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.55.1': - resolution: {integrity: sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==} + '@rollup/rollup-darwin-arm64@4.60.0': + resolution: {integrity: sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.55.1': - resolution: {integrity: sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==} + '@rollup/rollup-darwin-x64@4.60.0': + resolution: {integrity: sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.55.1': - resolution: {integrity: sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==} + '@rollup/rollup-freebsd-arm64@4.60.0': + resolution: {integrity: sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.55.1': - resolution: {integrity: sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==} + '@rollup/rollup-freebsd-x64@4.60.0': + resolution: {integrity: sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.55.1': - resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==} + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': + resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==} cpu: [arm] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.55.1': - resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==} + '@rollup/rollup-linux-arm-musleabihf@4.60.0': + resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==} cpu: [arm] os: [linux] + libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.55.1': - resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==} + '@rollup/rollup-linux-arm64-gnu@4.60.0': + resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==} cpu: [arm64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.55.1': - resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==} + '@rollup/rollup-linux-arm64-musl@4.60.0': + resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==} cpu: [arm64] os: [linux] + libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.55.1': - resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==} + '@rollup/rollup-linux-loong64-gnu@4.60.0': + resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==} cpu: [loong64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-loong64-musl@4.55.1': - resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==} + '@rollup/rollup-linux-loong64-musl@4.60.0': + resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==} cpu: [loong64] os: [linux] + libc: [musl] - '@rollup/rollup-linux-ppc64-gnu@4.55.1': - resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==} + '@rollup/rollup-linux-ppc64-gnu@4.60.0': + resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==} cpu: [ppc64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.55.1': - resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==} + '@rollup/rollup-linux-ppc64-musl@4.60.0': + resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==} cpu: [ppc64] os: [linux] + libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.55.1': - resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==} + '@rollup/rollup-linux-riscv64-gnu@4.60.0': + resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==} cpu: [riscv64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.55.1': - resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==} + '@rollup/rollup-linux-riscv64-musl@4.60.0': + resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==} cpu: [riscv64] os: [linux] + libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.55.1': - resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==} + '@rollup/rollup-linux-s390x-gnu@4.60.0': + resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==} cpu: [s390x] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.55.1': - resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==} + '@rollup/rollup-linux-x64-gnu@4.60.0': + resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==} cpu: [x64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.55.1': - resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==} + '@rollup/rollup-linux-x64-musl@4.60.0': + resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==} cpu: [x64] os: [linux] + libc: [musl] - '@rollup/rollup-openbsd-x64@4.55.1': - resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==} + '@rollup/rollup-openbsd-x64@4.60.0': + resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.55.1': - resolution: {integrity: sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==} + '@rollup/rollup-openharmony-arm64@4.60.0': + resolution: {integrity: sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.55.1': - resolution: {integrity: sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==} + '@rollup/rollup-win32-arm64-msvc@4.60.0': + resolution: {integrity: sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.55.1': - resolution: {integrity: sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==} + '@rollup/rollup-win32-ia32-msvc@4.60.0': + resolution: {integrity: sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.55.1': - resolution: {integrity: sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==} + '@rollup/rollup-win32-x64-gnu@4.60.0': + resolution: {integrity: sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.55.1': - resolution: {integrity: sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==} + '@rollup/rollup-win32-x64-msvc@4.60.0': + resolution: {integrity: sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==} cpu: [x64] os: [win32] @@ -509,46 +520,46 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} - '@types/node@25.0.3': - resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==} + '@types/node@25.5.0': + resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} - '@vitest/coverage-v8@4.0.16': - resolution: {integrity: sha512-2rNdjEIsPRzsdu6/9Eq0AYAzYdpP6Bx9cje9tL3FE5XzXRQF1fNU9pe/1yE8fCrS0HD+fBtt6gLPh6LI57tX7A==} + '@vitest/coverage-v8@4.1.1': + resolution: {integrity: sha512-nZ4RWwGCoGOQRMmU/Q9wlUY540RVRxJZ9lxFsFfy0QV7Zmo5VVBhB6Sl9Xa0KIp2iIs3zWfPlo9LcY1iqbpzCw==} peerDependencies: - '@vitest/browser': 4.0.16 - vitest: 4.0.16 + '@vitest/browser': 4.1.1 + vitest: 4.1.1 peerDependenciesMeta: '@vitest/browser': optional: true - '@vitest/expect@4.0.16': - resolution: {integrity: sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==} + '@vitest/expect@4.1.1': + resolution: {integrity: sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==} - '@vitest/mocker@4.0.16': - resolution: {integrity: sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==} + '@vitest/mocker@4.1.1': + resolution: {integrity: sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==} peerDependencies: msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0-0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: msw: optional: true vite: optional: true - '@vitest/pretty-format@4.0.16': - resolution: {integrity: sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==} + '@vitest/pretty-format@4.1.1': + resolution: {integrity: sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==} - '@vitest/runner@4.0.16': - resolution: {integrity: sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==} + '@vitest/runner@4.1.1': + resolution: {integrity: sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==} - '@vitest/snapshot@4.0.16': - resolution: {integrity: sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==} + '@vitest/snapshot@4.1.1': + resolution: {integrity: sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==} - '@vitest/spy@4.0.16': - resolution: {integrity: sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==} + '@vitest/spy@4.1.1': + resolution: {integrity: sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==} - '@vitest/utils@4.0.16': - resolution: {integrity: sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==} + '@vitest/utils@4.1.1': + resolution: {integrity: sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==} acorn-jsx-walk@2.0.0: resolution: {integrity: sha512-uuo6iJj4D4ygkdzd6jPtcxs8vZgDX9YFIkqczGImoypX2fQ4dVImmu3UzA4ynixCIMTrEOWW+95M2HuBaCEOVA==} @@ -562,22 +573,34 @@ packages: resolution: {integrity: sha512-PPvV6g8UGMGgjrMu+n/f9E/tCSkNQ2Y97eFvuVdJfG11+xdIeDcLyNdC8SHcrHbRqkfwLASdplyR6B6sKM1U4A==} engines: {node: '>=0.4.0'} - acorn-walk@8.3.4: - resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} engines: {node: '>=0.4.0'} - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} hasBin: true - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -609,8 +632,8 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} - ast-v8-to-istanbul@0.3.10: - resolution: {integrity: sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==} + ast-v8-to-istanbul@1.0.0: + resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==} async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} @@ -650,6 +673,14 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-truncate@5.2.0: + resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} + engines: {node: '>=20'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -657,13 +688,19 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - commander@14.0.2: - resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==} + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -708,8 +745,8 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} - dependency-cruiser@17.3.6: - resolution: {integrity: sha512-k0mXZaNvR2hZC0Dh2y4NGZZGqWLM6AbxQXmKL0T2m+KRE19nK5gAhm+PdV7f1L+AhdwwSOnebok7C6P27l2xXA==} + dependency-cruiser@17.3.9: + resolution: {integrity: sha512-LwaotlB9bZ8zhdFGGYf/g2oYkYj7YNxlqx1btL/XIYGob/aKRArsSwkLKo+ZrHiegsEArQVg4ZQ3NhAh8uk+hg==} engines: {node: ^20.12||^22||>=24} hasBin: true @@ -721,10 +758,17 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - enhanced-resolve@5.18.4: - resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + enhanced-resolve@5.20.0: + resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==} engines: {node: '>=10.13.0'} + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + es-abstract@1.24.1: resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} engines: {node: '>= 0.4'} @@ -737,8 +781,8 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} @@ -756,8 +800,8 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} - esbuild@0.27.2: - resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} + esbuild@0.27.4: + resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} engines: {node: '>=18'} hasBin: true @@ -850,6 +894,9 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -884,8 +931,8 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatted@3.3.3: - resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} @@ -910,6 +957,10 @@ packages: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} + get-east-asian-width@1.5.0: + resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -922,8 +973,8 @@ packages: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} - get-tsconfig@4.13.0: - resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + get-tsconfig@4.13.7: + resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} @@ -982,6 +1033,11 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1050,6 +1106,10 @@ packages: resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} engines: {node: '>= 0.4'} + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + is-generator-function@1.1.2: resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} engines: {node: '>= 0.4'} @@ -1128,16 +1188,12 @@ packages: resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} engines: {node: '>=10'} - istanbul-lib-source-maps@5.0.6: - resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} - engines: {node: '>=10'} - istanbul-reports@3.2.0: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} - js-tokens@9.0.1: - resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} @@ -1172,6 +1228,15 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lint-staged@16.4.0: + resolution: {integrity: sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==} + engines: {node: '>=20.17'} + hasBin: true + + listr2@9.0.5: + resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} + engines: {node: '>=20.0.0'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -1179,11 +1244,15 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - magicast@0.5.1: - resolution: {integrity: sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==} + magicast@0.5.2: + resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} @@ -1193,8 +1262,12 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -1237,6 +1310,10 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1278,12 +1355,16 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: @@ -1329,8 +1410,15 @@ packages: engines: {node: '>= 0.4'} hasBin: true - rollup@4.55.1: - resolution: {integrity: sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==} + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rollup@4.60.0: + resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -1353,8 +1441,8 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.7.3: - resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} hasBin: true @@ -1397,9 +1485,21 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + slice-ansi@7.1.2: + resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} + engines: {node: '>=18'} + + slice-ansi@8.0.0: + resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} + engines: {node: '>=20'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1407,13 +1507,25 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - std-env@3.10.0: - resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.0.0: + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.2.0: + resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} + engines: {node: '>=20'} + string.prototype.trim@1.2.10: resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} engines: {node: '>= 0.4'} @@ -1426,6 +1538,10 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -1442,8 +1558,8 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - tapable@2.3.0: - resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + tapable@2.3.2: + resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} engines: {node: '>=6'} temp@0.4.0: @@ -1453,16 +1569,20 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinyexec@1.0.2: - resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + tinybench@6.0.0: + resolution: {integrity: sha512-BWlWpVbbZXaYjRV0twGLNQO00Zj4HA/sjLOQP2IvzQqGwRGp+2kh7UU3ijyJ3ywFRogYDRbiHDMrUOfaMnN56g==} + engines: {node: '>=20.0.0'} + + tinyexec@1.0.4: + resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} engines: {node: '>=18'} tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} - tinyrainbow@3.0.3: - resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} tsconfig-paths-webpack-plugin@4.2.0: @@ -1508,8 +1628,8 @@ packages: resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} engines: {node: '>= 0.4'} - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + typescript@6.0.2: + resolution: {integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==} engines: {node: '>=14.17'} hasBin: true @@ -1517,8 +1637,8 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} - undici-types@7.16.0: - resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -1567,20 +1687,21 @@ packages: yaml: optional: true - vitest@4.0.16: - resolution: {integrity: sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==} + vitest@4.1.1: + resolution: {integrity: sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.0.16 - '@vitest/browser-preview': 4.0.16 - '@vitest/browser-webdriverio': 4.0.16 - '@vitest/ui': 4.0.16 + '@vitest/browser-playwright': 4.1.1 + '@vitest/browser-preview': 4.1.1 + '@vitest/browser-webdriverio': 4.1.1 + '@vitest/ui': 4.1.1 happy-dom: '*' jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -1601,8 +1722,8 @@ packages: jsdom: optional: true - watskeburt@5.0.0: - resolution: {integrity: sha512-fEMhfIzu9WOuAJdDcTT+aPjn0JHI2+UeJ+zWSEs/tgMvc+MFDZVmhlZ8C1uJWXax1ETYc4trUnHFHyx2DrG0jQ==} + watskeburt@5.0.3: + resolution: {integrity: sha512-g9CXukMjazlJJVQ3OHzXsnG25KFYgSgKMIyoJrD8ggr0DbS9UNF7OzIqWmmKKBMedkxj3T01uqEaGnn+y7QhMA==} engines: {node: ^20.12||^22.13||>=24.0} hasBin: true @@ -1618,8 +1739,8 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} - which-typed-array@1.1.19: - resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} + which-typed-array@1.1.20: + resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} engines: {node: '>= 0.4'} which@2.0.2: @@ -1636,12 +1757,21 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + + yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + engines: {node: '>= 14.6'} + hasBin: true + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - zod@4.3.5: - resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} snapshots: @@ -1649,132 +1779,128 @@ snapshots: '@babel/helper-validator-identifier@7.28.5': {} - '@babel/parser@7.28.5': + '@babel/parser@7.29.2': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 - '@babel/types@7.28.5': + '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 '@bcoe/v8-coverage@1.0.2': {} - '@biomejs/biome@2.3.11': + '@biomejs/biome@2.4.8': optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.3.11 - '@biomejs/cli-darwin-x64': 2.3.11 - '@biomejs/cli-linux-arm64': 2.3.11 - '@biomejs/cli-linux-arm64-musl': 2.3.11 - '@biomejs/cli-linux-x64': 2.3.11 - '@biomejs/cli-linux-x64-musl': 2.3.11 - '@biomejs/cli-win32-arm64': 2.3.11 - '@biomejs/cli-win32-x64': 2.3.11 - - '@biomejs/cli-darwin-arm64@2.3.11': + '@biomejs/cli-darwin-arm64': 2.4.8 + '@biomejs/cli-darwin-x64': 2.4.8 + '@biomejs/cli-linux-arm64': 2.4.8 + '@biomejs/cli-linux-arm64-musl': 2.4.8 + '@biomejs/cli-linux-x64': 2.4.8 + '@biomejs/cli-linux-x64-musl': 2.4.8 + '@biomejs/cli-win32-arm64': 2.4.8 + '@biomejs/cli-win32-x64': 2.4.8 + + '@biomejs/cli-darwin-arm64@2.4.8': optional: true - '@biomejs/cli-darwin-x64@2.3.11': + '@biomejs/cli-darwin-x64@2.4.8': optional: true - '@biomejs/cli-linux-arm64-musl@2.3.11': + '@biomejs/cli-linux-arm64-musl@2.4.8': optional: true - '@biomejs/cli-linux-arm64@2.3.11': + '@biomejs/cli-linux-arm64@2.4.8': optional: true - '@biomejs/cli-linux-x64-musl@2.3.11': + '@biomejs/cli-linux-x64-musl@2.4.8': optional: true - '@biomejs/cli-linux-x64@2.3.11': + '@biomejs/cli-linux-x64@2.4.8': optional: true - '@biomejs/cli-win32-arm64@2.3.11': + '@biomejs/cli-win32-arm64@2.4.8': optional: true - '@biomejs/cli-win32-x64@2.3.11': + '@biomejs/cli-win32-x64@2.4.8': optional: true - '@citizenfx/client@2.0.22443-1': {} - - '@citizenfx/server@2.0.22443-1': {} - - '@esbuild/aix-ppc64@0.27.2': + '@esbuild/aix-ppc64@0.27.4': optional: true - '@esbuild/android-arm64@0.27.2': + '@esbuild/android-arm64@0.27.4': optional: true - '@esbuild/android-arm@0.27.2': + '@esbuild/android-arm@0.27.4': optional: true - '@esbuild/android-x64@0.27.2': + '@esbuild/android-x64@0.27.4': optional: true - '@esbuild/darwin-arm64@0.27.2': + '@esbuild/darwin-arm64@0.27.4': optional: true - '@esbuild/darwin-x64@0.27.2': + '@esbuild/darwin-x64@0.27.4': optional: true - '@esbuild/freebsd-arm64@0.27.2': + '@esbuild/freebsd-arm64@0.27.4': optional: true - '@esbuild/freebsd-x64@0.27.2': + '@esbuild/freebsd-x64@0.27.4': optional: true - '@esbuild/linux-arm64@0.27.2': + '@esbuild/linux-arm64@0.27.4': optional: true - '@esbuild/linux-arm@0.27.2': + '@esbuild/linux-arm@0.27.4': optional: true - '@esbuild/linux-ia32@0.27.2': + '@esbuild/linux-ia32@0.27.4': optional: true - '@esbuild/linux-loong64@0.27.2': + '@esbuild/linux-loong64@0.27.4': optional: true - '@esbuild/linux-mips64el@0.27.2': + '@esbuild/linux-mips64el@0.27.4': optional: true - '@esbuild/linux-ppc64@0.27.2': + '@esbuild/linux-ppc64@0.27.4': optional: true - '@esbuild/linux-riscv64@0.27.2': + '@esbuild/linux-riscv64@0.27.4': optional: true - '@esbuild/linux-s390x@0.27.2': + '@esbuild/linux-s390x@0.27.4': optional: true - '@esbuild/linux-x64@0.27.2': + '@esbuild/linux-x64@0.27.4': optional: true - '@esbuild/netbsd-arm64@0.27.2': + '@esbuild/netbsd-arm64@0.27.4': optional: true - '@esbuild/netbsd-x64@0.27.2': + '@esbuild/netbsd-x64@0.27.4': optional: true - '@esbuild/openbsd-arm64@0.27.2': + '@esbuild/openbsd-arm64@0.27.4': optional: true - '@esbuild/openbsd-x64@0.27.2': + '@esbuild/openbsd-x64@0.27.4': optional: true - '@esbuild/openharmony-arm64@0.27.2': + '@esbuild/openharmony-arm64@0.27.4': optional: true - '@esbuild/sunos-x64@0.27.2': + '@esbuild/sunos-x64@0.27.4': optional: true - '@esbuild/win32-arm64@0.27.2': + '@esbuild/win32-arm64@0.27.4': optional: true - '@esbuild/win32-ia32@0.27.2': + '@esbuild/win32-ia32@0.27.4': optional: true - '@esbuild/win32-x64@0.27.2': + '@esbuild/win32-x64@0.27.4': optional: true '@eslint-community/eslint-utils@4.9.1(eslint@9.39.1)': @@ -1784,11 +1910,11 @@ snapshots: '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.1': + '@eslint/config-array@0.21.2': dependencies: '@eslint/object-schema': 2.1.7 debug: 4.4.3 - minimatch: 3.1.2 + minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -1800,16 +1926,16 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.3': + '@eslint/eslintrc@3.3.5': dependencies: - ajv: 6.12.6 + ajv: 6.14.0 debug: 4.4.3 espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 js-yaml: 4.1.1 - minimatch: 3.1.2 + minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color @@ -1843,79 +1969,79 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@rollup/rollup-android-arm-eabi@4.55.1': + '@rollup/rollup-android-arm-eabi@4.60.0': optional: true - '@rollup/rollup-android-arm64@4.55.1': + '@rollup/rollup-android-arm64@4.60.0': optional: true - '@rollup/rollup-darwin-arm64@4.55.1': + '@rollup/rollup-darwin-arm64@4.60.0': optional: true - '@rollup/rollup-darwin-x64@4.55.1': + '@rollup/rollup-darwin-x64@4.60.0': optional: true - '@rollup/rollup-freebsd-arm64@4.55.1': + '@rollup/rollup-freebsd-arm64@4.60.0': optional: true - '@rollup/rollup-freebsd-x64@4.55.1': + '@rollup/rollup-freebsd-x64@4.60.0': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.55.1': + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.55.1': + '@rollup/rollup-linux-arm-musleabihf@4.60.0': optional: true - '@rollup/rollup-linux-arm64-gnu@4.55.1': + '@rollup/rollup-linux-arm64-gnu@4.60.0': optional: true - '@rollup/rollup-linux-arm64-musl@4.55.1': + '@rollup/rollup-linux-arm64-musl@4.60.0': optional: true - '@rollup/rollup-linux-loong64-gnu@4.55.1': + '@rollup/rollup-linux-loong64-gnu@4.60.0': optional: true - '@rollup/rollup-linux-loong64-musl@4.55.1': + '@rollup/rollup-linux-loong64-musl@4.60.0': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.55.1': + '@rollup/rollup-linux-ppc64-gnu@4.60.0': optional: true - '@rollup/rollup-linux-ppc64-musl@4.55.1': + '@rollup/rollup-linux-ppc64-musl@4.60.0': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.55.1': + '@rollup/rollup-linux-riscv64-gnu@4.60.0': optional: true - '@rollup/rollup-linux-riscv64-musl@4.55.1': + '@rollup/rollup-linux-riscv64-musl@4.60.0': optional: true - '@rollup/rollup-linux-s390x-gnu@4.55.1': + '@rollup/rollup-linux-s390x-gnu@4.60.0': optional: true - '@rollup/rollup-linux-x64-gnu@4.55.1': + '@rollup/rollup-linux-x64-gnu@4.60.0': optional: true - '@rollup/rollup-linux-x64-musl@4.55.1': + '@rollup/rollup-linux-x64-musl@4.60.0': optional: true - '@rollup/rollup-openbsd-x64@4.55.1': + '@rollup/rollup-openbsd-x64@4.60.0': optional: true - '@rollup/rollup-openharmony-arm64@4.55.1': + '@rollup/rollup-openharmony-arm64@4.60.0': optional: true - '@rollup/rollup-win32-arm64-msvc@4.55.1': + '@rollup/rollup-win32-arm64-msvc@4.60.0': optional: true - '@rollup/rollup-win32-ia32-msvc@4.55.1': + '@rollup/rollup-win32-ia32-msvc@4.60.0': optional: true - '@rollup/rollup-win32-x64-gnu@4.55.1': + '@rollup/rollup-win32-x64-gnu@4.60.0': optional: true - '@rollup/rollup-win32-x64-msvc@4.55.1': + '@rollup/rollup-win32-x64-msvc@4.60.0': optional: true '@rtsao/scc@1.1.0': {} @@ -1935,93 +2061,100 @@ snapshots: '@types/json5@0.0.29': {} - '@types/node@25.0.3': + '@types/node@25.5.0': dependencies: - undici-types: 7.16.0 + undici-types: 7.18.2 - '@vitest/coverage-v8@4.0.16(vitest@4.0.16(@types/node@25.0.3)(tsx@4.21.0))': + '@vitest/coverage-v8@4.1.1(vitest@4.1.1(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)))': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.0.16 - ast-v8-to-istanbul: 0.3.10 + '@vitest/utils': 4.1.1 + ast-v8-to-istanbul: 1.0.0 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 5.0.6 istanbul-reports: 3.2.0 - magicast: 0.5.1 + magicast: 0.5.2 obug: 2.1.1 - std-env: 3.10.0 - tinyrainbow: 3.0.3 - vitest: 4.0.16(@types/node@25.0.3)(tsx@4.21.0) - transitivePeerDependencies: - - supports-color + std-env: 4.0.0 + tinyrainbow: 3.1.0 + vitest: 4.1.1(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) - '@vitest/expect@4.0.16': + '@vitest/expect@4.1.1': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.0.16 - '@vitest/utils': 4.0.16 + '@vitest/spy': 4.1.1 + '@vitest/utils': 4.1.1 chai: 6.2.2 - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 - '@vitest/mocker@4.0.16(vite@7.3.0(@types/node@25.0.3)(tsx@4.21.0))': + '@vitest/mocker@4.1.1(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: - '@vitest/spy': 4.0.16 + '@vitest/spy': 4.1.1 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.0(@types/node@25.0.3)(tsx@4.21.0) + vite: 7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3) - '@vitest/pretty-format@4.0.16': + '@vitest/pretty-format@4.1.1': dependencies: - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 - '@vitest/runner@4.0.16': + '@vitest/runner@4.1.1': dependencies: - '@vitest/utils': 4.0.16 + '@vitest/utils': 4.1.1 pathe: 2.0.3 - '@vitest/snapshot@4.0.16': + '@vitest/snapshot@4.1.1': dependencies: - '@vitest/pretty-format': 4.0.16 + '@vitest/pretty-format': 4.1.1 + '@vitest/utils': 4.1.1 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.0.16': {} + '@vitest/spy@4.1.1': {} - '@vitest/utils@4.0.16': + '@vitest/utils@4.1.1': dependencies: - '@vitest/pretty-format': 4.0.16 - tinyrainbow: 3.0.3 + '@vitest/pretty-format': 4.1.1 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 acorn-jsx-walk@2.0.0: {} - acorn-jsx@5.3.2(acorn@8.15.0): + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: - acorn: 8.15.0 + acorn: 8.16.0 acorn-loose@8.5.2: dependencies: - acorn: 8.15.0 + acorn: 8.16.0 - acorn-walk@8.3.4: + acorn-walk@8.3.5: dependencies: - acorn: 8.15.0 + acorn: 8.16.0 - acorn@8.15.0: {} + acorn@8.16.0: {} - ajv@6.12.6: + ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + + ansi-regex@6.2.2: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + ansi-styles@6.2.3: {} + argparse@2.0.1: {} array-buffer-byte-length@1.0.2: @@ -2076,11 +2209,11 @@ snapshots: assertion-error@2.0.1: {} - ast-v8-to-istanbul@0.3.10: + ast-v8-to-istanbul@1.0.0: dependencies: '@jridgewell/trace-mapping': 0.3.31 estree-walker: 3.0.3 - js-tokens: 9.0.1 + js-tokens: 10.0.0 async-function@1.0.0: {} @@ -2121,16 +2254,29 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-truncate@5.2.0: + dependencies: + slice-ansi: 8.0.0 + string-width: 8.2.0 + color-convert@2.0.1: dependencies: color-name: 1.1.4 color-name@1.1.4: {} - commander@14.0.2: {} + colorette@2.0.20: {} + + commander@14.0.3: {} concat-map@0.0.1: {} + convert-source-map@2.0.0: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -2177,15 +2323,15 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 - dependency-cruiser@17.3.6: + dependency-cruiser@17.3.9: dependencies: - acorn: 8.15.0 - acorn-jsx: 5.3.2(acorn@8.15.0) + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) acorn-jsx-walk: 2.0.0 acorn-loose: 8.5.2 - acorn-walk: 8.3.4 - commander: 14.0.2 - enhanced-resolve: 5.18.4 + acorn-walk: 8.3.5 + commander: 14.0.3 + enhanced-resolve: 5.20.0 ignore: 7.0.5 interpret: 3.1.1 is-installed-globally: 1.0.0 @@ -2194,9 +2340,9 @@ snapshots: prompts: 2.4.2 rechoir: 0.8.0 safe-regex: 2.1.1 - semver: 7.7.3 + semver: 7.7.4 tsconfig-paths-webpack-plugin: 4.2.0 - watskeburt: 5.0.0 + watskeburt: 5.0.3 doctrine@2.1.0: dependencies: @@ -2208,10 +2354,14 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - enhanced-resolve@5.18.4: + emoji-regex@10.6.0: {} + + enhanced-resolve@5.20.0: dependencies: graceful-fs: 4.2.11 - tapable: 2.3.0 + tapable: 2.3.2 + + environment@1.1.0: {} es-abstract@1.24.1: dependencies: @@ -2268,13 +2418,13 @@ snapshots: typed-array-byte-offset: 1.0.4 typed-array-length: 1.0.7 unbox-primitive: 1.1.0 - which-typed-array: 1.1.19 + which-typed-array: 1.1.20 es-define-property@1.0.1: {} es-errors@1.3.0: {} - es-module-lexer@1.7.0: {} + es-module-lexer@2.0.0: {} es-object-atoms@1.1.1: dependencies: @@ -2297,34 +2447,34 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 - esbuild@0.27.2: + esbuild@0.27.4: optionalDependencies: - '@esbuild/aix-ppc64': 0.27.2 - '@esbuild/android-arm': 0.27.2 - '@esbuild/android-arm64': 0.27.2 - '@esbuild/android-x64': 0.27.2 - '@esbuild/darwin-arm64': 0.27.2 - '@esbuild/darwin-x64': 0.27.2 - '@esbuild/freebsd-arm64': 0.27.2 - '@esbuild/freebsd-x64': 0.27.2 - '@esbuild/linux-arm': 0.27.2 - '@esbuild/linux-arm64': 0.27.2 - '@esbuild/linux-ia32': 0.27.2 - '@esbuild/linux-loong64': 0.27.2 - '@esbuild/linux-mips64el': 0.27.2 - '@esbuild/linux-ppc64': 0.27.2 - '@esbuild/linux-riscv64': 0.27.2 - '@esbuild/linux-s390x': 0.27.2 - '@esbuild/linux-x64': 0.27.2 - '@esbuild/netbsd-arm64': 0.27.2 - '@esbuild/netbsd-x64': 0.27.2 - '@esbuild/openbsd-arm64': 0.27.2 - '@esbuild/openbsd-x64': 0.27.2 - '@esbuild/openharmony-arm64': 0.27.2 - '@esbuild/sunos-x64': 0.27.2 - '@esbuild/win32-arm64': 0.27.2 - '@esbuild/win32-ia32': 0.27.2 - '@esbuild/win32-x64': 0.27.2 + '@esbuild/aix-ppc64': 0.27.4 + '@esbuild/android-arm': 0.27.4 + '@esbuild/android-arm64': 0.27.4 + '@esbuild/android-x64': 0.27.4 + '@esbuild/darwin-arm64': 0.27.4 + '@esbuild/darwin-x64': 0.27.4 + '@esbuild/freebsd-arm64': 0.27.4 + '@esbuild/freebsd-x64': 0.27.4 + '@esbuild/linux-arm': 0.27.4 + '@esbuild/linux-arm64': 0.27.4 + '@esbuild/linux-ia32': 0.27.4 + '@esbuild/linux-loong64': 0.27.4 + '@esbuild/linux-mips64el': 0.27.4 + '@esbuild/linux-ppc64': 0.27.4 + '@esbuild/linux-riscv64': 0.27.4 + '@esbuild/linux-s390x': 0.27.4 + '@esbuild/linux-x64': 0.27.4 + '@esbuild/netbsd-arm64': 0.27.4 + '@esbuild/netbsd-x64': 0.27.4 + '@esbuild/openbsd-arm64': 0.27.4 + '@esbuild/openbsd-x64': 0.27.4 + '@esbuild/openharmony-arm64': 0.27.4 + '@esbuild/sunos-x64': 0.27.4 + '@esbuild/win32-arm64': 0.27.4 + '@esbuild/win32-ia32': 0.27.4 + '@esbuild/win32-x64': 0.27.4 escape-string-regexp@4.0.0: {} @@ -2364,7 +2514,7 @@ snapshots: hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 - minimatch: 3.1.2 + minimatch: 3.1.5 object.fromentries: 2.0.8 object.groupby: 1.0.3 object.values: 1.2.1 @@ -2389,17 +2539,17 @@ snapshots: dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.1) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.1 + '@eslint/config-array': 0.21.2 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.3 + '@eslint/eslintrc': 3.3.5 '@eslint/js': 9.39.1 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.7 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 '@types/estree': 1.0.8 - ajv: 6.12.6 + ajv: 6.14.0 chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.3 @@ -2418,7 +2568,7 @@ snapshots: is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 lodash.merge: 4.6.2 - minimatch: 3.1.2 + minimatch: 3.1.5 natural-compare: 1.4.0 optionator: 0.9.4 transitivePeerDependencies: @@ -2426,8 +2576,8 @@ snapshots: espree@10.4.0: dependencies: - acorn: 8.15.0 - acorn-jsx: 5.3.2(acorn@8.15.0) + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) eslint-visitor-keys: 4.2.1 esquery@1.7.0: @@ -2446,6 +2596,8 @@ snapshots: esutils@2.0.3: {} + eventemitter3@5.0.4: {} + expect-type@1.3.0: {} fast-deep-equal@3.1.3: {} @@ -2454,9 +2606,9 @@ snapshots: fast-levenshtein@2.0.6: {} - fdir@6.5.0(picomatch@4.0.3): + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: - picomatch: 4.0.3 + picomatch: 4.0.4 file-entry-cache@8.0.0: dependencies: @@ -2469,10 +2621,10 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.3.3 + flatted: 3.4.2 keyv: 4.5.4 - flatted@3.3.3: {} + flatted@3.4.2: {} for-each@0.3.5: dependencies: @@ -2496,6 +2648,8 @@ snapshots: generator-function@2.0.1: {} + get-east-asian-width@1.5.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -2520,7 +2674,7 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 - get-tsconfig@4.13.0: + get-tsconfig@4.13.7: dependencies: resolve-pkg-maps: 1.0.0 @@ -2571,6 +2725,8 @@ snapshots: html-escaper@2.0.2: {} + husky@9.1.7: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -2638,6 +2794,10 @@ snapshots: dependencies: call-bound: 1.0.4 + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.5.0 + is-generator-function@1.1.2: dependencies: call-bound: 1.0.4 @@ -2692,7 +2852,7 @@ snapshots: is-typed-array@1.1.15: dependencies: - which-typed-array: 1.1.19 + which-typed-array: 1.1.20 is-weakmap@2.0.2: {} @@ -2717,20 +2877,12 @@ snapshots: make-dir: 4.0.0 supports-color: 7.2.0 - istanbul-lib-source-maps@5.0.6: - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - debug: 4.4.3 - istanbul-lib-coverage: 3.2.2 - transitivePeerDependencies: - - supports-color - istanbul-reports@3.2.0: dependencies: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 - js-tokens@9.0.1: {} + js-tokens@10.0.0: {} js-yaml@4.1.1: dependencies: @@ -2759,29 +2911,57 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lint-staged@16.4.0: + dependencies: + commander: 14.0.3 + listr2: 9.0.5 + picomatch: 4.0.4 + string-argv: 0.3.2 + tinyexec: 1.0.4 + yaml: 2.8.3 + + listr2@9.0.5: + dependencies: + cli-truncate: 5.2.0 + colorette: 2.0.20 + eventemitter3: 5.0.4 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.2 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 lodash.merge@4.6.2: {} + log-update@6.1.0: + dependencies: + ansi-escapes: 7.3.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.2 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - magicast@0.5.1: + magicast@0.5.2: dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 source-map-js: 1.2.1 make-dir@4.0.0: dependencies: - semver: 7.7.3 + semver: 7.7.4 math-intrinsics@1.1.0: {} - minimatch@3.1.2: + mimic-function@5.0.1: {} + + minimatch@3.1.5: dependencies: brace-expansion: 1.1.12 @@ -2828,6 +3008,10 @@ snapshots: obug@2.1.1: {} + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -2867,9 +3051,11 @@ snapshots: picomatch@4.0.3: {} + picomatch@4.0.4: {} + possible-typed-array-names@1.1.0: {} - postcss@8.5.6: + postcss@8.5.8: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 @@ -2922,35 +3108,42 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - rollup@4.55.1: + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + rfdc@1.4.1: {} + + rollup@4.60.0: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.55.1 - '@rollup/rollup-android-arm64': 4.55.1 - '@rollup/rollup-darwin-arm64': 4.55.1 - '@rollup/rollup-darwin-x64': 4.55.1 - '@rollup/rollup-freebsd-arm64': 4.55.1 - '@rollup/rollup-freebsd-x64': 4.55.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.55.1 - '@rollup/rollup-linux-arm-musleabihf': 4.55.1 - '@rollup/rollup-linux-arm64-gnu': 4.55.1 - '@rollup/rollup-linux-arm64-musl': 4.55.1 - '@rollup/rollup-linux-loong64-gnu': 4.55.1 - '@rollup/rollup-linux-loong64-musl': 4.55.1 - '@rollup/rollup-linux-ppc64-gnu': 4.55.1 - '@rollup/rollup-linux-ppc64-musl': 4.55.1 - '@rollup/rollup-linux-riscv64-gnu': 4.55.1 - '@rollup/rollup-linux-riscv64-musl': 4.55.1 - '@rollup/rollup-linux-s390x-gnu': 4.55.1 - '@rollup/rollup-linux-x64-gnu': 4.55.1 - '@rollup/rollup-linux-x64-musl': 4.55.1 - '@rollup/rollup-openbsd-x64': 4.55.1 - '@rollup/rollup-openharmony-arm64': 4.55.1 - '@rollup/rollup-win32-arm64-msvc': 4.55.1 - '@rollup/rollup-win32-ia32-msvc': 4.55.1 - '@rollup/rollup-win32-x64-gnu': 4.55.1 - '@rollup/rollup-win32-x64-msvc': 4.55.1 + '@rollup/rollup-android-arm-eabi': 4.60.0 + '@rollup/rollup-android-arm64': 4.60.0 + '@rollup/rollup-darwin-arm64': 4.60.0 + '@rollup/rollup-darwin-x64': 4.60.0 + '@rollup/rollup-freebsd-arm64': 4.60.0 + '@rollup/rollup-freebsd-x64': 4.60.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.0 + '@rollup/rollup-linux-arm-musleabihf': 4.60.0 + '@rollup/rollup-linux-arm64-gnu': 4.60.0 + '@rollup/rollup-linux-arm64-musl': 4.60.0 + '@rollup/rollup-linux-loong64-gnu': 4.60.0 + '@rollup/rollup-linux-loong64-musl': 4.60.0 + '@rollup/rollup-linux-ppc64-gnu': 4.60.0 + '@rollup/rollup-linux-ppc64-musl': 4.60.0 + '@rollup/rollup-linux-riscv64-gnu': 4.60.0 + '@rollup/rollup-linux-riscv64-musl': 4.60.0 + '@rollup/rollup-linux-s390x-gnu': 4.60.0 + '@rollup/rollup-linux-x64-gnu': 4.60.0 + '@rollup/rollup-linux-x64-musl': 4.60.0 + '@rollup/rollup-openbsd-x64': 4.60.0 + '@rollup/rollup-openharmony-arm64': 4.60.0 + '@rollup/rollup-win32-arm64-msvc': 4.60.0 + '@rollup/rollup-win32-ia32-msvc': 4.60.0 + '@rollup/rollup-win32-x64-gnu': 4.60.0 + '@rollup/rollup-win32-x64-msvc': 4.60.0 fsevents: 2.3.3 safe-array-concat@1.1.3: @@ -2978,7 +3171,7 @@ snapshots: semver@6.3.1: {} - semver@7.7.3: {} + semver@7.7.4: {} set-function-length@1.2.2: dependencies: @@ -3038,19 +3231,44 @@ snapshots: siginfo@2.0.0: {} + signal-exit@4.1.0: {} + sisteransi@1.0.5: {} + slice-ansi@7.1.2: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + slice-ansi@8.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + source-map-js@1.2.1: {} stackback@0.0.2: {} - std-env@3.10.0: {} + std-env@4.0.0: {} stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 internal-slot: 1.1.0 + string-argv@0.3.2: {} + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + + string-width@8.2.0: + dependencies: + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + string.prototype.trim@1.2.10: dependencies: call-bind: 1.0.8 @@ -3074,6 +3292,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-bom@3.0.0: {} strip-json-comments@3.1.1: {} @@ -3084,26 +3306,28 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - tapable@2.3.0: {} + tapable@2.3.2: {} temp@0.4.0: {} tinybench@2.9.0: {} - tinyexec@1.0.2: {} + tinybench@6.0.0: {} + + tinyexec@1.0.4: {} tinyglobby@0.2.15: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 - tinyrainbow@3.0.3: {} + tinyrainbow@3.1.0: {} tsconfig-paths-webpack-plugin@4.2.0: dependencies: chalk: 4.1.2 - enhanced-resolve: 5.18.4 - tapable: 2.3.0 + enhanced-resolve: 5.20.0 + tapable: 2.3.2 tsconfig-paths: 4.2.0 tsconfig-paths@3.15.0: @@ -3123,8 +3347,8 @@ snapshots: tsx@4.21.0: dependencies: - esbuild: 0.27.2 - get-tsconfig: 4.13.0 + esbuild: 0.27.4 + get-tsconfig: 4.13.7 optionalDependencies: fsevents: 2.3.3 @@ -3169,7 +3393,7 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript@5.9.3: {} + typescript@6.0.2: {} unbox-primitive@1.1.0: dependencies: @@ -3178,7 +3402,7 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 - undici-types@7.16.0: {} + undici-types@7.18.2: {} uri-js@4.4.1: dependencies: @@ -3186,57 +3410,48 @@ snapshots: uuid@13.0.0: {} - vite@7.3.0(@types/node@25.0.3)(tsx@4.21.0): + vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: - esbuild: 0.27.2 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.55.1 + esbuild: 0.27.4 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.8 + rollup: 4.60.0 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 25.0.3 + '@types/node': 25.5.0 fsevents: 2.3.3 tsx: 4.21.0 - - vitest@4.0.16(@types/node@25.0.3)(tsx@4.21.0): - dependencies: - '@vitest/expect': 4.0.16 - '@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@25.0.3)(tsx@4.21.0)) - '@vitest/pretty-format': 4.0.16 - '@vitest/runner': 4.0.16 - '@vitest/snapshot': 4.0.16 - '@vitest/spy': 4.0.16 - '@vitest/utils': 4.0.16 - es-module-lexer: 1.7.0 + yaml: 2.8.3 + + vitest@4.1.1(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)): + dependencies: + '@vitest/expect': 4.1.1 + '@vitest/mocker': 4.1.1(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/pretty-format': 4.1.1 + '@vitest/runner': 4.1.1 + '@vitest/snapshot': 4.1.1 + '@vitest/spy': 4.1.1 + '@vitest/utils': 4.1.1 + es-module-lexer: 2.0.0 expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.3 - std-env: 3.10.0 + picomatch: 4.0.4 + std-env: 4.0.0 tinybench: 2.9.0 - tinyexec: 1.0.2 + tinyexec: 1.0.4 tinyglobby: 0.2.15 - tinyrainbow: 3.0.3 - vite: 7.3.0(@types/node@25.0.3)(tsx@4.21.0) + tinyrainbow: 3.1.0 + vite: 7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 25.0.3 + '@types/node': 25.5.0 transitivePeerDependencies: - - jiti - - less - - lightningcss - msw - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - yaml - watskeburt@5.0.0: {} + watskeburt@5.0.3: {} which-boxed-primitive@1.1.1: dependencies: @@ -3260,7 +3475,7 @@ snapshots: isarray: 2.0.5 which-boxed-primitive: 1.1.1 which-collection: 1.0.2 - which-typed-array: 1.1.19 + which-typed-array: 1.1.20 which-collection@1.0.2: dependencies: @@ -3269,7 +3484,7 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 - which-typed-array@1.1.19: + which-typed-array@1.1.20: dependencies: available-typed-arrays: 1.0.7 call-bind: 1.0.8 @@ -3290,6 +3505,14 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + yaml@2.8.3: {} + yocto-queue@0.1.0: {} - zod@4.3.5: {} + zod@4.3.6: {} 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..a4f1672 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. @@ -5,7 +7,21 @@ export abstract class IEngineEvents { * @param eventName - The event name to listen for * @param handler - The callback to invoke when the event is emitted */ - abstract on(eventName: string, handler?: (...args: any[]) => void): void + abstract on( + eventName: string, + handler?: (...args: TArgs) => void, + ): void + + onRuntime( + eventName: RuntimeEventName, + handler?: (...args: TArgs) => void, + ): void { + this.on(this.getRuntimeEventMap()[eventName] ?? eventName, handler) + } + + getRuntimeEventMap(): RuntimeEventMap { + return DEFAULT_RUNTIME_EVENT_MAP + } /** * Emits a local event. @@ -17,5 +33,5 @@ export abstract class IEngineEvents { * @param eventName - The event name to emit * @param args - Arguments to pass to event handlers */ - abstract emit(eventName: string, ...args: any[]): void + abstract emit(eventName: string, ...args: TArgs): void } diff --git a/src/adapters/contracts/IExports.ts b/src/adapters/contracts/IExports.ts index 5014c64..c31d685 100644 --- a/src/adapters/contracts/IExports.ts +++ b/src/adapters/contracts/IExports.ts @@ -1,4 +1,4 @@ export abstract class IExports { - abstract register(exportName: string, handler: (...args: any[]) => any): void - abstract getResource(resourceName: string): T | undefined + abstract register(exportName: string, handler: (...args: unknown[]) => unknown): void + abstract getResource(resourceName: string): T | undefined } 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..b9b5b21 --- /dev/null +++ b/src/adapters/contracts/client/IClientLocalPlayerBridge.ts @@ -0,0 +1,29 @@ +import type { Vector3 } from '../../../kernel/utils/vector3' + +/** + * Port describing the local player from the client perspective. + * + * Adapters expose the local player's handle and spatial context so framework + * services do not need to reach into low-level platform primitives. + */ +export abstract class IClientLocalPlayerBridge { + /** + * Returns the current runtime handle for the local player entity. + */ + abstract getHandle(): number + + /** + * Returns the current world position for the local player. + */ + abstract getPosition(): Vector3 + + /** + * Returns the current heading for the local player. + */ + abstract getHeading(): number + + /** + * Moves the local player to a new position and optional heading. + */ + 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..e45a297 --- /dev/null +++ b/src/adapters/contracts/client/IClientRuntimeBridge.ts @@ -0,0 +1,52 @@ +export abstract class IClientRuntimeBridge { + abstract getCurrentResourceName(): string + abstract on( + eventName: string, + handler: (...args: TArgs) => void | Promise, + ): void + abstract registerCommand( + commandName: string, + handler: (...args: unknown[]) => 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: unknown, 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: unknown, 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: TArgs) => TResult, + ): 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/camera/IClientCameraPort.ts b/src/adapters/contracts/client/camera/IClientCameraPort.ts new file mode 100644 index 0000000..6b387ea --- /dev/null +++ b/src/adapters/contracts/client/camera/IClientCameraPort.ts @@ -0,0 +1,133 @@ +import type { Vector3 } from '../../../../kernel/utils/vector3' + +/** + * Euler rotation used by scripted camera implementations. + */ +export interface ClientCameraRotation { + x: number + y: number + z: number +} + +/** + * Complete transform payload for a scripted camera. + */ +export interface ClientCameraTransform { + position: Vector3 + rotation?: ClientCameraRotation + fov?: number +} + +/** + * Options for creating a new scripted camera. + */ +export interface ClientCameraCreateOptions { + camName?: string + active?: boolean + transform?: ClientCameraTransform +} + +/** + * Options for enabling or disabling camera rendering. + */ +export interface ClientCameraRenderOptions { + ease?: boolean + easeTimeMs?: number +} + +/** + * Shake parameters for a scripted camera. + */ +export interface ClientCameraShakeOptions { + type: string + amplitude: number +} + +/** + * Intent-oriented client camera port. + * + * The framework asks for high-level camera actions through this port and the + * active adapter decides how those actions are achieved for the runtime. + */ +export abstract class IClientCameraPort { + /** + * Creates a new scripted camera and returns its runtime handle. + */ + abstract create(options?: ClientCameraCreateOptions): number + + /** + * Activates or deactivates a specific camera. + */ + abstract setActive(camera: number, active: boolean): void + + /** + * Enables or disables scripted camera rendering. + */ + abstract render(enable: boolean, options?: ClientCameraRenderOptions): void + + /** + * Destroys a single scripted camera. + */ + abstract destroy(camera: number, destroyActiveCamera?: boolean): void + + /** + * Destroys every scripted camera controlled by the adapter/runtime. + */ + abstract destroyAll(destroyActiveCamera?: boolean): void + + /** + * Applies a transform to a camera. + */ + abstract setTransform(camera: number, transform: ClientCameraTransform): void + + /** + * Updates only the position of a camera. + */ + abstract setPosition(camera: number, position: Vector3): void + + /** + * Updates only the rotation of a camera. + */ + abstract setRotation(camera: number, rotation: ClientCameraRotation, rotationOrder?: number): void + + /** + * Updates only the field of view of a camera. + */ + abstract setFov(camera: number, fov: number): void + + /** + * Makes the camera point at a world position. + */ + abstract pointAtCoords(camera: number, position: Vector3): void + + /** + * Makes the camera point at an entity with an optional offset. + */ + abstract pointAtEntity(camera: number, entity: number, offset?: Vector3): void + + /** + * Stops any pointing target on the camera. + */ + abstract stopPointing(camera: number): void + + /** + * Interpolates from one camera to another. + */ + abstract interpolate( + fromCamera: number, + toCamera: number, + durationMs: number, + easeLocation?: boolean, + easeRotation?: boolean, + ): void + + /** + * Starts a camera shake effect. + */ + abstract shake(camera: number, options: ClientCameraShakeOptions): void + + /** + * Stops any active shake effect on a camera. + */ + abstract stopShaking(camera: number, stopImmediately?: boolean): void +} diff --git a/src/adapters/contracts/client/camera/index.ts b/src/adapters/contracts/client/camera/index.ts new file mode 100644 index 0000000..9985c65 --- /dev/null +++ b/src/adapters/contracts/client/camera/index.ts @@ -0,0 +1 @@ +export * from './IClientCameraPort' diff --git a/src/adapters/contracts/client/index.ts b/src/adapters/contracts/client/index.ts new file mode 100644 index 0000000..aef3a46 --- /dev/null +++ b/src/adapters/contracts/client/index.ts @@ -0,0 +1,11 @@ +export * from './camera' +export * from './IClientLogConsole' +export * from './IClientLocalPlayerBridge' +export * from './IClientPlatformBridge' +export * from './IClientRuntimeBridge' +export * from './IGtaPedAppearanceBridge' +export * from './ped' +export * from './progress' +export * from './spawn' +export * from './ui' +export * from './vehicle' diff --git a/src/adapters/contracts/client/ped/IClientPedPort.ts b/src/adapters/contracts/client/ped/IClientPedPort.ts new file mode 100644 index 0000000..c84265e --- /dev/null +++ b/src/adapters/contracts/client/ped/IClientPedPort.ts @@ -0,0 +1,71 @@ +import type { Vector3 } from '../../../../kernel/utils/vector3' + +/** + * High-level options for spawning a ped. + */ +export interface ClientPedSpawnOptions { + model: string + position: Vector3 + heading?: number + networked?: boolean + missionEntity?: boolean + relationshipGroup?: string + blockEvents?: boolean +} + +/** + * High-level animation request for a ped. + */ +export interface ClientPedAnimationOptions { + dict: string + anim: string + blendInSpeed?: number + blendOutSpeed?: number + duration?: number + flags?: number + playbackRate?: number +} + +/** + * Intent-oriented client ped port. + */ +export abstract class IClientPedPort { + /** Spawns a ped and returns its runtime handle. */ + abstract spawn(options: ClientPedSpawnOptions): Promise + /** Deletes a ped handle if it exists. */ + abstract delete(handle: number): void + /** Returns whether a ped handle exists. */ + abstract exists(handle: number): boolean + /** Plays an animation on a ped. */ + abstract playAnimation(handle: number, options: ClientPedAnimationOptions): Promise + /** Stops the current animation/task on a ped. */ + abstract stopAnimation(handle: number): void + /** Stops the current animation/task immediately on a ped. */ + abstract stopAnimationImmediately(handle: number): void + /** Freezes or unfreezes a ped. */ + abstract freeze(handle: number, freeze: boolean): void + /** Sets invincibility for a ped. */ + abstract setInvincible(handle: number, invincible: boolean): void + /** Gives a weapon to a ped. */ + abstract giveWeapon( + handle: number, + weapon: string, + ammo?: number, + hidden?: boolean, + forceInHand?: boolean, + ): void + /** Removes all weapons from a ped. */ + abstract removeAllWeapons(handle: number): void + /** Returns the closest ped to the local player. */ + abstract getClosest(radius?: number, excludeLocalPlayer?: boolean): number | null + /** Returns nearby ped handles. */ + abstract getNearby(position: Vector3, radius: number, excludeEntity?: number): number[] + /** Makes a ped look at an entity. */ + abstract lookAtEntity(handle: number, entity: number, duration?: number): void + /** Makes a ped look at coordinates. */ + abstract lookAtCoords(handle: number, position: Vector3, duration?: number): void + /** Makes a ped walk to coordinates. */ + abstract walkTo(handle: number, position: Vector3, speed?: number): void + /** Sets basic combat intent flags for a ped. */ + abstract setCombatAttributes(handle: number, canFight: boolean, canUseCover?: boolean): void +} diff --git a/src/adapters/contracts/client/ped/index.ts b/src/adapters/contracts/client/ped/index.ts new file mode 100644 index 0000000..2c0701a --- /dev/null +++ b/src/adapters/contracts/client/ped/index.ts @@ -0,0 +1 @@ +export * from './IClientPedPort' diff --git a/src/adapters/contracts/client/progress/IClientProgressPort.ts b/src/adapters/contracts/client/progress/IClientProgressPort.ts new file mode 100644 index 0000000..dc8719d --- /dev/null +++ b/src/adapters/contracts/client/progress/IClientProgressPort.ts @@ -0,0 +1,56 @@ +import type { Vector3 } from '../../../../kernel/utils/vector3' + +/** + * Progress task options used by client adapters. + */ +export interface ClientProgressOptions { + label: string + duration: number + useCircular?: boolean + canCancel?: boolean + disableControls?: boolean + disableMovement?: boolean + disableCombat?: boolean + animation?: { + dict: string + anim: string + flags?: number + } + prop?: { + model: string + bone: number + offset: Vector3 + rotation: Vector3 + } +} + +/** + * Runtime snapshot for an active progress task. + */ +export interface ClientProgressState { + active: boolean + progress: number + label: string + startTime: number + duration: number + options: ClientProgressOptions +} + +/** + * Intent-oriented client progress port. + * + * Adapters own the platform-specific implementation for task animations, + * props, controls, and HUD rendering while the framework exposes a stable API. + */ +export abstract class IClientProgressPort { + /** Starts a progress task and resolves to true when completed. */ + abstract start(options: ClientProgressOptions): Promise + /** Cancels the active task if one exists. */ + abstract cancel(): void + /** Returns whether a task is active. */ + abstract isActive(): boolean + /** Returns the current progress percentage. */ + abstract getProgress(): number + /** Returns the active task snapshot. */ + abstract getState(): ClientProgressState | null +} diff --git a/src/adapters/contracts/client/progress/index.ts b/src/adapters/contracts/client/progress/index.ts new file mode 100644 index 0000000..650a46c --- /dev/null +++ b/src/adapters/contracts/client/progress/index.ts @@ -0,0 +1 @@ +export * from './IClientProgressPort' diff --git a/src/adapters/contracts/client/spawn/IClientSpawnBridge.ts b/src/adapters/contracts/client/spawn/IClientSpawnBridge.ts new file mode 100644 index 0000000..6a19f25 --- /dev/null +++ b/src/adapters/contracts/client/spawn/IClientSpawnBridge.ts @@ -0,0 +1,6 @@ +import { IClientSpawnPort } from './IClientSpawnPort' + +/** + * @deprecated Use IClientSpawnPort for new runtime integrations. + */ +export abstract class IClientSpawnBridge extends IClientSpawnPort {} diff --git a/src/adapters/contracts/client/spawn/IClientSpawnPort.ts b/src/adapters/contracts/client/spawn/IClientSpawnPort.ts new file mode 100644 index 0000000..7553251 --- /dev/null +++ b/src/adapters/contracts/client/spawn/IClientSpawnPort.ts @@ -0,0 +1,23 @@ +import type { RespawnRequest, SpawnExecutionResult, SpawnRequest, TeleportRequest } from './types' + +export abstract class IClientSpawnPort { + /** + * Waits until the runtime reports that spawning can safely occur. + */ + abstract waitUntilReady(timeoutMs?: number): Promise + + /** + * Performs a full local-player spawn. + */ + abstract spawn(request: SpawnRequest): Promise + + /** + * Performs a respawn flow for the local player. + */ + abstract respawn(request: RespawnRequest): Promise + + /** + * Repositions the local player without a full spawn sequence. + */ + 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..285f6ad --- /dev/null +++ b/src/adapters/contracts/client/spawn/index.ts @@ -0,0 +1,4 @@ +export * from './IClientSpawnPort' +export * from './IClientSpawnBridge' +export * from './types' +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..57f6895 --- /dev/null +++ b/src/adapters/contracts/client/spawn/types.ts @@ -0,0 +1,21 @@ +import type { Vector3 } from '../../../../kernel/utils/vector3' + +export interface SpawnRequest { + position: Vector3 + model: string + heading?: number +} + +export interface SpawnExecutionResult { + localPlayerHandle?: 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..dfbd890 --- /dev/null +++ b/src/adapters/contracts/client/ui/webview/IClientWebViewBridge.ts @@ -0,0 +1,20 @@ +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 markAsChat(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..fc3d5b3 --- /dev/null +++ b/src/adapters/contracts/client/ui/webview/types.ts @@ -0,0 +1,30 @@ +export interface WebViewCapabilities { + supportsFocus: boolean + supportsCursor: boolean + supportsInputPassthrough: boolean + supportsBidirectionalMessaging: boolean + supportsExecute: boolean + supportsHeadless: boolean + supportsChatMode: boolean +} + +export interface WebViewDefinition { + id: string + url: string + visible?: boolean + focused?: boolean + cursor?: boolean + inputPassthrough?: boolean + chatMode?: boolean +} + +export interface WebViewFocusOptions { + cursor?: boolean + inputPassthrough?: boolean +} + +export interface WebViewMessage { + viewId: string + event: string + payload: unknown +} diff --git a/src/adapters/contracts/client/vehicle/IClientVehiclePort.ts b/src/adapters/contracts/client/vehicle/IClientVehiclePort.ts new file mode 100644 index 0000000..62795c6 --- /dev/null +++ b/src/adapters/contracts/client/vehicle/IClientVehiclePort.ts @@ -0,0 +1,195 @@ +import type { Vector3 } from '../../../../kernel/utils/vector3' + +/** + * High-level vehicle creation options understood by client adapters. + */ +export interface ClientVehicleSpawnOptions { + model: string + position: Vector3 + heading?: number + placeOnGround?: boolean + warpIntoVehicle?: boolean + seatIndex?: number + primaryColor?: number + secondaryColor?: number + plate?: string + networked?: boolean +} + +/** + * Common vehicle modification payload used by the framework. + */ +export interface ClientVehicleMods { + spoiler?: number + frontBumper?: number + rearBumper?: number + sideSkirt?: number + exhaust?: number + frame?: number + grille?: number + hood?: number + fender?: number + rightFender?: number + roof?: number + engine?: number + brakes?: number + transmission?: number + horns?: number + suspension?: number + armor?: number + turbo?: boolean + xenon?: boolean + wheelType?: number + wheels?: number + windowTint?: number + livery?: number + plateStyle?: number + neonEnabled?: [boolean, boolean, boolean, boolean] + neonColor?: [number, number, number] + extras?: Record + pearlescentColor?: number + wheelColor?: number +} + +/** + * Intent-oriented client vehicle port. + * + * The framework requests vehicle operations through this port and the adapter + * decides how to fulfill them for the active runtime. + */ +export abstract class IClientVehiclePort { + /** + * Spawns a vehicle and returns its runtime handle. + */ + abstract spawn(options: ClientVehicleSpawnOptions): Promise + + /** + * Deletes an existing vehicle handle. + */ + abstract delete(vehicle: number): void + + /** + * Repairs a vehicle and restores it to a drivable state. + */ + abstract repair(vehicle: number): void + + /** + * Sets normalized fuel in the 0..1 range unless the adapter documents otherwise. + */ + abstract setFuel(vehicle: number, level: number): void + + /** + * Gets normalized fuel in the 0..1 range unless the adapter documents otherwise. + */ + abstract getFuel(vehicle: number): number + + /** + * Returns the closest vehicle around the local player. + */ + abstract getClosest(radius?: number): number | null + + /** + * Returns whether the local player is currently inside any vehicle. + */ + abstract isLocalPlayerInVehicle(): boolean + + /** + * Returns the vehicle currently occupied by the local player. + */ + abstract getCurrentForLocalPlayer(): number | null + + /** + * Returns the last vehicle used by the local player. + */ + abstract getLastForLocalPlayer(): number | null + + /** + * Returns whether the local player is driving the provided vehicle. + */ + abstract isLocalPlayerDriver(vehicle: number): boolean + + /** + * Warps the local player into a seat. + */ + abstract warpLocalPlayerInto(vehicle: number, seatIndex?: number): void + + /** + * Makes the local player leave the provided vehicle. + */ + abstract leaveLocalPlayerVehicle(vehicle: number, flags?: number): void + + /** + * Applies visual/performance modifications to a vehicle. + */ + abstract applyMods(vehicle: number, mods: ClientVehicleMods): void + + /** + * Updates door lock state. + */ + abstract setDoorsLocked(vehicle: number, locked: boolean): void + + /** + * Starts or stops the engine. + */ + abstract setEngineRunning(vehicle: number, running: boolean, instant?: boolean): void + + /** + * Sets invincibility state for the vehicle entity. + */ + abstract setInvincible(vehicle: number, invincible: boolean): void + + /** + * Returns speed in meters per second. + */ + abstract getSpeed(vehicle: number): number + + /** + * Sets heading for a vehicle. + */ + abstract setHeading(vehicle: number, heading: number): void + + /** + * Teleports a vehicle to a world position. + */ + abstract teleport(vehicle: number, position: Vector3, heading?: number): void + + /** + * Returns whether a vehicle handle exists. + */ + abstract exists(vehicle: number): boolean + + /** + * Resolves the runtime network identifier for a vehicle. + */ + abstract getNetworkId(vehicle: number): number + + /** + * Resolves a vehicle handle from a runtime network identifier. + */ + abstract getFromNetworkId(networkId: number): number + + /** + * Reads a runtime state bag/state entry from a vehicle. + */ + abstract getState(vehicle: number, key: string): T | undefined + + /** + * Returns the world position of a vehicle. + */ + abstract getPosition(vehicle: number): Vector3 | null + + /** + * Returns the heading of a vehicle. + */ + abstract getHeading(vehicle: number): number + + /** + * Returns the model hash of a vehicle. + */ + abstract getModel(vehicle: number): number + + /** + * Returns the current number plate text. + */ + abstract getPlate(vehicle: number): string +} diff --git a/src/adapters/contracts/client/vehicle/index.ts b/src/adapters/contracts/client/vehicle/index.ts new file mode 100644 index 0000000..088e866 --- /dev/null +++ b/src/adapters/contracts/client/vehicle/index.ts @@ -0,0 +1 @@ +export * from './IClientVehiclePort' 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/events.api.ts b/src/adapters/contracts/transport/events.api.ts index ceb6fed..1b9e5ec 100644 --- a/src/adapters/contracts/transport/events.api.ts +++ b/src/adapters/contracts/transport/events.api.ts @@ -2,8 +2,8 @@ import { EventContext, RuntimeContext } from './context' import { Player } from '../../../runtime/server/entities/player' type EmitArgs = C extends 'server' - ? [target: Player | number | number[] | 'all', ...args: any[]] - : [...args: any[]] + ? [target: Player | number | number[] | 'all', ...args: unknown[]] + : [...args: unknown[]] /** * broadcast and listen to events without relying on runtime. The adapter will be used. @@ -17,9 +17,9 @@ export abstract class EventsAPI { * Client: * - triggered by server */ - abstract on( + abstract on( event: string, - handler: (ctx: EventContext, ...args: any[]) => void | Promise, + handler: (ctx: EventContext, ...args: TArgs) => void | Promise, ): void /** @@ -30,7 +30,7 @@ export abstract class EventsAPI { * Client: * - sends to server, targetOrArg will be ignored */ - abstract emit(event: string, target: Player | number | number[] | 'all', ...args: any[]): void + abstract emit(event: string, target: Player | number | number[] | 'all', ...args: unknown[]): void /** * Emit an event. 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/contracts/transport/rpc.api.ts b/src/adapters/contracts/transport/rpc.api.ts index 627584e..eaf3146 100644 --- a/src/adapters/contracts/transport/rpc.api.ts +++ b/src/adapters/contracts/transport/rpc.api.ts @@ -18,12 +18,12 @@ export type RpcTarget = number | number[] | 'all' export type RpcCallTarget = number | number[] type RpcCallArgs = C extends 'server' - ? [target: RpcCallTarget, ...args: any[]] - : [...args: any[]] + ? [target: RpcCallTarget, ...args: unknown[]] + : [...args: unknown[]] type RpcNotifyArgs = C extends 'server' - ? [target: RpcTarget, ...args: any[]] - : [...args: any[]] + ? [target: RpcTarget, ...args: unknown[]] + : [...args: unknown[]] /** * Remote Procedure Call API. @@ -56,7 +56,7 @@ export abstract class RpcAPI { * The handler receives a {@link RpcContext}. In server environments this usually includes * the `clientId` of the caller (via {@link EventContext}). */ - abstract on( + abstract on( name: string, handler: (ctx: RpcContext, ...args: TArgs) => TResult | Promise, ): void 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/node/transport/node.events.ts b/src/adapters/node/transport/node.events.ts index 4a04f70..5ad4e78 100644 --- a/src/adapters/node/transport/node.events.ts +++ b/src/adapters/node/transport/node.events.ts @@ -8,18 +8,18 @@ type NodeTarget = number | number[] | 'all' export class NodeEvents extends EventsAPI { private readonly emitter = new EventEmitter() - on( + on( event: string, - handler: (ctx: { clientId?: number; raw?: unknown }, ...args: any[]) => any, + handler: (ctx: { clientId?: number; raw?: unknown }, ...args: TArgs) => unknown, ): void { - this.emitter.on(event, (ctx: { clientId?: number; raw?: unknown }, ...args: any[]) => { - void Promise.resolve(handler(ctx, ...args)).catch((err) => { + this.emitter.on(event, (ctx: { clientId?: number; raw?: unknown }, ...args: unknown[]) => { + void Promise.resolve(handler(ctx, ...(args as unknown as TArgs))).catch((err) => { loggers.netEvent.error(`handler error for '${event}'`, {}, err) }) }) } - emit(event: string, targetOrArg?: NodeTarget | any, ...args: any[]): void { + emit(event: string, targetOrArg?: NodeTarget | unknown, ...args: unknown[]): void { if (targetOrArg === 'all' || typeof targetOrArg === 'number' || Array.isArray(targetOrArg)) { const target = (targetOrArg ?? 'all') as NodeTarget const payloadArgs = args @@ -43,7 +43,7 @@ export class NodeEvents extends EventsAPI { this.emitter.emit(event, { clientId: -1, raw: -1 }, targetOrArg, ...args) } - simulateClientEvent(event: string, clientId: number, ...args: any[]): void { + simulateClientEvent(event: string, clientId: number, ...args: unknown[]): void { this.emitter.emit(event, { clientId, raw: clientId }, ...args) } diff --git a/src/adapters/node/transport/node.rpc.ts b/src/adapters/node/transport/node.rpc.ts index 9d2615e..b8f75c0 100644 --- a/src/adapters/node/transport/node.rpc.ts +++ b/src/adapters/node/transport/node.rpc.ts @@ -5,29 +5,29 @@ import type { RuntimeContext } from '../../contracts/transport/context' export class NodeRpc extends RpcAPI { private readonly handlers = new Map< string, - (ctx: { requestId: string; clientId?: number; raw?: unknown }, ...args: any[]) => unknown + (ctx: { requestId: string; clientId?: number; raw?: unknown }, ...args: unknown[]) => unknown >() constructor(private readonly context: C) { super() } - on( + on( name: string, handler: ( ctx: { requestId: string; clientId?: number; raw?: unknown }, ...args: TArgs ) => TResult | Promise, ): void { - this.handlers.set(name, handler as any) + this.handlers.set(name, (ctx, ...args) => handler(ctx, ...(args as unknown as TArgs))) } - call(name: string, ...args: any[]): Promise { + call(name: string, ...args: unknown[]): Promise { const { target, payload } = this.normalizeInvocation(name, 'call', args) return this.executeCall(name, payload, target) } - notify(name: string, ...args: any[]): Promise { + notify(name: string, ...args: unknown[]): Promise { const { target, payload } = this.normalizeInvocation(name, 'notify', args) return this.executeNotify(name, payload, target) } @@ -35,8 +35,8 @@ export class NodeRpc extends RpcAPI extends RpcAPI( name: string, - payload: any[], + payload: readonly unknown[], _target?: RpcTarget, ): Promise { const handler = this.handlers.get(name) @@ -88,7 +88,11 @@ export class NodeRpc extends RpcAPI { + private async executeNotify( + name: string, + payload: readonly unknown[], + _target?: RpcTarget, + ): Promise { const handler = this.handlers.get(name) if (!handler) { return 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..8e22545 --- /dev/null +++ b/src/contracts.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 './runtime/shared/types/system-types' 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..1d737f2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,4 +2,5 @@ import 'reflect-metadata' export * from './kernel' export * from './runtime/core' -export * from './adapters/contracts' +export * from './contracts' +export * from './runtime/shared/types/system-types' 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..f739718 --- /dev/null +++ b/src/kernel/logger/client-log-console.ts @@ -0,0 +1,63 @@ +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('debug', message, details) + } + + debug(message: string, details?: unknown): void { + this.write('debug', message, details) + } + + info(message: string, details?: unknown): void { + this.write('info', message, details) + } + + warn(message: string, details?: unknown): void { + this.write('warn', message, details) + } + + error(message: string, details?: unknown): void { + this.write('error', message, details) + } + + private write( + level: 'debug' | 'info' | 'warn' | 'error', + message: string, + details?: unknown, + ): void { + if (details === undefined) { + console[level](message) + return + } + + console[level](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..5998b50 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,8 +20,16 @@ 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 { + getLogDomainLabel, + LogDomain, + LogDomainLabels, + LogLevel, + LogLevelLabels, + parseLogLevel, +} from './logger.types' export type { BufferedTransportOptions, LogOutputFormat } from './transports/buffered.transport' export { BufferedTransport } from './transports/buffered.transport' export type { ConsoleTransportOptions } from './transports/console.transport' diff --git a/src/kernel/logger/logger.types.ts b/src/kernel/logger/logger.types.ts index 54aa0d3..ed5508d 100644 --- a/src/kernel/logger/logger.types.ts +++ b/src/kernel/logger/logger.types.ts @@ -65,6 +65,52 @@ export const LogDomainLabels: Record = { [LogDomain.EXTERNAL]: 'EXTERNAL', } +declare const __OPENCORE_RESOURCE_NAME__: string | undefined + +function normalizeResourceName(resourceName: string): string { + return resourceName.trim().replace(/^\[(.*)\]$/, '$1') +} + +function getInjectedResourceName(): string | undefined { + if ( + typeof __OPENCORE_RESOURCE_NAME__ === 'string' && + normalizeResourceName(__OPENCORE_RESOURCE_NAME__).length > 0 + ) { + return normalizeResourceName(__OPENCORE_RESOURCE_NAME__) + } + + const fn = (globalThis as { GetCurrentResourceName?: unknown }).GetCurrentResourceName + if (typeof fn === 'function') { + try { + const value = fn() + if (typeof value === 'string' && normalizeResourceName(value).length > 0) { + return normalizeResourceName(value) + } + } catch { + // Ignore runtime lookup failures and fall back to default labels. + } + } + + return undefined +} + +export function getLogDomainLabel(domain: LogDomain): string { + if (domain !== LogDomain.FRAMEWORK) { + return LogDomainLabels[domain] + } + + const resourceName = getInjectedResourceName() + if (!resourceName) { + return LogDomainLabels[domain] + } + + if (resourceName.toLowerCase() === 'core') { + return 'CORE' + } + + return resourceName.toUpperCase() +} + /** * Additional contextual information that can be attached to any log entry. * Useful for tracing, debugging, and correlation. diff --git a/src/kernel/logger/transports/buffered.transport.ts b/src/kernel/logger/transports/buffered.transport.ts index 72a9b51..b4bde3e 100644 --- a/src/kernel/logger/transports/buffered.transport.ts +++ b/src/kernel/logger/transports/buffered.transport.ts @@ -1,4 +1,4 @@ -import { LogDomainLabels, type LogEntry, LogLevel, LogLevelLabels } from '../logger.types' +import { getLogDomainLabel, type LogEntry, LogLevel, LogLevelLabels } from '../logger.types' import { LogTransport } from './transport.interface' /** @@ -132,7 +132,7 @@ export class BufferedTransport implements LogTransport { const entries = this.buffer.map((entry) => ({ timestamp: entry.timestamp, level: LogLevelLabels[entry.level], - domain: LogDomainLabels[entry.domain], + domain: getLogDomainLabel(entry.domain), source: entry.context?.source, message: entry.message, context: this.cleanContext(entry.context), @@ -152,7 +152,7 @@ export class BufferedTransport implements LogTransport { .map((entry) => { const time = entry.timestamp.replace('T', ' ').slice(0, 23) const level = LogLevelLabels[entry.level].padEnd(5) - const domain = LogDomainLabels[entry.domain].padEnd(8) + const domain = getLogDomainLabel(entry.domain).padEnd(8) const source = entry.context?.source ? `[${entry.context.source}]` : '' let line = `${time} | ${domain} | ${level} | ${source} ${entry.message}` @@ -176,7 +176,7 @@ export class BufferedTransport implements LogTransport { const rows = this.buffer.map((entry) => [ entry.timestamp, LogLevelLabels[entry.level], - LogDomainLabels[entry.domain], + getLogDomainLabel(entry.domain), entry.context?.source ?? '', `"${entry.message.replace(/"/g, '""')}"`, JSON.stringify(this.cleanContext(entry.context)), diff --git a/src/kernel/logger/transports/console.transport.ts b/src/kernel/logger/transports/console.transport.ts index 6ece79b..f027544 100644 --- a/src/kernel/logger/transports/console.transport.ts +++ b/src/kernel/logger/transports/console.transport.ts @@ -1,6 +1,6 @@ import { + getLogDomainLabel, LogDomain, - LogDomainLabels, type LogEntry, LogLevel, LogLevelLabels, @@ -95,7 +95,7 @@ export class ConsoleTransport implements LogTransport { const { level, domain, message, timestamp, context, error } = entry const levelLabel = LogLevelLabels[level].padEnd(5) - const domainLabel = LogDomainLabels[domain] + const domainLabel = getLogDomainLabel(domain) const levelColor = this.colors ? LEVEL_COLORS[level] : '' const domainColor = this.colors ? DOMAIN_COLORS[domain] : '' const reset = this.colors ? COLORS.reset : '' 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..952e66d 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 { getLogDomainLabel, type LogEntry, LogLevel, LogLevelLabels } from '../logger.types' +import { getClientLogConsole } from '../client-log-console' import { LogTransport } from './transport.interface' export interface SimpleConsoleTransportOptions { @@ -47,9 +48,11 @@ 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] + const domainLabel = getLogDomainLabel(domain) // Build the log line without ANSI codes const parts: string[] = [] @@ -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..bc821dd --- /dev/null +++ b/src/runtime/client/adapter/index.ts @@ -0,0 +1,20 @@ +export * from './client-adapter' +export * from '../../../adapters/contracts/client/camera/IClientCameraPort' +export * from '../../../adapters/contracts/client/IClientLogConsole' +export * from '../../../adapters/contracts/client/ped/IClientPedPort' +export * from '../../../adapters/contracts/client/progress/IClientProgressPort' +export * from '../../../adapters/contracts/client/spawn/IClientSpawnPort' +export * from '../../../adapters/contracts/client/spawn/IClientSpawnBridge' +export * from '../../../adapters/contracts/client/vehicle/IClientVehiclePort' +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-camera-port.ts b/src/runtime/client/adapter/node-camera-port.ts new file mode 100644 index 0000000..f749f55 --- /dev/null +++ b/src/runtime/client/adapter/node-camera-port.ts @@ -0,0 +1,51 @@ +import { injectable } from 'tsyringe' +import { + type ClientCameraCreateOptions, + type ClientCameraRotation, + type ClientCameraRenderOptions, + type ClientCameraShakeOptions, + type ClientCameraTransform, + IClientCameraPort, +} from '../../../adapters/contracts/client/camera/IClientCameraPort' +import type { Vector3 } from '../../../kernel/utils/vector3' + +@injectable() +export class NodeClientCameraPort extends IClientCameraPort { + create(_options?: ClientCameraCreateOptions): number { + return 0 + } + + setActive(_camera: number, _active: boolean): void {} + + render(_enable: boolean, _options?: ClientCameraRenderOptions): void {} + + destroy(_camera: number, _destroyActiveCamera?: boolean): void {} + + destroyAll(_destroyActiveCamera?: boolean): void {} + + setTransform(_camera: number, _transform: ClientCameraTransform): void {} + + setPosition(_camera: number, _position: Vector3): void {} + + setRotation(_camera: number, _rotation: ClientCameraRotation, _rotationOrder?: number): void {} + + setFov(_camera: number, _fov: number): void {} + + pointAtCoords(_camera: number, _position: Vector3): void {} + + pointAtEntity(_camera: number, _entity: number, _offset?: Vector3): void {} + + stopPointing(_camera: number): void {} + + interpolate( + _fromCamera: number, + _toCamera: number, + _durationMs: number, + _easeLocation?: boolean, + _easeRotation?: boolean, + ): void {} + + shake(_camera: number, _options: ClientCameraShakeOptions): void {} + + stopShaking(_camera: number, _stopImmediately?: boolean): void {} +} 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..922c92e --- /dev/null +++ b/src/runtime/client/adapter/node-client-adapter.ts @@ -0,0 +1,106 @@ +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 { IClientSpawnPort } from '../../../adapters/contracts/client/spawn/IClientSpawnPort' +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 { NodeClientCameraPort } from './node-camera-port' +import { NodeClientMarkerBridge } from './node-marker-bridge' +import { NodeClientPedPort } from './node-ped-port' +import { NodeClientPlatformBridge } from './node-platform-bridge' +import { NodeClientProgressPort } from './node-progress-port' +import { NodeClientSpawnBridge } from './node-spawn-bridge' +import { NodeClientVehiclePort } from './node-vehicle-port' +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' +import { IClientCameraPort } from '../../../adapters/contracts/client/camera/IClientCameraPort' +import { IClientPedPort } from '../../../adapters/contracts/client/ped/IClientPedPort' +import { IClientProgressPort } from '../../../adapters/contracts/client/progress/IClientProgressPort' +import { IClientVehiclePort } from '../../../adapters/contracts/client/vehicle/IClientVehiclePort' + +/** + * 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( + IClientCameraPort as InjectionToken, + NodeClientCameraPort, + ) + ctx.bindSingleton( + IClientVehiclePort as InjectionToken, + NodeClientVehiclePort, + ) + ctx.bindSingleton(IClientPedPort as InjectionToken, NodeClientPedPort) + ctx.bindSingleton( + IClientProgressPort as InjectionToken, + NodeClientProgressPort, + ) + ctx.bindSingleton(IClientSpawnPort as InjectionToken, NodeClientSpawnBridge) + ctx.bindFactory(IClientSpawnBridge as InjectionToken, () => + ctx.container.resolve(IClientSpawnPort as InjectionToken), + ) + 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..7358d3b --- /dev/null +++ b/src/runtime/client/adapter/node-local-player-bridge.ts @@ -0,0 +1,23 @@ +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 { + getHandle(): number { + return 0 + } + + getPosition(): Vector3 { + return { x: 0, y: 0, z: 0 } + } + + getHeading(): number { + return 0 + } + + 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..b70a80d --- /dev/null +++ b/src/runtime/client/adapter/node-log-console.ts @@ -0,0 +1,56 @@ +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('debug', message, details) + } + + debug(message: string, details?: unknown): void { + this.write('debug', message, details) + } + + info(message: string, details?: unknown): void { + this.write('info', message, details) + } + + warn(message: string, details?: unknown): void { + this.write('warn', message, details) + } + + error(message: string, details?: unknown): void { + this.write('error', message, details) + } + + private write( + level: 'debug' | 'info' | 'warn' | 'error', + message: string, + details?: unknown, + ): void { + if (details === undefined) { + console[level](message) + return + } + + console[level](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-ped-port.ts b/src/runtime/client/adapter/node-ped-port.ts new file mode 100644 index 0000000..e863156 --- /dev/null +++ b/src/runtime/client/adapter/node-ped-port.ts @@ -0,0 +1,41 @@ +import { injectable } from 'tsyringe' +import { + type ClientPedAnimationOptions, + type ClientPedSpawnOptions, + IClientPedPort, +} from '../../../adapters/contracts/client/ped/IClientPedPort' +import type { Vector3 } from '../../../kernel/utils/vector3' + +@injectable() +export class NodeClientPedPort extends IClientPedPort { + async spawn(_options: ClientPedSpawnOptions): Promise { + return 0 + } + delete(_handle: number): void {} + exists(_handle: number): boolean { + return false + } + async playAnimation(_handle: number, _options: ClientPedAnimationOptions): Promise {} + stopAnimation(_handle: number): void {} + stopAnimationImmediately(_handle: number): void {} + freeze(_handle: number, _freeze: boolean): void {} + setInvincible(_handle: number, _invincible: boolean): void {} + giveWeapon( + _handle: number, + _weapon: string, + _ammo = 100, + _hidden = false, + _forceInHand = true, + ): void {} + removeAllWeapons(_handle: number): void {} + getClosest(_radius = 10, _excludeLocalPlayer = true): number | null { + return null + } + getNearby(_position: Vector3, _radius: number, _excludeEntity?: number): number[] { + return [] + } + lookAtEntity(_handle: number, _entity: number, _duration = -1): void {} + lookAtCoords(_handle: number, _position: Vector3, _duration = -1): void {} + walkTo(_handle: number, _position: Vector3, _speed = 1): void {} + setCombatAttributes(_handle: number, _canFight: boolean, _canUseCover = true): 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-progress-port.ts b/src/runtime/client/adapter/node-progress-port.ts new file mode 100644 index 0000000..c87af85 --- /dev/null +++ b/src/runtime/client/adapter/node-progress-port.ts @@ -0,0 +1,27 @@ +import { injectable } from 'tsyringe' +import { + type ClientProgressOptions, + type ClientProgressState, + IClientProgressPort, +} from '../../../adapters/contracts/client/progress/IClientProgressPort' + +@injectable() +export class NodeClientProgressPort extends IClientProgressPort { + start(_options: ClientProgressOptions): Promise { + return Promise.resolve(true) + } + + cancel(): void {} + + isActive(): boolean { + return false + } + + getProgress(): number { + return 0 + } + + getState(): ClientProgressState | null { + return null + } +} 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..27b8d8c --- /dev/null +++ b/src/runtime/client/adapter/node-runtime-bridge.ts @@ -0,0 +1,78 @@ +import { EventEmitter } from 'node:events' +import { injectable } from 'tsyringe' +import { IClientRuntimeBridge } from './runtime-bridge' + +type WebViewCallback = (data: unknown, cb: (response: unknown) => void) => void | Promise + +/** + * 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: (...args: TArgs) => void | Promise, + ): void { + this.events.on(eventName, (...args) => { + void handler(...(args as unknown as TArgs)) + }) + } + + 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: (...args: TArgs) => TResult, + ): 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..ac578cb --- /dev/null +++ b/src/runtime/client/adapter/node-spawn-bridge.ts @@ -0,0 +1,23 @@ +import { injectable } from 'tsyringe' +import { IClientSpawnPort } from '../../../adapters/contracts/client/spawn/IClientSpawnPort' +import type { + RespawnRequest, + SpawnExecutionResult, + SpawnRequest, + TeleportRequest, +} from '../../../adapters/contracts/client/spawn/types' + +@injectable() +export class NodeClientSpawnBridge extends IClientSpawnPort { + async waitUntilReady(_timeoutMs?: number): Promise {} + + async spawn(_request: SpawnRequest): Promise { + return {} + } + + async respawn(_request: RespawnRequest): Promise { + return {} + } + + async teleport(_request: TeleportRequest): Promise {} +} diff --git a/src/runtime/client/adapter/node-vehicle-port.ts b/src/runtime/client/adapter/node-vehicle-port.ts new file mode 100644 index 0000000..77e1d5f --- /dev/null +++ b/src/runtime/client/adapter/node-vehicle-port.ts @@ -0,0 +1,70 @@ +import { injectable } from 'tsyringe' +import { + type ClientVehicleMods, + type ClientVehicleSpawnOptions, + IClientVehiclePort, +} from '../../../adapters/contracts/client/vehicle/IClientVehiclePort' +import type { Vector3 } from '../../../kernel/utils/vector3' + +@injectable() +export class NodeClientVehiclePort extends IClientVehiclePort { + async spawn(_options: ClientVehicleSpawnOptions): Promise { + return 0 + } + delete(_vehicle: number): void {} + repair(_vehicle: number): void {} + setFuel(_vehicle: number, _level: number): void {} + getFuel(_vehicle: number): number { + return 0 + } + getClosest(_radius = 10): number | null { + return null + } + isLocalPlayerInVehicle(): boolean { + return false + } + getCurrentForLocalPlayer(): number | null { + return null + } + getLastForLocalPlayer(): number | null { + return null + } + isLocalPlayerDriver(_vehicle: number): boolean { + return false + } + warpLocalPlayerInto(_vehicle: number, _seatIndex = -1): void {} + leaveLocalPlayerVehicle(_vehicle: number, _flags = 16): void {} + applyMods(_vehicle: number, _mods: ClientVehicleMods): void {} + setDoorsLocked(_vehicle: number, _locked: boolean): void {} + setEngineRunning(_vehicle: number, _running: boolean, _instant = false): void {} + setInvincible(_vehicle: number, _invincible: boolean): void {} + getSpeed(_vehicle: number): number { + return 0 + } + setHeading(_vehicle: number, _heading: number): void {} + teleport(_vehicle: number, _position: Vector3, _heading?: number): void {} + exists(_vehicle: number): boolean { + return false + } + getNetworkId(_vehicle: number): number { + return 0 + } + getFromNetworkId(_networkId: number): number { + return 0 + } + getState(_vehicle: number, _key: string): T | undefined { + return undefined + } + getPosition(_vehicle: number): Vector3 | null { + return null + } + getHeading(_vehicle: number): number { + return 0 + } + getModel(_vehicle: number): number { + return 0 + } + getPlate(_vehicle: number): string { + return '' + } +} 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..7e37d2d --- /dev/null +++ b/src/runtime/client/adapter/node-webview-bridge.ts @@ -0,0 +1,44 @@ +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, + supportsChatMode: 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 {} + markAsChat(_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..2efa991 100644 --- a/src/runtime/client/controllers/appearance.controller.ts +++ b/src/runtime/client/controllers/appearance.controller.ts @@ -1,21 +1,29 @@ import { inject } from 'tsyringe' import { PlayerAppearance } from '../../../kernel/shared' +import { SYSTEM_EVENTS } from '../../shared/types/system-types' import { Controller, OnNet } from '../decorators' +import { IClientLocalPlayerBridge } from '../adapter/local-player-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(IClientLocalPlayerBridge as any) private readonly localPlayer: IClientLocalPlayerBridge, ) {} - @OnNet('opencore:appearance:apply') + @OnNet(SYSTEM_EVENTS.appearance.apply) async onApply(appearance: PlayerAppearance): Promise { - const ped = PlayerPedId() + loggers.netEvent.debug('appearance:apply received', { + appearance, + }) + const ped = this.localPlayer.getHandle() await this.appearanceService.applyAppearance(ped, appearance) } - @OnNet('opencore:appearance:reset') + @OnNet(SYSTEM_EVENTS.appearance.reset) onReset(): void { - const ped = PlayerPedId() + loggers.netEvent.debug('appearance:reset received') + const ped = this.localPlayer.getHandle() 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..ae533ac 100644 --- a/src/runtime/client/controllers/spawner.controller.ts +++ b/src/runtime/client/controllers/spawner.controller.ts @@ -1,4 +1,6 @@ import { Vector3 } from '../../../kernel/utils/vector3' +import { loggers } from '../../../kernel/logger' +import { SYSTEM_EVENTS } from '../../shared/types/system-types' import { Controller, OnNet } from '../decorators' import { SpawnService } from '../services' @@ -6,17 +8,18 @@ import { SpawnService } from '../services' export class SpawnerController { constructor(private readonly spawnService: SpawnService) {} - @OnNet('opencore:spawner:spawn') + @OnNet(SYSTEM_EVENTS.spawner.spawn) async handleSpawn(data: { position: Vector3; model: string }) { + loggers.spawn.debug('Spawn event received', data) await this.spawnService.spawn(data.position, data.model) } - @OnNet('opencore:spawner:respawn') + @OnNet(SYSTEM_EVENTS.spawner.respawn) async handleRespawn(position: Vector3, heading = 0.0): Promise { await this.spawnService.respawn(position, heading) } - @OnNet('opencore:spawner:teleport') + @OnNet(SYSTEM_EVENTS.spawner.teleport) async handleTeleport(position: Vector3, heading?: number): Promise { await this.spawnService.teleportTo(position, heading) } 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..ee81327 100644 --- a/src/runtime/client/services/camera.ts +++ b/src/runtime/client/services/camera.ts @@ -1,197 +1,92 @@ -import { injectable } from 'tsyringe' +import { inject, injectable } from 'tsyringe' import { Vector3 } from '../../../kernel/utils/vector3' - -/** - * 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 +import { + type ClientCameraCreateOptions as CameraCreateOptions, + type ClientCameraRenderOptions as CameraRenderOptions, + type ClientCameraRotation as CameraRotation, + type ClientCameraShakeOptions as CameraShakeOptions, + type ClientCameraTransform as CameraTransform, + IClientCameraPort, +} from '../../../adapters/contracts/client/camera/IClientCameraPort' + +export type { + CameraCreateOptions, + CameraRenderOptions, + CameraRotation, + CameraShakeOptions, + CameraTransform, } -/** - * 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) - } - - if (options.active) { - this.activeCam = cam - } + constructor(@inject(IClientCameraPort as any) private readonly cameras: IClientCameraPort) {} + create(options: CameraCreateOptions = {}): number { + const cam = this.cameras.create(options) + 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.cameras.setActive(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.cameras.render(enable, options) 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.cameras.destroy(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.cameras.destroyAll(destroyActiveCam) this.activeCam = null } - /** - * Sets camera world position. - */ setPosition(cam: number, position: Vector3): void { - SetCamCoord(cam, position.x, position.y, position.z) + this.cameras.setPosition(cam, position) } - /** - * Sets camera world rotation. - */ setRotation(cam: number, rotation: CameraRotation, rotationOrder = 2): void { - SetCamRot(cam, rotation.x, rotation.y, rotation.z, rotationOrder) + this.cameras.setRotation(cam, rotation, rotationOrder) } - /** - * Sets camera field of view. - */ setFov(cam: number, fov: number): void { - SetCamFov(cam, fov) + this.cameras.setFov(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) - } + this.cameras.setTransform(cam, transform) } - /** - * Points a camera at world coordinates. - */ pointAtCoords(cam: number, position: Vector3): void { - PointCamAtCoord(cam, position.x, position.y, position.z) + this.cameras.pointAtCoords(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.cameras.pointAtEntity(cam, entity, offset) } - /** - * Removes point-at target from the camera. - */ stopPointing(cam: number): void { - StopCamPointing(cam) + this.cameras.stopPointing(cam) } - /** - * Interpolates from one camera to another using native interpolation. - */ interpolate( fromCam: number, toCam: number, @@ -199,31 +94,20 @@ export class Camera { easeLocation = true, easeRotation = true, ): void { - SetCamActiveWithInterp(toCam, fromCam, durationMs, easeLocation ? 1 : 0, easeRotation ? 1 : 0) + this.cameras.interpolate(fromCam, toCam, durationMs, easeLocation, easeRotation) this.activeCam = toCam } - /** - * Starts a camera shake effect. - */ shake(cam: number, options: CameraShakeOptions): void { - ShakeCam(cam, options.type, options.amplitude) + this.cameras.shake(cam, options) } - /** - * Stops camera shake for a camera. - */ stopShaking(cam: number, stopImmediately = true): void { - StopCamShaking(cam, stopImmediately) + this.cameras.stopShaking(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..b2aaa68 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 { IClientLocalPlayerBridge } from '../adapter/local-player-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(IClientLocalPlayerBridge as any) private readonly localPlayer: IClientLocalPlayerBridge, + ) {} + 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.localPlayer.getPosition(), + }) + } + + 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..1eed01c 100644 --- a/src/runtime/client/services/ped.service.ts +++ b/src/runtime/client/services/ped.service.ts @@ -1,39 +1,13 @@ -import { injectable } from 'tsyringe' +import { inject, injectable } from 'tsyringe' import { Vector3 } from '../../../kernel/utils/vector3' +import { + type ClientPedAnimationOptions as PedAnimationOptions, + type ClientPedSpawnOptions as PedSpawnOptions, + IClientPedPort, +} from '../../../adapters/contracts/client/ped/IClientPedPort' +import { IClientLocalPlayerBridge } from '../adapter/local-player-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 -} +export type { PedSpawnOptions, PedAnimationOptions } export interface ManagedPed { id: string @@ -42,109 +16,35 @@ 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 - */ - async spawn(options: PedSpawnOptions): Promise<{ id: string; handle: number }> { - const { - model, - position, - heading = 0, - networked = false, - missionEntity = true, - blockEvents = true, - } = options - - const modelHash = GetHashKey(model) - - // Load the model - if (!IsModelInCdimage(modelHash) || !IsModelValid(modelHash)) { - throw new Error(`Invalid ped model: ${model}`) - } - - RequestModel(modelHash) - while (!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) - } + constructor( + @inject(IClientPedPort as any) private readonly pedsPort: IClientPedPort, + @inject(IClientLocalPlayerBridge as any) private readonly localPlayer: IClientLocalPlayerBridge, + ) {} - // Set default relationship (neutral) - SetPedRelationshipGroupHash(ped, GetHashKey('CIVMALE')) + async spawn(options: PedSpawnOptions): Promise<{ id: string; handle: number }> { + const ped = await this.pedsPort.spawn(options) + if (!ped || ped === 0) throw new Error('Failed to create ped') - // Register in our map const id = `ped_${++this.idCounter}` - this.peds.set(id, { id, handle: ped, model, position }) - + this.peds.set(id, { id, handle: ped, model: options.model, position: options.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) - } - + this.pedsPort.delete(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) - } - - // Remove from our map if tracked + this.pedsPort.delete(handle) for (const [id, ped] of this.peds) { if (ped.handle === handle) { this.peds.delete(id) @@ -153,247 +53,81 @@ 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) - } + this.pedsPort.delete(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, - anim, - blendInSpeed = 8.0, - blendOutSpeed = -8.0, - duration = -1, - flags = 1, - playbackRate = 0.0, - } = options - - if (!DoesEntityExist(handle)) return - - // Load anim dict - RequestAnimDict(dict) - while (!HasAnimDictLoaded(dict)) { - await new Promise((r) => setTimeout(r, 0)) - } - - TaskPlayAnim( - handle, - dict, - anim, - blendInSpeed, - blendOutSpeed, - duration, - flags, - playbackRate, - false, - false, - false, - ) + await this.pedsPort.playAnimation(handle, options) } - /** - * Stop all animations on a ped. - * - * @param handle - Ped handle - */ stopAnimation(handle: number): void { - if (!DoesEntityExist(handle)) return - ClearPedTasks(handle) + this.pedsPort.stopAnimation(handle) } - /** - * Stop animation immediately on a ped. - * - * @param handle - Ped handle - */ stopAnimationImmediately(handle: number): void { - if (!DoesEntityExist(handle)) return - ClearPedTasksImmediately(handle) + this.pedsPort.stopAnimationImmediately(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) + this.pedsPort.freeze(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) + this.pedsPort.setInvincible(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) + this.pedsPort.giveWeapon(handle, 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) + this.pedsPort.removeAllWeapons(handle) } - /** - * 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.localPlayer.getHandle() + const handle = this.pedsPort.getClosest(radius, excludePlayer) + 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.pedsPort.getNearby( + position, + radius, + excludePlayer ? this.localPlayer.getHandle() : 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) + this.pedsPort.lookAtEntity(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) + this.pedsPort.lookAtCoords(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) + this.pedsPort.walkTo(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 + this.pedsPort.setCombatAttributes(handle, canFight, 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.pedsPort.exists(ped.handle) : false } } diff --git a/src/runtime/client/services/progress.service.ts b/src/runtime/client/services/progress.service.ts index e19d405..76f955b 100644 --- a/src/runtime/client/services/progress.service.ts +++ b/src/runtime/client/services/progress.service.ts @@ -1,287 +1,33 @@ -import { injectable } from 'tsyringe' +import { inject, injectable } from 'tsyringe' +import { + type ClientProgressOptions as ProgressOptions, + type ClientProgressState as ProgressState, + IClientProgressPort, +} from '../../../adapters/contracts/client/progress/IClientProgressPort' -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 - offset: { x: number; y: number; z: number } - rotation: { x: number; y: number; z: number } - } -} - -export interface ProgressState { - active: boolean - progress: number - label: string - startTime: number - duration: number - options: ProgressOptions -} - -type ProgressCallback = (completed: boolean) => void +export type { ProgressOptions, ProgressState } -/** - * Service for displaying progress bars/indicators. - */ @injectable() export class ProgressService { - private state: ProgressState | null = null - private tickHandle: number | null = null - private callback: ProgressCallback | null = null - private propHandle: number | null = null + constructor( + @inject(IClientProgressPort as any) private readonly progressPort: IClientProgressPort, + ) {} - /** - * 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 - } - - return new Promise((resolve) => { - this.state = { - active: true, - progress: 0, - label: options.label, - startTime: GetGameTimer(), - duration: options.duration, - options, - } - - this.callback = resolve - this.startProgress() - }) + return this.progressPort.start(options) } - /** - * Cancel the current progress. - */ cancel(): void { - if (!this.state?.active) return - - this.cleanup(false) + this.progressPort.cancel() } - /** - * Check if a progress is currently active. - */ isActive(): boolean { - return this.state?.active ?? false + return this.progressPort.isActive() } - - /** - * Get current progress percentage (0-100). - */ getProgress(): number { - return this.state?.progress ?? 0 + return this.progressPort.getProgress() } - - /** - * 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() - - // Load and play animation if specified - if (options.animation) { - await this.loadAnimDict(options.animation.dict) - TaskPlayAnim( - ped, - options.animation.dict, - options.animation.anim, - 8.0, - -8.0, - 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( - 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, - ) - } - - // Start the tick - this.tickHandle = setTick(() => { - if (!this.state) return - - const elapsed = GetGameTimer() - this.state.startTime - this.state.progress = Math.min((elapsed / this.state.duration) * 100, 100) - - // Handle controls - if (options.disableControls) { - 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 - } - 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 - } - } - - // Check for cancel - if (options.canCancel && IsControlJustPressed(0, 200)) { - // ESC key - this.cancel() - return - } - - // Draw progress bar (native style) - this.drawProgressBar() - - // Check completion - 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) - } - } - - private cleanup(completed: boolean): void { - const ped = PlayerPedId() - - // Stop animation - if (this.state?.options.animation) { - StopAnimTask(ped, this.state.options.animation.dict, this.state.options.animation.anim, 1.0) - } - - // Remove prop - if (this.propHandle) { - DeleteEntity(this.propHandle) - this.propHandle = null - } - - // Clear tick - if (this.tickHandle !== null) { - clearTick(this.tickHandle) - this.tickHandle = null - } - - // Clear busy spinner - if (this.state?.options.useCircular) { - BusyspinnerOff() - } - - // Reset state - this.state = null - - // Invoke callback - if (this.callback) { - this.callback(completed) - this.callback = null - } - } - - private async loadAnimDict(dict: string): Promise { - RequestAnimDict(dict) - while (!HasAnimDictLoaded(dict)) { - await new Promise((r) => setTimeout(r, 0)) - } - } - - private async loadModel(model: string): Promise { - const hash = GetHashKey(model) - RequestModel(hash) - while (!HasModelLoaded(hash)) { - await new Promise((r) => setTimeout(r, 0)) - } + return this.progressPort.getState() } } 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..ed9f22b --- /dev/null +++ b/src/runtime/client/services/session-bridge.service.ts @@ -0,0 +1,49 @@ +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 { SYSTEM_EVENTS } from '../../shared/types/system-types' +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(SYSTEM_EVENTS.session.playerInit, (_ctx, data: { playerId: string }) => { + this.playerId = data.playerId + clientSession.info('Player session initialized', { playerId: data.playerId }) + }) + + this.events.on( + SYSTEM_EVENTS.session.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..28aada2 100644 --- a/src/runtime/client/services/spawn.service.ts +++ b/src/runtime/client/services/spawn.service.ts @@ -1,59 +1,27 @@ -import { injectable } from 'tsyringe' +import { inject, injectable } from 'tsyringe' import { loggers, PlayerAppearance } from '../../../kernel' import { Vector3 } from '../../../kernel/utils/vector3' import { AppearanceService } from './appearance.service' - -const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) +import { IClientSpawnPort } from '../../../adapters/contracts/client/spawn/IClientSpawnPort' 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(IClientSpawnPort as any) private readonly spawnPort: IClientSpawnPort, + ) {} 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 +32,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.spawnPort.waitUntilReady() + loggers.spawn.debug('Spawn bridge ready, executing spawn', { position, model, heading }) + const outcome = await this.spawnPort.spawn({ position, model, heading }) + + const ped = outcome.localPlayerHandle ?? 0 + 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 +66,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.spawnPort.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.spawnPort.waitUntilReady() + await this.spawnPort.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..90c3584 100644 --- a/src/runtime/client/services/vehicle-client.service.ts +++ b/src/runtime/client/services/vehicle-client.service.ts @@ -1,505 +1,257 @@ -import { injectable } from 'tsyringe' +import { inject, injectable } from 'tsyringe' +import { IClientVehiclePort } from '../../../adapters/contracts/client/vehicle/IClientVehiclePort' +import { EventsAPI } from '../../../adapters/contracts/transport/events.api' import { Vector3 } from '../../../kernel/utils/vector3' +import { SYSTEM_EVENTS } from '../../shared/types/system-types' +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(IClientVehiclePort as any) private readonly vehicles: IClientVehiclePort, + @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(SYSTEM_EVENTS.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(SYSTEM_EVENTS.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(SYSTEM_EVENTS.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 + return this.vehicles.getClosest(radius) } - /** - * Checks if the player is in a vehicle. - */ isPlayerInVehicle(): boolean { - return IsPedInAnyVehicle(PlayerPedId(), false) + return this.vehicles.isLocalPlayerInVehicle() } - /** - * 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) + return this.vehicles.getCurrentForLocalPlayer() } - /** - * 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.vehicles.getLastForLocalPlayer() } - /** - * 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.vehicles.isLocalPlayerDriver(vehicle) } - /** - * Gets vehicle speed in km/h. - * - * @param vehicle - Vehicle handle - */ getSpeed(vehicle: number): number { - if (!DoesEntityExist(vehicle)) return 0 - return GetEntitySpeed(vehicle) * 3.6 + return this.vehicles.getSpeed(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) + return this.vehicles.getNetworkId(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) + return this.vehicles.getFromNetworkId(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 + return this.vehicles.getState(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(SYSTEM_EVENTS.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(SYSTEM_EVENTS.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(SYSTEM_EVENTS.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) + this.vehicles.warpLocalPlayerInto(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) + this.vehicles.setHeading(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] } + return this.vehicles.getPosition(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) + return this.vehicles.getHeading(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) + return this.vehicles.getModel(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) + return this.vehicles.getPlate(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 (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]) - } - - if (mods.neonColor !== undefined) { - 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) - } - } - - if (mods.pearlescentColor !== undefined || mods.wheelColor !== undefined) { - const [currentPearl, currentWheel] = GetVehicleExtraColours(vehicle) - SetVehicleExtraColours( - vehicle, - mods.pearlescentColor ?? currentPearl, - mods.wheelColor ?? currentWheel, - ) - } + this.vehicles.applyMods(vehicle, mods) } - /** - * 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) + this.vehicles.repair(vehicle) } - /** - * 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))) + this.vehicles.setFuel(vehicle, level) } - /** - * Registers event handlers for server responses. - */ private registerEventHandlers(): void { - onNet( - '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) - } - } + this.events.on( + SYSTEM_EVENTS.vehicle.createResult, + (_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)) - } - - if (veh && DoesEntityExist(veh)) { - // Apply mods from server data - if (data.mods && Object.keys(data.mods).length > 0) { - this.applyMods(veh, data.mods) - } - - // Apply fuel from metadata - if (data.metadata?.fuel !== undefined) { - this.setFuel(veh, data.metadata.fuel) - } - } - }) + this.events.on( + SYSTEM_EVENTS.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) + }, + ) + + this.events.on( + SYSTEM_EVENTS.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) + }, + ) - onNet('opencore:vehicle:deleted', (networkId: number) => { - console.log('[VehicleClient] Vehicle deleted:', networkId) + this.events.on(SYSTEM_EVENTS.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:modified', (data: { networkId: number; mods: any }) => { - const veh = this.getVehicleFromNetworkId(data.networkId) - if (veh && DoesEntityExist(veh)) { - this.applyMods(veh, data.mods) - } + this.events.on( + SYSTEM_EVENTS.vehicle.playerVehiclesResult, + (_ctx, vehicles: SerializedVehicleData[]) => { + this.pendingPlayerVehicles?.(vehicles) + this.pendingPlayerVehicles = null + }, + ) + + this.events.on(SYSTEM_EVENTS.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:repaired', (networkId: number) => { + this.events.on( + SYSTEM_EVENTS.vehicle.modified, + (_ctx, data: { networkId: number; mods: any }) => { + const veh = this.getVehicleFromNetworkId(data.networkId) + if (veh && this.vehicles.exists(veh)) this.applyMods(veh, data.mods) + }, + ) + + this.events.on(SYSTEM_EVENTS.vehicle.repaired, (_ctx, networkId: number) => { const veh = this.getVehicleFromNetworkId(networkId) - if (veh && DoesEntityExist(veh)) { - this.repair(veh) - } + if (veh && this.vehicles.exists(veh)) this.repair(veh) }) - onNet('opencore:vehicle:lockedChanged', (data: { networkId: number; locked: boolean }) => { - console.log('[VehicleClient] Vehicle lock changed:', data.networkId, data.locked) - }) + this.events.on( + SYSTEM_EVENTS.vehicle.warpInto, + async (_ctx, networkId: number, seatIndex: number = -1) => { + const veh = await this.waitForVehicle(networkId) + if (veh) this.warpIntoVehicle(veh, seatIndex) + }, + ) + } - 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)) - } - - 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.vehicles.exists(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..2afe284 100644 --- a/src/runtime/client/services/vehicle.service.ts +++ b/src/runtime/client/services/vehicle.service.ts @@ -1,354 +1,92 @@ -import { injectable } from 'tsyringe' +import { inject, injectable } from 'tsyringe' import { Vector3 } from '../../../kernel/utils/vector3' +import { + type ClientVehicleMods as VehicleMods, + type ClientVehicleSpawnOptions as VehicleSpawnOptions, + IClientVehiclePort, +} from '../../../adapters/contracts/client/vehicle/IClientVehiclePort' -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 -} - -export interface VehicleMods { - spoiler?: number - frontBumper?: number - rearBumper?: number - sideSkirt?: number - exhaust?: number - frame?: number - grille?: number - hood?: number - fender?: number - rightFender?: number - roof?: number - engine?: number - brakes?: number - transmission?: number - horns?: number - suspension?: number - armor?: number - turbo?: boolean - xenon?: boolean - wheelType?: number - wheels?: number - windowTint?: number - livery?: number - plateStyle?: number -} +export type { VehicleSpawnOptions, VehicleMods } -/** - * Service for vehicle operations and management. - */ @injectable() export class VehicleService { - /** - * Spawn a vehicle at a position. - * - * @param options - Spawn options - * @returns The vehicle handle - */ - async spawn(options: VehicleSpawnOptions): Promise { - const { - model, - position, - heading = 0, - placeOnGround = true, - warpIntoVehicle = false, - seatIndex = -1, - primaryColor, - secondaryColor, - plate, - networked = true, - } = options - - const modelHash = GetHashKey(model) - - // Load the model - if (!IsModelInCdimage(modelHash) || !IsModelAVehicle(modelHash)) { - throw new Error(`Invalid vehicle model: ${model}`) - } - - RequestModel(modelHash) - while (!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) - } + constructor(@inject(IClientVehiclePort as any) private readonly vehicles: IClientVehiclePort) {} - // Set colors - 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) - } - - return vehicle + async spawn(options: VehicleSpawnOptions): Promise { + return this.vehicles.spawn(options) } - /** - * Delete a vehicle. - * - * @param vehicle - Vehicle handle - */ delete(vehicle: number): void { - if (DoesEntityExist(vehicle)) { - SetEntityAsMissionEntity(vehicle, true, true) - DeleteVehicle(vehicle) - } + this.vehicles.delete(vehicle) } - /** - * Delete the vehicle the player is currently in. - */ deleteCurrentVehicle(): void { const vehicle = this.getCurrentVehicle() if (vehicle) { - TaskLeaveVehicle(PlayerPedId(), vehicle, 16) + this.vehicles.leaveLocalPlayerVehicle(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) + this.vehicles.repair(vehicle) } - /** - * 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))) + this.vehicles.setFuel(vehicle, level) } - /** - * 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 + return this.vehicles.getFuel(vehicle) } - /** - * 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 + return this.vehicles.getClosest(radius) } - /** - * Check if the player is in a vehicle. - */ isPlayerInVehicle(): boolean { - return IsPedInAnyVehicle(PlayerPedId(), false) + return this.vehicles.isLocalPlayerInVehicle() } - /** - * 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) + return this.vehicles.getCurrentForLocalPlayer() } - /** - * 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.vehicles.getLastForLocalPlayer() } - /** - * 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.vehicles.isLocalPlayerDriver(vehicle) } - /** - * 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) + this.vehicles.applyMods(vehicle, mods) } - /** - * 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) + this.vehicles.setDoorsLocked(vehicle, locked) } - /** - * 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) + this.vehicles.setEngineRunning(vehicle, running, instant) } - /** - * 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) + this.vehicles.setInvincible(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 + return this.vehicles.getSpeed(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) + this.vehicles.setHeading(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) + this.vehicles.teleport(vehicle, position, heading) } } 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..83e521c --- /dev/null +++ b/src/runtime/client/webview-bridge.ts @@ -0,0 +1,119 @@ +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 + chatMode?: 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) + } + + getCapabilities() { + return this.service.getCapabilities() + } + + 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) + } + markAsChat(): void { + this.service.markAsChat(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..068674d --- /dev/null +++ b/src/runtime/client/webview.service.ts @@ -0,0 +1,112 @@ +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, + supportsChatMode: 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), + markAsChat: () => {}, + 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) + } + markAsChat(viewId: string): void { + this.bridge.markAsChat(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..af2819e --- /dev/null +++ b/src/runtime/server/adapter/node-player-appearance-lifecycle-server.ts @@ -0,0 +1,90 @@ +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' +import { SYSTEM_EVENTS } from '../../shared/types/system-types' + +@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(SYSTEM_EVENTS.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(SYSTEM_EVENTS.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..ee15d13 --- /dev/null +++ b/src/runtime/server/adapter/node-player-lifecycle-server.ts @@ -0,0 +1,47 @@ +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' +import { SYSTEM_EVENTS } from '../../shared/types/system-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(SYSTEM_EVENTS.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(SYSTEM_EVENTS.spawner.teleport, target, request.position, request.heading) + } + + respawn(playerSrc: string, request: RespawnPlayerRequest): void { + const target = this.resolveTarget(playerSrc) + if (!target) return + + this.events.emit(SYSTEM_EVENTS.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..1f0c17e --- /dev/null +++ b/src/runtime/server/adapter/node-vehicle-lifecycle-server.ts @@ -0,0 +1,54 @@ +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' +import { SYSTEM_EVENTS } from '../../shared/types/system-types' + +@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(SYSTEM_EVENTS.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/chat.api.ts b/src/runtime/server/apis/chat.api.ts index f4e8f81..7016aa1 100644 --- a/src/runtime/server/apis/chat.api.ts +++ b/src/runtime/server/apis/chat.api.ts @@ -1,6 +1,7 @@ import { inject, injectable } from 'tsyringe' import { EventsAPI } from '../../../adapters/contracts/transport/events.api' import { RGB } from '../../../kernel/utils/rgb' +import { SYSTEM_EVENTS } from '../../shared/types/system-types' import { Player } from '../entities/player' import { Players } from '../ports/players.api-port' @@ -25,7 +26,7 @@ export class Chat { * @param color - Message color (RGB). Defaults to white. */ broadcast(message: string, author: string = 'SYSTEM', color: RGB = { r: 255, g: 255, b: 255 }) { - this.events.emit('core:chat:message', 'all', { + this.events.emit(SYSTEM_EVENTS.chat.message, 'all', { args: [author, message], color: color, }) @@ -45,7 +46,7 @@ export class Chat { author: string = 'Private', color: RGB = { r: 200, g: 200, b: 200 }, ) { - this.events.emit('core:chat:addMessage', player.clientID, { + this.events.emit(SYSTEM_EVENTS.chat.addMessage, player.clientID, { args: [author, message], color: color, }) @@ -57,7 +58,7 @@ export class Chat { * @param player - Target player. */ clearChat(player: Player) { - this.events.emit('core:chat:clear', player.clientID) + this.events.emit(SYSTEM_EVENTS.chat.clear, player.clientID) } /** @@ -75,7 +76,7 @@ export class Chat { color: RGB = { r: 255, g: 255, b: 255 }, ) { const targetIds = players.map((p) => (typeof p === 'number' ? p : p.clientID)) - this.events.emit('core:chat:addMessage', targetIds, { + this.events.emit(SYSTEM_EVENTS.chat.addMessage, targetIds, { args: [author, message], color: color, }) @@ -130,6 +131,6 @@ export class Chat { * Clear chat for all connected players. */ clearChatAll() { - this.events.emit('core:chat:clear', 'all') + this.events.emit(SYSTEM_EVENTS.chat.clear, 'all') } } diff --git a/src/runtime/server/apis/npcs.api.ts b/src/runtime/server/apis/npcs.api.ts index 9977b91..e265cbc 100644 --- a/src/runtime/server/apis/npcs.api.ts +++ b/src/runtime/server/apis/npcs.api.ts @@ -3,16 +3,15 @@ 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' import { WorldContext } from '../../core/world' +import { SYSTEM_EVENTS } from '../../shared/types/system-types' 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 +33,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 +41,7 @@ export class Npcs { this.adapters = { entityServer: this.entityServer, pedServer: this.pedServer, + npcLifecycle: this.npcLifecycle, } } @@ -64,7 +65,6 @@ export class Npcs { position, heading = 0, networked = true, - pedType = DEFAULT_PED_TYPE, routingBucket = 0, persistent = false, metadata, @@ -81,38 +81,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 +118,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) @@ -349,7 +335,7 @@ export class Npcs { remainingNpcs: this.npcById.size, }) - this.events.emit('opencore:npc:deleted', 'all', npc.npcId) + this.events.emit(SYSTEM_EVENTS.npc.deleted, 'all', npc.npcId) return true } @@ -455,11 +441,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 +449,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/parallel-compute.api.ts b/src/runtime/server/apis/parallel-compute.api.ts index b0cdd56..baccd3b 100644 --- a/src/runtime/server/apis/parallel-compute.api.ts +++ b/src/runtime/server/apis/parallel-compute.api.ts @@ -1,3 +1,4 @@ +import { performance } from 'node:perf_hooks' import { injectable } from 'tsyringe' import { v4 as uuid } from 'uuid' import { Vector3 } from '../../../kernel' diff --git a/src/runtime/server/apis/vehicle-modification.api.ts b/src/runtime/server/apis/vehicle-modification.api.ts index af948ab..d34c76d 100644 --- a/src/runtime/server/apis/vehicle-modification.api.ts +++ b/src/runtime/server/apis/vehicle-modification.api.ts @@ -1,6 +1,9 @@ -import { inject, injectable } from 'tsyringe' +import { inject } from 'tsyringe' +import { EventsAPI } from '../../../adapters/contracts/transport/events.api' import { coreLogger } from '../../../kernel/logger' +import { SYSTEM_EVENTS } from '../../shared/types/system-types' import { VehicleModificationOptions, VehicleMods } from '../types/vehicle.types' +import { Bind } from '../decorators/bind' import { Vehicles } from './vehicles.api' /** @@ -15,9 +18,12 @@ import { Vehicles } from './vehicles.api' * - Modification limits * - Audit logging */ -@injectable() +@Bind('singleton') 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 +72,7 @@ export class VehicleModification { mods: Object.keys(validatedMods), }) - emitNet('opencore:vehicle:modified', -1, { + this.events.emit(SYSTEM_EVENTS.vehicle.modified, 'all', { networkId, mods: validatedMods, }) @@ -213,7 +219,7 @@ export class VehicleModification { coreLogger.info('Vehicle modifications reset', { networkId, requestedBy }) - emitNet('opencore:vehicle:modified', -1, { + this.events.emit(SYSTEM_EVENTS.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..6e3f9f8 100644 --- a/src/runtime/server/apis/vehicles.api.ts +++ b/src/runtime/server/apis/vehicles.api.ts @@ -1,14 +1,13 @@ -import { inject, injectable } from 'tsyringe' +import { inject } 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 { SYSTEM_EVENTS } from '../../shared/types/system-types' +import { Player } from '../entities/player' import { Vehicle, type VehicleAdapters } from '../entities/vehicle' import { SerializedVehicleData, @@ -16,6 +15,15 @@ import { VehicleSpawnResult, } from '../types/vehicle.types' import { Players } from '../ports/players.api-port' +import { Bind } from '../decorators/bind' + +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. @@ -30,7 +38,7 @@ import { Players } from '../ports/players.api-port' * All vehicle creation MUST go through this service to ensure security. * Uses CreateVehicleServerSetter for server-authoritative spawning. */ -@injectable() +@Bind('singleton') export class Vehicles { /** * Internal registry of all managed vehicles indexed by Network ID @@ -46,10 +54,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 +94,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 +115,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,16 +160,21 @@ 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, }) - this.events.emit('opencore:vehicle:created', 'all', vehicle.serialize()) + this.events.emit(SYSTEM_EVENTS.vehicle.created, 'all', vehicle.serialize()) return { networkId, @@ -213,10 +205,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 +219,54 @@ 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, + plate: ownership.type === 'player' ? player.name : 'anom', 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. * @@ -336,7 +361,7 @@ export class Vehicles { totalVehicles: this.vehiclesByNetworkId.size, }) - this.events.emit('opencore:vehicle:deleted', 'all', networkId) + this.events.emit(SYSTEM_EVENTS.vehicle.deleted, 'all', networkId) return true } @@ -387,10 +412,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..8dcb809 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' @@ -17,6 +19,7 @@ import { BinaryProcessManager } from './system/managers/binary-process.manager' import { SessionRecoveryService } from './services/session-recovery.local' import type { PluginInstallContext, PluginRegistry } from './library/plugin' import { registerServicesServer } from './services/services.register' +import { SYSTEM_EVENTS } from '../shared/types/system-types' import { METADATA_KEYS } from './system/metadata-server.keys' import { registerSystemServer } from './system/processors.register' @@ -109,9 +112,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') { @@ -200,15 +208,15 @@ export async function initServer( const events = GLOBAL_CONTAINER.resolve(EventsAPI as any) as EventsAPI<'server'> // 1. Broadast to resources already running - engineEvents.emit('core:ready') - events.emit('core:ready', 'all') + engineEvents.emit(SYSTEM_EVENTS.core.ready) + events.emit(SYSTEM_EVENTS.core.ready, 'all') - // 2. Listen for 'core:request-ready' for resources starting late (hot-reload) - engineEvents.on('core:request-ready', () => { - engineEvents.emit('core:ready') + // 2. Listen for '_systemcore:request-ready' for resources starting late (hot-reload) + engineEvents.on(SYSTEM_EVENTS.core.requestReady, () => { + engineEvents.emit(SYSTEM_EVENTS.core.ready) }) - loggers.bootstrap.info(`'core:ready' logic initialized and broadcasted`) + loggers.bootstrap.info(`'${SYSTEM_EVENTS.core.ready}' logic initialized and broadcasted`) } const logLevelLabel = LogLevelLabels[getLogLevel()] @@ -230,22 +238,27 @@ 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!`) + loggers.bootstrap.debug( + `Core '${coreName}' detected via '${SYSTEM_EVENTS.core.ready}' event!`, + ) cleanup() resolve() } } - engineEvents.on('core:ready', onReady) - loggers.bootstrap.debug(`Listening for 'core:ready' event from Core`) + engineEvents.on(SYSTEM_EVENTS.core.ready, onReady) + loggers.bootstrap.debug(`Listening for '${SYSTEM_EVENTS.core.ready}' event from Core`) // 2. Check if already ready via export (Polling) 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!`) @@ -263,8 +276,10 @@ function createCoreDependency(coreName: string): Promise { // 3. Request status (for hot-reload cases where Core is already up) // This is sent AFTER registering the listener so we can receive the response if (!resolved) { - loggers.bootstrap.debug(`Requesting Core status via 'core:request-ready' event`) - engineEvents.emit('core:request-ready') + loggers.bootstrap.debug( + `Requesting Core status via '${SYSTEM_EVENTS.core.requestReady}' event`, + ) + engineEvents.emit(SYSTEM_EVENTS.core.requestReady) } // 4. Timeout protection @@ -276,7 +291,7 @@ function createCoreDependency(coreName: string): Promise { cleanup() reject( new Error( - `[OpenCore] Timeout waiting for CORE '${coreName}'. The Core did not emit 'core:ready' or expose 'isCoreReady' within ${CORE_WAIT_TIMEOUT}ms.`, + `[OpenCore] Timeout waiting for CORE '${coreName}'. The Core did not emit '${SYSTEM_EVENTS.core.ready}' or expose 'isCoreReady' within ${CORE_WAIT_TIMEOUT}ms.`, ), ) } 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..32f42a6 100644 --- a/src/runtime/server/controllers/command-export.controller.ts +++ b/src/runtime/server/controllers/command-export.controller.ts @@ -1,7 +1,9 @@ import { inject } from 'tsyringe' +import { z } from 'zod' import { IEngineEvents } from '../../../adapters/contracts/IEngineEvents' import { AppError, SecurityError } from '../../../kernel/error' import { loggers } from '../../../kernel/logger' +import { buildRemoteCommandExecuteEventName, SYSTEM_EVENTS } from '../../shared/types/system-types' import { CommandErrorObserverContract } from '../contracts/security/command-error-observer.contract' import { Controller, Export, Public } from '../decorators' import { OnNet } from '../decorators/onNet' @@ -76,7 +78,7 @@ export class CommandExportController implements InternalCommandsExports { * (registered by RESOURCE mode instances). */ @Public() - @OnNet('core:execute-command') + @OnNet(SYSTEM_EVENTS.command.execute, z.tuple([z.string().min(1), z.array(z.string())])) async onCommandReceived(player: Player, command: string, args: string[]) { try { if (command.startsWith('/')) command = command.slice(1) @@ -238,7 +240,7 @@ export class CommandExportController implements InternalCommandsExports { await this.validateSecurity(player, commandName, remoteEntry.metadata.security) // Delegate to resource via local event (server-to-server, not network) - const eventName = `opencore:command:execute:${remoteEntry.resourceName}` + const eventName = buildRemoteCommandExecuteEventName(remoteEntry.resourceName) this.engineEvents.emit(eventName, clientID, commandName, args) loggers.command.debug(`Delegated remote command execution to ${remoteEntry.resourceName}`, { command: commandName, @@ -307,7 +309,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/remote-command-execution.controller.ts b/src/runtime/server/controllers/remote-command-execution.controller.ts index 305366a..013d21f 100644 --- a/src/runtime/server/controllers/remote-command-execution.controller.ts +++ b/src/runtime/server/controllers/remote-command-execution.controller.ts @@ -2,6 +2,7 @@ import { inject } from 'tsyringe' import { IEngineEvents } from '../../../adapters/contracts/IEngineEvents' import { IResourceInfo } from '../../../adapters/contracts/IResourceInfo' import { loggers } from '../../../kernel/logger' +import { buildRemoteCommandExecuteEventName } from '../../shared/types/system-types' import { CommandErrorObserverContract } from '../contracts/security/command-error-observer.contract' import { Controller } from '../decorators' import { normalizeToAppError } from '../helpers/normalize-app-error' @@ -61,7 +62,7 @@ export class RemoteCommandExecutionController { */ private registerEventHandler(): void { const resourceName = this.resourceInfo.getCurrentResourceName() - const eventName = `opencore:command:execute:${resourceName}` + const eventName = buildRemoteCommandExecuteEventName(resourceName) this.engineEvents.on( eventName, 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..2f8a36b 100644 --- a/src/runtime/server/controllers/vehicle.controller.ts +++ b/src/runtime/server/controllers/vehicle.controller.ts @@ -1,4 +1,6 @@ import { inject } from 'tsyringe' +import { EventsAPI } from '../../../adapters/contracts/transport/events.api' +import { SYSTEM_EVENTS } from '../../shared/types/system-types' import { Controller, OnNet } from '../decorators' import { Player } from '../entities/player' import { Vehicles } from '../apis/vehicles.api' @@ -11,20 +13,23 @@ 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. */ - @OnNet('opencore:vehicle:getData') + @OnNet(SYSTEM_EVENTS.vehicle.getData) handleGetData(player: Player, networkId: number) { const vehicle = this.vehicleService.getByNetworkId(networkId) if (!vehicle) { - emitNet('opencore:vehicle:dataResult', player.clientID, null) + this.events.emit(SYSTEM_EVENTS.vehicle.dataResult, player.clientID, null) return null } const data = vehicle.serialize() - emitNet('opencore:vehicle:dataResult', player.clientID, data) + this.events.emit(SYSTEM_EVENTS.vehicle.dataResult, player.clientID, data) return data } @@ -32,12 +37,12 @@ export class VehicleController { /** * Handles client request to get all player vehicles. */ - @OnNet('opencore:vehicle:getPlayerVehicles') + @OnNet(SYSTEM_EVENTS.vehicle.getPlayerVehicles) handleGetPlayerVehicles(player: Player) { const vehicles = this.vehicleService.getPlayerVehicles(player.clientID) const serialized = vehicles.map((v) => v.serialize()) - emitNet('opencore:vehicle:playerVehiclesResult', player.clientID, serialized) + this.events.emit(SYSTEM_EVENTS.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/command.ts b/src/runtime/server/decorators/command.ts index 67e899b..ffee82d 100644 --- a/src/runtime/server/decorators/command.ts +++ b/src/runtime/server/decorators/command.ts @@ -1,7 +1,11 @@ import { z } from 'zod' import { ClassConstructor } from '../../../kernel/di/class-constructor' import { Player } from '../entities/player' -import { getParameterNames, getSpreadParameterIndices } from '../helpers/function-helper' +import { + getDefaultParameterIndices, + getParameterNames, + getSpreadParameterIndices, +} from '../helpers/function-helper' import { METADATA_KEYS } from '../system/metadata-server.keys' import { SecurityMetadata } from '../types/core-exports.types' @@ -40,6 +44,8 @@ export interface CommandMetadata extends CommandConfig { security?: SecurityMetadata /** True if the last parameter uses the spread operator (...args) */ hasSpreadParam?: boolean + /** True when a parameter defines a JS default value. */ + defaultParams?: boolean[] } type ServerCommandHandler = (() => any) | ((player: Player, ...args: any[]) => any) @@ -150,6 +156,7 @@ export function Command(configOrName: string | CommandConfig, schema?: z.ZodType } const paramNames = getParameterNames(descriptor.value) + const defaultParams = getDefaultParameterIndices(descriptor.value) const spreadIndices = getSpreadParameterIndices(descriptor.value) const hasSpreadParam = spreadIndices.length > 0 && spreadIndices[spreadIndices.length - 1] @@ -161,6 +168,7 @@ export function Command(configOrName: string | CommandConfig, schema?: z.ZodType paramNames, expectsPlayer, hasSpreadParam, + defaultParams, } Reflect.defineMetadata(METADATA_KEYS.COMMAND, metadata, target, propertyKey) 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..f9586f7 100644 --- a/src/runtime/server/entities/npc.ts +++ b/src/runtime/server/entities/npc.ts @@ -1,14 +1,16 @@ -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' import { Spatial } from '../../core/spatial' import { SerializedNpcData } from '../types/npc.types' +import { NativeHandle } from '../../core' 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..d1e4a2e 100644 --- a/src/runtime/server/entities/player.ts +++ b/src/runtime/server/entities/player.ts @@ -1,15 +1,24 @@ 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' +import { SYSTEM_EVENTS } from '../../shared/types/system-types' import { LinkedID } from '../types/linked-id' import { PlayerSession } from '../types/player-session.types' import { SerializedPlayerData } from '../types/core-exports.types' -import { NativeHandle } from 'src/runtime/core/nativehandle' +import { NativeHandle } from '../../core' /** * Adapter bundle for player operations. @@ -18,6 +27,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 +47,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. @@ -53,10 +65,14 @@ export class Player extends BaseEntity implements Spatial, NativeHandle { } getHeading(): number { - return this.adapters.entityServer.getHeading(this.clientID) + const ped = this.adapters.playerServer.getPed(this.clientID.toString()) + if (!ped || ped === 0) return 0 + return this.adapters.entityServer.getHeading(ped) } setHeading(heading: number): void { - this.adapters.entityServer.setHeading(this.clientID, heading) + const ped = this.adapters.playerServer.getPed(this.clientID.toString()) + if (!ped || ped === 0) return + this.adapters.entityServer.setHeading(ped, heading) } getHandle(): number { @@ -157,7 +173,7 @@ export class Player extends BaseEntity implements Spatial, NativeHandle { * @param type - Message type for styling */ send(message: string, type: 'chat' | 'error' | 'success' | 'warning' = 'chat'): void { - this.emit('core:chat:send', message, type) + this.emit(SYSTEM_EVENTS.chat.send, message, type) } // ───────────────────────────────────────────────────────────────── @@ -174,7 +190,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 +208,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 +248,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 +366,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 +375,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 +391,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..b179dd5 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, @@ -12,19 +12,23 @@ export async function validateAndExecuteCommand( handler: (...args: any[]) => any, ): Promise { const paramNames = meta.expectsPlayer ? meta.paramNames.slice(1) : meta.paramNames + const defaultParams = meta.expectsPlayer + ? (meta.defaultParams ?? []).slice(1) + : (meta.defaultParams ?? []) let schema: z.ZodTypeAny | undefined = meta.schema if (!meta.expectsPlayer) { if (args.length > 0) { - throw new AppError('GAME:BAD_REQUEST', `Incorrect usage, use: ${meta.usage}`, 'client', { - usage: meta.usage, + const usage = resolveCommandUsage(meta, paramNames, defaultParams) + throw new AppError('GAME:BAD_REQUEST', `Incorrect usage, use: ${usage}`, 'client', { + usage, }) } return await handler() } if (!schema) { - schema = generateSchemaFromTypes(meta.paramTypes) + schema = generateSchemaFromTypes(meta.paramTypes, meta.defaultParams) if (!schema) { if (paramNames.length > 0) { @@ -67,8 +71,9 @@ export async function validateAndExecuteCommand( } const validated = await schema.parseAsync(inputObj).catch(() => { - throw new AppError('GAME:BAD_REQUEST', `Incorrect usage, use: ${meta.usage}`, 'client', { - usage: meta.usage, + const usage = resolveCommandUsage(meta, paramNames, defaultParams) + throw new AppError('GAME:BAD_REQUEST', `Incorrect usage, use: ${usage}`, 'client', { + usage, }) }) @@ -82,8 +87,9 @@ export async function validateAndExecuteCommand( const processedArgs = processTupleSchema(schema, args) const validated = await schema.parseAsync(processedArgs).catch(() => { - throw new AppError('GAME:BAD_REQUEST', `Incorrect usage, use: ${meta.usage}`, 'client', { - usage: meta.usage, + const usage = resolveCommandUsage(meta, paramNames, defaultParams) + throw new AppError('GAME:BAD_REQUEST', `Incorrect usage, use: ${usage}`, 'client', { + usage, }) }) @@ -108,3 +114,19 @@ export async function validateAndExecuteCommand( // fallback return await handler(player) } + +function resolveCommandUsage( + meta: CommandMetadata, + paramNames: string[], + defaultParams: boolean[], +): string { + if (meta.usage?.trim()) { + return meta.usage + } + + const renderedParams = paramNames.map((name, index) => + defaultParams[index] ? `[${name}]` : `<${name}>`, + ) + + return `/${meta.command}${renderedParams.length > 0 ? ` ${renderedParams.join(' ')}` : ''}` +} diff --git a/src/runtime/server/helpers/function-helper.ts b/src/runtime/server/helpers/function-helper.ts index a5bfb6e..9deb13a 100644 --- a/src/runtime/server/helpers/function-helper.ts +++ b/src/runtime/server/helpers/function-helper.ts @@ -1,16 +1,11 @@ export function getParameterNames(func: (...args: any[]) => any): string[] { - const stripped = func - .toString() - .replace(/\/\/.*$/gm, '') - .replace(/\/\*[\s\S]*?\*\//gm, '') - - const args = stripped - .slice(stripped.indexOf('(') + 1, stripped.indexOf(')')) - .split(',') + return parseParameterTokens(func) .map((arg) => arg.replace(/=[\s\S]*/, '').trim()) .filter(Boolean) +} - return args +export function getDefaultParameterIndices(func: (...args: any[]) => any): boolean[] { + return parseParameterTokens(func).map((arg) => arg.includes('=')) } /** @@ -31,3 +26,16 @@ export function getSpreadParameterIndices(func: (...args: any[]) => any): boolea return args.map((arg) => arg.startsWith('...')) } + +function parseParameterTokens(func: (...args: any[]) => any): string[] { + const stripped = func + .toString() + .replace(/\/\/.*$/gm, '') + .replace(/\/\*[\s\S]*?\*\//gm, '') + + return stripped + .slice(stripped.indexOf('(') + 1, stripped.indexOf(')')) + .split(',') + .map((arg) => arg.trim()) + .filter(Boolean) +} 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/channel.local.ts b/src/runtime/server/implementations/local/channel.local.ts index ce2b8a8..7e2ae8f 100644 --- a/src/runtime/server/implementations/local/channel.local.ts +++ b/src/runtime/server/implementations/local/channel.local.ts @@ -1,6 +1,7 @@ import { EventsAPI } from '../../../../adapters/contracts/transport/events.api' -import { RGB } from 'src/kernel' +import { RGB } from '../../../../kernel/utils' import { inject, injectable } from 'tsyringe' +import { SYSTEM_EVENTS } from '../../../shared/types/system-types' import { Player } from '../../entities' import { Channels } from '../../ports/channel.api-port' import { IChannelValidator, ChannelMetadata, ChannelType } from '../../types' @@ -117,7 +118,7 @@ export class LocalChannelImplementation extends Channels { return } - this.events.emit('core:chat:addMessage', targetIds, { + this.events.emit(SYSTEM_EVENTS.chat.addMessage, targetIds, { args: [author ?? sender.name, message], color: color, }) @@ -141,7 +142,7 @@ export class LocalChannelImplementation extends Channels { return } - this.events.emit('core:chat:addMessage', targetIds, { + this.events.emit(SYSTEM_EVENTS.chat.addMessage, targetIds, { args: [author, message], color: color, }) 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/ports/channel.api-port.ts b/src/runtime/server/ports/channel.api-port.ts index 8e9c3a4..1abdf40 100644 --- a/src/runtime/server/ports/channel.api-port.ts +++ b/src/runtime/server/ports/channel.api-port.ts @@ -1,4 +1,4 @@ -import { RGB } from 'src/kernel' +import { RGB } from '../../../kernel/utils' import { Channel } from '../concepts/channel' import { Player } from '../entities' import { ChannelMetadata, ChannelType, IChannelValidator } from '../types' 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/parallel/worker-pool.ts b/src/runtime/server/services/parallel/worker-pool.ts index 839b8a1..e6e7fde 100644 --- a/src/runtime/server/services/parallel/worker-pool.ts +++ b/src/runtime/server/services/parallel/worker-pool.ts @@ -6,7 +6,6 @@ * structured-cloned messages. */ -import * as path from 'node:path' import { Worker } from 'node:worker_threads' import { WorkerInfo, @@ -86,7 +85,7 @@ class NativeWorker { constructor( id: number, - workerScriptPath: string, + workerSource: string, callbacks: { onExit: (workerId: number, code: number | null) => void onError: (workerId: number, error: Error) => void @@ -94,7 +93,7 @@ class NativeWorker { ) { this.id = id - this.worker = new Worker(workerScriptPath) + this.worker = new Worker(workerSource, { eval: true }) this.worker.on('message', (response: WorkerResponse) => { const handlers = this.pendingResponses.get(response.id) if (handlers) { @@ -196,13 +195,46 @@ export class WorkerPool extends SimpleEventEmitter { private workerIdCounter = 0 private cleanupInterval: ReturnType | null = null private isShuttingDown = false - private workerScriptPath: string + private workerSource: string constructor(config: Partial = {}) { super() this.config = { ...DEFAULT_CONFIG, ...config } - this.workerScriptPath = path.join(__dirname, 'native-worker.entry.js') + this.workerSource = ` +const { parentPort } = require('worker_threads') +const { performance } = require('perf_hooks') + +if (!parentPort) { + throw new Error('native worker must be executed inside a Worker thread') +} + +function executeCompute(functionBody, input) { + const fn = new Function('input', 'return (' + functionBody + ')(input)') + return fn(input) +} + +parentPort.on('message', (message) => { + const startTime = performance.now() + + try { + const result = executeCompute(message.functionBody, message.input) + parentPort.postMessage({ + id: message.id, + success: true, + result, + executionTime: performance.now() - startTime, + }) + } catch (error) { + parentPort.postMessage({ + id: message.id, + success: false, + error: error instanceof Error ? error.message : String(error), + executionTime: performance.now() - startTime, + }) + } +}) +` // Start cleanup interval this.cleanupInterval = setInterval(() => this.cleanupIdleWorkers(), 10000) @@ -327,7 +359,7 @@ export class WorkerPool extends SimpleEventEmitter { private spawnWorker(): NativeWorker | null { try { const id = this.workerIdCounter++ - const worker = new NativeWorker(id, this.workerScriptPath, { + const worker = new NativeWorker(id, this.workerSource, { onExit: (workerId, code) => { const existing = this.workers.get(workerId) if (!existing) return diff --git a/src/runtime/server/services/parallel/worker.ts b/src/runtime/server/services/parallel/worker.ts index 302d60b..ccfa78f 100644 --- a/src/runtime/server/services/parallel/worker.ts +++ b/src/runtime/server/services/parallel/worker.ts @@ -12,6 +12,7 @@ * - Do not pass untrusted code as compute functions. */ +import { performance } from 'node:perf_hooks' import { WorkerMessage, WorkerResponse } from '../../types/parallel.types' /** 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/system/schema-generator.ts b/src/runtime/server/system/schema-generator.ts index 8c19e48..8a0cff8 100644 --- a/src/runtime/server/system/schema-generator.ts +++ b/src/runtime/server/system/schema-generator.ts @@ -21,7 +21,14 @@ function typeToZodSchema(type: any): z.ZodType | undefined { } } -export function generateSchemaFromTypes(paramTypes: any[]): z.ZodTuple | undefined { +function applyOptional(schema: z.ZodTypeAny, optional: boolean): z.ZodTypeAny { + return optional ? schema.optional() : schema +} + +export function generateSchemaFromTypes( + paramTypes: any[], + defaultParams: boolean[] = [], +): z.ZodTuple | undefined { if (!paramTypes || paramTypes.length === 0) return z.tuple([]) if (paramTypes[0] !== Player) { throw new AppError( @@ -33,10 +40,10 @@ export function generateSchemaFromTypes(paramTypes: any[]): z.ZodTuple | undefin if (paramTypes.length === 1) return z.tuple([]) const argSchemas: z.ZodTypeAny[] = [] - for (const t of paramTypes.slice(1)) { + for (const [index, t] of paramTypes.slice(1).entries()) { const s = typeToZodSchema(t) if (!s) return undefined - argSchemas.push(s) + argSchemas.push(applyOptional(s, defaultParams[index + 1] ?? false)) } return z.tuple(argSchemas as any) 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..ec4c569 --- /dev/null +++ b/src/runtime/shared/helpers/process-tuple-schema.ts @@ -0,0 +1,40 @@ +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]]] + } + } + + if (args.length < items.length) { + return [...args, ...Array.from({ length: items.length - args.length }, () => undefined)] + } + + return args +} diff --git a/src/runtime/shared/types/system-types.ts b/src/runtime/shared/types/system-types.ts new file mode 100644 index 0000000..014af42 --- /dev/null +++ b/src/runtime/shared/types/system-types.ts @@ -0,0 +1,69 @@ +type ValueOf = T[keyof T] + +const SYSTEM_EVENT_NAMESPACE = 'opencore' +const SYSTEM_CORE_EVENT_NAMESPACE = '_systemcore' + +const systemEvent = (scope: string, action: string) => + `${SYSTEM_EVENT_NAMESPACE}:${scope}:${action}` as const + +const systemCoreEvent = (action: string) => `${SYSTEM_CORE_EVENT_NAMESPACE}:${action}` as const + +export type RemoteCommandExecuteEventName = + `${typeof SYSTEM_EVENT_NAMESPACE}:command:execute:${string}` + +export const buildRemoteCommandExecuteEventName = ( + resourceName: string, +): RemoteCommandExecuteEventName => + `${SYSTEM_EVENTS.command.execute}:${resourceName}` as RemoteCommandExecuteEventName + +export const SYSTEM_EVENTS = { + core: { + ready: systemCoreEvent('ready'), + requestReady: systemCoreEvent('request-ready'), + }, + chat: { + message: systemEvent('chat', 'message'), + addMessage: systemEvent('chat', 'addMessage'), + send: systemEvent('chat', 'send'), + clear: systemEvent('chat', 'clear'), + }, + command: { + execute: systemEvent('command', 'execute'), + }, + spawner: { + spawn: systemEvent('spawner', 'spawn'), + teleport: systemEvent('spawner', 'teleport'), + respawn: systemEvent('spawner', 'respawn'), + }, + appearance: { + apply: systemEvent('appearance', 'apply'), + reset: systemEvent('appearance', 'reset'), + }, + vehicle: { + create: systemEvent('vehicle', 'create'), + createResult: systemEvent('vehicle', 'createResult'), + delete: systemEvent('vehicle', 'delete'), + deleteResult: systemEvent('vehicle', 'deleteResult'), + repair: systemEvent('vehicle', 'repair'), + repairResult: systemEvent('vehicle', 'repairResult'), + repaired: systemEvent('vehicle', 'repaired'), + setLocked: systemEvent('vehicle', 'setLocked'), + getData: systemEvent('vehicle', 'getData'), + dataResult: systemEvent('vehicle', 'dataResult'), + getPlayerVehicles: systemEvent('vehicle', 'getPlayerVehicles'), + playerVehiclesResult: systemEvent('vehicle', 'playerVehiclesResult'), + created: systemEvent('vehicle', 'created'), + deleted: systemEvent('vehicle', 'deleted'), + modified: systemEvent('vehicle', 'modified'), + warpInto: systemEvent('vehicle', 'warpInto'), + }, + npc: { + deleted: systemEvent('npc', 'deleted'), + }, + session: { + playerInit: systemEvent('player', 'sessionInit'), + teleportTo: systemEvent('player', 'teleportTo'), + }, +} as const + +export type SystemEventName = ValueOf> 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/integration/server/command-ports.test.ts b/tests/integration/server/command-ports.test.ts index 5be9fe6..e8ac5a9 100644 --- a/tests/integration/server/command-ports.test.ts +++ b/tests/integration/server/command-ports.test.ts @@ -1,6 +1,7 @@ import 'reflect-metadata' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { IEngineEvents } from '../../../src/adapters/contracts/IEngineEvents' +import { buildRemoteCommandExecuteEventName } from '../../../src/runtime/shared/types/system-types' import type { CommandErrorObserverContract } from '../../../src/runtime/server/contracts/security/command-error-observer.contract' import { CommandExportController } from '../../../src/runtime/server/controllers/command-export.controller' import type { CommandMetadata } from '../../../src/runtime/server/decorators/command' @@ -8,7 +9,7 @@ import { Player } from '../../../src/runtime/server/entities/player' import { LocalCommandImplementation } from '../../../src/runtime/server/implementations/local/command.local' import type { Players } from '../../../src/runtime/server/ports/players.api-port' import { createMockPlayerAdapters } from '../../helpers' -import { CommandExecutionPort } from 'src/runtime/server/ports/internal/command-execution.port' +import { CommandExecutionPort } from '../../../src/runtime/server/services' // Mock getRuntimeContext vi.mock('../../../src/runtime/server/runtime', () => ({ @@ -103,7 +104,7 @@ describe('Command Ports Integration', () => { await exportController.executeCommand(1, 'remote-heal', ['arg']) expect(mockEngineEvents.emit).toHaveBeenCalledWith( - 'opencore:command:execute:medical-resource', + buildRemoteCommandExecuteEventName('medical-resource'), 1, 'remote-heal', ['arg'], @@ -157,7 +158,7 @@ describe('Command Ports Integration', () => { // Execute police command await exportController.executeCommand(1, 'police-arrest', []) expect(mockEngineEvents.emit).toHaveBeenCalledWith( - 'opencore:command:execute:police-resource', + buildRemoteCommandExecuteEventName('police-resource'), expect.any(Number), 'police-arrest', expect.any(Array), @@ -168,7 +169,7 @@ describe('Command Ports Integration', () => { // Execute medical command await exportController.executeCommand(1, 'medical-revive', []) expect(mockEngineEvents.emit).toHaveBeenCalledWith( - 'opencore:command:execute:medical-resource', + buildRemoteCommandExecuteEventName('medical-resource'), expect.any(Number), 'medical-revive', expect.any(Array), @@ -234,7 +235,7 @@ describe('Command Ports Integration', () => { // Verify CORE emitted local event to resource (not network event) expect(mockEngineEvents.emit).toHaveBeenCalledWith( - 'opencore:command:execute:test-resource', + buildRemoteCommandExecuteEventName('test-resource'), 1, 'resource-cmd', ['arg1'], @@ -444,7 +445,7 @@ describe('Command Ports Integration', () => { // Should delegate to resource via local event expect(mockEngineEvents.emit).toHaveBeenCalledWith( - 'opencore:command:execute:police-resource', + buildRemoteCommandExecuteEventName('police-resource'), 1, 'arrest', ['player123'], diff --git a/tests/setup.ts b/tests/setup.ts index aa4332c..c1d0bae 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -3,10 +3,8 @@ import { beforeEach, vi } from 'vitest' import { resetContainer } from './helpers/di.helper' import { installGlobalMocks, resetCitizenFxMocks } from './mocks/citizenfx' -// Install FiveM API mocks globally installGlobalMocks() -// Reset state before each test beforeEach(() => { resetCitizenFxMocks() resetContainer() 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..5e2ce4e --- /dev/null +++ b/tests/unit/runtime/client/adapter-bootstrap.test.ts @@ -0,0 +1,136 @@ +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 { + getHandle(): number { + return 0 + } + + getPosition(): Vector3 { + return { x: 0, y: 0, z: 0 } + } + + getHeading(): number { + return 0 + } + + 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/node-player-state-sync-server.test.ts b/tests/unit/server/adapter/node-player-state-sync-server.test.ts new file mode 100644 index 0000000..a809bc7 --- /dev/null +++ b/tests/unit/server/adapter/node-player-state-sync-server.test.ts @@ -0,0 +1,33 @@ +import 'reflect-metadata' +import { describe, expect, it, vi } from 'vitest' +import { NodePlayerStateSyncServer } from '../../../../src/runtime/server/adapter/node-player-state-sync-server' + +describe('NodePlayerStateSyncServer', () => { + it('writes health and armor to the entity state bag', () => { + const state = new Map() + const entityServer = { + getHealth: vi.fn(() => 200), + setHealth: vi.fn(), + getArmor: vi.fn(() => 0), + setArmor: vi.fn(), + getStateBag: vi.fn(() => ({ + set: (key: string, value: unknown) => state.set(key, value), + get: (key: string) => state.get(key), + })), + } + + const playerServer = { + getPed: vi.fn(() => 77), + } + + const service = new NodePlayerStateSyncServer(playerServer as any, entityServer as any) + + service.setHealth('1', 175) + service.setArmor('1', 80) + + expect(entityServer.setHealth).toHaveBeenCalledWith(77, 175) + expect(entityServer.setArmor).toHaveBeenCalledWith(77, 80) + expect(state.get('health')).toBe(175) + expect(state.get('armor')).toBe(80) + }) +}) 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/apis/parallel-compute.api.test.ts b/tests/unit/server/apis/parallel-compute.api.test.ts new file mode 100644 index 0000000..83a5015 --- /dev/null +++ b/tests/unit/server/apis/parallel-compute.api.test.ts @@ -0,0 +1,172 @@ +import 'reflect-metadata' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + ParallelCompute, + defineBatchFilter, + defineBatchReduce, + defineBatchTransform, + defineTask, + getParallelComputeService, + shutdownParallelCompute, +} from '../../../../src/runtime/server/apis/parallel-compute.api' + +describe('ParallelCompute', () => { + afterEach(async () => { + await shutdownParallelCompute() + }) + + it('runs synchronously below the worker threshold', async () => { + const service = new ParallelCompute() + + const result = await service.run( + { + name: 'sum-sync', + estimateCost: (input: number[]) => input.length, + workerThreshold: 10, + compute: (input: number[]) => input.reduce((sum, value) => sum + value, 0), + }, + [1, 2, 3], + ) + + expect(result.result).toBe(6) + expect(result.mode).toBe('sync') + expect(service.getMetrics()).toMatchObject({ + totalTasks: 1, + syncTasks: 1, + parallelTasks: 0, + failedTasks: 0, + }) + }) + + it('runs in parallel when a worker pool is available', async () => { + const service = new ParallelCompute() + const execute = vi.fn(async (message: { input: number[] }) => { + ;(service as any).updateMetrics(4, 'parallel') + return message.input.reduce((sum, value) => sum + value, 0) + }) + + ;(service as any).pool = { + execute, + isNative: true, + getStats: () => ({ totalWorkers: 2 }), + shutdown: vi.fn(async () => undefined), + } + ;(service as any).isInitialized = true + + const result = await service.run( + { + name: 'sum-parallel', + estimateCost: (input: number[]) => input.length, + workerThreshold: 2, + compute: (input: number[]) => input.reduce((sum, value) => sum + value, 0), + }, + [1, 2, 3, 4], + ) + + expect(result.result).toBe(10) + expect(result.mode).toBe('parallel') + expect(execute).toHaveBeenCalledTimes(1) + }) + + it('supports distributed execution with chunking and merging', async () => { + const service = new ParallelCompute() + const execute = vi.fn(async (message: { input: number[] }) => + message.input.map((value) => value * 2), + ) + + ;(service as any).pool = { + execute, + isNative: true, + getStats: () => ({ totalWorkers: 2 }), + shutdown: vi.fn(async () => undefined), + } + ;(service as any).isInitialized = true + + const result = await service.distributed( + { + name: 'distributed-double', + compute: (input: number[]) => input.map((value) => value * 2), + chunker: (input: number[], workerCount: number) => { + const chunkSize = Math.ceil(input.length / workerCount) + const chunks: number[][] = [] + for (let index = 0; index < input.length; index += chunkSize) { + chunks.push(input.slice(index, index + chunkSize)) + } + return chunks + }, + merger: (results: number[][]) => results.flat(), + }, + [1, 2, 3, 4], + 2, + ) + + expect(result.result).toEqual([2, 4, 6, 8]) + expect(result.mode).toBe('distributed') + expect(result.workerCount).toBe(2) + expect(execute).toHaveBeenCalledTimes(2) + expect(service.getMetrics().parallelTasks).toBe(1) + }) + + it('falls back to sync execution when the pool is unavailable', async () => { + const service = new ParallelCompute() + + const result = await service.parallel( + { + name: 'fallback', + compute: (input: number) => input * 3, + }, + 7, + ) + + expect(result).toMatchObject({ result: 21, mode: 'sync' }) + }) + + it('tracks failed tasks and can reset metrics', () => { + const service = new ParallelCompute() + + expect(() => + service.sync( + { + name: 'boom', + compute: () => { + throw new Error('boom') + }, + }, + undefined, + ), + ).toThrow('boom') + + expect(service.getMetrics().failedTasks).toBe(1) + service.resetMetrics() + expect(service.getMetrics()).toMatchObject({ + totalTasks: 0, + syncTasks: 0, + parallelTasks: 0, + failedTasks: 0, + }) + }) + + it('exposes the global task helpers', async () => { + const globalService = getParallelComputeService() + + const batchTransform = defineBatchTransform('square', (value: number) => value * value) + const batchFilter = defineBatchFilter('even', (value: number) => value % 2 === 0) + const batchReduce = defineBatchReduce( + 'sum', + (sum: number, value: number) => sum + value, + 0, + (results: number[]) => results.reduce((sum, value) => sum + value, 0), + ) + const task = defineTask({ + name: 'identity', + compute: (value: number) => value, + estimateCost: () => 0, + }) + + expect(batchTransform.sync([2, 3])).toEqual([4, 9]) + expect(batchFilter.sync([1, 2, 3, 4])).toEqual([2, 4]) + expect(batchReduce.sync([1, 2, 3])).toBe(6) + await expect(task.run(9)).resolves.toBe(9) + expect(globalService.initialized).toBe(false) + }) +}) diff --git a/tests/unit/server/apis/vehicle-modification.api.test.ts b/tests/unit/server/apis/vehicle-modification.api.test.ts new file mode 100644 index 0000000..2c564d8 --- /dev/null +++ b/tests/unit/server/apis/vehicle-modification.api.test.ts @@ -0,0 +1,96 @@ +import 'reflect-metadata' +import { describe, expect, it, vi } from 'vitest' +import { SYSTEM_EVENTS } from '../../../../src/runtime/shared/types/system-types' +import { VehicleModification } from '../../../../src/runtime/server/apis/vehicle-modification.api' + +describe('VehicleModification', () => { + function createService(options: { exists?: boolean; owns?: boolean; near?: boolean } = {}) { + const vehicle = { + exists: options.exists ?? true, + ownership: { clientID: 7 }, + mods: {}, + } + + const vehicles = { + getByNetworkId: vi.fn(() => vehicle), + validateOwnership: vi.fn(() => options.owns ?? true), + validateProximity: vi.fn(() => options.near ?? true), + } + + const events = { + emit: vi.fn(), + } + + return { + service: new VehicleModification(vehicles as any, events as any), + vehicles, + events, + } + } + + it('applies validated modifications and emits the clamped payload', () => { + const { service, events } = createService() + + const success = service.applyModifications({ + networkId: 10, + requestedBy: 7, + mods: { + spoiler: 999, + windowTint: 20, + primaryColor: 500, + extras: { 1: true, 21: true }, + }, + }) + + expect(success).toBe(true) + expect(events.emit).toHaveBeenCalledWith(SYSTEM_EVENTS.vehicle.modified, 'all', { + networkId: 10, + mods: { + spoiler: 50, + windowTint: 6, + primaryColor: 160, + extras: { 1: true }, + }, + }) + }) + + it('blocks unauthorized modification attempts', () => { + const { service, events, vehicles } = createService({ owns: false }) + + const success = service.setTurbo(10, true, 99) + + expect(success).toBe(false) + expect(vehicles.validateOwnership).toHaveBeenCalledWith(10, 99) + expect(events.emit).not.toHaveBeenCalled() + }) + + it('blocks modification attempts when the player is too far away', () => { + const { service, events, vehicles } = createService({ near: false }) + + const success = service.setColors(10, 1, 2, 7) + + expect(success).toBe(false) + expect(vehicles.validateProximity).toHaveBeenCalledWith(10, 7, 15) + expect(events.emit).not.toHaveBeenCalled() + }) + + it('resets modifications to framework defaults', () => { + const { service, events } = createService() + + const success = service.resetModifications(15, 7) + + expect(success).toBe(true) + expect(events.emit).toHaveBeenCalledWith( + SYSTEM_EVENTS.vehicle.modified, + 'all', + expect.objectContaining({ + networkId: 15, + mods: expect.objectContaining({ + spoiler: -1, + turbo: false, + windowTint: 0, + }), + }), + ) + }) +}) diff --git a/tests/unit/server/controllers/command-export.controller.test.ts b/tests/unit/server/controllers/command-export.controller.test.ts index 9efced0..cecc1a1 100644 --- a/tests/unit/server/controllers/command-export.controller.test.ts +++ b/tests/unit/server/controllers/command-export.controller.test.ts @@ -1,12 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { IEngineEvents } from '../../../../src/adapters/contracts/IEngineEvents' import { AppError } from '../../../../src/kernel/error' +import { buildRemoteCommandExecuteEventName } from '../../../../src/runtime/shared/types/system-types' import type { CommandErrorObserverContract } from '../../../../src/runtime/server/contracts/security/command-error-observer.contract' import { CommandExportController } from '../../../../src/runtime/server/controllers/command-export.controller' import type { Players } from '../../../../src/runtime/server/ports/players.api-port' import type { CommandRegistrationDto } from '../../../../src/runtime/server/types/core-exports.types' import { createAuthenticatedPlayer, createTestPlayer } from '../../../helpers' -import { CommandExecutionPort } from 'src/runtime/server/services' +import { CommandExecutionPort } from '../../../../src/runtime/server/services' describe('CommandExportController', () => { let controller: CommandExportController @@ -166,7 +167,7 @@ describe('CommandExportController', () => { // Should emit event to resource via adapter expect(mockEngineEvents.emit).toHaveBeenCalledWith( - 'opencore:command:execute:my-resource', + buildRemoteCommandExecuteEventName('my-resource'), 1, 'remotecmd', ['arg1', 'arg2'], @@ -192,7 +193,7 @@ describe('CommandExportController', () => { await controller.executeCommand(1, 'remote', []) expect(mockEngineEvents.emit).toHaveBeenCalledWith( - 'opencore:command:execute:my-resource', + buildRemoteCommandExecuteEventName('my-resource'), 1, 'remote', [], @@ -241,7 +242,7 @@ describe('CommandExportController', () => { await controller.executeCommand(1, 'noargs', []) expect(mockEngineEvents.emit).toHaveBeenCalledWith( - 'opencore:command:execute:test-resource', + buildRemoteCommandExecuteEventName('test-resource'), 1, 'noargs', [], @@ -369,7 +370,7 @@ describe('CommandExportController', () => { await controller.executeCommand(1, 'cmd1', []) expect(mockEngineEvents.emit).toHaveBeenCalledWith( - 'opencore:command:execute:resource-a', + buildRemoteCommandExecuteEventName('resource-a'), expect.any(Number), 'cmd1', expect.any(Array), @@ -379,7 +380,7 @@ describe('CommandExportController', () => { await controller.executeCommand(1, 'cmd2', []) expect(mockEngineEvents.emit).toHaveBeenCalledWith( - 'opencore:command:execute:resource-b', + buildRemoteCommandExecuteEventName('resource-b'), expect.any(Number), 'cmd2', expect.any(Array), @@ -613,7 +614,7 @@ describe('CommandExportController', () => { // Should delegate to resource expect(mockEngineEvents.emit).toHaveBeenCalledWith( - 'opencore:command:execute:test-resource', + buildRemoteCommandExecuteEventName('test-resource'), 1, 'test', ['arg1'], diff --git a/tests/unit/server/controllers/remote-command-execution.controller.test.ts b/tests/unit/server/controllers/remote-command-execution.controller.test.ts index 9dc76ab..34988ef 100644 --- a/tests/unit/server/controllers/remote-command-execution.controller.test.ts +++ b/tests/unit/server/controllers/remote-command-execution.controller.test.ts @@ -2,11 +2,12 @@ import 'reflect-metadata' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { IEngineEvents } from '../../../../src/adapters/contracts/IEngineEvents' import type { IResourceInfo } from '../../../../src/adapters/contracts/IResourceInfo' +import { buildRemoteCommandExecuteEventName } from '../../../../src/runtime/shared/types/system-types' import type { CommandErrorObserverContract } from '../../../../src/runtime/server/contracts/security/command-error-observer.contract' import { RemoteCommandExecutionController } from '../../../../src/runtime/server/controllers/remote-command-execution.controller' import type { Players } from '../../../../src/runtime/server/ports/players.api-port' import { createTestPlayer } from '../../../helpers' -import { CommandExecutionPort } from 'src/runtime/server/services' +import { CommandExecutionPort } from '../../../../src/runtime/server/services' vi.mock('../../../../src/runtime/server/runtime', () => ({ getRuntimeContext: vi.fn(() => ({ mode: 'RESOURCE' })), @@ -68,7 +69,7 @@ describe('RemoteCommandExecutionController', () => { describe('event registration', () => { it('should register event handler with correct event name', () => { expect(mockEngineEvents.on).toHaveBeenCalledWith( - 'opencore:command:execute:test-resource', + buildRemoteCommandExecuteEventName('test-resource'), expect.any(Function), ) }) diff --git a/tests/unit/server/entities/vehicle.syncbag.test.ts b/tests/unit/server/entities/vehicle.syncbag.test.ts new file mode 100644 index 0000000..9e482bb --- /dev/null +++ b/tests/unit/server/entities/vehicle.syncbag.test.ts @@ -0,0 +1,83 @@ +import 'reflect-metadata' +import { describe, expect, it, vi } from 'vitest' +import type { + EntityStateBag, + IEntityServer, +} from '../../../../src/adapters/contracts/server/IEntityServer' +import type { IVehicleServer } from '../../../../src/adapters/contracts/server/IVehicleServer' +import { Vehicle } from '../../../../src/runtime/server/entities/vehicle' + +describe('Vehicle state bag synchronization', () => { + function createVehicle() { + const state = new Map() + const stateBag: EntityStateBag = { + set: vi.fn((key: string, value: unknown) => state.set(key, value)), + get: vi.fn((key: string) => state.get(key)), + } + + const entityServer: IEntityServer = { + doesExist: vi.fn(() => true), + getCoords: vi.fn(() => ({ x: 1, y: 2, z: 3 })), + setPosition: vi.fn(), + setCoords: vi.fn(), + getHeading: vi.fn(() => 90), + setHeading: vi.fn(), + getModel: vi.fn(() => 123), + delete: vi.fn(), + setOrphanMode: vi.fn(), + setDimension: vi.fn(), + getDimension: vi.fn(() => 0), + getStateBag: vi.fn(() => stateBag), + getHealth: vi.fn(() => 1000), + setHealth: vi.fn(), + getArmor: vi.fn(() => 50), + setArmor: vi.fn(), + } + + const vehicleServer: IVehicleServer = { + getNumberPlateText: vi.fn(() => 'TEST'), + setNumberPlateText: vi.fn(), + getColours: vi.fn(() => [0, 0]), + setColours: vi.fn(), + setDoorsLocked: vi.fn(), + getNetworkIdFromEntity: vi.fn(() => 55), + } as any + + return { + state, + stateBag, + entityServer, + vehicleServer, + vehicle: new Vehicle( + 99, + 55, + { clientID: 1, accountID: 'acc-1', type: 'player' }, + { entityServer, vehicleServer }, + false, + 0, + 'adder', + 123, + ), + } + } + + it('syncs ownership, metadata, mods and flags into the state bag', () => { + const { state, stateBag, vehicle, vehicleServer } = createVehicle() + + vehicle.setOwnership({ accountID: 'acc-2' }) + vehicle.setMods({ turbo: true, spoiler: 2 }) + vehicle.setMetadata('garage', 'city') + vehicle.setFuel(135) + vehicle.setDoorsLocked(true) + + expect(stateBag.set).toHaveBeenCalled() + expect(state.get('ownership')).toEqual({ clientID: 1, accountID: 'acc-2', type: 'player' }) + expect(state.get('mods')).toEqual({ turbo: true, spoiler: 2 }) + expect(state.get('meta_garage')).toBe('city') + expect(state.get('fuel')).toBe(100) + expect(state.get('locked')).toBe(true) + expect(vehicle.getMetadata('garage')).toBe('city') + expect(vehicle.getFuel()).toBe(100) + expect(vehicleServer.setDoorsLocked).toHaveBeenCalledWith(99, 2) + }) +}) diff --git a/tests/unit/server/services/channel.service.test.ts b/tests/unit/server/services/channel.service.test.ts index b6fa050..089e2b8 100644 --- a/tests/unit/server/services/channel.service.test.ts +++ b/tests/unit/server/services/channel.service.test.ts @@ -1,6 +1,7 @@ import 'reflect-metadata' import { beforeEach, describe, expect, it, vi } from 'vitest' import { EventsAPI } from '../../../../src/adapters/contracts/transport/events.api' +import { SYSTEM_EVENTS } from '../../../../src/runtime/shared/types/system-types' import { Player } from '../../../../src/runtime/server/entities/player' import { Players } from '../../../../src/runtime/server/ports/players.api-port' import { ChannelType } from '../../../../src/runtime/server/types/channel.types' @@ -184,7 +185,7 @@ describe('ChannelService', () => { it('should broadcast message to channel subscribers', () => { channelService.broadcast('test-channel', mockPlayer1, 'Hello everyone!') - expect(mockEventsAPI.emit).toHaveBeenCalledWith('core:chat:addMessage', [1, 2], { + expect(mockEventsAPI.emit).toHaveBeenCalledWith(SYSTEM_EVENTS.chat.addMessage, [1, 2], { args: ['Player1', 'Hello everyone!'], color: { r: 255, g: 255, b: 255 }, }) @@ -197,7 +198,7 @@ describe('ChannelService', () => { b: 200, }) - expect(mockEventsAPI.emit).toHaveBeenCalledWith('core:chat:addMessage', [1, 2], { + expect(mockEventsAPI.emit).toHaveBeenCalledWith(SYSTEM_EVENTS.chat.addMessage, [1, 2], { args: ['CustomAuthor', 'Test message'], color: { r: 100, g: 150, b: 200 }, }) @@ -206,7 +207,7 @@ describe('ChannelService', () => { it('should broadcast system message', () => { channelService.broadcastSystem('test-channel', 'System announcement') - expect(mockEventsAPI.emit).toHaveBeenCalledWith('core:chat:addMessage', [1, 2], { + expect(mockEventsAPI.emit).toHaveBeenCalledWith(SYSTEM_EVENTS.chat.addMessage, [1, 2], { args: ['SYSTEM', 'System announcement'], color: { r: 0, g: 191, b: 255 }, }) 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/tests/unit/transport/node-events.test.ts b/tests/unit/transport/node-events.test.ts index 8ab4f68..5b4f2f7 100644 --- a/tests/unit/transport/node-events.test.ts +++ b/tests/unit/transport/node-events.test.ts @@ -23,8 +23,8 @@ describe('NodeEvents', () => { // ═══════════════════════════════════════════════════════════════════════════ describe('on + emit', () => { it('should receive events emitted to a specific clientId target', async () => { - let receivedCtx: any - let receivedArgs: any[] = [] + let receivedCtx: { clientId?: number; raw?: unknown } | undefined + let receivedArgs: readonly unknown[] = [] events.on('test:event', (ctx, ...args) => { receivedCtx = ctx @@ -36,12 +36,12 @@ describe('NodeEvents', () => { await waitForEventProcessing() expect(receivedCtx).toBeDefined() - expect(receivedCtx.clientId).toBe(42) + expect(receivedCtx?.clientId).toBe(42) expect(receivedArgs).toEqual(['hello', 'world']) }) it('should receive events emitted to "all"', async () => { - let receivedCtx: any + let receivedCtx: { clientId?: number; raw?: unknown } | undefined events.on('broadcast', (ctx) => { receivedCtx = ctx @@ -52,7 +52,7 @@ describe('NodeEvents', () => { await waitForEventProcessing() expect(receivedCtx).toBeDefined() - expect(receivedCtx.clientId).toBe(-1) + expect(receivedCtx?.clientId).toBe(-1) }) it('should emit to each clientId in an array target', async () => { @@ -72,7 +72,7 @@ describe('NodeEvents', () => { }) it('should treat non-target first arg as payload', async () => { - let receivedArgs: any[] = [] + let receivedArgs: readonly unknown[] = [] events.on('payload:first', (_ctx, ...args) => { receivedArgs = args @@ -92,8 +92,8 @@ describe('NodeEvents', () => { // ═══════════════════════════════════════════════════════════════════════════ describe('simulateClientEvent', () => { it('should simulate an event from a specific client', async () => { - let receivedCtx: any - let receivedArgs: any[] = [] + let receivedCtx: { clientId?: number; raw?: unknown } | undefined + let receivedArgs: readonly unknown[] = [] events.on('client:action', (ctx, ...args) => { receivedCtx = ctx @@ -104,8 +104,8 @@ describe('NodeEvents', () => { await waitForEventProcessing() - expect(receivedCtx.clientId).toBe(7) - expect(receivedCtx.raw).toBe(7) + expect(receivedCtx?.clientId).toBe(7) + expect(receivedCtx?.raw).toBe(7) expect(receivedArgs).toEqual(['arg1', 'arg2']) }) diff --git a/tsconfig.json b/tsconfig.json index bfe36ce..d97c79b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,9 +13,7 @@ "experimentalDecorators": true, "emitDecoratorMetadata": true, - "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,