feat: 플레이 감사 장수 로그를 기수별로 조회

This commit is contained in:
2026-09-16 04:03:14 +00:00
parent 69a61b317c
commit 31c5927449
20 changed files with 437 additions and 21 deletions
@@ -59,7 +59,8 @@ const persistLogs = async (
logs: LogEntryDraft[], logs: LogEntryDraft[],
year: number, year: number,
month: number, month: number,
at: Date at: Date,
serverId: string | null
): Promise<void> => { ): Promise<void> => {
const data = logs.flatMap((entry) => { const data = logs.flatMap((entry) => {
const record = finalizeLogEntry(entry, { year, month, at }); const record = finalizeLogEntry(entry, { year, month, at });
@@ -68,6 +69,7 @@ const persistLogs = async (
} }
return [ return [
{ {
serverId,
scope: record.scope, scope: record.scope,
category: record.category, category: record.category,
subType: record.subType ?? null, subType: record.subType ?? null,
@@ -92,7 +94,8 @@ const persistEffects = async (
effects: GeneralActionEffect[], effects: GeneralActionEffect[],
year: number, year: number,
month: number, month: number,
at: Date at: Date,
serverId: string | null
): Promise<void> => { ): Promise<void> => {
const logs: LogEntryDraft[] = []; const logs: LogEntryDraft[] = [];
for (const effect of effects) { for (const effect of effects) {
@@ -122,7 +125,7 @@ const persistEffects = async (
logs.push(effect.entry); logs.push(effect.entry);
} }
} }
await persistLogs(db, orderLegacyActionLoggerFlush(logs), year, month, at); await persistLogs(db, orderLegacyActionLoggerFlush(logs), year, month, at, serverId);
}; };
const refreshFrontStates = async (db: DatabaseClient, mapName: string, nationIds: number[]): Promise<number[]> => { const refreshFrontStates = async (db: DatabaseClient, mapName: string, nationIds: number[]): Promise<number[]> => {
@@ -187,11 +190,13 @@ export const respondToDiplomaticMessage = async (options: {
} }
const world = await db.worldState.findFirst({ const world = await db.worldState.findFirst({
select: { currentYear: true, currentMonth: true, config: true }, select: { currentYear: true, currentMonth: true, config: true, meta: true },
}); });
if (!world) { if (!world) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '게임 상태가 없습니다.' }); throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '게임 상태가 없습니다.' });
} }
const serverIdValue = asRecord(world.meta).serverId;
const serverId = typeof serverIdValue === 'string' && serverIdValue.trim() ? serverIdValue : null;
const now = (await loadCurrentGameTime(db)).now; const now = (await loadCurrentGameTime(db)).now;
const action = parseAction(message.payload.option?.action); const action = parseAction(message.payload.option?.action);
if (message.msgType !== 'diplomacy' || !action || message.payload.option?.used) { if (message.msgType !== 'diplomacy' || !action || message.payload.option?.used) {
@@ -204,7 +209,8 @@ export const respondToDiplomaticMessage = async (options: {
buildFailureLog(actor.id, reason, actionName, response), buildFailureLog(actor.id, reason, actionName, response),
world.currentYear, world.currentYear,
world.currentMonth, world.currentMonth,
now now,
serverId
); );
return { return {
result: false, result: false,
@@ -302,7 +308,8 @@ export const respondToDiplomaticMessage = async (options: {
[...actorLogger.flush(), ...proposerLogger.flush()], [...actorLogger.flush(), ...proposerLogger.flush()],
world.currentYear, world.currentYear,
world.currentMonth, world.currentMonth,
now now,
serverId
); );
await invalidateMessages(db, [message.id]); await invalidateMessages(db, [message.id]);
return { return {
@@ -414,7 +421,7 @@ export const respondToDiplomaticMessage = async (options: {
...(action === 'noAggression' ? { treatyYear: treatyYear!, treatyMonth: treatyMonth! } : {}), ...(action === 'noAggression' ? { treatyYear: treatyYear!, treatyMonth: treatyMonth! } : {}),
} }
); );
await persistEffects(db, resolution.effects, world.currentYear, world.currentMonth, now); await persistEffects(db, resolution.effects, world.currentYear, world.currentMonth, now, serverId);
let affectedCityIds: number[] = []; let affectedCityIds: number[] = [];
if (resolution.refreshFront) { if (resolution.refreshFront) {
const worldConfig = asRecord(world.config); const worldConfig = asRecord(world.config);
@@ -1,5 +1,6 @@
import { nationSeries, zAuditNation } from './nationSeries.js'; import { nationSeries, zAuditNation } from './nationSeries.js';
import { cityDetail, generalDetail, generalTurns } from './details.js'; import { cityDetail, generalDetail, generalTurns } from './details.js';
import { generalLogs } from './logs.js';
import { z } from 'zod'; import { z } from 'zod';
import { canReadPlayAuditAccounts } from '@sammo-ts/common'; import { canReadPlayAuditAccounts } from '@sammo-ts/common';
import { router } from '../../trpc.js'; import { router } from '../../trpc.js';
@@ -22,6 +23,7 @@ import {
} from './projection.js'; } from './projection.js';
export const playAuditRouter = router({ export const playAuditRouter = router({
generalLogs,
cityDetail, cityDetail,
generalDetail, generalDetail,
generalTurns, generalTurns,
+60
View File
@@ -0,0 +1,60 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { auditProcedure, monthOrdinal, readAudit, readAuditWorld } from './shared.js';
const categoryByType = {
generalHistory: 'HISTORY',
generalAction: 'ACTION',
battleResult: 'BATTLE_BRIEF',
battleDetail: 'BATTLE_DETAIL',
} as const;
export const generalLogs = auditProcedure
.input(
z
.object({
generalId: z.number().int().nonnegative(),
type: z.enum(['generalHistory', 'generalAction', 'battleResult', 'battleDetail']),
month: z
.object({ year: z.number().int().min(0).max(9999), month: z.number().int().min(1).max(12) })
.strict()
.optional(),
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);
if (
input.month &&
(input.month.year < world.startYear ||
monthOrdinal(input.month.year, input.month.month) > monthOrdinal(world.year, world.month))
) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '현재 기수 안의 로그 월을 선택해 주세요.' });
}
const rows = world.serverId
? await tx.logEntry.findMany({
where: {
serverId: world.serverId,
generalId: input.generalId,
scope: 'GENERAL',
category: categoryByType[input.type],
id: input.cursor === undefined ? undefined : { lt: input.cursor },
year: input.month?.year,
month: input.month?.month,
},
orderBy: { id: 'desc' },
take: input.limit + 1,
select: { id: true, year: true, month: true, text: true, createdAt: true },
})
: [];
return {
...world,
type: input.type,
coverage: world.serverId ? ('IDENTIFIED_LOGS_ONLY' as const) : ('IDENTITY_MISSING' as const),
items: rows.slice(0, input.limit),
nextCursor: rows.length > input.limit ? rows[input.limit - 1]!.id : null,
};
})
);
+13
View File
@@ -1049,6 +1049,7 @@ describe('messages router missing-flow compatibility', () => {
findFirst: vi.fn(async () => ({ findFirst: vi.fn(async () => ({
currentYear: 200, currentYear: 200,
currentMonth: 3, currentMonth: 3,
meta: { serverId: 'diplomatic-response-audit' },
config: { environment: { mapName: 'che' } }, config: { environment: { mapName: 'che' } },
clockBaseTime: new Date('0200-03-01T00:00:00.000Z'), clockBaseTime: new Date('0200-03-01T00:00:00.000Z'),
clockTick: 1_000n, clockTick: 1_000n,
@@ -1116,6 +1117,9 @@ describe('messages router missing-flow compatibility', () => {
cityIds: [], cityIds: [],
}); });
expect(setup.diplomacyUpdate).toHaveBeenCalledTimes(2); expect(setup.diplomacyUpdate).toHaveBeenCalledTimes(2);
expect(setup.logCreateMany).toHaveBeenCalledWith({
data: expect.arrayContaining([expect.objectContaining({ serverId: 'diplomatic-response-audit' })]),
});
expect(setup.nationUpdate).toHaveBeenCalledWith( expect(setup.nationUpdate).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
where: { id: 2 }, where: { id: 2 },
@@ -1147,6 +1151,9 @@ describe('messages router missing-flow compatibility', () => {
expect(setup.diplomacyUpdate).not.toHaveBeenCalled(); expect(setup.diplomacyUpdate).not.toHaveBeenCalled();
expect(setup.messageUpdateMany).toHaveBeenCalledOnce(); expect(setup.messageUpdateMany).toHaveBeenCalledOnce();
expect(setup.logCreateMany).toHaveBeenCalledOnce(); expect(setup.logCreateMany).toHaveBeenCalledOnce();
expect(setup.logCreateMany).toHaveBeenCalledWith({
data: expect.arrayContaining([expect.objectContaining({ serverId: 'diplomatic-response-audit' })]),
});
}); });
it('permanently records rejection of an NPC aid-based non-aggression proposal', async () => { it('permanently records rejection of an NPC aid-based non-aggression proposal', async () => {
@@ -1217,6 +1224,9 @@ describe('messages router missing-flow compatibility', () => {
expect(result).toEqual({ result: true, reason: 'success' }); expect(result).toEqual({ result: true, reason: 'success' });
expect(setup.diplomacyUpdate).toHaveBeenCalledTimes(2); expect(setup.diplomacyUpdate).toHaveBeenCalledTimes(2);
expect(setup.logCreateMany).toHaveBeenCalledWith({
data: expect.arrayContaining([expect.objectContaining({ serverId: 'diplomatic-response-audit' })]),
});
if (action === 'stopWar') { if (action === 'stopWar') {
expect(setup.cityUpdate).toHaveBeenCalledTimes(2); expect(setup.cityUpdate).toHaveBeenCalledTimes(2);
expect(setup.cityUpdate).toHaveBeenCalledWith({ expect(setup.cityUpdate).toHaveBeenCalledWith({
@@ -1255,6 +1265,9 @@ describe('messages router missing-flow compatibility', () => {
expect(setup.diplomacyUpdate).not.toHaveBeenCalled(); expect(setup.diplomacyUpdate).not.toHaveBeenCalled();
expect(setup.messageUpdateMany).not.toHaveBeenCalled(); expect(setup.messageUpdateMany).not.toHaveBeenCalled();
expect(setup.logCreateMany).toHaveBeenCalledOnce(); expect(setup.logCreateMany).toHaveBeenCalledOnce();
expect(setup.logCreateMany).toHaveBeenCalledWith({
data: expect.arrayContaining([expect.objectContaining({ serverId: 'diplomatic-response-audit' })]),
});
}); });
it('does not let another nation process the diplomatic inbox row', async () => { it('does not let another nation process the diplomatic inbox row', async () => {
@@ -2238,6 +2238,87 @@ integration('game API security over HTTP transport', () => {
}); });
const admin = await token([`admin.playAudit.read:${profileName}`]); const admin = await token([`admin.playAudit.read:${profileName}`]);
const beforeInputs = await db.inputEvent.count(); const beforeInputs = await db.inputEvent.count();
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) =>
db.logEntry.create({
data: {
serverId: seasonId,
generalId: logGeneralId,
scope: 'GENERAL',
category: category as 'HISTORY' | 'ACTION' | 'BATTLE_BRIEF' | 'BATTLE_DETAIL',
year: 190,
month: 1,
text: `${seasonId}:${category}`,
},
})
)
);
const latest = await db.logEntry.create({
data: {
serverId: seasonId,
generalId: logGeneralId,
scope: 'GENERAL',
category: 'HISTORY',
year: 190,
month: 2,
text: `${seasonId}:latest`,
},
});
await db.logEntry.createMany({
data: [null, `${seasonId}:previous`].map((serverId) => ({
serverId,
generalId: logGeneralId,
scope: 'GENERAL' as const,
category: 'HISTORY' as const,
year: 190,
month: 1,
text: `${seasonId}:excluded`,
})),
});
for (const [index, type] of ['generalHistory', 'generalAction', 'battleResult', 'battleDetail'].entries()) {
const result = await get('generalLogs', admin, {
generalId: logGeneralId,
type,
month: { year: 190, month: 1 },
});
expect(result.status).toBe(200);
expect(result.body).toMatchObject({
result: {
data: {
coverage: 'IDENTIFIED_LOGS_ONLY',
items: [{ id: ownLogs[index]!.id }],
nextCursor: null,
},
},
});
expect(JSON.stringify(result.body)).not.toContain(`${seasonId}:excluded`);
}
expect(
(await get('generalLogs', admin, { generalId: logGeneralId, type: 'generalHistory', limit: 1 })).body
).toMatchObject({ result: { data: { items: [{ id: latest.id }], nextCursor: latest.id } } });
expect(
(
await get('generalLogs', admin, {
generalId: logGeneralId,
type: 'generalHistory',
limit: 1,
cursor: latest.id,
})
).body
).toMatchObject({ result: { data: { items: [{ id: ownLogs[0]!.id }], nextCursor: null } } });
for (const invalid of [
{ limit: 201 },
{ cursor: 0 },
{ month: { year: 190, month: 3 } },
{ month: { year: 189, month: 12 } },
]) {
expect(
(await get('generalLogs', admin, { generalId: logGeneralId, type: 'generalHistory', ...invalid }))
.status
).toBe(400);
}
expect((await get('generalDetail', admin, { id: generalId })).body).toMatchObject({ expect((await get('generalDetail', admin, { id: generalId })).body).toMatchObject({
result: { data: { collected: true, general: { id: generalId, name: current.name } } }, result: { data: { collected: true, general: { id: generalId, name: current.name } } },
}); });
@@ -2512,6 +2593,7 @@ integration('game API security over HTTP transport', () => {
); );
await expect.poll(async () => (await get('capabilities', admin)).status).toBe(401); await expect.poll(async () => (await get('capabilities', admin)).status).toBe(401);
} finally { } finally {
await db.logEntry.deleteMany({ where: { text: { startsWith: `${seasonId}:` } } });
await db.playAuditMonth.deleteMany({ where: { serverId: seasonId } }); await db.playAuditMonth.deleteMany({ where: { serverId: seasonId } });
await db.generalTurn.deleteMany({ where: { generalId, turnIdx: { in: [9001, 9002] } } }); await db.generalTurn.deleteMany({ where: { generalId, turnIdx: { in: [9001, 9002] } } });
await db.nation.deleteMany({ where: { id: { in: [99121, 99122] } } }); await db.nation.deleteMany({ where: { id: { in: [99121, 99122] } } });
@@ -338,7 +338,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
).toBe(initial.name); ).toBe(initial.name);
expect( expect(
await db.logEntry.count({ await db.logEntry.count({
where: { meta: { path: ['ownerUserId'], equals: userId } }, where: { serverId: profile, meta: { path: ['ownerUserId'], equals: userId } },
}) })
).toBe(2); ).toBe(2);
if (realtimeHub) { if (realtimeHub) {
@@ -432,7 +432,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
).toBe(target.uniqueName); ).toBe(target.uniqueName);
expect( expect(
await db.logEntry.count({ await db.logEntry.count({
where: { meta: { path: ['ownerUserId'], equals: userId } }, where: { serverId: profile, meta: { path: ['ownerUserId'], equals: userId } },
}) })
).toBe(4); ).toBe(4);
+8 -3
View File
@@ -619,7 +619,8 @@ const persistNationBettingOpen = async (
const persistNationBettingFinish = async ( const persistNationBettingFinish = async (
prisma: GamePrisma.TransactionClient, prisma: GamePrisma.TransactionClient,
finish: PendingNationBettingFinish, finish: PendingNationBettingFinish,
recordsFinalized: boolean recordsFinalized: boolean,
serverId: string | null
): Promise<void> => { ): Promise<void> => {
await prisma.$queryRaw` await prisma.$queryRaw`
SELECT id SELECT id
@@ -752,6 +753,7 @@ const persistNationBettingFinish = async (
if (finishLog) { if (finishLog) {
await prisma.logEntry.create({ await prisma.logEntry.create({
data: { data: {
serverId,
scope: finishLog.scope, scope: finishLog.scope,
category: finishLog.category, category: finishLog.category,
subType: finishLog.subType ?? null, subType: finishLog.subType ?? null,
@@ -1026,7 +1028,7 @@ const buildDiplomacyUpdate = (
const buildLogCreateData = ( const buildLogCreateData = (
entry: LogEntryDraft, entry: LogEntryDraft,
context: { year: number; month: number; at: Date } context: { year: number; month: number; at: Date; serverId: string | null }
): TurnEngineLogEntryCreateManyInput | null => { ): TurnEngineLogEntryCreateManyInput | null => {
const record = finalizeLogEntry(entry, { const record = finalizeLogEntry(entry, {
year: context.year, year: context.year,
@@ -1038,6 +1040,7 @@ const buildLogCreateData = (
} }
return { return {
serverId: context.serverId,
scope: record.scope, scope: record.scope,
category: record.category, category: record.category,
subType: record.subType ?? null, subType: record.subType ?? null,
@@ -1194,6 +1197,8 @@ export const createDatabaseTurnHooks = async (
}) })
)?.id ?? 0; )?.id ?? 0;
const logContext = { const logContext = {
serverId:
typeof state.meta.serverId === 'string' && state.meta.serverId.trim() ? state.meta.serverId : null,
year: state.currentYear, year: state.currentYear,
month: state.currentMonth, month: state.currentMonth,
at: state.lastTurnTime, at: state.lastTurnTime,
@@ -1474,7 +1479,7 @@ export const createDatabaseTurnHooks = async (
await persistNationBettingOpen(prisma, betting); await persistNationBettingOpen(prisma, betting);
} }
for (const finish of pendingNationBettingFinishes) { for (const finish of pendingNationBettingFinishes) {
await persistNationBettingFinish(prisma, finish, recordsFinalized); await persistNationBettingFinish(prisma, finish, recordsFinalized, logContext.serverId);
} }
const meta = asRecord(state.meta); const meta = asRecord(state.meta);
@@ -669,7 +669,9 @@ const appendSelectionLogs = async (options: {
generalText: string; generalText: string;
globalText: string; globalText: string;
}): Promise<void> => { }): Promise<void> => {
const serverId = asRecord(options.worldState.meta).serverId;
const common = { const common = {
serverId: typeof serverId === 'string' && serverId.trim() ? serverId : null,
year: options.worldState.currentYear, year: options.worldState.currentYear,
month: options.worldState.currentMonth, month: options.worldState.currentMonth,
nationId: null, nationId: null,
@@ -18,7 +18,12 @@ const cityIds = [991_201, 991_202, 991_203, 991_204, 991_205, 991_206, 991_207];
const nationId = 991_201; const nationId = 991_201;
const yearbookProfile = 'monthly-boundary-pre-persistence'; const yearbookProfile = 'monthly-boundary-pre-persistence';
const yearbookServerId = 'monthly-boundary-generation-20260731'; const yearbookServerId = 'monthly-boundary-generation-20260731';
const archivedLogTexts = ['월경계 과거 정세', '월경계 과거 장수 동향', '월경계 과거 호환 행동']; const archivedLogTexts = [
'월경계 과거 정세',
'월경계 과거 장수 동향',
'월경계 과거 호환 행동',
'월경계 감사 기수 로그',
];
integration('monthly pre-update persistence', () => { integration('monthly pre-update persistence', () => {
let db: GamePrismaClient; let db: GamePrismaClient;
@@ -197,6 +202,12 @@ integration('monthly pre-update persistence', () => {
const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { profileName: yearbookProfile }); const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { profileName: yearbookProfile });
try { try {
await world.advanceMonth(new Date('0201-01-01T00:00:00.000Z')); await world.advanceMonth(new Date('0201-01-01T00:00:00.000Z'));
world.pushLog({
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId: generalIds[0],
text: archivedLogTexts[3]!,
});
await hooks.hooks.flushChanges?.({ await hooks.hooks.flushChanges?.({
lastTurnTime: '0201-01-01T00:00:00.000Z', lastTurnTime: '0201-01-01T00:00:00.000Z',
processedGenerals: 0, processedGenerals: 0,
@@ -205,6 +216,12 @@ integration('monthly pre-update persistence', () => {
partial: false, partial: false,
}); });
expect(await db.logEntry.findFirst({ where: { text: archivedLogTexts[3] } })).toMatchObject({
serverId: yearbookServerId,
});
expect(await db.logEntry.findFirst({ where: { text: archivedLogTexts[0] } })).toMatchObject({
serverId: null,
});
expect( expect(
await db.generalAccessLog.findMany({ await db.generalAccessLog.findMany({
where: { generalId: { in: generalIds } }, where: { generalId: { in: generalIds } },
@@ -214,7 +214,7 @@ integration('monthly nation betting persistence', () => {
currentMonth: 12, currentMonth: 12,
tickSeconds: 600, tickSeconds: 600,
config: {}, config: {},
meta: { lastBettingId: bettingId - 1 }, meta: { lastBettingId: bettingId - 1, serverId: 'audit-betting-fixture' },
}, },
}); });
const state: TurnWorldState = { const state: TurnWorldState = {
@@ -223,7 +223,7 @@ integration('monthly nation betting persistence', () => {
currentMonth: 12, currentMonth: 12,
tickSeconds: 600, tickSeconds: 600,
lastTurnTime: new Date('2026-07-25T00:00:00.000Z'), lastTurnTime: new Date('2026-07-25T00:00:00.000Z'),
meta: { lastBettingId: bettingId - 1 }, meta: { lastBettingId: bettingId - 1, serverId: 'audit-betting-fixture' },
}; };
const scenarioConfig: TurnWorldSnapshot['scenarioConfig'] = { const scenarioConfig: TurnWorldSnapshot['scenarioConfig'] = {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 }, stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
@@ -356,6 +356,7 @@ integration('monthly nation betting persistence', () => {
where: { text: { contains: '천통국 예상 내기의 결과' } }, where: { text: { contains: '천통국 예상 내기의 결과' } },
}) })
).toMatchObject({ ).toMatchObject({
serverId: 'audit-betting-fixture',
year: 200, year: 200,
month: 2, month: 2,
text: '<C>●</>200년 2월:<B><b>【내기】</b></> 200년 1월에 열렸던 천통국 예상 내기의 결과가 나왔습니다!', text: '<C>●</>200년 2월:<B><b>【내기】</b></> 200년 1월에 열렸던 천통국 예상 내기의 결과가 나왔습니다!',
@@ -30,6 +30,7 @@ const history = [
]; ];
const publicResponse = (operation: string): unknown => { const publicResponse = (operation: string): unknown => {
if (operation === 'lobby.info') return response({ myGeneral: null });
if (operation === 'public.getMapLayout') return response({ mapName: 'che', cityList: [] }); if (operation === 'public.getMapLayout') return response({ mapName: 'che', cityList: [] });
if (operation === 'public.getCachedMap') { if (operation === 'public.getCachedMap') {
return response({ year: 200, month: 1, cityList: [], nationList: [], history }); return response({ year: 200, month: 1, cityList: [], nationList: [], history });
+70
View File
@@ -71,6 +71,22 @@ const install = async (page: Page, denied = false) => {
}, },
} }
: result({ profileName: gameProfile, read: true, accounts: false }); : result({ profileName: gameProfile, read: true, accounts: false });
case 'playAudit.generalLogs':
return result({
...world,
type: input.type,
coverage: 'IDENTIFIED_LOGS_ONLY',
items: [
{
id: 1,
year: 190,
month: 1,
text: `<script>window.auditInjected=true</script>${input.type} 감사 로그`,
createdAt: '0190-01-01T00:00:00.000Z',
},
],
nextCursor: null,
});
case 'playAudit.coverage': case 'playAudit.coverage':
return result({ ...world, status: 'COLLECTED', samples: [], nextCursor: null }); return result({ ...world, status: 'COLLECTED', samples: [], nextCursor: null });
case 'playAudit.nations': case 'playAudit.nations':
@@ -404,3 +420,57 @@ test('city detail is addressable without reloading the list and retains month fo
at: { year: 190, month: 6, kind: 'MONTH_END' }, at: { year: 190, month: 6, kind: 'MONTH_END' },
}); });
}); });
test('general logs load explicitly and cache each category without reloading entity lists', async ({ page }) => {
const requests = await install(page);
await page.goto(gamePath('/play-audit?tab=generals&general=1'));
await expect(page.getByRole('button', { name: '장수 기록 조회', exact: true })).toBeVisible();
expect(requests.filter((r) => r.operation === 'playAudit.generalLogs')).toHaveLength(0);
const listCount = requests.filter((r) => r.operation === 'playAudit.generals').length;
await page.getByRole('button', { name: '장수 기록 조회', exact: true }).click();
await expect(page.getByText('generalHistory 감사 로그', { exact: false })).toBeVisible();
await page.getByLabel('기록 종류').selectOption('generalAction');
await expect(page.getByText('generalAction 감사 로그', { exact: false })).toBeVisible();
await page.getByLabel('기록 종류').selectOption('generalHistory');
await expect(page.getByText('generalHistory 감사 로그', { exact: false })).toBeVisible();
expect(requests.filter((r) => r.operation === 'playAudit.generalLogs')).toHaveLength(2);
expect(requests.filter((r) => r.operation === 'playAudit.generals')).toHaveLength(listCount);
expect(await page.evaluate(() => Reflect.get(window, 'auditInjected'))).toBeUndefined();
await expect(page.locator('.audit-logs script')).toHaveCount(0);
await capture(page, 'general-logs');
});
test('historical log failure retries independently and sends only the selected month', async ({ page }) => {
const requests = await install(page);
let fail = true;
await page.route(gameTrpcRoute, async (route) => {
if (decodeURIComponent(route.request().url()).includes('playAudit.generalLogs') && fail) {
fail = false;
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify([
{
error: {
message: '기록 조회 재시도',
code: -32603,
data: { code: 'INTERNAL_SERVER_ERROR', httpStatus: 500 },
},
},
]),
});
} else await route.fallback();
});
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(gamePath('/play-audit?tab=generals&general=1&at=month&year=190&month=6'));
await page.getByRole('button', { name: '장수 기록 조회', exact: true }).click();
await expect(page.getByRole('alert')).toContainText('기록 조회 재시도');
await page.getByRole('button', { name: '다시 조회', exact: true }).click();
await expect(page.getByText('generalHistory 감사 로그', { exact: false })).toBeVisible();
expect(requests.filter((r) => r.operation === 'playAudit.generalLogs')[0]?.input).toMatchObject({
generalId: 1,
month: { year: 190, month: 6 },
});
expect(requests.filter((r) => r.operation === 'playAudit.generalTurns')).toHaveLength(0);
await capture(page, 'historical-general-logs-mobile');
});
@@ -8,13 +8,18 @@ const props = withDefaults(
loading?: boolean; loading?: boolean;
trustedHtml?: boolean; trustedHtml?: boolean;
unavailable?: GeneralRecordType[]; unavailable?: GeneralRecordType[];
types?: GeneralRecordType[];
errors?: Partial<Record<GeneralRecordType, string>>;
}>(), }>(),
{ {
loading: false, loading: false,
trustedHtml: false, trustedHtml: false,
unavailable: () => [], unavailable: () => [],
types: () => [...GENERAL_RECORD_TYPES],
errors: () => ({}),
} }
); );
defineEmits<{ retry: [type: GeneralRecordType] }>();
const labels: Record<GeneralRecordType, string> = { const labels: Record<GeneralRecordType, string> = {
generalHistory: '장수 열전', generalHistory: '장수 열전',
@@ -33,9 +38,12 @@ const unavailableText: Record<GeneralRecordType, string> = {
<template> <template>
<div class="log-grid" data-general-record-panels> <div class="log-grid" data-general-record-panels>
<div v-for="type in GENERAL_RECORD_TYPES" :key="type" class="log-block" :data-log-type="type"> <div v-for="type in props.types" :key="type" class="log-block" :data-log-type="type">
<div class="log-title">{{ labels[type] }}</div> <div class="log-title">{{ labels[type] }}</div>
<SkeletonLines v-if="loading" :lines="3" /> <SkeletonLines v-if="loading" :lines="3" />
<div v-else-if="props.errors[type]" class="empty" role="alert">
{{ props.errors[type] }} <button class="legacy-button" @click="$emit('retry', type)">다시 조회</button>
</div>
<template v-else-if="props.unavailable.includes(type)"> <template v-else-if="props.unavailable.includes(type)">
<div class="empty unavailable">{{ unavailableText[type] }}</div> <div class="empty unavailable">{{ unavailableText[type] }}</div>
</template> </template>
@@ -26,7 +26,7 @@ const load = async () => {
}; };
const format = (value: number) => value.toLocaleString('ko-KR', { maximumFractionDigits: 2 }); const format = (value: number) => value.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
watch( watch(
() => [props.cityId, props.at] as const, [() => props.cityId, () => props.at?.year, () => props.at?.month, () => props.at?.kind],
() => { () => {
void load(); void load();
}, },
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
import PanelCard from '../ui/PanelCard.vue'; import PanelCard from '../ui/PanelCard.vue';
import AuditGeneralLogs from './AuditGeneralLogs.vue';
import { trpc } from '../../utils/trpc'; import { trpc } from '../../utils/trpc';
const props = defineProps<{ generalId: number; at?: { year: number; month: number; kind: 'MONTH_END' | 'FINAL' } }>(); const props = defineProps<{ generalId: number; at?: { year: number; month: number; kind: 'MONTH_END' | 'FINAL' } }>();
defineEmits<{ close: [] }>(); defineEmits<{ close: [] }>();
@@ -12,6 +13,7 @@ const loading = ref(false);
const turnsLoading = ref(false); const turnsLoading = ref(false);
const error = ref(''); const error = ref('');
const turnsError = ref(''); const turnsError = ref('');
const showLogs = ref(false);
let generation = 0; let generation = 0;
const format = (value: number) => value.toLocaleString('ko-KR', { maximumFractionDigits: 2 }); const format = (value: number) => value.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
const load = async () => { const load = async () => {
@@ -56,7 +58,7 @@ const loadTurns = async (more = false) => {
} }
}; };
watch( watch(
() => [props.generalId, props.at] as const, [() => props.generalId, () => props.at?.year, () => props.at?.month, () => props.at?.kind],
() => { () => {
void load(); void load();
}, },
@@ -117,6 +119,14 @@ watch(
</template> </template>
</template> </template>
</template> </template>
<button class="legacy-button" @click="showLogs = !showLogs">
{{ showLogs ? '장수 기록 닫기' : '장수 기록 조회' }}
</button>
<AuditGeneralLogs
v-if="showLogs"
:general-id="generalId"
:month="at ? { year: at.year, month: at.month } : undefined"
/>
</PanelCard> </PanelCard>
</template> </template>
@@ -0,0 +1,106 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import GeneralRecordPanels from '../main/GeneralRecordPanels.vue';
import type { GeneralRecordType } from '../generalRecords';
import { formatLog } from '../../utils/formatLog';
import { trpc } from '../../utils/trpc';
const props = defineProps<{ generalId: number; month?: { year: number; month: number } }>();
type LogPage = Awaited<ReturnType<typeof trpc.playAudit.generalLogs.query>>;
const type = ref<GeneralRecordType>('generalHistory');
const pages = ref<Partial<Record<GeneralRecordType, LogPage>>>({});
const errors = ref<Partial<Record<GeneralRecordType, string>>>({});
const loading = ref<Partial<Record<GeneralRecordType, boolean>>>({});
let generation = 0;
const records = computed(() =>
Object.fromEntries(
Object.entries(pages.value).map(([key, page]) => [
key,
page.items.map((item) => ({ id: item.id, content: formatLog(item.text) })),
])
)
);
const load = async (target: GeneralRecordType, more = false) => {
if (loading.value[target]) return;
const request = generation;
loading.value[target] = true;
errors.value[target] = '';
try {
const response = await trpc.playAudit.generalLogs.query({
generalId: props.generalId,
type: target,
month: props.month,
limit: 50,
cursor: more ? (pages.value[target]?.nextCursor ?? undefined) : undefined,
});
if (request === generation)
pages.value[target] = {
...response,
items: more ? [...(pages.value[target]?.items ?? []), ...response.items] : response.items,
};
} catch (cause) {
if (request === generation)
errors.value[target] = cause instanceof Error ? cause.message : '기록을 조회하지 못했습니다.';
} finally {
if (request === generation) loading.value[target] = false;
}
};
watch(
[() => props.generalId, () => props.month?.year, () => props.month?.month],
() => {
generation++;
pages.value = {};
errors.value = {};
loading.value = {};
void load(type.value);
},
{ immediate: true }
);
watch(type, (target) => {
if (!pages.value[target]) void load(target);
});
</script>
<template>
<div class="audit-logs">
<label
>기록 종류
<select v-model="type" class="legacy-sort-select" aria-label="기록 종류">
<option value="generalHistory">장수 열전</option>
<option value="generalAction">개인 기록</option>
<option value="battleResult">전투 결과</option>
<option value="battleDetail">전투 기록</option>
</select></label
>
<p>
{{ month ? `${month.year}${month.month}월 전체 기록` : '현재 기수 기록' }} · 기수가 확인되는 로그만
표시합니다. 도입 이전의 식별자 없는 기록은 포함하지 않습니다.
</p>
<p v-if="pages[type]?.coverage === 'IDENTITY_MISSING'">게임의 기수 식별자가 없어 기록을 구분할 없습니다.</p>
<GeneralRecordPanels
:types="[type]"
:records="records"
:loading="loading[type]"
:errors="errors"
trusted-html
@retry="load($event)"
/>
<button
v-if="pages[type]?.nextCursor != null"
class="legacy-button"
:disabled="loading[type]"
@click="load(type, true)"
>
기록 불러오기
</button>
</div>
</template>
<style scoped>
.audit-logs {
display: grid;
gap: 8px;
margin-top: 12px;
min-width: 0;
overflow-wrap: anywhere;
}
</style>
+31 -3
View File
@@ -29,7 +29,7 @@ PanelCard, legacy-button, legacy-sort-select를 재사용한다. 새 차트 라
이 화면은 Core 신규 UX다. 최대 폭 1200px, 390px 모바일에서 문서 가로 넘침 없음, 이 화면은 Core 신규 UX다. 최대 폭 1200px, 390px 모바일에서 문서 가로 넘침 없음,
넓은 표만 내부 수평 스크롤, 공통 14px 기본 typography와 명시적 focus/disabled가 계약이다. 넓은 표만 내부 수평 스크롤, 공통 14px 기본 typography와 명시적 focus/disabled가 계약이다.
월말/FINAL 장수·도시 projection을 보여주지만 지도, 월말/FINAL 장수·도시 projection을 보여주지만 지도,
로그/예약 명령/전투 상세, 검색·정렬은 후속 구현으로 남는다. 전투 통계, 검색·정렬은 후속 구현으로 남는다. 로그와 현재 예약 조회는 아래 구현을 따른다.
따라서 기본 화면 추가만으로 R1~R3/P2를 완료 처리하지 않는다. 따라서 기본 화면 추가만으로 R1~R3/P2를 완료 처리하지 않는다.
Gateway 서버 관리의 프로필 카드에는 `admin.playAudit.read` capability의 해당 전체 Gateway 서버 관리의 프로필 카드에는 `admin.playAudit.read` capability의 해당 전체
@@ -57,7 +57,7 @@ FINAL의 관측값을 월말/반기 합계에 추가하지 않는다. 표본 없
넘은 잘못된 값도 숨기지 않는다. 인자는 읽기 전용 `argumentJson` 텍스트로 반환한다. 넘은 잘못된 값도 숨기지 않는다. 인자는 읽기 전용 `argumentJson` 텍스트로 반환한다.
범용 JSON의 재귀 타입을 UI에 그대로 전달하지 않으면서 값은 생략하지 않는다. 범용 JSON의 재귀 타입을 UI에 그대로 전달하지 않으면서 값은 생략하지 않는다.
예약 조회 실패는 장수 상세를 지우지 않는다. 과거 예약 변경은 이후 사건 원장이 담당하며 예약 조회 실패는 장수 상세를 지우지 않는다. 과거 예약 변경은 이후 사건 원장이 담당하며
현재 큐에서 복원한 것처럼 표시하지 않는다. 전투 요약/로그와 지도·검색/정렬은 남는다. 현재 큐에서 복원한 것처럼 표시하지 않는다. 전투 통계와 지도·검색/정렬은 남는다.
`app/game-engine/src/playAudit/snapshot.ts`는 기존 메모리 엔티티에서 명시적으로 `app/game-engine/src/playAudit/snapshot.ts`는 기존 메모리 엔티티에서 명시적으로
허용한 장수·도시 필드와 국가별 자원·숙련 집계를 만든다. 입력 iterable을 각각 허용한 장수·도시 필드와 국가별 자원·숙련 집계를 만든다. 입력 iterable을 각각
@@ -75,6 +75,34 @@ triggerState, credential과 전체 world는 복사하지 않는다.
국가 전후값·적용 세율·보정액은 이후 원장 구현에서 보존해야 하며 이 projection만으로 국가 전후값·적용 세율·보정액은 이후 원장 구현에서 보존해야 하며 이 projection만으로
R1을 완료했다고 판단하지 않는다. R1을 완료했다고 판단하지 않는다.
## 기존 장수 로그의 기수별 조회
`generalLogs`는 기존 `LogEntry`에서 현재 기수와 장수·기록 종류를 제한하고
ID 역순 cursor로 기본 50/최대 200행을 조회한다. 네 종류는 열전/개인 행동/
전투 결과/전투 상세다. 현재 장수가 사망했어도 보존된 로그를 조회할 수 있다.
과거 표본에서 열면 해당 게임 월 전체 기록을 조회하며 표본 순간까지의 기록인 것처럼
표시하지 않는다. `createdAt`은 게임 논리 시각일 수 있어 설치 wall time으로 필터하지 않는다.
별도 로그 복제 테이블 대신 nullable `LogEntry.serverId`를 추가했다. 엔진 공통 로그,
천통 내기 결과, 장수 선택/재선택, 외교 메시지 응답의 기존 저장 transaction에서
world meta의 실제 기수 ID를 함께 쓴다. 엔진과 장수 선택은 메모리에 있는 값을 사용한다.
외교 응답은 기존 world SELECT에 meta 필드를 추가한다(조회 횟수는 동일하지만 읽는 bytes는 증가).
별도 world SELECT나 로그 INSERT는 없다.
공백/누락 identity를 profile명으로 대신하지 않는다. 기존 및 레거시 이관 로그는
기수 귀속을 증명하지 못하므로 null 그대로 두며 API에서 제외하고 화면에 자료 범위를 표시한다.
설치 시 전체 backfill이나 로그 재복사는 수행하지 않는다.
현재 `(generalId, category, id)` index를 재사용한다. serverId/월은 잔여 조건이므로
오랜 기수의 희소한 월 조회에는 더 많은 index 행을 검사할 수 있다. 실행 5초 상한은
응답 실패를 자료 없음으로 숨기지 않는다. 큰 fixture의 EXPLAIN/지연 측정 후 복합 index의
추가 쓰기 비용과 비교하는 P6 gate는 남으며, 행 반환 상한을 스캔량 상한으로 보고하지 않는다.
`GeneralRecordPanels`에 선택 종류와 독립 오류/재시도 표시를 추가해 기존 표시를 재사용한다.
상세에서 버튼을 눌러야 로그를 읽고 종류별로 받은 페이지를 재사용한다. 엔티티/월이
바뀌면 캐시를 비우고 늦은 응답을 버린다. 동등한 월 객체가 재생성되어도 장수/도시 상세를
재조회하지 않도록 감시 대상을 ID·연·월·종류의 원시값으로 제한한다. 로그 원문은
기존 `formatLog` 허용 목록을 거쳐 렌더링한다.
## 월별 저장 구현 ## 월별 저장 구현
`playAudit/collection.ts``beforeMonthChanged`에 이전 월 표본을 queue한다. `playAudit/collection.ts``beforeMonthChanged`에 이전 월 표본을 queue한다.
@@ -136,7 +164,7 @@ no-general 허용, 무인증·일반 admin·다른 profile·제재 거부, 200
반환하고 수입/급여만 기간 합산한다. 누락·국가 없음·불완전 정산의 흐름은 null, 반환하고 수입/급여만 기간 합산한다. 누락·국가 없음·불완전 정산의 흐름은 null,
관측한 정산 없음은 0이다. 기간 일부 요청은 from/to와 complete=false로 표시한다. 관측한 정산 없음은 0이다. 기간 일부 요청은 from/to와 complete=false로 표시한다.
장수·도시 상세와 독립 로그, 국가 시계열의 FINAL 별도 표시, UI와 모든 조사 기능은 남았다. 장수·도시 상세와 독립 로그, FINAL 별도 표시는 기본 화면에 연결했다. 외교·정책·NPC 결정과 조사 기능은 남았다.
## 수집 지점과 쓰기 재검토 ## 수집 지점과 쓰기 재검토
+1
View File
@@ -791,6 +791,7 @@ model Event {
model LogEntry { model LogEntry {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
serverId String? @map("server_id")
scope LogScope scope LogScope
category LogCategory category LogCategory
subType String? @map("sub_type") subType String? @map("sub_type")
@@ -0,0 +1,2 @@
-- Existing rows have no provable season identity. Preserve them without guessing.
ALTER TABLE "log_entry" ADD COLUMN "server_id" TEXT;
+1
View File
@@ -416,6 +416,7 @@ export interface TurnEngineEventCreateManyInput {
} }
export interface TurnEngineLogEntryCreateManyInput { export interface TurnEngineLogEntryCreateManyInput {
serverId?: string | null;
scope: LogScope; scope: LogScope;
category: LogCategory; category: LogCategory;
subType: string | null; subType: string | null;