diff --git a/app/game-engine/src/scenario/scenarioSeeder.ts b/app/game-engine/src/scenario/scenarioSeeder.ts index 1ce594c..cdc6ad3 100644 --- a/app/game-engine/src/scenario/scenarioSeeder.ts +++ b/app/game-engine/src/scenario/scenarioSeeder.ts @@ -244,7 +244,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom blockGeneralCreate: install?.blockGeneralCreate, npcMode: install?.npcMode, showImgLevel: install?.showImgLevel, - tournamentTrig: install?.tournamentTrig, + tournamentTrig: install?.tournamentTrig ?? true, extendedGeneral: includeExtendedGeneral, turnTermMinutes: install?.turnTermMinutes, syncTurnTime: install?.sync, diff --git a/app/game-engine/test/scenarioSeeder.test.ts b/app/game-engine/test/scenarioSeeder.test.ts index 6fa3b82..7873eab 100644 --- a/app/game-engine/test/scenarioSeeder.test.ts +++ b/app/game-engine/test/scenarioSeeder.test.ts @@ -103,6 +103,7 @@ describeDb('scenario database seed', () => { await connector.connect(); try { const prisma = connector.prisma as unknown as ScenarioSeederPrismaClient; + const worldState = await prisma.worldState.findFirst(); const [nationCount, cityCount, generalCount, diplomacyCount, eventCount] = await Promise.all([ prisma.nation.count(), prisma.city.count(), @@ -116,6 +117,7 @@ describeDb('scenario database seed', () => { expect(generalCount).toBe(seed.generals.length); expect(diplomacyCount).toBe(seed.nations.length * Math.max(0, seed.nations.length - 1)); expect(eventCount).toBe(seed.events.length); + expect(worldState?.config).toMatchObject({ tournamentTrig: true }); expect(generalCount).toBeGreaterThan(0); const seededGeneral = await prisma.general.findFirst(); expect(seededGeneral?.startAge).toBe(seededGeneral?.age); @@ -201,7 +203,7 @@ describeDb('scenario database seed', () => { blockGeneralCreate: 2, npcMode: 0, showImgLevel: 3, - tournamentTrig: true, + tournamentTrig: false, joinMode: 'full', autorunUser: { limitMinutes: 60, @@ -234,6 +236,7 @@ describeDb('scenario database seed', () => { const config = (worldState.config ?? {}) as Record; expect(config.extendedGeneral).toBe(false); expect(config.joinMode).toBe('full'); + expect(config.tournamentTrig).toBe(false); const meta = (worldState.meta ?? {}) as Record; const autorun = (meta.autorun_user ?? {}) as Record; diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index 1bbf29c..9be2b57 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -4,7 +4,12 @@ import path from 'node:path'; import { createHash, randomBytes, randomUUID } from 'node:crypto'; import { type ScenarioInstallOptions } from '@sammo-ts/game-engine'; -import { createGamePostgresConnector, resolvePostgresConfigFromEnv } from '@sammo-ts/infra'; +import { + createGamePostgresConnector, + createRedisConnector, + resolvePostgresConfigFromEnv, + resolveRedisConfigFromEnv, +} from '@sammo-ts/infra'; import { isRecord } from '@sammo-ts/common'; import type { BuildCommand, BuildRunner } from './buildRunner.js'; @@ -41,6 +46,7 @@ export interface GatewayOrchestratorOptions { profileReadinessTimeoutMs?: number; now?: () => Date; fetchImpl?: typeof fetch; + clearTournamentRuntimeState?: (profileName: string) => Promise; } export interface ProfileRuntimeState { @@ -150,6 +156,18 @@ class OperationLeaseLostError extends Error {} const normalizeMeta = (value: unknown): Record => (isRecord(value) ? value : {}); +export const buildTournamentRuntimeKeys = (profileName: string): string[] => [ + `sammo:${profileName}:tournament:state`, + `sammo:${profileName}:tournament:participants`, + `sammo:${profileName}:tournament:matches`, + `sammo:${profileName}:tournament:betting`, +]; + +export const clearTournamentRuntimeKeys = async ( + redis: { del(keys: string[]): Promise }, + profileName: string +): Promise => redis.del(buildTournamentRuntimeKeys(profileName)); + const buildServerId = (profileName: string, now: Date, installOperationId?: string): string => { const year = String(now.getFullYear()).slice(-2); const month = String(now.getMonth() + 1).padStart(2, '0'); @@ -545,6 +563,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { private readonly profileReadinessTimeoutMs: number; private readonly now: () => Date; private readonly fetchImpl: typeof fetch; + private readonly clearTournamentRuntimeState: (profileName: string) => Promise; private reconcileTimer?: NodeJS.Timeout; private scheduleTimer?: NodeJS.Timeout; private buildTimer?: NodeJS.Timeout; @@ -573,6 +592,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { this.profileReadinessTimeoutMs = options.profileReadinessTimeoutMs ?? 30_000; this.now = options.now ?? (() => new Date()); this.fetchImpl = options.fetchImpl ?? fetch; + this.clearTournamentRuntimeState = + options.clearTournamentRuntimeState ?? + ((profileName) => this.clearTournamentRuntimeStateFromRedis(profileName)); } start(): void { @@ -1383,6 +1405,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { if (!seedResult.ok) { throw new Error(`Selected profile seed failed: ${seedResult.output.slice(-4000)}`); } + await this.clearTournamentRuntimeState(profile.profileName); + await assertLease?.(); const completedAt = this.now().toISOString(); const now = this.now(); const shouldPreopen = openAt ? openAt.getTime() > now.getTime() : false; @@ -1590,6 +1614,18 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { }).url; } + private async clearTournamentRuntimeStateFromRedis(profileName: string): Promise { + const connector = createRedisConnector( + resolveRedisConfigFromEnv(this.processConfig.baseEnv ?? process.env) + ); + await connector.connect(); + try { + await clearTournamentRuntimeKeys(connector.client, profileName); + } finally { + await connector.disconnect(); + } + } + async cleanupStaleWorkspaces(): Promise<{ removed: string[]; skipped: string[] }> { const profiles = await this.repository.listProfiles(); const cutoff = this.computeCutoffDate(6); diff --git a/app/gateway-api/test/tournamentResetState.test.ts b/app/gateway-api/test/tournamentResetState.test.ts new file mode 100644 index 0000000..8b526cd --- /dev/null +++ b/app/gateway-api/test/tournamentResetState.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildTournamentRuntimeKeys, + clearTournamentRuntimeKeys, +} from '../src/orchestrator/gatewayOrchestrator.js'; + +describe('tournament reset state', () => { + it('targets every season-owned tournament key for the selected profile only', () => { + expect(buildTournamentRuntimeKeys('che:1010')).toEqual([ + 'sammo:che:1010:tournament:state', + 'sammo:che:1010:tournament:participants', + 'sammo:che:1010:tournament:matches', + 'sammo:che:1010:tournament:betting', + ]); + expect(buildTournamentRuntimeKeys('hwe:915')).not.toContain('sammo:che:1010:tournament:state'); + }); + + it('deletes the tournament state as one profile-scoped reset operation', async () => { + const calls: string[][] = []; + const deleted = await clearTournamentRuntimeKeys( + { + del: async (keys) => { + calls.push(keys); + return keys.length; + }, + }, + 'che:1010' + ); + + expect(deleted).toBe(4); + expect(calls).toEqual([buildTournamentRuntimeKeys('che:1010')]); + }); +}); diff --git a/tools/integration-tests/test/tournamentLifecycle.test.ts b/tools/integration-tests/test/tournamentLifecycle.test.ts index 39cf1ad..53dd6a3 100644 --- a/tools/integration-tests/test/tournamentLifecycle.test.ts +++ b/tools/integration-tests/test/tournamentLifecycle.test.ts @@ -8,7 +8,7 @@ import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; import { sealGatewayPassword } from '../src/passwordEnvelope.js'; import type { AppRouter as GatewayAppRouter } from '@sammo-ts/gateway-api'; -import { createGatewayApiServer } from '@sammo-ts/gateway-api'; +import { clearTournamentRuntimeKeys, createGatewayApiServer } from '@sammo-ts/gateway-api'; import type { AppRouter as GameAppRouter } from '@sammo-ts/game-api'; import { buildTournamentKeys, @@ -17,7 +17,7 @@ import { processTournamentTick, TournamentStore, } from '@sammo-ts/game-api'; -import { createTurnDaemonRuntime } from '@sammo-ts/game-engine'; +import { createTurnDaemonRuntime, seedScenarioToDatabase } from '@sammo-ts/game-engine'; import { createGamePostgresConnector, createGatewayPostgresConnector, @@ -92,7 +92,8 @@ const truncateSchema = async (schema: string): Promise => { await connector.connect(); try { const rows = (await connector.prisma.$queryRawUnsafe( - `SELECT tablename FROM pg_tables WHERE schemaname = '${schema}'` + `SELECT tablename FROM pg_tables + WHERE schemaname = '${schema}' AND tablename <> '_prisma_migrations'` )) as Array<{ tablename: string }>; if (rows.length === 0) { return; @@ -168,6 +169,7 @@ describe('actual tournament lifecycle', () => { gatewayServer = await createGatewayApiServer(); await gatewayServer.app.listen({ host: gatewayServer.config.host, port: gatewayServer.config.port }); + process.env.GATEWAY_INTERNAL_API_URL = `http://127.0.0.1:${gatewayServer.config.port}`; gameServer = await createGameApiServer(); await gameServer.app.listen({ host: gameServer.config.host, port: gameServer.config.port }); @@ -210,10 +212,23 @@ describe('actual tournament lifecycle', () => { localAccountGeneralCreationGraceDays: 7, }, }); - await gatewayClient.admin.profiles.installNow.mutate({ - profileName: 'che:908', - install: { - scenarioId: 908, + const staleTournamentRedis = createRedisConnector(resolveRedisConfigFromEnv()); + await staleTournamentRedis.connect(); + const staleTournamentKeys = buildTournamentKeys('che:908'); + try { + await staleTournamentRedis.client.mSet({ + [staleTournamentKeys.stateKey]: JSON.stringify({ stage: 6, auto: true }), + [staleTournamentKeys.participantsKey]: '[{"id":99999}]', + [staleTournamentKeys.matchesKey]: '[{"id":99999}]', + [staleTournamentKeys.bettingKey]: '[{"generalId":99999}]', + }); + } finally { + await staleTournamentRedis.disconnect(); + } + await seedScenarioToDatabase({ + scenarioId: 908, + databaseUrl: resolvePostgresConfigFromEnv({ schema: 'che' }).url, + installOptions: { turnTermMinutes: 1, sync: false, fiction: 0, @@ -227,6 +242,41 @@ describe('actual tournament lifecycle', () => { }, }); + const resetTournamentRedis = createRedisConnector(resolveRedisConfigFromEnv()); + await resetTournamentRedis.connect(); + try { + await clearTournamentRuntimeKeys(resetTournamentRedis.client, 'che:908'); + expect( + await resetTournamentRedis.client.mGet([ + staleTournamentKeys.stateKey, + staleTournamentKeys.participantsKey, + staleTournamentKeys.matchesKey, + staleTournamentKeys.bettingKey, + ]) + ).toEqual([null, null, null, null]); + } finally { + await resetTournamentRedis.disconnect(); + } + + const gameDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'che' }).url; + const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url; + gameConnector = createGamePostgresConnector({ url: gameDatabaseUrl }); + await gameConnector.connect(); + redisConnector = createRedisConnector(resolveRedisConfigFromEnv()); + await redisConnector.connect(); + store = new TournamentStore(redisConnector.client, buildTournamentKeys('che:908')); + transport = new DatabaseTurnDaemonTransport(gameConnector.prisma, 30_000); + turnDaemon = await createTurnDaemonRuntime({ + profile: 'che', + profileName: 'che:908', + databaseUrl: gameDatabaseUrl, + gatewayDatabaseUrl, + redisUrl: resolveRedisConfigFromEnv().url, + }); + turnDaemonLoop = turnDaemon.lifecycle.start(); + const status = await transport.requestStatus(10_000); + expect(status).not.toBeNull(); + for (const [username, displayName] of users) { const login = await gatewayClient.auth.login.mutate({ username, @@ -255,10 +305,6 @@ describe('actual tournament lifecycle', () => { } } - const gameDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'che' }).url; - const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url; - gameConnector = createGamePostgresConnector({ url: gameDatabaseUrl }); - await gameConnector.connect(); await gameConnector.prisma.general.updateMany({ where: { id: { in: [...generalIds.values()] } }, data: { gold: 10_000 }, @@ -296,19 +342,6 @@ describe('actual tournament lifecycle', () => { })), }); - redisConnector = createRedisConnector(resolveRedisConfigFromEnv()); - await redisConnector.connect(); - store = new TournamentStore(redisConnector.client, buildTournamentKeys('che:908')); - transport = new DatabaseTurnDaemonTransport(gameConnector.prisma, 30_000); - - turnDaemon = await createTurnDaemonRuntime({ - profile: 'che', - profileName: 'che:908', - databaseUrl: gameDatabaseUrl, - gatewayDatabaseUrl, - redisUrl: resolveRedisConfigFromEnv().url, - }); - for (let attempt = 0; attempt < 36; attempt += 1) { const current = turnDaemon.world.getState().lastTurnTime; const next = new Date(current.getTime()); @@ -319,10 +352,6 @@ describe('actual tournament lifecycle', () => { } } expect(await store.getState()).toMatchObject({ stage: 1, auto: true }); - - turnDaemonLoop = turnDaemon.lifecycle.start(); - const status = await transport.requestStatus(10_000); - expect(status).not.toBeNull(); }, 120_000); afterAll(async () => {