fix: 설문과 장수 선택 기한 및 토너먼트 개최를 게임 시각으로 통일

This commit is contained in:
2026-09-16 01:38:21 +00:00
parent a51574d20c
commit f4aabec1fa
9 changed files with 388 additions and 56 deletions
+26 -3
View File
@@ -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 <T>(
tournamentMutationLockHeld?: boolean;
},
store: TournamentStore,
operation: () => Promise<T>,
operation: (gameTime: CurrentGameTime) => Promise<T>,
ensureFence = ensureActiveRedisClockFence
): Promise<T> => {
const gameTime = await loadCurrentGameTime(ctx.db);
@@ -85,7 +85,7 @@ const withTournamentClockMutation = async <T>(
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 () => {
@@ -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<string, un
};
describe('tournament router permissions and mutations', () => {
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' });