feat: complete tournament API lifecycle
This commit is contained in:
@@ -4,18 +4,10 @@ import path from 'node:path';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
createGamePostgresConnector,
|
||||
resolvePostgresConfigFromEnv,
|
||||
type GatewayPrisma,
|
||||
} from '@sammo-ts/infra';
|
||||
import { createGamePostgresConnector, resolvePostgresConfigFromEnv, type GatewayPrisma } from '@sammo-ts/infra';
|
||||
|
||||
import { procedure, router } from './trpc.js';
|
||||
import {
|
||||
listScenarioPreviews,
|
||||
resolveGitBranchCommitSha,
|
||||
resolveGitCommitSha,
|
||||
} from './scenario/scenarioCatalog.js';
|
||||
import { listScenarioPreviews, resolveGitBranchCommitSha, resolveGitCommitSha } from './scenario/scenarioCatalog.js';
|
||||
import type { UserSanctions, UserServerRestriction } from './auth/userRepository.js';
|
||||
import { toPublicUser } from './auth/userRepository.js';
|
||||
import type { AdminAuthContext } from './adminAuth.js';
|
||||
@@ -40,15 +32,7 @@ const zServerAction = z.enum([
|
||||
]);
|
||||
|
||||
const TURN_TERM_MINUTES = [1, 2, 5, 10, 20, 30, 60, 120] as const;
|
||||
const AUTORUN_USER_OPTIONS = [
|
||||
'develop',
|
||||
'warp',
|
||||
'recruit',
|
||||
'recruit_high',
|
||||
'train',
|
||||
'battle',
|
||||
'chief',
|
||||
] as const;
|
||||
const AUTORUN_USER_OPTIONS = ['develop', 'warp', 'recruit', 'recruit_high', 'train', 'battle', 'chief'] as const;
|
||||
|
||||
const ADMIN_ROLE_PREFIX = 'admin.';
|
||||
const ADMIN_ROLE_SUPERUSER = 'admin.superuser';
|
||||
@@ -254,9 +238,12 @@ const isUniqueConstraintError = (error: unknown): boolean =>
|
||||
|
||||
const zInstallOptions = z.object({
|
||||
scenarioId: z.number().int().min(0),
|
||||
turnTermMinutes: z.number().int().refine((value) => isAllowedTurnTerm(value), {
|
||||
message: 'turnTermMinutes must divide 120.',
|
||||
}),
|
||||
turnTermMinutes: z
|
||||
.number()
|
||||
.int()
|
||||
.refine((value) => isAllowedTurnTerm(value), {
|
||||
message: 'turnTermMinutes must divide 120.',
|
||||
}),
|
||||
sync: z.boolean(),
|
||||
fiction: z.number().int().min(0).max(1),
|
||||
extend: z.boolean(),
|
||||
@@ -771,55 +758,51 @@ export const adminRouter = router({
|
||||
});
|
||||
}
|
||||
}),
|
||||
cancel: adminProcedure
|
||||
.input(z.object({ id: z.string().uuid() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const adminAuth = requireAdminAuth(ctx);
|
||||
const previous = await ctx.profiles.getOperation(input.id);
|
||||
if (!previous) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Operation not found.' });
|
||||
}
|
||||
assertPermission(adminAuth, ROLE_ADMIN_PROFILES, previous.profileName);
|
||||
const cancelled = await ctx.profiles.cancelOperation(input.id);
|
||||
if (!cancelled) {
|
||||
cancel: adminProcedure.input(z.object({ id: z.string().uuid() })).mutation(async ({ ctx, input }) => {
|
||||
const adminAuth = requireAdminAuth(ctx);
|
||||
const previous = await ctx.profiles.getOperation(input.id);
|
||||
if (!previous) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Operation not found.' });
|
||||
}
|
||||
assertPermission(adminAuth, ROLE_ADMIN_PROFILES, previous.profileName);
|
||||
const cancelled = await ctx.profiles.cancelOperation(input.id);
|
||||
if (!cancelled) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'Only queued operations can be cancelled.',
|
||||
});
|
||||
}
|
||||
return { ok: true };
|
||||
}),
|
||||
retry: adminProcedure.input(z.object({ id: z.string().uuid() })).mutation(async ({ ctx, input }) => {
|
||||
const adminAuth = requireAdminAuth(ctx);
|
||||
const previous = await ctx.profiles.getOperation(input.id);
|
||||
if (!previous) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Operation not found.' });
|
||||
}
|
||||
assertPermission(adminAuth, ROLE_ADMIN_PROFILES, previous.profileName);
|
||||
try {
|
||||
const operation = await ctx.profiles.retryOperation(input.id, adminAuth.user.id);
|
||||
if (!operation) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'Only queued operations can be cancelled.',
|
||||
message: 'Only failed or cancelled operations can be retried.',
|
||||
});
|
||||
}
|
||||
return { ok: true };
|
||||
}),
|
||||
retry: adminProcedure
|
||||
.input(z.object({ id: z.string().uuid() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const adminAuth = requireAdminAuth(ctx);
|
||||
const previous = await ctx.profiles.getOperation(input.id);
|
||||
if (!previous) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Operation not found.' });
|
||||
return operation;
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) {
|
||||
throw error;
|
||||
}
|
||||
assertPermission(adminAuth, ROLE_ADMIN_PROFILES, previous.profileName);
|
||||
try {
|
||||
const operation = await ctx.profiles.retryOperation(input.id, adminAuth.user.id);
|
||||
if (!operation) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'Only failed or cancelled operations can be retried.',
|
||||
});
|
||||
}
|
||||
return operation;
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) {
|
||||
throw error;
|
||||
}
|
||||
if (!isUniqueConstraintError(error)) {
|
||||
throw error;
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'This profile already has a queued or running operation.',
|
||||
});
|
||||
if (!isUniqueConstraintError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'This profile already has a queued or running operation.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
}),
|
||||
profiles: router({
|
||||
list: adminProcedure.query(async ({ ctx }) => {
|
||||
@@ -834,6 +817,7 @@ export const adminRouter = router({
|
||||
profileName: profile.profileName,
|
||||
apiRunning: false,
|
||||
daemonRunning: false,
|
||||
tournamentRunning: false,
|
||||
},
|
||||
}));
|
||||
}),
|
||||
|
||||
@@ -26,6 +26,7 @@ export type LobbyProfileStatus = {
|
||||
runtime: {
|
||||
apiRunning: boolean;
|
||||
daemonRunning: boolean;
|
||||
tournamentRunning: boolean;
|
||||
};
|
||||
korName: string;
|
||||
color: string;
|
||||
@@ -68,7 +69,7 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
|
||||
|
||||
private mapProfile(
|
||||
row: GatewayProfileRecord,
|
||||
runtimeMap: Map<string, { apiRunning: boolean; daemonRunning: boolean }>
|
||||
runtimeMap: Map<string, { apiRunning: boolean; daemonRunning: boolean; tournamentRunning: boolean }>
|
||||
): LobbyProfileStatus {
|
||||
const meta = row.meta;
|
||||
return {
|
||||
@@ -80,6 +81,7 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
|
||||
runtime: runtimeMap.get(row.profileName) ?? {
|
||||
apiRunning: false,
|
||||
daemonRunning: false,
|
||||
tournamentRunning: false,
|
||||
},
|
||||
korName: (meta.korName as string | undefined) ?? row.profile,
|
||||
color: (meta.color as string | undefined) ?? '#ffffff',
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface GatewayOrchestratorOptions {
|
||||
export interface ProfileRuntimeState {
|
||||
apiRunning: boolean;
|
||||
daemonRunning: boolean;
|
||||
tournamentRunning: boolean;
|
||||
}
|
||||
|
||||
export interface ProfileRuntimeSnapshot extends ProfileRuntimeState {
|
||||
@@ -65,13 +66,13 @@ export const planProfileReconcile = (
|
||||
): { shouldStart: boolean; shouldStop: boolean } => {
|
||||
if (status === 'RUNNING' || status === 'PREOPEN' || status === 'PAUSED' || status === 'COMPLETED') {
|
||||
return {
|
||||
shouldStart: !(runtime.apiRunning && runtime.daemonRunning),
|
||||
shouldStart: !(runtime.apiRunning && runtime.daemonRunning && runtime.tournamentRunning),
|
||||
shouldStop: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
shouldStart: false,
|
||||
shouldStop: runtime.apiRunning || runtime.daemonRunning,
|
||||
shouldStop: runtime.apiRunning || runtime.daemonRunning || runtime.tournamentRunning,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -197,14 +198,18 @@ const parseInstallOptions = (
|
||||
: undefined;
|
||||
const sync = typeof install.sync === 'boolean' ? install.sync : undefined;
|
||||
const fiction =
|
||||
typeof install.fiction === 'number' && Number.isFinite(install.fiction) ? Math.floor(install.fiction) : undefined;
|
||||
typeof install.fiction === 'number' && Number.isFinite(install.fiction)
|
||||
? Math.floor(install.fiction)
|
||||
: undefined;
|
||||
const extend = typeof install.extend === 'boolean' ? install.extend : undefined;
|
||||
const blockGeneralCreate =
|
||||
typeof install.blockGeneralCreate === 'number' && Number.isFinite(install.blockGeneralCreate)
|
||||
? Math.floor(install.blockGeneralCreate)
|
||||
: undefined;
|
||||
const npcMode =
|
||||
typeof install.npcMode === 'number' && Number.isFinite(install.npcMode) ? Math.floor(install.npcMode) : undefined;
|
||||
typeof install.npcMode === 'number' && Number.isFinite(install.npcMode)
|
||||
? Math.floor(install.npcMode)
|
||||
: undefined;
|
||||
const showImgLevel =
|
||||
typeof install.showImgLevel === 'number' && Number.isFinite(install.showImgLevel)
|
||||
? Math.floor(install.showImgLevel)
|
||||
@@ -241,9 +246,7 @@ const parseInstallOptions = (
|
||||
? install.adminUser.username
|
||||
: install.adminUser.id,
|
||||
displayName:
|
||||
typeof install.adminUser.displayName === 'string'
|
||||
? install.adminUser.displayName
|
||||
: undefined,
|
||||
typeof install.adminUser.displayName === 'string' ? install.adminUser.displayName : undefined,
|
||||
}
|
||||
: null;
|
||||
|
||||
@@ -270,8 +273,8 @@ const parseInstallOptions = (
|
||||
};
|
||||
};
|
||||
|
||||
const buildProcessName = (profileName: string, role: 'api' | 'daemon'): string =>
|
||||
`sammo:${profileName}:${role === 'api' ? 'game-api' : 'turn-daemon'}`;
|
||||
const buildProcessName = (profileName: string, role: 'api' | 'daemon' | 'tournament'): string =>
|
||||
`sammo:${profileName}:${role === 'api' ? 'game-api' : role === 'daemon' ? 'turn-daemon' : 'tournament-worker'}`;
|
||||
|
||||
const isMissingProcessError = (error: unknown): boolean =>
|
||||
error instanceof Error && /process or namespace not found/i.test(error.message);
|
||||
@@ -282,10 +285,12 @@ export const buildProcessDefinitions = (
|
||||
): {
|
||||
api: { name: string; script: string; cwd: string; env: Record<string, string> };
|
||||
daemon: { name: string; script: string; cwd: string; env: Record<string, string> };
|
||||
tournament: { name: string; script: string; cwd: string; env: Record<string, string> };
|
||||
} => {
|
||||
const baseEnv = { ...(config.baseEnv ?? {}) };
|
||||
const apiName = buildProcessName(profile.profileName, 'api');
|
||||
const daemonName = buildProcessName(profile.profileName, 'daemon');
|
||||
const tournamentName = buildProcessName(profile.profileName, 'tournament');
|
||||
const runtimeWorkspace = profile.buildWorkspace ?? config.workspaceRoot;
|
||||
const apiCwd = path.join(runtimeWorkspace, 'app', 'game-api');
|
||||
const daemonCwd = path.join(runtimeWorkspace, 'app', 'game-engine');
|
||||
@@ -322,6 +327,15 @@ export const buildProcessDefinitions = (
|
||||
cwd: daemonCwd,
|
||||
env: daemonEnv,
|
||||
},
|
||||
tournament: {
|
||||
name: tournamentName,
|
||||
script: apiScript,
|
||||
cwd: apiCwd,
|
||||
env: {
|
||||
...apiEnv,
|
||||
GAME_API_ROLE: 'tournament-worker',
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -362,10 +376,12 @@ const mapRuntimeStates = (profileNames: string[], processNames: Map<string, bool
|
||||
profileNames.map((profileName) => {
|
||||
const apiName = buildProcessName(profileName, 'api');
|
||||
const daemonName = buildProcessName(profileName, 'daemon');
|
||||
const tournamentName = buildProcessName(profileName, 'tournament');
|
||||
return {
|
||||
profileName,
|
||||
apiRunning: processNames.get(apiName) ?? false,
|
||||
daemonRunning: processNames.get(daemonName) ?? false,
|
||||
tournamentRunning: processNames.get(tournamentName) ?? false,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -756,8 +772,13 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
this.buildInFlight = true;
|
||||
this.resetInFlight.add(profile.profileName);
|
||||
try {
|
||||
const { installOptions, scenarioId: installScenarioId, adminUser, openAt, preopenAt } =
|
||||
parseInstallOptions(action);
|
||||
const {
|
||||
installOptions,
|
||||
scenarioId: installScenarioId,
|
||||
adminUser,
|
||||
openAt,
|
||||
preopenAt,
|
||||
} = parseInstallOptions(action);
|
||||
const tickOverride =
|
||||
installOptions?.turnTermMinutes !== undefined ? installOptions.turnTermMinutes * 60 : undefined;
|
||||
const seedInfo = await this.resolveResetSeedInfo(profile, {
|
||||
@@ -850,7 +871,12 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
private async resolveResetSeedInfo(
|
||||
profile: GatewayProfileRecord,
|
||||
overrides?: { scenarioId?: number | null; tickSeconds?: number }
|
||||
): Promise<{ databaseUrl: string; scenarioId: number | null; tickSeconds?: number; meta: Record<string, unknown> } > {
|
||||
): Promise<{
|
||||
databaseUrl: string;
|
||||
scenarioId: number | null;
|
||||
tickSeconds?: number;
|
||||
meta: Record<string, unknown>;
|
||||
}> {
|
||||
const databaseUrl = resolvePostgresConfigFromEnv({
|
||||
env: this.processConfig.baseEnv ?? process.env,
|
||||
schema: profile.profile,
|
||||
@@ -871,7 +897,11 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
scenarioId = resolvedScenario;
|
||||
}
|
||||
}
|
||||
if (tickSeconds === undefined && typeof row.tickSeconds === 'number' && Number.isFinite(row.tickSeconds)) {
|
||||
if (
|
||||
tickSeconds === undefined &&
|
||||
typeof row.tickSeconds === 'number' &&
|
||||
Number.isFinite(row.tickSeconds)
|
||||
) {
|
||||
tickSeconds = row.tickSeconds;
|
||||
}
|
||||
meta = normalizeMeta(row.meta);
|
||||
@@ -889,11 +919,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
const workspace = await this.workspaceManager.prepare(commitSha);
|
||||
const lastUsedAt = this.now().toISOString();
|
||||
await this.repository.updateWorkspaceUsage(profileName, workspace.root, lastUsedAt);
|
||||
const commands = buildWorkspaceCommands(
|
||||
workspace.root,
|
||||
workspace.needsInstall,
|
||||
this.processConfig.baseEnv
|
||||
);
|
||||
const commands = buildWorkspaceCommands(workspace.root, workspace.needsInstall, this.processConfig.baseEnv);
|
||||
return this.buildRunner.run(commands);
|
||||
}
|
||||
|
||||
@@ -955,6 +981,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
try {
|
||||
await this.processManager.start(definitions.api);
|
||||
await this.processManager.start(definitions.daemon);
|
||||
await this.processManager.start(definitions.tournament);
|
||||
await this.repository.updateLastError(profile.profileName, null);
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -969,9 +996,10 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
private async stopProfile(profile: GatewayProfileRecord): Promise<void> {
|
||||
const apiName = buildProcessName(profile.profileName, 'api');
|
||||
const daemonName = buildProcessName(profile.profileName, 'daemon');
|
||||
const tournamentName = buildProcessName(profile.profileName, 'tournament');
|
||||
const existingNames = new Set((await this.processManager.list()).map((process) => process.name));
|
||||
const failures: string[] = [];
|
||||
for (const name of [apiName, daemonName]) {
|
||||
for (const name of [apiName, daemonName, tournamentName]) {
|
||||
if (!existingNames.has(name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user