fix: 설문과 장수 선택 기한 및 토너먼트 개최를 게임 시각으로 통일
This commit is contained in:
@@ -10,7 +10,7 @@ import { buildTournamentKeys } from '../../tournament/keys.js';
|
|||||||
import { assignManualApplicantGroup } from '../../tournament/workerHelpers.js';
|
import { assignManualApplicantGroup } from '../../tournament/workerHelpers.js';
|
||||||
import { accessAuthedProcedure, authedProcedure, engineAuthedProcedure, procedure, router } from '../../trpc.js';
|
import { accessAuthedProcedure, authedProcedure, engineAuthedProcedure, procedure, router } from '../../trpc.js';
|
||||||
import { getMyGeneral } from '../shared/general.js';
|
import { getMyGeneral } from '../shared/general.js';
|
||||||
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
import { loadCurrentGameTime, type CurrentGameTime } from '../../services/gameClock.js';
|
||||||
import {
|
import {
|
||||||
ensureActiveRedisClockFence,
|
ensureActiveRedisClockFence,
|
||||||
ensureBettingRedisClockFence,
|
ensureBettingRedisClockFence,
|
||||||
@@ -67,7 +67,7 @@ const withTournamentClockMutation = async <T>(
|
|||||||
tournamentMutationLockHeld?: boolean;
|
tournamentMutationLockHeld?: boolean;
|
||||||
},
|
},
|
||||||
store: TournamentStore,
|
store: TournamentStore,
|
||||||
operation: () => Promise<T>,
|
operation: (gameTime: CurrentGameTime) => Promise<T>,
|
||||||
ensureFence = ensureActiveRedisClockFence
|
ensureFence = ensureActiveRedisClockFence
|
||||||
): Promise<T> => {
|
): Promise<T> => {
|
||||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||||
@@ -85,7 +85,7 @@ const withTournamentClockMutation = async <T>(
|
|||||||
dateToTick: gameTime.dateToTick,
|
dateToTick: gameTime.dateToTick,
|
||||||
};
|
};
|
||||||
return store.withClockContext(clockContext, () =>
|
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 };
|
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 }) => {
|
setState: adminProcedure.input(zTournamentState).mutation(async ({ ctx, input }) => {
|
||||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||||
return withTournamentClockMutation(ctx, store, async () => {
|
return withTournamentClockMutation(ctx, store, async () => {
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ const buildContext = (options: {
|
|||||||
clockPhase?: 'PREOPEN' | 'RUNNING' | 'MANUAL' | 'SUSPENDED' | 'RECONCILING';
|
clockPhase?: 'PREOPEN' | 'RUNNING' | 'MANUAL' | 'SUSPENDED' | 'RECONCILING';
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
clockWallAnchor?: Date;
|
clockWallAnchor?: Date;
|
||||||
|
recovery?: boolean;
|
||||||
}): GameApiContext => {
|
}): GameApiContext => {
|
||||||
const db = {
|
const db = {
|
||||||
general: {
|
general: {
|
||||||
@@ -165,10 +166,20 @@ const buildContext = (options: {
|
|||||||
rankData: {
|
rankData: {
|
||||||
findMany: async () => options.rankRows ?? [],
|
findMany: async () => options.rankRows ?? [],
|
||||||
},
|
},
|
||||||
|
$queryRaw: async () => [{ ready: true }],
|
||||||
worldState: {
|
worldState: {
|
||||||
findFirst: async () => ({
|
findFirst: async () => ({
|
||||||
clockBaseTime: new Date('2026-01-01T00:00:00.000Z'),
|
clockBaseTime: new Date('2026-01-01T00:00:00.000Z'),
|
||||||
|
currentYear: 193,
|
||||||
|
currentMonth: 7,
|
||||||
clockTick: 0n,
|
clockTick: 0n,
|
||||||
|
...(options.recovery
|
||||||
|
? {
|
||||||
|
clockRecoveryStartTick: 0n,
|
||||||
|
clockRecoveryEndTick: 720_000_000n,
|
||||||
|
clockRecoveryStartWallAt: options.clockWallAnchor,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
clockMode: 'realtime',
|
clockMode: 'realtime',
|
||||||
clockWallAnchor: options.clockWallAnchor ?? new Date('2026-01-01T00:00:00.000Z'),
|
clockWallAnchor: options.clockWallAnchor ?? new Date('2026-01-01T00:00:00.000Z'),
|
||||||
clockPhase: options.clockPhase ?? 'RUNNING',
|
clockPhase: options.clockPhase ?? 'RUNNING',
|
||||||
@@ -215,6 +226,88 @@ const setTournamentFixture = async (redis: MemoryRedis, state: Record<string, un
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('tournament router permissions and mutations', () => {
|
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 () => {
|
it('returns persisted group fight logs to an authenticated tournament viewer', async () => {
|
||||||
const redis = new MemoryRedis();
|
const redis = new MemoryRedis();
|
||||||
const transport = new TournamentTransport();
|
const transport = new TournamentTransport();
|
||||||
@@ -560,6 +653,7 @@ describe('tournament router permissions and mutations', () => {
|
|||||||
nextAt: '2026-07-26T01:00:00.000Z',
|
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.setState(state)).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||||
await expect(caller.tournament.patchState({ phase: 1 })).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
await expect(caller.tournament.patchState({ phase: 1 })).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||||
await expect(caller.tournament.setParticipants([])).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
await expect(caller.tournament.setParticipants([])).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||||
|
|||||||
@@ -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: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#888"/></svg>',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/icons/**', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: 'image/svg+xml',
|
||||||
|
body: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"/>',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
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');
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ const operationNames = (route: Route) =>
|
|||||||
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
|
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
|
||||||
|
|
||||||
type FixtureState = {
|
type FixtureState = {
|
||||||
|
clockOffsetMs?: number;
|
||||||
reservationCalls: number;
|
reservationCalls: number;
|
||||||
reservationInputs: Array<Record<string, unknown>>;
|
reservationInputs: Array<Record<string, unknown>>;
|
||||||
rawBodies: unknown[];
|
rawBodies: unknown[];
|
||||||
@@ -131,6 +132,10 @@ const installFixture = async (page: Page, state: FixtureState): Promise<void> =>
|
|||||||
if (operation === 'auth.status') return response({ ok: true });
|
if (operation === 'auth.status') return response({ ok: true });
|
||||||
if (operation === 'lobby.info') {
|
if (operation === 'lobby.info') {
|
||||||
return response({
|
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,
|
myGeneral: state.hasGeneral ? { id: 1, name: '빙의후보1' } : null,
|
||||||
year: 180,
|
year: 180,
|
||||||
month: 1,
|
month: 1,
|
||||||
@@ -445,6 +450,7 @@ test('renders Ref-shaped token cards, preserves keep cooldown and retries posses
|
|||||||
firstRequestId as string
|
firstRequestId as string
|
||||||
);
|
);
|
||||||
|
|
||||||
|
state.clockOffsetMs = 120_000;
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const expiredNow = Date.now() + 120_000;
|
const expiredNow = Date.now() + 120_000;
|
||||||
Date.now = () => expiredNow;
|
Date.now = () => expiredNow;
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export default defineConfig({
|
|||||||
'auction.spec.ts',
|
'auction.spec.ts',
|
||||||
'nationBetting.spec.ts',
|
'nationBetting.spec.ts',
|
||||||
'tournamentBracket.spec.ts',
|
'tournamentBracket.spec.ts',
|
||||||
|
'gameDeadlines.spec.ts',
|
||||||
'battleSimulator.spec.ts',
|
'battleSimulator.spec.ts',
|
||||||
'battleSimulatorRef.spec.ts',
|
'battleSimulatorRef.spec.ts',
|
||||||
'commandArguments.spec.ts',
|
'commandArguments.spec.ts',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||||
import PanelCard from '../components/ui/PanelCard.vue';
|
import PanelCard from '../components/ui/PanelCard.vue';
|
||||||
@@ -11,7 +11,7 @@ import { useSessionStore } from '../stores/session';
|
|||||||
import { cityLevelMap, formatOfficerLevelText, regionMap } from '../utils/nationFormat';
|
import { cityLevelMap, formatOfficerLevelText, regionMap } from '../utils/nationFormat';
|
||||||
import { getNpcColor } from '../utils/npcColor';
|
import { getNpcColor } from '../utils/npcColor';
|
||||||
import { useClockDisplay } from '../composables/useClockDisplay';
|
import { useClockDisplay } from '../composables/useClockDisplay';
|
||||||
const { formatTime: formatSeoulDateTime } = useClockDisplay();
|
const { formatTime: formatSeoulDateTime, gameTime } = useClockDisplay();
|
||||||
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
||||||
import { abilityLeadint, abilityLeadpow, abilityPowint, abilityRand, type GeneralStats } from '../utils/generalStats';
|
import { abilityLeadint, abilityLeadpow, abilityPowint, abilityRand, type GeneralStats } from '../utils/generalStats';
|
||||||
import { legacyLuminanceTextColor } from '../utils/legacyNationColor';
|
import { legacyLuminanceTextColor } from '../utils/legacyNationColor';
|
||||||
@@ -185,8 +185,6 @@ const npcReservation = ref<PossessReservation | null>(null);
|
|||||||
const npcLoading = ref(false);
|
const npcLoading = ref(false);
|
||||||
const npcError = ref<string | null>(null);
|
const npcError = ref<string | null>(null);
|
||||||
const keptNpcIds = ref<number[]>([]);
|
const keptNpcIds = ref<number[]>([]);
|
||||||
const nowMs = ref(Date.now());
|
|
||||||
const npcPickMoreAvailableAtMs = ref(0);
|
|
||||||
const pendingPossessAction = ref<PendingPossessAction | null>(null);
|
const pendingPossessAction = ref<PendingPossessAction | null>(null);
|
||||||
const npcGeneralList = ref<NpcGeneralList | null>(null);
|
const npcGeneralList = ref<NpcGeneralList | null>(null);
|
||||||
const npcGeneralListLoading = ref(false);
|
const npcGeneralListLoading = ref(false);
|
||||||
@@ -202,17 +200,22 @@ const publicGeneralsLoaded = ref(false);
|
|||||||
const publicGeneralsLoading = ref(false);
|
const publicGeneralsLoading = ref(false);
|
||||||
const publicGeneralsError = ref('');
|
const publicGeneralsError = ref('');
|
||||||
const publicGeneralFilter = ref('');
|
const publicGeneralFilter = ref('');
|
||||||
let npcTimer: number | null = null;
|
|
||||||
|
|
||||||
const npcCandidates = computed<PossessCandidate[]>(() => npcReservation.value?.candidates ?? []);
|
const npcCandidates = computed<PossessCandidate[]>(() => npcReservation.value?.candidates ?? []);
|
||||||
const npcValidUntilMs = computed(() => {
|
const npcValidUntilMs = computed(() => {
|
||||||
const value = npcReservation.value?.validUntil;
|
const value = npcReservation.value?.validUntil;
|
||||||
return value ? new Date(value).getTime() : 0;
|
return value ? new Date(value).getTime() : 0;
|
||||||
});
|
});
|
||||||
const npcExpired = computed(() => npcValidUntilMs.value > 0 && npcValidUntilMs.value < nowMs.value);
|
const npcExpired = computed(
|
||||||
const npcPickMoreSeconds = computed(() =>
|
() => npcValidUntilMs.value > 0 && gameTime.value !== null && npcValidUntilMs.value < gameTime.value.getTime()
|
||||||
Math.max(0, Math.ceil((npcPickMoreAvailableAtMs.value - nowMs.value) / 1000))
|
|
||||||
);
|
);
|
||||||
|
const npcPickMoreSeconds = computed(() => {
|
||||||
|
const reservation = npcReservation.value;
|
||||||
|
if (!reservation) return 0;
|
||||||
|
if (!gameTime.value) return reservation.pickMoreSeconds;
|
||||||
|
// pickMoreFrom은 GAME 기한이므로 복구 배속과 정지 상태도 같은 시계를 따른다.
|
||||||
|
return Math.max(0, Math.ceil((new Date(reservation.pickMoreFrom).getTime() - gameTime.value.getTime()) / 1000));
|
||||||
|
});
|
||||||
const hasPendingPossession = computed(
|
const hasPendingPossession = computed(
|
||||||
() => pendingPossessAction.value !== null && pendingPossessAction.value.ownerUserId === joinConfig.value?.user.id
|
() => pendingPossessAction.value !== null && pendingPossessAction.value.ownerUserId === joinConfig.value?.user.id
|
||||||
);
|
);
|
||||||
@@ -261,7 +264,8 @@ const filteredPublicGenerals = computed(() => {
|
|||||||
return sortGeneralsByTypeThenName(filtered);
|
return sortGeneralsByTypeThenName(filtered);
|
||||||
});
|
});
|
||||||
const npcValidColor = computed(() => {
|
const npcValidColor = computed(() => {
|
||||||
const remaining = npcValidUntilMs.value - nowMs.value;
|
if (!gameTime.value) return '#ffffff';
|
||||||
|
const remaining = npcValidUntilMs.value - gameTime.value.getTime();
|
||||||
if (remaining > 30_000) return '#ffffff';
|
if (remaining > 30_000) return '#ffffff';
|
||||||
const channel = Math.max(0, Math.min(255, Math.round((remaining / 30_000) * 255)));
|
const channel = Math.max(0, Math.min(255, Math.round((remaining / 30_000) * 255)));
|
||||||
return `rgb(255, ${channel}, ${channel})`;
|
return `rgb(255, ${channel}, ${channel})`;
|
||||||
@@ -465,9 +469,6 @@ const loadNpcCandidates = async (refresh = false) => {
|
|||||||
});
|
});
|
||||||
npcReservation.value = reservation;
|
npcReservation.value = reservation;
|
||||||
keptNpcIds.value = [];
|
keptNpcIds.value = [];
|
||||||
const receivedAt = Date.now();
|
|
||||||
nowMs.value = receivedAt;
|
|
||||||
npcPickMoreAvailableAtMs.value = receivedAt + reservation.pickMoreSeconds * 1000;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
npcError.value = err instanceof Error ? err.message : 'npc_list_failed';
|
npcError.value = err instanceof Error ? err.message : 'npc_list_failed';
|
||||||
if (refresh) {
|
if (refresh) {
|
||||||
@@ -648,17 +649,8 @@ watch(contextTab, (value) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
npcTimer = window.setInterval(() => {
|
|
||||||
nowMs.value = Date.now();
|
|
||||||
}, 250);
|
|
||||||
void loadConfig();
|
void loadConfig();
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (npcTimer !== null) {
|
|
||||||
window.clearInterval(npcTimer);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -1119,7 +1111,9 @@ onUnmounted(() => {
|
|||||||
id="btn-pick-more"
|
id="btn-pick-more"
|
||||||
class="legacy-button legacy-button--secondary"
|
class="legacy-button legacy-button--secondary"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="npcLoading || npcPickMoreSeconds > 0 || submitting || hasPendingPossession"
|
:disabled="
|
||||||
|
npcLoading || !gameTime || npcPickMoreSeconds > 0 || submitting || hasPendingPossession
|
||||||
|
"
|
||||||
@click="loadNpcCandidates(true)"
|
@click="loadNpcCandidates(true)"
|
||||||
>
|
>
|
||||||
다른 장수 보기<span v-if="npcPickMoreSeconds > 0">({{ npcPickMoreSeconds }}초)</span>
|
다른 장수 보기<span v-if="npcPickMoreSeconds > 0">({{ npcPickMoreSeconds }}초)</span>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { usePageExit } from '../composables/usePageExit';
|
import { usePageExit } from '../composables/usePageExit';
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||||
import { useSessionStore } from '../stores/session';
|
import { useSessionStore } from '../stores/session';
|
||||||
import { useClockDisplay } from '../composables/useClockDisplay';
|
import { useClockDisplay } from '../composables/useClockDisplay';
|
||||||
const { formatTime: formatSeoulDateTime } = useClockDisplay();
|
const { formatTime: formatSeoulDateTime, gameTime } = useClockDisplay();
|
||||||
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
||||||
import { legacyLuminanceTextColor } from '../utils/legacyNationColor';
|
import { legacyLuminanceTextColor } from '../utils/legacyNationColor';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
@@ -38,8 +38,6 @@ const selectedIconId = ref('');
|
|||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
const now = ref(Date.now());
|
|
||||||
let timer: number | null = null;
|
|
||||||
const pendingActionStorageKey = 'sammo-select-pool-pending-action';
|
const pendingActionStorageKey = 'sammo-select-pool-pending-action';
|
||||||
|
|
||||||
const candidates = computed(() => reservation.value?.candidates ?? []);
|
const candidates = computed(() => reservation.value?.candidates ?? []);
|
||||||
@@ -54,9 +52,12 @@ const validUntil = computed(() => {
|
|||||||
const value = reservation.value?.validUntil;
|
const value = reservation.value?.validUntil;
|
||||||
return value ? new Date(value).getTime() : 0;
|
return value ? new Date(value).getTime() : 0;
|
||||||
});
|
});
|
||||||
const expired = computed(() => validUntil.value > 0 && now.value > validUntil.value);
|
const expired = computed(
|
||||||
|
() => validUntil.value > 0 && gameTime.value !== null && gameTime.value.getTime() > validUntil.value
|
||||||
|
);
|
||||||
const validUntilColor = computed(() => {
|
const validUntilColor = computed(() => {
|
||||||
const remaining = validUntil.value - now.value;
|
if (!gameTime.value) return '#fff';
|
||||||
|
const remaining = validUntil.value - gameTime.value.getTime();
|
||||||
if (remaining <= 0 || remaining > 30_000) {
|
if (remaining <= 0 || remaining > 30_000) {
|
||||||
return '#fff';
|
return '#fff';
|
||||||
}
|
}
|
||||||
@@ -227,7 +228,6 @@ async function loadPage(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
reservation.value = await trpc.join.getSelectionPool.mutate();
|
reservation.value = await trpc.join.getSelectionPool.mutate();
|
||||||
now.value = Date.now();
|
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
console.error(cause);
|
console.error(cause);
|
||||||
error.value = errorText(cause);
|
error.value = errorText(cause);
|
||||||
@@ -250,17 +250,8 @@ const goBack = (): void => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
timer = window.setInterval(() => {
|
|
||||||
now.value = Date.now();
|
|
||||||
}, 1_000);
|
|
||||||
void loadPage();
|
void loadPage();
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
if (timer !== null) {
|
|
||||||
window.clearInterval(timer);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useClockDisplay } from '../composables/useClockDisplay';
|
||||||
import { usePageExit } from '../composables/usePageExit';
|
import { usePageExit } from '../composables/usePageExit';
|
||||||
|
|
||||||
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
|
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
|
||||||
@@ -7,6 +8,7 @@ import { computed, onMounted, ref } from 'vue';
|
|||||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
|
|
||||||
|
const { gameTime } = useClockDisplay();
|
||||||
const { pageExitLabel, exitPage } = usePageExit();
|
const { pageExitLabel, exitPage } = usePageExit();
|
||||||
|
|
||||||
type VoteListResponse = Awaited<ReturnType<typeof trpc.vote.getVoteList.query>>;
|
type VoteListResponse = Awaited<ReturnType<typeof trpc.vote.getVoteList.query>>;
|
||||||
@@ -41,11 +43,15 @@ const isEnded = (poll: { endAt: string | null; closedAt: string | null }): boole
|
|||||||
if (poll.closedAt) {
|
if (poll.closedAt) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return poll.endAt ? new Date(poll.endAt).getTime() < Date.now() : false;
|
return Boolean(poll.endAt && gameTime.value && new Date(poll.endAt).getTime() < gameTime.value.getTime());
|
||||||
};
|
};
|
||||||
|
|
||||||
const canVote = computed(
|
const canVote = computed(
|
||||||
() => Boolean(currentVote.value) && !currentVote.value?.myVote && !isEnded(currentVote.value!.voteInfo)
|
() =>
|
||||||
|
Boolean(currentVote.value) &&
|
||||||
|
!currentVote.value?.myVote &&
|
||||||
|
(!currentVote.value?.voteInfo.endAt || gameTime.value !== null) &&
|
||||||
|
!isEnded(currentVote.value!.voteInfo)
|
||||||
);
|
);
|
||||||
|
|
||||||
const voteTotal = computed(() => (currentVote.value?.votes ?? []).reduce((total, vote) => total + vote.count, 0));
|
const voteTotal = computed(() => (currentVote.value?.votes ?? []).reduce((total, vote) => total + vote.count, 0));
|
||||||
|
|||||||
@@ -172,20 +172,8 @@ const cancel = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
const now = new Date();
|
|
||||||
try {
|
try {
|
||||||
await trpc.tournament.setState.mutate({
|
await trpc.tournament.start.mutate();
|
||||||
stage: 1,
|
|
||||||
phase: 0,
|
|
||||||
type: 0,
|
|
||||||
auto: true,
|
|
||||||
openYear: snapshot.value?.state?.openYear ?? now.getUTCFullYear(),
|
|
||||||
openMonth: snapshot.value?.state?.openMonth ?? now.getUTCMonth() + 1,
|
|
||||||
termSeconds: snapshot.value?.state?.termSeconds ?? 60,
|
|
||||||
nextAt: new Date(Date.now() + 60_000).toISOString(),
|
|
||||||
bettingSettled: false,
|
|
||||||
rewardSettled: false,
|
|
||||||
});
|
|
||||||
showSuccessToast('토너먼트를 개최했습니다.');
|
showSuccessToast('토너먼트를 개최했습니다.');
|
||||||
await load();
|
await load();
|
||||||
} catch (value) {
|
} catch (value) {
|
||||||
|
|||||||
Reference in New Issue
Block a user