From f4aabec1fa13a0ae136b8ba96e8aa5dfa9a8e79e Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 16 Sep 2026 01:38:21 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=84=A4=EB=AC=B8=EA=B3=BC=20=EC=9E=A5?= =?UTF-8?q?=EC=88=98=20=EC=84=A0=ED=83=9D=20=EA=B8=B0=ED=95=9C=20=EB=B0=8F?= =?UTF-8?q?=20=ED=86=A0=EB=84=88=EB=A8=BC=ED=8A=B8=20=EA=B0=9C=EC=B5=9C?= =?UTF-8?q?=EB=A5=BC=20=EA=B2=8C=EC=9E=84=20=EC=8B=9C=EA=B0=81=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/router/tournament/index.ts | 29 ++- app/game-api/test/tournamentRouter.test.ts | 94 +++++++ app/game-frontend/e2e/gameDeadlines.spec.ts | 229 ++++++++++++++++++ app/game-frontend/e2e/npcPossession.spec.ts | 6 + app/game-frontend/e2e/playwright.config.mjs | 1 + app/game-frontend/src/views/JoinView.vue | 38 ++- .../src/views/SelectGeneralView.vue | 23 +- app/game-frontend/src/views/SurveyView.vue | 10 +- .../src/views/TournamentView.vue | 14 +- 9 files changed, 388 insertions(+), 56 deletions(-) create mode 100644 app/game-frontend/e2e/gameDeadlines.spec.ts diff --git a/app/game-api/src/router/tournament/index.ts b/app/game-api/src/router/tournament/index.ts index 8f575e13..12216a3d 100644 --- a/app/game-api/src/router/tournament/index.ts +++ b/app/game-api/src/router/tournament/index.ts @@ -10,7 +10,7 @@ import { buildTournamentKeys } from '../../tournament/keys.js'; import { assignManualApplicantGroup } from '../../tournament/workerHelpers.js'; import { accessAuthedProcedure, authedProcedure, engineAuthedProcedure, procedure, router } from '../../trpc.js'; import { getMyGeneral } from '../shared/general.js'; -import { loadCurrentGameTime } from '../../services/gameClock.js'; +import { loadCurrentGameTime, type CurrentGameTime } from '../../services/gameClock.js'; import { ensureActiveRedisClockFence, ensureBettingRedisClockFence, @@ -67,7 +67,7 @@ const withTournamentClockMutation = async ( tournamentMutationLockHeld?: boolean; }, store: TournamentStore, - operation: () => Promise, + operation: (gameTime: CurrentGameTime) => Promise, ensureFence = ensureActiveRedisClockFence ): Promise => { const gameTime = await loadCurrentGameTime(ctx.db); @@ -85,7 +85,7 @@ const withTournamentClockMutation = async ( dateToTick: gameTime.dateToTick, }; return store.withClockContext(clockContext, () => - ctx.tournamentMutationLockHeld ? operation() : store.withMutationLock(operation) + ctx.tournamentMutationLockHeld ? operation(gameTime) : store.withMutationLock(() => operation(gameTime)) ); }; @@ -351,6 +351,29 @@ export const tournamentRouter = router({ return { prefix, ...tournamentRankInfo[prefix], entries }; }); }), + start: adminProcedure.input(z.void()).mutation(async ({ ctx }) => { + const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); + return withTournamentClockMutation(ctx, store, async (gameTime) => { + const [current, world] = await Promise.all([store.getState(), ctx.db.worldState.findFirst()]); + if (!world) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' }); + } + // 브라우저 WALL 시각을 GAME 일정으로 저장하지 않고 검증한 서버 시계로 시작한다. + await store.setState({ + stage: 1, + phase: 0, + type: 0, + auto: true, + openYear: world.currentYear, + openMonth: world.currentMonth, + termSeconds: current?.termSeconds ?? 60, + nextAt: new Date(gameTime.now.getTime() + 60_000).toISOString(), + bettingSettled: false, + rewardSettled: false, + }); + return { ok: true }; + }); + }), setState: adminProcedure.input(zTournamentState).mutation(async ({ ctx, input }) => { const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); return withTournamentClockMutation(ctx, store, async () => { diff --git a/app/game-api/test/tournamentRouter.test.ts b/app/game-api/test/tournamentRouter.test.ts index 3a5b4de3..75932a7a 100644 --- a/app/game-api/test/tournamentRouter.test.ts +++ b/app/game-api/test/tournamentRouter.test.ts @@ -154,6 +154,7 @@ const buildContext = (options: { clockPhase?: 'PREOPEN' | 'RUNNING' | 'MANUAL' | 'SUSPENDED' | 'RECONCILING'; requestId?: string; clockWallAnchor?: Date; + recovery?: boolean; }): GameApiContext => { const db = { general: { @@ -165,10 +166,20 @@ const buildContext = (options: { rankData: { findMany: async () => options.rankRows ?? [], }, + $queryRaw: async () => [{ ready: true }], worldState: { findFirst: async () => ({ clockBaseTime: new Date('2026-01-01T00:00:00.000Z'), + currentYear: 193, + currentMonth: 7, clockTick: 0n, + ...(options.recovery + ? { + clockRecoveryStartTick: 0n, + clockRecoveryEndTick: 720_000_000n, + clockRecoveryStartWallAt: options.clockWallAnchor, + } + : {}), clockMode: 'realtime', clockWallAnchor: options.clockWallAnchor ?? new Date('2026-01-01T00:00:00.000Z'), clockPhase: options.clockPhase ?? 'RUNNING', @@ -215,6 +226,88 @@ const setTournamentFixture = async (redis: MemoryRedis, state: Record { + it.each([false, true])('starts from the server GAME time (recovery=%s)', async (recovery) => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-01-01T12:00:10Z')); + try { + const redis = new MemoryRedis(); + const context = buildContext({ + redis, + transport: new TournamentTransport(), + generals: [], + userId: 'admin', + roles: ['admin.tournament:che:default'], + clockWallAnchor: new Date('2026-01-01T12:00:00Z'), + recovery, + }); + const caller = appRouter.createCaller(context); + // @ts-expect-error 클라이언트가 보낸 WALL 일정은 입력 단계에서 거부한다. + await expect(caller.tournament.start({ nextAt: '2099-01-01T00:00:00Z' })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + expect(await redis.get('sammo:che:default:tournament:state')).toBeNull(); + await expect(caller.tournament.start()).resolves.toEqual({ ok: true }); + const state = JSON.parse((await redis.get('sammo:che:default:tournament:state'))!); + expect(state).toMatchObject({ + stage: 1, + openYear: 193, + openMonth: 7, + termSeconds: 60, + nextAt: recovery ? '2026-01-01T00:01:20.000Z' : '2026-01-01T00:01:10.000Z', + nextTick: recovery ? 48_000_000 : 42_000_000, + clockRevision: 1, + deadlineGeneration: 1, + }); + } finally { + vi.useRealTimers(); + } + }); + + it.each(['PREOPEN', 'SUSPENDED', 'RECONCILING'] as const)('rejects admin start in %s', async (clockPhase) => { + const redis = new MemoryRedis(); + const caller = appRouter.createCaller( + buildContext({ + redis, + transport: new TournamentTransport(), + generals: [], + userId: 'admin', + roles: ['admin'], + clockPhase, + }) + ); + await expect(caller.tournament.start()).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' }); + expect(await redis.get('sammo:che:default:tournament:state')).toBeNull(); + }); + + it('does not start against a stale Redis clock revision', async () => { + const redis = new MemoryRedis(); + await redis.set('sammo:che:default:clock:active-revision', '2'); + await redis.set('sammo:che:default:clock:deadline-generation', '1'); + await redis.set('sammo:che:default:clock:phase', 'RUNNING'); + const caller = appRouter.createCaller( + buildContext({ + redis, + transport: new TournamentTransport(), + generals: [], + userId: 'admin', + roles: ['admin'], + }) + ); + await expect(caller.tournament.start()).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' }); + expect(await redis.get('sammo:che:default:tournament:state')).toBeNull(); + }); + + it('requires authentication to start a tournament', async () => { + const context = buildContext({ + redis: new MemoryRedis(), + transport: new TournamentTransport(), + generals: [], + userId: 'guest', + }); + const caller = appRouter.createCaller({ ...context, auth: null }); + await expect(caller.tournament.start()).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); + it('returns persisted group fight logs to an authenticated tournament viewer', async () => { const redis = new MemoryRedis(); const transport = new TournamentTransport(); @@ -560,6 +653,7 @@ describe('tournament router permissions and mutations', () => { nextAt: '2026-07-26T01:00:00.000Z', }; + await expect(caller.tournament.start()).rejects.toMatchObject({ code: 'FORBIDDEN' }); await expect(caller.tournament.setState(state)).rejects.toMatchObject({ code: 'FORBIDDEN' }); await expect(caller.tournament.patchState({ phase: 1 })).rejects.toMatchObject({ code: 'FORBIDDEN' }); await expect(caller.tournament.setParticipants([])).rejects.toMatchObject({ code: 'FORBIDDEN' }); diff --git a/app/game-frontend/e2e/gameDeadlines.spec.ts b/app/game-frontend/e2e/gameDeadlines.spec.ts new file mode 100644 index 00000000..6ea30990 --- /dev/null +++ b/app/game-frontend/e2e/gameDeadlines.spec.ts @@ -0,0 +1,229 @@ +import { expect, test, type Page, type TestInfo } from '@playwright/test'; +import { writeFile } from 'node:fs/promises'; +import { canonicalFrontendFixture as fixture } from '../../../tools/frontend-legacy-parity/fixtures/canonical.js'; +import { gameBasePath, gameProfile, gameTrpcRoute } from './gameTestPaths.js'; + +const wall = new Date('2026-09-16T12:00:00Z'); +const game = new Date('2026-09-16T11:50:00Z'); +type ClockCase = 'normal' | 'recovery' | 'recovery-end' | 'suspended'; +const install = async (page: Page, routeName: string, clockCase: ClockCase, displayMode: string) => { + await page.clock.install({ time: wall }); + await page.clock.setFixedTime(wall); + await page.addInitScript( + ({ profile, base, displayMode }) => { + localStorage.setItem('sammo-game-token', 'ga_deadline_fixture'); + localStorage.setItem('sammo-game-profile', profile); + localStorage.setItem(`sammo-clock-display:${profile}:${base}/`, displayMode); + }, + { profile: gameProfile, base: gameBasePath, displayMode } + ); + const calls: string[] = []; + let voted = false; + const endAt = new Date(game.getTime() + 20_000).toISOString(); + await page.route('**/events**', (route) => route.abort()); + await page.route('**/image/**', (route) => + route.fulfill({ + contentType: 'image/svg+xml', + body: '', + }) + ); + await page.route('**/icons/**', (route) => + route.fulfill({ + contentType: 'image/svg+xml', + body: '', + }) + ); + await page.route(gameTrpcRoute, async (route) => { + const operations = decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split( + ',' + ); + const results = operations.map((operation) => { + calls.push(operation); + let data: unknown = {}; + if (operation === 'auth.status') data = { ok: true }; + if (operation === 'lobby.info') + data = { + ...fixture.game.lobby, + myGeneral: routeName === 'join' || routeName === 'select-general' ? null : { id: 1, name: '관우' }, + serverTime: game.toISOString(), + serverWallTime: wall.toISOString(), + clockMode: 'realtime', + clockRunning: clockCase !== 'suspended', + clockRecovery: + clockCase === 'recovery' || clockCase === 'recovery-end' + ? { + startsAt: wall.toISOString(), + endsAt: new Date( + wall.getTime() + (clockCase === 'recovery' ? 600_000 : 5_000) + ).toISOString(), + } + : null, + }; + if (operation === 'general.me') data = { general: { id: 1, name: '관우' } }; + if (operation === 'join.getConfig') + data = { + rules: { + stat: { total: 165, min: 15, max: 80, bonusMin: 3, bonusMax: 5 }, + allowDirectCreation: false, + allowCustomName: true, + }, + user: { + id: 'user', + displayName: '사용자', + canCreateGeneral: true, + icons: [], + preferredPicture: null, + }, + personalities: [{ key: 'Random', name: '???', info: '' }], + warSpecials: [], + nations: [], + serverInfo: { + currentYear: 193, + currentMonth: 7, + tickMinutes: 5, + maxGeneral: 500, + userGeneralCount: 0, + npcGeneralCount: 1, + }, + selectionPool: { enabled: routeName === 'select-general', hasGeneral: false, allowOptions: [] }, + npcPossession: { enabled: routeName === 'join' }, + }; + if (operation === 'join.getSelectionPool') + data = { + validUntil: endAt, + hasGeneral: false, + candidates: [ + { + uniqueName: 'candidate', + generalName: '관우', + leadership: 80, + strength: 80, + intel: 80, + picture: 'default.jpg', + imageServer: 0, + specialDomesticName: '인덕', + dex: [0, 0, 0, 0, 0], + }, + ], + }; + if (operation === 'join.listPossessCandidates') + data = { + validUntil: endAt, + pickMoreFrom: new Date(game.getTime() + 10_000).toISOString(), + pickMoreSeconds: 10, + tokenNonce: 'nonce', + candidates: [ + { + id: 1, + name: '관우', + nation: { id: 0, name: '재야', color: '#aaaaaa' }, + stats: { leadership: 80, strength: 80, intelligence: 80 }, + picture: 'default.jpg', + imageServer: 0, + personality: { code: 'x', name: '안전', info: '' }, + specialDomestic: { code: 'x', name: '인덕', info: '' }, + specialWar: { code: 'x', name: '무쌍', info: '' }, + keepCount: 3, + }, + ], + }; + if (operation === 'vote.getVoteList') data = fixture.game.surveyList; + if (operation === 'vote.getVoteDetail') + data = { + ...fixture.game.surveyDetail, + myVote: voted ? [0] : null, + voteInfo: { ...fixture.game.surveyDetail.voteInfo, endAt, closedAt: null, multipleOptions: 1 }, + }; + if (operation === 'vote.submitVote') { + voted = true; + data = { ok: true }; + } + if (operation === 'tournament.getAdminStatus') data = { ok: true }; + if (operation === 'tournament.getSnapshot') + data = { state: null, participants: [], matches: [], betCount: 0 }; + if (operation === 'tournament.getRankings') data = []; + if (operation === 'tournament.start') data = { ok: true }; + return { result: { data } }; + }); + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify( + new URL(route.request().url()).searchParams.get('batch') === '1' ? results : results[0] + ), + }); + }); + await page.goto(routeName); + return calls; +}; +const capture = async (page: Page, info: TestInfo, name: string) => { + await page.evaluate(() => document.fonts.ready); + await writeFile( + info.outputPath(`${name}.json`), + JSON.stringify( + await page.locator('main').evaluate((el) => ({ + html: el.outerHTML, + rect: el.getBoundingClientRect().toJSON(), + fontSize: getComputedStyle(el).fontSize, + })) + ) + ); + await page.screenshot({ path: info.outputPath(`${name}.png`), fullPage: true }); +}; +for (const width of [1365, 390]) { + for (const clockCase of ['normal', 'recovery', 'recovery-end', 'suspended'] as const) { + for (const routeName of ['survey', 'select-general', 'join']) { + test(`${routeName} GAME deadline ${clockCase} ${width}px`, async ({ page }, info) => { + await page.setViewportSize({ width, height: 900 }); + const calls = await install(page, routeName, clockCase, width === 390 ? 'real' : 'game'); + const voteButton = page.getByRole('button', { name: '투표', exact: true }); + const expired = page.locator(routeName === 'join' ? '.npc-token-expired' : '.expired-text'); + const refresh = page.getByRole('button', { name: /다른 장수 보기/ }); + if (routeName === 'survey') await expect(voteButton).toBeVisible(); + else { + await expect(page.getByText('까지 유효', { exact: false })).toBeVisible(); + await expect(expired).toHaveCount(0); + } + if (routeName === 'select-general') + await page.getByRole('button', { name: '선택하기', exact: true }).click(); + if (routeName === 'join') await expect(refresh).toBeDisabled(); + await capture(page, info, 'open'); + await page.clock.pauseAt(wall); + await page.clock.setSystemTime(wall); + const cooldownMs = clockCase === 'recovery' || clockCase === 'recovery-end' ? 5_250 : 10_250; + await page.clock.runFor(cooldownMs); + if (routeName === 'join') { + if (clockCase === 'suspended') await expect(refresh).toBeDisabled(); + else { + await expect(refresh).toBeEnabled(); + await refresh.click(); + await page.clock.runFor(50); + await expect + .poll(() => calls.filter((call) => call === 'join.listPossessCandidates').length) + .toBe(2); + } + } + const closeMs = clockCase === 'recovery' ? 10_250 : clockCase === 'recovery-end' ? 15_250 : 20_250; + await page.clock.runFor(closeMs - cooldownMs); + if (routeName === 'survey') { + if (clockCase === 'suspended') await expect(voteButton).toBeVisible(); + else await expect(voteButton).toHaveCount(0); + } else if (clockCase === 'suspended') await expect(expired).toHaveCount(0); + else await expect(expired).toBeVisible(); + await capture(page, info, 'after'); + }); + } + } +} +test('survey submits during recovery', async ({ page }) => { + const votes = await install(page, 'survey', 'recovery', 'real'); + await page.getByRole('radio').first().check(); + await page.getByRole('button', { name: '투표', exact: true }).click(); + await expect.poll(() => votes.includes('vote.submitVote')).toBe(true); + await expect(page.getByRole('button', { name: '투표', exact: true })).toHaveCount(0); +}); +test('admin start uses the server-owned start endpoint', async ({ page }) => { + const calls = await install(page, 'tournament', 'recovery', 'real'); + await page.getByRole('button', { name: '개최', exact: true }).click(); + await expect.poll(() => calls.includes('tournament.start')).toBe(true); + expect(calls).not.toContain('tournament.setState'); +}); diff --git a/app/game-frontend/e2e/npcPossession.spec.ts b/app/game-frontend/e2e/npcPossession.spec.ts index 336a5980..5266fdf7 100644 --- a/app/game-frontend/e2e/npcPossession.spec.ts +++ b/app/game-frontend/e2e/npcPossession.spec.ts @@ -7,6 +7,7 @@ const operationNames = (route: Route) => decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(','); type FixtureState = { + clockOffsetMs?: number; reservationCalls: number; reservationInputs: Array>; rawBodies: unknown[]; @@ -131,6 +132,10 @@ const installFixture = async (page: Page, state: FixtureState): Promise => if (operation === 'auth.status') return response({ ok: true }); if (operation === 'lobby.info') { return response({ + serverTime: new Date(Date.now() + (state.clockOffsetMs ?? 0)).toISOString(), + serverWallTime: new Date(Date.now() + (state.clockOffsetMs ?? 0)).toISOString(), + clockRunning: true, + clockMode: 'realtime', myGeneral: state.hasGeneral ? { id: 1, name: '빙의후보1' } : null, year: 180, month: 1, @@ -445,6 +450,7 @@ test('renders Ref-shaped token cards, preserves keep cooldown and retries posses firstRequestId as string ); + state.clockOffsetMs = 120_000; await page.evaluate(() => { const expiredNow = Date.now() + 120_000; Date.now = () => expiredNow; diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index 4e309933..bd716471 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -37,6 +37,7 @@ export default defineConfig({ 'auction.spec.ts', 'nationBetting.spec.ts', 'tournamentBracket.spec.ts', + 'gameDeadlines.spec.ts', 'battleSimulator.spec.ts', 'battleSimulatorRef.spec.ts', 'commandArguments.spec.ts', diff --git a/app/game-frontend/src/views/JoinView.vue b/app/game-frontend/src/views/JoinView.vue index b0083d33..e1b7e6df 100644 --- a/app/game-frontend/src/views/JoinView.vue +++ b/app/game-frontend/src/views/JoinView.vue @@ -1,5 +1,5 @@