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 } });
|
||||
|
||||
@@ -43,6 +43,34 @@ const general = {
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
},
|
||||
};
|
||||
const decision = {
|
||||
id: 'd'.repeat(64),
|
||||
executionId: 'e'.repeat(64),
|
||||
phase: 'general',
|
||||
generalId: 1,
|
||||
nationId: 2,
|
||||
cityId: 3,
|
||||
npcState: 2,
|
||||
year: 190,
|
||||
month: 6,
|
||||
tick: '100',
|
||||
stepCount: 129,
|
||||
createdAt: '2026-09-16T00:00:00.000Z',
|
||||
summary: {
|
||||
schemaVersion: 1,
|
||||
coverage: 'PROCEDURES',
|
||||
clockRevision: 1,
|
||||
codeVersion: null,
|
||||
policyRefs: {},
|
||||
requestedAction: '휴식',
|
||||
selectedAction: 'che_징병',
|
||||
selectedReason: '징병 선택',
|
||||
executedAction: '휴식',
|
||||
completed: false,
|
||||
usedFallback: true,
|
||||
blockedReason: '자원 부족',
|
||||
},
|
||||
};
|
||||
const install = async (page: Page, denied = false, baseline: boolean | 'document' | 'created' | 'removed' = false) => {
|
||||
const requests: { operation: string; input: Record<string, unknown> }[] = [];
|
||||
await page.addInitScript((profile) => {
|
||||
@@ -73,6 +101,47 @@ const install = async (page: Page, denied = false, baseline: boolean | 'document
|
||||
},
|
||||
}
|
||||
: result({ profileName: gameProfile, read: true, accounts: false });
|
||||
case 'playAudit.decisionHistory':
|
||||
return result({
|
||||
...world,
|
||||
month: input.month ?? { year: 190, month: 7 },
|
||||
coverage: 'PROCEDURES_ONLY',
|
||||
items: [
|
||||
{
|
||||
...decision,
|
||||
id: input.cursor ? 'c'.repeat(64) : decision.id,
|
||||
phase: input.cursor ? 'nation' : 'general',
|
||||
},
|
||||
],
|
||||
nextCursor: input.cursor ? null : { tick: '100', id: decision.id },
|
||||
});
|
||||
case 'playAudit.decisionDetail':
|
||||
return result({
|
||||
...world,
|
||||
decision,
|
||||
chunks: [
|
||||
{
|
||||
ordinal: input.cursor === undefined ? 0 : 1,
|
||||
steps: [
|
||||
{
|
||||
phase: 'general',
|
||||
generalId: 1,
|
||||
nationId: 2,
|
||||
cityId: 3,
|
||||
npcState: 2,
|
||||
year: 190,
|
||||
month: 6,
|
||||
tick: 100,
|
||||
sequence: input.cursor === undefined ? 0 : 128,
|
||||
...(input.cursor === undefined
|
||||
? { kind: 'PROCEDURE_START', procedure: '<b>징병판정</b>' }
|
||||
: { kind: 'DECISION_END', action: 'che_징병', reason: '징병 선택' }),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
nextCursor: input.cursor === undefined ? 0 : null,
|
||||
});
|
||||
case 'playAudit.generalLogs':
|
||||
return result({
|
||||
...world,
|
||||
@@ -1015,3 +1084,73 @@ test('general search is explicit and persists across pagination and reload', asy
|
||||
await expect(page.getByRole('rowheader', { name: /감사장수/ })).toBeVisible();
|
||||
await capture(page, 'mobile-general-search');
|
||||
});
|
||||
|
||||
test('NPC decisions are explicit, paginated, independently addressable and escaped', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const requests = await install(page);
|
||||
await page.goto(gamePath('/play-audit?tab=generals&at=month&year=190&month=6&general=1'));
|
||||
await expect(page.getByRole('heading', { name: '선택 장수 상세' })).toBeVisible();
|
||||
expect(requests.some((r) => r.operation.startsWith('playAudit.decision'))).toBe(false);
|
||||
await page.getByRole('button', { name: 'NPC 결정 기록 조회', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: '개인 판단 · tick 100', exact: true })).toBeVisible();
|
||||
expect(requests.filter((r) => r.operation === 'playAudit.decisionHistory').at(-1)?.input).toMatchObject({
|
||||
generalId: 1,
|
||||
month: { year: 190, month: 6 },
|
||||
});
|
||||
expect(requests.some((r) => r.operation === 'playAudit.decisionDetail')).toBe(false);
|
||||
const counts = {
|
||||
list: requests.filter((r) => r.operation === 'playAudit.generals').length,
|
||||
history: requests.filter((r) => r.operation === 'playAudit.decisionHistory').length,
|
||||
};
|
||||
await page.getByRole('button', { name: '개인 판단 · tick 100', exact: true }).click();
|
||||
await expect(page.getByRole('list', { name: '판단 절차' })).toContainText('<b>징병판정</b>');
|
||||
await expect(page.getByRole('list', { name: '판단 절차' }).locator('b')).toHaveCount(0);
|
||||
await page.getByRole('button', { name: '판단 절차 더 불러오기', exact: true }).click();
|
||||
await expect(page.getByRole('list', { name: '판단 절차' })).toContainText('최종 선택');
|
||||
expect(requests.filter((r) => r.operation === 'playAudit.decisionDetail').at(-1)?.input).toMatchObject({
|
||||
id: decision.id,
|
||||
generalId: 1,
|
||||
cursor: 0,
|
||||
limit: 1,
|
||||
});
|
||||
expect(requests.filter((r) => r.operation === 'playAudit.generals')).toHaveLength(counts.list);
|
||||
expect(requests.filter((r) => r.operation === 'playAudit.decisionHistory')).toHaveLength(counts.history);
|
||||
await capture(page, 'mobile-npc-decision');
|
||||
await page.reload();
|
||||
await expect(page.getByRole('list', { name: '판단 절차' })).toContainText('징병판정');
|
||||
await page.getByRole('button', { name: '결정 목록 더 불러오기', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: '수뇌 판단 · tick 100', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('NPC decision detail retry preserves history and other general information', async ({ page }) => {
|
||||
const requests = await install(page);
|
||||
let fail = true;
|
||||
await page.route(gameTrpcRoute, async (route) => {
|
||||
if (fail && route.request().url().includes('playAudit.decisionDetail')) {
|
||||
fail = false;
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{
|
||||
error: {
|
||||
message: '결정 상세 재시도',
|
||||
code: -32603,
|
||||
data: { code: 'INTERNAL_SERVER_ERROR', httpStatus: 500 },
|
||||
},
|
||||
},
|
||||
]),
|
||||
});
|
||||
} else await route.fallback();
|
||||
});
|
||||
await page.goto(gamePath('/play-audit?tab=generals&general=1'));
|
||||
await page.getByRole('button', { name: 'NPC 결정 기록 조회', exact: true }).click();
|
||||
await page.getByRole('button', { name: '개인 판단 · tick 100', exact: true }).click();
|
||||
await expect(page.getByRole('alert')).toContainText('결정 상세 재시도');
|
||||
await expect(page.getByRole('button', { name: '개인 판단 · tick 100', exact: true })).toBeVisible();
|
||||
const count = requests.filter((r) => r.operation === 'playAudit.decisionHistory').length;
|
||||
await page.getByRole('button', { name: '결정 상세 다시 조회', exact: true }).click();
|
||||
await expect(page.getByRole('list', { name: '판단 절차' })).toContainText('징병판정');
|
||||
expect(requests.filter((r) => r.operation === 'playAudit.decisionHistory')).toHaveLength(count);
|
||||
await capture(page, 'desktop-npc-decision');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { trpc } from '../../utils/trpc';
|
||||
const props = defineProps<{ generalId: number; month?: { year: number; month: number } }>();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
type History = Awaited<ReturnType<typeof trpc.playAudit.decisionHistory.query>>;
|
||||
type Detail = Awaited<ReturnType<typeof trpc.playAudit.decisionDetail.query>>;
|
||||
type Step = Detail['chunks'][number]['steps'][number];
|
||||
const history = ref<History | null>(null);
|
||||
const detail = ref<Detail | null>(null);
|
||||
const error = ref('');
|
||||
const detailError = ref('');
|
||||
const loading = ref(false);
|
||||
const detailLoading = ref(false);
|
||||
let generation = 0;
|
||||
let detailGeneration = 0;
|
||||
const selected = computed(() => (typeof route.query.decision === 'string' ? route.query.decision : null));
|
||||
const message = (cause: unknown) => (cause instanceof Error ? cause.message : 'NPC 결정 기록을 조회하지 못했습니다.');
|
||||
const load = async (more = false) => {
|
||||
if (loading.value) return;
|
||||
const request = generation;
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const response = await trpc.playAudit.decisionHistory.query({
|
||||
generalId: props.generalId,
|
||||
month: more ? (history.value?.month ?? props.month) : props.month,
|
||||
limit: 50,
|
||||
cursor: more ? (history.value?.nextCursor ?? undefined) : undefined,
|
||||
});
|
||||
if (request === generation)
|
||||
history.value = {
|
||||
...response,
|
||||
items: more ? [...(history.value?.items ?? []), ...response.items] : response.items,
|
||||
};
|
||||
} catch (cause) {
|
||||
if (request === generation) error.value = message(cause);
|
||||
} finally {
|
||||
if (request === generation) loading.value = false;
|
||||
}
|
||||
};
|
||||
const loadDetail = async (more = false) => {
|
||||
if (!selected.value || detailLoading.value) return;
|
||||
const request = detailGeneration;
|
||||
detailLoading.value = true;
|
||||
detailError.value = '';
|
||||
try {
|
||||
const response = await trpc.playAudit.decisionDetail.query({
|
||||
id: selected.value,
|
||||
generalId: props.generalId,
|
||||
cursor: more ? (detail.value?.nextCursor ?? undefined) : undefined,
|
||||
limit: 1,
|
||||
});
|
||||
if (request === detailGeneration)
|
||||
detail.value = {
|
||||
...response,
|
||||
chunks: more ? [...(detail.value?.chunks ?? []), ...response.chunks] : response.chunks,
|
||||
};
|
||||
} catch (cause) {
|
||||
if (request === detailGeneration) detailError.value = message(cause);
|
||||
} finally {
|
||||
if (request === detailGeneration) detailLoading.value = false;
|
||||
}
|
||||
};
|
||||
const select = (id: string | null) => router.push({ query: { ...route.query, decision: id ?? undefined } });
|
||||
const outcome = (done: boolean | null) => (done === null ? '결과 미관측' : done ? '실행 완료' : '실행 실패');
|
||||
const rngValue = (value: Extract<Step, { kind: 'RNG' }>['result']): string => {
|
||||
if (Array.isArray(value)) return value.map(rngValue).join(', ');
|
||||
if (value === null) return '없음';
|
||||
if (typeof value === 'object') return 'entityId' in value ? `대상 #${value.entityId}` : '상세 값 미수집';
|
||||
return String(value);
|
||||
};
|
||||
const stepText = (step: Step): string => {
|
||||
switch (step.kind) {
|
||||
case 'DECISION_START':
|
||||
return `판단 시작 · 예약 ${step.reservedAction}`;
|
||||
case 'DECISION_END':
|
||||
return `최종 선택 · ${step.action ?? '선택 없음'} · ${step.reason ?? '사유 미관측'}`;
|
||||
case 'DECISION_ERROR':
|
||||
return '판단 중 오류';
|
||||
case 'PROCEDURE_START':
|
||||
return `${step.procedure} · 평가 시작`;
|
||||
case 'PROCEDURE_END':
|
||||
return `${step.procedure} · ${step.action ?? '선택 없음'} · ${step.reason ?? '내부 사유 미수집'}`;
|
||||
case 'PROCEDURE_SKIP':
|
||||
return `${step.procedure} · ${{ POLICY: '정책으로 제외', AUTOMATION: '자동화 권한으로 제외', NO_HANDLER: '처리 절차 없음' }[step.reason]}`;
|
||||
case 'CANDIDATE':
|
||||
return `${step.action} · ${{ INVALID_ARGS: '인자 오류', allow: '조건 통과', deny: '조건 차단', unknown: '조건 미확인' }[step.result]}${step.constraint ? ` · ${step.constraint}` : ''}`;
|
||||
case 'RNG':
|
||||
return `${step.method}(${step.parameters?.join(', ') ?? ''}) → ${rngValue(step.result)}`;
|
||||
}
|
||||
};
|
||||
watch(
|
||||
[() => props.generalId, () => props.month?.year, () => props.month?.month],
|
||||
() => {
|
||||
generation++;
|
||||
history.value = null;
|
||||
error.value = '';
|
||||
loading.value = false;
|
||||
void load();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
watch(
|
||||
[selected, () => props.generalId],
|
||||
() => {
|
||||
detailGeneration++;
|
||||
detail.value = null;
|
||||
detailError.value = '';
|
||||
detailLoading.value = false;
|
||||
if (selected.value) void loadDetail();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="audit-decisions" aria-label="NPC 결정 기록">
|
||||
<p>
|
||||
{{ history ? `${history.month.year}년 ${history.month.month}월` : '선택 월' }} · NPC·유저 자동턴의 개인/수뇌
|
||||
판단
|
||||
</p>
|
||||
<p>
|
||||
절차와 선택 결과를 수집한 기록입니다. 후보 내부 조건 전체는 아직 포함되지 않으며, 기록이 없다고 판단 시도가
|
||||
없었다는 뜻은 아닙니다.
|
||||
</p>
|
||||
<p v-if="loading" role="status">결정 목록 조회 중…</p>
|
||||
<p v-if="error" role="alert">
|
||||
{{ error }} <button class="legacy-button" @click="load()">결정 목록 다시 조회</button>
|
||||
</p>
|
||||
<p v-if="history && !history.items.length">이 월에 수집된 결정 기록이 없습니다.</p>
|
||||
<div v-if="history?.items.length" class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>판단</th>
|
||||
<th>주체</th>
|
||||
<th>선택 → 실행</th>
|
||||
<th>결과</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in history.items" :key="item.id">
|
||||
<td>
|
||||
<button class="legacy-button" @click="select(item.id)">
|
||||
{{ item.phase === 'nation' ? '수뇌 판단' : '개인 판단' }} · tick {{ item.tick }}
|
||||
</button>
|
||||
</td>
|
||||
<td>{{ item.npcState < 2 ? '유저 자동턴' : item.npcState === 5 ? '부대장 NPC' : 'NPC' }}</td>
|
||||
<td>{{ item.summary.selectedAction ?? '선택 없음' }} → {{ item.summary.executedAction }}</td>
|
||||
<td>
|
||||
{{ outcome(item.summary.completed) }}{{ item.summary.usedFallback ? ' · 대체 실행' : '' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button v-if="history?.nextCursor" class="legacy-button" :disabled="loading" @click="load(true)">
|
||||
결정 목록 더 불러오기
|
||||
</button>
|
||||
<section v-if="selected" aria-label="선택 결정 상세">
|
||||
<button class="legacy-button" @click="select(null)">결정 상세 닫기</button>
|
||||
<p v-if="detailLoading" role="status">결정 상세 조회 중…</p>
|
||||
<p v-if="detailError" role="alert">
|
||||
{{ detailError }}
|
||||
<button class="legacy-button" @click="loadDetail(Boolean(detail))">결정 상세 다시 조회</button>
|
||||
</p>
|
||||
<template v-if="detail">
|
||||
<p>
|
||||
{{ detail.decision.year }}년 {{ detail.decision.month }}월 · 국가 #{{ detail.decision.nationId }} ·
|
||||
도시 #{{ detail.decision.cityId }} · tick {{ detail.decision.tick }}
|
||||
</p>
|
||||
<p>
|
||||
예약 {{ detail.decision.summary.requestedAction }} · 선택
|
||||
{{ detail.decision.summary.selectedAction ?? '없음' }} · 실행
|
||||
{{ detail.decision.summary.executedAction }}
|
||||
</p>
|
||||
<p>
|
||||
선택 사유: {{ detail.decision.summary.selectedReason ?? '미관측' }} ·
|
||||
{{ outcome(detail.decision.summary.completed) }}
|
||||
</p>
|
||||
<p v-if="detail.decision.summary.blockedReason">
|
||||
차단 사유: {{ detail.decision.summary.blockedReason }}
|
||||
</p>
|
||||
<p>
|
||||
코드 버전: {{ detail.decision.summary.codeVersion ?? '미관측' }} · 전체 관측
|
||||
{{ detail.decision.stepCount }}개
|
||||
</p>
|
||||
<details>
|
||||
<summary>당시 정책 참조</summary>
|
||||
<p v-if="!Object.keys(detail.decision.summary.policyRefs).length">확보된 정책 참조가 없습니다.</p>
|
||||
<p v-for="(id, area) in detail.decision.summary.policyRefs" :key="area">{{ area }}: {{ id }}</p>
|
||||
</details>
|
||||
<ol aria-label="판단 절차">
|
||||
<template v-for="chunk in detail.chunks" :key="chunk.ordinal"
|
||||
><li v-for="step in chunk.steps" :key="step.sequence" :value="step.sequence + 1">
|
||||
{{ stepText(step) }}
|
||||
</li></template
|
||||
>
|
||||
</ol>
|
||||
<button
|
||||
v-if="detail.nextCursor !== null"
|
||||
class="legacy-button"
|
||||
:disabled="detailLoading"
|
||||
@click="loadDetail(true)"
|
||||
>
|
||||
판단 절차 더 불러오기
|
||||
</button>
|
||||
</template>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.audit-decisions {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.table-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
min-width: 640px;
|
||||
width: 100%;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: 6px;
|
||||
text-align: left;
|
||||
border: 1px solid gray;
|
||||
}
|
||||
ol {
|
||||
padding-left: 28px;
|
||||
}
|
||||
li {
|
||||
padding: 4px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import AuditGeneralDecisions from './AuditGeneralDecisions.vue';
|
||||
import PanelCard from '../ui/PanelCard.vue';
|
||||
import AuditGeneralLogs from './AuditGeneralLogs.vue';
|
||||
import { trpc } from '../../utils/trpc';
|
||||
@@ -17,6 +19,16 @@ const turnsLoading = ref(false);
|
||||
const error = ref('');
|
||||
const turnsError = ref('');
|
||||
const showLogs = ref(false);
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const showDecisions = ref(false);
|
||||
const decisionsOpen = computed(() => showDecisions.value || typeof route.query.decision === 'string');
|
||||
const toggleDecisions = async () => {
|
||||
if (decisionsOpen.value) {
|
||||
showDecisions.value = false;
|
||||
await router.push({ query: { ...route.query, decision: undefined } });
|
||||
} else showDecisions.value = true;
|
||||
};
|
||||
let generation = 0;
|
||||
const format = (value: number) => value.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
|
||||
const load = async () => {
|
||||
@@ -126,6 +138,14 @@ watch(
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
<button class="legacy-button" @click="toggleDecisions">
|
||||
{{ decisionsOpen ? 'NPC 결정 기록 닫기' : 'NPC 결정 기록 조회' }}
|
||||
</button>
|
||||
<AuditGeneralDecisions
|
||||
v-if="decisionsOpen"
|
||||
:general-id="generalId"
|
||||
:month="at ? { year: at.year, month: at.month } : undefined"
|
||||
/>
|
||||
<button class="legacy-button" @click="showLogs = !showLogs">
|
||||
{{ showLogs ? '장수 기록 닫기' : '장수 기록 조회' }}
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { trpc } from '../../utils/trpc';
|
||||
import type { trpc } from '../../utils/trpc';
|
||||
|
||||
type Series = Awaited<ReturnType<typeof trpc.playAudit.nationSeries.query>>;
|
||||
type Point = Series['items'][number];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { trpc } from '../../utils/trpc';
|
||||
import type { trpc } from '../../utils/trpc';
|
||||
type Snapshot = Awaited<ReturnType<typeof trpc.playAudit.nationSnapshot.query>>;
|
||||
const props = defineProps<{ data: Snapshot }>();
|
||||
const label = computed(() => (props.data.sample?.kind === 'INITIAL' ? '수집 시작 기준' : '최종 표본'));
|
||||
|
||||
@@ -97,10 +97,10 @@ const selectedCity = computed(() =>
|
||||
: null
|
||||
);
|
||||
const selectGeneral = (id: number) =>
|
||||
router.push({ query: { ...route.query, general: String(id), cityRecord: undefined } });
|
||||
const closeGeneral = () => router.push({ query: { ...route.query, general: undefined } });
|
||||
router.push({ query: { ...route.query, general: String(id), cityRecord: undefined, decision: undefined } });
|
||||
const closeGeneral = () => router.push({ query: { ...route.query, general: undefined, decision: undefined } });
|
||||
const selectCity = (id: number) =>
|
||||
router.push({ query: { ...route.query, cityRecord: String(id), general: undefined } });
|
||||
router.push({ query: { ...route.query, cityRecord: String(id), general: undefined, decision: undefined } });
|
||||
const closeCity = () => router.push({ query: { ...route.query, cityRecord: undefined } });
|
||||
const at = computed(() =>
|
||||
moment.value === 'current'
|
||||
@@ -296,7 +296,12 @@ watch(
|
||||
() =>
|
||||
JSON.stringify(
|
||||
Object.entries(route.query).filter(
|
||||
([key]) => key !== 'general' && key !== 'cityRecord' && key !== 'policy' && key !== 'event'
|
||||
([key]) =>
|
||||
key !== 'general' &&
|
||||
key !== 'cityRecord' &&
|
||||
key !== 'policy' &&
|
||||
key !== 'event' &&
|
||||
key !== 'decision'
|
||||
)
|
||||
),
|
||||
() => {
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('readReleaseManifest', () => {
|
||||
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
|
||||
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
|
||||
gatewaySchemaHead: '20260825000000_add_bulk_release_batches',
|
||||
gameSchemaHead: '20260916070000_add_play_audit_decision',
|
||||
gameSchemaHead: '20260916080000_index_play_audit_decision_month',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user