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' });
+229
View File
@@ -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(',');
type FixtureState = {
clockOffsetMs?: number;
reservationCalls: number;
reservationInputs: Array<Record<string, 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 === '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;
@@ -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',
+16 -22
View File
@@ -1,5 +1,5 @@
<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 { useGameFeedback } from '../composables/useGameFeedback';
import PanelCard from '../components/ui/PanelCard.vue';
@@ -11,7 +11,7 @@ import { useSessionStore } from '../stores/session';
import { cityLevelMap, formatOfficerLevelText, regionMap } from '../utils/nationFormat';
import { getNpcColor } from '../utils/npcColor';
import { useClockDisplay } from '../composables/useClockDisplay';
const { formatTime: formatSeoulDateTime } = useClockDisplay();
const { formatTime: formatSeoulDateTime, gameTime } = useClockDisplay();
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
import { abilityLeadint, abilityLeadpow, abilityPowint, abilityRand, type GeneralStats } from '../utils/generalStats';
import { legacyLuminanceTextColor } from '../utils/legacyNationColor';
@@ -185,8 +185,6 @@ const npcReservation = ref<PossessReservation | null>(null);
const npcLoading = ref(false);
const npcError = ref<string | null>(null);
const keptNpcIds = ref<number[]>([]);
const nowMs = ref(Date.now());
const npcPickMoreAvailableAtMs = ref(0);
const pendingPossessAction = ref<PendingPossessAction | null>(null);
const npcGeneralList = ref<NpcGeneralList | null>(null);
const npcGeneralListLoading = ref(false);
@@ -202,17 +200,22 @@ const publicGeneralsLoaded = ref(false);
const publicGeneralsLoading = ref(false);
const publicGeneralsError = ref('');
const publicGeneralFilter = ref('');
let npcTimer: number | null = null;
const npcCandidates = computed<PossessCandidate[]>(() => npcReservation.value?.candidates ?? []);
const npcValidUntilMs = computed(() => {
const value = npcReservation.value?.validUntil;
return value ? new Date(value).getTime() : 0;
});
const npcExpired = computed(() => npcValidUntilMs.value > 0 && npcValidUntilMs.value < nowMs.value);
const npcPickMoreSeconds = computed(() =>
Math.max(0, Math.ceil((npcPickMoreAvailableAtMs.value - nowMs.value) / 1000))
const npcExpired = computed(
() => npcValidUntilMs.value > 0 && gameTime.value !== null && npcValidUntilMs.value < gameTime.value.getTime()
);
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(
() => pendingPossessAction.value !== null && pendingPossessAction.value.ownerUserId === joinConfig.value?.user.id
);
@@ -261,7 +264,8 @@ const filteredPublicGenerals = computed(() => {
return sortGeneralsByTypeThenName(filtered);
});
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';
const channel = Math.max(0, Math.min(255, Math.round((remaining / 30_000) * 255)));
return `rgb(255, ${channel}, ${channel})`;
@@ -465,9 +469,6 @@ const loadNpcCandidates = async (refresh = false) => {
});
npcReservation.value = reservation;
keptNpcIds.value = [];
const receivedAt = Date.now();
nowMs.value = receivedAt;
npcPickMoreAvailableAtMs.value = receivedAt + reservation.pickMoreSeconds * 1000;
} catch (err) {
npcError.value = err instanceof Error ? err.message : 'npc_list_failed';
if (refresh) {
@@ -648,17 +649,8 @@ watch(contextTab, (value) => {
});
onMounted(() => {
npcTimer = window.setInterval(() => {
nowMs.value = Date.now();
}, 250);
void loadConfig();
});
onUnmounted(() => {
if (npcTimer !== null) {
window.clearInterval(npcTimer);
}
});
</script>
<template>
@@ -1119,7 +1111,9 @@ onUnmounted(() => {
id="btn-pick-more"
class="legacy-button legacy-button--secondary"
type="button"
:disabled="npcLoading || npcPickMoreSeconds > 0 || submitting || hasPendingPossession"
:disabled="
npcLoading || !gameTime || npcPickMoreSeconds > 0 || submitting || hasPendingPossession
"
@click="loadNpcCandidates(true)"
>
다른 장수 보기<span v-if="npcPickMoreSeconds > 0">({{ npcPickMoreSeconds }})</span>
@@ -1,12 +1,12 @@
<script setup lang="ts">
import { usePageExit } from '../composables/usePageExit';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useGameFeedback } from '../composables/useGameFeedback';
import { useSessionStore } from '../stores/session';
import { useClockDisplay } from '../composables/useClockDisplay';
const { formatTime: formatSeoulDateTime } = useClockDisplay();
const { formatTime: formatSeoulDateTime, gameTime } = useClockDisplay();
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
import { legacyLuminanceTextColor } from '../utils/legacyNationColor';
import { trpc } from '../utils/trpc';
@@ -38,8 +38,6 @@ const selectedIconId = ref('');
const loading = ref(true);
const submitting = ref(false);
const error = ref('');
const now = ref(Date.now());
let timer: number | null = null;
const pendingActionStorageKey = 'sammo-select-pool-pending-action';
const candidates = computed(() => reservation.value?.candidates ?? []);
@@ -54,9 +52,12 @@ const validUntil = computed(() => {
const value = reservation.value?.validUntil;
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 remaining = validUntil.value - now.value;
if (!gameTime.value) return '#fff';
const remaining = validUntil.value - gameTime.value.getTime();
if (remaining <= 0 || remaining > 30_000) {
return '#fff';
}
@@ -227,7 +228,6 @@ async function loadPage(): Promise<void> {
return;
}
reservation.value = await trpc.join.getSelectionPool.mutate();
now.value = Date.now();
} catch (cause) {
console.error(cause);
error.value = errorText(cause);
@@ -250,17 +250,8 @@ const goBack = (): void => {
};
onMounted(() => {
timer = window.setInterval(() => {
now.value = Date.now();
}, 1_000);
void loadPage();
});
onBeforeUnmount(() => {
if (timer !== null) {
window.clearInterval(timer);
}
});
</script>
<template>
+8 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useClockDisplay } from '../composables/useClockDisplay';
import { usePageExit } from '../composables/usePageExit';
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
@@ -7,6 +8,7 @@ import { computed, onMounted, ref } from 'vue';
import { useGameFeedback } from '../composables/useGameFeedback';
import { trpc } from '../utils/trpc';
const { gameTime } = useClockDisplay();
const { pageExitLabel, exitPage } = usePageExit();
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) {
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(
() => 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));
+1 -13
View File
@@ -172,20 +172,8 @@ const cancel = async () => {
};
const start = async () => {
const now = new Date();
try {
await trpc.tournament.setState.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,
});
await trpc.tournament.start.mutate();
showSuccessToast('토너먼트를 개최했습니다.');
await load();
} catch (value) {