NPC 결정 기록 조회와 월별 인덱스를 연결하고 감사 화면 검증

This commit is contained in:
2026-09-16 08:14:48 +00:00
parent 63907d95cc
commit 22cf566c6b
15 changed files with 772 additions and 17 deletions
@@ -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 } });
+139
View File
@@ -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'
)
),
() => {
+1 -1
View File
@@ -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',
});
});
+19 -4
View File
@@ -9,6 +9,21 @@ NPC 결정 trace와 조사 A~F의 완성, 전체 종료 경계 및 COST gate는
## 현재 구현
### NPC 결정 조회 API와 화면
`playAudit.decisionHistory/decisionDetail`은 같은 프로필 감사 권한·현재 기수 경계를 따른다.
장수별 조회는 선택 월(미지정은 현재 월), 선택 phase와 `(tick,id)` 내림차순 cursor를 사용한다.
목록은50건 기본/200건 상한으로 요약만 읽고, 상세는 명시 선택 시128 event chunk를
기본1개/최대4개 읽는다. header의 stepCount로 다음 chunk를 판단해 추가 본문이나 COUNT를
읽지 않는다. summary/step은 허용 필드만 투영하며 seed/raw metadata를 반환하지 않는다.
같은 tick의 개인·수뇌 결정 및 사망 장수의 현재 기수 기록도 조회한다.
migration58은 기존 장수 인덱스를 `(server, general, year, month, tick, id)`로 교체한다.
월 조건 밖의 기수 기록을 훑지 않으며 인덱스 개수는 늘리지 않는다. UI는 기존 장수 상세와
버튼·표 스타일을 재사용한다. 열기/상세 선택/더 보기는 명시적으로 수행하고 polling하지
않는다. 결정 URL 복원과 상세 재시도는 상위 장수 목록을 다시 읽지 않는다.
절차 coverage와 미수집 코드 버전을 표시하며 전체 후보 조건·유효 정책 연결은 남는다.
### NPC 결정 저장·복구 기반
새 migration57은 `play_audit_decision` 요약과 `play_audit_decision_chunk` 상세를 분리한다.
@@ -27,7 +42,7 @@ rollback되고 commit 뒤에만 pending prefix를 비운다. 실패한 실행의
정리는 이전 기수 header를 잠그고200개 chunk씩 삭제한 후 header를 제거한다. FK RESTRICT로
무제한 cascade를 막으며 현재 기수는 기존 schema lock/identity 재검사로 보호한다.
빈57·기존56→57·재실행 no-op,302 event/3chunk 복원, 삽입 실패/재시도/충돌 및 실제
DB hooks와 정리 회귀를 검증했다. 전체 비용 gate 전용 API/GUI는 아직 후속이다.
DB hooks와 정리 회귀를 검증했다. 전체 비용 gate는 후속이며 전용 API/GUI는 위 조회 절에 연결했다.
### NPC 판단 관측 기반
@@ -42,9 +57,9 @@ GeneralAI와 예약 실행 handler에 선택적 `onDecisionTrace` 관측 경계
고정 seed3종에서16개 RNG utility 호출의 반환값·객체 identity·다음 RNG 결과가 같고,
실제 NPC 선전포고→개전→점령 fixture에서도 수집 on/off 회귀가 통과했다.
초기 관측 단계에서는 default daemon을 켜지 않았다. 이후 아래 저장 경계에서 현재 기수의 새 실행을 수집하도록 연결했다.
이는 R5의 관측 기반일 뿐 완료가 아니다. 다음 작업은 불변 결정 ID·정책/code version,
후보/조건별 실제 관측값, 실행 결과 연결, 같은 gameplay transaction의 pending/rollback,
정식 migration·bounded 정리, 프로필 목록/상세 API와 GUI를 연결하는 것이다.
이는 R5의 관측 기반일 뿐 완료가 아니다. 불변 결정 ID·기존 정책 참조·실행 결과·
pending/rollback·migration·정리·목록/상세 API와 GUI는 위 절에서 연결했다.
후보/조건별 실제 관측값, 합성 유효 정책과 code version 연결은 남는다.
### 전달 전 DB tick 정밀도 보완
+5 -3
View File
@@ -15,6 +15,7 @@
| 장수 | 이름 부분 검색·장수 번호 정렬, 모든 국가·재야의 현재/월말 장수, 자원·능력·숙련·병력·훈련·사기·장비·특기·위치, 독립 로그 상세 | 현재 예약은 현재 조회에서만 제공. 과거 월말은 그달 모든 명령의 이력이 아님 |
| 도시 | 현재/월말 소유·내정 상태와 국가별 주둔 장수 상세 연결 | 월말 주둔은 그달 모든 방문자가 아님. 지도 기반 탐색은 미완성 |
| 외교 | 국가쌍·기간별 문서 제안/승인/철회/파기, 즉시 합의와 월간·명령 관계 전이, 생성/소멸 관계 | 문서 내용과 실제 관계는 별개. 최초 관측 이전 사건은 복원하지 않음 |
| NPC 결정 | 장수별 개인·수뇌 판단, 절차 시도/차단, 관측한 RNG 결과와 선택·실제 실행 | 새 실행부터 수집하며 후보 내부 조건 전체는 미완성 |
| 정책 | NPC 국가 값·국가/장수 우선순위·국방 설정의 기준 버전과 변경 전후, 당시 주체 | 수뇌용 공개 화면과 NPC 결정의 정책 참조 연결은 아직 없음 |
목록/그래프와 선택 상세를 분리해 읽는다. 표는 기본50건이며 더 보기를 명시적으로
@@ -42,7 +43,7 @@
## DB migration과 적용 순서
정식 game migration에 감사 테이블과 인덱스가 포함되어 있다. `prisma db push`
수동 CREATE TABLE로 대신 적용하지 않는다. 현재 game chain은57개이며 다음 감사
수동 CREATE TABLE로 대신 적용하지 않는다. 현재 game chain은58개이며 다음 감사
migration들을 포함한다. 기존 기록을 삭제하거나 지난달 상세를 역산하지 않는다.
| migration | 준비되는 저장소/제약 |
@@ -55,6 +56,7 @@ migration들을 포함한다. 기존 기록을 삭제하거나 지난달 상세
| `20260916050000_add_play_audit_diplomacy` | 방향·국가쌍·실행 순서 기반 외교 사건과 불변 원문 보호 |
| `20260916060000_widen_play_audit_ticks` | 월 표본/정책 tick을 BIGINT로 확장하여 약60개월 이후 INTEGER 초과 방지 |
| `20260916070000_add_play_audit_decision` | NPC/자동턴 결정 요약과 순서별 상세 chunk, bounded 정리용 FK/index |
| `20260916080000_index_play_audit_decision_month` | 기존 장수 인덱스를 월 조건 포함 인덱스로 교체하여 기수 전체 조회 방지 |
운영은 [릴리스 절차](release-operations.md)의 **DB 보존 버전 업데이트**로 해당 고정
commit을 적용한다. 이 기능을 켜기 위해 시나리오를 초기화할 필요는 없다. 수동 환경의
@@ -73,7 +75,7 @@ Gateway의 새 진입/권한 catalog도 사용하려면 같은 commit의 Gateway
재시작하면 전체 transaction 재시도가 가능하다. 기존 migration을 되돌리거나 수정하지 않는다. tick 확장 migration은 기존 감사 행의
값과 hash를 보존하지만 열 형식 변경의 테이블 잠금/재작성 비용이 있다. 첫 감사 도입에서는
앞 migration이 만든 빈 테이블에 적용되며, 시험판 감사 기록이 이미 많다면 기존 업데이트
유지보수 구간에서 적용 시간을 확인한다. API의 tick은 정밀도 손실을 막기 위해 문자열로 반환한다.
유지보수 구간에서 적용 시간을 확인한다. API의 tick은 정밀도 손실을 막기 위해 문자열로 반환한다. 월별 결정 인덱스 교체도 기존 결정 기록이 많으면 인덱스 생성 시간과 잠금을 업데이트 구간에 고려한다.
확인은 프로필 감사 진입 → 현재 장수/도시 → 초기 표본 → 정책/외교 기준 → 다음 정상
월 경계 후 월말 표본 순서로 한다. 조기 수집 구간의 정산 coverage가 부분일 수 있으므로
@@ -88,7 +90,7 @@ Gateway의 새 진입/권한 catalog도 사용하려면 같은 commit의 Gateway
현재 감사 API도 사용할 수 없다.** 취소 후 다음 초기화까지 읽는 수명주기는 후속 작업이다.
- NPC/유저 자동턴의 개인·수뇌 절차, 정책 차단, RNG utility 결과와 최종 실행 결과는
migration 이후 새 실행부터 저장한다. 후보 내부 조건 전체는 아직 없으며 `PROCEDURES`
coverage로 구분한다. 전용 조회 화면은 후속 작업이다. 과거 결정은 역산하지 않는다.
coverage로 구분한다. 장수 상세의 **NPC 결정 기록 조회**에서 선택 월의 목록과 순서별 상세를 읽는다. 과거 결정은 역산하지 않는다.
- 결정은 당시 확보된 정책 참조를 보존한다. 합성된 유효 정책 상세와 코드 버전 연결은
아직 미완성이다. 코드 버전이 주입되지 않은 실행은 null로 남기며 현재 버전으로 메우지 않는다.
- 계정/IP HMAC 조사, 상세 자원 이동, 예약 변경/실행 연결, 실패·rollback 조사와 알려진
+1 -1
View File
@@ -1222,7 +1222,7 @@ model PlayAuditDecision {
chunks PlayAuditDecisionChunk[]
@@unique([executionId, phase])
@@index([serverId, generalId, tick, id])
@@index([serverId, generalId, year, month, tick, id], map: "play_audit_decision_general_month_idx")
@@index([serverId, nationId, tick, id])
@@map("play_audit_decision")
}
@@ -0,0 +1,2 @@
CREATE INDEX "play_audit_decision_general_month_idx" ON "play_audit_decision"("server_id", "general_id", "year", "month", "tick", "id");
DROP INDEX "play_audit_decision_server_id_general_id_tick_id_idx";
+1 -1
View File
@@ -2,6 +2,6 @@
"formatVersion": 1,
"controllerProtocol": 2,
"gatewaySchemaHead": "20260825000000_add_bulk_release_batches",
"gameSchemaHead": "20260916070000_add_play_audit_decision",
"gameSchemaHead": "20260916080000_index_play_audit_decision_month",
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
}