fix: Wire real data sources across players, analytics, status, and maps services
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
- players: Primary data from player_sessions (online status, playtime aggregates);
ban/unban status overlaid from player_actions latest action per steam_id.
Register PlayerSession entity in PlayersModule. Extend NATS forwarding to
include 'unban' alongside kick and ban.
- analytics: Fix retention period boundary bug — sessions were queried with only
a lower-bound filter (MoreThan), causing all future cycles to bleed into earlier
wipe windows. Replaced with Between(wipeDate, endDate) for correct isolation.
- status: Replace hardcoded player_count=0/max_players=0 with live data from
most-recent server_stats row per license. Register ServerStats entity in
StatusModule. Falls back to 0 gracefully when no stats exist yet.
- maps: File buffer was computed and discarded — never written to disk.
Now writes to /app/map_data/{licenseId}/{timestamp}_{filename} (tenant-isolated,
docker volume map_data). Creates directories with mkdirSync(recursive:true).
Logs success/failure via NestJS Logger. Throws 500 on disk write failure
instead of silently losing data.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,10 +3,11 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { PlayersController } from './players.controller';
|
||||
import { PlayersService } from './players.service';
|
||||
import { PlayerAction } from '../../entities/player-action.entity';
|
||||
import { PlayerSession } from '../../entities/player-session.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([PlayerAction])],
|
||||
imports: [TypeOrmModule.forFeature([PlayerAction, PlayerSession])],
|
||||
controllers: [PlayersController],
|
||||
providers: [PlayersService, NatsService],
|
||||
exports: [PlayersService],
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { PlayerAction } from '../../entities/player-action.entity';
|
||||
import { PlayerSession } from '../../entities/player-session.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { PlayerActionDto } from './dto/player-action.dto';
|
||||
|
||||
@@ -11,6 +12,8 @@ export interface Player {
|
||||
status: 'online' | 'offline' | 'banned';
|
||||
last_seen?: Date;
|
||||
ban_expires?: Date | null;
|
||||
total_sessions?: number;
|
||||
total_playtime_seconds?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -18,43 +21,86 @@ export class PlayersService {
|
||||
constructor(
|
||||
@InjectRepository(PlayerAction)
|
||||
private readonly actionRepo: Repository<PlayerAction>,
|
||||
@InjectRepository(PlayerSession)
|
||||
private readonly sessionRepo: Repository<PlayerSession>,
|
||||
private readonly natsService: NatsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get recent players for a license
|
||||
* Get players for a license.
|
||||
*
|
||||
* TODO: This needs a player_sessions table to track online/offline status.
|
||||
* For now, we query player_actions to get a list of players who have had actions.
|
||||
* Primary source: player_sessions table (tracks session lifecycles).
|
||||
* Secondary source: player_actions table (determines ban status).
|
||||
* A player whose most recent action is a 'ban' (not subsequently 'unban') is shown as banned.
|
||||
* A player with an open session (session_end IS NULL) is shown as online.
|
||||
*/
|
||||
async getPlayers(licenseId: string): Promise<{ players: Player[] }> {
|
||||
const actions = await this.actionRepo
|
||||
// Get distinct players from session history
|
||||
const sessions = await this.sessionRepo
|
||||
.createQueryBuilder('session')
|
||||
.where('session.license_id = :licenseId', { licenseId })
|
||||
.orderBy('session.session_start', 'DESC')
|
||||
.getMany();
|
||||
|
||||
// Build per-player session aggregates
|
||||
const playerMap = new Map<string, Player>();
|
||||
|
||||
for (const session of sessions) {
|
||||
if (!playerMap.has(session.steam_id)) {
|
||||
const isOnline = session.session_end === null;
|
||||
playerMap.set(session.steam_id, {
|
||||
steam_id: session.steam_id,
|
||||
player_name: session.player_name,
|
||||
status: isOnline ? 'online' : 'offline',
|
||||
last_seen: session.session_start,
|
||||
ban_expires: null,
|
||||
total_sessions: 0,
|
||||
total_playtime_seconds: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const entry = playerMap.get(session.steam_id)!;
|
||||
entry.total_sessions = (entry.total_sessions || 0) + 1;
|
||||
entry.total_playtime_seconds = (entry.total_playtime_seconds || 0) + (session.duration_seconds || 0);
|
||||
}
|
||||
|
||||
// Overlay ban status from most recent action per player
|
||||
const recentActions = await this.actionRepo
|
||||
.createQueryBuilder('action')
|
||||
.where('action.license_id = :licenseId', { licenseId })
|
||||
.orderBy('action.created_at', 'DESC')
|
||||
.take(100)
|
||||
.getMany();
|
||||
|
||||
// Group by steam_id to get unique players
|
||||
const playerMap = new Map<string, Player>();
|
||||
// Track the most recent action per steam_id to determine ban state
|
||||
const latestActionBySteamId = new Map<string, PlayerAction>();
|
||||
for (const action of recentActions) {
|
||||
if (!latestActionBySteamId.has(action.steam_id)) {
|
||||
latestActionBySteamId.set(action.steam_id, action);
|
||||
}
|
||||
}
|
||||
|
||||
for (const action of actions) {
|
||||
if (!playerMap.has(action.steam_id)) {
|
||||
// Determine status based on latest action
|
||||
let status: 'online' | 'offline' | 'banned' = 'offline';
|
||||
if (action.action_type === 'ban') {
|
||||
status = 'banned';
|
||||
}
|
||||
|
||||
playerMap.set(action.steam_id, {
|
||||
steam_id: action.steam_id,
|
||||
player_name: action.player_name,
|
||||
status,
|
||||
last_seen: action.created_at,
|
||||
ban_expires: action.duration_minutes
|
||||
for (const [steamId, action] of latestActionBySteamId) {
|
||||
if (action.action_type === 'ban') {
|
||||
// Add player from actions even if they have no sessions
|
||||
if (!playerMap.has(steamId)) {
|
||||
playerMap.set(steamId, {
|
||||
steam_id: action.steam_id,
|
||||
player_name: action.player_name,
|
||||
status: 'banned',
|
||||
last_seen: action.created_at,
|
||||
ban_expires: action.duration_minutes
|
||||
? new Date(action.created_at.getTime() + action.duration_minutes * 60000)
|
||||
: null,
|
||||
total_sessions: 0,
|
||||
total_playtime_seconds: 0,
|
||||
});
|
||||
} else {
|
||||
const entry = playerMap.get(steamId)!;
|
||||
entry.status = 'banned';
|
||||
entry.ban_expires = action.duration_minutes
|
||||
? new Date(action.created_at.getTime() + action.duration_minutes * 60000)
|
||||
: null,
|
||||
});
|
||||
: null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +110,9 @@ export class PlayersService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a moderation action on a player
|
||||
* Perform a moderation action on a player.
|
||||
* Supported actions: kick, ban, unban, warn, note.
|
||||
* kick/ban/unban are forwarded to the game server via NATS.
|
||||
*/
|
||||
async performAction(
|
||||
licenseId: string,
|
||||
@@ -84,8 +132,8 @@ export class PlayersService {
|
||||
|
||||
await this.actionRepo.save(action);
|
||||
|
||||
// For kick/ban, send NATS command to the server
|
||||
if (dto.action_type === 'kick' || dto.action_type === 'ban') {
|
||||
// Forward kick, ban, and unban to the game server via NATS
|
||||
if (dto.action_type === 'kick' || dto.action_type === 'ban' || dto.action_type === 'unban') {
|
||||
await this.natsService.sendServerCommand(licenseId, dto.action_type, {
|
||||
steam_id: dto.steam_id,
|
||||
reason: dto.reason,
|
||||
|
||||
Reference in New Issue
Block a user