From 4ca749f0b0c5949f53726dfbc27f57ed8827fdb1 Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 27 Aug 2026 06:05:38 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=EA=B0=80=EC=98=A4=ED=94=88=20=EC=84=A4?= =?UTF-8?q?=EB=AC=B8=EC=9D=98=20=EC=A7=84=ED=96=89=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EB=A5=BC=20=EB=85=BC=EB=A6=AC=20=EC=8B=9C=EA=B0=81=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=ED=8C=90=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 메인 현황도 투표 경로와 같은 game tick 우선 판정을 사용해 PREOPEN 설문을 숨기지 않는다. 투표 완료 후에는 설문 제목을 유지하고 새 설문 알림만 생략하는 API와 Chromium 회귀를 추가한다. --- app/game-api/src/router/general/index.ts | 43 +++++++++++++-- .../test/mainFrontStatusRouter.test.ts | 55 +++++++++++++++++-- app/game-frontend/e2e/mainNavigation.spec.ts | 20 +++++++ 3 files changed, 109 insertions(+), 9 deletions(-) diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index f7e47c90..d9e2c1c3 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -15,6 +15,7 @@ import { } from '../../trpc.js'; import { ConflictingTurnDaemonCommandError } from '../../daemon/databaseTransport.js'; import { resolveAccessWindows } from '../../services/generalAccess.js'; +import { loadCurrentGameTime, type CurrentGameTime } from '../../services/gameClock.js'; import { adjustAccountIconForUser } from '../../services/accountIconSync.js'; import { loadCrewTypeDisplayDetails, @@ -43,6 +44,7 @@ import { resolveMainNationTech, splitNationTraitInfo, } from '../../services/mainNationProjection.js'; + import { resolveGeneralTypeCall, resolveLeadershipBonus, @@ -51,6 +53,29 @@ import { resolveRemainingMinutes, } from '../../services/generalBasicCardProjection.js'; +type FrontStatusPoll = { + id: number; + title: string; + startAt: Date; + startTick: bigint | null; + endAt: Date | null; + endTick: bigint | null; + closedAt: Date | null; +}; + +const isFrontStatusPollActive = (poll: FrontStatusPoll, gameTime: CurrentGameTime): boolean => { + if (poll.closedAt) return false; + const started = + poll.startTick !== null && gameTime.tick !== null + ? poll.startTick <= BigInt(gameTime.tick) + : poll.startAt.getTime() <= gameTime.now.getTime(); + const ended = + poll.endTick !== null && gameTime.tick !== null + ? poll.endTick < BigInt(gameTime.tick) + : Boolean(poll.endAt && poll.endAt.getTime() < gameTime.now.getTime()); + return started && !ended; +}; + const zGeneralSettings = z.object({ tnmt: z.number().int().optional(), defence_train: z.number().int().optional(), @@ -965,7 +990,7 @@ export const generalRouter = router({ const now = new Date(); const { scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, worldState.meta); - const [onlineAccess, ownNation, latestVote] = await Promise.all([ + const [onlineAccess, ownNation, openPolls, gameTime] = await Promise.all([ ctx.db.generalAccessLog.findMany({ where: { lastActionAt: { @@ -980,19 +1005,29 @@ export const generalRouter = router({ select: { meta: true }, }) : Promise.resolve(null), - ctx.db.votePoll.findFirst({ + ctx.db.votePoll.findMany({ where: { - startAt: { lte: now }, closedAt: null, - OR: [{ endAt: null }, { endAt: { gte: now } }], }, orderBy: { id: 'desc' }, select: { id: true, title: true, + startAt: true, + startTick: true, + endAt: true, + endTick: true, + closedAt: true, }, }), + loadCurrentGameTime(ctx.db, now), ]); + // vote_poll timestamps are logical game-wall values. During PREOPEN the + // logical clock is held at the future open anchor, so comparing them with + // JavaScript wall time inside a timestamp-without-time-zone predicate can + // hide an otherwise active poll. Resolve the same tick-first clock contract + // used by voting instead; hasVoted only affects the notice, not activity. + const latestVote = openPolls.find((poll) => isFrontStatusPollActive(poll, gameTime)) ?? null; const onlineGeneralIds = onlineAccess.map((entry) => entry.generalId); const onlineGenerals = diff --git a/app/game-api/test/mainFrontStatusRouter.test.ts b/app/game-api/test/mainFrontStatusRouter.test.ts index 3833eee5..795556da 100644 --- a/app/game-api/test/mainFrontStatusRouter.test.ts +++ b/app/game-api/test/mainFrontStatusRouter.test.ts @@ -20,7 +20,9 @@ const auth: GameSessionTokenPayload = { sanctions: {}, }; -const buildContext = (options: { auth?: GameSessionTokenPayload | null; hasVoted?: boolean } = {}) => +const buildContext = ( + options: { auth?: GameSessionTokenPayload | null; hasVoted?: boolean; preopenClock?: boolean } = {} +) => ({ auth: options.auth === undefined ? auth : options.auth, db: { @@ -40,6 +42,14 @@ const buildContext = (options: { auth?: GameSessionTokenPayload | null; hasVoted worldState: { findFirst: vi.fn(async () => ({ tickSeconds: 3600, + ...(options.preopenClock + ? { + clockBaseTime: new Date('2026-08-27T16:00:00.000Z'), + clockTick: -180_000_000n, + clockMode: 'realtime', + clockWallAnchor: new Date('2026-08-27T11:00:00.000Z'), + } + : {}), meta: { serverId: 'che_260819_front', lastTurnTime: '2026-07-26T10:00:00.000Z', @@ -61,10 +71,19 @@ const buildContext = (options: { auth?: GameSessionTokenPayload | null; hasVoted ]), }, votePoll: { - findFirst: vi.fn(async () => ({ - id: 12, - title: '다음 시즌 턴 시간', - })), + findMany: vi.fn(async () => [ + { + id: 12, + title: '다음 시즌 턴 시간', + startAt: new Date( + options.preopenClock ? '2026-08-27T11:00:00.000Z' : '2026-07-26T10:00:00.000Z' + ), + startTick: options.preopenClock ? -180_000_000n : null, + endAt: null, + endTick: null, + closedAt: null, + }, + ]), }, vote: { findFirst: vi.fn(async () => (options.hasVoted ? { id: 21 } : null)), @@ -122,6 +141,32 @@ describe('general.getFrontStatus', () => { }); }); + it('keeps a PREOPEN logical-time survey active after the authenticated general voted', async () => { + vi.setSystemTime(new Date('2026-08-27T05:56:00.000Z')); + const context = buildContext({ hasVoted: true, preopenClock: true }); + + await expect(appRouter.createCaller(context).general.getFrontStatus()).resolves.toMatchObject({ + latestVote: { + id: 12, + title: '다음 시즌 턴 시간', + hasVoted: true, + }, + }); + expect(context.db.votePoll.findMany).toHaveBeenCalledWith({ + where: { closedAt: null }, + orderBy: { id: 'desc' }, + select: { + id: true, + title: true, + startAt: true, + startTick: true, + endAt: true, + endTick: true, + closedAt: true, + }, + }); + }); + it('requires a game session and does not expose names or policy publicly', async () => { const caller = appRouter.createCaller(buildContext({ auth: null })); diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 2cbfbddc..e2e9206f 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -1181,6 +1181,26 @@ test('scopes the new-survey notice cursor to the reset-specific server ID', asyn await expect(page.locator('.survey-notice')).toContainText('새로운 설문조사가 있습니다.'); }); +test('keeps the active survey title after voting without reopening the new-survey notice', async ({ page }) => { + const state: NavigationFixture = { + officerLevel: 5, + permission: 2, + nationLevel: 3, + stage: 0, + npcMode: 1, + serverId: 'che_260827_preopen', + latestVote: { id: 1, title: '신버전입니다.', hasVoted: true }, + generalMeCalls: 0, + operations: [], + }; + await installFixture(page, state); + + await waitForMain(page); + + await expect(page.locator('.vote-status')).toHaveText('설문: 신버전입니다.'); + await expect(page.locator('.survey-notice')).toHaveCount(0); +}); + test('desktop menus preserve ref columns, prefix-safe routes, and controlled dropdown behavior', async ({ page, }, testInfo) => {