fix: 설문과 장수 선택 기한 및 토너먼트 개최를 게임 시각으로 통일
This commit is contained in:
@@ -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' });
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user