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'">