feat: 프로필 감사 화면에서 정책 버전과 전후 값을 조회
This commit is contained in:
@@ -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] } } });
|
||||
|
||||
@@ -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>
|
||||
@@ -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'">
|
||||
|
||||
@@ -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 결정과 조사 기능은 남았다.
|
||||
|
||||
## 수집 지점과 쓰기 재검토
|
||||
|
||||
|
||||
Reference in New Issue
Block a user