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,
|
||||
|
||||
@@ -2243,6 +2243,129 @@ integration('game API security over HTTP transport', () => {
|
||||
});
|
||||
const admin = await token([`admin.playAudit.read:${profileName}`]);
|
||||
const beforeInputs = await db.inputEvent.count();
|
||||
const decisionIds = [policyId(501), policyId(502)].sort().reverse();
|
||||
const decisionGeneral = 99129; // live general 없이 보존 이력을 읽는다.
|
||||
const decisionSummary = {
|
||||
schemaVersion: 1,
|
||||
coverage: 'PROCEDURES',
|
||||
clockRevision: 1,
|
||||
codeVersion: null,
|
||||
policyRefs: { DEFENCE: policyId(1), secret: 'decision-secret' },
|
||||
requestedAction: '휴식',
|
||||
selectedAction: 'che_징병',
|
||||
selectedReason: '징병',
|
||||
executedAction: '휴식',
|
||||
completed: false,
|
||||
usedFallback: true,
|
||||
blockedReason: '자원 부족',
|
||||
seed: 'decision-secret',
|
||||
};
|
||||
await db.playAuditDecision.createMany({
|
||||
data: decisionIds.map((id, index) => ({
|
||||
id,
|
||||
serverId: seasonId,
|
||||
executionId: id,
|
||||
phase: index ? 'nation' : 'general',
|
||||
generalId: decisionGeneral,
|
||||
nationId: ownerNationId,
|
||||
cityId: 1,
|
||||
npcState: index ? 1 : 2,
|
||||
year: 190,
|
||||
month: 1,
|
||||
tick: 4_320_000_000n,
|
||||
stepCount: 129,
|
||||
summary: decisionSummary,
|
||||
hash: id,
|
||||
})),
|
||||
});
|
||||
const step = {
|
||||
phase: 'general',
|
||||
generalId: decisionGeneral,
|
||||
nationId: ownerNationId,
|
||||
cityId: 1,
|
||||
npcState: 2,
|
||||
year: 190,
|
||||
month: 1,
|
||||
tick: 4_320_000_000,
|
||||
kind: 'PROCEDURE_START',
|
||||
procedure: '상세에서만표시',
|
||||
secret: 'decision-secret',
|
||||
};
|
||||
await db.playAuditDecisionChunk.createMany({
|
||||
data: [
|
||||
{
|
||||
decisionId: decisionIds[0]!,
|
||||
ordinal: 0,
|
||||
steps: Array.from({ length: 128 }, (_, sequence) => ({ ...step, sequence })),
|
||||
},
|
||||
{ decisionId: decisionIds[0]!, ordinal: 1, steps: [{ ...step, sequence: 128 }] },
|
||||
],
|
||||
});
|
||||
const decisionInput = { generalId: decisionGeneral, month: { year: 190, month: 1 }, limit: 1 };
|
||||
expect((await get('decisionHistory', undefined, decisionInput)).status).toBe(401);
|
||||
expect((await get('decisionHistory', await token(['admin']), decisionInput)).status).toBe(403);
|
||||
const decisionList = await get('decisionHistory', admin, decisionInput);
|
||||
expect(decisionList.body).toMatchObject({
|
||||
result: {
|
||||
data: {
|
||||
coverage: 'PROCEDURES_ONLY',
|
||||
items: [{ id: decisionIds[0], tick: '4320000000' }],
|
||||
nextCursor: { tick: '4320000000', id: decisionIds[0] },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(decisionList.body)).not.toContain('상세에서만표시');
|
||||
expect(JSON.stringify(decisionList.body)).not.toContain('decision-secret');
|
||||
expect(
|
||||
(
|
||||
await get('decisionHistory', admin, {
|
||||
...decisionInput,
|
||||
cursor: { tick: '4320000000', id: decisionIds[0] },
|
||||
})
|
||||
).body
|
||||
).toMatchObject({ result: { data: { items: [{ id: decisionIds[1] }], nextCursor: null } } });
|
||||
expect((await get('decisionHistory', admin, { ...decisionInput, phase: 'nation' })).body).toMatchObject({
|
||||
result: { data: { items: [{ id: decisionIds[1] }] } },
|
||||
});
|
||||
const decisionDetailInput = { generalId: decisionGeneral, id: decisionIds[0] };
|
||||
const decisionPage = await get('decisionDetail', admin, decisionDetailInput);
|
||||
expect(decisionPage.status).toBe(200);
|
||||
expect(decisionPage.body).toMatchObject({
|
||||
result: {
|
||||
data: {
|
||||
chunks: [
|
||||
{
|
||||
ordinal: 0,
|
||||
steps: expect.arrayContaining(
|
||||
[{ ...step, secret: undefined, sequence: 0 }].map(
|
||||
({ secret: _secret, ...value }) => value
|
||||
)
|
||||
),
|
||||
},
|
||||
],
|
||||
nextCursor: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(decisionPage.body)).not.toContain('decision-secret');
|
||||
expect((await get('decisionDetail', admin, { ...decisionDetailInput, cursor: 0 })).body).toMatchObject({
|
||||
result: { data: { chunks: [{ ordinal: 1, steps: [{ sequence: 128 }] }], nextCursor: null } },
|
||||
});
|
||||
expect((await get('decisionDetail', admin, { ...decisionDetailInput, generalId })).status).toBe(404);
|
||||
expect((await get('decisionDetail', admin, { ...decisionDetailInput, limit: 5 })).status).toBe(400);
|
||||
expect((await get('decisionHistory', admin, { ...decisionInput, limit: 201 })).status).toBe(400);
|
||||
expect(
|
||||
(
|
||||
await get('decisionHistory', admin, {
|
||||
...decisionInput,
|
||||
cursor: { tick: '9007199254740992', id: decisionIds[0] },
|
||||
})
|
||||
).status
|
||||
).toBe(400);
|
||||
expect(
|
||||
(await get('decisionHistory', admin, { ...decisionInput, month: { year: 9999, month: 1 } })).status
|
||||
).toBe(400);
|
||||
|
||||
const diplomacyInput = {
|
||||
nationId: 99121,
|
||||
otherNationId: 99122,
|
||||
@@ -3002,6 +3125,10 @@ integration('game API security over HTTP transport', () => {
|
||||
result: { data: { items: [] } },
|
||||
});
|
||||
expect((await get('diplomacyEvent', admin, { id: policyId(101) })).status).toBe(404);
|
||||
expect((await get('decisionHistory', admin, decisionInput)).body).toMatchObject({
|
||||
result: { data: { items: [] } },
|
||||
});
|
||||
expect((await get('decisionDetail', admin, decisionDetailInput)).status).toBe(404);
|
||||
expect(await db.inputEvent.count()).toBe(beforeInputs);
|
||||
await redis!.client.publish(
|
||||
`${redisPrefix}:flush`,
|
||||
@@ -3013,6 +3140,8 @@ integration('game API security over HTTP transport', () => {
|
||||
);
|
||||
await expect.poll(async () => (await get('capabilities', admin)).status).toBe(401);
|
||||
} finally {
|
||||
await db.playAuditDecisionChunk.deleteMany({ where: { decision: { serverId: seasonId } } });
|
||||
await db.playAuditDecision.deleteMany({ where: { serverId: seasonId } });
|
||||
await db.playAuditPolicy.deleteMany({ where: { serverId: seasonId } });
|
||||
await db.playAuditDiplomacyEvent.deleteMany({ where: { serverId: seasonId } });
|
||||
await db.diplomacyLetter.deleteMany({ where: { srcNationId: 99121, destNationId: 99122 } });
|
||||
|
||||
Reference in New Issue
Block a user