import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import type { GamePrisma } from '@sammo-ts/infra'; import { auditProcedure, monthOrdinal, readAudit, readAuditWorld, zAuditMonth } from './shared.js'; const zId = z.string().regex(/^[a-f0-9]{64}$/); const zTick = z .string() .regex(/^\d{1,16}$/) .refine((value) => BigInt(value) <= BigInt(Number.MAX_SAFE_INTEGER)); const zSummary = z.object({ schemaVersion: z.literal(1), coverage: z.literal('PROCEDURES'), clockRevision: z.number().int(), codeVersion: z.string().nullable(), policyRefs: z.object({ NPC_VALUES: zId.optional(), NPC_NATION_PRIORITY: zId.optional(), NPC_GENERAL_PRIORITY: zId.optional(), DEFENCE: zId.optional(), }), requestedAction: z.string(), selectedAction: z.string().nullable(), selectedReason: z.string().nullable(), executedAction: z.string(), completed: z.boolean().nullable(), usedFallback: z.boolean(), blockedReason: z.string().nullable(), }); const zValue = z.union([ z.string(), z.number(), z.boolean(), z.null(), z.object({ entityId: z.number() }), z.object({ unprojected: z.literal(true) }), ]); const zStep = z.intersection( z.object({ sequence: z.number().int().nonnegative(), phase: z.enum(['general', 'nation']), generalId: z.number().int(), nationId: z.number().int(), cityId: z.number().int(), npcState: z.number().int(), year: z.number().int(), month: z.number().int(), tick: z.number().nullable(), }), z.discriminatedUnion('kind', [ z.object({ kind: z.literal('DECISION_START'), reservedAction: z.string() }), z.object({ kind: z.literal('DECISION_END'), action: z.string().nullable(), reason: z.string().nullable() }), z.object({ kind: z.literal('DECISION_ERROR') }), z.object({ kind: z.literal('PROCEDURE_START'), procedure: z.string() }), z.object({ kind: z.literal('PROCEDURE_END'), procedure: z.string(), action: z.string().nullable(), reason: z.string().nullable(), }), z.object({ kind: z.literal('PROCEDURE_SKIP'), procedure: z.string(), reason: z.enum(['POLICY', 'AUTOMATION', 'NO_HANDLER']), }), z.object({ kind: z.literal('CANDIDATE'), action: z.string(), result: z.enum(['INVALID_ARGS', 'allow', 'deny', 'unknown']), constraint: z.string().nullable(), }), z.object({ kind: z.literal('RNG'), method: z.string(), parameters: z.array(z.number()).nullable(), result: z.union([zValue, z.array(zValue)]), }), ]) ); const select = { id: true, executionId: true, phase: true, generalId: true, nationId: true, cityId: true, npcState: true, year: true, month: true, tick: true, stepCount: true, summary: true, createdAt: true, } satisfies GamePrisma.PlayAuditDecisionSelect; const project = (row: GamePrisma.PlayAuditDecisionGetPayload<{ select: typeof select }>) => ({ ...row, phase: z.enum(['general', 'nation']).parse(row.phase), tick: row.tick.toString(), summary: zSummary.parse(row.summary), }); export const decisionHistory = auditProcedure .input( z .object({ generalId: z.number().int().positive().max(2147483647), month: zAuditMonth.omit({ kind: true }).optional(), phase: z.enum(['general', 'nation']).optional(), cursor: z.object({ tick: zTick, id: zId }).strict().optional(), limit: z.number().int().min(1).max(200).default(50), }) .strict() ) .query(({ ctx, input }) => readAudit(ctx, async (tx) => { const world = await readAuditWorld(tx); const month = input.month ?? { year: world.year, month: world.month }; const ordinal = monthOrdinal(month.year, month.month); if ( ordinal < monthOrdinal(world.startYear, world.startMonth) || ordinal > monthOrdinal(world.year, world.month) ) throw new TRPCError({ code: 'BAD_REQUEST', message: '현재 기수 안의 결정 조회 월을 선택해 주세요.' }); const rows = world.serverId ? await tx.playAuditDecision.findMany({ where: { serverId: world.serverId, generalId: input.generalId, year: month.year, month: month.month, phase: input.phase, ...(input.cursor ? { OR: [ { tick: { lt: BigInt(input.cursor.tick) } }, { tick: BigInt(input.cursor.tick), id: { lt: input.cursor.id } }, ], } : {}), }, orderBy: [{ tick: 'desc' }, { id: 'desc' }], take: input.limit + 1, select, }) : []; const last = rows[input.limit - 1]; return { ...world, month, coverage: world.serverId ? ('PROCEDURES_ONLY' as const) : ('IDENTITY_MISSING' as const), items: rows.slice(0, input.limit).map(project), nextCursor: rows.length > input.limit && last ? { tick: last.tick.toString(), id: last.id } : null, }; }) ); export const decisionDetail = auditProcedure .input( z .object({ id: zId, generalId: z.number().int().positive().max(2147483647), cursor: z.number().int().nonnegative().max(2147483647).optional(), limit: z.number().int().min(1).max(4).default(1), }) .strict() ) .query(({ ctx, input }) => readAudit(ctx, async (tx) => { const world = await readAuditWorld(tx); const row = world.serverId ? await tx.playAuditDecision.findFirst({ where: { id: input.id, generalId: input.generalId, serverId: world.serverId }, select, }) : null; if (!row) throw new TRPCError({ code: 'NOT_FOUND', message: '현재 기수에서 해당 결정 기록을 찾을 수 없습니다.' }); const chunks = await tx.playAuditDecisionChunk.findMany({ where: { decisionId: row.id, ordinal: input.cursor === undefined ? undefined : { gt: input.cursor } }, orderBy: { ordinal: 'asc' }, take: input.limit, select: { ordinal: true, steps: true }, }); return { ...world, decision: project(row), chunks: chunks.slice(0, input.limit).map((chunk) => ({ ordinal: chunk.ordinal, steps: z.array(zStep).max(128).parse(chunk.steps), })), nextCursor: chunks.length && chunks.at(-1)!.ordinal + 1 < Math.ceil(row.stepCount / 128) ? chunks.at(-1)!.ordinal : null, }; }) );