feat: add logical game clock

This commit is contained in:
2026-08-04 02:51:27 +00:00
parent 87965a39d6
commit a26031dc3f
51 changed files with 1605 additions and 398 deletions
+29 -23
View File
@@ -9,7 +9,8 @@ import { ItemLoader, isItemKey } from '@sammo-ts/logic';
import { asNumber, asRecord } from '@sammo-ts/common';
import { buildAuctionAlias } from '@sammo-ts/logic';
import { openAuctionWithDaemon } from '../../auction/open.js';
import { resolveAuctionTimerScore } from '../../auction/scheduler.js';
import { loadCurrentGameTime } from '../../services/gameClock.js';
const zBidInput = z.object({
auctionId: z.number().int().positive(),
@@ -99,10 +100,7 @@ const ensureAuctionSeasonActive = async (db: DatabaseClient): Promise<void> => {
}
};
const loadAuction = async (
db: DatabaseClient,
auctionId: number
): Promise<AuctionRow | null> => {
const loadAuction = async (db: DatabaseClient, auctionId: number): Promise<AuctionRow | null> => {
const rows = (await db.$queryRaw(
GamePrisma.sql`
SELECT id,
@@ -190,10 +188,7 @@ export const auctionRouter = router({
const [auctions, worldState, point, recentLogs] = await Promise.all([
ctx.db.auction.findMany({
where: {
OR: [
{ type: { in: ['BUY_RICE', 'SELL_RICE'] }, status: 'OPEN' },
{ type: 'UNIQUE_ITEM' },
],
OR: [{ type: { in: ['BUY_RICE', 'SELL_RICE'] }, status: 'OPEN' }, { type: 'UNIQUE_ITEM' }],
},
orderBy: [{ status: 'asc' }, { id: 'desc' }],
take: 120,
@@ -238,7 +233,7 @@ export const auctionRouter = router({
const hiddenSeed =
typeof worldMeta.hiddenSeed === 'string' || typeof worldMeta.hiddenSeed === 'number'
? worldMeta.hiddenSeed
: worldState?.id ?? 0;
: (worldState?.id ?? 0);
const callerAlias = buildAuctionAlias(general.id, hiddenSeed, configConst);
const mapped = auctions.map((auction) => {
@@ -252,8 +247,8 @@ export const auctionRouter = router({
status: auction.status,
hostGeneralId: isUnique ? null : auction.hostGeneralId,
hostName: isUnique
? auction.hostName ?? buildAuctionAlias(auction.hostGeneralId, hiddenSeed, configConst)
: auction.hostName ?? names.get(auction.hostGeneralId) ?? '상인',
? (auction.hostName ?? buildAuctionAlias(auction.hostGeneralId, hiddenSeed, configConst))
: (auction.hostName ?? names.get(auction.hostGeneralId) ?? '상인'),
isCallerHost: auction.hostGeneralId === general.id,
closeAt: auction.closeAt.toISOString(),
detail,
@@ -262,7 +257,7 @@ export const auctionRouter = router({
amount: highestBid.amount,
bidderName: isUnique
? buildAuctionAlias(highestBid.generalId, hiddenSeed, configConst)
: names.get(highestBid.generalId) ?? '상인',
: (names.get(highestBid.generalId) ?? '상인'),
isCaller: highestBid.generalId === general.id,
eventAt: highestBid.eventAt.toISOString(),
}
@@ -305,14 +300,13 @@ export const auctionRouter = router({
const hiddenSeed =
typeof worldMeta.hiddenSeed === 'string' || typeof worldMeta.hiddenSeed === 'number'
? worldMeta.hiddenSeed
: worldState?.id ?? 0;
: (worldState?.id ?? 0);
return {
auction: {
id: auction.id,
targetCode: auction.targetCode,
status: auction.status,
hostName:
auction.hostName ?? buildAuctionAlias(auction.hostGeneralId, hiddenSeed, configConst),
hostName: auction.hostName ?? buildAuctionAlias(auction.hostGeneralId, hiddenSeed, configConst),
isCallerHost: auction.hostGeneralId === general.id,
closeAt: auction.closeAt.toISOString(),
detail: parseDetail(auction.detail),
@@ -362,7 +356,8 @@ export const auctionRouter = router({
throw new TRPCError({ code: 'BAD_REQUEST', message: '경매가 종료되었습니다.' });
}
const now = new Date();
const gameTime = await loadCurrentGameTime(ctx.db);
const { now } = gameTime;
if (auction.closeAt <= now) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '경매가 종료되었습니다.' });
}
@@ -418,7 +413,9 @@ export const auctionRouter = router({
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
const nextCloseAt = new Date(result.closeAt);
await ctx.redis.zAdd(timerKeys.timerKey, [{ score: nextCloseAt.getTime(), value: String(auction.id) }]);
await ctx.redis.zAdd(timerKeys.timerKey, [
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt), value: String(auction.id) },
]);
return { ok: true };
}),
@@ -434,7 +431,8 @@ export const auctionRouter = router({
throw new TRPCError({ code: 'BAD_REQUEST', message: '경매가 종료되었습니다.' });
}
const now = new Date();
const gameTime = await loadCurrentGameTime(ctx.db);
const { now } = gameTime;
if (auction.closeAt <= now) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '경매가 종료되었습니다.' });
}
@@ -490,7 +488,9 @@ export const auctionRouter = router({
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
const nextCloseAt = new Date(result.closeAt);
await ctx.redis.zAdd(timerKeys.timerKey, [{ score: nextCloseAt.getTime(), value: String(auction.id) }]);
await ctx.redis.zAdd(timerKeys.timerKey, [
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt), value: String(auction.id) },
]);
return { ok: true };
}),
@@ -506,7 +506,8 @@ export const auctionRouter = router({
throw new TRPCError({ code: 'BAD_REQUEST', message: '경매가 종료되었습니다.' });
}
const now = new Date();
const gameTime = await loadCurrentGameTime(ctx.db);
const { now } = gameTime;
if (auction.closeAt <= now) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '경매가 종료되었습니다.' });
}
@@ -586,7 +587,10 @@ export const auctionRouter = router({
}
const otherItem = await itemLoader.load(other.targetCode);
if (otherItem.slot === itemModule.slot) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '1순위 입찰자인 경매중에 같은 부위가 있습니다.' });
throw new TRPCError({
code: 'BAD_REQUEST',
message: '1순위 입찰자인 경매중에 같은 부위가 있습니다.',
});
}
}
}
@@ -620,7 +624,9 @@ export const auctionRouter = router({
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
const nextCloseAt = new Date(result.closeAt);
await ctx.redis.zAdd(timerKeys.timerKey, [{ score: nextCloseAt.getTime(), value: String(auction.id) }]);
await ctx.redis.zAdd(timerKeys.timerKey, [
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt), value: String(auction.id) },
]);
return { ok: true };
}),
+5
View File
@@ -15,6 +15,7 @@ import {
} from '@sammo-ts/logic';
import { readInheritancePoint, resolveInheritConstants } from '../../services/inheritance.js';
import { loadAuthoritativeAccountIcon } from '../../services/accountIconSync.js';
import { loadCurrentGameTime } from '../../services/gameClock.js';
import { getSelectionPoolStatus, reserveSelectionPool, resolveSelectionMaxGeneral } from '../../services/selectPool.js';
import {
ConflictingTurnDaemonCommandError,
@@ -373,10 +374,12 @@ export const joinRouter = router({
message: 'World state is not initialized.',
});
}
const gameTime = await loadCurrentGameTime(ctx.db);
return reserveSelectionPool({
db: ctx.db,
worldState,
userId,
now: gameTime.now,
seedOwnerIdentity: ctx.auth?.user.legacyMemberNo ?? userId,
});
}),
@@ -558,6 +561,7 @@ export const joinRouter = router({
});
}
try {
const gameTime = await loadCurrentGameTime(ctx.db);
return await reserveNpcPossessionCandidates({
db: ctx.db,
worldState,
@@ -565,6 +569,7 @@ export const joinRouter = router({
ownerIdentity: auth.user.legacyMemberNo ?? auth.user.id,
refresh: input.refresh,
keepIds: input.keepIds,
now: gameTime.now,
});
} catch (error) {
if (error instanceof NpcPossessionError) {
+136 -137
View File
@@ -26,6 +26,7 @@ import { publishRealtimeEvent } from '../../realtime/publisher.js';
import { getOwnedGeneral } from '../shared/general.js';
import { resolveNationPermission } from '../nation/shared.js';
import { respondToDiplomaticMessage } from '../../messages/diplomaticResponse.js';
import { loadCurrentGameTime } from '../../services/gameClock.js';
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
@@ -288,7 +289,8 @@ export const messagesRouter = router({
if (message.payload.option?.deletable === false) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제할 수 없는 메시지입니다.' });
}
if (Date.now() - message.time.getTime() > 5 * 60 * 1000) {
const { now } = await loadCurrentGameTime(ctx.db);
if (now.getTime() - message.time.getTime() > 5 * 60 * 1000) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '5분 이내의 메시지만 삭제할 수 있습니다.' });
}
const receiverMessageId = message.payload.option?.receiverMessageID;
@@ -389,155 +391,152 @@ export const messagesRouter = router({
};
}),
send: accessAuthedInputProcedure(
z.object({
generalId: z.number().int().positive(),
mailbox: z.number().int(),
text: z.string().min(1),
})
)
.mutation(async ({ ctx, input }) => {
const general = await getOwnedGeneral(ctx, input.generalId);
if (!ctx.auth || isMessageFeatureBlocked(ctx.auth.sanctions, [ctx.profile.name, ctx.profile.id])) {
z.object({
generalId: z.number().int().positive(),
mailbox: z.number().int(),
text: z.string().min(1),
})
).mutation(async ({ ctx, input }) => {
const general = await getOwnedGeneral(ctx, input.generalId);
if (!ctx.auth || isMessageFeatureBlocked(ctx.auth.sanctions, [ctx.profile.name, ctx.profile.id])) {
throw new TRPCError({
code: 'FORBIDDEN',
message: '메시지 전송이 제한된 계정입니다.',
});
}
const src = await buildTargetFromGeneral(ctx.db, general);
const { now } = await loadCurrentGameTime(ctx.db);
const validUntil = new Date('9999-12-31T00:00:00Z');
let msgType: MessageType;
let dest = src;
let receiverMailbox = input.mailbox;
if (input.mailbox === MESSAGE_MAILBOX_PUBLIC) {
if (hasPenalty(general.penalty, 'noSendPublicMsg')) {
throw new TRPCError({
code: 'FORBIDDEN',
message: '메시지 전송이 제한된 계정입니다.',
message: '공개 메세지를 보낼 수 없습니다.',
});
}
const src = await buildTargetFromGeneral(ctx.db, general);
const now = new Date();
const validUntil = new Date('9999-12-31T00:00:00Z');
let msgType: MessageType;
let dest = src;
let receiverMailbox = input.mailbox;
if (input.mailbox === MESSAGE_MAILBOX_PUBLIC) {
if (hasPenalty(general.penalty, 'noSendPublicMsg')) {
throw new TRPCError({
code: 'FORBIDDEN',
message: '공개 메세지를 보낼 수 없습니다.',
});
}
msgType = 'public';
} else if (input.mailbox >= MESSAGE_MAILBOX_NATIONAL_BASE) {
const sourceNation =
general.nationId > 0
? await ctx.db.nation.findUnique({
where: { id: general.nationId },
select: { meta: true },
})
: null;
const permission =
general.nationId > 0 && sourceNation ? resolveNationPermission(general, sourceNation.meta) : -1;
const destNationId = permission < 4 ? general.nationId : input.mailbox - MESSAGE_MAILBOX_NATIONAL_BASE;
const nationInfo = await resolveNationInfo(ctx.db, destNationId);
if (destNationId > 0) {
const destNation = await ctx.db.nation.findUnique({ where: { id: destNationId } });
if (!destNation) {
throw new TRPCError({
code: 'NOT_FOUND',
message: '존재하지 않는 국가입니다.',
});
}
}
dest = buildNationTarget(destNationId, nationInfo.name, nationInfo.color);
msgType = destNationId === general.nationId ? 'national' : 'diplomacy';
receiverMailbox = MESSAGE_MAILBOX_NATIONAL_BASE + destNationId;
} else if (input.mailbox > 0) {
if (hasPenalty(general.penalty, 'noSendPrivateMsg')) {
throw new TRPCError({
code: 'FORBIDDEN',
message: '개인 메세지를 보낼 수 없습니다.',
});
}
const intervalSeconds = Math.max(
0,
Math.ceil(readPenaltyNumber(general.penalty, 'sendPrivateMsgDelay', 2))
);
if (intervalSeconds > 0) {
const rateLimitKey = `game:${ctx.profile.name}:message:private:${ctx.auth.sessionId}`;
const acquired = await ctx.redis.set(rateLimitKey, '1', {
NX: true,
PX: intervalSeconds * 1000,
});
if (acquired === null) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: `개인메세지는 ${intervalSeconds}초당 1건만 보낼 수 있습니다!`,
});
}
}
const destGeneral = await ctx.db.general.findUnique({
where: { id: input.mailbox },
});
if (!destGeneral) {
msgType = 'public';
} else if (input.mailbox >= MESSAGE_MAILBOX_NATIONAL_BASE) {
const sourceNation =
general.nationId > 0
? await ctx.db.nation.findUnique({
where: { id: general.nationId },
select: { meta: true },
})
: null;
const permission =
general.nationId > 0 && sourceNation ? resolveNationPermission(general, sourceNation.meta) : -1;
const destNationId = permission < 4 ? general.nationId : input.mailbox - MESSAGE_MAILBOX_NATIONAL_BASE;
const nationInfo = await resolveNationInfo(ctx.db, destNationId);
if (destNationId > 0) {
const destNation = await ctx.db.nation.findUnique({ where: { id: destNationId } });
if (!destNation) {
throw new TRPCError({
code: 'NOT_FOUND',
message: '존재하지 않는 유저입니다.',
message: '존재하지 않는 국가입니다.',
});
}
const [sourceNation, destNation] = await Promise.all([
general.nationId > 0
? ctx.db.nation.findUnique({ where: { id: general.nationId }, select: { meta: true } })
: null,
destGeneral.nationId > 0
? ctx.db.nation.findUnique({ where: { id: destGeneral.nationId }, select: { meta: true } })
: null,
]);
const sourcePermission =
sourceNation && general.nationId > 0
? resolveNationPermission(general, sourceNation.meta, false)
: -1;
const destPermission =
destNation && destGeneral.nationId > 0
? resolveNationPermission(destGeneral, destNation.meta, false)
: -1;
if (sourcePermission === 4 && destPermission === 4 && destGeneral.nationId !== general.nationId) {
throw new TRPCError({
code: 'FORBIDDEN',
message: '외교권자끼리는 메시지를 보낼 수 없습니다.',
});
}
dest = await buildTargetFromGeneral(ctx.db, destGeneral);
msgType = 'private';
} else {
}
dest = buildNationTarget(destNationId, nationInfo.name, nationInfo.color);
msgType = destNationId === general.nationId ? 'national' : 'diplomacy';
receiverMailbox = MESSAGE_MAILBOX_NATIONAL_BASE + destNationId;
} else if (input.mailbox > 0) {
if (hasPenalty(general.penalty, 'noSendPrivateMsg')) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Invalid mailbox.',
code: 'FORBIDDEN',
message: '개인 메세지를 보낼 수 없습니다.',
});
}
const draft: MessageDraft = {
msgType,
src,
dest,
text: input.text,
time: now,
validUntil,
option: {},
};
const result = await sendMessage(
{
insertMessage: (draft: MessageRecordDraft) => insertMessage(ctx.db, draft),
},
draft
const intervalSeconds = Math.max(
0,
Math.ceil(readPenaltyNumber(general.penalty, 'sendPrivateMsgDelay', 2))
);
try {
await publishRealtimeEvent(ctx.redis, ctx.profile.name, {
type: 'messageCreated',
at: now.toISOString(),
mailbox: receiverMailbox,
msgType,
messageId: result.receiverId,
senderId: general.id,
if (intervalSeconds > 0) {
const rateLimitKey = `game:${ctx.profile.name}:message:private:${ctx.auth.sessionId}`;
const acquired = await ctx.redis.set(rateLimitKey, '1', {
NX: true,
PX: intervalSeconds * 1000,
});
} catch {
// 실시간 알림 실패는 메시지 전송 실패로 취급하지 않는다.
if (acquired === null) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: `개인메세지는 ${intervalSeconds}초당 1건만 보낼 수 있습니다!`,
});
}
}
const destGeneral = await ctx.db.general.findUnique({
where: { id: input.mailbox },
});
if (!destGeneral) {
throw new TRPCError({
code: 'NOT_FOUND',
message: '존재하지 않는 유저입니다.',
});
}
const [sourceNation, destNation] = await Promise.all([
general.nationId > 0
? ctx.db.nation.findUnique({ where: { id: general.nationId }, select: { meta: true } })
: null,
destGeneral.nationId > 0
? ctx.db.nation.findUnique({ where: { id: destGeneral.nationId }, select: { meta: true } })
: null,
]);
const sourcePermission =
sourceNation && general.nationId > 0 ? resolveNationPermission(general, sourceNation.meta, false) : -1;
const destPermission =
destNation && destGeneral.nationId > 0
? resolveNationPermission(destGeneral, destNation.meta, false)
: -1;
if (sourcePermission === 4 && destPermission === 4 && destGeneral.nationId !== general.nationId) {
throw new TRPCError({
code: 'FORBIDDEN',
message: '외교권자끼리는 메시지를 보낼 수 없습니다.',
});
}
dest = await buildTargetFromGeneral(ctx.db, destGeneral);
msgType = 'private';
} else {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Invalid mailbox.',
});
}
return { msgType, msgId: result.receiverId };
}),
const draft: MessageDraft = {
msgType,
src,
dest,
text: input.text,
time: now,
validUntil,
option: {},
};
const result = await sendMessage(
{
insertMessage: (draft: MessageRecordDraft) => insertMessage(ctx.db, draft),
},
draft
);
try {
await publishRealtimeEvent(ctx.redis, ctx.profile.name, {
type: 'messageCreated',
at: now.toISOString(),
mailbox: receiverMailbox,
msgType,
messageId: result.receiverId,
senderId: general.id,
});
} catch {
// 실시간 알림 실패는 메시지 전송 실패로 취급하지 않는다.
}
return { msgType, msgId: result.receiverId };
}),
});
+5 -2
View File
@@ -9,6 +9,7 @@ import { TournamentStore } from '../../tournament/store.js';
import { buildTournamentKeys } from '../../tournament/keys.js';
import { accessAuthedProcedure, authedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js';
import { loadCurrentGameTime } from '../../services/gameClock.js';
const hasAdminRole = (roles: string[], profileName: string): boolean => {
if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) {
@@ -474,6 +475,7 @@ export const tournamentRouter = router({
await Promise.all([store.setParticipants([]), store.setMatches([]), store.setBettingEntries([])]);
const gameTime = await loadCurrentGameTime(ctx.db);
const nextState: TournamentState = {
...state,
stage: 0,
@@ -484,7 +486,7 @@ export const tournamentRouter = router({
rewardSettled: false,
bettingCloseAt: undefined,
participantsLockedAt: undefined,
nextAt: new Date().toISOString(),
nextAt: gameTime.now.toISOString(),
};
await store.setState(nextState);
return { ok: true };
@@ -506,7 +508,8 @@ export const tournamentRouter = router({
throw new TRPCError({ code: 'BAD_REQUEST', message: '베팅 기간이 아닙니다.' });
}
const closeAt = state.bettingCloseAt ? new Date(state.bettingCloseAt).getTime() : 0;
if (closeAt && closeAt <= Date.now()) {
const gameNow = (await loadCurrentGameTime(ctx.db)).now.getTime();
if (closeAt && closeAt <= gameNow) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '베팅이 마감되었습니다.' });
}
+39 -9
View File
@@ -18,6 +18,7 @@ import {
import { authedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js';
import { loadCurrentGameTime, type CurrentGameTime } from '../../services/gameClock.js';
const hasAdminRole = (roles: string[], profileName: string): boolean => {
if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) {
@@ -133,9 +134,26 @@ type VotePollRow = {
opener_name: string;
start_at: Date;
end_at: Date | null;
end_tick: bigint | null;
closed_at: Date | null;
};
const hasPollEnded = (poll: Pick<VotePollRow, 'closed_at' | 'end_at' | 'end_tick'>, time: CurrentGameTime): boolean =>
Boolean(poll.closed_at) ||
(poll.end_tick !== null && time.tick !== null
? poll.end_tick <= BigInt(time.tick)
: Boolean(poll.end_at && poll.end_at <= time.now));
const toGameTickOrNull = (time: CurrentGameTime, date: Date | null): bigint | null => {
if (!date) return null;
try {
const tick = time.dateToTick(date);
return tick === null ? null : BigInt(tick);
} catch {
return null;
}
};
type VoteListRow = {
id: number;
title: string;
@@ -215,6 +233,7 @@ export const voteRouter = router({
opener_name,
start_at,
end_at,
end_tick,
closed_at
FROM vote_poll
WHERE id = ${input.voteId}
@@ -226,7 +245,8 @@ export const voteRouter = router({
}
const options = parseOptions(row.options);
const pollEnded = Boolean(row.closed_at) || (row.end_at ? row.end_at <= new Date() : false);
const gameTime = await loadCurrentGameTime(ctx.db);
const pollEnded = hasPollEnded(row, gameTime);
const userId = ctx.auth?.user.id;
const general = userId ? await ctx.db.general.findFirst({ where: { userId }, select: { id: true } }) : null;
@@ -330,6 +350,7 @@ export const voteRouter = router({
opener_name,
start_at,
end_at,
end_tick,
closed_at
FROM vote_poll
WHERE id = ${input.voteId}
@@ -339,7 +360,8 @@ export const voteRouter = router({
if (!poll) {
throw new TRPCError({ code: 'NOT_FOUND', message: '설문조사가 없습니다.' });
}
if (poll.closed_at || (poll.end_at && poll.end_at < new Date())) {
const gameTime = await loadCurrentGameTime(ctx.db);
if (hasPollEnded(poll, gameTime)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '설문조사가 종료되었습니다.' });
}
@@ -536,7 +558,8 @@ export const voteRouter = router({
if (endAt && Number.isNaN(endAt.getTime())) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 잘못되었습니다.' });
}
if (endAt && endAt < new Date()) {
const gameTime = await loadCurrentGameTime(ctx.db);
if (endAt && endAt < gameTime.now) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' });
}
@@ -551,7 +574,7 @@ export const voteRouter = router({
if (input.closePrevious) {
await ctx.db.$queryRaw(GamePrisma.sql`
UPDATE vote_poll
SET closed_at = NOW(), updated_at = NOW()
SET closed_at = ${gameTime.now}, updated_at = NOW()
WHERE closed_at IS NULL
`);
}
@@ -566,7 +589,9 @@ export const voteRouter = router({
opener_general_id,
opener_name,
start_at,
end_at
start_tick,
end_at,
end_tick
)
VALUES (
${input.title},
@@ -576,8 +601,10 @@ export const voteRouter = router({
${input.revealMode},
${general.id},
${general.name},
NOW(),
${endAt}
${gameTime.now},
${gameTime.tick === null ? null : BigInt(gameTime.tick)},
${endAt},
${toGameTickOrNull(gameTime, endAt)}
)
`);
@@ -608,6 +635,7 @@ export const voteRouter = router({
opener_name,
start_at,
end_at,
end_tick,
closed_at
FROM vote_poll
WHERE id = ${input.voteId}
@@ -646,7 +674,8 @@ export const voteRouter = router({
if (endAt && Number.isNaN(endAt.getTime())) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 잘못되었습니다.' });
}
if (endAt && endAt < new Date()) {
const gameTime = await loadCurrentGameTime(ctx.db);
if (endAt && endAt < gameTime.now) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' });
}
@@ -670,6 +699,7 @@ export const voteRouter = router({
multiple_options = COALESCE(${nextMultipleOptions}, multiple_options),
reveal_mode = COALESCE(${input.revealMode}, reveal_mode),
end_at = ${endAt ?? poll.end_at},
end_tick = ${endAt ? toGameTickOrNull(gameTime, endAt) : poll.end_tick},
updated_at = NOW()
WHERE id = ${input.voteId}
`);
@@ -681,7 +711,7 @@ export const voteRouter = router({
.mutation(async ({ ctx, input }) => {
const rows = await ctx.db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
UPDATE vote_poll
SET closed_at = NOW(), updated_at = NOW()
SET closed_at = ${(await loadCurrentGameTime(ctx.db)).now}, updated_at = NOW()
WHERE id = ${input.voteId}
RETURNING id
`);