import { randomUUID } from 'node:crypto'; import { ConflictException, Inject, Injectable, } from '@nestjs/common'; import { Queue } from 'bullmq'; import type Redis from 'ioredis'; import type { CreateServerRequest, ServerActionResponse, ServerListResponse, ServerResponse, } from '@hexahost/contracts'; import type { GameServer } from '@hexahost/database'; type GameServerStatus = GameServer['status']; import { PrismaService } from '../prisma/prisma.service'; import { findOwnedServer } from './shared/server-ownership.util'; import { ServerStateService } from './server-state.service'; export const REDIS_CLIENT = Symbol('SERVERS_REDIS_CLIENT'); export const SERVER_LIFECYCLE_QUEUE = Symbol('SERVER_LIFECYCLE_QUEUE'); export const SERVER_PROVISIONING_QUEUE = Symbol('SERVER_PROVISIONING_QUEUE'); export const NODE_COMMANDS_CHANNEL = 'hgc:node:commands' as const; @Injectable() export class ServersService { constructor( private readonly prisma: PrismaService, private readonly serverState: ServerStateService, @Inject(REDIS_CLIENT) private readonly redis: Redis, @Inject(SERVER_LIFECYCLE_QUEUE) private readonly lifecycleQueue: Queue, @Inject(SERVER_PROVISIONING_QUEUE) private readonly provisioningQueue: Queue, ) {} async createServer( userId: string, input: CreateServerRequest, ): Promise { const server = await this.prisma.gameServer.create({ data: { userId, name: input.name, edition: input.edition, softwareFamily: input.softwareFamily, minecraftVersion: input.minecraftVersion, eulaAccepted: input.eulaAccepted, ramMb: input.ramMb ?? 1024, planId: input.planId, status: 'DRAFT', }, }); await this.recordTransition(server.id, null, 'DRAFT', 'created', userId); return this.toResponse(server); } async listServers(userId: string): Promise { const servers = await this.prisma.gameServer.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, }); return { servers: servers.map((server) => this.toResponse(server)), }; } async getServer(userId: string, serverId: string): Promise { const server = await findOwnedServer(this.prisma, userId, serverId); return this.toResponse(server); } async restartServer( userId: string, serverId: string, skipStop = false, ): Promise { const server = await findOwnedServer(this.prisma, userId, serverId); this.serverState.assertNotInProgress(server.status); if (!skipStop && server.status === 'RUNNING') { await this.stopServer(userId, serverId); } return this.startServer(userId, serverId); } async startServer( userId: string, serverId: string, ): Promise { const server = await findOwnedServer(this.prisma, userId, serverId); this.serverState.assertNotInProgress(server.status); const targetStatus = this.serverState.resolveStartTarget(server.status); this.serverState.assertTransition(server.status, targetStatus); const correlationId = randomUUID(); const updated = await this.transitionServer( server, targetStatus, 'start requested', userId, correlationId, ); let jobId: string | undefined; if (targetStatus === 'PROVISIONING') { const job = await this.provisioningQueue.add( 'provision-server', { serverId, correlationId }, { jobId: correlationId }, ); jobId = job.id; } else { const job = await this.lifecycleQueue.add( 'start-server', { serverId, action: 'start', correlationId }, { jobId: correlationId }, ); jobId = job.id; if (updated.nodeId) { await this.publishNodeCommand(updated.nodeId, { protocolVersion: 1, messageId: randomUUID(), correlationId, type: 'server.start', timestamp: new Date().toISOString(), payload: { serverId, generation: updated.version, desiredGeneration: updated.version, }, }); } } return { server: this.toResponse(updated), jobId, }; } async stopServer( userId: string, serverId: string, ): Promise { const server = await findOwnedServer(this.prisma, userId, serverId); this.serverState.assertNotInProgress(server.status); const targetStatus = this.serverState.resolveStopTarget(server.status); this.serverState.assertTransition(server.status, targetStatus); const correlationId = randomUUID(); const updated = await this.transitionServer( server, targetStatus, 'stop requested', userId, correlationId, ); const job = await this.lifecycleQueue.add( 'stop-server', { serverId, action: 'stop', correlationId }, { jobId: correlationId }, ); if (updated.nodeId) { await this.publishNodeCommand(updated.nodeId, { protocolVersion: 1, messageId: randomUUID(), correlationId, type: 'server.stop', timestamp: new Date().toISOString(), payload: { serverId, generation: updated.version, }, }); } return { server: this.toResponse(updated), jobId: job.id, }; } async publishNodeCommand( nodeId: string, envelope: Record, ): Promise { await this.redis.publish( NODE_COMMANDS_CHANNEL, JSON.stringify({ nodeId, envelope }), ); } async applyAgentStatus( serverId: string, nextStatus: GameServerStatus, metadata?: { correlationId?: string; reason?: string; errorCode?: string; errorMessage?: string; }, ): Promise { const server = await this.prisma.gameServer.findUnique({ where: { id: serverId }, }); if (!server || server.status === nextStatus) { return; } this.serverState.assertTransition(server.status, nextStatus); await this.transitionServer( server, nextStatus, metadata?.reason ?? 'agent reported state', null, metadata?.correlationId, metadata?.errorCode, metadata?.errorMessage, ); } private async transitionServer( server: GameServer, toStatus: GameServerStatus, reason: string, actorId: string | null, correlationId?: string, errorCode?: string, errorMessage?: string, ): Promise { if (server.status === toStatus) { throw new ConflictException('Server is already in the requested state'); } const updated = await this.prisma.gameServer.update({ where: { id: server.id }, data: { status: toStatus, version: { increment: 1 }, }, }); await this.recordTransition( server.id, server.status, toStatus, reason, actorId, correlationId, errorCode, errorMessage, ); return updated; } private async recordTransition( gameServerId: string, fromStatus: GameServerStatus | null, toStatus: GameServerStatus, reason: string, actorId: string | null, correlationId?: string, errorCode?: string, errorMessage?: string, ): Promise { await this.prisma.gameServerStateTransition.create({ data: { gameServerId, fromStatus, toStatus, reason, actorId, correlationId, errorCode, errorMessage, }, }); } private toResponse(server: GameServer): ServerResponse { return { id: server.id, userId: server.userId, planId: server.planId, nodeId: server.nodeId, name: server.name, status: server.status, version: server.version, edition: server.edition, softwareFamily: server.softwareFamily, minecraftVersion: server.minecraftVersion, eulaAccepted: server.eulaAccepted, hostPort: server.hostPort, containerId: server.containerId, dataPath: server.dataPath, activeWorldName: server.activeWorldName, ramMb: server.ramMb, createdAt: server.createdAt.toISOString(), updatedAt: server.updatedAt.toISOString(), }; } }