From 8f5a5330ff45e3711c6d74e1d7046946d3ef8f35 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 2 Aug 2026 04:45:07 +0000 Subject: [PATCH 01/10] fix(tournament): reset season runtime state --- .../src/scenario/scenarioSeeder.ts | 2 +- app/game-engine/test/scenarioSeeder.test.ts | 5 ++- .../src/orchestrator/gatewayOrchestrator.ts | 38 ++++++++++++++++++- .../test/tournamentResetState.test.ts | 34 +++++++++++++++++ .../test/tournamentLifecycle.test.ts | 36 +++++++++++++++++- 5 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 app/gateway-api/test/tournamentResetState.test.ts 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..7886999 100644 --- a/tools/integration-tests/test/tournamentLifecycle.test.ts +++ b/tools/integration-tests/test/tournamentLifecycle.test.ts @@ -107,13 +107,17 @@ const truncateSchema = async (schema: string): Promise => { const resetServices = async (): Promise => { await ensureSchema('public'); await ensureSchema('che'); - await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:db:push:gateway', '--accept-data-loss'], { + const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url; + const gameDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'che' }).url; + await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:gateway'], { ...process.env, POSTGRES_SCHEMA: 'public', + GATEWAY_DATABASE_URL: gatewayDatabaseUrl, }); - await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:db:push:game', '--accept-data-loss'], { + await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:game'], { ...process.env, POSTGRES_SCHEMA: 'che', + DATABASE_URL: gameDatabaseUrl, }); await truncateSchema('public'); await truncateSchema('che'); @@ -210,6 +214,19 @@ describe('actual tournament lifecycle', () => { localAccountGeneralCreationGraceDays: 7, }, }); + 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 gatewayClient.admin.profiles.installNow.mutate({ profileName: 'che:908', install: { @@ -227,6 +244,21 @@ describe('actual tournament lifecycle', () => { }, }); + const resetTournamentRedis = createRedisConnector(resolveRedisConfigFromEnv()); + await resetTournamentRedis.connect(); + try { + expect( + await resetTournamentRedis.client.mGet([ + staleTournamentKeys.stateKey, + staleTournamentKeys.participantsKey, + staleTournamentKeys.matchesKey, + staleTournamentKeys.bettingKey, + ]) + ).toEqual([null, null, null, null]); + } finally { + await resetTournamentRedis.disconnect(); + } + for (const [username, displayName] of users) { const login = await gatewayClient.auth.login.mutate({ username, From 472de491510e5c629f8aad16385530fa37eb2f2a Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 2 Aug 2026 04:47:03 +0000 Subject: [PATCH 02/10] test(tournament): baseline lifecycle migrations --- .../test/tournamentLifecycle.test.ts | 73 ++++++++++++++++++- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/tools/integration-tests/test/tournamentLifecycle.test.ts b/tools/integration-tests/test/tournamentLifecycle.test.ts index 7886999..746aead 100644 --- a/tools/integration-tests/test/tournamentLifecycle.test.ts +++ b/tools/integration-tests/test/tournamentLifecycle.test.ts @@ -104,21 +104,90 @@ const truncateSchema = async (schema: string): Promise => { } }; +const hasMigrationHistory = async (schema: string): Promise => { + const connector = createGatewayPostgresConnector({ + url: resolvePostgresConfigFromEnv({ schema: 'public' }).url, + }); + await connector.connect(); + try { + const rows = (await connector.prisma.$queryRawUnsafe( + `SELECT EXISTS ( + SELECT 1 FROM pg_tables + WHERE schemaname = '${schema}' AND tablename = '_prisma_migrations' + ) AS present` + )) as Array<{ present: boolean }>; + return rows[0]?.present === true; + } finally { + await connector.disconnect(); + } +}; + +const baselineMigrations = async ( + directory: 'gateway-migrations' | 'migrations', + schemaFile: 'gateway.prisma' | 'game.prisma', + env: NodeJS.ProcessEnv +): Promise => { + const migrations = ( + await fs.readdir(path.join(workspaceRoot, 'packages', 'infra', 'prisma', directory), { + withFileTypes: true, + }) + ) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + for (const migration of migrations) { + await execCommand( + 'pnpm', + [ + '--dir', + 'packages/infra', + 'exec', + 'prisma', + 'migrate', + 'resolve', + '--applied', + migration, + '--schema', + `prisma/${schemaFile}`, + '--config', + schemaFile === 'gateway.prisma' ? 'prisma.gateway.config.ts' : 'prisma.config.ts', + ], + env + ); + } +}; + const resetServices = async (): Promise => { await ensureSchema('public'); await ensureSchema('che'); const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url; const gameDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'che' }).url; - await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:gateway'], { + const gatewayHasHistory = await hasMigrationHistory('public'); + const gameHasHistory = await hasMigrationHistory('che'); + await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:db:push:gateway', '--accept-data-loss'], { ...process.env, POSTGRES_SCHEMA: 'public', GATEWAY_DATABASE_URL: gatewayDatabaseUrl, }); - await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:game'], { + await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:db:push:game', '--accept-data-loss'], { ...process.env, POSTGRES_SCHEMA: 'che', DATABASE_URL: gameDatabaseUrl, }); + if (!gatewayHasHistory) { + await baselineMigrations('gateway-migrations', 'gateway.prisma', { + ...process.env, + POSTGRES_SCHEMA: 'public', + GATEWAY_DATABASE_URL: gatewayDatabaseUrl, + }); + } + if (!gameHasHistory) { + await baselineMigrations('migrations', 'game.prisma', { + ...process.env, + POSTGRES_SCHEMA: 'che', + DATABASE_URL: gameDatabaseUrl, + }); + } await truncateSchema('public'); await truncateSchema('che'); From 6caa368520c7d0a4f8357d7e222f13b061981d53 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 2 Aug 2026 04:51:07 +0000 Subject: [PATCH 03/10] test(tournament): preserve migration baseline --- tools/integration-tests/test/tournamentLifecycle.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tools/integration-tests/test/tournamentLifecycle.test.ts b/tools/integration-tests/test/tournamentLifecycle.test.ts index 746aead..7eca293 100644 --- a/tools/integration-tests/test/tournamentLifecycle.test.ts +++ b/tools/integration-tests/test/tournamentLifecycle.test.ts @@ -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; @@ -424,7 +425,7 @@ describe('actual tournament lifecycle', () => { turnDaemonLoop = turnDaemon.lifecycle.start(); const status = await transport.requestStatus(10_000); expect(status).not.toBeNull(); - }, 120_000); + }, 300_000); afterAll(async () => { if (turnDaemon) { @@ -436,7 +437,7 @@ describe('actual tournament lifecycle', () => { await gameConnector?.disconnect(); await gameServer?.app.close(); await gatewayServer?.app.close(); - }, 30_000); + }, 60_000); it('runs auto-open, enrollment, betting, finals, rewards, and payout through the real daemon', async () => { if (!store || !transport || !gameConnector) { From a0005dfe0194d62f80d538afff4a12876a6da313 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 2 Aug 2026 04:55:57 +0000 Subject: [PATCH 04/10] test(tournament): isolate gameplay lifecycle --- .../test/tournamentLifecycle.test.ts | 90 ++----------------- 1 file changed, 9 insertions(+), 81 deletions(-) diff --git a/tools/integration-tests/test/tournamentLifecycle.test.ts b/tools/integration-tests/test/tournamentLifecycle.test.ts index 7eca293..825d8aa 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, @@ -105,90 +105,17 @@ const truncateSchema = async (schema: string): Promise => { } }; -const hasMigrationHistory = async (schema: string): Promise => { - const connector = createGatewayPostgresConnector({ - url: resolvePostgresConfigFromEnv({ schema: 'public' }).url, - }); - await connector.connect(); - try { - const rows = (await connector.prisma.$queryRawUnsafe( - `SELECT EXISTS ( - SELECT 1 FROM pg_tables - WHERE schemaname = '${schema}' AND tablename = '_prisma_migrations' - ) AS present` - )) as Array<{ present: boolean }>; - return rows[0]?.present === true; - } finally { - await connector.disconnect(); - } -}; - -const baselineMigrations = async ( - directory: 'gateway-migrations' | 'migrations', - schemaFile: 'gateway.prisma' | 'game.prisma', - env: NodeJS.ProcessEnv -): Promise => { - const migrations = ( - await fs.readdir(path.join(workspaceRoot, 'packages', 'infra', 'prisma', directory), { - withFileTypes: true, - }) - ) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - .sort(); - for (const migration of migrations) { - await execCommand( - 'pnpm', - [ - '--dir', - 'packages/infra', - 'exec', - 'prisma', - 'migrate', - 'resolve', - '--applied', - migration, - '--schema', - `prisma/${schemaFile}`, - '--config', - schemaFile === 'gateway.prisma' ? 'prisma.gateway.config.ts' : 'prisma.config.ts', - ], - env - ); - } -}; - const resetServices = async (): Promise => { await ensureSchema('public'); await ensureSchema('che'); - const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url; - const gameDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'che' }).url; - const gatewayHasHistory = await hasMigrationHistory('public'); - const gameHasHistory = await hasMigrationHistory('che'); await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:db:push:gateway', '--accept-data-loss'], { ...process.env, POSTGRES_SCHEMA: 'public', - GATEWAY_DATABASE_URL: gatewayDatabaseUrl, }); await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:db:push:game', '--accept-data-loss'], { ...process.env, POSTGRES_SCHEMA: 'che', - DATABASE_URL: gameDatabaseUrl, }); - if (!gatewayHasHistory) { - await baselineMigrations('gateway-migrations', 'gateway.prisma', { - ...process.env, - POSTGRES_SCHEMA: 'public', - GATEWAY_DATABASE_URL: gatewayDatabaseUrl, - }); - } - if (!gameHasHistory) { - await baselineMigrations('migrations', 'game.prisma', { - ...process.env, - POSTGRES_SCHEMA: 'che', - DATABASE_URL: gameDatabaseUrl, - }); - } await truncateSchema('public'); await truncateSchema('che'); @@ -297,10 +224,10 @@ describe('actual tournament lifecycle', () => { } finally { await staleTournamentRedis.disconnect(); } - await gatewayClient.admin.profiles.installNow.mutate({ - profileName: 'che:908', - install: { - scenarioId: 908, + await seedScenarioToDatabase({ + scenarioId: 908, + databaseUrl: resolvePostgresConfigFromEnv({ schema: 'che' }).url, + installOptions: { turnTermMinutes: 1, sync: false, fiction: 0, @@ -317,6 +244,7 @@ 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, @@ -425,7 +353,7 @@ describe('actual tournament lifecycle', () => { turnDaemonLoop = turnDaemon.lifecycle.start(); const status = await transport.requestStatus(10_000); expect(status).not.toBeNull(); - }, 300_000); + }, 120_000); afterAll(async () => { if (turnDaemon) { @@ -437,7 +365,7 @@ describe('actual tournament lifecycle', () => { await gameConnector?.disconnect(); await gameServer?.app.close(); await gatewayServer?.app.close(); - }, 60_000); + }, 30_000); it('runs auto-open, enrollment, betting, finals, rewards, and payout through the real daemon', async () => { if (!store || !transport || !gameConnector) { From 39f7988a07af94222841e36a29830af7e962aef5 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 2 Aug 2026 04:57:25 +0000 Subject: [PATCH 05/10] test(tournament): route account icon source --- tools/integration-tests/test/tournamentLifecycle.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/integration-tests/test/tournamentLifecycle.test.ts b/tools/integration-tests/test/tournamentLifecycle.test.ts index 825d8aa..40310f0 100644 --- a/tools/integration-tests/test/tournamentLifecycle.test.ts +++ b/tools/integration-tests/test/tournamentLifecycle.test.ts @@ -169,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 }); From 4d14fe27e413ffc864cf9c824e73784f6756dd5c Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 2 Aug 2026 04:58:47 +0000 Subject: [PATCH 06/10] test(tournament): start daemon before joins --- .../test/tournamentLifecycle.test.ts | 40 +++++++++---------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/tools/integration-tests/test/tournamentLifecycle.test.ts b/tools/integration-tests/test/tournamentLifecycle.test.ts index 40310f0..53dd6a3 100644 --- a/tools/integration-tests/test/tournamentLifecycle.test.ts +++ b/tools/integration-tests/test/tournamentLifecycle.test.ts @@ -258,6 +258,25 @@ describe('actual tournament lifecycle', () => { 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, @@ -286,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 }, @@ -327,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()); @@ -350,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 () => { From 4d0c3c8c18fd5d3098170c0b45b850a3d265be54 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 2 Aug 2026 05:05:18 +0000 Subject: [PATCH 07/10] fix(frontend): render responsive tournament bracket --- app/game-frontend/e2e/playwright.config.mjs | 1 + .../e2e/tournamentBracket.spec.ts | 201 +++++++++++ app/game-frontend/package.json | 1 + .../tournament/TournamentBracket.vue | 312 ++++++++++++++++++ .../src/utils/tournamentBracket.ts | 76 +++++ .../src/views/TournamentView.vue | 93 +----- .../test/tournamentBracket.test.ts | 62 ++++ 7 files changed, 663 insertions(+), 83 deletions(-) create mode 100644 app/game-frontend/e2e/tournamentBracket.spec.ts create mode 100644 app/game-frontend/src/components/tournament/TournamentBracket.vue create mode 100644 app/game-frontend/src/utils/tournamentBracket.ts create mode 100644 app/game-frontend/test/tournamentBracket.test.ts diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index 09a31bc..2f852a0 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -25,6 +25,7 @@ export default defineConfig({ 'nationGeneralSecret.spec.ts', 'npcPolicy.spec.ts', 'auction.spec.ts', + 'tournamentBracket.spec.ts', 'battleSimulator.spec.ts', 'battleSimulatorRef.spec.ts', 'commandArguments.spec.ts', diff --git a/app/game-frontend/e2e/tournamentBracket.spec.ts b/app/game-frontend/e2e/tournamentBracket.spec.ts new file mode 100644 index 0000000..920ffa5 --- /dev/null +++ b/app/game-frontend/e2e/tournamentBracket.spec.ts @@ -0,0 +1,201 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; +import { readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { gameProfile, gameTrpcRoute } from './gameTestPaths.js'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const imageRoots = [ + ...(process.env.FRONTEND_PARITY_IMAGE_ROOT ? [resolve(process.env.FRONTEND_PARITY_IMAGE_ROOT, 'game')] : []), + resolve(repositoryRoot, '../image/game'), + resolve(repositoryRoot, '../../image/game'), +]; +const names = [ + '관우', + '장료', + '조운', + '하후돈', + '손책', + '태사자', + '마초', + '황충', + '여포', + '전위', + '감녕', + '문추', + '안량', + '허저', + '주태', + '방덕', +]; +const participants = names.map((name, index) => ({ + id: index + 1, + name, + leadership: 80, + strength: 80, + intel: 80, + level: 10, + groupId: 10 + (index % 8), + groupNo: Math.floor(index / 8), + win: 3 - (index % 2), + draw: index % 2, + lose: 0, + gl: 12 - index, + finalRank: Math.floor(index / 8) + 1, +})); +const matches = [ + ...Array.from({ length: 8 }, (_, index) => ({ + id: index + 1, + stage: 7, + roundIndex: index, + attackerId: index * 2 + 1, + defenderId: index * 2 + 2, + winnerId: index * 2 + 1, + })), + ...Array.from({ length: 4 }, (_, index) => ({ + id: index + 9, + stage: 8, + roundIndex: index, + attackerId: index * 4 + 1, + defenderId: index * 4 + 3, + winnerId: index * 4 + 1, + })), + ...Array.from({ length: 2 }, (_, index) => ({ + id: index + 13, + stage: 9, + roundIndex: index, + attackerId: index * 8 + 1, + defenderId: index * 8 + 5, + winnerId: index * 8 + 1, + })), + { id: 15, stage: 10, roundIndex: 0, attackerId: 1, defenderId: 9, winnerId: 1 }, +]; + +const response = (data: unknown) => ({ result: { data } }); +const operationNames = (route: Route): string[] => { + const url = new URL(route.request().url()); + return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(','); +}; + +const readReferenceImage = async (filename: string): Promise => { + for (const imageRoot of imageRoots) { + try { + return await readFile(resolve(imageRoot, filename)); + } catch { + // Worktrees can be nested at different depths. + } + } + throw new Error(`Reference image not found: ${filename}`); +}; + +const installFixture = async (page: Page) => { + await page.addInitScript((profile) => { + window.localStorage.setItem('sammo-game-token', 'ga_tournament_bracket_playwright'); + window.localStorage.setItem('sammo-game-profile', profile); + }, gameProfile); + for (const filename of ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg']) { + await page.route(`**/image/game/${filename}`, async (route) => { + await route.fulfill({ status: 200, contentType: 'image/jpeg', body: await readReferenceImage(filename) }); + }); + } + await page.route(gameTrpcRoute, async (route) => { + const results = operationNames(route).map((operation) => { + if (operation === 'auth.status') return response({ ok: true }); + if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: names[0] } }); + if (operation === 'join.getConfig') return response({}); + if (operation === 'general.me') return response({ general: { id: 1, name: names[0] } }); + if (operation === 'tournament.getAdminStatus') return response({ ok: false }); + if (operation === 'tournament.getSnapshot') { + return response({ + state: { + stage: 0, + phase: 0, + type: 0, + auto: false, + openYear: 184, + openMonth: 1, + termSeconds: 60, + nextAt: '2026-08-02T00:00:00.000Z', + winnerId: 1, + }, + participants, + matches, + betCount: 16, + }); + } + if (operation === 'tournament.getBettingSummary') { + return response({ + totals: Object.fromEntries(participants.map((participant, index) => [participant.id, 100 + index * 10])), + myTotals: {}, + totalAmount: 2800, + myAmount: 0, + }); + } + return response(null); + }); + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) }); + }); +}; + +const openTournament = async (page: Page) => { + await installFixture(page); + await page.goto('tournament'); + await expect(page.getByLabel('토너먼트 대진표')).toBeVisible(); +}; + +test('desktop bracket connects every real general slot to the next round', async ({ page }, testInfo) => { + await page.setViewportSize({ width: 1365, height: 900 }); + await openTournament(page); + + await expect(page.locator('.bracket-canvas .bracket-name[data-general-id]')).toHaveCount(31); + await expect(page.locator('.bracket-canvas .connector-segment')).toHaveCount(15); + await expect(page.locator('.bracket-canvas .bracket-name.advanced', { hasText: '관우' })).toHaveCount(5); + + const geometry = await page.locator('.bracket-canvas').evaluate((canvas) => { + const firstConnector = canvas.querySelector('.connector-segment')!.getBoundingClientRect(); + const champion = canvas.querySelector('.bracket-champion .bracket-name')!.getBoundingClientRect(); + const finalists = [...canvas.querySelectorAll('.bracket-round:nth-of-type(3) .bracket-name')].map( + (element) => element.getBoundingClientRect() + ); + return { + canvasWidth: canvas.getBoundingClientRect().width, + connectorCenter: firstConnector.x + firstConnector.width / 2, + championCenter: champion.x + champion.width / 2, + finalistCenters: finalists.map((rect) => rect.x + rect.width / 2), + connectorQuarters: [firstConnector.x + firstConnector.width / 4, firstConnector.x + (firstConnector.width * 3) / 4], + }; + }); + expect(geometry.canvasWidth).toBe(2000); + expect(Math.abs(geometry.connectorCenter - geometry.championCenter)).toBeLessThan(1); + expect(geometry.finalistCenters).toHaveLength(2); + expect(Math.abs(geometry.finalistCenters[0]! - geometry.connectorQuarters[0]!)).toBeLessThan(1); + expect(Math.abs(geometry.finalistCenters[1]! - geometry.connectorQuarters[1]!)).toBeLessThan(1); + + await page.screenshot({ path: testInfo.outputPath('tournament-bracket-desktop.webp'), fullPage: true }); +}); + +test('mobile bracket shows every round and general within the handheld width', async ({ page }, testInfo) => { + await page.setViewportSize({ width: 390, height: 844 }); + await openTournament(page); + + const bracket = page.locator('.mobile-bracket'); + await expect(bracket).toBeVisible(); + await expect(bracket.locator('.mobile-bracket-name')).toHaveCount(31); + await expect(bracket.locator('.mobile-bracket-name', { hasText: '방덕' })).toBeVisible(); + await expect(bracket.locator('.mobile-bracket-name', { hasText: '관우' })).toHaveCount(5); + const bounds = await bracket.evaluate((element) => { + const names = [...element.querySelectorAll('.mobile-bracket-name')].map((name) => + name.getBoundingClientRect() + ); + const own = element.getBoundingClientRect(); + return { + width: own.width, + minX: Math.min(...names.map((rect) => rect.left - own.left)), + maxX: Math.max(...names.map((rect) => rect.right - own.left)), + }; + }); + expect(bounds.width).toBe(390); + expect(bounds.minX).toBeGreaterThanOrEqual(0); + expect(bounds.maxX).toBeLessThanOrEqual(390); + await page.screenshot({ path: testInfo.outputPath('tournament-bracket-mobile.webp'), fullPage: true }); +}); diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index c36d02f..6ed76c5 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -15,6 +15,7 @@ "test:e2e:board": "playwright test board.spec.ts --config e2e/playwright.config.mjs", "test:e2e:directories": "playwright test directoryLists.spec.ts --config e2e/playwright.config.mjs", "test:e2e:auction": "playwright test auction.spec.ts --config e2e/playwright.config.mjs", + "test:e2e:tournament-bracket": "playwright test tournamentBracket.spec.ts --config e2e/playwright.config.mjs", "test:e2e:battle-simulator": "playwright test battleSimulator.spec.ts --config e2e/playwright.config.mjs", "test:e2e:join-live": "playwright test --config e2e/joinGeneral.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json", "test:e2e:npc-possession": "playwright test npcPossession.spec.ts --config e2e/playwright.config.mjs", diff --git a/app/game-frontend/src/components/tournament/TournamentBracket.vue b/app/game-frontend/src/components/tournament/TournamentBracket.vue new file mode 100644 index 0000000..f1ff856 --- /dev/null +++ b/app/game-frontend/src/components/tournament/TournamentBracket.vue @@ -0,0 +1,312 @@ + + + + + diff --git a/app/game-frontend/src/utils/tournamentBracket.ts b/app/game-frontend/src/utils/tournamentBracket.ts new file mode 100644 index 0000000..2b29e23 --- /dev/null +++ b/app/game-frontend/src/utils/tournamentBracket.ts @@ -0,0 +1,76 @@ +export interface TournamentBracketParticipant { + id: number; + name: string; +} + +export interface TournamentBracketMatch { + id: number; + stage: number; + roundIndex: number; + attackerId: number; + defenderId: number; + winnerId?: number; +} + +export interface TournamentBracketSlot { + id: number | null; + name: string; + advanced: boolean; +} + +export interface TournamentBracketRound { + stage: number; + slots: TournamentBracketSlot[]; +} + +export interface TournamentBracketModel { + champion: TournamentBracketSlot; + final: TournamentBracketRound; + semi: TournamentBracketRound; + quarter: TournamentBracketRound; + top16: TournamentBracketRound; +} + +const emptySlot = (): TournamentBracketSlot => ({ id: null, name: '-', advanced: false }); + +export const buildTournamentBracket = ( + participants: TournamentBracketParticipant[], + matches: TournamentBracketMatch[], + winnerId?: number +): TournamentBracketModel => { + const participantsById = new Map(participants.map((participant) => [participant.id, participant])); + const nameOf = (id: number | null): string => + id === null ? '-' : (participantsById.get(id)?.name ?? `#${id}`); + + const buildRound = (stage: number, slotCount: number): TournamentBracketRound => { + const roundMatches = matches + .filter((match) => match.stage === stage) + .sort((lhs, rhs) => lhs.roundIndex - rhs.roundIndex || lhs.id - rhs.id); + const slots: TournamentBracketSlot[] = roundMatches.flatMap((match) => + [match.attackerId, match.defenderId].map((id) => ({ + id, + name: nameOf(id), + advanced: match.winnerId === id, + })) + ); + while (slots.length < slotCount) { + slots.push(emptySlot()); + } + return { stage, slots: slots.slice(0, slotCount) }; + }; + + const final = buildRound(10, 2); + const resolvedWinnerId = winnerId ?? matches.find((match) => match.stage === 10)?.winnerId ?? null; + + return { + champion: { + id: resolvedWinnerId, + name: nameOf(resolvedWinnerId), + advanced: resolvedWinnerId !== null, + }, + final, + semi: buildRound(9, 4), + quarter: buildRound(8, 8), + top16: buildRound(7, 16), + }; +}; diff --git a/app/game-frontend/src/views/TournamentView.vue b/app/game-frontend/src/views/TournamentView.vue index f255404..6b40b64 100644 --- a/app/game-frontend/src/views/TournamentView.vue +++ b/app/game-frontend/src/views/TournamentView.vue @@ -1,5 +1,6 @@ + + + + diff --git a/app/game-frontend/src/components/chief/ChiefTurnCard.vue b/app/game-frontend/src/components/chief/ChiefTurnCard.vue index 1be9216..cf72796 100644 --- a/app/game-frontend/src/components/chief/ChiefTurnCard.vue +++ b/app/game-frontend/src/components/chief/ChiefTurnCard.vue @@ -18,6 +18,7 @@ const props = defineProps<{ compact?: boolean; isMe?: boolean; clickable?: boolean; + turnTimeLabel?: string; }>(); const emit = defineEmits<{ @@ -40,13 +41,26 @@ const handleClick = () => { @click="handleClick" >
-
- {{ props.officerLevelText }} - - {{ props.name ?? '-' }} - -
- ME + +
@@ -72,7 +86,9 @@ const handleClick = () => { .chief-card.clickable { cursor: pointer; - transition: border-color 0.2s ease, box-shadow 0.2s ease; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; } .chief-card.clickable:hover { @@ -163,6 +179,28 @@ const handleClick = () => { font-size: 0.65rem; } +.compact-name, +.compact-meta { + display: grid; + place-items: center; + min-width: 0; + overflow: hidden; + white-space: nowrap; +} +.compact-meta { + grid-template-columns: 1fr 1fr; +} +.chief-card.compact .chief-header { + height: 72px; + grid-template-rows: 36px 36px; + display: grid; + padding: 0; +} +.chief-card.compact .chief-row { + height: 46px; + line-height: 46px; +} + .chief-card.compact .chief-level, .chief-card.compact .chief-name { font-size: 0.6rem; diff --git a/app/game-frontend/src/components/main/CommandSelectForm.vue b/app/game-frontend/src/components/main/CommandSelectForm.vue index 9015cfa..3228315 100644 --- a/app/game-frontend/src/components/main/CommandSelectForm.vue +++ b/app/game-frontend/src/components/main/CommandSelectForm.vue @@ -25,6 +25,7 @@ const props = defineProps<{ commandTable: CommandTable | null; loading: boolean; activeCategory?: string; + scope?: 'all' | 'general' | 'nation'; }>(); const emit = defineEmits<{ @@ -48,6 +49,10 @@ const categories = computed(() => { category: group.category, groupType: 'nation' as const, })); + if (props.scope === 'general') return general; + if (props.scope === 'nation') { + return nation.map((entry) => ({ ...entry, label: entry.category === '국가' ? '기타' : entry.category })); + } return [...general, ...nation]; }); @@ -58,7 +63,10 @@ const selectedGroup = computed(() => { } const [scope, ...categoryParts] = selectedCategory.value.split(':'); const category = categoryParts.join(':'); - return props.commandTable[scope === 'nation' ? 'nation' : 'general'].find((group) => group.category === category) ?? null; + return ( + props.commandTable[scope === 'nation' ? 'nation' : 'general'].find((group) => group.category === category) ?? + null + ); }); watch( @@ -109,9 +117,7 @@ const statusLabel = (command: CommandAvailability) => {
-
- 명령 목록을 불러오지 못했습니다. -
+
명령 목록을 불러오지 못했습니다.