import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { asRecord, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; import { GamePrisma } from '@sammo-ts/infra'; import { ITEM_KEYS, addOccupiedUniqueItemKeys, buildVoteUniqueSeed, countOccupiedUniqueItems, createItemModuleRegistry, loadItemModules, resolveUniqueConfig, rollUniqueLottery, type GeneralItemSlots, type ItemModule, } from '@sammo-ts/logic'; 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')) { return true; } return roles.some( (role) => role === 'admin.survey.open' || role === 'admin.survey.open:*' || role === `admin.survey.open:${profileName}` ); }; const adminProcedure = authedProcedure.use(({ ctx, next }) => { const roles = ctx.auth?.user.roles ?? []; if (!hasAdminRole(roles, ctx.profile.name)) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin permission is required.' }); } return next(); }); let itemRegistryPromise: Promise> | null = null; const getItemRegistry = async (): Promise> => { if (!itemRegistryPromise) { itemRegistryPromise = loadItemModules([...ITEM_KEYS]).then((modules) => createItemModuleRegistry(modules)); } return itemRegistryPromise; }; const resolveNumber = (source: Record, keys: string[], fallback: number): number => { for (const key of keys) { const value = source[key]; if (typeof value === 'number' && Number.isFinite(value)) { return value; } if (typeof value === 'string') { const parsed = Number(value); if (Number.isFinite(parsed)) { return parsed; } } } return fallback; }; const normalizeCode = (value: string | null | undefined): string | null => { if (!value || value === 'None') { return null; } return value; }; const normalizeOptions = (options: string[]): string[] => options.map((option) => option.trim()).filter((option) => option.length > 0); const readMetaNumber = (meta: Record, key: string, fallback: number): number => { const value = meta[key]; if (typeof value === 'number' && Number.isFinite(value)) { return Math.floor(value); } if (typeof value === 'string') { const parsed = Number(value); if (Number.isFinite(parsed)) { return Math.floor(parsed); } } return fallback; }; const parseOptions = (value: unknown): string[] => { if (Array.isArray(value)) { return value.filter((entry): entry is string => typeof entry === 'string'); } if (typeof value === 'string') { try { const parsed = JSON.parse(value); if (Array.isArray(parsed)) { return parsed.filter((entry): entry is string => typeof entry === 'string'); } } catch { return []; } } return []; }; const parseSelection = (value: unknown): number[] => { if (Array.isArray(value)) { return value.map((entry) => Number(entry)).filter((entry) => Number.isFinite(entry)); } if (typeof value === 'string') { try { const parsed = JSON.parse(value); if (Array.isArray(parsed)) { return parsed.map((entry) => Number(entry)).filter((entry) => Number.isFinite(entry)); } } catch { return []; } } return []; }; type VotePollRow = { id: number; title: string; body: string; options: unknown; multiple_options: number; reveal_mode: string; opener_general_id: number; opener_name: string; start_at: Date; end_at: Date | null; end_tick: bigint | null; closed_at: Date | null; }; const hasPollEnded = (poll: Pick, 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; start_at: Date; end_at: Date | null; closed_at: Date | null; reveal_mode: string; multiple_options: number; options_count: number; }; type VoteCommentRow = { id: number; vote_id: number; general_id: number; nation_id: number; general_name: string; nation_name: string; text: string; created_at: Date; }; type VoteResultRow = { selection: unknown; cnt: number | bigint; }; const zRevealMode = z.enum(['after_vote', 'after_end']); export const voteRouter = router({ getVoteList: authedProcedure.query(async ({ ctx }) => { const worldState = await ctx.db.worldState.findFirst(); const worldMeta = asRecord(worldState?.meta ?? {}); const config = asRecord(worldState?.config ?? {}); const constValues = asRecord(config.const); const develCost = resolveNumber( worldMeta, ['develcost', 'develCost'], resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0) ); const voteReward = develCost * 5; const rows = await ctx.db.$queryRaw(GamePrisma.sql` SELECT id, title, start_at, end_at, closed_at, reveal_mode, multiple_options, jsonb_array_length(options) AS options_count FROM vote_poll ORDER BY id DESC `); const polls = rows.map((row) => ({ id: row.id, title: row.title, startAt: row.start_at.toISOString(), endAt: row.end_at ? row.end_at.toISOString() : null, closedAt: row.closed_at ? row.closed_at.toISOString() : null, revealMode: row.reveal_mode as 'after_vote' | 'after_end', optionsCount: Number(row.options_count ?? 0), multipleOptions: row.multiple_options, })); return { polls, voteReward }; }), getVoteDetail: authedProcedure .input(z.object({ voteId: z.number().int().positive() })) .query(async ({ ctx, input }) => { const rows = await ctx.db.$queryRaw(GamePrisma.sql` SELECT id, title, body, options, multiple_options, reveal_mode, opener_general_id, opener_name, start_at, end_at, end_tick, closed_at FROM vote_poll WHERE id = ${input.voteId} LIMIT 1 `); const row = rows[0]; if (!row) { throw new TRPCError({ code: 'NOT_FOUND', message: '설문조사가 없습니다.' }); } const options = parseOptions(row.options); 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; const [comments, userCnt, myVoteRow] = await Promise.all([ ctx.db.$queryRaw(GamePrisma.sql` SELECT id, vote_id, general_id, nation_id, general_name, nation_name, text, created_at FROM vote_comment WHERE vote_id = ${input.voteId} ORDER BY created_at ASC `), ctx.db.general.count({ where: { npcState: { lt: 2 } } }), general ? ctx.db.$queryRaw>(GamePrisma.sql` SELECT selection FROM vote WHERE vote_id = ${input.voteId} AND general_id = ${general.id} LIMIT 1 `) : Promise.resolve([]), ]); const myVote = myVoteRow[0]?.selection ? parseSelection(myVoteRow[0].selection) : null; // 레거시는 투표 전에도 현재 집계를 보여준다. after_end만 명시적으로 숨긴다. const canReveal = row.reveal_mode === 'after_end' ? pollEnded : true; const voteResults = canReveal ? await ctx.db.$queryRaw(GamePrisma.sql` SELECT selection, count(*) as cnt FROM vote WHERE vote_id = ${input.voteId} GROUP BY selection `) : []; const votes = voteResults.map((entry) => ({ selection: parseSelection(entry.selection), count: Number(entry.cnt), })); return { voteInfo: { id: row.id, title: row.title, body: row.body, options, multipleOptions: row.multiple_options, revealMode: row.reveal_mode as 'after_vote' | 'after_end', openerGeneralId: row.opener_general_id, openerName: row.opener_name, startAt: row.start_at.toISOString(), endAt: row.end_at ? row.end_at.toISOString() : null, closedAt: row.closed_at ? row.closed_at.toISOString() : null, }, votes, comments: comments.map((comment) => ({ id: comment.id, voteId: comment.vote_id, generalId: comment.general_id, nationId: comment.nation_id, generalName: comment.general_name, nationName: comment.nation_name, text: comment.text, createdAt: comment.created_at.toISOString(), })), myVote, userCnt, }; }), submitVote: authedProcedure .input( z.object({ voteId: z.number().int().positive(), selection: z.array(z.number().int()), }) ) .mutation(async ({ ctx, input }) => { const selection = input.selection; if (!selection || selection.length === 0) { throw new TRPCError({ code: 'BAD_REQUEST', message: '선택한 항목이 없습니다.' }); } const pollRows = await ctx.db.$queryRaw(GamePrisma.sql` SELECT id, title, body, options, multiple_options, reveal_mode, opener_general_id, opener_name, start_at, end_at, end_tick, closed_at FROM vote_poll WHERE id = ${input.voteId} LIMIT 1 `); const poll = pollRows[0]; if (!poll) { throw new TRPCError({ code: 'NOT_FOUND', message: '설문조사가 없습니다.' }); } const gameTime = await loadCurrentGameTime(ctx.db); if (hasPollEnded(poll, gameTime)) { throw new TRPCError({ code: 'BAD_REQUEST', message: '설문조사가 종료되었습니다.' }); } const options = parseOptions(poll.options); if (poll.multiple_options >= 1 && selection.length > poll.multiple_options) { throw new TRPCError({ code: 'BAD_REQUEST', message: '선택한 항목이 너무 많습니다.' }); } const optionCount = options.length; for (const value of selection) { if (!Number.isFinite(value) || value < 0 || value >= optionCount) { throw new TRPCError({ code: 'BAD_REQUEST', message: '선택한 항목이 없습니다.' }); } } const sortedSelection = [...selection].sort((a, b) => a - b); if (new Set(sortedSelection).size !== sortedSelection.length) { throw new TRPCError({ code: 'BAD_REQUEST', message: '선택한 항목이 올바르지 않습니다.' }); } const general = await getMyGeneral(ctx); const rows = await ctx.db.$queryRaw>(GamePrisma.sql` INSERT INTO vote (vote_id, general_id, nation_id, selection) VALUES ( ${input.voteId}, ${general.id}, ${general.nationId}, CAST(${JSON.stringify(sortedSelection)} AS jsonb) ) ON CONFLICT (vote_id, general_id) DO NOTHING RETURNING id `); if (!rows[0]?.id) { throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 설문조사를 완료하였습니다.' }); } const worldState = await ctx.db.worldState.findFirst(); if (!worldState) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' }); } const worldMeta = asRecord(worldState.meta); const config = asRecord(worldState.config); const constValues = asRecord(config.const); const develCost = resolveNumber( worldMeta, ['develcost', 'develCost'], resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0) ); const voteReward = develCost * 5; const scenarioMeta = asRecord(worldMeta.scenarioMeta); const startYear = readMetaNumber(scenarioMeta, 'startYear', worldState.currentYear); const initYear = readMetaNumber(worldMeta, 'initYear', startYear); const initMonth = readMetaNumber(worldMeta, 'initMonth', 1); const scenarioId = readMetaNumber(worldMeta, 'scenarioId', 0); const hiddenSeed = worldMeta.hiddenSeed ?? worldMeta.seed ?? worldState.id; const itemRegistry = await getItemRegistry(); const uniqueConfig = resolveUniqueConfig(constValues); const [generalRows, reservedUniqueRows] = await Promise.all([ ctx.db.general.findMany({ select: { horseCode: true, weaponCode: true, bookCode: true, itemCode: true, }, }), ctx.db.auction.findMany({ where: { type: 'UNIQUE_ITEM', status: { in: ['OPEN', 'FINALIZING'] }, targetCode: { not: null }, }, select: { targetCode: true }, }), ]); const generalItems: GeneralItemSlots[] = generalRows.map((row) => ({ horse: normalizeCode(row.horseCode), weapon: normalizeCode(row.weaponCode), book: normalizeCode(row.bookCode), item: normalizeCode(row.itemCode), })); const occupiedUniqueCounts = countOccupiedUniqueItems(generalItems, itemRegistry); addOccupiedUniqueItemKeys( occupiedUniqueCounts, reservedUniqueRows.map((row) => row.targetCode), itemRegistry ); const userCount = await ctx.db.general.count({ where: { npcState: { lt: 2 } } }); const rngSeed = buildVoteUniqueSeed( typeof hiddenSeed === 'string' || typeof hiddenSeed === 'number' ? hiddenSeed : String(hiddenSeed), input.voteId, general.id ); const rng = new RandUtil(LiteHashDRBG.build(rngSeed)); const itemKey = rollUniqueLottery({ rng, config: uniqueConfig, itemRegistry, generalItems: { horse: normalizeCode(general.horseCode), weapon: normalizeCode(general.weaponCode), book: normalizeCode(general.bookCode), item: normalizeCode(general.itemCode), }, occupiedUniqueCounts, scenarioId, userCount, currentYear: worldState.currentYear, currentMonth: worldState.currentMonth, startYear, initYear, initMonth, acquireType: '설문조사', }); const rewardResult = await ctx.turnDaemon.requestCommand({ type: 'voteReward', voteId: input.voteId, generalId: general.id, goldReward: voteReward, unique: { expected: Boolean(itemKey), itemKey: itemKey ?? null, }, }); if (!rewardResult || rewardResult.type !== 'voteReward') { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' }); } if (!rewardResult.ok) { throw new TRPCError({ code: 'BAD_REQUEST', message: rewardResult.reason }); } ctx.changeJournal?.mark('front.general', general.id); return { ok: true, wonLottery: rewardResult.awardedUnique }; }), addComment: authedProcedure .input( z.object({ voteId: z.number().int().positive(), text: z.string().trim().max(200), }) ) .mutation(async ({ ctx, input }) => { if (!input.text) { throw new TRPCError({ code: 'BAD_REQUEST', message: '내용이 필요합니다.' }); } const pollRows = await ctx.db.$queryRaw>(GamePrisma.sql` SELECT id FROM vote_poll WHERE id = ${input.voteId} LIMIT 1 `); if (!pollRows[0]?.id) { throw new TRPCError({ code: 'NOT_FOUND', message: '설문조사가 없습니다.' }); } const general = await getMyGeneral(ctx); const nation = general.nationId ? await ctx.db.nation.findFirst({ where: { id: general.nationId }, select: { name: true } }) : null; const nationName = nation?.name ?? '재야'; await ctx.db.$queryRaw(GamePrisma.sql` INSERT INTO vote_comment (vote_id, general_id, nation_id, general_name, nation_name, text) VALUES (${input.voteId}, ${general.id}, ${general.nationId}, ${general.name}, ${nationName}, ${input.text}) `); return { ok: true }; }), createPoll: adminProcedure .input( z.object({ title: z.string().trim().min(1).max(200), body: z.string().trim().max(5000).optional().default(''), options: z.array(z.string()).default([]), multipleOptions: z.number().int().optional().default(1), endAt: z.string().optional(), revealMode: zRevealMode, closePrevious: z.boolean().optional().default(true), }) ) .mutation(async ({ ctx, input }) => { const general = await getMyGeneral(ctx); const options = normalizeOptions(input.options); if (options.length === 0) { throw new TRPCError({ code: 'BAD_REQUEST', message: '항목이 없습니다.' }); } const endAt = input.endAt ? new Date(input.endAt) : null; if (endAt && Number.isNaN(endAt.getTime())) { throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 잘못되었습니다.' }); } const gameTime = await loadCurrentGameTime(ctx.db); if (endAt && endAt < gameTime.now) { throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' }); } let multipleOptions = input.multipleOptions; if (multipleOptions < 0) { multipleOptions = 0; } if (multipleOptions > options.length) { multipleOptions = options.length; } if (input.closePrevious) { await ctx.db.$queryRaw(GamePrisma.sql` UPDATE vote_poll SET closed_at = ${gameTime.now}, updated_at = NOW() WHERE closed_at IS NULL `); } await ctx.db.$queryRaw(GamePrisma.sql` INSERT INTO vote_poll ( title, body, options, multiple_options, reveal_mode, opener_general_id, opener_name, start_at, start_tick, end_at, end_tick ) VALUES ( ${input.title}, ${input.body ?? ''}, CAST(${JSON.stringify(options)} AS jsonb), ${multipleOptions}, ${input.revealMode}, ${general.id}, ${general.name}, ${gameTime.now}, ${gameTime.tick === null ? null : BigInt(gameTime.tick)}, ${endAt}, ${toGameTickOrNull(gameTime, endAt)} ) `); ctx.changeJournal?.mark('front.global'); return { ok: true }; }), updatePoll: adminProcedure .input( z.object({ voteId: z.number().int().positive(), title: z.string().trim().min(1).max(200).optional(), body: z.string().trim().max(5000).optional(), appendOptions: z.array(z.string()).optional(), multipleOptions: z.number().int().optional(), endAt: z.string().optional(), revealMode: zRevealMode.optional(), }) ) .mutation(async ({ ctx, input }) => { const pollRows = await ctx.db.$queryRaw(GamePrisma.sql` SELECT id, title, body, options, multiple_options, reveal_mode, opener_general_id, opener_name, start_at, end_at, end_tick, closed_at FROM vote_poll WHERE id = ${input.voteId} LIMIT 1 `); const poll = pollRows[0]; if (!poll) { throw new TRPCError({ code: 'NOT_FOUND', message: '설문조사가 없습니다.' }); } const voteCountRow = await ctx.db.$queryRaw>(GamePrisma.sql` SELECT count(*) as cnt FROM vote WHERE vote_id = ${input.voteId} `); const voteCount = Number(voteCountRow[0]?.cnt ?? 0); if (voteCount > 0) { throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 설문조사가 진행중입니다.' }); } const existingOptions = parseOptions(poll.options); const appendedOptions = normalizeOptions(input.appendOptions ?? []); const nextOptions = appendedOptions.length > 0 ? [...existingOptions, ...appendedOptions] : existingOptions; let nextMultipleOptions = input.multipleOptions; if (nextMultipleOptions !== undefined) { if (nextMultipleOptions < 0) { nextMultipleOptions = 0; } if (nextMultipleOptions > nextOptions.length) { nextMultipleOptions = nextOptions.length; } } const endAt = input.endAt ? new Date(input.endAt) : undefined; if (endAt && Number.isNaN(endAt.getTime())) { throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 잘못되었습니다.' }); } const gameTime = await loadCurrentGameTime(ctx.db); if (endAt && endAt < gameTime.now) { throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' }); } if ( input.title === undefined && input.body === undefined && appendedOptions.length === 0 && nextMultipleOptions === undefined && endAt === undefined && input.revealMode === undefined ) { throw new TRPCError({ code: 'BAD_REQUEST', message: '변경할 항목이 없습니다.' }); } await ctx.db.$queryRaw(GamePrisma.sql` UPDATE vote_poll SET title = COALESCE(${input.title}, title), body = COALESCE(${input.body}, body), options = ${appendedOptions.length > 0 ? GamePrisma.sql`CAST(${JSON.stringify(nextOptions)} AS jsonb)` : GamePrisma.sql`options`}, 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} `); if (input.title !== undefined || endAt !== undefined) { ctx.changeJournal?.mark('front.global'); } return { ok: true }; }), closePoll: adminProcedure .input(z.object({ voteId: z.number().int().positive() })) .mutation(async ({ ctx, input }) => { const rows = await ctx.db.$queryRaw>(GamePrisma.sql` UPDATE vote_poll SET closed_at = ${(await loadCurrentGameTime(ctx.db)).now}, updated_at = NOW() WHERE id = ${input.voteId} RETURNING id `); if (!rows[0]?.id) { throw new TRPCError({ code: 'NOT_FOUND', message: '설문조사가 없습니다.' }); } ctx.changeJournal?.mark('front.global'); return { ok: true }; }), getAdminStatus: adminProcedure.query(async () => ({ ok: true })), });