fix: 가오픈 설문의 진행 상태를 논리 시각으로 판정
메인 현황도 투표 경로와 같은 game tick 우선 판정을 사용해 PREOPEN 설문을 숨기지 않는다. 투표 완료 후에는 설문 제목을 유지하고 새 설문 알림만 생략하는 API와 Chromium 회귀를 추가한다.
This commit is contained in:
@@ -15,6 +15,7 @@ import {
|
|||||||
} from '../../trpc.js';
|
} from '../../trpc.js';
|
||||||
import { ConflictingTurnDaemonCommandError } from '../../daemon/databaseTransport.js';
|
import { ConflictingTurnDaemonCommandError } from '../../daemon/databaseTransport.js';
|
||||||
import { resolveAccessWindows } from '../../services/generalAccess.js';
|
import { resolveAccessWindows } from '../../services/generalAccess.js';
|
||||||
|
import { loadCurrentGameTime, type CurrentGameTime } from '../../services/gameClock.js';
|
||||||
import { adjustAccountIconForUser } from '../../services/accountIconSync.js';
|
import { adjustAccountIconForUser } from '../../services/accountIconSync.js';
|
||||||
import {
|
import {
|
||||||
loadCrewTypeDisplayDetails,
|
loadCrewTypeDisplayDetails,
|
||||||
@@ -43,6 +44,7 @@ import {
|
|||||||
resolveMainNationTech,
|
resolveMainNationTech,
|
||||||
splitNationTraitInfo,
|
splitNationTraitInfo,
|
||||||
} from '../../services/mainNationProjection.js';
|
} from '../../services/mainNationProjection.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
resolveGeneralTypeCall,
|
resolveGeneralTypeCall,
|
||||||
resolveLeadershipBonus,
|
resolveLeadershipBonus,
|
||||||
@@ -51,6 +53,29 @@ import {
|
|||||||
resolveRemainingMinutes,
|
resolveRemainingMinutes,
|
||||||
} from '../../services/generalBasicCardProjection.js';
|
} 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({
|
const zGeneralSettings = z.object({
|
||||||
tnmt: z.number().int().optional(),
|
tnmt: z.number().int().optional(),
|
||||||
defence_train: z.number().int().optional(),
|
defence_train: z.number().int().optional(),
|
||||||
@@ -965,7 +990,7 @@ export const generalRouter = router({
|
|||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const { scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, worldState.meta);
|
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({
|
ctx.db.generalAccessLog.findMany({
|
||||||
where: {
|
where: {
|
||||||
lastActionAt: {
|
lastActionAt: {
|
||||||
@@ -980,19 +1005,29 @@ export const generalRouter = router({
|
|||||||
select: { meta: true },
|
select: { meta: true },
|
||||||
})
|
})
|
||||||
: Promise.resolve(null),
|
: Promise.resolve(null),
|
||||||
ctx.db.votePoll.findFirst({
|
ctx.db.votePoll.findMany({
|
||||||
where: {
|
where: {
|
||||||
startAt: { lte: now },
|
|
||||||
closedAt: null,
|
closedAt: null,
|
||||||
OR: [{ endAt: null }, { endAt: { gte: now } }],
|
|
||||||
},
|
},
|
||||||
orderBy: { id: 'desc' },
|
orderBy: { id: 'desc' },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
title: 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 onlineGeneralIds = onlineAccess.map((entry) => entry.generalId);
|
||||||
const onlineGenerals =
|
const onlineGenerals =
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ const auth: GameSessionTokenPayload = {
|
|||||||
sanctions: {},
|
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,
|
auth: options.auth === undefined ? auth : options.auth,
|
||||||
db: {
|
db: {
|
||||||
@@ -40,6 +42,14 @@ const buildContext = (options: { auth?: GameSessionTokenPayload | null; hasVoted
|
|||||||
worldState: {
|
worldState: {
|
||||||
findFirst: vi.fn(async () => ({
|
findFirst: vi.fn(async () => ({
|
||||||
tickSeconds: 3600,
|
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: {
|
meta: {
|
||||||
serverId: 'che_260819_front',
|
serverId: 'che_260819_front',
|
||||||
lastTurnTime: '2026-07-26T10:00:00.000Z',
|
lastTurnTime: '2026-07-26T10:00:00.000Z',
|
||||||
@@ -61,10 +71,19 @@ const buildContext = (options: { auth?: GameSessionTokenPayload | null; hasVoted
|
|||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
votePoll: {
|
votePoll: {
|
||||||
findFirst: vi.fn(async () => ({
|
findMany: vi.fn(async () => [
|
||||||
|
{
|
||||||
id: 12,
|
id: 12,
|
||||||
title: '다음 시즌 턴 시간',
|
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: {
|
vote: {
|
||||||
findFirst: vi.fn(async () => (options.hasVoted ? { id: 21 } : null)),
|
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 () => {
|
it('requires a game session and does not expose names or policy publicly', async () => {
|
||||||
const caller = appRouter.createCaller(buildContext({ auth: null }));
|
const caller = appRouter.createCaller(buildContext({ auth: null }));
|
||||||
|
|
||||||
|
|||||||
@@ -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('새로운 설문조사가 있습니다.');
|
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 ({
|
test('desktop menus preserve ref columns, prefix-safe routes, and controlled dropdown behavior', async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user