NPC 결정 기록 조회와 월별 인덱스를 연결하고 감사 화면 검증
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
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,
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -1,3 +1,4 @@
|
||||
import { decisionHistory, decisionDetail } from './decisions.js';
|
||||
import { diplomacyHistory, diplomacyEvent } from './diplomacy.js';
|
||||
import { nationSeries, zAuditNation } from './nationSeries.js';
|
||||
import { cityDetail, generalDetail, generalTurns } from './details.js';
|
||||
@@ -25,6 +26,8 @@ import {
|
||||
} from './projection.js';
|
||||
|
||||
export const playAuditRouter = router({
|
||||
decisionHistory,
|
||||
decisionDetail,
|
||||
diplomacyHistory,
|
||||
diplomacyEvent,
|
||||
policyHistory,
|
||||
|
||||
Reference in New Issue
Block a user