Initial commit — Southwest Roleplay FiveM

This commit is contained in:
jack
2026-06-20 13:05:34 +00:00
commit 3857de3d85
397 changed files with 30322 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
dist/
*.map

90
PORTING.md Normal file
View File

@@ -0,0 +1,90 @@
# FiveM Port — Feature Porting Guide
## Status
| Layer | Status | Notes |
|---|---|---|
| TypeORM entities (207) | ✅ Copied verbatim | Zero changes needed |
| Express REST API | ✅ Copied verbatim | Zero changes needed |
| Shared constants | ✅ Copied verbatim | |
| Core log/DB/Redis/Mongo | ✅ Copied verbatim | |
| `mp` server shim | ✅ Done | Covers events, players, vehicles, config |
| `mp` client shim | ✅ Done | Covers events, game, gui, browsers→NUI |
| RPC system | ✅ Done | Replaces `mp.events.addProc` |
| Player wrapper | ✅ Done | Wraps FiveM net ID into PlayerMp-compatible object |
| Vehicle wrapper | ✅ Done | Wraps FiveM entity handle |
| CommandSystem | ✅ Done | Uses `RegisterCommand()` per command |
| Server features (101) | 🔲 Not started | See porting order below |
| Client features (~80) | 🔲 Not started | See porting order below |
---
## How to port a server feature
1. **Copy** the feature folder from `game-server/src/features/<name>/` to `fivem-server/src/server/features/<name>/`
2. **Fix imports** — update relative paths (e.g. `../../entities/``../../entities/`, `../../core/` stays the same)
3. **Search for `mp.colshapes`** — FiveM has no server colshapes. Replace with the polygon position-tracking system.
4. **Search for `mp.objects.new` / `mp.peds.new`** — use FiveM `CreateObject` / `CreatePed` natives.
5. **Search for `player.call(`** — this works unchanged via `PlayerWrapper.call()`.
6. **Uncomment the import** in `src/server/index.ts` and add to `featuresParallel` or `featuresSeries`.
## How to port a client feature
1. **Copy** the feature folder from `game-client/src/<name>/` to `fivem-server/src/client/features/<name>/`
2. **Fix imports**
3. **Replace `mp.events.addProc`** with `rpc.registerClientProc` from `../core/rpc`
4. **Replace `mp.browsers.new(url).execute(js)`** with `nui.send(type, data)` (PostMessage to Vue)
5. **Replace `mp.game.invoke(hash, ...args)`** with direct native calls where the name is known, or keep `mp.game.invoke` (the shim passes through to `Citizen.invokeNative`)
6. **Uncomment the require** in `src/client/index.ts`
---
## Suggested porting order (server features)
Start with features that have minimal RageMP API surface and no colshape dependency:
1. `shared` — pure data sync
2. `time` — simple, no colshapes
3. `weather` — simple
4. `chat` — core comms, needed by everything
5. `login` — needed before players can do anything
6. `session` — depends on login
7. `player` — needed by commands
8. `permissions` — needed by guards
9. `money` — needed by jobs/most features
10. `inventory` — high-value, many features depend on it
11. `vehicle` — large but critical
12. `job/*` — already on feature/jobsystem branch, good to port early
Defer until late (complex colshape or streaming dependencies):
- `properties` (heavy colshape use → needs polygon system)
- `furniture` (server objects)
- `drugs` (colshapes + objects)
- `graffiti` (colshapes)
---
## RageMP → FiveM cheatsheet
| RageMP (server) | FiveM (server) |
|---|---|
| `mp.events.add('playerJoin', (p) => ...)` | Auto-mapped by shim → `playerConnecting` |
| `mp.events.add('playerQuit', (p) => ...)` | Auto-mapped by shim → `playerDropped` |
| `mp.events.add('myEvent', (p, data) => ...)` | Shim registers net event; player injected from `source` |
| `player.call('event', [a, b])` | `TriggerClientEvent('event', netId, a, b)` |
| `player.notify(msg)` | `TriggerClientEvent('_mp:notify', netId, msg)` |
| `player.setVariable(k, v)` | FiveM StateBag: `Player(id).state.set(k, v, true)` |
| `mp.vehicles.new(model, pos, opts)` | `vehicleRegistry.create(model, pos, opts)` |
| `mp.events.addProc('name', fn)` | `rpc.register('name', fn)` |
| `mp.colshapes.newSphere(...)` | Use polygon position tracking system |
| `mp.players.broadcast(msg)` | `TriggerClientEvent('_mp:chatMessage', -1, msg)` |
| RageMP (client) | FiveM (client) |
|---|---|
| `mp.events.add('render', fn)` | Shim adds to tick loop |
| `mp.events.callRemote('event', ...args)` | `TriggerServerEvent('event', ...args)` |
| `mp.events.addProc('name', fn)` | `rpc.registerClientProc('name', fn)` |
| `mp.players.local.setComponentVariation(...)` | `SetPedComponentVariation(PlayerPedId(), ...)` |
| `mp.game.invoke('0xHASH', ...args)` | `Citizen.invokeNative('0xHASH', ...args)` |
| `mp.browsers.new(url)` | FiveM NUI (declared in `fxmanifest.lua`, one page) |
| `mp.gui.chat.push(msg)` | `TriggerEvent('chat:addMessage', { args: [msg] })` |

382
README.md Normal file
View File

@@ -0,0 +1,382 @@
# Southwest Roleplay — FiveM Server
This resource is a TypeScript FiveM port of the Southwest Roleplay game-server (originally written for RageMP). It shares the same database layer, REST API, and feature module structure as the original. The goal is a 1-to-1 feature parity port: one feature at a time, minimal API surface changes.
---
## Table of Contents
1. [Prerequisites](#prerequisites)
2. [Getting Started](#getting-started)
3. [Environment Variables](#environment-variables)
4. [Project Structure](#project-structure)
5. [Architecture Overview](#architecture-overview)
6. [Build System](#build-system)
7. [Boot Sequence](#boot-sequence)
8. [Key Systems](#key-systems)
9. [REST API](#rest-api)
10. [Porting a Feature](#porting-a-feature)
11. [RageMP → FiveM Cheatsheet](#ragemp--fivem-cheatsheet)
12. [Porting Status](#porting-status)
---
## Prerequisites
| Tool | Version |
|---|---|
| Node.js | 18+ |
| npm | 8+ |
| MySQL | 8.0 |
| Redis | 6+ |
| FiveM server artifact | build 7290+ |
---
## Getting Started
```bash
# 1. Install dependencies
npm install
# 2. Build both server and client bundles
npm run build
# 3. Drop the compiled resource into your FiveM server resources folder
# and add `ensure swrp-fivem-server` to server.cfg
```
For active development, use watch mode — both bundles rebuild automatically on save:
```bash
npm run watch
```
Lint the TypeScript source:
```bash
npm run lint
```
---
## Environment Variables
All variables have sensible defaults for the dev environment. Override them in your FiveM `server.cfg` using `set` convars, or export them in your shell when running outside FiveM.
| Variable | Default | Description |
|---|---|---|
| `DB_HOST` | `play.southwest-roleplay.dev` | MySQL host |
| `DB_PORT` | `3306` | MySQL port |
| `DB_USER` | `username` | MySQL user |
| `DB_PASS` | *(see `database.ts`)* | MySQL password |
| `DB_NAME` | `game` | MySQL database name |
| `REDIS_HOST` | `swrp-redis` | Redis host |
| `REDIS_PORT` | `6379` | Redis port |
| `REDIS_URL` | *(unset)* | Full Redis URL (overrides host/port) |
| `LSRAGE_MONGO_QUEUE` | `mongo:jobs` | Redis list key used for the Mongo write queue |
| `SWRP_UCP_URL` | `http://127.0.0.1` | Comma-separated allowed origins for the REST API CORS policy |
> **Note:** The database does not auto-migrate (`synchronize: false`). Schema migrations are managed separately.
---
## Project Structure
```
fivem-server/
├── fxmanifest.lua # FiveM resource manifest
├── package.json
├── webpack.server.js # Server bundle config (Node target)
├── webpack.client.js # Client bundle config (browser target)
├── gulpfile.js # Orchestrates webpack via gulp
├── tsconfig.server.json
├── tsconfig.client.json
├── ormconfig.js # TypeORM CLI config (uses compiled entities)
├── PORTING.md # Feature porting checklist and guide
├── src/
│ ├── server/
│ │ ├── index.ts # Entry point — boot sequence
│ │ ├── core/
│ │ │ ├── mp-shim.ts # Global `mp` object (RageMP compat layer)
│ │ │ ├── database.ts # TypeORM / MySQL
│ │ │ ├── redis.ts # Redis client
│ │ │ ├── mongo.ts # Mongo write queue (via Redis list)
│ │ │ ├── preload.ts # Eager DB cache (game entity types)
│ │ │ ├── feature-system.ts # Base class for all feature modules
│ │ │ ├── commands/ # Command system + guards
│ │ │ ├── log/ # Structured logging + log events
│ │ │ ├── rpc/ # Bidirectional client ↔ server RPC
│ │ │ ├── player/ # PlayerWrapper + player registry
│ │ │ └── vehicle/ # VehicleWrapper + vehicle registry
│ │ ├── entities/ # TypeORM entities (207 tables)
│ │ ├── features/ # Feature modules (one folder per feature)
│ │ └── rest/ # Express REST API (external service hooks)
│ │
│ ├── client/
│ │ ├── index.ts # Client entry point
│ │ ├── core/
│ │ │ ├── mp-shim.ts # Client-side `mp` compat layer
│ │ │ └── rpc/ # Client RPC (call server procs)
│ │ └── features/ # Ported client features
│ │
│ └── shared/
│ └── const/ # Constants shared between server and client
├── dist/ # Compiled output (gitignored, produced by build)
│ ├── server/index.js
│ └── client/index.js
└── ui/ # Pre-built Vue NUI (served as the resource's ui_page)
```
---
## Architecture Overview
```
┌──────────────────────────────────────────────────┐
│ FiveM Server │
│ │
│ ┌──────────────┐ ┌──────────────────────────┐ │
│ │ mp shim │ │ FeatureSystem modules │ │
│ │ (global mp) │ │ (player, login, chat…) │ │
│ └──────┬───────┘ └──────────────────────────┘ │
│ │ │
│ ┌──────▼───────┐ ┌──────────────────────────┐ │
│ │ CommandSystem│ │ REST API (Express :3001) │ │
│ └──────────────┘ └──────────────────────────┘ │
│ │
│ ┌──────────┐ ┌──────────┐ ┌────────────────┐ │
│ │ MySQL │ │ Redis │ │ Mongo queue │ │
│ │ (TypeORM)│ │ (cache) │ │ (via Redis) │ │
│ └──────────┘ └──────────┘ └────────────────┘ │
└──────────────────────────────────────────────────┘
▲ net events / RPC ▼
┌──────────────────────────────────────────────────┐
│ FiveM Client │
│ │
│ ┌──────────────┐ ┌────────────────────────┐ │
│ │ mp shim │ │ Feature modules │ │
│ │ (global mp) │ │ (login, vehicle, …) │ │
│ └──────────────┘ └────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ NUI (Vue — ui/index.html) │ │
│ └──────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
```
### The `mp` compatibility shim
Both server and client expose a global `mp` object that mirrors the RageMP API. This means ported feature files compile and run with minimal changes. Functions that have no FiveM equivalent are stubbed and log a `console.warn` so they are easy to find.
Key mappings:
| Concept | RageMP | FiveM (via shim) |
|---|---|---|
| Player state | `player.setVariable(k, v)` | FiveM StateBag |
| Server → client event | `player.call('event', [...])` | `TriggerClientEvent` |
| Client → server event | `mp.events.callRemote('event')` | `TriggerServerEvent` |
| Server proc (RPC) | `mp.events.addProc('name', fn)` | `rpc.register('name', fn)` |
| Config values | `mp.config.key` | `GetConvar('key', ...)` |
---
## Build System
Webpack bundles the TypeScript source into two CommonJS files that FiveM loads at runtime:
| Bundle | Entry | Output | Notes |
|---|---|---|---|
| Server | `src/server/index.ts` | `dist/server/index.js` | `target: node`; npm deps are external |
| Client | `src/client/index.ts` | `dist/client/index.js` | Runs inside FiveM's V8 sandbox |
npm packages are left as external `require()` calls in the server bundle — FiveM resolves them from the resource's `node_modules/` directory at runtime.
Source maps are emitted alongside each bundle for stack-trace readability.
---
## Boot Sequence
`src/server/index.ts` orchestrates startup in this order:
1. **`mp-shim`** — must be the first import; builds the global `mp` object before anything else.
2. **`databaseSystem.init()`** — opens the MySQL connection (retries up to 5 times).
3. **`mongoSystem.init()`** — verifies the Redis-backed Mongo write queue is available.
4. **`redisSystem.init()`** — connects to Redis and flushes all keys.
5. **`preloadSystem.init()`** — eager-loads game entity types from the DB into memory.
6. **Parallel features**`featuresParallel` array; all `init()` calls run concurrently.
7. **Series features**`featuresSeries` array; each `init()` runs in order (use for features with dependencies).
8. **`postInit` pass** — optional second-pass for cross-feature wiring.
9. **`commandSystem.init()`** — collects commands from all features and registers them with FiveM's `RegisterCommand()`.
To add a newly-ported feature to the boot sequence, import it in `src/server/index.ts` and push it onto `featuresParallel` or `featuresSeries`.
---
## Key Systems
### FeatureSystem
Every feature extends `FeatureSystem`:
```ts
import { FeatureSystem } from '../../core/feature-system';
class MyFeature extends FeatureSystem {
commands = [MyCommand]; // optional — registers with CommandSystem
async init(): Promise<void> {
await super.init();
// set up event handlers, load DB data, etc.
}
async postInit(): Promise<void> {
// cross-feature wiring that requires other features to be initialised
}
}
export const myFeature = new MyFeature();
```
### CommandSystem
Commands are classes that extend `BaseCommand`. They declare their name, parameters, guards, and a `run()` method. The system validates parameters automatically and runs guards before `run()`.
Guards live in `src/server/core/commands/guards/` and return `true` on success or an error string on failure. Available guards:
- `PermissionsCommandGuard` — checks player permission group
- `PlayerStateCommandGuard` — checks alive/dead state
- `PrisonCommandGuard` — blocks commands while imprisoned
- `NoSpectateCommandGuard` — blocks commands while spectating
- `PhoneCommandGuard` — requires a phone
- `VehicleCommandGuard` — requires the player to be in a vehicle
### RPC System
Two-way remote procedure calls between server and client, replacing `mp.events.addProc` / `mp.events.callRemoteProc`.
**Server registers a handler:**
```ts
import { rpc } from '../core/rpc';
rpc.register('getPlayerInfo', async (player, ...args) => {
return { name: player.name };
});
```
**Client calls the server:**
```ts
import { rpc } from '../core/rpc';
const info = await rpc.call('getPlayerInfo');
```
**Server calls a client proc:**
```ts
const result = await rpc.callClient(player, 'getPedPosition');
```
The wire protocol uses two net events (`_rpc:call` / `_rpc:result`) and auto-times out client calls after 10 seconds.
### Player Registry
`PlayerWrapper` wraps a FiveM net ID and exposes a RageMP-compatible player API (`.name`, `.id`, `.notify()`, `.call()`, `.setVariable()`, `.getVariable()`, etc.). The registry is maintained by the mp-shim on `playerConnecting` / `playerDropped`.
---
## REST API
An Express server starts on port **3001** alongside the FiveM resource. It is used by external services (UCP, Discord bots, admin tooling) to interact with the live game server.
All requests must include an `rpckey` header matching a key stored in the `rpc_keys` database table. Optionally the key can be IP-restricted to a CIDR range.
### Routes
| Prefix | Module | Purpose |
|---|---|---|
| `/account` | AccountRpcModule | Bans, admin notes, session invalidation |
| `/character` | CharacterRpcModule | Spawn items/money, refunds, in-game checks |
| `/chat` | ChatRpcModule | Post messages to in-game chat channels |
| `/company` | CompanyRpcModule | Manage company employees and metadata |
| `/faction` | FactionRpcModule | Faction creation / kick events |
| `/group` | GroupRpcModule | Permission group and frequency changes |
| `/motd` | MotdRpcModule | Push new MOTD / force reload |
| `/players` | PlayersRpcModule | Online player list / admin list |
| `/property` | PropertyRpcModule | Reload property parameters |
| `/vehicle` | VehicleRpcModule | Reload vehicle parameters, spawn checks |
---
## Porting a Feature
See `PORTING.md` for the full guide and suggested porting order. The short version:
### Server feature
1. Copy `game-server/src/features/<name>/``fivem-server/src/server/features/<name>/`
2. Fix relative imports (`../../entities/`, `../../core/` paths stay the same).
3. Replace `mp.colshapes.*` with the polygon position-tracking system.
4. Replace `mp.objects.new` / `mp.peds.new` with FiveM `CreateObject` / `CreatePed` natives.
5. `player.call(...)` works unchanged via `PlayerWrapper.call()`.
6. Uncomment the import in `src/server/index.ts` and add to `featuresParallel` or `featuresSeries`.
### Client feature
1. Copy `game-client/src/<name>/``fivem-server/src/client/features/<name>/`
2. Fix imports.
3. Replace `mp.events.addProc('name', fn)` with `rpc.registerClientProc('name', fn)`.
4. Replace `mp.browsers.new(url).execute(js)` with `nui.send(type, data)` (PostMessage to Vue).
5. Replace `mp.game.invoke('0xHASH', ...)` with `Citizen.invokeNative('0xHASH', ...)` or the named native.
6. Uncomment the import in `src/client/index.ts`.
---
## RageMP → FiveM Cheatsheet
| RageMP (server) | FiveM (server) |
|---|---|
| `mp.events.add('playerJoin', fn)` | Shim maps → `playerConnecting` |
| `mp.events.add('playerQuit', fn)` | Shim maps → `playerDropped` |
| `mp.events.add('myEvent', fn)` | Shim registers net event; player injected from `source` |
| `player.call('event', [a, b])` | `TriggerClientEvent('event', netId, a, b)` |
| `player.notify(msg)` | `TriggerClientEvent('_mp:notify', netId, msg)` |
| `player.setVariable(k, v)` | FiveM StateBag: `Player(id).state.set(k, v, true)` |
| `mp.vehicles.new(model, pos, opts)` | `vehicleRegistry.create(model, pos, opts)` |
| `mp.events.addProc('name', fn)` | `rpc.register('name', fn)` |
| `mp.colshapes.newSphere(...)` | Polygon position-tracking system |
| `mp.players.broadcast(msg)` | `TriggerClientEvent('_mp:chatMessage', -1, msg)` |
| RageMP (client) | FiveM (client) |
|---|---|
| `mp.events.add('render', fn)` | Shim adds to tick loop |
| `mp.events.callRemote('event', ...args)` | `TriggerServerEvent('event', ...args)` |
| `mp.events.addProc('name', fn)` | `rpc.registerClientProc('name', fn)` |
| `mp.players.local.setComponentVariation(...)` | `SetPedComponentVariation(PlayerPedId(), ...)` |
| `mp.game.invoke('0xHASH', ...args)` | `Citizen.invokeNative('0xHASH', ...args)` |
| `mp.browsers.new(url)` | FiveM NUI (`ui/index.html`) |
| `mp.gui.chat.push(msg)` | `TriggerEvent('chat:addMessage', { args: [msg] })` |
---
## Porting Status
| Layer | Status | Notes |
|---|---|---|
| TypeORM entities (207) | Done | Copied verbatim, no changes needed |
| Express REST API | Done | Copied verbatim, no changes needed |
| Shared constants | Done | |
| Core log / DB / Redis / Mongo | Done | |
| `mp` server shim | Done | Events, players, vehicles, config |
| `mp` client shim | Done | Events, game, gui, NUI |
| RPC system | Done | Replaces `mp.events.addProc` |
| Player wrapper | Done | Wraps FiveM net ID into `PlayerMp`-compatible object |
| Vehicle wrapper | Done | Wraps FiveM entity handle |
| CommandSystem | Done | Uses `RegisterCommand()` per command |
| Server features (101) | Not started | See `PORTING.md` for suggested order |
| Client features (~80) | Not started | See `PORTING.md` for suggested order |
The recommended first features to port (in order) are: `time`, `weather`, `chat`, `login`, `session`, `player`, `permissions`, `money`, `inventory`, `vehicle`.
Defer features with heavy colshape or server-object dependencies (`properties`, `furniture`, `drugs`, `graffiti`) until the polygon position-tracking system is fully integrated.

28
fxmanifest.lua Normal file
View File

@@ -0,0 +1,28 @@
fx_version 'cerulean'
game 'gta5'
description 'Southwest Roleplay'
version '1.0.0'
-- Server scripts (Node.js runtime, CommonJS bundles)
server_scripts {
'dist/server/index.js',
}
-- Client scripts (single bundled file)
client_scripts {
'dist/client/index.js',
}
-- NUI page (same Vue UI from game-client)
ui_page 'ui/index.html'
files {
'ui/**',
}
-- Dependencies
dependencies {
'/server:7290',
'/onesync',
}

40
gulpfile.js Normal file
View File

@@ -0,0 +1,40 @@
const gulp = require('gulp');
const { exec } = require('child_process');
function buildServer(cb) {
exec('npx webpack --config webpack.server.js', (err, stdout, stderr) => {
if (stdout) process.stdout.write(stdout);
if (stderr) process.stderr.write(stderr);
cb(err);
});
}
function buildClient(cb) {
exec('npx webpack --config webpack.client.js', (err, stdout, stderr) => {
if (stdout) process.stdout.write(stdout);
if (stderr) process.stderr.write(stderr);
cb(err);
});
}
function watchServer(cb) {
exec('npx webpack --config webpack.server.js --watch', (err, stdout, stderr) => {
if (stdout) process.stdout.write(stdout);
if (stderr) process.stderr.write(stderr);
});
cb();
}
function watchClient(cb) {
exec('npx webpack --config webpack.client.js --watch', (err, stdout, stderr) => {
if (stdout) process.stdout.write(stdout);
if (stderr) process.stderr.write(stderr);
});
cb();
}
exports.buildServer = buildServer;
exports.buildClient = buildClient;
exports.build = gulp.parallel(buildServer, buildClient);
exports.watch = gulp.parallel(watchServer, watchClient);
exports.default = exports.build;

11
ormconfig.js Normal file
View File

@@ -0,0 +1,11 @@
module.exports = {
type: 'mysql',
host: process.env.DB_HOST || 'play.southwest-roleplay.dev',
port: Number(process.env.DB_PORT) || 3306,
username: process.env.DB_USER || 'username',
password: process.env.DB_PASS || 'password',
database: process.env.DB_NAME || 'game',
logging: ['error', 'log'],
entities: ['./dist/server/entities/*.js'],
synchronize: false,
};

8476
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

65
package.json Normal file
View File

@@ -0,0 +1,65 @@
{
"name": "swrp-fivem-server",
"version": "1.0.0",
"description": "Southwest Roleplay - FiveM",
"license": "ISC",
"scripts": {
"watch": "npx gulp watch",
"build": "npx gulp build",
"lint": "eslint \"src/**/*.ts\""
},
"dependencies": {
"ansi_up": "^5.0.1",
"async-mutex": "^0.5.0",
"aws-sdk": "^2.1693.0",
"body-parser": "^1.20.0",
"colors": "^1.4.0",
"cors": "^2.8.5",
"dayjs": "^1.11.20",
"express": "^4.17.1",
"ip-cidr": "^2.1.4",
"lodash": "^4.17.21",
"mongodb": "^3.7.4",
"mustache": "^4.2.0",
"mysql": "^2.18.1",
"node-fetch": "^2.6.1",
"node-schedule": "^2.1.1",
"nodemailer": "^8.0.5",
"redis": "^3.1.2",
"showdown": "^2.1.0",
"source-map-support": "^0.5.21",
"typeorm": "^0.2.45",
"uuid": "^8.3.2"
},
"devDependencies": {
"@citizenfx/client": "*",
"@citizenfx/server": "*",
"@eslint/js": "^9.18.0",
"@types/cors": "^2.8.10",
"@types/express": "^4.17.11",
"@types/lodash": "^4.14.168",
"@types/mongodb": "^4.0.6",
"@types/mustache": "^4.1.1",
"@types/node": "^18.0.0",
"@types/node-fetch": "^2.5.9",
"@types/node-schedule": "^2.1.8",
"@types/nodemailer": "^8.0.0",
"@types/redis": "^2.8.28",
"@types/showdown": "^2.0.6",
"@types/source-map-support": "^0.5.10",
"@types/uuid": "^8.3.0",
"del": "^8.0.1",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"globals": "^17.0.0",
"gulp": "^5.0.1",
"gulp-sourcemaps": "^3.0.0",
"gulp-typescript": "^6.0.0-alpha.1",
"prettier": "^2.8.8",
"typescript": "^5.9.3",
"typescript-eslint": "^8.20.0",
"webpack": "^5.0.0",
"webpack-cli": "^4.0.0",
"ts-loader": "^9.0.0"
}
}

266
src/client/core/mp-shim.ts Normal file
View File

@@ -0,0 +1,266 @@
/**
* mp-shim.ts — RageMP compatibility layer for FiveM (client-side).
*
* Exposes a global `mp` object matching the RageMP client API so that ported
* client feature files compile and run with minimal changes.
*
* Key differences from the server shim:
* - No player pool (there's only the local player)
* - Native calls go through FiveM's global native functions
* - mp.game.* wraps native namespaces
* - Browsers are replaced by FiveM NUI
* - mp.events.addProc is handled by src/client/core/rpc/index.ts
*/
import { rpc } from './rpc/index';
// ---------------------------------------------------------------------------
// Well-known RageMP client events → FiveM equivalents
// ---------------------------------------------------------------------------
const RAGEMP_TO_FIVEM: Record<string, string> = {
render: 'FRAME_STARTED', // PARTIAL — see note below
entityStreamIn: 'entityCreating', // PARTIAL
entityStreamOut: 'entityRemoved', // PARTIAL
playerEnterVehicle: 'enteredVehicle', // via baseevents
playerExitVehicle: 'leftVehicle', // via baseevents
playerSpawn: 'playerSpawned',
};
// FiveM doesn't have a 'render' equivalent exactly — FRAME_STARTED doesn't
// exist either. For per-tick logic we use a persistent thread below.
const tickHandlers: Array<() => void> = [];
setTick(() => {
for (const handler of tickHandlers) {
try { handler(); } catch (e) { console.error('[mp-shim] tick handler error:', e); }
}
});
// ---------------------------------------------------------------------------
// Events
// ---------------------------------------------------------------------------
const events = {
// Register a client-side event handler.
// - 'render' → runs every tick
// - Net events sent from server → AddEventHandler + RegisterNetEvent
// - Game events (playerEnterVehicle etc.) → mapped to FiveM baseevents
add(eventOrMap: string | Record<string, Function>, handler?: Function): void {
if (typeof eventOrMap === 'object') {
for (const [name, fn] of Object.entries(eventOrMap)) {
events.add(name, fn);
}
return;
}
const name = eventOrMap as string;
const fn = handler!;
if (name === 'render') {
tickHandlers.push(fn as () => void);
return;
}
if (name in RAGEMP_TO_FIVEM) {
AddEventHandler(RAGEMP_TO_FIVEM[name], fn);
return;
}
// Assume it's a server→client net event
RegisterNetEvent(name);
AddEventHandler(name, fn);
},
// RageMP client proc — handled by custom RPC module
addProc(name: string, handler: (...args: any[]) => any): void {
rpc.registerClientProc(name, handler);
},
// Client → Server event (replaces mp.events.callRemote)
callRemote(eventName: string, ...args: any[]): void {
TriggerServerEvent(eventName, ...args);
},
// Local client event emit
call(eventName: string, ...args: any[]): void {
TriggerEvent(eventName, ...args);
},
};
// ---------------------------------------------------------------------------
// Local player
// ---------------------------------------------------------------------------
const localPlayer = {
get handle(): number { return PlayerPedId(); },
// Component variations (clothing)
setComponentVariation(componentId: number, drawableId: number, textureId: number, paletteId = 0): void {
SetPedComponentVariation(PlayerPedId(), componentId, drawableId, textureId, paletteId);
},
getComponentVariation(componentId: number): [number, number, number] {
return [
GetPedDrawableVariation(PlayerPedId(), componentId),
GetPedTextureVariation(PlayerPedId(), componentId),
GetPedPaletteVariation(PlayerPedId(), componentId),
];
},
setPropIndex(propId: number, drawableId: number, textureId: number, attach: boolean): void {
SetPedPropIndex(PlayerPedId(), propId, drawableId, textureId, attach);
},
clearProp(propId: number): void {
ClearPedProp(PlayerPedId(), propId);
},
// Head blend / facial features (character creator)
setHeadBlendData(
shapeFirst: number, shapeSecond: number, shapeThird: number,
skinFirst: number, skinSecond: number, skinThird: number,
shapeMix: number, skinMix: number, thirdMix: number, isParent: boolean,
): void {
SetPedHeadBlendData(PlayerPedId(), shapeFirst, shapeSecond, shapeThird, skinFirst, skinSecond, skinThird, shapeMix, skinMix, thirdMix, isParent);
},
setHeadOverlay(overlayId: number, index: number, opacity: number, firstColor?: number, secondColor?: number): void {
SetPedHeadOverlay(PlayerPedId(), overlayId, index, opacity);
if (firstColor !== undefined) SetPedHeadOverlayColor(PlayerPedId(), overlayId, 1, firstColor, secondColor ?? 0);
},
setFaceFeature(featureId: number, scale: number): void {
SetPedFaceFeature(PlayerPedId(), featureId, scale);
},
setHairColor(colorId: number, highlightColorId: number): void {
SetPedHairColor(PlayerPedId(), colorId, highlightColorId);
},
setEyeColor(index: number): void {
SetPedEyeColor(PlayerPedId(), index);
},
// Config flags
setConfigFlag(flag: number, value: boolean): void {
SetPedConfigFlag(PlayerPedId(), flag, value);
},
// Position / movement
get position(): { x: number; y: number; z: number } {
const [x, y, z] = GetEntityCoords(PlayerPedId(), true);
return { x, y, z };
},
isInVehicle(): boolean {
return IsPedInAnyVehicle(PlayerPedId(), false);
},
getCurrentVehicle(): number | null {
const v = GetVehiclePedIsIn(PlayerPedId(), false);
return v !== 0 ? v : null;
},
// STUB — animation playback proxied to game.invoke or native wrappers
playAnimation(dict: string, name: string, blendInSpeed: number, blendOutSpeed: number, duration: number, flag: number, startOffset: number): void {
TaskPlayAnim(PlayerPedId(), dict, name, blendInSpeed, blendOutSpeed, duration, flag, startOffset, false, false, false);
},
stopAnimation(): void {
ClearPedTasks(PlayerPedId());
},
};
// ---------------------------------------------------------------------------
// mp.game — wraps GTA native namespaces
// ---------------------------------------------------------------------------
const game = {
audio: {
setAudioFlag(flag: string, value: boolean): void {
SetAudioFlag(flag, value);
},
playSound(soundId: number, soundName: string, soundSetName: string, isPedPersonalSound: boolean, p4: number, p5: boolean): void {
PlaySound(soundId, soundName, soundSetName, isPedPersonalSound, p4, p5);
},
playSoundFrontend(soundId: number, audioName: string, audioRef: string, p3: boolean): void {
PlaySoundFrontend(soundId, audioName, audioRef, p3);
},
},
vehicle: {
set defaultEngineBehaviour(value: boolean) {
// SetVehicleDefaultEngineBehaviour doesn't exist; handled via config flags
},
setResetUnoccupiedSteerAngle(value: boolean): void {
// STUB — no direct FiveM equivalent
},
},
// Catch-all for raw native hash invocations (mp.game.invoke)
invoke(hash: string, ...args: any[]): any {
return (Citizen as any).invokeNative(hash, ...args);
},
};
// ---------------------------------------------------------------------------
// mp.gui — chat and cursor
// ---------------------------------------------------------------------------
const gui = {
chat: {
activate(value: boolean): void {
// FiveM chat activation is handled client-side via NUI events
TriggerEvent('chat:visible', value);
},
show(value: boolean): void {
TriggerEvent('chat:visible', value);
},
push(message: string): void {
TriggerEvent('chat:addMessage', { args: [message] });
},
},
cursor: {
show(value: boolean, doLockCursor: boolean): void {
SetCursorLocation(0.5, 0.5);
// Full NUI cursor management handled per-feature
},
},
};
// ---------------------------------------------------------------------------
// mp.browsers — replaced by FiveM NUI
// ---------------------------------------------------------------------------
const browsers = {
/**
* FiveM NUI replaces RageMP CEF browsers.
* The UI page is declared in fxmanifest.lua; only one exists at a time.
* Returns a stub object whose .execute() sends NUI messages.
*/
new(_url: string): { execute: (js: string) => void; active: boolean } {
return {
active: true,
execute(js: string) {
// Evaluate JS inside the NUI frame — use sparingly
SendNUIMessage({ type: '__eval__', code: js });
},
};
},
};
// ---------------------------------------------------------------------------
// mp.nui — FiveM-native NUI helpers (no RageMP equivalent; new API)
// ---------------------------------------------------------------------------
export const nui = {
send(type: string, data?: any): void {
SendNUIMessage({ type, ...(data ?? {}) });
},
focus(hasFocus: boolean, hasCursor: boolean): void {
SetNuiFocus(hasFocus, hasCursor);
},
onCallback(name: string, handler: (data: any, cb: (result: any) => void) => void): void {
RegisterNuiCallback(name, handler);
},
};
// ---------------------------------------------------------------------------
// Assemble global mp object
// ---------------------------------------------------------------------------
(global as any).mp = {
events,
players: {
local: localPlayer,
},
game,
gui,
browsers,
nui,
};

View File

@@ -0,0 +1,59 @@
/**
* Client-side RPC — replaces RageMP's mp.events.callRemoteProc / addProc.
*
* Call a server proc (registered with rpc.register on the server):
* const result = await rpc.call('getClosestTrunk', ...args);
*
* Register a client proc (called from server via rpc.callClient):
* rpc.registerClientProc('getVehicleClass', (vehicle) => { ... });
*/
let callCounter = 0;
const pending = new Map<string, { resolve: (v: any) => void; reject: (e: any) => void }>();
// Receive results from the server
RegisterNetEvent('_rpc:result');
AddEventHandler('_rpc:result', (callId: string, result: any, error: string | null) => {
const cb = pending.get(callId);
if (!cb) return;
pending.delete(callId);
if (error) cb.reject(new Error(error));
else cb.resolve(result);
});
// Server → client proc invocations
RegisterNetEvent('_rpc:clientCall');
AddEventHandler('_rpc:clientCall', (callId: string, name: string, args: any[]) => {
const handler = clientProcs.get(name);
if (!handler) {
TriggerServerEvent('_rpc:clientResult', callId, null, `No client proc: ${name}`);
return;
}
Promise.resolve()
.then(() => handler(...(args ?? [])))
.then((result) => TriggerServerEvent('_rpc:clientResult', callId, result, null))
.catch((err) => TriggerServerEvent('_rpc:clientResult', callId, null, err?.message ?? String(err)));
});
const clientProcs = new Map<string, (...args: any[]) => any>();
export const rpc = {
call<T = any>(name: string, ...args: any[]): Promise<T> {
const id = `${Date.now()}_${callCounter++}`;
return new Promise<T>((resolve, reject) => {
pending.set(id, { resolve, reject });
TriggerServerEvent('_rpc:call', id, name, args);
// Timeout after 10s
setTimeout(() => {
if (pending.has(id)) {
pending.delete(id);
reject(new Error(`RPC timeout: ${name}`));
}
}, 10000);
});
},
registerClientProc(name: string, handler: (...args: any[]) => any): void {
clientProcs.set(name, handler);
},
};

View File

@@ -0,0 +1,238 @@
/**
* Login client — FiveM port of game-client/src/login/index.ts
*/
const loginCameras = [
{ pos: [2026.90, 3731.27, 50.40], rot: [0, 0, 71.62], fov: 45 },
{ pos: [-325.87, 4485.50, 106.75], rot: [0, 0, 117.66], fov: 45 },
{ pos: [-382.56, 4721.71, 309.94], rot: [0, 0, -28.25], fov: 45 },
];
const pickedCam = loginCameras[Math.floor(Math.random() * loginCameras.length)];
let loginCamHandle: number = -1;
let loginActive = false;
let overlayReady = false;
// Pending spawn data set by _mp:spawn / _mp:setModel before login:characterLoaded fires
let pendingSpawnPos: { x: number; y: number; z: number } | null = null;
let pendingModel = 'mp_m_freemode_01';
// ---------------------------------------------------------------------------
// Register all server→client net events
// ---------------------------------------------------------------------------
const NET_EVENTS = [
'login:show', 'login:hide', 'login:success', 'login:error',
'login:otp', 'login:characters', 'login:spawns', 'login:characterLoaded',
'login:characterError', '_mp:spawn', '_mp:setModel', '_mp:notify',
];
for (const e of NET_EVENTS) RegisterNetEvent(e);
// ---------------------------------------------------------------------------
// NUI bridge helpers
// ---------------------------------------------------------------------------
function nuiSend(eventName: string, ...args: any[]): void {
SendNUIMessage({ type: 'mp:event', eventName, args });
}
RegisterNuiCallback('nuiEvent', (data: { eventName: string; args: any[] }, cb: Function) => {
const { eventName, args } = data;
TriggerEvent(eventName, ...(args ?? []));
cb({});
});
// ---------------------------------------------------------------------------
// Camera helpers
// ---------------------------------------------------------------------------
function createLoginCamera(): void {
if (loginCamHandle !== -1) return;
const [px, py, pz] = pickedCam.pos;
const [rx, ry, rz] = pickedCam.rot;
loginCamHandle = CreateCam('DEFAULT_SCRIPTED_CAMERA', true);
SetCamCoord(loginCamHandle, px, py, pz);
SetCamRot(loginCamHandle, rx, ry, rz, 2);
SetCamFov(loginCamHandle, pickedCam.fov);
SetCamActive(loginCamHandle, true);
RenderScriptCams(true, false, 0, true, false);
const ped = PlayerPedId();
SetEntityVisible(ped, false, false);
FreezeEntityPosition(ped, true);
SetNuiFocus(true, true);
DisplayHud(false);
DisplayRadar(false);
}
function destroyLoginCamera(): void {
if (loginCamHandle === -1) return;
RenderScriptCams(false, true, 500, true, false);
SetCamActive(loginCamHandle, false);
DestroyCam(loginCamHandle, false);
loginCamHandle = -1;
SetNuiFocus(false, false);
DisplayHud(true);
DisplayRadar(true);
loginActive = false;
}
// ---------------------------------------------------------------------------
// Token storage
// ---------------------------------------------------------------------------
function getStoredToken(): string {
return GetResourceKvpString('sw-rp:auth-token') ?? '';
}
function setStoredToken(token: string): void {
SetResourceKvp('sw-rp:auth-token', token);
}
function clearStoredToken(): void {
DeleteResourceKvp('sw-rp:auth-token');
}
// ---------------------------------------------------------------------------
// Boot
// ---------------------------------------------------------------------------
function startLogin(): void {
if (loginActive) return;
loginActive = true;
overlayReady = true;
createLoginCamera();
TriggerServerEvent('login:init', getStoredToken());
}
AddEventHandler('overlay:ready', startLogin);
setTimeout(() => {
if (!overlayReady) startLogin();
}, 3000);
// ---------------------------------------------------------------------------
// Server → client events — forward to NUI
// ---------------------------------------------------------------------------
AddEventHandler('login:show', () => {
if (!loginActive) { loginActive = true; createLoginCamera(); }
nuiSend('login:show');
});
AddEventHandler('login:hide', () => { nuiSend('login:hide'); });
AddEventHandler('login:success', (token?: string) => {
if (token) setStoredToken(token);
nuiSend('login:success', token);
});
AddEventHandler('login:error', (error: string) => { nuiSend('login:error', error); });
AddEventHandler('login:otp', (failure: boolean) => { nuiSend('login:otp', failure); });
AddEventHandler('login:characters', (characters: string) => { nuiSend('login:characters', characters); });
AddEventHandler('login:spawns', (spawns: string) => { nuiSend('login:spawns', spawns); });
AddEventHandler('login:characterError', (msg: string) => { nuiSend('login:error', msg); });
// ---------------------------------------------------------------------------
// Character loaded — run the full FiveM spawn sequence ourselves:
// model load → SetPlayerModel → NetworkResurrectLocalPlayer →
// collision wait → ShutdownLoadingScreen (clears "Awaiting scripts") →
// destroy login camera
// ---------------------------------------------------------------------------
AddEventHandler('login:characterLoaded', async () => {
nuiSend('login:hide');
const pos = pendingSpawnPos ?? { x: -1037.43, y: -2733.075, z: 13.7566 };
// 1. Load model by name (string form is safest across FiveM JS)
const model = pendingModel;
RequestModel(model as any);
let modelTries = 0;
while (!HasModelLoaded(model as any) && modelTries < 100) {
await new Promise<void>((r) => setTimeout(r, 50));
modelTries++;
}
// 2. Swap model — SetPlayerModel creates a new ped; PlayerPedId() updates after
if (HasModelLoaded(model as any)) {
SetPlayerModel(PlayerId(), model as any);
SetModelAsNoLongerNeeded(model as any);
}
// 3. Always fetch ped AFTER SetPlayerModel — it creates a new ped handle
let ped = PlayerPedId();
// 4. Place player — NetworkResurrectLocalPlayer is what spawnmanager uses
RequestCollisionAtCoord(pos.x, pos.y, pos.z);
SetEntityCoordsNoOffset(ped, pos.x, pos.y, pos.z, false, false, false, true);
NetworkResurrectLocalPlayer(pos.x, pos.y, pos.z, 0.0, true, true, false);
// 5. Fetch ped AGAIN after resurrection (NetworkResurrectLocalPlayer can swap it)
ped = PlayerPedId();
SetEntityVisible(ped, true, false);
FreezeEntityPosition(ped, false);
// 6. Wait for collision (up to 5 s), then clear "Awaiting scripts"
const colStart = Date.now();
while (!HasCollisionLoadedAroundEntity(ped) && Date.now() - colStart < 5000) {
await new Promise<void>((r) => setTimeout(r, 100));
}
ShutdownLoadingScreen();
// 7. Hand camera back to game
destroyLoginCamera();
pendingSpawnPos = null;
});
// ---------------------------------------------------------------------------
// NUI → server events
// ---------------------------------------------------------------------------
AddEventHandler('login:submit', (username: string, password: string, otp = '', remember = false) => {
TriggerServerEvent('login:attempt', username, password, otp || null, Boolean(remember));
});
AddEventHandler('login:characterSelect', (characterId: number) => {
TriggerServerEvent('login:character', Number(characterId));
});
AddEventHandler('login:spawnSelect', (spawnId: number) => {
TriggerServerEvent('login:spawn', Number(spawnId));
});
AddEventHandler('login:reset', () => {
clearStoredToken();
TriggerServerEvent('login:init', '');
nuiSend('login:show');
});
// ---------------------------------------------------------------------------
// _mp:spawn — store position for spawnmanager call in login:characterLoaded
// ---------------------------------------------------------------------------
AddEventHandler('_mp:spawn', (pos: { x: number; y: number; z: number }) => {
pendingSpawnPos = pos;
});
// ---------------------------------------------------------------------------
// _mp:setModel — store model name for spawnmanager call in login:characterLoaded
// ---------------------------------------------------------------------------
AddEventHandler('_mp:setModel', (modelName: string) => {
pendingModel = modelName || 'mp_m_freemode_01';
});
// ---------------------------------------------------------------------------
// Version check on connect
// ---------------------------------------------------------------------------
AddEventHandler('connect', () => {
TriggerServerEvent('versionCheck', '1.0');
});

35
src/client/globals.d.ts vendored Normal file
View File

@@ -0,0 +1,35 @@
/// <reference path="../../node_modules/@citizenfx/client/index.d.ts" />
/// <reference path="../../node_modules/@citizenfx/client/natives_universal.d.ts" />
// FiveM client globals not covered by @citizenfx/client typings
declare function RegisterNetEvent(eventName: string, cb?: Function): void;
declare function AddEventHandler(eventName: string, handler: Function): void;
declare function TriggerServerEvent(eventName: string, ...args: any[]): void;
declare function TriggerEvent(eventName: string, ...args: any[]): void;
declare function setTick(handler: () => void): number;
declare function clearTick(tickId: number): void;
declare function SendNuiMessage(data: string): void;
declare function RegisterNuiCallbackType(name: string): void;
declare function SetNuiFocus(hasFocus: boolean, hasCursor: boolean): void;
declare function GetPlayerServerId(player: number): number;
declare function PlayerId(): number;
declare function PlayerPedId(): number;
declare function GetPlayerPed(player: number): number;
declare function NetworkGetPlayerIndexFromPed(ped: number): number;
declare function GetEntityCoords(entity: number, alive: boolean): [number, number, number];
declare function GetEntityHeading(entity: number): number;
declare function GetEntityModel(entity: number): number;
declare function GetPedInVehicleSeat(vehicle: number, seat: number): number;
declare function GetVehiclePedIsIn(ped: number, lastVehicle: boolean): number;
declare function GetHashKey(key: string): number;
declare function NetworkResurrectLocalPlayer(x: number, y: number, z: number, heading: number, p4: boolean, p5: boolean, p6: boolean): void;
declare function ShutdownLoadingScreen(): void;
declare function RequestCollisionAtCoord(x: number, y: number, z: number): void;
declare function HasCollisionLoadedAroundEntity(entity: number): boolean;
declare function SetEntityCoordsNoOffset(entity: number, x: number, y: number, z: number, xAxis: boolean, yAxis: boolean, zAxis: boolean, clearArea: boolean): void;
// exports — FiveM resource exports
declare const exports: Record<string, any>;
// mp global — set at runtime by core/mp-shim.ts
declare const mp: any;

26
src/client/index.ts Normal file
View File

@@ -0,0 +1,26 @@
/**
* FiveM client entry point — mirrors src/index.ts from game-client.
* The mp shim MUST be the very first import.
*/
import './core/mp-shim';
// ---------------------------------------------------------------------------
// Feature imports
// ---------------------------------------------------------------------------
import './features/login';
// require('./vehicle');
// require('./weather');
// require('./destination-marker');
// require('./chat');
// require('./inventory');
// require('./phone');
// require('./player');
// ... add as features are ported
// Disable default FiveM chat UI (replaced by custom NUI)
mp.gui.chat.activate(false);
mp.gui.chat.show(false);

View File

@@ -0,0 +1 @@
export { ChatColor, ChatColors, ChatColorsInt } from '../../shared/const/chat-colors';

View File

@@ -0,0 +1,18 @@
import { CommandOptions } from './options';
import { ObjectLiteral } from 'typeorm';
export interface BaseCommand {
$options: CommandOptions<{
[key: string]: string;
}>;
run(
player: PlayerMp,
params?:
| {
[key: string]: string | number | null | PlayerMp | VehicleMp;
}
| ObjectLiteral,
): Promise<void>;
}
export class BaseCommand implements BaseCommand {}

View File

@@ -0,0 +1,91 @@
import path from 'path';
import { promises as fs } from 'fs';
import { commandSystem } from './index';
import { clientSideCommands } from '../../../shared/const/clientside-commands';
import { logSystem } from '../log';
type GuardWithPermissions = CommandGuard & {
__permissions?: string[];
};
function formatValidator(validator: CommandParamValidator): string {
if (Array.isArray(validator)) {
return `one-of(${validator.map((value) => String(value)).join('|')})`;
}
if (validator instanceof RegExp) {
return `regex(${validator.toString()})`;
}
return String(validator);
}
function buildParamString(params?: CommandParamMetaData<any>): string {
if (!params || !Object.keys(params).length) {
return 'none';
}
return Object.entries(params)
.map(([key, meta]) => {
const optional = meta.default !== undefined;
const optionalHint = optional ? ` optional default=${String(meta.default)}` : ' required';
return `${key} <${formatValidator(meta.validator)}> (${meta.hint};${optionalHint})`;
})
.join(', ');
}
function extractPermissions(guards?: CommandGuard[]): string[] {
if (!guards?.length) {
return [];
}
const permissions = guards
.flatMap((guard) => {
const typedGuard = guard as GuardWithPermissions;
return typedGuard.__permissions ?? [];
})
.filter((permission, index, all) => permission && all.indexOf(permission) === index);
return permissions;
}
export async function writeCommandManifestTxt(): Promise<void> {
const lines: string[] = [];
lines.push('Southwest Roleplay Command Manifest');
lines.push(`Generated: ${new Date().toISOString()}`);
lines.push('');
const commandEntries = Array.from(commandSystem.commands.entries()).sort(([left], [right]) => left.localeCompare(right));
lines.push(`Server Commands (${commandEntries.length})`);
lines.push('----------------------------------------');
for (const [commandName, command] of commandEntries) {
const params = buildParamString(command.$options.params);
const permissions = extractPermissions(command.$options.guards);
lines.push(`/${commandName}`);
lines.push(` params: ${params}`);
lines.push(` required_permissions: ${permissions.length ? permissions.join(', ') : 'none'}`);
lines.push(` grouped: ${command.$options.grouped ?? 'none'}`);
lines.push(` description: ${command.$options.description ?? 'none'}`);
lines.push('');
}
const uniqueClientCommands = [...new Set(clientSideCommands)].sort((left, right) => left.localeCompare(right));
lines.push(`Client-side Commands (${uniqueClientCommands.length})`);
lines.push('----------------------------------------');
for (const commandName of uniqueClientCommands) {
lines.push(`/${commandName}`);
lines.push(' params: n/a (handled client-side)');
lines.push(' required_permissions: n/a (handled client-side)');
lines.push('');
}
const outputPath = path.resolve(process.cwd(), 'command-manifest.txt');
await fs.writeFile(outputPath, `${lines.join('\n')}\n`, 'utf8');
logSystem.initLog('Command Manifest', `Wrote ${commandEntries.length} server command entries to ${outputPath}.`);
}

View File

@@ -0,0 +1,9 @@
export const NoSpectateCommandGuard = (): CommandGuard => {
return (player: PlayerMp) => {
if (player.state?.spectating?.target) {
return `You can't use this command while spectating.`;
}
return true;
};
};

View File

@@ -0,0 +1,21 @@
import { permissionSystem } from '../../../features/permission';
export const PermissionCommandGuard = (...permissionNames: string[]): CommandGuard => {
const permission = ((player: PlayerMp) => {
for (const permissionName of permissionNames) {
if (permissionSystem.doesPlayerHavePermission(player, permissionName)) {
return true;
}
}
if (permissionNames.includes('aduty') && player.getVariable('adminDuty')) {
return true;
}
return `You're not allowed to use this command`;
}) as CommandGuard & { __permissions?: string[] };
permission.__permissions = [...permissionNames];
return permission;
};

View File

@@ -0,0 +1,36 @@
import { phoneSystem } from '../../../features/phone';
type PhoneCommandGuardOptions = {
on?: boolean;
unlocked?: boolean;
};
export const PhoneCommandGuard = (options?: PhoneCommandGuardOptions): CommandGuard => {
return (player: PlayerMp) => {
if (!options) {
return true;
}
if (!player.character?.phone || !player.character.phone.item) {
return `You must have a phone.`;
}
if (options.on !== undefined) {
if (options.on === true && !phoneSystem.isPhoneOn(player.character.phone.item)) {
return `Your phone must be turned on.`;
} else if (options.on === false && phoneSystem.isPhoneOn(player.character.phone.item)) {
return `Your phone must be turned off.`;
}
}
if (options.unlocked !== undefined) {
if (options.unlocked === true && !phoneSystem.isPlayerPhoneLocked(player as PlayerMpLoggedIn)) {
return `Your phone must be locked.`;
} else if (options.unlocked === false && phoneSystem.isPlayerPhoneLocked(player as PlayerMpLoggedIn)) {
return `Your phone must be unlocked.`;
}
}
return true;
};
};

View File

@@ -0,0 +1,24 @@
type PrisonCommandGuardOptions = {
prison?: boolean;
jail?: boolean;
adminJail?: boolean;
all?: boolean;
};
export const PrisonCommandGuard = (options: PrisonCommandGuardOptions): CommandGuard => {
return (player: PlayerMp) => {
if ((options.prison || options.all) && player.character?.prison_cell_id) {
return `You can't use this while imprisoned.`;
}
if ((options.jail || options.all) && player.state.jailed) {
return `You can't use this while jailed.`;
}
if ((options.adminJail || options.all) && player.state.adminJail?.releaseTime) {
return `You can't use this while admin jailed.`;
}
return true;
};
};

View File

@@ -0,0 +1,46 @@
type PlayerStateCommandGuardOption = {
alive?: boolean;
wounded?: boolean;
knocked?: boolean;
dead?: boolean;
};
export const PlayerStateCommandGuard = (options?: PlayerStateCommandGuardOption): CommandGuard => {
return (player: PlayerMp) => {
if (!options) {
return true;
}
if (options.alive !== undefined) {
if (options.alive && player.state.deathState !== 'ALIVE') {
return `You must be alive.`;
} else if (!options.alive && player.state.deathState === 'ALIVE') {
return `You must not be alive.`;
}
}
if (options.wounded !== undefined) {
if (options.wounded && player.state.deathState !== 'WOUNDED') {
return `You aren't brutally wounded.`;
} else if (!options.wounded && player.state.deathState === 'WOUNDED') {
return `You're brutally wounded.`;
}
}
if (options.knocked !== undefined) {
if (!options.knocked && player.state.deathState === 'KNOCKED') {
return `You're knocked out.`;
}
}
if (options.dead !== undefined) {
if (options.dead && player.state.deathState !== 'DEAD') {
return `You aren't dead.`;
} else if (!options.dead && player.state.deathState === 'DEAD') {
return `You're dead.`;
}
}
return true;
};
};

View File

@@ -0,0 +1,45 @@
import { VehicleSystem } from '../../../features/vehicle';
const DRIVER_SEAT = -1; // GTA/FiveM convention: -1 = driver seat
type VehicleCommandGuardOptions = {
driver?: boolean;
owner?: boolean;
engine?: 'on' | 'off';
};
export const VehicleCommandGuard = (options?: VehicleCommandGuardOptions): CommandGuard => {
return (player: PlayerMp) => {
if (!player.vehicle) {
return `You aren't in a vehicle`;
}
if (options?.engine !== undefined) {
if (VehicleSystem.GetVehicleClassName(player.vehicle) !== 'CYCLES') {
if (options.engine === 'on' && !player.vehicle.engine) {
return `The engine must be turned on`;
} else if (options.engine === 'off' && player.vehicle.engine) {
return `The engine must be turned off`;
}
}
}
if (options?.driver !== undefined) {
if (options.driver && player.seat !== DRIVER_SEAT) {
return `You're not driving a vehicle`;
} else if (!options.driver && player.seat === DRIVER_SEAT) {
return `You're not a passenger`;
}
}
if (options?.owner !== undefined) {
if (options.owner && !VehicleSystem.DoesPlayerOwnVehicle(player, player.vehicle)) {
return `You don't own the vehicle`;
} else if (!options.owner && VehicleSystem.DoesPlayerOwnVehicle(player, player.vehicle)) {
return `You can't own the vehicle`;
}
}
return true;
};
};

View File

@@ -0,0 +1,364 @@
/**
* CommandSystem — FiveM port.
*
* Key difference from RageMP: each command is registered individually via
* RegisterCommand() rather than through a single 'playerCommand' event.
* The rest of the validation/dispatch logic is unchanged.
*/
import { BaseCommand } from './base.command';
import { LogSystem, logSystem } from '../log';
import { ObjectLiteral } from 'typeorm';
import { PlayerSystem } from '../../features/player';
import { ChatSystem } from '../../features/chat';
import { clientSideCommands } from '../../../shared/const/clientside-commands';
import { AnimationEntity } from '../../entities/animation.entity';
import { animationSystem } from '../../features/animations';
import { PlayerStateCommandGuard } from './guards/state.command.guard';
import { portableRadioSystem } from '../../features/portable-radio';
import { propertySystem } from '../../features/properties';
import { PropertyEntity } from '../../entities/property.entity';
import { ItemEntity } from '../../entities/item.entity';
import { PrisonCommandGuard } from './guards/prisoned.command.guard';
import { staffSystem } from '../../features/staff';
import { ContainsInvalidCharacters } from '../../features/utility/ContainsInvalidCharacters';
import { Sanitize } from '../../features/utility/Sanitize';
import { PlayerLogEvents } from '../log/events/player.log.events';
import { companySystem } from '../../features/company';
import { CompanyEntity } from '../../entities/company.entity';
import { factionSystem } from '../../features/faction';
import { FactionEntity } from '../../entities/faction.entity';
import { LargeDataServer } from '../../features/utility/SendLargeData';
import { punishmentSystem } from '../../features/admin/punishment';
import { LoginSystem } from '../../features/login';
import { playerRegistry, PlayerWrapper } from '../player/player-registry';
type CommandList = {
name: string[];
params: { hint: string; default?: string }[];
guards?: CommandGuard[];
grouped?: string;
description?: string;
}[];
export class CommandSystem {
commands: Map<string, BaseCommand> = new Map();
commandList: CommandList = [];
init(features: { commands?: (typeof BaseCommand)[] }[]): void {
features
.filter((f) => f.commands)
.forEach((f) => {
f.commands!.forEach((FeatureCommand: typeof BaseCommand) => {
const command = new FeatureCommand();
if (this.areCommandParamsValid(command)) {
this.addCommandToMap(this.commands, command);
}
});
});
this.generateCommandList();
this.registerAllWithFiveM();
// Send command list when client requests it
RegisterNetEvent('requestCommandList');
AddEventHandler('requestCommandList', () => {
const src = (global as any).source as number;
const player = playerRegistry.get(src);
if (player) this.requestCommandList(player);
});
logSystem.initLog('Command System', `Loaded ${this.commands.size} commands.`);
}
private registerAllWithFiveM(): void {
for (const [name] of this.commands) {
this.registerWithFiveM(name);
}
}
private registerWithFiveM(name: string): void {
RegisterCommand(name, async (source: number, args: string[], rawCommand: string) => {
const player = playerRegistry.get(source);
if (!player) return;
// Re-assemble the full command text the way RageMP provided it
const fullText = args.length ? `${name} ${args.join(' ')}` : name;
await this.playerCommand(player, fullText);
}, false);
}
initAnimCommand(anim: AnimationEntity): void {
if (this.commands.has(anim.command)) {
LogSystem.Error('Animation', 'initAnimCommand', `Duplicate command /${anim.command}`, anim);
return;
}
const writeCommand: BaseCommand = {
$options: { name: anim.command, grouped: 'animation' },
run: async (player: PlayerMp) => {
animationSystem.playAnim(player as PlayerMpLoggedIn, anim.command);
},
};
this.addCommandToMap(this.commands, writeCommand);
this.registerWithFiveM(anim.command);
}
initRadioCommands(): void {
for (let index = 1; index <= 20; index++) {
const makeGuards = () => [
PlayerStateCommandGuard({ alive: true }),
PrisonCommandGuard({ all: true }),
];
const radioCommands: BaseCommand[] = [
{
$options: {
name: `r${index}`,
params: { message: { hint: 'Message', validator: 'string' } },
guards: makeGuards(),
grouped: 'Portable Radio',
description: `Talk in slot ${index} of your radio`,
},
run: async (player: PlayerMpLoggedIn, params: { message: string }) => {
if (punishmentSystem.checkMuted(player, 'ic')) return;
if (!portableRadioSystem.hasRadio(player)) return ChatSystem.Error(player, "You don't have a radio.");
if (!portableRadioSystem.isRadioOn(player)) return ChatSystem.Error(player, 'Turn on your radio to use it.');
if (!portableRadioSystem.getSlotFrequency(player, index)) return ChatSystem.Error(player, `You don't have a frequency in slot #${index}.`);
portableRadioSystem.sendRadioMessage(player, params.message, index, false);
},
},
// r{n}low, r{n}me, r{n}do — same structure, abbreviated for brevity
// TODO: port the remaining radio sub-commands from the original CommandSystem
];
for (const cmd of radioCommands) {
this.addCommandToMap(this.commands, cmd);
}
}
}
addCommandToMap(map: Map<any, any>, command: BaseCommand): void {
const names = Array.isArray(command.$options.name) ? command.$options.name : [command.$options.name];
for (const n of names) {
map.set(n.toLowerCase(), command);
}
}
areCommandParamsValid(command: BaseCommand): boolean {
if (!command.$options.params) return true;
const paramKeys = Object.keys(command.$options.params);
const invalid = paramKeys
.map((pk) => command.$options.params![pk])
.find((param, index) => {
if (param.validator === 'string' && index !== paramKeys.length - 1) {
console.error(`Skipping /${command.$options.name}: 'string' param must be last`);
return true;
}
if (param.default !== undefined) {
if (paramKeys.slice(index).find((pk) => command.$options.params![pk].default === undefined)) {
console.error(`Skipping /${command.$options.name}: optional param before required param`);
return true;
}
}
return false;
});
return !invalid;
}
getCommand(name: string): BaseCommand | undefined {
return this.commands.get(name.toLowerCase());
}
async runCommand(player: PlayerMp, command: BaseCommand, params?: ObjectLiteral): Promise<boolean> {
await command.run(player, params);
return true;
}
parseParams(params: any, words: string[]) {
const paramEntries = Object.entries(params) as any;
for (const [index, paramEntry] of paramEntries.entries()) {
if (!paramEntry?.[1]?.overrides) continue;
if (paramEntry[1].overrides[words[index]]) {
const overrideEntries = Object.entries(paramEntry[1].overrides[words[index]]) as any;
for (const [overrideIndex, overrideEntry] of overrideEntries) {
paramEntries[overrideIndex] = [overrideEntry._propertyName, overrideEntry];
}
}
}
const paramsParsed: any = {};
for (const entry of paramEntries) {
if (entry) paramsParsed[entry[0]] = entry[1];
}
return paramsParsed;
}
async playerCommand(player: PlayerWrapper, fullText: string): Promise<void> {
if (!LoginSystem.IsLoggedIn(player)) return;
if (ContainsInvalidCharacters(fullText)) {
LogSystem.PlayerEvent(player, PlayerLogEvents.invalid_input, 'Attempted command with invalid characters', { command: fullText });
return ChatSystem.Error(player, 'Your command contains invalid characters.');
}
const split = fullText.trim().split(' ');
const commandName = split.shift();
if (!commandName) return ChatSystem.Error(player, 'Well give us a command will you!');
const command = this.getCommand(commandName);
LogSystem.CommandEvent(
player,
command?.$options.name
? (Array.isArray(command.$options.name) ? command.$options.name[0] : command.$options.name).toLowerCase()
: null,
`/${fullText}`,
);
if (!command) {
if (!clientSideCommands.includes(commandName)) {
return ChatSystem.Error(player, `${commandName} is not a valid command!`);
}
return;
}
if (command.$options.guards?.length) {
for (const guard of command.$options.guards) {
const passed = guard(player);
if (passed !== true) return ChatSystem.Error(player, passed);
}
}
if (!command.$options.params) {
await this.runCommand(player, command);
return;
}
const paramsTemplate = this.parseParams(command.$options.params, split);
const params: Record<string, any> = {};
const paramNames = Object.keys(paramsTemplate);
const mandatoryCount = paramNames.filter((pk) => paramsTemplate[pk].default === undefined).length;
if (split.length < mandatoryCount) {
const missing = paramNames
.slice(split.length)
.map((p) => `${paramsTemplate[p].hint}${paramsTemplate[p].default !== undefined ? ` (default: ${paramsTemplate[p].default})` : ''}`);
return ChatSystem.Error(player, `Missing parameter${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}`);
}
const errors: [string, string][] = [];
paramNames.forEach((paramName, index) => {
const meta = paramsTemplate[paramName];
const { validator, hint } = meta;
const value = split[index];
if (value === undefined && meta.default !== undefined) {
params[paramName] = meta.default;
return;
}
if (validator === 'number') {
if (!/^[+-]?([0-9]*[.])?[0-9]+$/.test(value)) return void errors.push([hint, 'has to be a number']);
params[paramName] = Number(value);
} else if (validator === 'integer') {
if (!/^[+-]?[0-9]+$/.test(value)) return void errors.push([hint, 'has to be a whole number']);
params[paramName] = Number(value);
} else if (validator === 'string') {
const input = split.slice(index).join(' ');
if (meta.length && input.length > meta.length) return void errors.push([hint, `longer than ${meta.length} symbols`]);
params[paramName] = input;
} else if (validator === 'vehicle') {
const vehicle = mp.vehicles.at(Number(value));
if (!vehicle) return void errors.push([hint, 'invalid ID']);
params[paramName] = vehicle;
} else if (validator === 'character') {
params[paramName] = PlayerSystem.GetPlayerFromString(value, true, false, false);
if (!params[paramName]) return void errors.push([hint, 'invalid character name']);
} else if (validator === 'account') {
params[paramName] = PlayerSystem.GetPlayerFromString(value, false, true, false);
if (!params[paramName]) return void errors.push([hint, 'invalid account name']);
} else if (validator === 'player') {
params[paramName] = PlayerSystem.GetPlayerFromString(value, true, true, true, false, staffSystem.isOnAdminDuty(player));
if (!params[paramName]) return void errors.push([hint, 'invalid character/account name']);
} else if (validator === 'company') {
if (!/^[0-9]+$/.test(value)) return void errors.push([hint, 'invalid company ID']);
params[paramName] = companySystem.getCompanyById(Number(value)) ?? null;
if (!params[paramName]) return void errors.push([hint, 'invalid company ID']);
} else if (validator === 'faction') {
if (!/^[0-9]+$/.test(value)) return void errors.push([hint, 'invalid faction ID']);
params[paramName] = factionSystem.getFactionFromId(Number(value)) ?? null;
if (!params[paramName]) return void errors.push([hint, 'invalid faction ID']);
} else if (validator === 'property') {
const prop = propertySystem.getPropertyByNetId(Number(value));
if (!prop) return void errors.push([hint, 'invalid property ID']);
params[paramName] = prop;
} else if (validator === 'word') {
if (!value) return void errors.push([hint, 'required']);
params[paramName] = value;
} else if (Array.isArray(validator)) {
if (!validator.includes(value)) return void errors.push([hint, `must be one of: ${validator.join(', ')}`]);
params[paramName] = value;
} else if (validator instanceof RegExp) {
if (!validator.test(value)) return void errors.push([hint, 'invalid format']);
params[paramName] = value;
} else {
params[paramName] = value;
}
});
if (errors.length) {
return ChatSystem.Error(player, `Invalid: ${errors.map(([h, e]) => `${h} ${e}`).join(', ')}`);
}
await this.runCommand(player, command, params);
}
requestCommandList(player: PlayerWrapper): void {
if (!LoginSystem.IsLoggedIn(player)) return;
let list = this.commandList.filter((cmd) => {
if (!cmd.guards) return true;
for (const guard of cmd.guards) {
if (guard.name !== 'permission' && guard.name !== 'staff') continue;
if (guard(player) !== true) return false;
}
return true;
});
list = list.map((cmd) =>
Object.fromEntries(
Object.entries(cmd).filter(
([_, v]) => (Array.isArray(v) && v.length > 0 && !(v.length === 1 && v[0] === null)) || (!Array.isArray(v) && v != null),
),
),
) as CommandList;
LargeDataServer.Send(player, 'commandList', JSON.stringify(list));
}
generateCommandList(): CommandList {
this.commandList = Array.from(this.commands.values()).map((cmd) => {
const params = cmd.$options.params ?? {};
return {
name: Array.isArray(cmd.$options.name) ? cmd.$options.name : [cmd.$options.name],
params: Object.keys(params).map((pk) => ({
hint: params[pk].hint,
...(params[pk].default !== undefined ? { default: String(params[pk].default) } : {}),
...(params[pk].overrides ? { overrides: params[pk].overrides } : {}),
})),
guards: cmd.$options.guards,
grouped: cmd.$options.grouped,
description: cmd.$options.description,
};
});
return this.commandList;
}
}
export const commandSystem = new CommandSystem();

View File

@@ -0,0 +1,15 @@
import { BaseCommand } from './base.command';
export type CommandOptions<T> = {
name: string | string[];
params?: CommandParamMetaData<T>;
guards?: CommandGuard[];
grouped?: string;
description?: string;
};
export function Command<T>(options: CommandOptions<T>) {
return function (constructor: typeof BaseCommand): void {
constructor.prototype.$options = options;
};
}

View File

@@ -0,0 +1,47 @@
import { Connection, createConnection, ConnectionOptions } from 'typeorm';
import { ALL_ENTITIES } from '../entities/index';
import { logSystem } from './log';
function buildConfig(): ConnectionOptions {
return {
type: 'mysql',
host: process.env.DB_HOST || 'play.southwest-roleplay.dev',
port: Number(process.env.DB_PORT) || 3306,
username: process.env.DB_USER || 'username',
password: process.env.DB_PASS || 'password',
database: process.env.DB_NAME || 'game',
logging: ['error', 'log'],
entities: ALL_ENTITIES as unknown as any[],
synchronize: false,
};
}
export class DatabaseSystem {
connection?: Connection | void;
attempts = 0;
async init(): Promise<void> {
const config = buildConfig();
do {
try {
if (this.connection) {
await this.connection.connect();
} else {
this.connection = await createConnection(config);
}
} catch (error) {
if (this.attempts < 5) {
console.error(`[Bootstrap] Database connection unsuccessful, retrying ...`, error);
this.attempts++;
} else {
console.error(`[Bootstrap] Database connection unsuccessful`, error);
process.exit();
}
}
} while (!this.connection);
logSystem.initLog('Database', 'Connection successful');
}
}
export const databaseSystem = new DatabaseSystem();

View File

@@ -0,0 +1,14 @@
import { BaseCommand } from "./commands/base.command";
import { logSystem } from "./log";
export abstract class FeatureSystem {
public commands: typeof BaseCommand[];
constructor() { }
postInit?(): Promise<void>;
public async init(message?: string): Promise<void> {
logSystem.initLog(this.constructor.name, message ?? 'Initialized.');
}
}

View File

@@ -0,0 +1,85 @@
export const AdminLogEvents = {
spectating: 'spectating',
stop_spectating: 'stop_spectating',
kick: 'kick',
ajail: 'ajail',
ban: 'ban',
unjail: 'unjail',
teleport: 'teleport',
watch_pm: 'watch_pm',
watch_radio: 'watch_radio',
mute: 'mute',
get_health: 'get_health',
set_health: 'set_health',
set_armor: 'set_armor',
froze: 'froze',
slap: 'slap',
respawn_car: 'respawn_car',
spawn_car: 'spawn_car',
park_car: 'park_car',
respawn: 'respawn',
setstat: 'setstat',
anti_cheat: 'anti_cheat',
removed_ad: 'removed_ad',
viewed_items: 'viewed_items',
decrypt_mask: 'decrypt_mask',
give_money: 'give_money',
vehicle_livery: 'vehicle_livery',
noclip: 'noclip',
throw_off: 'throw_off',
anote_issued: 'anote_issued',
anote_deleted: 'anote_deleted',
anote_edited: 'anote_edited',
anote_expired: 'anote_expired',
report: 'report',
set_ptp: 'set_ptp',
watch_faction: 'watch_faction',
create_company: 'create_company',
set_company: 'set_company',
revived: 'revived',
create_faction: 'create_faction',
set_faction: 'set_faction',
spawn_item: 'spawn_item',
set_license: 'set_license',
atm: 'atm',
payday: 'payday',
portable_radio: 'portable_radio',
prison: 'prison',
set_property: 'set_property',
create_property: 'create_property',
create_car: 'create_car',
set_car: 'set_car',
delete_car: 'delete_car',
vehicle: 'vehicle',
impound_lot: 'impound_lot',
weather: 'weather',
set_time: 'set_time',
set_weather: 'set_weather',
override_weather: 'override_weather',
trucking: 'trucking',
item_type_groups: 'item_type_groups',
property_stock: 'property_stock',
mission: 'mission',
managejob: 'managejob',
soberup: 'soberup',
deletescene: 'deletescene',
gotoscene: 'gotoscene',
expirescene: 'expirescene',
trip: 'trip',
canceltow: 'cancel_tow',
graffiti: 'graffiti',
pet: 'pet',
loan_defaulted: 'loan_defaulted',
set_pet: 'set_pet',
goto_pet: 'goto_pet',
get_pet: 'get_pet',
force_tutorial: 'force_tutorial',
updated_tutorial: 'updated_tutorial',
vehicle_mods: 'vehicle_mods',
ipl: 'ipl',
roadblock: 'roadblock',
find_prop_inventories: 'find_prop_inventories',
radio_station: 'radio_station',
} as const;
export type AdminLogEvent = keyof typeof AdminLogEvents;

View File

@@ -0,0 +1,7 @@
export const BlackjackLogEvents = {
game: 'game',
tillOnline: 'tillOnline',
till: 'till',
} as const;
export type BlackjackLogEvent = keyof typeof BlackjackLogEvents;

View File

@@ -0,0 +1,12 @@
export const ChatLogEvents = {
ic: 'ic',
ic_quiet: 'ic_low',
ic_loud: 'ic_loud',
ic_whisper: 'ic_whisper',
ooc: 'ooc',
ooc_aooc: 'ooc_aooc',
pm: 'pm',
vicinity: 'vicinity',
} as const;
export type ChatLogEvent = keyof typeof ChatLogEvents;

View File

@@ -0,0 +1,19 @@
export const CompanyLogEvents = {
created: 'created',
primary_property_change: 'primary_property_change',
paycheck_reward: 'paycheck_reward',
position_created: 'position_created',
position_modified: 'position_modified',
position_deleted: 'position_deleted',
pos_payment: 'pos_payment',
type_changed: 'type_changed',
protect_changed: 'protect_changed',
employee_added: 'employee_added',
employee_fired: 'employee_fired',
respawn_car: 'respawn_car',
tow_cars: 'tow_cars',
set_company: 'set_company',
vehicle: 'vehicle',
} as const;
export type CompanyLogEvent = keyof typeof CompanyLogEvents;

View File

@@ -0,0 +1,16 @@
export const CorpseLogEvents = {
created: 'created',
spawned: 'spawned',
despawned: 'despawned',
moved_veh: 'moved_veh',
moved_morgue: 'moved_morgue',
drag: 'drag',
stop_drag: 'stop_drag',
request_id: 'request_id',
identification: 'identification',
buried: 'buried',
exhumed: 'exhumed',
} as const;
export type CorpseLogEvent = keyof typeof CorpseLogEvents;

View File

@@ -0,0 +1,7 @@
export const DarkNetLogEvents = {
order_placed: 'order_placed',
delivery_failed: 'delivery_failed',
delivered: 'delivered',
} as const;
export type DarkNetLogEvent = keyof typeof DarkNetLogEvents;

View File

@@ -0,0 +1,8 @@
export const DMVLogEvent = {
testing: 'testing',
passed: 'passed',
failed: 'failed',
renewed: 'renewed',
} as const;
export type DMVLogEvent = keyof typeof DMVLogEvent;

View File

@@ -0,0 +1,5 @@
export const DoorLogEvents = {
lock_status: 'lock_status',
} as const;
export type DoorLogEvent = keyof typeof DoorLogEvents;

View File

@@ -0,0 +1,27 @@
export const FactionLogEvents = {
created: 'created',
name_change: 'name_change',
abbrev_change: 'abbrev_change',
type_change: 'type_change',
faction_property: 'faction_property',
chat_color_change: 'chat_color_change',
primary_property_change: 'primary_property_change',
paycheck_reward: 'paycheck_reward',
permit_revoke: 'permit_revoke',
permit_issue: 'permit_issue',
rank_created: 'rank_created',
rank_modified: 'rank_modified',
rank_deleted: 'rank_deleted',
mdc: 'mdc',
faction_invite: 'faction_invite',
set_faction: 'set_faction',
player_ranks: 'player_ranks',
faction_uninvite: 'faction_uninvite',
prison: 'prison',
respawn_car: 'respawn_car',
tow_cars: 'tow_cars',
pet: 'pet',
cut_power: 'cut_power',
} as const;
export type FactionLogEvent = keyof typeof FactionLogEvents;

View File

@@ -0,0 +1,16 @@
export const FurnitureLogEvents = {
created: 'created',
modified: 'modified',
duplicate: 'duplicate',
sold: 'sold',
created_inventory: 'created_inventory',
tagged: 'tagged',
entered_object_editor: 'entered_object_editor',
named: 'named',
purged_furniture: 'purged_furniture',
replaced: 'replaced',
lock_status: 'lock_status',
created_tv: 'created_tv',
} as const;
export type FurnitureLogEvent = keyof typeof FurnitureLogEvents;

View File

@@ -0,0 +1,8 @@
export const GraffitiLogEvents = {
destroyed: 'destroyed',
washed: 'washed',
sprayed: 'sprayed',
created: 'created',
} as const;
export type GraffitiLogEvent = keyof typeof GraffitiLogEvents;

View File

@@ -0,0 +1,18 @@
export const ItemLogEvents = {
created: 'created',
transaction: 'transaction',
dropped: 'dropped',
world_place: 'world_place',
world_pickup: 'world_pickup',
world_moved: 'world_moved',
vehicle_world_place: 'vehicle_world_place',
vehicle_world_pickup: 'vehicle_world_pickup',
vehicle_world_moved: 'vehicle_world_moved',
data: 'data',
deleted: 'deleted',
confiscated: 'confiscate',
rename: 'rename',
merged: 'merged',
} as const;
export type ItemLogEvent = keyof typeof ItemLogEvents;

View File

@@ -0,0 +1,8 @@
export const LawLogEvents = {
jail: 'jail',
arsenal: 'arsenal',
loadgun: 'loadgun',
takegun: 'takegun',
};
export type LawLogEvent = keyof typeof LawLogEvents;

View File

@@ -0,0 +1,7 @@
export const MarkerLogEvents = {
expired: 'expired',
created: 'created',
permanent: 'permanent',
} as const;
export type MarkerLogEvent = keyof typeof MarkerLogEvents;

View File

@@ -0,0 +1,18 @@
export const MDCLogEvents = {
opened: 'opened',
searched: 'searched',
trace: 'trace',
executable: 'executable',
prescription: 'prescription',
fine: 'fine',
mugshot: 'mugshot',
notice: 'notice',
medical: 'medical',
driver: 'driver',
weapon: 'weapon',
permit_issue: 'permit_issue',
permit_revoke: 'permit_revoke',
corpse_id: 'corpse_id',
} as const;
export type MDCLogEvent = keyof typeof MDCLogEvents;

View File

@@ -0,0 +1,13 @@
export const PetLogEvents = {
created: 'created',
removed: 'removed',
spawned: 'spawned',
despawned: 'despawned',
ordered: 'order',
name_change: 'name_change',
type_change: 'type_change',
faction_change: 'faction_change',
character_change: 'character_change',
};
export type PetLogEvent = keyof typeof PetLogEvents;

View File

@@ -0,0 +1,98 @@
export const PlayerLogEvents = {
join: 'join',
login: 'login',
disconnect: 'disconnect',
login_fail: 'login_fail',
login_groups: 'login_groups',
character_loaded: 'character_loaded',
phone_created: 'phone_created',
character_create: 'character_create',
jailed: 'jailed',
kick: 'kick',
ajail: 'ajail',
muted: 'muted',
banned: 'banned',
towcars: 'towcars',
anti_cheat: 'anti_cheat',
transaction_flag: 'transaction_flagged',
showing_atp_alerts: 'showing_atp_alerts',
read_atp_alerts: 'read_atp_alerts',
entered_vehicle: 'entered_vehicle',
exited_vehicle: 'exited_vehicle',
vehicle_purchase: 'vehicle_purchase',
vehicle_modshop: 'vehicle_modshop',
canceled_vehicle_purchase: 'canceled_vehicle_purchase',
freeze: 'freeze',
thawed: 'thawed',
unjailed: 'unjailed',
report: 'report',
anotes_expired: 'anotes_expired',
afk: 'afk',
animation: 'animation',
barber_shop: 'barber_shop',
ads: 'ads',
employment: 'employment',
damage: 'damage',
carrying: 'carrying',
helpup: 'helpup',
weapons: 'weapons',
darknet: 'darknet',
dmv: 'dmv',
door: 'door',
duty: 'duty',
menu: 'menu',
property: 'property',
company: 'company',
faction: 'faction',
vehicle: 'vehicle',
mdc: 'mdc',
medic: 'medic',
restraints: 'restraints',
fishing: 'fishing',
gambling: 'gambling',
object: 'object',
inventory: 'inventory',
money: 'money',
offer: 'offer',
permissions: 'permissions',
phone: 'phone',
level: 'level',
spawned: 'spawned',
frisk: 'frisk',
mask: 'mask',
portable_radio: 'portable_radio',
prison: 'prison',
xm_radio: 'xm_radio',
store: 'store',
fine: 'fine',
left_company: 'left_company',
mission: 'mission',
job: 'job',
invalid_input: 'invalid_input',
confiscate: 'confiscate',
reset_position: 'reset_position',
rent: 'rent',
manually_unfroze: 'manually_unfroze',
phone_alert: 'phone_alert',
graffiti: 'graffiti',
camera: 'camera',
outfit: 'outfit',
clean_cash: 'clean_cash',
pawn: 'pawn',
pet: 'pet',
loan: 'loan',
transfer_property: 'transfer_property',
expression: 'expression',
cinematic: 'cinematic',
coroner: 'coroner',
scratch_weapon: 'scratch_weapon',
convert_appearance: 'convert_appearance',
force_tutorial: 'force_tutorial',
crawling: 'crawling',
corpse: 'corpse',
uncaughtException: 'uncaughtException',
share: 'share',
surgery: 'surgery',
} as const;
export type PlayerLogEvent = keyof typeof PlayerLogEvents;

View File

@@ -0,0 +1,57 @@
export const PropertyLogEvents = {
created: 'created',
renamed: 'renamed',
exterior_change: 'exterior_change',
interior_change: 'interior_change',
type_change: 'type_change',
parent_change: 'parent_change',
faction_change: 'faction_change',
company_change: 'company_change',
character_change: 'character_change',
furniture_slots: 'furniture_slots',
furniture_loading: 'furniture_loading',
furniture_unloading: 'furniture_unloading',
security_loading: 'security_loading',
security_unloading: 'security_unloading',
settings_change: 'settings_change',
permit_revoke: 'permit_revoke',
permit_issue: 'permit_issue',
access_key: 'access_key',
lease: 'lease',
player_entered: 'player_entered',
player_exited: 'plaeyr_exited',
locked: 'locked',
rent: 'rent',
interior_props: 'interior_props',
interior_ipls: 'interior_ipls',
areas: 'areas',
address: 'address',
master_change: 'master_change',
name_change: 'name_change',
xm_radio: 'xm_radio',
local_time: 'local_time',
local_weather: 'local_weather',
blip: 'blip',
reload: 'reload',
stock: 'stock',
shop: 'shop',
freeze: 'freeze',
enter_reward: 'enter_reward',
visitor_cap: 'visitor_cap',
collection: 'collection',
eviction: 'eviction',
hospital_price: 'hospital_price',
eviction_inactive: 'eviction_inactive',
clean_cash: 'clean_cash',
pawn: 'pawn',
purchased: 'purchased',
transfer: 'transfer',
lockpick: 'lockpick',
security_subscribe: 'security_subscribe',
mission: 'mission',
power: 'power',
metal_detector: 'metal_detector',
elevator: 'elevator',
};
export type PropertyLogEvent = keyof typeof PropertyLogEvents;

View File

@@ -0,0 +1,9 @@
export const PointToPointLogEvents = {
created: 'created',
entered: 'entered',
exited: 'exited',
set_ptp: 'set_ptp',
lock: 'lock',
} as const;
export type PointToPointLogEvent = keyof typeof PointToPointLogEvents;

View File

@@ -0,0 +1,7 @@
export const RouletteLogEvents = {
game: 'game',
tillOnline: 'tillOnline',
till: 'till',
} as const;
export type RouletteLogEvent = keyof typeof RouletteLogEvents;

View File

@@ -0,0 +1,65 @@
export const VehicleLogEvents = {
created: 'created',
deleted: 'deleted',
spawned: 'spawned',
respawned: 'respawned',
engine: 'engine',
parked: 'parked',
rent: 'rent',
rent_extend: 'rent_extend',
rent_stop: 'rent_stop',
plate: 'plate',
registration: 'registration',
player_entered: 'player_entered',
player_exited: 'player_exited',
canceled_dealership: 'canceled_dealership',
successful_dealership: 'successful_dealership',
livery: 'livery',
slap: 'slap',
ejected: 'ejected',
weapons: 'weapons',
access_key: 'access_key',
helo: 'helo',
mechanic: 'mechanic',
xm_radio: 'xm_radio',
fine: 'fine',
extras: 'extras',
fuel: 'fuel',
flipped: 'flipped',
keyless: 'keyless',
repair: 'repair',
color_change: 'color_change',
character_change: 'character_change',
faction_change: 'faction_change',
company_change: 'company_change',
spawn_change: 'spawn_change',
wheel_change: 'wheel_change',
tint_change: 'tint_change',
suspension_change: 'suspension_change',
dirt_change: 'dirt_change',
manual_change: 'manual_change',
protected_change: 'protected_change',
engine_change: 'engine_change',
model_change: 'model_change',
upgrades: 'upgrades',
raw: 'raw',
transfer_owner: 'transfer_owner',
trunk: 'trunk',
hood: 'hood',
wash: 'wash',
impound: 'impound',
tow: 'tow',
personaltow: 'personaltow',
lock: 'lock',
alarm: 'alarm',
stolen: 'stolen',
modshop: 'modshop',
pet_entered: 'pet_entered',
pet_exited: 'pet_exited',
pet_searched: 'pet_searched',
insurance: 'insurance',
mods_change: 'mods_change',
mods_copy: 'mods_copy',
} as const;
export type VehicleLogEvent = keyof typeof VehicleLogEvents;

View File

@@ -0,0 +1,7 @@
export const WhitelistLogEvents = {
rejected: 'rejected',
removed: 'removed',
added: 'added',
};
export type WhitelistLogEvent = keyof typeof WhitelistLogEvents;

View File

@@ -0,0 +1,744 @@
import * as colors from 'colors';
import { mongoSystem } from '../mongo';
import { PlayerLogEvent, PlayerLogEvents } from './events/player.log.events';
import { ChatLogEvent, ChatLogEvents } from './events/chat.log.events';
import { PropertyEntity } from '../../entities/property.entity';
import { PropertyLogEvent, PropertyLogEvents } from './events/property.log.events';
import { FactionEntity } from '../../entities/faction.entity';
import { FactionLogEvent, FactionLogEvents } from './events/faction.log.events';
import { WhitelistLogEvent } from './events/whitelist.log.events';
import { AdminLogEvent } from './events/admin.log.events';
import { ItemEntity } from '../../entities/item.entity';
import { ItemLogEvent } from './events/item.log.events';
import { JSONStringifyRecursive } from '../../features/utility/JSONStringifyNoRecursive';
import { LawLogEvent } from './events/law.log.events';
import { VehicleLogEvent } from './events/vehicle.log.events';
import { VehicleSystem } from '../../features/vehicle';
import dayjs from 'dayjs';
import { CompanyEntity } from '../../entities/company.entity';
import { CompanyLogEvent } from './events/company.log.events';
import { VehicleEntity } from '../../entities/vehicle.entity';
import { ObjectLiteral } from 'typeorm';
import { DarkNetLogEvent } from './events/darknet.log.events';
import { DoorEntity } from '../../entities/door.entity';
import { DoorLogEvent } from './events/door.log.events';
import { PointToPointEntity } from '../../entities/point-to-point.entity';
import { PointToPointLogEvent } from './events/ptp.log.events';
import { FurnitureLogEvent } from './events/furniture.log.events';
import { FurnitureItemEntity } from '../../entities/furniture-item.entity';
import { mdcSystem } from '../../features/mdc';
import { MDCLogEvent } from './events/mdc.log.events';
import { DarknetOrderEntity } from '../../entities/darknet-order.entity';
import { GraffitiLogEvent } from './events/graffiti.log.events';
import { GraffitiEntity } from '../../entities/graffiti.entity';
import { PetLogEvent } from './events/pet.log.events';
import { PetEntity } from '../../entities/pet.entity';
import { BlackjackLogEvent } from './events/blackjack.log.events';
import { CorpseLogEvent } from './events/corpse.log.event';
import { MarkerLogEvent } from './events/marker.log.events';
import { InfoMarkerEntity } from '../../entities/info-markers.entity';
import { RouletteLogEvent } from './events/roulette.log.event';
colors.enable();
interface LogCollection {
name: string;
permission: string;
hasEvents?: boolean;
fields: {
name: string;
type: string;
trace?: string;
default?: boolean;
required?: boolean;
}[];
}
export type DarknetOrderData = {
id: number;
name: string;
faction: {
id: number;
name: string;
};
};
export type DarknetItems = {
type: {
id: number;
name: string;
};
price: number;
oocPrice: number;
amount: number;
unit: string;
}[];
export class LogSystem {
lastInitTime: number;
firstInitTime: number;
static Collections: LogCollection[] = [
{
name: 'players',
permission: 'LOG_VIEW_PLAYERS',
hasEvents: true,
fields: [
{ name: 'player', type: 'player', default: true, required: true },
{ name: 'ip', trace: 'player.ip', type: 'string' },
{ name: 'log', type: 'string', default: true },
{ name: 'target', type: 'player' },
],
},
];
init(): void {
this.lastInitTime = +new Date();
this.firstInitTime = +new Date();
}
initLog(feature: string, content: string): void {
const now = +new Date();
const time = ((now - this.lastInitTime) / 1000).toFixed(1).padStart(4, ' ') + 's';
const totalTime = ((now - this.firstInitTime) / 1000).toFixed(1).padEnd(4, ' ') + 's';
LogSystem.Collect(`${(totalTime + ' | ' + time).grey} [init:${feature}]`.green + `: ${content.yellow}`);
this.lastInitTime = now;
}
static Log(feature: string, content: string): void {
LogSystem.Collect(`[${feature}]`.green + `: ${content.reset}`);
// LogSystem.Collect(`LogSystem.Log is for debugging only, all relevant data needs to be stored using event functions`.red);
}
static ComposePlayerObject(player?: PlayerMp, options?: { state?: boolean }): Record<string, any> | undefined {
if (!player) {
return undefined;
}
const playerObject: {
gameId: number;
ip: string;
sessionId?: number;
accountId?: number;
accountName?: string;
characterId?: number;
characterName?: string;
state?: PlayerState;
} = {
gameId: player.id,
ip: player.ip,
};
if (player.session) {
playerObject.sessionId = player.session.id;
}
if (player.account) {
playerObject.accountId = player.account.id;
playerObject.accountName = player.account.name;
}
if (player.character) {
playerObject.characterId = player.character.id;
playerObject.characterName = player.character.name;
}
if (options?.state) {
playerObject.state = player.state;
}
return JSON.parse(JSONStringifyRecursive(playerObject));
}
static ComposeGraffitiEntityObject(graffiti: GraffitiEntity): Record<string, any> | undefined {
const graffitiObject: {
id: number;
content: string;
font: string;
colour: string;
opacity: number;
destroyed: Date;
creator_id: number;
} = {
id: graffiti.id,
content: graffiti.content,
font: graffiti.font,
colour: graffiti.colour,
opacity: graffiti.opacity,
destroyed: graffiti.destroyed,
creator_id: graffiti.creator_id,
};
return JSON.parse(JSONStringifyRecursive(graffitiObject));
}
static ComposeDoorEntityObject(door: DoorEntity): Record<string, any> | undefined {
const doorObject: {
id: number;
name: string;
permission: string;
lockType: string;
property: {
id: number;
name: string;
} | null;
} = {
id: door.id,
name: door.name,
permission: door.permission_name || 'null',
lockType: door.lock_type,
property: door.property
? {
id: door.property.id,
name: door.property.getName(),
}
: null,
};
return JSON.parse(JSONStringifyRecursive(doorObject));
}
static ComposePTPEntityObject(ptp: PointToPointEntity): Record<string, any> | undefined {
const ptpObject: {
id: number;
name: string;
permission: string | null;
lockStatus: boolean;
} = {
id: ptp.id,
name: ptp.name,
permission: ptp.permission,
lockStatus: ptp.locked,
};
return JSON.parse(JSONStringifyRecursive(ptpObject));
}
static ComposePlayerPosition(player: PlayerMp): { x: number; y: number; z: number; dimension: number } {
return {
x: Math.round(player.position.x * 1000) / 1000,
y: Math.round(player.position.y * 1000) / 1000,
z: Math.round(player.position.z * 1000) / 1000,
dimension: player.dimension,
};
}
static ComposePlayerName(player: PlayerMp): string {
if (!mp.players.exists(player)) {
return 'Unknown Player';
}
let name = player.name;
if (player.account) {
name = player.account.name;
if (player.character) {
name = `${name} - ${player.character.name}`;
}
}
name = `${name} [${player.id}]`;
return name;
}
static ComposeFurnitureItemObject(item: FurnitureItemEntity): Record<string, any> {
const itemObject = {
id: item.id,
created: mdcSystem.formatDate(item.created),
creator: item.created_by_character,
price: item.price,
name: item.name,
tag: item.tag,
inventory:
item.has_inventory && item.inventory
? {
inventory_id: item.inventory.id,
items: item.inventory.items.map((item) => {
return {
id: item.id,
type: item.type.name,
};
}),
}
: false,
prison_cell: item.prison_cell_id,
property_id: item.property_id,
position: {
x: item.x,
y: item.y,
z: item.z,
rx: item.rx,
ry: item.ry,
rz: item.rz,
},
};
return JSON.parse(JSONStringifyRecursive(itemObject));
}
static ComposeVehicleObject(vehicle?: VehicleMp): Record<string, any> {
if (!vehicle) {
return {};
}
const speed = Math.floor(VehicleSystem.GetVehicleMph(vehicle));
const vehicleObject: VehicleLogComposition = {
gameId: vehicle.id,
model: VehicleSystem.GetVehicleNameFromHash(vehicle.model) || String(vehicle.model),
plate: vehicle.numberPlate,
bodyHealth: vehicle.bodyHealth,
engineHealth: vehicle.engineHealth,
occupants: LogSystem.ComposeVehicleOccupants(vehicle),
speedInMph: speed,
};
if (vehicle.vehicleEntity) {
vehicleObject.id = vehicle.vehicleEntity.id;
if (vehicle.vehicleEntity.owner) {
vehicleObject.owner = vehicle.vehicleEntity.owner;
}
}
return JSON.parse(JSONStringifyRecursive(vehicleObject));
}
static ComposeVehiclePosition(vehicle: VehicleMp): { x: number; y: number; z: number; heading: number; dimension: number } {
return {
x: Math.round(vehicle.position.x * 1000) / 1000,
y: Math.round(vehicle.position.z * 1000) / 1000,
z: Math.round(vehicle.position.z * 1000) / 1000,
heading: Math.round(vehicle.heading),
dimension: vehicle.dimension,
};
}
static ComposeVehicleOccupants(vehicle: VehicleMp): any {
const occupants: {
seatId: number;
player: any;
}[] = [];
vehicle.getVehicleOccupants().forEach((player) => {
const composed = LogSystem.ComposePlayerObject(player);
occupants.push({
seatId: player.seat,
player: composed,
});
});
return JSON.parse(JSONStringifyRecursive(occupants));
}
static ComposeVehicleName(vehicle: VehicleMp | VehicleEntity): string {
let name = VehicleSystem.GetVehicleName(vehicle);
if (!(vehicle instanceof VehicleEntity) && vehicle.vehicleEntity) {
name += ` (#${vehicle.vehicleEntity.id}`;
if (vehicle.vehicleEntity.owner) {
name += `, ${vehicle.vehicleEntity.owner})`;
} else {
name += ')';
}
name += ` [${vehicle.id}]`;
} else if (vehicle instanceof VehicleEntity) {
name += ` (Entity #${vehicle.id})`;
} else {
name += ` [${vehicle.id}]`;
}
return name;
}
static async PlayerEvent(player: PlayerMp, event: PlayerLogEvent, log: string, data?: ObjectLiteral): Promise<void> {
await mongoSystem.insertOne('logs', 'players', {
date: new Date(),
player: LogSystem.ComposePlayerObject(player),
event,
log,
position: LogSystem.ComposePlayerPosition(player),
data: data !== undefined ? JSON.parse(JSONStringifyRecursive(data)) : undefined,
});
LogSystem.Collect(`[Player:${event}]`.green + `: ${LogSystem.ComposePlayerName(player).yellow} ${log.reset}`);
}
static async GraffitiEvent(graffiti: GraffitiEntity, event: GraffitiLogEvent, log: string, data?: ObjectLiteral): Promise<void> {
await mongoSystem.insertOne('logs', 'graffiti', {
date: new Date(),
graffiti: graffiti ? LogSystem.ComposeGraffitiEntityObject(graffiti) : undefined,
event,
log,
data: data !== undefined ? JSON.parse(JSONStringifyRecursive(data)) : undefined,
});
LogSystem.Collect(`[Graffiti:${event}]`.green + ` ${log.reset}`);
}
static async VehicleEvent(vehicle: VehicleMp | VehicleEntity, event: VehicleLogEvent, log: string, player?: PlayerMp): Promise<void> {
await mongoSystem.insertOne('logs', 'vehicles', {
date: new Date(),
vehicle: vehicle instanceof VehicleEntity ? { id: vehicle.id } : LogSystem.ComposeVehicleObject(vehicle),
event,
log,
position: vehicle instanceof VehicleEntity ? null : LogSystem.ComposeVehiclePosition(vehicle),
player: LogSystem.ComposePlayerObject(player),
});
LogSystem.Collect(`[Vehicle:${event}]`.green + `: ${LogSystem.ComposeVehicleName(vehicle).yellow} ${log.reset}`);
}
static async ChatEvent(player: PlayerMp, event: ChatLogEvent, log: string, target?: PlayerMp): Promise<void> {
await mongoSystem.insertOne('logs', 'chat', {
date: new Date(),
player: LogSystem.ComposePlayerObject(player),
event,
log,
position: LogSystem.ComposePlayerPosition(player),
target: LogSystem.ComposePlayerObject(target),
});
LogSystem.Collect(`[Chat:${event}]`.green + `: ${LogSystem.ComposePlayerName(player).yellow} ${log.reset}`);
}
static async BlackjackEvent(event: BlackjackLogEvent, tableId: number, roundId: string, log: string, player?: PlayerMp) {
await mongoSystem.insertOne('logs', 'blackjack', {
date: new Date(),
tableId,
roundId,
log,
player: LogSystem.ComposePlayerObject(player),
position: player ? LogSystem.ComposePlayerPosition(player) : undefined,
});
LogSystem.Collect(`[Blackjack:${event}]`.green + `: ${tableId}/${roundId} ${log.reset}`);
}
static async RouletteEvent(event: RouletteLogEvent, tableId: number, gameId: string, log: string, player?: PlayerMp) {
await mongoSystem.insertOne('logs', 'roulette', {
date: new Date(),
tableId,
gameId,
log,
player: LogSystem.ComposePlayerObject(player),
position: player ? LogSystem.ComposePlayerPosition(player) : undefined,
});
LogSystem.Collect(`[Roulette:${event}]`.green + `: ${tableId}/${gameId} ${log.reset}`);
}
static async CorpseEvent(event: CorpseLogEvent, corpseId: number, log: string, player?: PlayerMp) {
await mongoSystem.insertOne('logs', 'corpses', {
date: new Date(),
corpseId,
log,
player: LogSystem.ComposePlayerObject(player),
position: player ? LogSystem.ComposePlayerPosition(player) : undefined,
});
LogSystem.Collect(`[Corpse:${event}]`.green + `: ${corpseId} ${log.reset}`);
}
static async MarkerEvent(event: MarkerLogEvent, marker: InfoMarkerEntity, log: string, player?: PlayerMp): Promise<void> {
await mongoSystem.insertOne('logs', 'marker', {
date: new Date(),
marker,
log,
player: LogSystem.ComposePlayerObject(player),
position: player ? LogSystem.ComposePlayerPosition(player) : undefined,
});
LogSystem.Collect(`[Marker:${event}]`.green + `: ${marker.id} ${log.reset}`);
}
static async ItemEvent(item: ItemEntity, event: ItemLogEvent, log: string, player?: PlayerMp): Promise<void> {
await mongoSystem.insertOne('logs', 'items', {
date: new Date(),
item,
event,
log,
player: LogSystem.ComposePlayerObject(player),
position: player ? LogSystem.ComposePlayerPosition(player) : undefined,
});
LogSystem.Collect(`[Item:${event}]`.green + `: ${item.inventory_id}/${item.id} ${log.reset}`);
}
static async CommandEvent(player: PlayerMp, command: string | null, log: string): Promise<void> {
await mongoSystem.insertOne('logs', 'commands', {
date: new Date(),
player: LogSystem.ComposePlayerObject(player),
command,
log,
position: LogSystem.ComposePlayerPosition(player),
});
LogSystem.Collect(`[Command:${command}]`.green + `: ${LogSystem.ComposePlayerName(player).yellow} ${log.reset}`);
}
static async PropertyEvent(property: PropertyEntity, event: PropertyLogEvent, log: string, player?: PlayerMp): Promise<void> {
await mongoSystem.insertOne('logs', 'properties', {
date: new Date(),
property: {
id: property.id,
},
event,
log,
player: LogSystem.ComposePlayerObject(player),
});
let eventLog = `[Property(${property.id}):${event}]`.green + `: ${log}`;
if (player) {
eventLog += ` (${LogSystem.ComposePlayerName(player)})`;
}
LogSystem.Collect(eventLog);
}
static async PetEvent(pet: PetEntity, event: PetLogEvent, log: string, player?: PlayerMp): Promise<void> {
await mongoSystem.insertOne('logs', 'pets', {
date: new Date(),
pet: {
id: pet.id,
},
event,
log,
player: LogSystem.ComposePlayerObject(player),
});
let eventLog = `[Pet(${pet.id}):${event}]`.green + `: ${log}`;
if (player) {
eventLog += ` (${LogSystem.ComposePlayerName(player)})`;
}
LogSystem.Collect(eventLog);
}
static async MDCEvent(player: PlayerMp, event: MDCLogEvent, log: string, data?: any): Promise<void> {
await mongoSystem.insertOne('logs', 'mdc', {
date: new Date(),
event,
player: LogSystem.ComposePlayerObject(player),
faction: player.character?.faction
? {
id: player.character.faction.id,
}
: undefined,
log,
data: !data ? undefined : JSON.parse(JSONStringifyRecursive(data)),
});
const eventLog = `[MDC:${event}]`.green + `: ${LogSystem.ComposePlayerName(player).yellow} ${log.reset}`;
LogSystem.Collect(eventLog);
}
static async FurnitureEvent(
player: PlayerMp,
event: FurnitureLogEvent,
dimension: number,
log: string,
furnitureItem?: FurnitureItemEntity,
property?: PropertyEntity,
): Promise<void> {
await mongoSystem.insertOne('logs', 'furniture', {
date: new Date(),
event,
log,
dimension,
player: LogSystem.ComposePlayerObject(player),
item: furnitureItem ? LogSystem.ComposeFurnitureItemObject(furnitureItem) : undefined,
property: property
? {
id: property.id,
currentFurnitureCount: property.furnitureItems ? property.furnitureItems.length : 0,
}
: undefined,
});
const eventLog = `[Furniture:${event}]`.green + `: ${LogSystem.ComposePlayerName(player).yellow} ${log.reset}`;
LogSystem.Collect(eventLog);
}
static async FactionEvent(faction: FactionEntity, event: FactionLogEvent, log: string, player?: PlayerMp): Promise<void> {
await mongoSystem.insertOne('logs', 'factions', {
date: new Date(),
faction: {
id: faction.id,
},
event,
log,
player: LogSystem.ComposePlayerObject(player),
});
let eventLog = `[Faction(${faction.id}):${event}]`.green + `: ${log}`;
if (player) {
eventLog += ` (${LogSystem.ComposePlayerName(player)})`;
}
LogSystem.Collect(eventLog);
}
static async CompanyEvent(company: CompanyEntity, event: CompanyLogEvent, log: string, player?: PlayerMp): Promise<void> {
await mongoSystem.insertOne('logs', 'companies', {
date: new Date(),
company: {
id: company.id,
},
event,
log,
player: LogSystem.ComposePlayerObject(player),
});
let eventLog = `[Company(${company.id}):${event}]`.green + `: ${log}`;
if (player) {
eventLog += ` (${LogSystem.ComposePlayerName(player)})`;
}
LogSystem.Collect(eventLog);
}
static async Error(system: string, event: string, log: string, trace: any, player?: PlayerMp): Promise<void> {
await mongoSystem.insertOne('logs', 'errors', {
date: new Date(),
system,
event,
log,
trace: JSONStringifyRecursive(trace),
player: LogSystem.ComposePlayerObject(player, { state: true }),
});
LogSystem.Collect(`[Error:${system}/${event}]`.red + ` ${log.red}`);
console.error(trace);
}
static async WhitelistEvent(event: WhitelistLogEvent, log: string, player?: PlayerMp): Promise<void> {
await mongoSystem.insertOne('logs', 'whitelist', {
date: new Date(),
player: LogSystem.ComposePlayerObject(player),
event,
log,
});
let eventLog = `[Whitelist:${event}]`.green + `: ${log}`;
if (player) {
eventLog += ` (${LogSystem.ComposePlayerName(player)})`;
}
LogSystem.Collect(eventLog);
}
static async AdminLog(event: AdminLogEvent, log: string, player?: PlayerMp, target?: PlayerMp): Promise<void> {
await mongoSystem.insertOne('logs', 'admin', {
date: new Date(),
player: LogSystem.ComposePlayerObject(player),
event,
log,
target: LogSystem.ComposePlayerObject(target),
});
let eventLog = `[Admin:${event}]`.green + `: ${log}`;
if (player) {
eventLog += ` (${LogSystem.ComposePlayerName(player)})`;
}
if (target) {
eventLog += ` (${LogSystem.ComposePlayerName(target)})`;
}
LogSystem.Collect(eventLog);
}
static async DarkNetLog(event: DarkNetLogEvent, log: string, metadata?: DarknetOrderData, order?: DarknetOrderEntity, item?: DarknetItems): Promise<void> {
await mongoSystem.insertOne('logs', 'darknet', {
date: new Date(),
event,
log,
metadata,
order,
item,
});
const eventLog = `[DarkNet:${event}]`.green + `: ${log}`;
LogSystem.Collect(eventLog);
}
static async DoorLog(door: DoorEntity, event: DoorLogEvent, log: string, player?: PlayerMp): Promise<void> {
await mongoSystem.insertOne('logs', 'door', {
date: new Date(),
door: LogSystem.ComposeDoorEntityObject(door),
event,
log,
player: LogSystem.ComposePlayerObject(player),
});
let eventLog = `[DoorSystem:${event}]`.green + `: Door #${door.id}`.yellow + ` ${log.reset}`;
if (player) {
eventLog += ` (${LogSystem.ComposePlayerName(player)})`;
}
LogSystem.Collect(eventLog);
}
static async PTPLog(ptp: PointToPointEntity, event: PointToPointLogEvent, log: string, player?: PlayerMp): Promise<void> {
await mongoSystem.insertOne('logs', 'point_to_point', {
date: new Date(),
ptp: LogSystem.ComposePTPEntityObject(ptp),
event,
log,
player: LogSystem.ComposePlayerObject(player),
});
let eventLog = `[PointToPoint:${event}]`.green + `: PTP #${ptp.id}`.yellow + ` ${log.reset}`;
if (player) {
eventLog += ` (${LogSystem.ComposePlayerName(player)})`;
}
LogSystem.Collect(eventLog);
}
static async LawLog(event: LawLogEvent, log: string, player?: PlayerMp, target?: PlayerMp): Promise<void> {
await mongoSystem.insertOne('logs', 'law', {
date: new Date(),
player: LogSystem.ComposePlayerObject(player),
event,
log,
target: LogSystem.ComposePlayerObject(target),
});
let eventLog = `[Law:${event}]`.green + `: ${log}`;
if (player) {
eventLog += ` (${LogSystem.ComposePlayerName(player)})`;
}
if (target) {
eventLog += ` (${LogSystem.ComposePlayerName(target)})`;
}
LogSystem.Collect(eventLog);
}
static GetLogEvents(): {
players: string[];
properties: string[];
chat: string[];
factions: string[];
} {
return {
players: Object.keys(PlayerLogEvents),
properties: Object.keys(PropertyLogEvents),
chat: Object.keys(ChatLogEvents),
factions: Object.keys(FactionLogEvents),
};
}
static Collect(raw: string): void {
const now = dayjs(new Date());
const timeFormat = now.format('hh:mm:ss');
console.log('['.white + `${timeFormat}`.grey + '] '.white + raw);
}
}
process.on('uncaughtException', (error: Error, origin: string) => {
LogSystem.Error('Node.js', 'uncaughtException', 'Uncaught Exception', { origin, error });
});
export const logSystem = new LogSystem();

85
src/server/core/mongo.ts Normal file
View File

@@ -0,0 +1,85 @@
import { randomBytes } from 'crypto';
import { logSystem } from './log';
import { redisSystem } from './redis';
const createJobId = (): string => {
const bytes = randomBytes(16);
// RFC 4122 UUID v4 variant/version bits
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = bytes.toString('hex');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
};
type MongoJobPayload =
| {
op: 'insertOne';
db: string;
collection: string;
document: unknown;
}
| {
op: 'updateOne';
db: string;
collection: string;
filter: Record<string, unknown>;
update: Record<string, unknown>;
options?: Record<string, unknown>;
};
type MongoJob = MongoJobPayload & {
id: string;
ts: number;
};
export class MongoSystem {
private queueKey = process.env.LSRAGE_MONGO_QUEUE || 'mongo:jobs';
async init(): Promise<void> {
// No direct Mongo connection in game-server anymore.
// Only verifies Redis path is available.
const redis: any = (redisSystem as any).client ?? (redisSystem as any).redis ?? redisSystem;
if (!redis) {
throw new Error('Redis client is not available for Mongo queue publishing.');
}
logSystem.initLog('MongoQueue', `Enabled (queue=${this.queueKey})`);
}
async publish(job: MongoJobPayload): Promise<void> {
const redis: any = (redisSystem as any).client ?? (redisSystem as any).redis ?? redisSystem;
const payload: MongoJob = {
id: createJobId(),
ts: Date.now(),
...job,
};
// Support both node-redis (lPush) and ioredis (lpush)
if (typeof redis.lPush === 'function') {
await redis.lPush(this.queueKey, JSON.stringify(payload));
} else if (typeof redis.lpush === 'function') {
await redis.lpush(this.queueKey, JSON.stringify(payload));
} else {
throw new Error('Redis client does not support LPUSH.');
}
}
async insertOne(db: string, collection: string, document: unknown): Promise<void> {
await this.publish({ op: 'insertOne', db, collection, document });
}
async updateOne(
db: string,
collection: string,
filter: Record<string, unknown>,
update: Record<string, unknown>,
options?: Record<string, unknown>,
): Promise<void> {
await this.publish({ op: 'updateOne', db, collection, filter, update, options });
}
}
export const mongoSystem = new MongoSystem();

297
src/server/core/mp-shim.ts Normal file
View File

@@ -0,0 +1,297 @@
/**
* mp-shim.ts — RageMP compatibility layer for FiveM (server-side).
*
* Exposes a global `mp` object that mirrors the RageMP server API so that
* ported feature files compile and run with minimal changes.
*
* Migration status markers used in this file:
* COMPAT — fully implemented equivalent
* PARTIAL — works for most cases, edge cases may differ
* STUB — not yet implemented; logs a warning when called
*/
import { playerRegistry, PlayerWrapper } from './player/player-registry';
import { vehicleRegistry, VehicleWrapper } from './vehicle/vehicle-wrapper';
import { rpc } from './rpc/index';
// ---------------------------------------------------------------------------
// Well-known RageMP server events → FiveM event names
// ---------------------------------------------------------------------------
const RAGEMP_TO_FIVEM: Record<string, string> = {
playerJoin: 'playerConnecting', // COMPAT (slightly different — no char load yet)
playerQuit: 'playerDropped', // COMPAT
playerReady: 'playerSpawned', // PARTIAL — FiveM fires after first spawn
};
// Events where the handler's first argument should be a PlayerWrapper
const PLAYER_SOURCE_EVENTS = new Set([
'playerJoin', 'playerQuit', 'playerReady', 'playerDeath',
'playerCommand', 'requestCommandList',
// All custom client→server events are also player-sourced
]);
// ---------------------------------------------------------------------------
// Event bus
// ---------------------------------------------------------------------------
const localEventEmitter = new (require('events').EventEmitter)();
const events = {
delayInitialization: false,
// COMPAT — primary event registration
add(eventOrMap: string | Record<string, Function>, handler?: Function): void {
if (typeof eventOrMap === 'object') {
for (const [name, fn] of Object.entries(eventOrMap)) {
events.add(name, fn);
}
return;
}
const name = eventOrMap as string;
const fn = handler!;
if (name in RAGEMP_TO_FIVEM) {
// Map to FiveM built-in event
const fivemName = RAGEMP_TO_FIVEM[name];
AddEventHandler(fivemName, function (...args: any[]) {
const src = (global as any).source as number;
if (src > 0) {
const player = playerRegistry.getOrCreate(src);
fn(player, ...args);
} else {
fn(...args);
}
});
} else {
// Custom event: register as net event (player-sourced) AND as local event
RegisterNetEvent(name);
AddEventHandler(name, function (...args: any[]) {
const src = (global as any).source as number;
if (src > 0) {
const player = playerRegistry.getOrCreate(src);
fn(player, ...args);
} else {
fn(...args);
}
});
// Local event bus (for mp.events.call)
localEventEmitter.on(name, fn);
}
},
// COMPAT — replaces mp.events.addProc (server-side RPC handler)
addProc(name: string, handler: (...args: any[]) => any): void {
rpc.register(name, handler);
},
// COMPAT — fire a local server-side event (no player source).
// Use only the JS EventEmitter — TriggerEvent would msgpack-serialize
// complex objects (e.g. PlayerWrapper) and strip their prototype getters.
call(eventName: string, ...args: any[]): void {
localEventEmitter.emit(eventName, ...args);
},
// PARTIAL — call remote on all players (no per-player targeting here)
callRemote(eventName: string, ...args: any[]): void {
TriggerClientEvent(eventName, -1, ...args);
},
};
// ---------------------------------------------------------------------------
// Player pool
// ---------------------------------------------------------------------------
const players = {
// COMPAT
at(id: number): PlayerWrapper | undefined {
return playerRegistry.get(id);
},
// COMPAT
toArray(): PlayerWrapper[] {
return playerRegistry.toArray();
},
toArrayFast(): PlayerWrapper[] {
return playerRegistry.toArray();
},
forEach(fn: (player: PlayerWrapper) => void): void {
playerRegistry.forEach(fn);
},
forEachFast(fn: (player: PlayerWrapper) => void): void {
playerRegistry.forEach(fn);
},
// COMPAT — broadcast chat message to all
broadcast(message: string): void {
TriggerClientEvent('_mp:chatMessage', -1, message);
},
get length(): number {
return playerRegistry.toArray().length;
},
exists(player: PlayerWrapper): boolean {
return playerRegistry.get(player.id) !== undefined;
},
};
// ---------------------------------------------------------------------------
// Vehicle pool
// ---------------------------------------------------------------------------
const vehicles = {
// COMPAT — replaces mp.vehicles.new(model, position, options)
new(model: string | number, position: Vector3, options?: any): VehicleWrapper {
return vehicleRegistry.create(model, position, options);
},
at(handle: number): VehicleWrapper | undefined {
return vehicleRegistry.get(handle);
},
toArray(): VehicleWrapper[] {
return vehicleRegistry.toArray();
},
forEach(fn: (vehicle: VehicleWrapper) => void): void {
vehicleRegistry.forEach(fn);
},
};
// ---------------------------------------------------------------------------
// Config (replaces mp.config — reads FiveM ConVars set in server.cfg)
// ---------------------------------------------------------------------------
const config = new Proxy({} as Record<string, any>, {
get(_target, prop: string) {
const val = GetConvar(prop, '__undefined__');
if (val === '__undefined__') return undefined;
if (val === 'true') return true;
if (val === 'false') return false;
const num = Number(val);
return isNaN(num) ? val : num;
},
});
// ---------------------------------------------------------------------------
// Objects pool — STUB (RageMP server objects = FiveM server-side peds/objects)
// ---------------------------------------------------------------------------
const objects = {
new(...args: any[]): any {
console.warn('[mp-shim] mp.objects.new() not yet implemented');
return null;
},
at(id: number): any { return null; },
toArray(): any[] { return []; },
forEach(_fn: any): void {},
};
// ---------------------------------------------------------------------------
// Peds pool — STUB
// ---------------------------------------------------------------------------
const peds = {
new(...args: any[]): any {
console.warn('[mp-shim] mp.peds.new() not yet implemented');
return null;
},
at(id: number): any { return null; },
toArray(): any[] { return []; },
forEach(_fn: any): void {},
};
// ---------------------------------------------------------------------------
// Markers / blips / labels — client-side constructs in FiveM; server sends
// creation events to clients. Stubs here so code compiles.
// ---------------------------------------------------------------------------
const markers = {
new(...args: any[]): any {
console.warn('[mp-shim] mp.markers.new() — use client-side equivalent');
return null;
},
};
const blips = {
new(...args: any[]): any {
console.warn('[mp-shim] mp.blips.new() — use client-side equivalent');
return null;
},
};
const labels = {
new(...args: any[]): any {
console.warn('[mp-shim] mp.labels.new() — use client-side equivalent');
return null;
},
};
const checkpoints = {
new(...args: any[]): any {
console.warn('[mp-shim] mp.checkpoints.new() — use client-side equivalent');
return null;
},
};
// Colshapes are client-side in FiveM (or use server polygons with player position tracking)
const colshapes = {
newSphere(...args: any[]): any {
console.warn('[mp-shim] mp.colshapes — use polygon system equivalent');
return null;
},
newCuboid(...args: any[]): any {
console.warn('[mp-shim] mp.colshapes — use polygon system equivalent');
return null;
},
newCircle(...args: any[]): any {
console.warn('[mp-shim] mp.colshapes — use polygon system equivalent');
return null;
},
newRectangle(...args: any[]): any {
console.warn('[mp-shim] mp.colshapes — use polygon system equivalent');
return null;
},
newTube(...args: any[]): any {
console.warn('[mp-shim] mp.colshapes — use polygon system equivalent');
return null;
},
newPolygon(...args: any[]): any {
console.warn('[mp-shim] mp.colshapes — use polygon system equivalent');
return null;
},
};
// ---------------------------------------------------------------------------
// Vector3 constructor (replaces new mp.Vector3(x, y, z))
class Vector3Impl {
constructor(public x: number, public y: number, public z: number) {}
}
// ---------------------------------------------------------------------------
// Assemble the global mp object
// ---------------------------------------------------------------------------
(global as any).mp = {
events,
players,
vehicles,
objects,
peds,
markers,
blips,
labels,
checkpoints,
colshapes,
config,
Vector3: Vector3Impl,
};
// ---------------------------------------------------------------------------
// Player lifecycle — build/tear down PlayerWrapper on connect/disconnect
// ---------------------------------------------------------------------------
AddEventHandler('playerConnecting', (name: string, _setKickReason: Function, deferrals: any) => {
const netId = (global as any).source as number;
playerRegistry.add(netId);
});
AddEventHandler('playerDropped', (reason: string) => {
const netId = (global as any).source as number;
playerRegistry.remove(netId);
});

69
src/server/core/notify.ts Normal file
View File

@@ -0,0 +1,69 @@
export type NotifyType = 'info' | 'success' | 'warning' | 'error';
/**
* A button shown on an action toast. When clicked, `event` is fired as a
* remote event on the server with the supplied `args`.
*/
export interface NotifyAction {
label: string;
event: string;
args?: (string | number | boolean)[];
}
/**
* Server-side helper for sending toast notifications to players via the
* custom overlay notify component. Replaces raw player.notify() calls.
*
* Standard toasts:
* NotifySystem.Info(player, 'Heading to depot...')
* NotifySystem.Success(player, 'Package delivered!')
* NotifySystem.Warning(player, 'Van not at depot yet.')
* NotifySystem.Error(player, 'You must be in a company vehicle.')
*
* Action toasts (persistent, with buttons):
* NotifySystem.Action(player, 'ride_1', 'Taxi request 1.2km away', 'info', [
* { label: 'Accept', event: 'taxiDuty:accept', args: [1] },
* { label: 'Decline', event: 'taxiDuty:decline', args: [1] },
* ]);
*
* Dismiss a specific action toast by key:
* NotifySystem.Dismiss(player, 'ride_1');
*/
export class NotifySystem {
static Show(player: PlayerMp, message: string, type: NotifyType = 'info', duration = 5000): void {
player.call('notify:show', [message, type, duration]);
}
static Info(player: PlayerMp, message: string, duration?: number): void {
this.Show(player, message, 'info', duration);
}
static Success(player: PlayerMp, message: string, duration?: number): void {
this.Show(player, message, 'success', duration);
}
static Warning(player: PlayerMp, message: string, duration?: number): void {
this.Show(player, message, 'warning', duration);
}
static Error(player: PlayerMp, message: string, duration?: number): void {
this.Show(player, message, 'error', duration);
}
/**
* Show a persistent toast with action buttons. The toast stays on screen
* until the player clicks a button, closes it manually, or the server
* calls Dismiss() with the same key.
*/
static Action(player: PlayerMp, key: string, message: string, type: NotifyType = 'info', actions: NotifyAction[]): void {
player.call('notify:action', [key, message, type, JSON.stringify(actions)]);
}
/**
* Remove a specific action toast from a player's screen by key.
* Safe to call even if the toast has already been dismissed.
*/
static Dismiss(player: PlayerMp, key: string): void {
player.call('notify:dismiss', [key]);
}
}

View File

@@ -0,0 +1,218 @@
import { CharacterEntity } from '../../entities/character.entity';
import { SessionEntity } from '../../entities/session.entity';
import { AccountDto } from '../../features/sw-rp-api/index';
export class PlayerWrapper {
readonly id: number; // FiveM net ID (= RageMP player.id)
state: PlayerState;
character?: CharacterEntity;
account?: AccountDto;
session?: SessionEntity;
groups: number[] = [];
permissions: EntityPermission[] = [];
constructor(netId: number) {
this.id = netId;
this.state = createDefaultPlayerState();
}
// --- Identity ---
get name(): string {
return GetPlayerName(String(this.id)) ?? `Player${this.id}`;
}
get ip(): string {
const endpoint = GetPlayerEndpoint(String(this.id)) ?? '';
const colonIdx = endpoint.lastIndexOf(':');
return colonIdx > 0 ? endpoint.slice(0, colonIdx) : endpoint;
}
get socialClub(): string {
return GetPlayerIdentifierByType(String(this.id), 'steam') ?? '';
}
// --- Position (server-side via ped) ---
get position(): Vector3 {
const ped = GetPlayerPed(String(this.id));
const [x, y, z] = GetEntityCoords(ped);
return { x, y, z } as Vector3;
}
get heading(): number {
return GetEntityHeading(GetPlayerPed(String(this.id)));
}
// --- Health ---
get health(): number {
return GetEntityHealth(GetPlayerPed(String(this.id)));
}
set health(value: number) {
SetEntityHealth(GetPlayerPed(String(this.id)), value);
}
// --- Dimension (stored server-side, synced via state bag) ---
get dimension(): number {
return (Player(this.id).state.dimension as number) ?? 0;
}
setDimension(dimension: number): void {
Player(this.id).state.set('dimension', dimension, true);
}
// --- Server → Client event (replaces player.call) ---
// RageMP: player.call('eventName', [arg1, arg2])
// FiveM: TriggerClientEvent('eventName', netId, arg1, arg2)
call(eventName: string, args: any[] = []): void {
TriggerClientEvent(eventName, this.id, ...args);
}
// --- Notifications (RageMP player.notify) ---
notify(message: string): void {
TriggerClientEvent('_mp:notify', this.id, message);
}
outputChatBox(message: string): void {
TriggerClientEvent('_mp:chatMessage', this.id, message);
}
// --- Synced variables (replaces player.setVariable / getVariable) ---
// Non-serialisable values (timers, promises, entity refs) must stay in .state;
// only primitives & plain objects should go into state bags.
setVariable(key: string, value: any): void {
try {
Player(this.id).state.set(key, value, true);
} catch {
// State bags can only hold serialisable values; silently ignore others.
}
}
getVariable<T = any>(key: string): T {
return Player(this.id).state[key] as T;
}
setOwnVariable(key: string, value: any): void {
Player(this.id).state.set(key, value, false); // not broadcast to other clients
}
// --- Player management ---
kick(reason: string): void {
DropPlayer(String(this.id), reason);
}
kickSilent(): void {
DropPlayer(String(this.id), '');
}
ban(reason: string): void {
// Record the ban then kick — ban storage handled by admin system
this.kick(`[Banned] ${reason}`);
}
// --- Appearance helper (sends to client for ped apply) ---
spawn(position: Vector3): void {
TriggerClientEvent('_mp:spawn', this.id, position);
}
// --- Vehicle (replaces RageMP player.vehicle / player.seat) ---
get vehicle(): import('../vehicle/vehicle-wrapper').VehicleWrapper | null {
const { vehicleRegistry } = require('../vehicle/vehicle-wrapper');
const ped = GetPlayerPed(String(this.id));
const handle = GetVehiclePedIsIn(ped, false);
if (handle === 0) return null;
return vehicleRegistry.get(handle) ?? null;
}
// Seat number: -1 = driver, 0+ = passenger (matches RageMP/GTA convention)
get seat(): number {
const ped = GetPlayerPed(String(this.id));
const handle = GetVehiclePedIsIn(ped, false);
if (handle === 0) return -2;
if (GetPedInVehicleSeat(handle, -1) === ped) return -1;
for (let i = 0; i < 8; i++) {
if (GetPedInVehicleSeat(handle, i) === ped) return i;
}
return -2;
}
// --- Distance ---
dist(other: Vector3): number {
const p = this.position;
return Math.sqrt((p.x - other.x) ** 2 + (p.y - other.y) ** 2 + (p.z - other.z) ** 2);
}
distSquared(other: Vector3): number {
const p = this.position;
return (p.x - other.x) ** 2 + (p.y - other.y) ** 2 + (p.z - other.z) ** 2;
}
// --- Type guard helpers (mirrors RageMP pattern) ---
get isLoggedIn(): boolean {
return !!(this.character && this.account && this.session);
}
}
function createDefaultPlayerState(): PlayerState {
return {
reconnecting: false,
phoneAnim: { currentStatus: 'out' },
currentHairSlot: 0,
togLevel: false,
loginAttempts: 0,
damageHistory: [],
deathState: 'alive' as DeathState,
falling: false,
pmWatchers: [],
pmWatched: [],
freqWatched: [],
factionWatched: [],
companyWatched: [],
report: [],
props: [],
trackedDutyIds: [],
wikiBookmarks: [],
paymentMutex: null as any, // initialised by MoneySystem
outfitOverrides: [],
serverWeapons: {},
actionLock: 0,
maxHealth: 200,
dutyHandcuffs: 0,
} as any;
}
export class PlayerRegistry {
private players: Map<number, PlayerWrapper> = new Map();
add(netId: number): PlayerWrapper {
const wrapper = new PlayerWrapper(netId);
this.players.set(netId, wrapper);
return wrapper;
}
remove(netId: number): void {
this.players.delete(netId);
}
get(netId: number): PlayerWrapper | undefined {
return this.players.get(netId);
}
getOrCreate(netId: number): PlayerWrapper {
return this.players.get(netId) ?? this.add(netId);
}
toArray(): PlayerWrapper[] {
return [...this.players.values()];
}
toArrayFast(): PlayerWrapper[] {
return this.toArray();
}
forEach(fn: (player: PlayerWrapper) => void): void {
this.players.forEach(fn);
}
}
export const playerRegistry = new PlayerRegistry();

View File

@@ -0,0 +1,28 @@
import { getRepository } from 'typeorm';
import { GameEntityTypeEntity } from '../entities/game-entity-type.entity';
import { logSystem } from './log';
export class PreloadSystem {
private gameEntityTypesMap: {
[key: string]: number;
} = {};
async init(): Promise<void> {
const gameEntities = await getRepository(GameEntityTypeEntity).find();
for (const gameEntity of gameEntities) {
this.gameEntityTypesMap[gameEntity.name] = gameEntity.id;
}
logSystem.initLog('Preload System', `Loaded ${gameEntities.length} game entity types.`);
}
getGameEntityTypeId(name: string): number {
return this.gameEntityTypesMap[name];
}
getGameEntityTypeById(id: number): string {
return Object.entries(this.gameEntityTypesMap).find((type) => type[1] === id)?.[0] ?? 'unknown';
}
}
export const preloadSystem = new PreloadSystem();

87
src/server/core/redis.ts Normal file
View File

@@ -0,0 +1,87 @@
import * as redis from 'redis';
import { LogSystem, logSystem } from './log';
import { promisify } from 'util';
const REDIS_HOST = process.env.REDIS_HOST || 'swrp-redis';
const REDIS_PORT = Number(process.env.REDIS_PORT || 6379);
const REDIS_URL = process.env.REDIS_URL;
export class RedisSystem {
client: redis.RedisClient = REDIS_URL ? redis.createClient(REDIS_URL) : redis.createClient(REDIS_PORT, REDIS_HOST);
async init(): Promise<void> {
this.client.on('error', this.error.bind(this));
return new Promise((resolve) => {
this.client.flushall((error: any) => {
if (!error) {
logSystem.initLog('Redis', 'Connection Ready');
resolve();
} else {
LogSystem.Error('Redis', 'error', 'Failed to flush', error);
process.exit();
}
});
});
}
async get(key: string): Promise<string | null> {
return promisify(this.client.get).call(this.client, key);
}
async hget(key: string, field: string): Promise<string> {
return promisify(this.client.hget).call(this.client, key, field);
}
async hdel(key: string, field: string): Promise<number> {
// @ts-ignore
return promisify(this.client.hdel).call(this.client, key, field);
}
async hgetall(key: string): Promise<any> {
return promisify(this.client.hgetall).call(this.client, key);
}
async hset(key: string, field: string, value: string): Promise<number> {
// @ts-ignore
return promisify(this.client.hset).call(this.client, key, field, value);
}
async smembers(key: string): Promise<string[]> {
return promisify(this.client.smembers).call(this.client, key);
}
async sinter(keys: string[]): Promise<string[]> {
// @ts-ignore
return promisify(this.client.sinter).call(this.client, keys);
}
async sismemebr(key: string, member: string): Promise<number> {
return promisify(this.client.sismember).call(this.client, key, member);
}
async scard(key: string): Promise<number> {
return promisify(this.client.scard).call(this.client, key);
}
async del(key: string): Promise<number> {
// @ts-ignore
return promisify(this.client.del).call(this.client, key);
}
async sadd(key: string, member: string | string[]): Promise<number> {
// @ts-ignore
return promisify(this.client.sadd).call(this.client, key, member);
}
async srem(key: string, member: string): Promise<number> {
// @ts-ignore
return promisify(this.client.srem).call(this.client, key, member);
}
error(error: string): void {
LogSystem.Error('Redis', 'error', error, error);
}
}
export const redisSystem = new RedisSystem();

View File

@@ -0,0 +1,73 @@
/**
* Server-side RPC — replaces RageMP's mp.events.addProc / mp.events.callRemoteProc.
*
* Server registers a proc:
* rpc.register('getClosestTrunk', async (player, ...args) => { return result; });
*
* Client calls it via the client RPC module (see src/client/core/rpc/index.ts):
* const result = await rpc.call('getClosestTrunk', ...args);
*
* The wire protocol uses two net events:
* _rpc:call player → server { id, name, args }
* _rpc:result server → player { id, result, error }
*/
import { playerRegistry } from '../player/player-registry';
type ProcHandler = (player: import('../player/player-registry').PlayerWrapper, ...args: any[]) => any;
const handlers = new Map<string, ProcHandler>();
let clientCallCounter = 0;
const clientPending = new Map<string, { resolve: (v: any) => void; reject: (e: any) => void }>();
RegisterNetEvent('_rpc:clientResult');
AddEventHandler('_rpc:clientResult', (callId: string, result: any, error: string | null) => {
const cb = clientPending.get(callId);
if (!cb) return;
clientPending.delete(callId);
if (error) cb.reject(new Error(error));
else cb.resolve(result);
});
export const rpc = {
register(name: string, handler: ProcHandler): void {
handlers.set(name, handler);
},
// Server → client proc call (replaces mp.events.callRemoteProc / player.callProc)
callClient<T = any>(player: import('../player/player-registry').PlayerWrapper, name: string, ...args: any[]): Promise<T> {
const id = `${Date.now()}_${clientCallCounter++}`;
return new Promise<T>((resolve, reject) => {
clientPending.set(id, { resolve, reject });
TriggerClientEvent('_rpc:clientCall', player.id, id, name, args);
setTimeout(() => {
if (clientPending.has(id)) {
clientPending.delete(id);
reject(new Error(`Client RPC timeout: ${name}`));
}
}, 10000);
});
},
};
RegisterNetEvent('_rpc:call');
AddEventHandler('_rpc:call', async (callId: string, name: string, args: any[]) => {
const netId = (global as any).source as number;
const player = playerRegistry.get(netId);
if (!player) return;
const handler = handlers.get(name);
if (!handler) {
TriggerClientEvent('_rpc:result', netId, callId, null, `No proc registered: ${name}`);
return;
}
try {
const result = await handler(player, ...(args ?? []));
TriggerClientEvent('_rpc:result', netId, callId, result, null);
} catch (err: any) {
TriggerClientEvent('_rpc:result', netId, callId, null, err?.message ?? String(err));
}
});

View File

@@ -0,0 +1,41 @@
import { QueryRunner } from 'typeorm';
export class SessionRunnerSystem {
pendingRunners: {
player: PlayerMp;
runner: QueryRunner;
}[] = [];
constructor() {
mp.events.add('playerQuit', this.playerQuit.bind(this));
}
register(player: PlayerMp, runner: QueryRunner): void {
this.pendingRunners.push({ player, runner });
console.log(`Registered runner for ${player.name}`);
}
unregister(player: PlayerMp, runner: QueryRunner): void {
this.pendingRunners = this.pendingRunners.filter((pr) => !(pr.player === player && pr.runner === runner));
console.log('unregistering runner');
}
playerQuit(player: PlayerMp): void {
const playerRunners = this.pendingRunners.filter((entry) => entry.player === player);
this.pendingRunners = this.pendingRunners.filter((entry) => entry.player !== player);
playerRunners.forEach((pendingRunner) => {
if (pendingRunner.runner.isTransactionActive) {
pendingRunner.runner.rollbackTransaction();
}
if (!pendingRunner.runner.isReleased) {
pendingRunner.runner.release();
}
console.log('removing runner on quit');
});
}
}
export const sessionRunnerSystem = new SessionRunnerSystem();

423
src/server/core/types.d.ts vendored Normal file
View File

@@ -0,0 +1,423 @@
/**
* Global type augmentations for the FiveM port.
*
* PlayerMp / VehicleMp etc. are aliased to our wrapper classes so that
* existing feature code that references these types compiles without changes.
*/
import { PlayerWrapper } from './player/player-registry';
import { VehicleWrapper } from './vehicle/vehicle-wrapper';
import { CharacterEntity } from '../entities/character.entity';
import { AccountDto } from '../features/sw-rp-api/index';
import { SessionEntity } from '../entities/session.entity';
// Re-export wrappers under the RageMP type names so existing code just works
declare global {
type PlayerMp = PlayerWrapper;
type VehicleMp = VehicleWrapper;
// Logged-in subtype (mirrors original)
interface PlayerMpLoggedIn extends PlayerWrapper {
character: CharacterEntity;
account: AccountDto;
session: SessionEntity;
}
// ObjectMp — FiveM server-side objects (peds, props); stub for now
interface ObjectMp {
id: number;
handle: number;
setVariable(key: string, value: any): void;
getVariable<T = any>(key: string): T;
furnitureOfProperty?: import('../entities/property.entity').PropertyEntity;
inventory?: import('../entities/inventory.entity').InventoryEntity;
itemLock: boolean;
bluetoothConnectedTo?: PlayerWrapper;
notifyStreaming?: boolean;
createdBy?: string;
createdOn?: string;
doorLocked?: boolean;
doorAjar?: boolean;
}
// Vector3 — matches RageMP's Vector3 shape
interface Vector3 {
x: number;
y: number;
z: number;
}
type Array4d = [number, number, number, number];
// ColshapeMp stub
interface ColshapeMp {
vehicleDealership?: import('../entities/vehicle-dealership.entity').VehicleDealershipEntity;
property?: import('../entities/property.entity').PropertyEntity;
forProperty?: import('../entities/property.entity').PropertyEntity;
}
// PedMp stub
interface PedMp {
entity?: import('../entities/corpse.entity').CorpseEntity;
handle: number;
}
interface EntityPermission {
id: number;
name: string;
description: string;
repositoryId: number;
repositoryType: string;
}
// VehicleMpDatabase — vehicle with a guaranteed DB entity
interface VehicleMpDatabase extends VehicleWrapper {
vehicleEntity: import('../entities/vehicle.entity').VehicleEntity;
}
// ---- PlayerState -------------------------------------------------------
// Copied verbatim from the original types.d.ts — nothing in here references
// RageMP APIs directly so it's portable as-is.
interface PlayerState {
diedToFall?: boolean;
reconnecting: boolean;
lastPlayerDeath?: number;
isOnPhone?: boolean;
phoneAnim: {
currentStatus: 'out' | 'call' | 'text';
lastDict?: string;
lastAnim?: string;
};
companyChat?: import('../entities/company.entity').CompanyEntity;
interviewing?: PlayerWrapper;
interviewedBy?: PlayerWrapper;
shift?: { companyId: number; startTime: Date; paycheckBuffer: number; paydays: number };
currentHairSlot: number;
metroDuty?: string;
sentenceId?: number | null;
prisoned?: Date | null;
jailed?: Date | null;
jailedFor?: number | null;
jailCellId?: number | null;
paroled?: Date | null;
segregated?: Date | null;
segregatedFor?: number | null;
segregatedUntil?: Date | null;
sentencedUntil?: Date | null;
remanded?: Date | null;
paroleEligible?: number | null;
noticeServed?: { release: boolean; parole: boolean };
togLevel: boolean;
togXChat?: boolean;
phoneCall?: {
id: number; number: string; party?: PlayerMpLoggedIn; objectParty?: ObjectMp;
automatedParty?: string; outgoing: boolean; status?: string; start?: number;
active: boolean; dialTimeout?: NodeJS.Timeout; timeoutTimeout?: NodeJS.Timeout;
clearingTimeout?: NodeJS.Timeout; loudSpeaker?: boolean; muted?: boolean;
};
posResetUnlock: number;
propertyInteractionUnlock: number;
vehicleInteraction: boolean;
destinationMarker?: Vector3;
emergencyDispatch?: any;
nonEmergencyDispatch?: any;
menuCompany?: import('../entities/company.entity').CompanyEntity;
phoneCaller?: PlayerMpLoggedIn;
phoneDialTimeout?: NodeJS.Timeout;
clothing: import('../features/clothing/index').StatefulClothingItem[];
characterLoadedAt?: number;
inProperty?: import('../entities/property.entity').PropertyEntity;
inPropertyArea?: any;
mechanicOperation?: VehicleMechanicOperation;
atPropertyCol?: import('../entities/property.entity').PropertyEntity;
atBackdoorCol?: import('../entities/property.entity').PropertyEntity;
jailTimer?: NodeJS.Timeout;
insideJail?: import('../entities/jail-cell.entity').JailCellEntity;
jailReleaseTime?: number;
paymentMutex: import('async-mutex').MutexInterface;
mission?: import('../features/mission/mission.model').Mission;
navDestCallback?: (...args: any[]) => void;
adjustingWeapon?: number;
objectEditor?: any;
objectSelector?: any;
vehicleTask?: any;
aimControls?: any;
furnishing?: any;
furnitureEditor?: any;
dmvTest?: any;
dmvTmpKnowledge?: boolean;
dmvTesting?: boolean;
dmvWaivedPayments?: number;
report: any[];
wakeUpTimer?: NodeJS.Timeout;
spectating?: any;
itemOffer?: any;
bluetoothConnectedTo?: ObjectMp;
localStation?: any;
localStationVolume?: number;
killEvent?: any;
killTime?: number;
damageHistory: any[];
deathState: DeathState;
falling: boolean;
lastMissclick?: number;
drugVisualEffects?: any[];
drugCameraEffect?: any;
smoking?: any;
drinking?: any;
offer?: PlayerOffer;
mdcInitialized?: boolean;
loginTimeOut?: NodeJS.Timeout;
loginAttempts: number;
afkTimeout?: NodeJS.Timeout;
trackedDutyIds: number[];
afkStartTime?: number;
afkDutyStartTime?: number;
afkDutyEndTime?: number;
tabStartTime?: number;
tabEndTime?: number;
adminDutyStartTime?: number;
testerDutyStartTime?: number;
quietModeStartTime?: number;
pmWatchers: PlayerMpLoggedIn[];
pmWatched: PlayerMpLoggedIn[];
freqWatched: number[];
factionWatched: any[];
companyWatched: any[];
muted?: any;
lastKillAlert?: number;
adminJail?: any;
characterCreatorCharacterId?: number;
helpmeRequest?: any;
wikiBookmarks: any[];
weapons?: any[];
atm?: any;
atmAccountUsed?: any;
healthTracker?: NodeJS.Timer;
actionLock: number;
polygonEditor?: any;
duty?: DutyState;
serverWeapons: Record<string, number>;
dutyTimeOverall?: number;
specialDutyTimeOverall?: number;
specialDuty?: number;
lastWound?: number;
maxHealth: number;
regenTick?: number;
deptFrequency?: any;
friskRequests?: PlayerMpLoggedIn[];
frisksApproved?: PlayerMpLoggedIn[];
fishing?: any;
barberShop?: any;
adCooldown?: number;
lastLessLethalHit?: number;
lastPaintballHit?: number;
lastPaintbillRuleHit?: number;
paintballRuleHits?: number;
attachingPlate?: any;
removingPlate?: any;
blockPm?: any;
prisonCell?: any;
propertyRadioColshapes?: any;
vehicleLockBreakin?: VehicleWrapper;
vehicleHotwire?: VehicleWrapper;
bleedOut?: any;
propertyBreakin?: number;
dutyHandcuffs: number;
applyingMakeup?: any;
staticWeather?: string;
staticTime?: number;
pendingVehicle?: boolean;
oocChatDisabled?: boolean;
staffMessagesDisabled?: boolean;
processingMissionKey?: boolean;
showJoinMessages?: boolean;
nametagDamageBlipTimeout?: NodeJS.Timeout;
outfitOverrides: any[];
lastWeaponShot?: any;
autoLow?: any;
vehicleModShop?: any;
tvAccess?: number;
lastPMSender?: PlayerMpLoggedIn;
handlingCorpse?: boolean;
tutorialCooldown?: number;
mdcExtraData?: any;
currentCallMenu?: string;
radioFreqOrder?: any;
lastEquippedBulletWeapon?: string;
lastEquippedMeleeWeapon?: string;
holdingObject?: boolean;
tackleCooldown?: number;
tackled?: boolean;
changeCharacterCooldown?: number;
corpseDigging?: boolean;
chatHighlight?: number[];
pushingGurney?: ObjectMp;
onGurney?: ObjectMp;
processingPhonePower?: boolean;
lastTeleport?: any;
fakeCall?: boolean;
props: [number, number][];
}
// ---- Misc types from original types.d.ts -------------------------------
type DeathState = 'ALIVE' | 'WOUNDED' | 'KNOCKED' | 'DEAD';
interface PlayerOffer {
target: PlayerWrapper;
autoReject: NodeJS.Timeout;
proximity?: number;
resolve: (value: boolean) => void;
}
interface VehicleMechanicOperation {
name: string;
mechanic: PlayerMpLoggedIn;
customer: PlayerMpLoggedIn;
vehicle: VehicleWrapper;
companyId: number;
vehicleEntity: import('../entities/vehicle.entity').VehicleEntity;
activity: 'paint' | 'wheels' | 'tint' | 'upgrade' | 'repair' | 'suspension' | 'engine' | 'brakes';
data: Record<string, any>;
timer?: NodeJS.Timeout;
timeReady: number;
invoiceId: number;
mechanicReward: number;
}
interface VehicleAttachment {
attachment: any;
bone: string;
collision: boolean;
x: number; y: number; z: number;
rx: number; ry: number; rz: number;
opacity: number;
}
interface VehicleLabel {
text: string; font: number; color: number; size: number;
outline: boolean; bone: string; offset: Vector3; opacity: number; distance: number;
}
type CommandParamValidator =
| 'player' | 'character' | 'account' | 'number' | 'integer'
| 'string' | 'word' | 'vehicle' | 'property' | 'item'
| 'company' | 'faction' | 'ped'
| readonly any[] | any[] | RegExp;
type CommandGuard = (player: PlayerWrapper) => true | string;
type DutyState = { callsign: string; start: number; weapons: any[] };
type BoundingBox = [number, number, number, number, number, number];
type FineEntities = 'person' | 'vehicle' | 'company' | 'faction' | 'property';
type EngineData = [string, string, number, number, number, number];
type KeyValueCollection = Record<string, any>;
type EventMpThis = any;
}
declare global {
// DamageHistory / KillEntry — from features/damage/state.d.ts
type DamageHistory = {
id: number;
damager: { id: number; name: string; position: Vector3; dimension: number; health: number; armor: number };
weapon: { gameId: number; weaponName: string; ammoType: string; currentWeaponAmmo: number };
boneIndex: number; damage: number; time: number; position: Vector3; dimension: number;
armorHit: boolean; distance: number; health: number; armor: number;
bleeding: number | null; postMortem?: boolean;
};
interface KillEntry {
_id: string; victim_id: number; victim_name: string; victim_damages: DamageHistory[];
killer_id: number | null; killer_name: string | null;
kill_type: 'WOUNDED' | 'KNOCKED'; weapon: string; time: string;
executed_time: string | null; executed_by_id: number | null;
executed_by_name: string | null; executed_by_weapon: string | null;
revived: string | null; respawned: string | null; accepted_death: string | null;
rescued: string | null; rescued_by: string | null;
}
// mp global — set at runtime by mp-shim.ts, declared here for TypeScript
const mp: {
events: {
add(event: string, handler: Function): void;
add(events: Record<string, Function>): void;
addProc(name: string, handler: Function): void;
call(event: string, ...args: any[]): void;
callRemote(event: string, ...args: any[]): void;
delayInitialization: boolean;
};
players: {
at(id: number): PlayerWrapper | undefined;
toArray(): PlayerWrapper[];
toArrayFast(): PlayerWrapper[];
forEach(fn: (player: PlayerWrapper) => void): void;
broadcast(message: string): void;
length: number;
exists(player: PlayerWrapper): boolean;
};
Vector3: new (x: number, y: number, z: number) => Vector3;
vehicles: {
new(model: string | number, position: Vector3, options?: any): import('./vehicle/vehicle-wrapper').VehicleWrapper;
at(id: number): import('./vehicle/vehicle-wrapper').VehicleWrapper | undefined;
toArray(): import('./vehicle/vehicle-wrapper').VehicleWrapper[];
forEach(fn: (vehicle: import('./vehicle/vehicle-wrapper').VehicleWrapper) => void): void;
};
config: Record<string, any>;
objects: { new(...args: any[]): any; at(id: number): any; toArray(): any[]; forEach(fn: any): void };
peds: { new(...args: any[]): any; at(id: number): any; toArray(): any[]; forEach(fn: any): void };
markers: { new(...args: any[]): any };
blips: { new(...args: any[]): any };
labels: { new(...args: any[]): any };
colshapes: {
newSphere(...args: any[]): any;
newCuboid(...args: any[]): any;
newCircle(...args: any[]): any;
newRectangle(...args: any[]): any;
newTube(...args: any[]): any;
newPolygon(...args: any[]): any;
};
};
type CommandParamMetaData<T> = {
[K in keyof T]: {
hint: string;
validator: CommandParamValidator;
default?: any;
length?: number;
overrides?: Record<string, Record<number, CommandParamMetaData<any>>>;
};
};
interface VehicleLogComposition {
id?: number;
gameId: number;
owner?: string;
model: string;
plate: string;
occupants: string;
bodyHealth: number;
engineHealth: number;
speedInMph: number;
}
// Client-side map object stubs (server never creates these directly in FiveM)
interface BlipMp { destroy(): void; }
interface MarkerMp { destroy(): void; }
interface LabelMp { destroy(): void; }
interface CheckpointMp { destroy(): void; }
interface TextLabelMp { destroy(): void; text: string; }
// PedCustomization — from original types.d.ts
interface PedCustomization {
headBlend: { shapes: number[]; skins: number[]; shapeMix: number; skinMix: number; thirdMix: number };
headOverlay: { overlayId: number; data: Array4d }[];
faceFeatures: { featureId: number; data: number }[];
hairData: { drawable: number; color1: number; color2: number };
outfitData: { component: number; drawable: number[] }[];
propData: { propIndex: number; prop: [number, number] }[];
vehicle?: { status: boolean; vehID: number; seat: number };
weapon?: number;
}
}
export {};

View File

@@ -0,0 +1,129 @@
import { VehicleEntity } from '../../entities/vehicle.entity';
import { ActiveJob } from '../../features/job/job.interface';
export class VehicleWrapper {
readonly handle: number; // FiveM server entity handle
vehicleEntity?: VehicleEntity;
job?: ActiveJob;
dmvTest?: any;
extraBitmap: number = 0;
fuelLastPosition: Vector3 = { x: 0, y: 0, z: 0 } as Vector3;
alarmTimer?: NodeJS.Timeout;
keyInIgnition: boolean = false;
engine: boolean = false; // tracked via client→server sync event
bodyHealth: number = 1000; // synced from client
engineHealth: number = 1000; // synced from client
dimension: number = 0; // stored server-side
mechanicOperation?: VehicleMechanicOperation;
attachments?: VehicleAttachment[];
labels?: VehicleLabel[];
constructor(handle: number) {
this.handle = handle;
}
// RageMP compat: vehicle.id maps to network ID (what clients use to identify the vehicle)
get id(): number {
return NetworkGetNetworkIdFromEntity(this.handle);
}
get model(): number {
return GetEntityModel(this.handle);
}
get position(): Vector3 {
const [x, y, z] = GetEntityCoords(this.handle);
return { x, y, z } as Vector3;
}
set position(v: Vector3) {
SetEntityCoords(this.handle, v.x, v.y, v.z, false, false, false, false);
}
get heading(): number {
return GetEntityHeading(this.handle);
}
set heading(h: number) {
SetEntityHeading(this.handle, h);
}
get numberPlate(): string {
return GetVehicleNumberPlateText(this.handle);
}
set numberPlate(plate: string) {
SetVehicleNumberPlateText(this.handle, plate);
}
destroy(): void {
vehicleRegistry.remove(this.handle);
DeleteEntity(this.handle);
}
getVehicleOccupants(): import('../player/player-registry').PlayerWrapper[] {
const { playerRegistry } = require('../player/player-registry');
return playerRegistry.toArray().filter((p: any) => {
const ped = GetPlayerPed(String(p.id));
return GetVehiclePedIsIn(ped, false) === this.handle;
});
}
}
export class VehicleRegistry {
private vehicles: Map<number, VehicleWrapper> = new Map();
create(model: string | number, position: Vector3, options?: {
heading?: number;
numberPlate?: string;
dimension?: number;
color?: [number, number];
}): VehicleWrapper {
const modelHash = typeof model === 'string' ? GetHashKey(model) : model;
const heading = options?.heading ?? 0;
const handle = CreateVehicle(
modelHash,
position.x, position.y, position.z,
heading,
true, // isNetwork
false, // netMissionEntity
);
if (options?.numberPlate) {
SetVehicleNumberPlateText(handle, options.numberPlate);
}
if (options?.color) {
SetVehicleColours(handle, options.color[0], options.color[1]);
}
const wrapper = new VehicleWrapper(handle);
this.vehicles.set(handle, wrapper);
return wrapper;
}
remove(handle: number): void {
this.vehicles.delete(handle);
}
get(handle: number): VehicleWrapper | undefined {
return this.vehicles.get(handle);
}
getByNetId(netId: number): VehicleWrapper | undefined {
const handle = NetworkGetEntityFromNetworkId(netId);
return this.vehicles.get(handle);
}
toArray(): VehicleWrapper[] {
return [...this.vehicles.values()];
}
forEach(fn: (vehicle: VehicleWrapper) => void): void {
this.vehicles.forEach(fn);
}
}
export const vehicleRegistry = new VehicleRegistry();

View File

@@ -0,0 +1,14 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity('acknowledge_messages')
export class AcknowledgeMessageEntity {
@PrimaryGeneratedColumn() id: number;
@Column({ type: 'int', nullable: true }) character_id: number;
@Column({ type: 'int', nullable: true }) account_id: number;
@Column({ type: 'longtext' }) content: string;
@Column({ type: 'varchar', length: 128 }) author: string;
@Column({ type: 'int', nullable: true }) author_account_id: number;
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) created: Date;
@Column({ type: 'datetime', nullable: true }) acknowledged: Date | null;
@Column({ type: 'datetime', nullable: true, default: null }) deleted: Date | null;
}

View File

@@ -0,0 +1,27 @@
import { Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from 'typeorm';
import { CharacterEntity } from './character.entity';
@Entity('admin_notes')
export class AdminNoteEntity {
@PrimaryGeneratedColumn() id: number;
@Column() character_id: number;
@Column() account_id: number;
@Column({ type: 'varchar', length: 34 }) account_name: string;
@Column() admin_account_id: number;
@Column({ type: 'varchar', length: 34 }) admin_account_name: string;
@Column({ type: 'longtext' }) note: string;
@Column() alert_type: number;
@Column({ type: 'datetime' }) expiration: string;
@Column({ type: 'datetime', default: null, nullable: true }) expired: string | null;
@Column({ type: 'datetime' }) created: string;
@OneToOne(() => CharacterEntity)
@JoinColumn({
name: 'character_id',
referencedColumnName: 'id',
})
character?: CharacterEntity;
}

View File

@@ -0,0 +1,28 @@
import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { CharacterEntity } from './character.entity';
@Entity('ajails')
export class AdminJailEntity {
@PrimaryGeneratedColumn() id: number;
@Index()
@Column()
account_id: number;
@Column({ type: 'varchar', length: 64 }) account_name: string;
@Index()
@Column()
character_id: number;
@Column({ type: 'varchar', length: 64 }) character_name: string;
@Column({ type: 'varchar', length: 16 }) player_ip: string;
@Column({ type: 'int', nullable: true }) ajailer_account_id: number | null;
@Column({ type: 'varchar', length: 64 }) ajailer_name: string;
@Column({ type: 'varchar', length: 256 }) reason: string;
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) date: string;
@Column() length: number;
@Column() remaining_length: number;
@Column({ type: 'datetime', default: null }) served: string | null;
@Column({ type: 'datetime', default: null }) voided: string | null;
@ManyToOne(() => CharacterEntity)
@JoinColumn({ name: 'character_id' })
character?: CharacterEntity;
}

View File

@@ -0,0 +1,30 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity('animations')
export class AnimationEntity {
@PrimaryGeneratedColumn() id: number;
@Column({ type: 'varchar', length: 50 }) category: string;
@Column({ type: 'varchar', length: 30, unique: true }) command: string;
@Column({ type: 'varchar', length: 130 }) dict: string;
@Column({ type: 'varchar', length: 130 }) name: string;
@Column({ type: 'int', default: 1 }) speed: number;
@Column({ type: 'int' }) flag: number;
@Column({ type: 'varchar', length: 100 }) description: string;
@Column({ type: 'int', default: 0 }) allowed_in_vehicle: number;
@Column({ type: 'varchar', default: null, nullable: true }) attached_object_model: string;
@Column({ type: 'int', default: null, nullable: true }) attached_object_bone: number;
@Column({ type: 'float', default: null, nullable: true }) attached_object_pos_x: number;
@Column({ type: 'float', default: null, nullable: true }) attached_object_pos_y: number;
@Column({ type: 'float', default: null, nullable: true }) attached_object_pos_z: number;
@Column({ type: 'float', default: null, nullable: true }) attached_object_rot_x: number;
@Column({ type: 'float', default: null, nullable: true }) attached_object_rot_y: number;
@Column({ type: 'float', default: null, nullable: true }) attached_object_rot_z: number;
get attachedObjectPos(): Vector3 {
return new mp.Vector3(this.attached_object_pos_x, this.attached_object_pos_y, this.attached_object_pos_z);
}
get attachedObjectRot(): Vector3 {
return new mp.Vector3(this.attached_object_rot_x, this.attached_object_rot_y, this.attached_object_rot_z);
}
}

View File

@@ -0,0 +1,21 @@
import { Column, Entity, getRepository, PrimaryColumn } from 'typeorm';
export type DrawableData = {
componentId: number;
drawableId: number;
texture: number;
palette?: number;
};
@Entity('appearance_presets')
export class AppearancePresetEntity {
@PrimaryColumn({ type: 'varchar', length: 32, nullable: false }) name: string;
@Column({ type: 'int', nullable: false }) character_id: number;
@Column({ type: 'json' }) clothing_data: DrawableData[];
@Column({ type: 'json' }) prop_data: DrawableData[];
static async SaveAppearance(name: string, clothingData: DrawableData[], propData: DrawableData[], characterId = -1) {
const res = await getRepository(this).save({ name, character_id: characterId, clothing_data: clothingData, prop_data: propData });
return res;
}
}

View File

@@ -0,0 +1,43 @@
import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { BankEntity } from './bank.entity';
import { CompanyEntity } from './company.entity';
@Entity('atms')
export class AtmEntity {
static MAX_CASH = 100_000;
@PrimaryGeneratedColumn() id: number;
@Index()
@Column()
bank_id: number;
@Column() posx: number;
@Column() posy: number;
@Column() posz: number;
@Column() rotx: number;
@Column() roty: number;
@Column() rotz: number;
@Column({ type: 'varchar', length: 20, default: null, nullable: true }) model: string | null;
@Column('datetime') created: string;
@Column() dimension: number;
@Index()
@Column({ type: 'int', nullable: true })
operator_id: number | null;
@Column() fee: number;
operatedBy?: PlayerMpLoggedIn;
object?: ObjectMp;
@ManyToOne(() => BankEntity, (bank) => bank.accounts)
@JoinColumn({ name: 'bank_id' })
bank?: BankEntity;
@ManyToOne(() => CompanyEntity)
@JoinColumn({ name: 'operator_id' })
operator?: CompanyEntity;
availableCash = AtmEntity.MAX_CASH;
position() {
return new mp.Vector3(this.posx, this.posy, this.posz);
}
}

View File

@@ -0,0 +1,16 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity('auth_tokens')
export class AuthTokenEntity {
@PrimaryGeneratedColumn() id: number;
@Column() account_id: number;
@Column({ type: 'varchar', length: 32 }) account_username: string;
@Column({ type: 'varchar', length: 128 }) token: string;
@Column({ type: 'datetime' }) expires: Date;
@Column({ type: 'tinyint' }) persistent: boolean;
@Column({ type: 'varchar', length: 128 }) serial: string;
@Column({ type: 'varchar', length: 32 }) ip: string;
@Column({ type: 'varchar', length: 24 }) socialclub_id: string;
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) created: string;
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) last_used: string;
}

View File

@@ -0,0 +1,162 @@
import { AfterLoad, Column, Entity, JoinColumn, ManyToOne, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
import { getRepository } from 'typeorm';
import { BankTransactionEntity } from './bank-transaction.entity';
import { BankEntity } from './bank.entity';
import { CharacterEntity } from './character.entity';
import { FactionEntity } from './faction.entity';
import { CompanyEntity } from './company.entity';
import { companySystem } from '../features/company';
import { LoanEntity } from './loan.entity';
import { MazePayAccountEntity } from './maze-pay-account.entity';
@Entity('bank_accounts')
export class BankAccountEntity {
@PrimaryGeneratedColumn() id: number;
@Column({ unique: true, length: 10 }) number: string;
@Column({ type: 'int' }) bank_id: number;
@Column({ type: 'int', default: 0 }) balance: number;
@Column({ type: 'int', default: null }) character_id: number | null;
@Column({ type: 'int', default: null }) company_id: number | null;
@Column({ type: 'int', default: null }) faction_id: number | null;
@Column({ type: 'datetime' }) created: Date;
@OneToMany(() => BankTransactionEntity, (transaction) => transaction.bankAccount)
transactions: BankTransactionEntity[];
@ManyToOne(() => BankEntity, (bank) => bank.accounts)
@JoinColumn({ name: 'bank_id' })
bank: BankEntity;
@ManyToOne(() => CompanyEntity, (company) => company.bankAccounts)
@JoinColumn({ name: 'company_id' })
company: CompanyEntity;
@ManyToOne(() => CharacterEntity, (character) => character.bankAccounts)
@JoinColumn({ name: 'character_id' })
character: CharacterEntity;
@ManyToOne(() => FactionEntity, (faction) => faction.bankAccounts)
@JoinColumn({ name: 'faction_id' })
faction: FactionEntity;
@OneToMany(() => LoanEntity, (loan) => loan.bankAccount)
loans: LoanEntity[];
@OneToMany(() => MazePayAccountEntity, (mpae) => mpae.bankAccount)
mazePayAccounts: MazePayAccountEntity[];
iban?: string;
historyForPreview: number[] = [];
name: string;
@AfterLoad()
afterLoad(): void {
this.iban = this.getIban(false);
}
async generateHistoryForPreview(): Promise<void> {
const recentTransactions = await getRepository(BankTransactionEntity)
.createQueryBuilder()
.where('DATE(`date`) > DATE_SUB(NOW(), INTERVAL 10 DAY) AND bank_account_id = :id', {
id: this.id,
})
.orderBy({
date: 'DESC',
})
.getMany();
const date = new Date();
this.historyForPreview = [this.balance];
let currentBalance = this.balance;
for (let i = 0; i < 9; i++) {
const amountInDate = recentTransactions
.filter((rt) => {
return new Date(rt.date).toDateString() === date.toDateString();
})
.reduce((total, rt) => total - rt.amount, currentBalance);
this.historyForPreview.unshift(amountInDate);
currentBalance = amountInDate;
date.setDate(date.getDate() - 1);
}
}
getIban(pretty: boolean, bold?: boolean): string {
const statePrefix = 'LS';
let accountType = '10'; // personal
if (this.faction_id) {
accountType = '20';
} else if (this.company_id) {
accountType = '30';
}
if (pretty) {
if (bold) {
return `${statePrefix}${accountType} <strong>${this.bank_id}</strong> 0000 00<strong>${this.number.slice(0, 2)} ${this.number.slice(
2,
6,
)} ${this.number.slice(6)}</strong>`;
}
return `${statePrefix}${accountType} ${this.bank_id} 0000 00${this.number.slice(0, 2)} ${this.number.slice(2, 6)} ${this.number.slice(6)}`;
}
return `${statePrefix}${accountType}${this.bank_id}000000${this.number}`;
}
async cacheName(): Promise<void> {
this.name = await this.getName();
}
async getName(): Promise<string> {
if (this.character_id) {
const character = await getRepository(CharacterEntity).findOne({
id: this.character_id,
});
if (character) {
return character.getIcName(false);
}
return this.getIban(true);
}
if (this.faction_id) {
const faction = await getRepository(FactionEntity).findOne({
id: this.faction_id,
});
if (faction) {
return faction.name;
}
return this.getIban(true);
}
if (this.company_id) {
const company = companySystem.companies.find((c) => c.id === this.company_id);
if (company) {
return company.name;
}
}
return this.getIban(true);
}
getTypeName(): string {
if (this.character_id) {
return 'Personal Account';
}
if (this.company_id) {
return 'Business Account';
}
if (this.faction_id) {
return 'Agency Account';
}
return 'Unknown Account';
}
}

View File

@@ -0,0 +1,29 @@
import { Column, Entity, JoinColumn, ManyToOne, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
import { BankAccountEntity } from './bank-account.entity';
import { PaydayRewardEntity } from './payday-reward.entity';
@Entity('bank_transactions')
export class BankTransactionEntity {
@PrimaryGeneratedColumn() id: number;
@Column() bank_account_id: number;
@Column({ type: 'int', nullable: true }) counter_bank_account_id: number | null;
@Column({ type: 'varchar', length: 64 }) counter_name: string;
@Column() amount: number;
@Column({ type: 'varchar', length: 64 }) reason: string;
@Column({ type: 'varchar', length: 128 }) description: string;
@Column({ type: 'varchar', length: 16 }) type: string;
@Column({ type: 'varchar', length: 32 }) category: string;
@Column({ type: 'binary', length: 32 }) ref: string;
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) date: string;
@ManyToOne(() => BankAccountEntity, (bankAccount) => bankAccount.transactions)
@JoinColumn({ name: 'bank_account_id' })
bankAccount: BankAccountEntity;
@ManyToOne(() => BankAccountEntity, (bankAccount) => bankAccount.transactions)
@JoinColumn({ name: 'counter_bank_account_id' })
counterBankAccount: BankAccountEntity;
@OneToMany(() => PaydayRewardEntity, (reward) => reward.bankTransaction)
paydayRewards: PaydayRewardEntity[];
}

View File

@@ -0,0 +1,11 @@
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
import { BankAccountEntity } from './bank-account.entity';
@Entity('banks')
export class BankEntity {
@PrimaryGeneratedColumn() id: number;
@Column({ type: 'varchar', length: 64 }) name: string;
@OneToMany(() => BankAccountEntity, (account) => account.bank)
accounts: BankAccountEntity[];
}

View File

@@ -0,0 +1,10 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity('barber_shop_seat')
export class BarberShopSeatEntity {
@PrimaryGeneratedColumn() id: number;
@Column({ type: 'float' }) x: number;
@Column({ type: 'float' }) y: number;
@Column({ type: 'float' }) z: number;
@Column({ type: 'float' }) heading: number;
}

View File

@@ -0,0 +1,31 @@
import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn, Unique } from 'typeorm';
import { PropertyEntity } from './property.entity';
import { ItemTypeEntity } from './item-type.entity';
@Entity('business_stock_levels')
@Unique(['property_id', 'item_type_id'])
export class BusinessStockLevelEntity {
@PrimaryGeneratedColumn()
id: number;
@Index()
@Column()
property_id: number;
@Column()
item_type_id: number;
@Column({ type: 'int', default: 0 })
quantity: number;
@Column({ type: 'int', default: 0 })
min_threshold: number;
@ManyToOne(() => PropertyEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'property_id' })
property: PropertyEntity;
@ManyToOne(() => ItemTypeEntity)
@JoinColumn({ name: 'item_type_id' })
itemType: ItemTypeEntity;
}

View File

@@ -0,0 +1,16 @@
import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { CompanyEntity } from './company.entity';
@Entity('changing_rooms')
export class ChangingRoomEntity {
@PrimaryGeneratedColumn() id: number;
@Column() company_id: number;
@Column({ type: 'float' }) x: number;
@Column({ type: 'float' }) y: number;
@Column({ type: 'float' }) z: number;
@Column() dimension: number;
@ManyToOne(() => CompanyEntity, (company) => company.changingRooms)
@JoinColumn({ name: 'company_id' })
company: CompanyEntity;
}

View File

@@ -0,0 +1,58 @@
import { Column, Entity, Index, JoinColumn, OneToOne, PrimaryGeneratedColumn } from 'typeorm';
import { CharacterEntity } from './character.entity';
export interface CharacterShape {
shapeFirstId: number;
shapeSecondId: number;
shapeThirdId: number;
shapeMix: number;
thirdMix: number;
}
export interface CharacterSkin {
skinFirstId: number;
skinSecondId: number;
skinThirdId: number;
skinMix: number;
}
export interface CharacterOverlays {
overlayId: number;
index: number;
opacity: number;
firstColor?: number;
secondColor?: number;
}
export interface CharacterFaceFeatures {
index: number;
scale: number;
}
export interface CharacterMiscFeatures {
hairStyle: number;
hairColor: number;
hairColorSecondary: number;
eyeColor: number;
microPigmentation: boolean;
microPigmentationOne: number;
microPigmentationTwo: number;
microPigmentationThree: number;
}
@Entity('character_appearances')
export class CharacterAppearanceEntity {
@PrimaryGeneratedColumn() id: number;
@Index()
@Column()
character_id: number;
@Column({ type: 'json' }) shape: CharacterShape;
@Column({ type: 'json' }) skin: CharacterSkin;
@Column({ type: 'json' }) overlays: CharacterOverlays[];
@Column({ type: 'json' }) face: CharacterFaceFeatures[];
@Column({ type: 'json' }) misc: CharacterMiscFeatures;
@OneToOne(() => CharacterEntity)
@JoinColumn({ name: 'character_id' })
character?: CharacterEntity;
}

View File

@@ -0,0 +1,8 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity('attributes')
export class AttributesEntity {
@PrimaryGeneratedColumn() id: number;
@Column({ unique: true }) character_name: string;
@Column({ length: 94 }) attributes: string;
}

View File

@@ -0,0 +1,16 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity('character_cache')
export class CharacterCacheEntity {
@PrimaryGeneratedColumn() id: number;
@Column({ type: 'varchar', length: 24, unique: true }) name: string;
@Column() gender: 0 | 1;
@Column({ type: 'varchar', length: 10 }) date_of_birth: string;
@Column({ type: 'int', nullable: true }) ssn: number | null;
@Column({ type: 'text', nullable: true }) appearance: string | null;
@Column({ type: 'int', nullable: true }) jail_id: number | null;
@Column({ type: 'int', nullable: true }) jail_time: number | null;
@Column({ type: 'int', nullable: true }) prison_minutes: number | null;
@Column({ type: 'datetime', nullable: true }) prison_until: Date | null;
@Column({ type: 'int', nullable: true }) prison_seg_minutes: number | null;
}

View File

@@ -0,0 +1,27 @@
import { Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from 'typeorm';
import { CompanyEntity } from './company.entity';
import { CompanyPositionEntity } from './company-position.entity';
@Entity('character_company_positions')
export class CharacterCompanyPositionEntity {
@PrimaryGeneratedColumn() id: number;
@Column({ type: 'int' }) character_id: number;
@Column({ type: 'int' }) company_id: number;
@Column({ type: 'int' }) company_position_id: number;
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) created: Date;
@Column({ type: 'int' }) created_by: number;
@OneToOne(() => CompanyEntity)
@JoinColumn({
name: 'company_id',
referencedColumnName: 'id',
})
company: CompanyEntity;
@OneToOne(() => CompanyPositionEntity)
@JoinColumn({
name: 'company_position_id',
referencedColumnName: 'id',
})
companyPosition: CompanyPositionEntity;
}

View File

@@ -0,0 +1,21 @@
import { Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from 'typeorm';
import { CharacterEntity } from './character.entity';
@Entity('character_name_changes')
export class CharacterNameChangesEntity {
@PrimaryGeneratedColumn() id: number;
@Column({ type: 'int' }) account_id: number;
@Column({ type: 'int' }) character_id: number;
@Column({ type: 'varchar', length: 64 }) from: string;
@Column({ type: 'varchar', length: 64 }) to: string;
@Column({ type: 'datetime' }) date: Date;
@Column({ type: 'tinyint' }) is_ck: boolean;
@Column({ type: 'int' }) changed_by_account_id: number;
@OneToOne(() => CharacterEntity)
@JoinColumn({
name: 'character_id',
referencedColumnName: 'id',
})
character: CharacterEntity;
}

View File

@@ -0,0 +1,78 @@
import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { Gender } from '../../shared/const/gender.const';
import { CharacterEntity } from './character.entity';
export type CharacterOutfitStackPart = [number, number] | null;
export type CharacterOutfitStack = [
CharacterOutfitStackPart,
CharacterOutfitStackPart,
CharacterOutfitStackPart,
CharacterOutfitStackPart,
CharacterOutfitStackPart,
CharacterOutfitStackPart,
CharacterOutfitStackPart,
CharacterOutfitStackPart,
CharacterOutfitStackPart,
CharacterOutfitStackPart,
CharacterOutfitStackPart,
CharacterOutfitStackPart,
];
export type CharacterOutfitStackProps = [
CharacterOutfitStackPart,
CharacterOutfitStackPart,
CharacterOutfitStackPart,
CharacterOutfitStackPart,
CharacterOutfitStackPart,
CharacterOutfitStackPart,
CharacterOutfitStackPart,
CharacterOutfitStackPart,
];
export type CharacterOutfitStackState = [
boolean,
boolean,
boolean,
boolean,
boolean,
boolean,
boolean,
boolean,
boolean,
boolean,
boolean,
boolean,
];
export enum CharacterOutfitOverrideLayer {
FactionUniform,
CompanyUniform,
PrisonUniform,
}
export enum CharacterOutfitOverrideKey {
FactionUniform = 'Faction Uniform',
CompanyUniform = 'Company Uniform',
PrisonUniform = 'Prison Uniform',
}
export interface CharacterOutfitOverride {
components: CharacterOutfitStack;
props?: CharacterOutfitStackProps;
resumeOnReconnect?: number;
key: CharacterOutfitOverrideKey;
layer: CharacterOutfitOverrideLayer;
}
@Entity('character_outfits')
export class CharacterOutfitEntity {
@PrimaryGeneratedColumn() id: number;
@Column() character_id: number;
@Column({ type: 'int' }) character_gender: Gender;
@Column({ type: 'varchar', length: 64 }) name: string;
@Column({ type: 'json' }) components: CharacterOutfitStack;
@ManyToOne(() => CharacterEntity, (character) => character.outfits)
@JoinColumn({ name: 'character_id' })
character: CharacterEntity;
}

View File

@@ -0,0 +1,23 @@
import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { CharacterEntity } from './character.entity';
export interface CharacterOverlay {
overlayId: number;
index: number;
opacity: number;
colors: [number, number];
}
@Entity('character_overlay_presets')
export class CharacterOverlayPresetEntity {
@PrimaryGeneratedColumn() id: number;
@Index()
@Column()
character_id: number;
@Column({ type: 'varchar', length: 128 }) name: string;
@Column({ type: 'json' }) overlays: CharacterOverlay[];
@ManyToOne(() => CharacterEntity)
@JoinColumn({ name: 'character_id' })
character?: CharacterEntity;
}

View File

@@ -0,0 +1,26 @@
import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { CharacterEntity } from './character.entity';
import { TattooEntity } from './tattoo.entity';
@Entity('character_tattoos')
export class CharacterTattooEntity {
@PrimaryGeneratedColumn() id: number;
@Column() character_id: number;
@Column({ type: 'varchar', length: 32 }) character_name: string;
@Column() tattoo_id: number;
@Column() opacity: number;
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) created: Date;
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) modified: Date;
@ManyToOne(() => CharacterEntity, (character) => character.tattoos)
@JoinColumn({
name: 'character_id',
})
character: CharacterEntity;
@ManyToOne(() => TattooEntity, (tattoo) => tattoo.characterTattoos)
@JoinColumn({
name: 'tattoo_id',
})
tattoo: TattooEntity;
}

View File

@@ -0,0 +1,283 @@
import { Column, Entity, getRepository, JoinColumn, JoinTable, ManyToMany, ManyToOne, OneToMany, OneToOne, PrimaryGeneratedColumn } from 'typeorm';
import { InventoryEntity } from './inventory.entity';
import { FactionEntity } from './faction.entity';
import { PhoneEntity } from './phone.entity';
import { CompanyEntity } from './company.entity';
import { CompanyPositionEntity } from './company-position.entity';
import { SessionEntity } from './session.entity';
import { FactionRankEntity } from './factionrank.entity';
import { PlayerSettings } from '../features/settings';
import { DriversLicenseEntity } from './drivers-license.entity';
import { WeaponLicenseEntity } from './weapon-license.entity';
import dayjs from 'dayjs';
import { BankAccountEntity } from './bank-account.entity';
import { PropertyKeyEntity } from './property-key.entity';
import { VehicleOrderEntity } from './vehicle-order.entity';
import { VehicleEntity } from './vehicle.entity';
import { CommercialDriversLicenseEntity } from './commercial-drivers-license.entity';
import { TaxiLicenseEntity } from './taxi-license.entity';
import { PrisonCellEntity } from './prison-cell.entity';
import { CharacterOutfitEntity, CharacterOutfitStack, CharacterOutfitStackState } from './character-outfit.entity';
import { CharacterTattooEntity } from './character-tattoo.entity';
import { SceneEntity } from './scene.entity';
import { GraffitiEntity } from './graffiti.entity';
import { PetEntity } from './pet.entity';
import { WeaponAttachmentEntity } from './weapon-attachment.entity';
import { colourableChats } from '../features/chat/const/chat-color-channels';
import { CharacterOverlay } from './character-overlay-preset.entity';
import { CharacterAppearanceEntity } from './character-appearance.entity';
import { aconfigSettings } from '../features/admin/types';
import { Gender } from '../../shared/const/gender.const';
@Entity('characters')
export class CharacterEntity {
@PrimaryGeneratedColumn() id: number;
@Column({ type: 'varchar', length: 24 }) name: string;
@Column({ default: 500 }) cash: number;
@Column({ type: 'varchar', width: 10, default: '2003-12-01' }) date_of_birth: string;
@Column() gender: Gender;
@Column({ type: 'int', default: null, nullable: true }) faction_id: number | null;
@Column({ type: 'int', default: null, nullable: true }) faction_rank: number | null;
@Column() account_id: number;
@Column() level: number;
@Column() play_time: number;
@Column({ type: 'json', default: '{}' }) joined_job_play_times: { [companyId: number]: number };
@Column('datetime') created: Date;
@Column({ type: 'datetime', nullable: true }) last_online: Date | null;
@Column({ type: 'int', nullable: true }) jail_time: number | null;
@Column({ type: 'varchar', length: 32 }) spawn: string | null;
@Column({ type: 'int', nullable: true }) ssn: number | null;
@Column({ type: 'json' }) settings: Partial<PlayerSettings>;
@Column({ type: 'varchar', length: 40, nullable: true, default: null }) walkstyle: string | null;
@Column({ type: 'varchar', length: 30, default: 'mood_normal_1' }) expression: string;
@Column({ type: 'int', nullable: true }) prison_cell_id: number | null;
@Column({ type: 'int', nullable: true }) prison_minutes: number | null;
@Column({ type: 'int', nullable: true }) prison_seg_minutes: number | null;
@Column({ type: 'varchar', length: 24, nullable: true, default: null }) signature: string | null;
@Column({ type: 'varchar', length: 32, nullable: true, default: null }) signature_font: string | null;
@Column() payday_duty_buffer: number;
@Column() welfare_buffer: number;
@Column({ type: 'tinyint', width: 1, default: 0 }) killed: boolean;
@Column({ type: 'json' }) outfit: CharacterOutfitStack;
@Column({ type: 'json' }) overlay: CharacterOverlay[] | null;
@Column({ type: 'json' }) outfit_state: CharacterOutfitStackState;
@Column({ type: 'int', default: 0 }) reward_stack: number;
@Column({ type: 'float' }) last_x: number;
@Column({ type: 'float' }) last_y: number;
@Column({ type: 'float' }) last_z: number;
@Column({ type: 'int' }) last_d: number;
@Column({ type: 'int', default: 1 }) max_companies: number;
@Column({ type: 'int', default: 0 }) join_rewards: number;
@Column({ type: 'json', default: '[0,0,0]' }) hairs: [number, number, number];
@Column({ type: 'datetime', nullable: true }) lockpick_reset: Date | null;
@Column({ type: 'datetime', nullable: true }) weaponfile_reset: Date | null;
@Column({ type: 'datetime', nullable: true }) surgeon_reset: Date | null;
@Column({ type: 'json' }) chat_colours: { [colour in keyof typeof colourableChats]: string } | Record<string, never> | null;
@Column({ type: 'json' }) admin_config: { [setting in keyof typeof aconfigSettings]: boolean | undefined };
@Column({ type: 'int', nullable: true }) nail_style_id: number | null;
@Column({ type: 'tinyint', width: 1, default: 0 }) tutorial: boolean;
@ManyToOne(() => PrisonCellEntity, (prisonCell) => prisonCell.residents)
@JoinColumn({ name: 'prison_cell_id' })
prisonCell: PrisonCellEntity;
// @ManyToOne(() => CompanyEntity, (company) => company.employees)
// @JoinColumn({ name: 'company_id' })
// company?: CompanyEntity;
@ManyToMany(() => CompanyEntity, {
eager: true,
})
@JoinTable({
name: 'character_company_positions',
joinColumn: { name: 'character_id' },
inverseJoinColumn: { name: 'company_id' },
})
companies: CompanyEntity[];
@ManyToMany(() => CompanyPositionEntity, {
eager: true,
})
@JoinTable({
name: 'character_company_positions',
joinColumn: { name: 'character_id' },
inverseJoinColumn: { name: 'company_position_id' },
})
companyPositions: CompanyPositionEntity[];
@OneToMany(() => BankAccountEntity, (bankAccount) => bankAccount.character)
bankAccounts: BankAccountEntity[];
@OneToMany(() => SessionEntity, (session) => session.character)
@JoinColumn({ name: 'id' })
sessions: SessionEntity;
@OneToMany(() => GraffitiEntity, (graffiti) => graffiti.creator_id)
graffitis: GraffitiEntity[];
@OneToMany(() => PetEntity, (pet) => pet.character_id)
pets: PetEntity[];
@OneToMany(() => VehicleEntity, (vehicle) => vehicle.character)
vehicles: VehicleEntity[];
@OneToMany(() => PhoneEntity, (phone) => phone.character)
phones: PhoneEntity[];
@OneToMany(() => SceneEntity, (scene) => scene.character)
scenes: SceneEntity[];
@OneToMany(() => WeaponAttachmentEntity, (attachment) => attachment.character_id)
weaponAttachments: WeaponAttachmentEntity[];
phone?: PhoneEntity;
inventory?: InventoryEntity;
@ManyToOne(() => FactionEntity, (faction) => faction.members)
@JoinColumn({ name: 'faction_id' })
faction?: FactionEntity;
factionRank?: FactionRankEntity;
@OneToMany(() => CompanyEntity, (company) => company.owner)
ownedCompanies: CompanyEntity[];
@OneToMany(() => PropertyKeyEntity, (pke) => pke.character)
propertyKeys: PropertyKeyEntity[];
@OneToMany(() => VehicleOrderEntity, (voe) => voe.character)
vehicleOrders: VehicleOrderEntity[];
@OneToMany(() => CharacterOutfitEntity, (coe) => coe.character)
outfits: CharacterOutfitEntity[];
@OneToMany(() => CharacterTattooEntity, (cte) => cte.character)
tattoos: CharacterTattooEntity[];
@OneToOne(() => CharacterAppearanceEntity)
@JoinColumn({
name: 'id',
referencedColumnName: 'character_id',
})
appearanceData?: CharacterAppearanceEntity;
masked: boolean;
maskedName: string;
driverLicense?: DriversLicenseEntity;
weaponLicense?: WeaponLicenseEntity;
bankAccount?: BankAccountEntity;
cdl?: CommercialDriversLicenseEntity;
taxiLicense?: TaxiLicenseEntity;
attributes?: string;
async save(): Promise<CharacterEntity> {
return await getRepository(CharacterEntity).save(this);
}
getIcName(includeMaskId = true): string {
if (includeMaskId && this.masked) {
return this.maskedName;
}
return this.name.replace('_', ' ');
}
getFactionName(includeMaskId = false): string {
const parts = [];
if (this.factionRank) {
parts.push(this.factionRank.name);
}
parts.push(this.getIcName(includeMaskId));
return parts.join(' ');
}
getFirstName(): string {
return this.name.split('_')[0];
}
getAge(): number {
return dayjs().diff(dayjs(this.date_of_birth), 'years');
}
getLastName(withInitial = false): string {
const parts = this.name.split('_');
if (withInitial) {
return `${parts[0][0]}. ${parts[1]}`;
}
return parts[1];
}
getName(): string {
return this.getIcName(false);
}
getSSN(): string | undefined {
if (!this.ssn) {
return undefined;
}
const s = String(this.ssn);
return [s.slice(0, 3), s.slice(3, 5), s.slice(5)].join('-');
}
getSignatureStyle() {
return this.signature || 'full';
}
getSignatureFont() {
return this.signature_font || 'Alex Brush';
}
getEntityType(): 'character' {
return 'character';
}
getSignature() {
const style = this.getSignatureStyle();
if (style === 'full') {
return this.name.replace('_', ' ');
}
const parts = this.name.split('_');
if (style === 'firsti') {
return `${parts[0][0]}. ${parts[1]}`;
}
if (style === 'firstin') {
return `${parts[0][0]} ${parts[1]}`;
}
if (style === 'lasti') {
return `${parts[0]} ${parts[1][0]}.`;
}
if (style === 'lastin') {
return `${parts[0]} ${parts[1][0]}`;
}
if (style === 'i') {
return `${parts[0][0]}. ${parts[1][0]}.`;
}
if (style === 'in') {
return `${parts[0][0]} ${parts[1][0]}`;
}
if (style === 'last') {
return `${parts[1]}`;
}
}
}

View File

@@ -0,0 +1,15 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity('charges')
export class ChargeEntity {
@PrimaryGeneratedColumn() id: number;
@Column({ type: 'int' }) character_id: number;
@Column({ type: 'varchar', width: 32 }) character_name: string;
@Column({ type: 'int' }) account_id: number;
@Column({ type: 'int' }) by_character_id: number;
@Column({ type: 'varchar', width: 50 }) by_name: string;
@Column({ type: 'varchar', width: 256 }) reason: string;
@Column({ type: 'text', default: null, nullable: true }) statement: string | null;
@Column({ type: 'varchar', width: 20 }) status: 'charge' | 'prison' | 'manual-entry' | 'update';
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP()' }) time: Date;
}

View File

@@ -0,0 +1,17 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity('chat_ads')
export class ChatAdEntity {
@PrimaryGeneratedColumn() id: number;
@Column({ type: 'varchar', width: 40 }) character_name: string;
@Column({ type: 'int' }) character_id: number;
@Column({ type: 'int' }) account_id: number;
@Column({ type: 'varchar', width: 296 }) advert: string;
@Column({ type: 'datetime' }) time: Date;
@Column({ type: 'int' }) paid: number;
@Column({ type: 'datetime', default: null, nullable: true }) aired: Date | null;
@Column({ type: 'datetime', default: null, nullable: true }) removed: Date | null;
@Column({ type: 'varchar', width: 40, default: null, nullable: true }) removed_by: string | null;
@Column({ type: 'boolean', default: false }) premium: boolean;
}

View File

@@ -0,0 +1,21 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
export type ClothingItemType = 'jacket' | 'shirt';
export interface ClothingItemState {
type: string;
variations: {
drawable: number;
texture: number;
}[];
}
@Entity('clothing_items')
export class ClothingItemEntity {
@PrimaryGeneratedColumn() id: number;
@Column({ type: 'varchar', length: 64, nullable: false }) name: string;
@Column({ type: 'varchar', length: 16, nullable: false }) type: ClothingItemType;
@Column({ type: 'int', width: 11, nullable: false }) drawable: string;
@Column({ type: 'int', width: 11, nullable: false }) texture: string;
@Column({ type: 'json', nullable: true }) states?: ClothingItemState[];
}

View File

@@ -0,0 +1,15 @@
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
import { CommercialDriversLicenseEntity } from './commercial-drivers-license.entity';
@Entity('commercial_drivers_license_types')
export class CommercialDriversLicenseTypeEntity {
@PrimaryGeneratedColumn() id: number;
@Column({ type: 'varchar', length: 64 }) name: string;
@Column() max_load: number;
@Column() required_xp: number;
@Column() level: number;
@Column() reward_multiplier: number;
@OneToMany(() => CommercialDriversLicenseEntity, (cdle) => cdle.type)
cdls: CommercialDriversLicenseEntity[];
}

View File

@@ -0,0 +1,17 @@
import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { CommercialDriversLicenseTypeEntity } from './commercial-drivers-license-type.entity';
@Entity('commercial_drivers_licenses')
export class CommercialDriversLicenseEntity {
@PrimaryGeneratedColumn() id: number;
@Column({ type: 'int' }) character_id: number | null;
@Column({ type: 'varchar', length: 32 }) character_name: string | null;
@Column() commercial_drivers_license_type_id: number;
@Column({ type: 'int', default: 0 }) xp: number;
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) created: Date;
@Column({ type: 'datetime' }) expires: Date;
@ManyToOne(() => CommercialDriversLicenseTypeEntity, (cdlte) => cdlte.cdls)
@JoinColumn({ name: 'commercial_drivers_license_type_id' })
type: CommercialDriversLicenseTypeEntity;
}

View File

@@ -0,0 +1,11 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity('commercial_vehicles')
export class CommercialVehicleEntity {
@PrimaryGeneratedColumn() id: number;
@Column({ type: 'varchar', length: 64 }) model: string;
@Column({ type: 'json' }) cargo_space: BoundingBox;
@Column() capacity_weight: number;
@Column() min_cdl_level: number;
@Column({ type: 'boolean', default: false }) requires_trailer: boolean;
}

View File

@@ -0,0 +1,26 @@
import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn, PrimaryGeneratedColumn } from 'typeorm';
import { CompanyEntity } from './company.entity';
import { GovernmentContractEntity } from './government-contract.entity';
@Entity('company_contracts')
export class CompanyContractEntity {
@PrimaryGeneratedColumn() id: number;
@Index()
@Column()
company_id: number;
@Index()
@Column()
contract_id: number;
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) signed: Date;
@Column({ type: 'datetime', nullable: true, default: null }) expiration: Date | null;
@ManyToOne(() => CompanyEntity)
@JoinColumn({ name: 'company_id' })
company?: CompanyEntity;
@ManyToOne(() => GovernmentContractEntity)
@JoinColumn({ name: 'contract_id' })
contract?: GovernmentContractEntity;
}

View File

@@ -0,0 +1,41 @@
import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { CompanyEntity } from './company.entity';
import { CompanyPositionEntity } from './company-position.entity';
@Entity('company_employment_points')
export class CompanyEmploymentPointEntity {
@PrimaryGeneratedColumn() id: number;
@Index()
@Column()
company_id: number;
@Index()
@Column()
company_position_id: number;
@Column({ type: 'int', nullable: true }) blip: number | null;
@Column({ type: 'int', nullable: true }) marker: number | null;
@Column({ type: 'float' }) x: number;
@Column({ type: 'float' }) y: number;
@Column({ type: 'float' }) z: number;
@Column() dimension: number;
@Column({ type: 'varchar', length: 32 }) image: string;
@Column({ type: 'text' }) description: string;
@Column({ type: 'tinyint', default: 0 }) requires_drivers_license: boolean;
@Column({ type: 'tinyint', default: 0 }) requires_cdl: boolean;
@Column({ type: 'varchar', length: 64, nullable: true }) wiki_link_title: string | null;
@ManyToOne(() => CompanyEntity, (company) => company.employmentPoints)
@JoinColumn({ name: 'company_id' })
company: CompanyEntity;
@ManyToOne(() => CompanyPositionEntity)
@JoinColumn({ name: 'company_position_id' })
position: CompanyPositionEntity;
blipEntity?: BlipMp;
markerEntity?: MarkerMp;
colShapeEntity?: ColshapeMp;
}

View File

@@ -0,0 +1,32 @@
import { Column, Entity, JoinColumn, JoinTable, ManyToMany, ManyToOne, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
import { CompanyEntity } from './company.entity';
import { CharacterEntity } from './character.entity';
@Entity('company_positions')
export class CompanyPositionEntity {
@PrimaryGeneratedColumn() id: number;
@Column() company_id: number;
@Column() sort_id: number;
@Column({ type: 'varchar', length: 64 }) name: string;
@Column() modify_power: number;
@Column() needed_modify_power: number;
@Column() hourly_pay: number;
@ManyToOne(() => CompanyEntity, (company) => company.positions)
@JoinColumn({ name: 'company_id' })
company: CompanyEntity;
@ManyToMany(() => CharacterEntity, (character) => character.companyPositions)
@JoinTable({
name: 'character_company_positions',
joinColumn: { name: 'company_position_id' },
inverseJoinColumn: { name: 'character_id' },
})
employees: CharacterEntity[];
permissions: {
id: number;
name: string;
description: string;
}[] = [];
}

View File

@@ -0,0 +1,10 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity('company_types')
export class CompanyTypeEntity {
@PrimaryGeneratedColumn() id: number;
@Column({ type: 'varchar', length: 64 }) name: string;
@Column({ type: 'json' }) suffixes: string[];
@Column({ type: 'int' }) max_members: number;
@Column({ type: 'decimal' }) income_tax: number;
}

View File

@@ -0,0 +1,15 @@
import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { CompanyEntity } from './company.entity';
@Entity('company_uniforms')
export class CompanyUniformEntity {
@PrimaryGeneratedColumn() id: number;
@Column() company_id: number;
@Column({ type: 'int', nullable: true }) position_id: number | null;
@Column({ type: 'varchar', length: 32 }) name: string;
@Column({ type: 'json' }) clothing_items: number[];
@ManyToOne(() => CompanyEntity, (company) => company.uniforms)
@JoinColumn({ name: 'company_id' })
company: CompanyEntity;
}

View File

@@ -0,0 +1,89 @@
import { Column, Entity, JoinColumn, JoinTable, ManyToMany, ManyToOne, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
import { CompanyPositionEntity } from './company-position.entity';
import { CompanyEmploymentPointEntity } from './company-employment-point.entity';
import { ChangingRoomEntity } from './changing-room.entity';
import { CompanyUniformEntity } from './company-uniform.entity';
import { CharacterEntity } from './character.entity';
import { GovernmentContractEntity } from './government-contract.entity';
import { VehicleEntity } from './vehicle.entity';
import { BankAccountEntity } from './bank-account.entity';
import { PhoneEntity } from './phone.entity';
import { VehicleOrderEntity } from './vehicle-order.entity';
@Entity('companies')
export class CompanyEntity {
@PrimaryGeneratedColumn() id: number;
@Column({ type: 'varchar', length: 64 }) name: string;
@Column({ type: 'int' }) type: number;
@Column({ type: 'varchar', length: 64, nullable: true }) stamp: string | null;
@Column({ type: 'int', nullable: true }) owner_character_id: number | null;
@Column({ type: 'tinyint' }) protected: boolean;
@Column({ type: 'varchar', length: 6 }) chat_color: string;
@Column({ type: 'int', default: 3 }) max_on_shift: number;
// type should be type_id so we can add relations to companytypeentity
@OneToMany(() => CompanyPositionEntity, (position) => position.company)
positions: CompanyPositionEntity[];
@OneToMany(() => CompanyEmploymentPointEntity, (point) => point.company)
employmentPoints: CompanyEmploymentPointEntity[];
@OneToMany(() => ChangingRoomEntity, (changingRoom) => changingRoom.company)
changingRooms: ChangingRoomEntity[];
@OneToMany(() => CompanyUniformEntity, (uniform) => uniform.company)
uniforms: CompanyUniformEntity[];
@ManyToMany(() => CharacterEntity, (character) => character.companies)
@JoinTable({
name: 'character_company_positions',
joinColumn: { name: 'company_id' },
inverseJoinColumn: { name: 'character_id' },
})
employees: CharacterEntity[];
@OneToMany(() => VehicleEntity, (vehicle) => vehicle.company)
vehicles: VehicleEntity[];
@OneToMany(() => BankAccountEntity, (bankAccount) => bankAccount.company)
bankAccounts: BankAccountEntity[];
@OneToMany(() => PhoneEntity, (phone) => phone.company)
phones: PhoneEntity[];
@ManyToMany(() => GovernmentContractEntity, (contract) => contract.companies)
@JoinTable({
name: 'company_contracts',
joinColumn: { name: 'company_id' },
inverseJoinColumn: { name: 'contract_id' },
})
contracts: GovernmentContractEntity[];
@ManyToOne(() => CharacterEntity, (character) => character.ownedCompanies)
@JoinColumn({ name: 'owner_character_id' })
owner?: CharacterEntity;
@OneToMany(() => VehicleOrderEntity, (voe) => voe.character)
vehicleOrders: VehicleOrderEntity[];
positionPermissionAccess: {
id: number;
name: string;
description: string;
}[] = [];
getRegNo() {
return String(this.id).padStart(6, '0');
}
getName() {
return this.name ?? `Company #${this.getRegNo()}`;
}
getEntityType(): 'company' {
return 'company';
}
chatDisabled = false;
}

View File

@@ -0,0 +1,22 @@
import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { ContractEntity } from './contract.entity';
@Entity('contract_parties')
export class ContractPartyEntity {
@PrimaryGeneratedColumn() id: number;
@Column() contract_id: number;
@Column() entity_type_id: number;
@Column() entity_id: number;
@Column({ type: 'datetime', nullable: true }) signed: Date | null;
@Column({ type: 'varchar', length: 128, nullable: true }) entity_name: string | null;
@Column({ type: 'varchar', length: 128, nullable: true }) entity_note: string | null;
@Column({ type: 'varchar', length: 64, nullable: true }) stamp: string | null;
@Column({ type: 'varchar', length: 24, nullable: true }) signature: string | null;
@Column({ type: 'varchar', length: 32, nullable: true }) signature_font: string | null;
@Column({ type: 'int', nullable: true }) character_id: number | null;
@Column({ type: 'varchar', length: 64, nullable: false }) name: string;
@ManyToOne(() => ContractEntity, (contract) => contract.parties)
@JoinColumn({ name: 'contract_id' })
contract: ContractEntity;
}

View File

@@ -0,0 +1,9 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity('contract_templates')
export class ContractTemplateEntity {
@PrimaryGeneratedColumn() id: number;
@Column({ type: 'varchar', length: 64 }) key: string;
@Column({ type: 'varchar', length: 128 }) name: string;
@Column({ type: 'longtext' }) markdown: string;
}

View File

@@ -0,0 +1,15 @@
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
import { ContractPartyEntity } from './contract-party.entity';
@Entity('contracts2')
export class ContractEntity {
@PrimaryGeneratedColumn() id: number;
@Column({ type: 'varchar', length: 128 }) name: string;
@Column({ type: 'longtext' }) html: string;
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) created: Date;
@Column({ type: 'datetime', nullable: true }) validated: Date;
@Column({ type: 'datetime', nullable: true }) invalidated: Date;
@OneToMany(() => ContractPartyEntity, (cpe) => cpe.contract)
parties: ContractPartyEntity[];
}

View File

@@ -0,0 +1,33 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
import { ColumnDecimalTransformer } from '../features/utility/ColumnDecimalTransformer';
import { InventoryEntity } from './inventory.entity';
@Entity('corpses')
export class CorpseEntity {
@PrimaryGeneratedColumn() id: number;
@Column({ type: 'int' }) character_id: number;
@Column({ type: 'json' }) appearance: PedCustomization;
@Column({ type: 'json' }) damages: DamageHistory[];
@Column({ type: 'varchar', length: 32 }) model: string;
@Column({ type: 'decimal', precision: 17, scale: 12, transformer: new ColumnDecimalTransformer() }) x: number;
@Column({ type: 'decimal', precision: 17, scale: 12, transformer: new ColumnDecimalTransformer() }) y: number;
@Column({ type: 'decimal', precision: 17, scale: 12, transformer: new ColumnDecimalTransformer() }) z: number;
@Column({ type: 'int', nullable: false }) dimension: number;
@Column({ type: 'boolean', default: false }) at_morgue: boolean;
@Column({ type: 'boolean', default: false }) identified: boolean;
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP()' }) created: Date;
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP()' }) last_interaction: Date;
@Column({ type: 'datetime', nullable: true, default: null }) stored: Date | null;
@Column({ type: 'datetime', nullable: true, default: null }) requested_id: Date | null;
@Column({ type: 'boolean', default: false }) buried: boolean;
@Column({ type: 'datetime', nullable: true, default: null }) buried_date: Date | null;
inventory?: InventoryEntity;
spawned = false;
in_vehicle?: number | undefined;
being_buried = false;
get position(): Vector3 {
return new mp.Vector3(this.x, this.y, this.z);
}
}

View File

@@ -0,0 +1,19 @@
import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { ItemTypeEntity } from './item-type.entity';
@Entity('darknet_entity_access')
export class DarknetEntityAccessEntity {
@PrimaryGeneratedColumn() id: number;
@Column() entity_type_id: number;
@Column() entity_id: number;
@Column() item_type_id: number;
@Column({ default: 1 }) amount: number;
@Column() price: number;
@Column() ooc_price: number;
@Column() created: Date;
@Column() created_by: number;
@ManyToOne(() => ItemTypeEntity, (type) => type.items)
@JoinColumn({ name: 'item_type_id' })
itemType: ItemTypeEntity;
}

View File

@@ -0,0 +1,20 @@
import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { DarknetOrderEntity } from './darknet-order.entity';
import { ItemTypeEntity } from './item-type.entity';
@Entity('darknet_order_items')
export class DarknetOrderItemEntity {
@PrimaryGeneratedColumn() id: number;
@Column() order_id: number;
@Column() item_type_id: number;
@Column() amount: number;
@Column() unit_amount: number;
@ManyToOne(() => DarknetOrderEntity, (order) => order.items)
@JoinColumn({ name: 'order_id' })
order: DarknetOrderEntity;
@ManyToOne(() => ItemTypeEntity, (itemType) => itemType.darknetOrderItems)
@JoinColumn({ name: 'item_type_id' })
itemType: ItemTypeEntity;
}

View File

@@ -0,0 +1,27 @@
import { Column, Entity, JoinColumn, ManyToOne, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
import { DarknetOrderItemEntity } from './darknet-order-item.entity';
import { PropertyEntity } from './property.entity';
import { InventoryEntity } from './inventory.entity';
@Entity('darknet_orders')
export class DarknetOrderEntity {
@PrimaryGeneratedColumn() id: number;
@Column() character_id: number;
@Column() property_id: number;
@Column() inventory_id: number;
@Column() delivery_attempts: number;
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) created: Date;
@Column({ type: 'datetime', nullable: true }) delivered: Date | null;
@Column({ type: 'datetime' }) deliver_after: Date;
@OneToMany(() => DarknetOrderItemEntity, (item) => item.order)
items: DarknetOrderItemEntity[];
@ManyToOne(() => PropertyEntity, (property) => property.darknetOrders)
@JoinColumn({ name: 'property_id' })
property: PropertyEntity;
@ManyToOne(() => InventoryEntity, (inventory) => inventory.darknetOrders)
@JoinColumn({ name: 'inventory_id' })
inventory: InventoryEntity;
}

Some files were not shown because too many files have changed in this diff Show More