feat: 프로필 감사 화면에서 정책 버전과 전후 값을 조회

This commit is contained in:
2026-09-16 05:02:32 +00:00
parent d549cbf887
commit 3d60e6122d
8 changed files with 845 additions and 11 deletions
@@ -1,6 +1,7 @@
import { nationSeries, zAuditNation } from './nationSeries.js';
import { cityDetail, generalDetail, generalTurns } from './details.js';
import { generalLogs } from './logs.js';
import { policyHistory, policyVersion } from './policies.js';
import { z } from 'zod';
import { canReadPlayAuditAccounts } from '@sammo-ts/common';
import { router } from '../../trpc.js';
@@ -23,6 +24,8 @@ import {
} from './projection.js';
export const playAuditRouter = router({
policyHistory,
policyVersion,
generalLogs,
cityDetail,
generalDetail,
@@ -0,0 +1,159 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { asRecord } from '@sammo-ts/common';
import type { GamePrisma } from '@sammo-ts/infra';
import { auditProcedure, monthOrdinal, readAudit, readAuditWorld, zAuditMonth } from './shared.js';
const zArea = z.enum(['NPC_VALUES', 'NPC_NATION_PRIORITY', 'NPC_GENERAL_PRIORITY', 'DEFENCE']);
const zMonth = zAuditMonth.omit({ kind: true });
const zActor = z
.object({
generalId: z.number().int(),
name: z.string(),
nationId: z.number().int(),
officerLevel: z.number().int(),
npcState: z.number().int(),
})
.nullable();
const summarySelect = {
id: true,
schemaVersion: true,
nationId: true,
area: true,
revision: true,
previousId: true,
source: true,
year: true,
month: true,
actor: true,
createdAt: true,
} satisfies GamePrisma.PlayAuditPolicySelect;
type Summary = GamePrisma.PlayAuditPolicyGetPayload<{ select: typeof summarySelect }>;
/** 향후 자국 권한 API의 공개 정보 경계. 실제 자국 인가는 호출부에서 별도로 수행한다. */
export const projectPolicySummary = (row: Summary) => {
if (row.schemaVersion !== 1)
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '지원하지 않는 정책 기록 버전입니다.' });
return {
id: row.id,
nationId: row.nationId,
area: zArea.parse(row.area),
revision: row.revision,
previousId: row.previousId,
source: z.enum(['BASELINE', 'CHANGE', 'OBSERVED_GAP']).parse(row.source),
year: row.year,
month: row.month,
actor: zActor.parse(row.actor),
createdAt: row.createdAt,
};
};
/** 계정·요청·내부 진단 값을 제외한 정책 전후 projection이다. */
export const projectPolicyConfiguration = (
row: Summary & { before: GamePrisma.JsonValue; after: GamePrisma.JsonValue }
) => {
const before = row.before === null ? null : asRecord(row.before);
const after = asRecord(row.after);
return {
...projectPolicySummary(row),
fields: [...new Set([...Object.keys(before ?? {}), ...Object.keys(after)])].sort().map((key) => ({
key,
beforeJson: before === null ? null : JSON.stringify(before[key] ?? null),
afterJson: JSON.stringify(after[key] ?? null),
changed: before !== null && JSON.stringify(before[key] ?? null) !== JSON.stringify(after[key] ?? null),
})),
};
};
export const policyHistory = auditProcedure
.input(
z
.object({
nationId: z.number().int().nonnegative(),
area: zArea,
from: zMonth,
to: zMonth,
cursor: z.number().int().positive().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 from = monthOrdinal(input.from.year, input.from.month);
const to = monthOrdinal(input.to.year, input.to.month);
if (
from > to ||
from < monthOrdinal(world.startYear, world.startMonth) ||
to > monthOrdinal(world.year, world.month)
)
throw new TRPCError({ code: 'BAD_REQUEST', message: '현재 기수 안의 정책 조회 기간을 선택해 주세요.' });
const rows = world.serverId
? await tx.playAuditPolicy.findMany({
where: {
serverId: world.serverId,
nationId: input.nationId,
area: input.area,
revision: input.cursor === undefined ? undefined : { lt: input.cursor },
AND: [
{
OR: [
{ year: { gt: input.from.year } },
{ year: input.from.year, month: { gte: input.from.month } },
],
},
{
OR: [
{ year: { lt: input.to.year } },
{ year: input.to.year, month: { lte: input.to.month } },
],
},
],
},
orderBy: { revision: 'desc' },
take: input.limit + 1,
select: summarySelect,
})
: [];
return {
...world,
coverage: world.serverId ? ('RECORDED_VERSIONS_ONLY' as const) : ('IDENTITY_MISSING' as const),
items: rows.slice(0, input.limit).map(projectPolicySummary),
nextCursor: rows.length > input.limit ? rows[input.limit - 1]!.revision : null,
};
})
);
export const policyVersion = auditProcedure
.input(z.object({ id: z.string().regex(/^[a-f0-9]{64}$/) }).strict())
.query(({ ctx, input }) =>
readAudit(ctx, async (tx) => {
const world = await readAuditWorld(tx);
const row = world.serverId
? await tx.playAuditPolicy.findFirst({
where: { id: input.id, serverId: world.serverId },
select: {
...summarySelect,
tick: true,
ordinal: true,
before: true,
after: true,
requestId: true,
inputSequence: true,
},
})
: null;
if (!row) throw new TRPCError({ code: 'NOT_FOUND', message: '현재 기수의 정책 버전을 찾을 수 없습니다.' });
return {
...world,
version: {
...projectPolicyConfiguration(row),
tick: row.tick?.toString() ?? null,
ordinal: row.ordinal,
requestId: row.requestId,
inputSequence: row.inputSequence?.toString() ?? null,
},
};
})
);
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import { projectPolicyConfiguration } from '../src/router/playAudit/policies.js';
const row = {
id: 'policy',
schemaVersion: 1,
nationId: 2,
area: 'DEFENCE',
revision: 2,
previousId: 'baseline',
source: 'CHANGE',
year: 190,
month: 2,
tick: 99,
ordinal: 18,
actor: {
userId: 'private-account',
generalId: 3,
name: '당시군주',
nationId: 2,
officerLevel: 12,
npcState: 0,
permission: 4,
debug: 'internal',
},
createdAt: new Date('2026-09-16T00:00:00Z'),
before: { scout: 0 },
after: { scout: 1 },
requestId: 'internal-request',
inputSequence: 123n,
};
describe('policy public configuration boundary', () => {
it('retains historical office and values while excluding account and admin diagnostics', () => {
const projection = projectPolicyConfiguration(row);
expect(projection).toMatchObject({
nationId: 2,
revision: 2,
actor: { generalId: 3, officerLevel: 12 },
fields: [{ key: 'scout', beforeJson: '0', afterJson: '1', changed: true }],
});
const serialized = JSON.stringify(projection);
for (const excluded of [
'private-account',
'permission',
'debug',
'internal-request',
'inputSequence',
'tick',
'ordinal',
])
expect(serialized).not.toContain(excluded);
});
it('refuses an unsupported record schema rather than guessing its meaning', () => {
expect(() => projectPolicyConfiguration({ ...row, schemaVersion: 2 })).toThrow('지원하지 않는 정책 기록 버전');
});
it('distinguishes unobserved baseline from a recorded inherited setting', () => {
expect(
projectPolicyConfiguration({
...row,
source: 'BASELINE',
actor: null,
before: null,
after: { scout: null },
}).fields
).toEqual([{ key: 'scout', beforeJson: null, afterJson: 'null', changed: false }]);
});
});
@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto';
import { projectCurrentGeneral } from '../src/router/playAudit/projection.js';
import { asRecord } from '@sammo-ts/common';
import fs from 'node:fs/promises';
@@ -2154,6 +2155,8 @@ integration('game API security over HTTP transport', () => {
const auditUserId = `audit-http-${process.pid}`;
const seasonId = `audit-season-${process.pid}`;
const sampleId = `${seasonId}:190:1`;
const policyId = (revision: number) =>
createHash('sha256').update(`${seasonId}:policy:${revision}`).digest('hex');
const originalWorld = await db.worldState.findUniqueOrThrow({ where: { id: fixtureWorldId } });
const token = async (roles: string[], sanctions: GameSessionTokenPayload['sanctions'] = {}) => {
const payload = buildPayload(`audit-${roles.join('-')}`, sanctions, auditUserId);
@@ -2238,6 +2241,100 @@ integration('game API security over HTTP transport', () => {
});
const admin = await token([`admin.playAudit.read:${profileName}`]);
const beforeInputs = await db.inputEvent.count();
const policyInput = {
nationId: 99128,
area: 'DEFENCE',
from: { year: 190, month: 1 },
to: { year: 190, month: 2 },
};
await db.playAuditPolicy.createMany({
data: [1, 2, 3].map((revision) => ({
id: policyId(revision),
serverId: seasonId,
nationId: 99128,
area: 'DEFENCE',
revision,
previousId: revision > 1 ? policyId(revision - 1) : null,
source: revision === 1 ? 'BASELINE' : 'CHANGE',
year: 190,
month: revision === 3 ? 2 : 1,
ordinal: revision,
tick: 12,
requestId: revision > 1 ? 'audit-policy-request' : null,
inputSequence: revision > 1 ? 9007199254740993n : null,
actor:
revision > 1
? {
userId: 'hidden-account',
generalId,
name: '당시군주',
nationId: 99128,
officerLevel: 12,
npcState: 0,
permission: 3,
extra: 'hidden-extra',
}
: GamePrisma.DbNull,
before: revision === 1 ? GamePrisma.DbNull : { scout: revision - 1 },
after: { scout: revision },
hash: 'fixture',
})),
});
const policyPage = await get('policyHistory', admin, { ...policyInput, limit: 1 });
expect(policyPage.status).toBe(200);
expect(policyPage.body).toMatchObject({
result: {
data: {
nextCursor: 3,
items: [{ revision: 3, source: 'CHANGE', actor: { name: '당시군주', officerLevel: 12 } }],
},
},
});
for (const excluded of ['hidden-account', 'hidden-extra', 'before', 'after', 'inputSequence', 'requestId'])
expect(JSON.stringify(policyPage.body)).not.toContain(excluded);
expect((await get('policyHistory', admin, { ...policyInput, cursor: 3 })).body).toMatchObject({
result: { data: { nextCursor: null, items: [{ revision: 2 }, { revision: 1, actor: null }] } },
});
expect(
(await get('policyHistory', admin, { ...policyInput, to: { year: 190, month: 1 } })).body
).toMatchObject({ result: { data: { items: [{ revision: 2 }, { revision: 1 }] } } });
const policyDetail = await get('policyVersion', admin, { id: policyId(3) });
expect(policyDetail.status).toBe(200);
expect(policyDetail.body).toMatchObject({
result: {
data: {
version: {
previousId: policyId(2),
inputSequence: '9007199254740993',
fields: [{ key: 'scout', beforeJson: '2', afterJson: '3', changed: true }],
},
},
},
});
expect(JSON.stringify(policyDetail.body)).not.toContain('hidden-account');
expect((await get('policyVersion', admin, { id: policyId(1) })).body).toMatchObject({
result: { data: { version: { fields: [{ beforeJson: null, changed: false }] } } },
});
expect((await get('policyVersion', admin, { id: policyId(99) })).status).toBe(404);
for (const patch of [
{ limit: 201 },
{ cursor: 0 },
{ area: 'ANY' },
{ from: { year: 189, month: 12 } },
{ to: { year: 191, month: 1 } },
{ from: { year: 190, month: 2 }, to: { year: 190, month: 1 } },
])
expect((await get('policyHistory', admin, { ...policyInput, ...patch })).status).toBe(400);
expect((await get('policyVersion', admin, { id: '../bad' })).status).toBe(400);
for (const [operation, input] of [
['policyHistory', policyInput],
['policyVersion', { id: policyId(3) }],
] as const) {
expect((await get(operation, undefined, input)).status).toBe(401);
for (const roles of [['admin'], ['admin.playAudit.read:other:default']])
expect((await get(operation, await token(roles), input)).status).toBe(403);
}
const logGeneralId = 99129; // No live general: death must not hide retained records.
const ownLogs = await Promise.all(
['HISTORY', 'ACTION', 'BATTLE_BRIEF', 'BATTLE_DETAIL'].map((category) =>
@@ -2398,6 +2495,8 @@ integration('game API security over HTTP transport', () => {
serverRestrictions: { [profileName]: { blockedFeatures: ['gameplay'] } },
});
expect((await get('capabilities', blocked)).status).toBe(403);
expect((await get('policyHistory', blocked, policyInput)).status).toBe(403);
expect((await get('policyVersion', blocked, { id: policyId(3) })).status).toBe(403);
const population = {
count: 0,
gold: 0,
@@ -2647,6 +2746,10 @@ integration('game API security over HTTP transport', () => {
expect((await get('generals', admin, { at: { year: 190, month: 1 } })).body).toMatchObject({
result: { data: { collected: false, items: [] } },
});
expect((await get('policyHistory', admin, policyInput)).body).toMatchObject({
result: { data: { items: [] } },
});
expect((await get('policyVersion', admin, { id: policyId(3) })).status).toBe(404);
expect(await db.inputEvent.count()).toBe(beforeInputs);
await redis!.client.publish(
`${redisPrefix}:flush`,
@@ -2658,6 +2761,7 @@ integration('game API security over HTTP transport', () => {
);
await expect.poll(async () => (await get('capabilities', admin)).status).toBe(401);
} finally {
await db.playAuditPolicy.deleteMany({ where: { serverId: seasonId } });
await db.logEntry.deleteMany({ where: { text: { startsWith: `${seasonId}:` } } });
await db.playAuditMonth.deleteMany({ where: { serverId: seasonId } });
await db.generalTurn.deleteMany({ where: { generalId, turnIdx: { in: [9001, 9002] } } });
+172
View File
@@ -88,6 +88,72 @@ const install = async (page: Page, denied = false) => {
],
nextCursor: null,
});
case 'playAudit.policyHistory':
return result({
...world,
coverage: 'RECORDED_VERSIONS_ONLY',
items: [
{
id: String(input.cursor ? 'a' : 'b').repeat(64),
nationId: 2,
area: input.area,
revision: input.cursor ? 1 : 2,
source: input.cursor ? 'BASELINE' : 'CHANGE',
year: 190,
month: 6,
previousId: input.cursor ? null : 'a'.repeat(64),
actor: input.cursor
? null
: {
generalId: 1,
name: '당시군주',
nationId: 2,
officerLevel: 12,
npcState: 0,
},
createdAt: world.asOf,
},
],
nextCursor: input.cursor ? null : 2,
});
case 'playAudit.policyVersion': {
const baseline = input.id === 'a'.repeat(64);
return result({
...world,
version: {
id: input.id,
nationId: 2,
area: 'DEFENCE',
revision: baseline ? 1 : 2,
source: baseline ? 'BASELINE' : 'CHANGE',
year: 190,
month: 6,
previousId: baseline ? null : 'a'.repeat(64),
actor: baseline
? null
: { generalId: 1, name: '당시군주', nationId: 2, officerLevel: 12, npcState: 0 },
createdAt: world.asOf,
tick: '100',
ordinal: baseline ? 1 : 2,
requestId: baseline ? null : 'policy-request-fixture',
inputSequence: baseline ? null : '9007199254740993',
fields: [
{
key: 'scout',
beforeJson: baseline ? null : '0',
afterJson: '1',
changed: !baseline,
},
{
key: 'priority',
beforeJson: baseline ? null : 'null',
afterJson: '["<script>window.auditInjected=true</script>"]',
changed: !baseline,
},
],
},
});
}
case 'playAudit.coverage':
return result({ ...world, status: 'COLLECTED', samples: [], nextCursor: null });
case 'playAudit.nations':
@@ -510,3 +576,109 @@ test('initial calendar before the scenario year bounds default periods and month
.poll(() => requests.find((r) => r.operation === 'playAudit.nationSeries')?.input)
.toMatchObject({ from: { year: 189, month: 10 }, to: { year: 189, month: 10 } });
});
test('policy history reads summaries and selected versions only, preserving deep links and pagination', async ({
page,
}) => {
const requests = await install(page);
const path = '/play-audit?tab=policies&nation=2&policyArea=DEFENCE&fromYear=190&fromMonth=1&year=190&month=6';
await page.goto(gamePath(path));
await expect(page.getByRole('button', { name: '버전 2', exact: true })).toBeVisible();
expect(
requests.some(({ operation }) =>
['playAudit.policyVersion', 'playAudit.nationSeries', 'playAudit.generals'].includes(operation)
)
).toBe(false);
const listReads = requests.filter(({ operation }) => operation === 'playAudit.policyHistory').length;
const nationReads = requests.filter(({ operation }) => operation === 'playAudit.nations').length;
await page.getByRole('button', { name: '버전 2', exact: true }).click();
await expect(page.getByText('임관 권유 설정 (변경)', { exact: true })).toBeVisible();
await expect(page.getByText(/국가 #2 · 버전 2/)).toBeVisible();
expect(requests.filter(({ operation }) => operation === 'playAudit.policyHistory')).toHaveLength(listReads);
expect(requests.filter(({ operation }) => operation === 'playAudit.nations')).toHaveLength(nationReads);
await page.getByText('요청 연결', { exact: true }).click();
await expect(page.getByText('입력 순번 9007199254740993', { exact: true })).toBeVisible();
expect(await page.evaluate(() => Object.hasOwn(window, 'auditInjected'))).toBe(false);
await capture(page, 'desktop-policy-detail');
await page.reload();
await expect(page.getByText(/국가 #2 · 버전 2/)).toBeVisible();
await page.getByRole('button', { name: '이전 정책 버전', exact: true }).click();
await expect(page.getByText(/국가 #2 · 버전 1/)).toBeVisible();
await expect(page.getByRole('cell', { name: '관측하지 않음', exact: true })).toHaveCount(2);
await page.goBack();
await expect(page.getByText(/국가 #2 · 버전 2/)).toBeVisible();
await page.setViewportSize({ width: 390, height: 844 });
await capture(page, 'mobile-policy-detail');
await page.getByRole('button', { name: '정책 상세 닫기' }).click();
await expect(page.getByRole('heading', { name: /선택 정책 버전/ })).toHaveCount(0);
await page.getByRole('button', { name: '다음 정책 50개' }).click();
await expect(page.getByRole('button', { name: '버전 1', exact: true })).toBeVisible();
expect(requests.filter(({ operation }) => operation === 'playAudit.policyHistory').at(-1)?.input).toMatchObject({
area: 'DEFENCE',
cursor: 2,
nationId: 2,
});
await page.getByLabel('정책 영역').selectOption('NPC_GENERAL_PRIORITY');
const before = requests.filter(({ operation }) => operation === 'playAudit.policyHistory').length;
await page.getByRole('button', { name: '조회', exact: true }).click();
await expect
.poll(() => requests.filter(({ operation }) => operation === 'playAudit.policyHistory').length)
.toBeGreaterThan(before);
expect(requests.filter(({ operation }) => operation === 'playAudit.policyHistory').at(-1)?.input.area).toBe(
'NPC_GENERAL_PRIORITY'
);
});
test('policy detail failure retries independently without reloading its history', async ({ page }) => {
const requests = await install(page);
let fail = true;
await page.route(gameTrpcRoute, async (route) => {
if (fail && route.request().url().includes('playAudit.policyVersion')) {
fail = false;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{
error: {
message: '정책 버전 일시 오류',
code: -32603,
data: { code: 'INTERNAL_SERVER_ERROR', httpStatus: 500 },
},
},
]),
});
return;
}
await route.fallback();
});
await page.goto(
gamePath('/play-audit?tab=policies&nation=2&policyArea=DEFENCE&fromYear=190&fromMonth=1&year=190&month=6')
);
await page.getByRole('button', { name: '버전 2', exact: true }).click();
await expect(page.getByRole('alert')).toContainText('정책 버전 일시 오류');
await expect(page.getByRole('button', { name: '버전 2', exact: true })).toBeVisible();
const count = requests.filter(({ operation }) => operation === 'playAudit.policyHistory').length;
await page.getByRole('button', { name: '버전 다시 조회', exact: true }).click();
await expect(page.getByText(/국가 #2 · 버전 2/)).toBeVisible();
expect(requests.filter(({ operation }) => operation === 'playAudit.policyHistory')).toHaveLength(count);
});
test('policy filter drafts do not read until applied, including default dates', async ({ page }) => {
const requests = await install(page);
await page.goto(gamePath('/play-audit?tab=policies&nation=2'));
await expect(page.getByRole('button', { name: '버전 2', exact: true })).toBeVisible();
const count = requests.filter(({ operation }) => operation === 'playAudit.policyHistory').length;
await page.getByLabel('시작 월', { exact: true }).fill('3');
await page.getByLabel('정책 영역').selectOption('DEFENCE');
await page.getByRole('button', { name: '조회', exact: true }).focus();
expect(requests.filter(({ operation }) => operation === 'playAudit.policyHistory')).toHaveLength(count);
await page.getByRole('button', { name: '조회', exact: true }).click();
await expect
.poll(() => requests.filter(({ operation }) => operation === 'playAudit.policyHistory').length)
.toBe(count + 1);
expect(requests.filter(({ operation }) => operation === 'playAudit.policyHistory').at(-1)?.input).toMatchObject({
area: 'DEFENCE',
from: { year: 190, month: 3 },
});
});
@@ -0,0 +1,256 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { trpc } from '../../utils/trpc';
const props = defineProps<{
nationId: number;
area: 'NPC_VALUES' | 'NPC_NATION_PRIORITY' | 'NPC_GENERAL_PRIORITY' | 'DEFENCE';
from: { year: number; month: number };
to: { year: number; month: number };
}>();
type History = Awaited<ReturnType<typeof trpc.playAudit.policyHistory.query>>;
type Detail = Awaited<ReturnType<typeof trpc.playAudit.policyVersion.query>>;
const route = useRoute();
const router = useRouter();
const data = 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.policy === 'string' ? route.query.policy : null));
const fieldLabels: Record<string, string> = {
reqNationGold: '국가 권장 금',
reqNationRice: '국가 권장 쌀',
reqHumanWarUrgentGold: '유저전투장 긴급포상 금',
reqHumanWarUrgentRice: '유저전투장 긴급포상 쌀',
reqHumanWarRecommandGold: '유저전투장 권장 금',
reqHumanWarRecommandRice: '유저전투장 권장 쌀',
reqHumanDevelGold: '유저내정장 권장 금',
reqHumanDevelRice: '유저내정장 권장 쌀',
reqNPCWarGold: 'NPC전투장 권장 금',
reqNPCWarRice: 'NPC전투장 권장 쌀',
reqNPCDevelGold: 'NPC내정장 권장 금',
reqNPCDevelRice: 'NPC내정장 권장 쌀',
minimumResourceActionAmount: '포상/몰수/헌납/삼/팜 최소 단위',
maximumResourceActionAmount: '포상/몰수/헌납/삼/팜 최대 단위',
minWarCrew: '최소 전투 가능 병력 수',
minNPCRecruitCityPopulation: 'NPC 최소 징병 가능 인구 수',
safeRecruitCityPopulationRatio: '제자리 징병 허용 인구율 (비율)',
minNPCWarLeadership: 'NPC 전투 참여 통솔 기준',
properWarTrainAtmos: '훈련/사기진작 목표치',
cureThreshold: '요양 기준',
CombatForce: '전투 부대 편성',
SupportForce: '지원 부대 편성',
DevelopForce: '내정 부대 편성',
priority: '행동 우선순위',
war: '전쟁 금지 설정',
scout: '임관 권유 설정',
secretlimit: '기밀 공개 기준 (년)',
};
const labels = { BASELINE: '최초 관측', CHANGE: '실제 변경', OBSERVED_GAP: '관측 누락 이후 기준' };
const message = (cause: unknown) => (cause instanceof Error ? cause.message : '정책 이력을 조회하지 못했습니다.');
const load = async (append = false) => {
const request = ++generation;
loading.value = true;
error.value = '';
if (!append) data.value = null;
try {
const response = await trpc.playAudit.policyHistory.query({
...props,
limit: 50,
cursor: append ? (data.value?.nextCursor ?? undefined) : undefined,
});
if (request === generation)
data.value = {
...response,
items: append ? [...(data.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 () => {
const request = ++detailGeneration;
detail.value = null;
detailError.value = '';
detailLoading.value = false;
if (!selected.value) return;
detailLoading.value = true;
try {
const response = await trpc.playAudit.policyVersion.query({ id: selected.value });
if (request === detailGeneration) detail.value = response;
} 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, policy: id ?? undefined } });
watch(
[
() => props.nationId,
() => props.area,
() => props.from.year,
() => props.from.month,
() => props.to.year,
() => props.to.month,
],
() => {
void load();
},
{ immediate: true }
);
watch(
selected,
() => {
void loadDetail();
},
{ immediate: true }
);
</script>
<template>
<section aria-label="정책 변경 이력">
<p>설정된 정책의 변경 이력입니다. 최초 관측 이전의 변경은 복원하지 않습니다.</p>
<p v-if="loading" role="status">정책 이력 조회 </p>
<p v-if="error" role="alert">{{ error }} <button class="legacy-button" @click="load()">다시 조회</button></p>
<template v-if="data">
<p v-if="data.coverage === 'IDENTITY_MISSING'">기수 식별자가 없어 정책 이력을 조회할 없습니다.</p>
<p v-else-if="!data.items.length">선택한 기간에 기록된 버전이 없습니다. 변경이 없었다는 뜻은 아닙니다.</p>
<div v-else class="table-scroll" tabindex="0" aria-label="정책 버전 목록">
<table>
<thead>
<tr>
<th>버전</th>
<th>게임 시각</th>
<th>종류</th>
<th>당시 변경 주체</th>
</tr>
</thead>
<tbody>
<tr v-for="item in data.items" :key="item.id">
<th scope="row">
<button class="legacy-button" @click="select(item.id)">버전 {{ item.revision }}</button>
</th>
<td>{{ item.year }} {{ item.month }}</td>
<td>{{ labels[item.source] }}</td>
<td v-if="item.actor">
{{ item.actor.name }} (#{{ item.actor.generalId }}) · 국가 #{{ item.actor.nationId }} ·
직책 {{ item.actor.officerLevel }}
</td>
<td v-else>관측 기준 · 변경 주체 미상</td>
</tr>
</tbody>
</table>
</div>
<button v-if="data.nextCursor !== null" class="legacy-button" :disabled="loading" @click="load(true)">
다음 정책 50개
</button>
</template>
<section v-if="selected" aria-label="선택 정책 버전">
<h3>선택 정책 버전 <button class="legacy-button" @click="select(null)">정책 상세 닫기</button></h3>
<p v-if="detailLoading" role="status">정책 버전 조회 </p>
<p v-if="detailError" role="alert">
{{ detailError }} <button class="legacy-button" @click="loadDetail">버전 다시 조회</button>
</p>
<template v-if="detail">
<p>
국가 #{{ detail.version.nationId }} · 버전 {{ detail.version.revision }} ·
{{ labels[detail.version.source] }} · {{ detail.version.year }} {{ detail.version.month }}
</p>
<p>
기록 시각 {{ detail.version.createdAt }} · tick {{ detail.version.tick ?? '미상' }} · 순번
{{ detail.version.ordinal }}
</p>
<p v-if="detail.version.actor">
{{ detail.version.actor.name }} (#{{ detail.version.actor.generalId }}) · 당시 국가 #{{
detail.version.actor.nationId
}}
· 직책 {{ detail.version.actor.officerLevel }}
</p>
<p v-if="detail.version.source !== 'CHANGE'">
버전은 관측 기준입니다. 이전 값과 변경 주체를 추정하지 않습니다.
</p>
<p>
null은 개별 설정이 없음을 뜻합니다. 설정값을 기록하며 당시 NPC별 유효 값은 여기서 재계산하지
않습니다.
</p>
<div class="table-scroll" tabindex="0" aria-label="정책 전후 ">
<table>
<thead>
<tr>
<th>설정</th>
<th>변경 </th>
<th>변경 </th>
</tr>
</thead>
<tbody>
<tr v-for="field in detail.version.fields" :key="field.key">
<th scope="row">
{{ fieldLabels[field.key] ?? field.key }} <span v-if="field.changed">(변경)</span>
</th>
<td>
<pre>{{ field.beforeJson ?? '관측하지 않음' }}</pre>
</td>
<td>
<pre>{{ field.afterJson }}</pre>
</td>
</tr>
</tbody>
</table>
</div>
<button
v-if="detail.version.previousId"
class="legacy-button"
@click="select(detail.version.previousId)"
>
이전 정책 버전
</button>
<details>
<summary>요청 연결</summary>
<p>요청 {{ detail.version.requestId ?? '해당 없음' }}</p>
<p>입력 순번 {{ detail.version.inputSequence ?? '해당 없음' }}</p>
</details>
</template>
</section>
</section>
</template>
<style scoped>
.table-scroll {
overflow-x: auto;
}
table {
width: 100%;
min-width: 640px;
border-collapse: collapse;
}
th,
td {
border: 1px solid gray;
padding: 6px;
text-align: left;
}
pre {
white-space: pre-wrap;
overflow-wrap: anywhere;
max-width: 400px;
font: inherit;
margin: 0;
}
h3 {
font-size: var(--sammo-font-size-normal);
}
[role='alert'] {
color: #ffb9b9;
}
details {
overflow-wrap: anywhere;
}
</style>
+61 -8
View File
@@ -5,6 +5,7 @@ import PanelCard from '../components/ui/PanelCard.vue';
import AuditNationSeries from '../components/playAudit/AuditNationSeries.vue';
import AuditNationSnapshot from '../components/playAudit/AuditNationSnapshot.vue';
import AuditGeneralDetail from '../components/playAudit/AuditGeneralDetail.vue';
import AuditPolicyHistory from '../components/playAudit/AuditPolicyHistory.vue';
import AuditCityDetail from '../components/playAudit/AuditCityDetail.vue';
import { usePageExit } from '../composables/usePageExit';
import { trpc } from '../utils/trpc';
@@ -29,6 +30,28 @@ const profileName = ref('');
const loading = ref(false);
const error = ref('');
const tab = ref('nations');
const policyArea = ref<'NPC_VALUES' | 'NPC_NATION_PRIORITY' | 'NPC_GENERAL_PRIORITY' | 'DEFENCE'>('NPC_VALUES');
const appliedPolicy = computed(() => {
const to = {
year: numeric(route.query.year, coverage.value?.year ?? 0),
month: numeric(route.query.month, coverage.value?.month ?? 1),
};
const start = Math.max(
(coverage.value?.startYear ?? to.year) * 12 + (coverage.value?.startMonth ?? 1) - 1,
to.year * 12 + to.month - 6
);
return {
nationId: numeric(route.query.nation, 0),
area: (['NPC_NATION_PRIORITY', 'NPC_GENERAL_PRIORITY', 'DEFENCE'].includes(String(route.query.policyArea))
? route.query.policyArea
: 'NPC_VALUES') as typeof policyArea.value,
from: {
year: numeric(route.query.fromYear, Math.floor(start / 12)),
month: numeric(route.query.fromMonth, (start % 12) + 1),
},
to,
};
});
const nationId = ref('');
const cityId = ref('');
const population = ref('');
@@ -91,9 +114,10 @@ const result = computed(() =>
: series.value
);
const readQuery = () => {
tab.value = ['nations', 'generals', 'cities'].includes(String(route.query.tab))
tab.value = ['nations', 'generals', 'cities', 'policies'].includes(String(route.query.tab))
? String(route.query.tab)
: 'nations';
policyArea.value = appliedPolicy.value.area;
nationId.value = route.query.nation ? String(numeric(route.query.nation, 0)) : '';
cityId.value = route.query.city ? String(numeric(route.query.city, 0)) : '';
population.value = ['human', 'npc', 'troopNpc'].includes(String(route.query.population))
@@ -149,6 +173,8 @@ const load = async (append = false) => {
...response,
items: append ? [...(cities.value?.items ?? []), ...response.items] : response.items,
};
} else if (tab.value === 'policies') {
// 정책 목록/상세는 해당 component가 필요한 요청만 실행한다.
} else if (nationId.value !== '' && moment.value === 'final') {
const response = await trpc.playAudit.nationSnapshot.query({
nationId: Number(nationId.value),
@@ -201,6 +227,7 @@ const apply = async () => {
fromYear: String(fromYear.value),
fromMonth: String(fromMonth.value),
resolution: resolution.value,
policyArea: tab.value === 'policies' ? policyArea.value : undefined,
};
if (JSON.stringify(route.query) === JSON.stringify(query)) await refresh();
else {
@@ -236,7 +263,10 @@ const moreNations = async () => {
}
};
watch(
() => JSON.stringify(Object.entries(route.query).filter(([key]) => key !== 'general' && key !== 'cityRecord')),
() =>
JSON.stringify(
Object.entries(route.query).filter(([key]) => key !== 'general' && key !== 'cityRecord' && key !== 'policy')
),
() => {
if (authorized.value) {
readQuery();
@@ -286,11 +316,14 @@ onMounted(async () => {
<option value="nations">국가 시계열</option>
<option value="generals">전체 장수</option>
<option value="cities">도시 상태</option>
<option value="policies">정책 변경 이력</option>
</select></label
>
<label
>국가<select class="legacy-sort-select" v-model="nationId">
<option value="">{{ tab === 'nations' ? '국가 선택' : '모든 국가' }}</option>
<option value="">
{{ tab === 'nations' || tab === 'policies' ? '국가 선택' : '모든 국가' }}
</option>
<option value="0">무소속</option>
<option
v-for="nation in nations?.items.filter((item) => item.id !== 0)"
@@ -328,7 +361,7 @@ onMounted(async () => {
</select></label
>
<label
>{{ tab === 'nations' ? '종료 연도' : '표본 연도'
>{{ tab === 'nations' || tab === 'policies' ? '종료 연도' : '표본 연도'
}}<input
v-model.number="year"
type="number"
@@ -344,7 +377,7 @@ onMounted(async () => {
:max="year === coverage.year ? coverage.month : 12"
required
/></label>
<template v-if="tab === 'nations' && moment !== 'final'">
<template v-if="(tab === 'nations' && moment !== 'final') || tab === 'policies'">
<label
>시작 연도<input
v-model.number="fromYear"
@@ -361,13 +394,21 @@ onMounted(async () => {
:max="fromYear === coverage.year ? coverage.month : 12"
required
/></label>
<label
<label v-if="tab === 'nations'"
>간격<select class="legacy-sort-select" v-model="resolution">
<option value="halfYear">반기 (1~6월 / 7~12월)</option>
<option value="month">매월</option>
</select></label
>
</template>
<label v-if="tab === 'policies'"
>정책 영역<select class="legacy-sort-select" v-model="policyArea">
<option value="NPC_VALUES">NPC 국가 정책</option>
<option value="NPC_NATION_PRIORITY">국가 행동 우선순위</option>
<option value="NPC_GENERAL_PRIORITY">장수 행동 우선순위</option>
<option value="DEFENCE">국방 설정</option>
</select></label
>
<template v-if="tab === 'generals'">
<label>도시 번호<input v-model="cityId" type="number" min="0" placeholder="모든 도시" /></label>
<label
@@ -385,12 +426,24 @@ onMounted(async () => {
</PanelCard>
<PanelCard
v-if="authorized && coverage"
:title="tab === 'nations' ? '국가 시계열' : tab === 'generals' ? '전체 장수' : '도시 상태'"
:title="
tab === 'nations'
? '국가 시계열'
: tab === 'generals'
? '전체 장수'
: tab === 'policies'
? '정책 변경 이력'
: '도시 상태'
"
>
<p v-if="result">조회 시각 {{ result.asOf }} · tick {{ result.tick ?? '없음' }}</p>
<p v-if="tab === 'nations' && !nationId">
<p v-if="(tab === 'nations' || tab === 'policies') && !nationId">
국가를 선택하고 조회해 주세요. 멸망한 국가는 해당 월말 기준의 국가 목록에서 선택할 수 있습니다.
</p>
<AuditPolicyHistory
v-if="tab === 'policies' && route.query.tab === 'policies' && route.query.nation"
v-bind="appliedPolicy"
/>
<AuditNationSeries v-if="series && tab === 'nations'" :data="series" />
<AuditNationSnapshot v-if="nationSnapshot && tab === 'nations'" :data="nationSnapshot" />
<template v-if="generals && tab === 'generals'">
+22 -3
View File
@@ -2,7 +2,7 @@
[확정 설계](play-audit.md)의 P1~P6를 구현하는 작업 기록이다. 전체 기능은 진행 중이며,
월별 projection과 runtime 수집·DB transaction 연결을 구현했다. 프로필 권한과 장수·도시 현재/월말 조회 API,
국가 월/반기 시계열과 `/play-audit` 기본 조회 화면을 연결했다. 정책 이력 저장 기반을 추가했으며 정책 조회 화면, 외교, NPC trace와 조사 도구는 남아 있다.
국가 월/반기 시계열과 `/play-audit` 기본 조회 화면을 연결했다. 정책 이력 저장과 목록/상세 조회 화면을 추가했으며 초기 기준 내구성, 외교, NPC trace와 조사 도구는 남아 있다.
Push 요청 이후 `feat/play-audit` 전용 worktree에서 계속 구현하며 전체 완료 후 main 통합·push한다.
## 현재 구현
@@ -127,7 +127,26 @@ checksum은 수정하지 않고 schema_version 필드는 별도 증분 migration
정리 worker는 이전 기수 정책 ID도 최대200개씩 삭제한다. policy version의 self-reference는
삭제 FK로 강제하지 않아 과거 비공개 기수를 key batch로 정리할 수 있다. 현재 기수 포인터는
같은 transaction으로 저장하고 조회 시 항상 현재 기수 범위를 검사해야 한다.
policyHistory API/UI, 완전한 초기 기준 내구화와 NPC 결정 연결은 아직 남았다.
완전한 초기 기준 내구화와 NPC 결정 연결은 아직 남았다.
`policyHistory`는 국가/영역/기간을 필수로 받고 기본50·최대200개의 버전 요약을
revision 내림차순 cursor로 반환한다. 목록에서는 전후 정책 본문과 요청 자료를 읽지 않는다.
`policyVersion`은 선택 ID 한 건의 현재 기수 범위를 검사하고 전후 설정·당시 직책·입력
sequence를 반환한다. BigInt는 문자열로 보존하며 목록/상세 모두 계정 ID를 노출하지 않는다.
world identity와 자료는 기존 RepeatableRead/timeout 계약을 공유한다. 별도 count/쓰기나
현재 정책을 읽어 과거를 채우는 조회는 없다. 기존 국가/영역/revision 및 기간 index를
재사용한다. 반환 상한은 DB scan 상한의 증명이 아니며 실행계획/부하 측정은 P6에 남는다.
`projectPolicyConfiguration`은 수뇌 공개에 재사용할 전후 값·당시 소속/직책만 남기고
계정·요청·tick/ordinal·관리자 진단을 제외한다. 현행 endpoint는 관리자 전용이다.
향후 자국 API에서 기존 국가 resolver의 인가를 별도 적용해야 하며 이 projection 자체가
인가를 대신하지 않는다. 수뇌용 route나 직책 권한을 이번 변경에서 새로 열지 않았다.
프로필 `/play-audit`의 정책 화면은 적용한 국가/영역/기간만 읽고, 상세 열기/이전 버전
이동/실패 재시도가 목록과 국가 목록을 다시 읽지 않도록 한다. URL로 선택 버전을 보존한다.
입력 중인 필터는 조회 버튼을 누르기 전 SQL 요청을 발생시키지 않는다. 최초 관측,
실제 변경, 관측 누락 이후 기준과 자료 없음의 의미를 구분한다. 기존 PanelCard와 제어
스타일을 사용하고 NPC 설정 화면의 한국어 필드 이름을 따른다. 값은 텍스트로 출력한다.
## 이전 기수 월별 표본 정리
@@ -249,7 +268,7 @@ no-general 허용, 무인증·일반 admin·다른 profile·제재 거부, 200
반환하고 수입/급여만 기간 합산한다. 누락·국가 없음·불완전 정산의 흐름은 null,
관측한 정산 없음은 0이다. 기간 일부 요청은 from/to와 complete=false로 표시한다.
장수·도시 상세와 독립 로그, FINAL 별도 표시는 기본 화면에 연결했다. 외교·정책·NPC 결정과 조사 기능은 남았다.
장수·도시 상세와 독립 로그, FINAL 별도 표시와 정책 조회는 기본 화면에 연결했다. 외교·NPC 결정과 조사 기능은 남았다.
## 수집 지점과 쓰기 재검토