Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
131 changes: 131 additions & 0 deletions apps/core/src/modules/game/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,60 @@ const mockIsAnyIdentifierWhitelisted = mock(async () => false);
const mockUpsertPlayer = mock(async () => ({}));
const mockUpdatePlaytime = mock(async () => {});

// Stateful in-memory fake for the player_sessions repo so the join/drop wiring
// can be asserted through real open/close/listSessions behaviour.
let sessionRows: any[] = [];
let nextSessionId = 1;
const mockPlayerSessions = {
open: mock(
(
playerId: number,
serverSessionId: number | null,
connectedAt: Date = new Date(),
) => {
const row = {
id: nextSessionId++,
playerId,
serverSessionId,
connectedAt: connectedAt.getTime(),
disconnectedAt: null as number | null,
durationMs: null as number | null,
endReason: null as string | null,
};
sessionRows.push(row);
return row;
},
),
close: mock(
(
playerId: number,
endReason: string | null = null,
disconnectedAt: Date = new Date(),
) => {
const open = [...sessionRows]
.reverse()
.find((r) => r.playerId === playerId && r.disconnectedAt === null);
if (!open) return null;
open.disconnectedAt = disconnectedAt.getTime();
open.durationMs = Math.max(0, open.disconnectedAt - open.connectedAt);
open.endReason = endReason;
return open;
},
),
closeDangling: mock(() => {}),
listSessions: (playerId: number, page = 1, pageSize = 25) => {
const all = sessionRows
.filter((r) => r.playerId === playerId)
.sort((a, b) => b.connectedAt - a.connectedAt);
return {
items: all.slice((page - 1) * pageSize, (page - 1) * pageSize + pageSize),
total: all.length,
page,
pageSize,
};
},
};

mock.module('@fxmanager/database', () => ({
repo: {
players: {
Expand All @@ -40,6 +94,7 @@ mock.module('@fxmanager/database', () => ({
closeDangling: () => {},
prune: () => {},
},
playerSessions: mockPlayerSessions,
},
}));

Expand Down Expand Up @@ -79,6 +134,13 @@ describe('GameManager', () => {
mockUpsertPlayer.mockReset();
mockUpdatePlaytime.mockReset();

// Reset the stateful player_sessions fake
sessionRows = [];
nextSessionId = 1;
mockPlayerSessions.open.mockClear();
mockPlayerSessions.close.mockClear();
mockPlayerSessions.closeDangling.mockClear();

// Spying on core system managers cleanly isolates state mutations to this file execution context
wsSpy = spyOn(wsManager, 'broadcast').mockImplementation(() => {});

Expand Down Expand Up @@ -387,6 +449,31 @@ describe('GameManager', () => {
expect(gameManager.getPlayerList()).toHaveLength(1);
expect(setPlayerCountSpy).toHaveBeenLastCalledWith(1);
});

it('opens an in-progress player_session on join', async () => {
const dbPlayerPayload = {
id: 77,
name: 'Rex',
playtime: 0,
identifiers: sampleIdentifiers,
isStaff: false,
firstSeen: new Date(),
lastSeen: new Date(),
};
mockUpsertPlayer.mockResolvedValueOnce(dbPlayerPayload);

await gameManager.playerJoin({
name: 'Rex',
identifiers: sampleIdentifiers,
serverId: 3,
});

expect(mockPlayerSessions.open).toHaveBeenCalledWith(77, null);
const { items } = mockPlayerSessions.listSessions(77, 1, 10);
expect(items).toHaveLength(1);
expect(items[0].disconnectedAt).toBeNull();
expect(items[0].durationMs).toBeNull();
});
});

describe('resetPlayerlist()', () => {
Expand Down Expand Up @@ -478,6 +565,50 @@ describe('GameManager', () => {
expect(recordSpy).not.toHaveBeenCalled();
recordSpy.mockRestore();
});

it('closes the open player_session with the drop reason', async () => {
mockPlayerSessions.open(12, null, new Date(Date.now() - 5_000));
(gameManager as any).playerlist.push({
serverId: 9,
id: 12,
name: 'Vader',
playtime: 1000,
identifiers: sampleIdentifiers,
isStaff: false,
firstSeen: new Date(),
lastSeen: new Date(Date.now() - 5_000),
health: 100,
ping: 25,
});

await gameManager.playerDrop(9, { reason: 'Quit', category: 0 });

expect(mockPlayerSessions.close).toHaveBeenCalledWith(12, 'Quit');
const { items } = mockPlayerSessions.listSessions(12, 1, 10);
expect(items[0].disconnectedAt).not.toBeNull();
expect(items[0].durationMs).toBeGreaterThanOrEqual(0);
expect(items[0].endReason).toBe('Quit');
});

it('closes the player_session with a null reason when drop carries no reason', async () => {
mockPlayerSessions.open(12, null, new Date(Date.now() - 5_000));
(gameManager as any).playerlist.push({
serverId: 9,
id: 12,
name: 'Vader',
playtime: 1000,
identifiers: sampleIdentifiers,
isStaff: false,
firstSeen: new Date(),
lastSeen: new Date(Date.now() - 5_000),
health: 100,
ping: 25,
});

await gameManager.playerDrop(9);

expect(mockPlayerSessions.close).toHaveBeenCalledWith(12, null);
});
});

describe('playerUpdates()', () => {
Expand Down
8 changes: 8 additions & 0 deletions apps/core/src/modules/game/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ export class GameManager {

if (player.isStaff) aceSync.refresh();

repo.playerSessions.open(player.id, sessionManager.getCurrentId());

const playerPayload = {
serverId,
health: -1,
Expand Down Expand Up @@ -216,6 +218,12 @@ export class GameManager {
const newPlaytime = player.playtime + sessionDuration;

repo.players.updatePlaytime(player.id, newPlaytime);

const endReason =
typeof drop?.reason === 'string' && drop.reason.length > 0
? drop.reason
: null;
repo.playerSessions.close(player.id, endReason);
wsManager.broadcast<{ serverId: number }>({
channel: 'playerlist',
event: 'player_left',
Expand Down
18 changes: 18 additions & 0 deletions apps/core/src/modules/session/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const mockClose = mock(() => closedStub());
const mockCloseDangling = mock(() => {});
const mockPrune = mock(() => {});
const mockListRecent = mock(() => [closedStub()]);
const mockPlayerCloseDangling = mock(() => {});

mock.module('@fxmanager/database', () => ({
repo: {
Expand All @@ -38,6 +39,9 @@ mock.module('@fxmanager/database', () => ({
prune: mockPrune,
listRecent: mockListRecent,
},
playerSessions: {
closeDangling: mockPlayerCloseDangling,
},
},
}));

Expand All @@ -55,6 +59,7 @@ describe('sessionManager', () => {
mockCloseDangling.mockReset();
mockPrune.mockReset();
mockListRecent.mockReset().mockReturnValue([closedStub()]);
mockPlayerCloseDangling.mockReset();
wsSpy.mockClear();
});

Expand All @@ -67,6 +72,19 @@ describe('sessionManager', () => {
expect(mockCloseDangling).toHaveBeenCalledTimes(1);
});

it('init reconciles dangling player sessions after server sessions', () => {
const order: string[] = [];
mockCloseDangling.mockImplementation(() => {
order.push('server');
});
mockPlayerCloseDangling.mockImplementation(() => {
order.push('player');
});
sessionManager.init();
expect(mockPlayerCloseDangling).toHaveBeenCalledTimes(1);
expect(order).toEqual(['server', 'player']);
});

it('openSession opens, caches, and does not double-open', () => {
const s = sessionManager.openSession();
expect(s.id).toBe(7);
Expand Down
1 change: 1 addition & 0 deletions apps/core/src/modules/session/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class SessionManager {

init(): void {
repo.serverSessions.closeDangling();
repo.playerSessions.closeDangling();
}

openSession(): ServerSession {
Expand Down
155 changes: 155 additions & 0 deletions apps/core/src/routes/api/players.activity.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/** biome-ignore-all lint/suspicious/noExplicitAny: fakes for gm/repo/admin are cast to satisfy handler options */
import { beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test';
import Fastify, { type FastifyInstance } from 'fastify';

let currentAdmin = { id: 1, username: 'Tester', permissions: 0 };
let authed = true;

const activityPayload = {
from: '2026-06-06',
to: '2026-07-05',
days: [
{ date: '2026-07-01', playtimeMs: 3_600_000, sessionCount: 2 },
{ date: '2026-07-03', playtimeMs: 1_800_000, sessionCount: 1 },
],
summary: {
daysActive: 2,
totalPlaytimeMs: 5_400_000,
longestSessionMs: 3_600_000,
avgSessionMs: 1_800_000,
},
};

const sessionsPage = {
items: [
{
id: 3,
connectedAt: 3000,
disconnectedAt: 4000,
durationMs: 1000,
endReason: 'quit',
},
{
id: 2,
connectedAt: 2000,
disconnectedAt: 2500,
durationMs: 500,
endReason: 'crash',
},
],
total: 3,
page: 1,
pageSize: 2,
};

const mockGetRangeActivity = mock(
(_playerId: number, _from: Date, _to: Date) => activityPayload,
);
const mockListSessions = mock(
(_playerId: number, _page: number, _pageSize: number) => sessionsPage,
);

const fakeRepo = {
playerSessions: {
getRangeActivity: mockGetRangeActivity,
listSessions: mockListSessions,
},
};

mock.module('@fxmanager/database', () => ({ repo: fakeRepo }));
mock.module('../../middleware/session', () => ({
sessionAuth: async (req: any, reply: any) => {
if (!authed) {
return reply.code(401).send({ success: false, error: 'Unauthorized' });
}
req.admin = currentAdmin;
},
}));

const { default: PlayersModule } = await import('./players');

const fakeGm = { getPlayer: () => undefined } as any;

const DAY_MS = 86_400_000;

describe('players activity + sessions endpoints (HTTP)', () => {
let app: FastifyInstance;

const get = (url: string) => app.inject({ method: 'GET', url });

beforeAll(async () => {
app = Fastify();
await app.register(PlayersModule.handler, {
prefix: '/players',
gm: fakeGm,
} as any);
await app.ready();
});

beforeEach(() => {
currentAdmin = { id: 1, username: 'Tester', permissions: 0 };
authed = true;
mockGetRangeActivity.mockClear();
mockListSessions.mockClear();
});

it('GET /:id/activity returns days + summary over a default ~30-day window', async () => {
const res = await get('/players/10/activity');

expect(res.statusCode).toBe(200);
expect(res.json()).toMatchObject({ success: true, data: activityPayload });

expect(mockGetRangeActivity).toHaveBeenCalledTimes(1);
const call = mockGetRangeActivity.mock.calls[0];
expect(call?.[0]).toBe(10);
const from = call?.[1] as Date;
const to = call?.[2] as Date;
expect(from).toBeInstanceOf(Date);
expect(to).toBeInstanceOf(Date);
// default window spans 30 calendar days (29 full days + end-of-today)
expect(Math.round((to.getTime() - from.getTime()) / DAY_MS)).toBe(30);
});

it('GET /:id/activity?from&to honours the explicit range', async () => {
const res = await get('/players/10/activity?from=2026-06-01&to=2026-06-30');

expect(res.statusCode).toBe(200);
const call = mockGetRangeActivity.mock.calls[0];
expect(call?.[0]).toBe(10);
const from = call?.[1] as Date;
const to = call?.[2] as Date;
expect(from.getTime()).toBe(new Date('2026-06-01T00:00:00.000').getTime());
expect(to.getTime()).toBe(new Date('2026-06-30T23:59:59.999').getTime());
});

it('GET /:id/sessions paginates with explicit page/pageSize', async () => {
const res = await get('/players/10/sessions?page=1&pageSize=2');

expect(res.statusCode).toBe(200);
expect(res.json()).toMatchObject(sessionsPage);
expect(mockListSessions).toHaveBeenCalledWith(10, 1, 2);
});

it('GET /:id/sessions defaults to page 1, pageSize 25', async () => {
const res = await get('/players/10/sessions');

expect(res.statusCode).toBe(200);
expect(mockListSessions).toHaveBeenCalledWith(10, 1, 25);
});

it('requires auth for activity (401 without a session)', async () => {
authed = false;
const res = await get('/players/10/activity');

expect(res.statusCode).toBe(401);
expect(mockGetRangeActivity).not.toHaveBeenCalled();
});

it('requires auth for sessions (401 without a session)', async () => {
authed = false;
const res = await get('/players/10/sessions');

expect(res.statusCode).toBe(401);
expect(mockListSessions).not.toHaveBeenCalled();
});
});
Loading
Loading