Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
40c5884
fix(resource): use installed build utility and narrow style scanning
Scorpion7162 Oct 26, 2026
026f31e
fix(resource): validate and bound callbacks across player sessions
Scorpion7162 Oct 26, 2026
a5944a8
fix(moderation): bind disconnects to player identity and report failures
Scorpion7162 Oct 26, 2026
6b76a60
feat(reports): persist permission-scoped reports and conversations
Scorpion7162 Oct 26, 2026
7c6c42e
feat(moderation): add player actions and live world monitoring
Scorpion7162 Oct 26, 2026
d9fd88b
feat(panel): add report desk and searchable player moderation UI
Scorpion7162 Oct 26, 2026
9d75767
fix(panel): display GTA health with the 100-point offset
Scorpion7162 Oct 26, 2026
d39bc15
docs(resource): document panel controls and permissions
Scorpion7162 Oct 26, 2026
3418bda
fix(resource): resolve client and server typecheck errors
Scorpion7162 Oct 27, 2026
1dca600
chore(resource): include client server and UI in workspace typecheck
Scorpion7162 Oct 27, 2026
f2af0b0
refactor(reports): defer backend and simplify player report UI
Scorpion7162 Oct 27, 2026
377c611
fix(panel): restore full-screen layout and keep quick actions in quic…
Scorpion7162 Oct 27, 2026
d9f649f
feat(players): search online and offline players with connection status
Scorpion7162 Oct 27, 2026
23382e5
feat(players): clarify browsing scopes and offline player details
Scorpion7162 Oct 28, 2026
e26a31c
fix(core/game): game manager test not including abort signal in body
Maximus7474 Nov 10, 2026
0b67596
fix(resource/server): query test - mock node fetch
Maximus7474 Nov 10, 2026
a00316b
fix(resource/server): txadmin compat shutdown event
Maximus7474 Nov 10, 2026
a4c3a72
fix(core/routes/internal): missing return for server ready endpoint
Maximus7474 Nov 10, 2026
f9cb902
chore(resource/client): removed the text based armour value display for
Maximus7474 Nov 10, 2026
7b52b19
refactor(resource/web/panel): use shadcn components for players section
Maximus7474 Nov 10, 2026
7827f29
tweak(resource/web/panel): hover highlight in players section table
Maximus7474 Nov 10, 2026
78313bb
ffix(resource/web/panel): action card overflow issues
Maximus7474 Nov 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ node_modules
.env.test.local
.env.production.local

# AI coding agents
.freebuff
.agents
skills-lock.json

# Testing
coverage

Expand Down
14 changes: 14 additions & 0 deletions apps/core/src/modules/game/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -660,11 +660,25 @@ describe('GameManager', () => {
Application: 'json/application',
'x-resource-token': 'mock-token',
},
signal: AbortSignal.timeout(8000),
},
);
expect(response).toEqual({ success: true, data: null });
});

it('includes the selected license so a reused server ID cannot disconnect another player', async () => {
global.fetch = mock(
async () =>
({ ok: true, json: async () => ({ success: true }) }) as Response,
) as any;
await gameManager.dropPlayer(2, 'Reason', 'license:selected');
const options = (global.fetch as any).mock.calls[0][1];
expect(JSON.parse(options.body)).toMatchObject({
serverId: 2,
license: 'license:selected',
});
});

it('should catch validation error flags gracefully if target router gates declare response failures', async () => {
global.fetch = mock(async () => {
return {
Expand Down
8 changes: 7 additions & 1 deletion apps/core/src/modules/game/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,15 +253,21 @@ export class GameManager {

// region emitting actions

async dropPlayer(serverId: number, reason: string): Promise<ApiResponse> {
async dropPlayer(
serverId: number,
reason: string,
license?: string,
): Promise<ApiResponse> {
try {
const resourceToken = await this.getApiToken();
const endpoint = await getServerNetEndpoint();
const response = await fetch(`http://${endpoint}/fxManager/drop`, {
signal: AbortSignal.timeout(8000),
method: 'POST',
body: JSON.stringify({
serverId,
reason,
license,
}),
headers: {
Application: 'json/application',
Expand Down
10 changes: 10 additions & 0 deletions apps/core/src/routes/internal/ingame.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,16 @@ describe('ingame API integration (HTTP)', () => {
expect(dropPlayer).not.toHaveBeenCalled();
});

it('does not report a successful kick when the disconnect fails', async () => {
dropPlayer.mockResolvedValueOnce({ success: false });
const result = await call('POST', '/kick', { target: 2, reason: 'afk' });
expect(result.statusCode).toBe(409);
expect(mockEmit).not.toHaveBeenCalledWith(
'playerKicked',
expect.anything(),
);
});

it('409s when a longer active ban already exists', async () => {
mockAddBan.mockResolvedValueOnce(false as any);
const res = await call('POST', '/bans', { target: 2, reason: 'x' });
Expand Down
20 changes: 18 additions & 2 deletions apps/core/src/routes/internal/ingame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,12 @@ const IngameEndpoints: RouteModule['handler'] = async (fastify, options) => {
const expiryDate = expiresAt ? expiresAt.toLocaleString() : 'Permanent';
const kickMessage = `You have been banned from the server. Reason: ${body.reason}. Expires: ${expiryDate}`;

if (online) await gm.dropPlayer(online.serverId, kickMessage);
if (online)
await gm.dropPlayer(
online.serverId,
kickMessage,
online.identifiers.license,
);

const profile =
online ?? (await repo.players.findById(resolved.playerId));
Expand Down Expand Up @@ -420,8 +425,19 @@ const IngameEndpoints: RouteModule['handler'] = async (fastify, options) => {
});

const kickMessage = `You have been kicked from the server. Reason: ${body.reason}`;
await gm.dropPlayer(online.serverId, kickMessage);
const dropped = await gm.dropPlayer(
online.serverId,
kickMessage,
online.identifiers.license,
);

if (!dropped.success)
return reply
.code(409)
.send({
message:
'Player could not be disconnected. Refresh the player list and retry.',
});
void txAdminCompat.emit('playerKicked', {
target: online.serverId,
author,
Expand Down
1 change: 1 addition & 0 deletions apps/core/src/routes/internal/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const ServerEndpoints: RouteModule['handler'] = async (fastify, options) => {

fastify.post('/ready', () => {
pm.setFxServerReady();
return { ok: true };
});
};

Expand Down
64 changes: 15 additions & 49 deletions apps/resource/README.md
Original file line number Diff line number Diff line change
@@ -1,62 +1,28 @@
# fivem-typescript-boilerplate
# fxManager in-game resource

A boilerplate for creating FiveM resources with TypeScript.
F6 opens the quick menu. F7 opens the panel. Staff sections follow the configured ACE permissions.

## Getting Started
## Reports

### Node.js v18+
The Reports page is a UI scaffold. Backend integration is deferred while its design is under discussion; submitting reports and replies is unavailable in this branch.

Install any LTS release of [`Node.js`](https://nodejs.org/) from v18.
## Player monitoring

### pnpm
**F6 → Player tags / Player blips** exposes tags and blips. Monitoring requires `fxmanager.game.players`.

Install the [`pnpm`](https://pnpm.io/installation) package manager globally.
Tags appear above other streamed players within 150 metres, not your own character. They show a server ID, name, armour value, health bar, and speaking indicator. Blips distinguish on-foot, vehicle, and dead players in your routing bucket.

```
npm install -g pnpm
```

### Setup

Initialise your own repository by using one of the options below.

- [Create a new repository](https://github.com/new?template_name=fivem-typescript-boilerplate&template_owner=communityox) using this template.
- [Download](https://github.com/communityox/fivem-typescript-boilerplate/archive/refs/heads/main.zip) the template directly.
- Use the [GitHub CLI](https://cli.github.com/).
- `gh repo create <name> --template=communityox/fivem-typescript-boilerplate`

Navigate to your new directory and execute the following command to install dependencies.

```
pnpm install
```
Displayed health subtracts GTA’s 100-point offset and clamps at zero (200 → 100, 150 → 50). The player roster includes health, armour, vehicle state, ping, and routing bucket. The Players page defaults to Online. All players includes online players and up to 50 recent database matches by name or identifier, with online/offline status and last-seen times for offline players. Offline players can be warned or banned; kicks require an online player. Moderation actions still require their individual kick, ban, or warn permissions.

## Development

Use `pnpm watch` to actively rebuild modified files while developing the resource.
From the repository root:

During web development, use `pnpm web:dev` to start vite's webserver and watch for changes.

## Build

Use `pnpm build` to build all project files in production mode.

To build and create GitHub releases, tag your commit (e.g. `v1.0.0`) and push it.

## Events

Custom events follow the naming convention described in [EVENTS.md](EVENTS.md). All
event names are declared as constants in `EVENT_NAMES` (`src/common/types/callbacks.ts`).

## Layout
```powershell
bun install
bun run --cwd apps/resource build
bun run --cwd apps/resource web:dev
```

- [/dist/](dist)
- Compiled project files.
- [/locales/](locales)
- JSON files used for translations with [ox_lib](https://coxdocs.dev/ox_lib/Modules/Locale/Shared).
- [/scripts/](scripts)
- Scripts used in the development process, but not part of the compiled resource.
- [/src/](src)
- Project source code.
- [/static/](static)
- Files to include with the resource that aren't compiled or loaded (e.g. config).
The resource build uses the installed `@overextended/fx-utils`. The core supplies the resource API token and API port. Restart through the manager so those runtime settings are restored correctly.
1 change: 1 addition & 0 deletions apps/resource/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"description": "",
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit -p src/client/tsconfig.json && tsc --noEmit -p src/server/tsconfig.json && tsc --noEmit -p web/tsconfig.json",
"web:dev": "cd web && vite dev",
"build": "bun scripts/build.js",
"deploy": "bun scripts/deploy.js",
Expand Down
2 changes: 1 addition & 1 deletion apps/resource/scripts/build.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//@ts-check

import { exists, exec, getFiles } from './utils.js';
import { createBuilder, createFxmanifest } from '@communityox/fx-utils';
import { createBuilder, createFxmanifest } from '@overextended/fx-utils';

const watch = process.argv.includes('--watch');
const web = await exists('./web');
Expand Down
49 changes: 49 additions & 0 deletions apps/resource/src/client/modules/moderation/blips.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { expect, mock, test } from 'bun:test';
const removed: number[] = [];
const sprites: number[] = [];
let handle = 0;
mock.module('../../utils/nui-callbacks', () => ({ NuiCallback: () => {} }));
mock.module('../../utils/permissions', () => ({
permissions: { has: () => true },
}));
mock.module('../../utils/callbacks', () => ({ ClientCallbacks: {} }));
mock.module('../nui/quickmenu', () => ({ updateMenuStore: () => {} }));
Object.assign(globalThis, {
onNet: () => {},
on: () => {},
PlayerId: () => 0,
GetPlayerServerId: () => 1,
AddBlipForCoord: () => ++handle,
RemoveBlip: (id: number) => removed.push(id),
DoesBlipExist: () => true,
SetBlipSprite: (_id: number, sprite: number) => sprites.push(sprite),
});
for (const name of [
'SetBlipScale',
'SetBlipCategory',
'SetBlipCoords',
'SetBlipColour',
'SetBlipRotation',
'ShowHeadingIndicatorOnBlip',
'BeginTextCommandSetBlipName',
'AddTextComponentSubstringPlayerName',
'EndTextCommandSetBlipName',
])
Object.assign(globalThis, { [name]: () => {} });
const { blipManager } = await import('./blips');
test('vehicle and dead states update, disconnected players and disabled blips are removed', () => {
blipManager.enabled = true;
blipManager.syncBlips({
'2': { username: 'Player', coords: [1, 2, 3], inVehicle: true },
});
expect(sprites.at(-1)).toBe(225);
blipManager.syncBlips({
'2': { username: 'Player', coords: [1, 2, 3], dead: true },
});
expect(sprites.at(-1)).toBe(274);
blipManager.syncBlips({});
expect(removed).toEqual([1]);
blipManager.syncBlips({ '3': { username: 'Other', coords: [1, 2, 3] } });
blipManager.clearBlips();
expect(removed).toEqual([1, 2]);
});
Loading
Loading