NPC 결정 기록 조회와 월별 인덱스를 연결하고 감사 화면 검증
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import type { GamePrisma } from '@sammo-ts/infra';
|
||||
import { auditProcedure, monthOrdinal, readAudit, readAuditWorld, zAuditMonth } from './shared.js';
|
||||
|
||||
const zId = z.string().regex(/^[a-f0-9]{64}$/);
|
||||
const zTick = z
|
||||
.string()
|
||||
.regex(/^\d{1,16}$/)
|
||||
.refine((value) => BigInt(value) <= BigInt(Number.MAX_SAFE_INTEGER));
|
||||
const zSummary = z.object({
|
||||
schemaVersion: z.literal(1),
|
||||
coverage: z.literal('PROCEDURES'),
|
||||
clockRevision: z.number().int(),
|
||||
codeVersion: z.string().nullable(),
|
||||
policyRefs: z.object({
|
||||
NPC_VALUES: zId.optional(),
|
||||
NPC_NATION_PRIORITY: zId.optional(),
|
||||
NPC_GENERAL_PRIORITY: zId.optional(),
|
||||
DEFENCE: zId.optional(),
|
||||
}),
|
||||
requestedAction: z.string(),
|
||||
selectedAction: z.string().nullable(),
|
||||
selectedReason: z.string().nullable(),
|
||||
executedAction: z.string(),
|
||||
completed: z.boolean().nullable(),
|
||||
usedFallback: z.boolean(),
|
||||
blockedReason: z.string().nullable(),
|
||||
});
|
||||
const zValue = z.union([
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.boolean(),
|
||||
z.null(),
|
||||
z.object({ entityId: z.number() }),
|
||||
z.object({ unprojected: z.literal(true) }),
|
||||
]);
|
||||
const zStep = z.intersection(
|
||||
z.object({
|
||||
sequence: z.number().int().nonnegative(),
|
||||
phase: z.enum(['general', 'nation']),
|
||||
generalId: z.number().int(),
|
||||
nationId: z.number().int(),
|
||||
cityId: z.number().int(),
|
||||
npcState: z.number().int(),
|
||||
year: z.number().int(),
|
||||
month: z.number().int(),
|
||||
tick: z.number().nullable(),
|
||||
}),
|
||||
z.discriminatedUnion('kind', [
|
||||
z.object({ kind: z.literal('DECISION_START'), reservedAction: z.string() }),
|
||||
z.object({ kind: z.literal('DECISION_END'), action: z.string().nullable(), reason: z.string().nullable() }),
|
||||
z.object({ kind: z.literal('DECISION_ERROR') }),
|
||||
z.object({ kind: z.literal('PROCEDURE_START'), procedure: z.string() }),
|
||||
z.object({
|
||||
kind: z.literal('PROCEDURE_END'),
|
||||
procedure: z.string(),
|
||||
action: z.string().nullable(),
|
||||
reason: z.string().nullable(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('PROCEDURE_SKIP'),
|
||||
procedure: z.string(),
|
||||
reason: z.enum(['POLICY', 'AUTOMATION', 'NO_HANDLER']),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('CANDIDATE'),
|
||||
action: z.string(),
|
||||
result: z.enum(['INVALID_ARGS', 'allow', 'deny', 'unknown']),
|
||||
constraint: z.string().nullable(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('RNG'),
|
||||
method: z.string(),
|
||||
parameters: z.array(z.number()).nullable(),
|
||||
result: z.union([zValue, z.array(zValue)]),
|
||||
}),
|
||||
])
|
||||
);
|
||||
const select = {
|
||||
id: true,
|
||||
executionId: true,
|
||||
phase: true,
|
||||
generalId: true,
|
||||
nationId: true,
|
||||
cityId: true,
|
||||
npcState: true,
|
||||
year: true,
|
||||
month: true,
|
||||
tick: true,
|
||||
stepCount: true,
|
||||
summary: true,
|
||||
createdAt: true,
|
||||
} satisfies GamePrisma.PlayAuditDecisionSelect;
|
||||
const project = (row: GamePrisma.PlayAuditDecisionGetPayload<{ select: typeof select }>) => ({
|
||||
...row,
|
||||
phase: z.enum(['general', 'nation']).parse(row.phase),
|
||||
tick: row.tick.toString(),
|
||||
summary: zSummary.parse(row.summary),
|
||||
});
|
||||
export const decisionHistory = auditProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
generalId: z.number().int().positive().max(2147483647),
|
||||
month: zAuditMonth.omit({ kind: true }).optional(),
|
||||
phase: z.enum(['general', 'nation']).optional(),
|
||||
cursor: z.object({ tick: zTick, id: zId }).strict().optional(),
|
||||
limit: z.number().int().min(1).max(200).default(50),
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
.query(({ ctx, input }) =>
|
||||
readAudit(ctx, async (tx) => {
|
||||
const world = await readAuditWorld(tx);
|
||||
const month = input.month ?? { year: world.year, month: world.month };
|
||||
const ordinal = monthOrdinal(month.year, month.month);
|
||||
if (
|
||||
ordinal < monthOrdinal(world.startYear, world.startMonth) ||
|
||||
ordinal > monthOrdinal(world.year, world.month)
|
||||
)
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '현재 기수 안의 결정 조회 월을 선택해 주세요.' });
|
||||
const rows = world.serverId
|
||||
? await tx.playAuditDecision.findMany({
|
||||
where: {
|
||||
serverId: world.serverId,
|
||||
generalId: input.generalId,
|
||||
year: month.year,
|
||||
month: month.month,
|
||||
phase: input.phase,
|
||||
...(input.cursor
|
||||
? {
|
||||
OR: [
|
||||
{ tick: { lt: BigInt(input.cursor.tick) } },
|
||||
{ tick: BigInt(input.cursor.tick), id: { lt: input.cursor.id } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: [{ tick: 'desc' }, { id: 'desc' }],
|
||||
take: input.limit + 1,
|
||||
select,
|
||||
})
|
||||
: [];
|
||||
const last = rows[input.limit - 1];
|
||||
return {
|
||||
...world,
|
||||
month,
|
||||
coverage: world.serverId ? ('PROCEDURES_ONLY' as const) : ('IDENTITY_MISSING' as const),
|
||||
items: rows.slice(0, input.limit).map(project),
|
||||
nextCursor: rows.length > input.limit && last ? { tick: last.tick.toString(), id: last.id } : null,
|
||||
};
|
||||
})
|
||||
);
|
||||
export const decisionDetail = auditProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
id: zId,
|
||||
generalId: z.number().int().positive().max(2147483647),
|
||||
cursor: z.number().int().nonnegative().max(2147483647).optional(),
|
||||
limit: z.number().int().min(1).max(4).default(1),
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
.query(({ ctx, input }) =>
|
||||
readAudit(ctx, async (tx) => {
|
||||
const world = await readAuditWorld(tx);
|
||||
const row = world.serverId
|
||||
? await tx.playAuditDecision.findFirst({
|
||||
where: { id: input.id, generalId: input.generalId, serverId: world.serverId },
|
||||
select,
|
||||
})
|
||||
: null;
|
||||
if (!row)
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '현재 기수에서 해당 결정 기록을 찾을 수 없습니다.' });
|
||||
const chunks = await tx.playAuditDecisionChunk.findMany({
|
||||
where: { decisionId: row.id, ordinal: input.cursor === undefined ? undefined : { gt: input.cursor } },
|
||||
orderBy: { ordinal: 'asc' },
|
||||
take: input.limit,
|
||||
select: { ordinal: true, steps: true },
|
||||
});
|
||||
return {
|
||||
...world,
|
||||
decision: project(row),
|
||||
chunks: chunks.slice(0, input.limit).map((chunk) => ({
|
||||
ordinal: chunk.ordinal,
|
||||
steps: z.array(zStep).max(128).parse(chunk.steps),
|
||||
})),
|
||||
nextCursor:
|
||||
chunks.length && chunks.at(-1)!.ordinal + 1 < Math.ceil(row.stepCount / 128)
|
||||
? chunks.at(-1)!.ordinal
|
||||
: null,
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -1,3 +1,4 @@
|
||||
import { decisionHistory, decisionDetail } from './decisions.js';
|
||||
import { diplomacyHistory, diplomacyEvent } from './diplomacy.js';
|
||||
import { nationSeries, zAuditNation } from './nationSeries.js';
|
||||
import { cityDetail, generalDetail, generalTurns } from './details.js';
|
||||
@@ -25,6 +26,8 @@ import {
|
||||
} from './projection.js';
|
||||
|
||||
export const playAuditRouter = router({
|
||||
decisionHistory,
|
||||
decisionDetail,
|
||||
diplomacyHistory,
|
||||
diplomacyEvent,
|
||||
policyHistory,
|
||||
|
||||
@@ -2243,6 +2243,129 @@ integration('game API security over HTTP transport', () => {
|
||||
});
|
||||
const admin = await token([`admin.playAudit.read:${profileName}`]);
|
||||
const beforeInputs = await db.inputEvent.count();
|
||||
const decisionIds = [policyId(501), policyId(502)].sort().reverse();
|
||||
const decisionGeneral = 99129; // live general 없이 보존 이력을 읽는다.
|
||||
const decisionSummary = {
|
||||
schemaVersion: 1,
|
||||
coverage: 'PROCEDURES',
|
||||
clockRevision: 1,
|
||||
codeVersion: null,
|
||||
policyRefs: { DEFENCE: policyId(1), secret: 'decision-secret' },
|
||||
requestedAction: '휴식',
|
||||
selectedAction: 'che_징병',
|
||||
selectedReason: '징병',
|
||||
executedAction: '휴식',
|
||||
completed: false,
|
||||
usedFallback: true,
|
||||
blockedReason: '자원 부족',
|
||||
seed: 'decision-secret',
|
||||
};
|
||||
await db.playAuditDecision.createMany({
|
||||
data: decisionIds.map((id, index) => ({
|
||||
id,
|
||||
serverId: seasonId,
|
||||
executionId: id,
|
||||
phase: index ? 'nation' : 'general',
|
||||
generalId: decisionGeneral,
|
||||
nationId: ownerNationId,
|
||||
cityId: 1,
|
||||
npcState: index ? 1 : 2,
|
||||
year: 190,
|
||||
month: 1,
|
||||
tick: 4_320_000_000n,
|
||||
stepCount: 129,
|
||||
summary: decisionSummary,
|
||||
hash: id,
|
||||
})),
|
||||
});
|
||||
const step = {
|
||||
phase: 'general',
|
||||
generalId: decisionGeneral,
|
||||
nationId: ownerNationId,
|
||||
cityId: 1,
|
||||
npcState: 2,
|
||||
year: 190,
|
||||
month: 1,
|
||||
tick: 4_320_000_000,
|
||||
kind: 'PROCEDURE_START',
|
||||
procedure: '상세에서만표시',
|
||||
secret: 'decision-secret',
|
||||
};
|
||||
await db.playAuditDecisionChunk.createMany({
|
||||
data: [
|
||||
{
|
||||
decisionId: decisionIds[0]!,
|
||||
ordinal: 0,
|
||||
steps: Array.from({ length: 128 }, (_, sequence) => ({ ...step, sequence })),
|
||||
},
|
||||
{ decisionId: decisionIds[0]!, ordinal: 1, steps: [{ ...step, sequence: 128 }] },
|
||||
],
|
||||
});
|
||||
const decisionInput = { generalId: decisionGeneral, month: { year: 190, month: 1 }, limit: 1 };
|
||||
expect((await get('decisionHistory', undefined, decisionInput)).status).toBe(401);
|
||||
expect((await get('decisionHistory', await token(['admin']), decisionInput)).status).toBe(403);
|
||||
const decisionList = await get('decisionHistory', admin, decisionInput);
|
||||
expect(decisionList.body).toMatchObject({
|
||||
result: {
|
||||
data: {
|
||||
coverage: 'PROCEDURES_ONLY',
|
||||
items: [{ id: decisionIds[0], tick: '4320000000' }],
|
||||
nextCursor: { tick: '4320000000', id: decisionIds[0] },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(decisionList.body)).not.toContain('상세에서만표시');
|
||||
expect(JSON.stringify(decisionList.body)).not.toContain('decision-secret');
|
||||
expect(
|
||||
(
|
||||
await get('decisionHistory', admin, {
|
||||
...decisionInput,
|
||||
cursor: { tick: '4320000000', id: decisionIds[0] },
|
||||
})
|
||||
).body
|
||||
).toMatchObject({ result: { data: { items: [{ id: decisionIds[1] }], nextCursor: null } } });
|
||||
expect((await get('decisionHistory', admin, { ...decisionInput, phase: 'nation' })).body).toMatchObject({
|
||||
result: { data: { items: [{ id: decisionIds[1] }] } },
|
||||
});
|
||||
const decisionDetailInput = { generalId: decisionGeneral, id: decisionIds[0] };
|
||||
const decisionPage = await get('decisionDetail', admin, decisionDetailInput);
|
||||
expect(decisionPage.status).toBe(200);
|
||||
expect(decisionPage.body).toMatchObject({
|
||||
result: {
|
||||
data: {
|
||||
chunks: [
|
||||
{
|
||||
ordinal: 0,
|
||||
steps: expect.arrayContaining(
|
||||
[{ ...step, secret: undefined, sequence: 0 }].map(
|
||||
({ secret: _secret, ...value }) => value
|
||||
)
|
||||
),
|
||||
},
|
||||
],
|
||||
nextCursor: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(decisionPage.body)).not.toContain('decision-secret');
|
||||
expect((await get('decisionDetail', admin, { ...decisionDetailInput, cursor: 0 })).body).toMatchObject({
|
||||
result: { data: { chunks: [{ ordinal: 1, steps: [{ sequence: 128 }] }], nextCursor: null } },
|
||||
});
|
||||
expect((await get('decisionDetail', admin, { ...decisionDetailInput, generalId })).status).toBe(404);
|
||||
expect((await get('decisionDetail', admin, { ...decisionDetailInput, limit: 5 })).status).toBe(400);
|
||||
expect((await get('decisionHistory', admin, { ...decisionInput, limit: 201 })).status).toBe(400);
|
||||
expect(
|
||||
(
|
||||
await get('decisionHistory', admin, {
|
||||
...decisionInput,
|
||||
cursor: { tick: '9007199254740992', id: decisionIds[0] },
|
||||
})
|
||||
).status
|
||||
).toBe(400);
|
||||
expect(
|
||||
(await get('decisionHistory', admin, { ...decisionInput, month: { year: 9999, month: 1 } })).status
|
||||
).toBe(400);
|
||||
|
||||
const diplomacyInput = {
|
||||
nationId: 99121,
|
||||
otherNationId: 99122,
|
||||
@@ -3002,6 +3125,10 @@ integration('game API security over HTTP transport', () => {
|
||||
result: { data: { items: [] } },
|
||||
});
|
||||
expect((await get('diplomacyEvent', admin, { id: policyId(101) })).status).toBe(404);
|
||||
expect((await get('decisionHistory', admin, decisionInput)).body).toMatchObject({
|
||||
result: { data: { items: [] } },
|
||||
});
|
||||
expect((await get('decisionDetail', admin, decisionDetailInput)).status).toBe(404);
|
||||
expect(await db.inputEvent.count()).toBe(beforeInputs);
|
||||
await redis!.client.publish(
|
||||
`${redisPrefix}:flush`,
|
||||
@@ -3013,6 +3140,8 @@ integration('game API security over HTTP transport', () => {
|
||||
);
|
||||
await expect.poll(async () => (await get('capabilities', admin)).status).toBe(401);
|
||||
} finally {
|
||||
await db.playAuditDecisionChunk.deleteMany({ where: { decision: { serverId: seasonId } } });
|
||||
await db.playAuditDecision.deleteMany({ where: { serverId: seasonId } });
|
||||
await db.playAuditPolicy.deleteMany({ where: { serverId: seasonId } });
|
||||
await db.playAuditDiplomacyEvent.deleteMany({ where: { serverId: seasonId } });
|
||||
await db.diplomacyLetter.deleteMany({ where: { srcNationId: 99121, destNationId: 99122 } });
|
||||
|
||||
@@ -43,6 +43,34 @@ const general = {
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
},
|
||||
};
|
||||
const decision = {
|
||||
id: 'd'.repeat(64),
|
||||
executionId: 'e'.repeat(64),
|
||||
phase: 'general',
|
||||
generalId: 1,
|
||||
nationId: 2,
|
||||
cityId: 3,
|
||||
npcState: 2,
|
||||
year: 190,
|
||||
month: 6,
|
||||
tick: '100',
|
||||
stepCount: 129,
|
||||
createdAt: '2026-09-16T00:00:00.000Z',
|
||||
summary: {
|
||||
schemaVersion: 1,
|
||||
coverage: 'PROCEDURES',
|
||||
clockRevision: 1,
|
||||
codeVersion: null,
|
||||
policyRefs: {},
|
||||
requestedAction: '휴식',
|
||||
selectedAction: 'che_징병',
|
||||
selectedReason: '징병 선택',
|
||||
executedAction: '휴식',
|
||||
completed: false,
|
||||
usedFallback: true,
|
||||
blockedReason: '자원 부족',
|
||||
},
|
||||
};
|
||||
const install = async (page: Page, denied = false, baseline: boolean | 'document' | 'created' | 'removed' = false) => {
|
||||
const requests: { operation: string; input: Record<string, unknown> }[] = [];
|
||||
await page.addInitScript((profile) => {
|
||||
@@ -73,6 +101,47 @@ const install = async (page: Page, denied = false, baseline: boolean | 'document
|
||||
},
|
||||
}
|
||||
: result({ profileName: gameProfile, read: true, accounts: false });
|
||||
case 'playAudit.decisionHistory':
|
||||
return result({
|
||||
...world,
|
||||
month: input.month ?? { year: 190, month: 7 },
|
||||
coverage: 'PROCEDURES_ONLY',
|
||||
items: [
|
||||
{
|
||||
...decision,
|
||||
id: input.cursor ? 'c'.repeat(64) : decision.id,
|
||||
phase: input.cursor ? 'nation' : 'general',
|
||||
},
|
||||
],
|
||||
nextCursor: input.cursor ? null : { tick: '100', id: decision.id },
|
||||
});
|
||||
case 'playAudit.decisionDetail':
|
||||
return result({
|
||||
...world,
|
||||
decision,
|
||||
chunks: [
|
||||
{
|
||||
ordinal: input.cursor === undefined ? 0 : 1,
|
||||
steps: [
|
||||
{
|
||||
phase: 'general',
|
||||
generalId: 1,
|
||||
nationId: 2,
|
||||
cityId: 3,
|
||||
npcState: 2,
|
||||
year: 190,
|
||||
month: 6,
|
||||
tick: 100,
|
||||
sequence: input.cursor === undefined ? 0 : 128,
|
||||
...(input.cursor === undefined
|
||||
? { kind: 'PROCEDURE_START', procedure: '<b>징병판정</b>' }
|
||||
: { kind: 'DECISION_END', action: 'che_징병', reason: '징병 선택' }),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
nextCursor: input.cursor === undefined ? 0 : null,
|
||||
});
|
||||
case 'playAudit.generalLogs':
|
||||
return result({
|
||||
...world,
|
||||
@@ -1015,3 +1084,73 @@ test('general search is explicit and persists across pagination and reload', asy
|
||||
await expect(page.getByRole('rowheader', { name: /감사장수/ })).toBeVisible();
|
||||
await capture(page, 'mobile-general-search');
|
||||
});
|
||||
|
||||
test('NPC decisions are explicit, paginated, independently addressable and escaped', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const requests = await install(page);
|
||||
await page.goto(gamePath('/play-audit?tab=generals&at=month&year=190&month=6&general=1'));
|
||||
await expect(page.getByRole('heading', { name: '선택 장수 상세' })).toBeVisible();
|
||||
expect(requests.some((r) => r.operation.startsWith('playAudit.decision'))).toBe(false);
|
||||
await page.getByRole('button', { name: 'NPC 결정 기록 조회', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: '개인 판단 · tick 100', exact: true })).toBeVisible();
|
||||
expect(requests.filter((r) => r.operation === 'playAudit.decisionHistory').at(-1)?.input).toMatchObject({
|
||||
generalId: 1,
|
||||
month: { year: 190, month: 6 },
|
||||
});
|
||||
expect(requests.some((r) => r.operation === 'playAudit.decisionDetail')).toBe(false);
|
||||
const counts = {
|
||||
list: requests.filter((r) => r.operation === 'playAudit.generals').length,
|
||||
history: requests.filter((r) => r.operation === 'playAudit.decisionHistory').length,
|
||||
};
|
||||
await page.getByRole('button', { name: '개인 판단 · tick 100', exact: true }).click();
|
||||
await expect(page.getByRole('list', { name: '판단 절차' })).toContainText('<b>징병판정</b>');
|
||||
await expect(page.getByRole('list', { name: '판단 절차' }).locator('b')).toHaveCount(0);
|
||||
await page.getByRole('button', { name: '판단 절차 더 불러오기', exact: true }).click();
|
||||
await expect(page.getByRole('list', { name: '판단 절차' })).toContainText('최종 선택');
|
||||
expect(requests.filter((r) => r.operation === 'playAudit.decisionDetail').at(-1)?.input).toMatchObject({
|
||||
id: decision.id,
|
||||
generalId: 1,
|
||||
cursor: 0,
|
||||
limit: 1,
|
||||
});
|
||||
expect(requests.filter((r) => r.operation === 'playAudit.generals')).toHaveLength(counts.list);
|
||||
expect(requests.filter((r) => r.operation === 'playAudit.decisionHistory')).toHaveLength(counts.history);
|
||||
await capture(page, 'mobile-npc-decision');
|
||||
await page.reload();
|
||||
await expect(page.getByRole('list', { name: '판단 절차' })).toContainText('징병판정');
|
||||
await page.getByRole('button', { name: '결정 목록 더 불러오기', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: '수뇌 판단 · tick 100', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('NPC decision detail retry preserves history and other general information', async ({ page }) => {
|
||||
const requests = await install(page);
|
||||
let fail = true;
|
||||
await page.route(gameTrpcRoute, async (route) => {
|
||||
if (fail && route.request().url().includes('playAudit.decisionDetail')) {
|
||||
fail = false;
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{
|
||||
error: {
|
||||
message: '결정 상세 재시도',
|
||||
code: -32603,
|
||||
data: { code: 'INTERNAL_SERVER_ERROR', httpStatus: 500 },
|
||||
},
|
||||
},
|
||||
]),
|
||||
});
|
||||
} else await route.fallback();
|
||||
});
|
||||
await page.goto(gamePath('/play-audit?tab=generals&general=1'));
|
||||
await page.getByRole('button', { name: 'NPC 결정 기록 조회', exact: true }).click();
|
||||
await page.getByRole('button', { name: '개인 판단 · tick 100', exact: true }).click();
|
||||
await expect(page.getByRole('alert')).toContainText('결정 상세 재시도');
|
||||
await expect(page.getByRole('button', { name: '개인 판단 · tick 100', exact: true })).toBeVisible();
|
||||
const count = requests.filter((r) => r.operation === 'playAudit.decisionHistory').length;
|
||||
await page.getByRole('button', { name: '결정 상세 다시 조회', exact: true }).click();
|
||||
await expect(page.getByRole('list', { name: '판단 절차' })).toContainText('징병판정');
|
||||
expect(requests.filter((r) => r.operation === 'playAudit.decisionHistory')).toHaveLength(count);
|
||||
await capture(page, 'desktop-npc-decision');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { trpc } from '../../utils/trpc';
|
||||
const props = defineProps<{ generalId: number; month?: { year: number; month: number } }>();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
type History = Awaited<ReturnType<typeof trpc.playAudit.decisionHistory.query>>;
|
||||
type Detail = Awaited<ReturnType<typeof trpc.playAudit.decisionDetail.query>>;
|
||||
type Step = Detail['chunks'][number]['steps'][number];
|
||||
const history = ref<History | null>(null);
|
||||
const detail = ref<Detail | null>(null);
|
||||
const error = ref('');
|
||||
const detailError = ref('');
|
||||
const loading = ref(false);
|
||||
const detailLoading = ref(false);
|
||||
let generation = 0;
|
||||
let detailGeneration = 0;
|
||||
const selected = computed(() => (typeof route.query.decision === 'string' ? route.query.decision : null));
|
||||
const message = (cause: unknown) => (cause instanceof Error ? cause.message : 'NPC 결정 기록을 조회하지 못했습니다.');
|
||||
const load = async (more = false) => {
|
||||
if (loading.value) return;
|
||||
const request = generation;
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const response = await trpc.playAudit.decisionHistory.query({
|
||||
generalId: props.generalId,
|
||||
month: more ? (history.value?.month ?? props.month) : props.month,
|
||||
limit: 50,
|
||||
cursor: more ? (history.value?.nextCursor ?? undefined) : undefined,
|
||||
});
|
||||
if (request === generation)
|
||||
history.value = {
|
||||
...response,
|
||||
items: more ? [...(history.value?.items ?? []), ...response.items] : response.items,
|
||||
};
|
||||
} catch (cause) {
|
||||
if (request === generation) error.value = message(cause);
|
||||
} finally {
|
||||
if (request === generation) loading.value = false;
|
||||
}
|
||||
};
|
||||
const loadDetail = async (more = false) => {
|
||||
if (!selected.value || detailLoading.value) return;
|
||||
const request = detailGeneration;
|
||||
detailLoading.value = true;
|
||||
detailError.value = '';
|
||||
try {
|
||||
const response = await trpc.playAudit.decisionDetail.query({
|
||||
id: selected.value,
|
||||
generalId: props.generalId,
|
||||
cursor: more ? (detail.value?.nextCursor ?? undefined) : undefined,
|
||||
limit: 1,
|
||||
});
|
||||
if (request === detailGeneration)
|
||||
detail.value = {
|
||||
...response,
|
||||
chunks: more ? [...(detail.value?.chunks ?? []), ...response.chunks] : response.chunks,
|
||||
};
|
||||
} catch (cause) {
|
||||
if (request === detailGeneration) detailError.value = message(cause);
|
||||
} finally {
|
||||
if (request === detailGeneration) detailLoading.value = false;
|
||||
}
|
||||
};
|
||||
const select = (id: string | null) => router.push({ query: { ...route.query, decision: id ?? undefined } });
|
||||
const outcome = (done: boolean | null) => (done === null ? '결과 미관측' : done ? '실행 완료' : '실행 실패');
|
||||
const rngValue = (value: Extract<Step, { kind: 'RNG' }>['result']): string => {
|
||||
if (Array.isArray(value)) return value.map(rngValue).join(', ');
|
||||
if (value === null) return '없음';
|
||||
if (typeof value === 'object') return 'entityId' in value ? `대상 #${value.entityId}` : '상세 값 미수집';
|
||||
return String(value);
|
||||
};
|
||||
const stepText = (step: Step): string => {
|
||||
switch (step.kind) {
|
||||
case 'DECISION_START':
|
||||
return `판단 시작 · 예약 ${step.reservedAction}`;
|
||||
case 'DECISION_END':
|
||||
return `최종 선택 · ${step.action ?? '선택 없음'} · ${step.reason ?? '사유 미관측'}`;
|
||||
case 'DECISION_ERROR':
|
||||
return '판단 중 오류';
|
||||
case 'PROCEDURE_START':
|
||||
return `${step.procedure} · 평가 시작`;
|
||||
case 'PROCEDURE_END':
|
||||
return `${step.procedure} · ${step.action ?? '선택 없음'} · ${step.reason ?? '내부 사유 미수집'}`;
|
||||
case 'PROCEDURE_SKIP':
|
||||
return `${step.procedure} · ${{ POLICY: '정책으로 제외', AUTOMATION: '자동화 권한으로 제외', NO_HANDLER: '처리 절차 없음' }[step.reason]}`;
|
||||
case 'CANDIDATE':
|
||||
return `${step.action} · ${{ INVALID_ARGS: '인자 오류', allow: '조건 통과', deny: '조건 차단', unknown: '조건 미확인' }[step.result]}${step.constraint ? ` · ${step.constraint}` : ''}`;
|
||||
case 'RNG':
|
||||
return `${step.method}(${step.parameters?.join(', ') ?? ''}) → ${rngValue(step.result)}`;
|
||||
}
|
||||
};
|
||||
watch(
|
||||
[() => props.generalId, () => props.month?.year, () => props.month?.month],
|
||||
() => {
|
||||
generation++;
|
||||
history.value = null;
|
||||
error.value = '';
|
||||
loading.value = false;
|
||||
void load();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
watch(
|
||||
[selected, () => props.generalId],
|
||||
() => {
|
||||
detailGeneration++;
|
||||
detail.value = null;
|
||||
detailError.value = '';
|
||||
detailLoading.value = false;
|
||||
if (selected.value) void loadDetail();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="audit-decisions" aria-label="NPC 결정 기록">
|
||||
<p>
|
||||
{{ history ? `${history.month.year}년 ${history.month.month}월` : '선택 월' }} · NPC·유저 자동턴의 개인/수뇌
|
||||
판단
|
||||
</p>
|
||||
<p>
|
||||
절차와 선택 결과를 수집한 기록입니다. 후보 내부 조건 전체는 아직 포함되지 않으며, 기록이 없다고 판단 시도가
|
||||
없었다는 뜻은 아닙니다.
|
||||
</p>
|
||||
<p v-if="loading" role="status">결정 목록 조회 중…</p>
|
||||
<p v-if="error" role="alert">
|
||||
{{ error }} <button class="legacy-button" @click="load()">결정 목록 다시 조회</button>
|
||||
</p>
|
||||
<p v-if="history && !history.items.length">이 월에 수집된 결정 기록이 없습니다.</p>
|
||||
<div v-if="history?.items.length" class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>판단</th>
|
||||
<th>주체</th>
|
||||
<th>선택 → 실행</th>
|
||||
<th>결과</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in history.items" :key="item.id">
|
||||
<td>
|
||||
<button class="legacy-button" @click="select(item.id)">
|
||||
{{ item.phase === 'nation' ? '수뇌 판단' : '개인 판단' }} · tick {{ item.tick }}
|
||||
</button>
|
||||
</td>
|
||||
<td>{{ item.npcState < 2 ? '유저 자동턴' : item.npcState === 5 ? '부대장 NPC' : 'NPC' }}</td>
|
||||
<td>{{ item.summary.selectedAction ?? '선택 없음' }} → {{ item.summary.executedAction }}</td>
|
||||
<td>
|
||||
{{ outcome(item.summary.completed) }}{{ item.summary.usedFallback ? ' · 대체 실행' : '' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button v-if="history?.nextCursor" class="legacy-button" :disabled="loading" @click="load(true)">
|
||||
결정 목록 더 불러오기
|
||||
</button>
|
||||
<section v-if="selected" aria-label="선택 결정 상세">
|
||||
<button class="legacy-button" @click="select(null)">결정 상세 닫기</button>
|
||||
<p v-if="detailLoading" role="status">결정 상세 조회 중…</p>
|
||||
<p v-if="detailError" role="alert">
|
||||
{{ detailError }}
|
||||
<button class="legacy-button" @click="loadDetail(Boolean(detail))">결정 상세 다시 조회</button>
|
||||
</p>
|
||||
<template v-if="detail">
|
||||
<p>
|
||||
{{ detail.decision.year }}년 {{ detail.decision.month }}월 · 국가 #{{ detail.decision.nationId }} ·
|
||||
도시 #{{ detail.decision.cityId }} · tick {{ detail.decision.tick }}
|
||||
</p>
|
||||
<p>
|
||||
예약 {{ detail.decision.summary.requestedAction }} · 선택
|
||||
{{ detail.decision.summary.selectedAction ?? '없음' }} · 실행
|
||||
{{ detail.decision.summary.executedAction }}
|
||||
</p>
|
||||
<p>
|
||||
선택 사유: {{ detail.decision.summary.selectedReason ?? '미관측' }} ·
|
||||
{{ outcome(detail.decision.summary.completed) }}
|
||||
</p>
|
||||
<p v-if="detail.decision.summary.blockedReason">
|
||||
차단 사유: {{ detail.decision.summary.blockedReason }}
|
||||
</p>
|
||||
<p>
|
||||
코드 버전: {{ detail.decision.summary.codeVersion ?? '미관측' }} · 전체 관측
|
||||
{{ detail.decision.stepCount }}개
|
||||
</p>
|
||||
<details>
|
||||
<summary>당시 정책 참조</summary>
|
||||
<p v-if="!Object.keys(detail.decision.summary.policyRefs).length">확보된 정책 참조가 없습니다.</p>
|
||||
<p v-for="(id, area) in detail.decision.summary.policyRefs" :key="area">{{ area }}: {{ id }}</p>
|
||||
</details>
|
||||
<ol aria-label="판단 절차">
|
||||
<template v-for="chunk in detail.chunks" :key="chunk.ordinal"
|
||||
><li v-for="step in chunk.steps" :key="step.sequence" :value="step.sequence + 1">
|
||||
{{ stepText(step) }}
|
||||
</li></template
|
||||
>
|
||||
</ol>
|
||||
<button
|
||||
v-if="detail.nextCursor !== null"
|
||||
class="legacy-button"
|
||||
:disabled="detailLoading"
|
||||
@click="loadDetail(true)"
|
||||
>
|
||||
판단 절차 더 불러오기
|
||||
</button>
|
||||
</template>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.audit-decisions {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.table-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
min-width: 640px;
|
||||
width: 100%;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: 6px;
|
||||
text-align: left;
|
||||
border: 1px solid gray;
|
||||
}
|
||||
ol {
|
||||
padding-left: 28px;
|
||||
}
|
||||
li {
|
||||
padding: 4px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import AuditGeneralDecisions from './AuditGeneralDecisions.vue';
|
||||
import PanelCard from '../ui/PanelCard.vue';
|
||||
import AuditGeneralLogs from './AuditGeneralLogs.vue';
|
||||
import { trpc } from '../../utils/trpc';
|
||||
@@ -17,6 +19,16 @@ const turnsLoading = ref(false);
|
||||
const error = ref('');
|
||||
const turnsError = ref('');
|
||||
const showLogs = ref(false);
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const showDecisions = ref(false);
|
||||
const decisionsOpen = computed(() => showDecisions.value || typeof route.query.decision === 'string');
|
||||
const toggleDecisions = async () => {
|
||||
if (decisionsOpen.value) {
|
||||
showDecisions.value = false;
|
||||
await router.push({ query: { ...route.query, decision: undefined } });
|
||||
} else showDecisions.value = true;
|
||||
};
|
||||
let generation = 0;
|
||||
const format = (value: number) => value.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
|
||||
const load = async () => {
|
||||
@@ -126,6 +138,14 @@ watch(
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
<button class="legacy-button" @click="toggleDecisions">
|
||||
{{ decisionsOpen ? 'NPC 결정 기록 닫기' : 'NPC 결정 기록 조회' }}
|
||||
</button>
|
||||
<AuditGeneralDecisions
|
||||
v-if="decisionsOpen"
|
||||
:general-id="generalId"
|
||||
:month="at ? { year: at.year, month: at.month } : undefined"
|
||||
/>
|
||||
<button class="legacy-button" @click="showLogs = !showLogs">
|
||||
{{ showLogs ? '장수 기록 닫기' : '장수 기록 조회' }}
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { trpc } from '../../utils/trpc';
|
||||
import type { trpc } from '../../utils/trpc';
|
||||
|
||||
type Series = Awaited<ReturnType<typeof trpc.playAudit.nationSeries.query>>;
|
||||
type Point = Series['items'][number];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { trpc } from '../../utils/trpc';
|
||||
import type { trpc } from '../../utils/trpc';
|
||||
type Snapshot = Awaited<ReturnType<typeof trpc.playAudit.nationSnapshot.query>>;
|
||||
const props = defineProps<{ data: Snapshot }>();
|
||||
const label = computed(() => (props.data.sample?.kind === 'INITIAL' ? '수집 시작 기준' : '최종 표본'));
|
||||
|
||||
@@ -97,10 +97,10 @@ const selectedCity = computed(() =>
|
||||
: null
|
||||
);
|
||||
const selectGeneral = (id: number) =>
|
||||
router.push({ query: { ...route.query, general: String(id), cityRecord: undefined } });
|
||||
const closeGeneral = () => router.push({ query: { ...route.query, general: undefined } });
|
||||
router.push({ query: { ...route.query, general: String(id), cityRecord: undefined, decision: undefined } });
|
||||
const closeGeneral = () => router.push({ query: { ...route.query, general: undefined, decision: undefined } });
|
||||
const selectCity = (id: number) =>
|
||||
router.push({ query: { ...route.query, cityRecord: String(id), general: undefined } });
|
||||
router.push({ query: { ...route.query, cityRecord: String(id), general: undefined, decision: undefined } });
|
||||
const closeCity = () => router.push({ query: { ...route.query, cityRecord: undefined } });
|
||||
const at = computed(() =>
|
||||
moment.value === 'current'
|
||||
@@ -296,7 +296,12 @@ watch(
|
||||
() =>
|
||||
JSON.stringify(
|
||||
Object.entries(route.query).filter(
|
||||
([key]) => key !== 'general' && key !== 'cityRecord' && key !== 'policy' && key !== 'event'
|
||||
([key]) =>
|
||||
key !== 'general' &&
|
||||
key !== 'cityRecord' &&
|
||||
key !== 'policy' &&
|
||||
key !== 'event' &&
|
||||
key !== 'decision'
|
||||
)
|
||||
),
|
||||
() => {
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('readReleaseManifest', () => {
|
||||
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
|
||||
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
|
||||
gatewaySchemaHead: '20260825000000_add_bulk_release_batches',
|
||||
gameSchemaHead: '20260916070000_add_play_audit_decision',
|
||||
gameSchemaHead: '20260916080000_index_play_audit_decision_month',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,21 @@ NPC 결정 trace와 조사 A~F의 완성, 전체 종료 경계 및 COST gate는
|
||||
|
||||
## 현재 구현
|
||||
|
||||
### NPC 결정 조회 API와 화면
|
||||
|
||||
`playAudit.decisionHistory/decisionDetail`은 같은 프로필 감사 권한·현재 기수 경계를 따른다.
|
||||
장수별 조회는 선택 월(미지정은 현재 월), 선택 phase와 `(tick,id)` 내림차순 cursor를 사용한다.
|
||||
목록은50건 기본/200건 상한으로 요약만 읽고, 상세는 명시 선택 시128 event chunk를
|
||||
기본1개/최대4개 읽는다. header의 stepCount로 다음 chunk를 판단해 추가 본문이나 COUNT를
|
||||
읽지 않는다. summary/step은 허용 필드만 투영하며 seed/raw metadata를 반환하지 않는다.
|
||||
같은 tick의 개인·수뇌 결정 및 사망 장수의 현재 기수 기록도 조회한다.
|
||||
|
||||
migration58은 기존 장수 인덱스를 `(server, general, year, month, tick, id)`로 교체한다.
|
||||
월 조건 밖의 기수 기록을 훑지 않으며 인덱스 개수는 늘리지 않는다. UI는 기존 장수 상세와
|
||||
버튼·표 스타일을 재사용한다. 열기/상세 선택/더 보기는 명시적으로 수행하고 polling하지
|
||||
않는다. 결정 URL 복원과 상세 재시도는 상위 장수 목록을 다시 읽지 않는다.
|
||||
절차 coverage와 미수집 코드 버전을 표시하며 전체 후보 조건·유효 정책 연결은 남는다.
|
||||
|
||||
### NPC 결정 저장·복구 기반
|
||||
|
||||
새 migration57은 `play_audit_decision` 요약과 `play_audit_decision_chunk` 상세를 분리한다.
|
||||
@@ -27,7 +42,7 @@ rollback되고 commit 뒤에만 pending prefix를 비운다. 실패한 실행의
|
||||
정리는 이전 기수 header를 잠그고200개 chunk씩 삭제한 후 header를 제거한다. FK RESTRICT로
|
||||
무제한 cascade를 막으며 현재 기수는 기존 schema lock/identity 재검사로 보호한다.
|
||||
빈57·기존56→57·재실행 no-op,302 event/3chunk 복원, 삽입 실패/재시도/충돌 및 실제
|
||||
DB hooks와 정리 회귀를 검증했다. 전체 비용 gate와 전용 API/GUI는 아직 후속이다.
|
||||
DB hooks와 정리 회귀를 검증했다. 전체 비용 gate는 후속이며 전용 API/GUI는 위 조회 절에 연결했다.
|
||||
|
||||
### NPC 판단 관측 기반
|
||||
|
||||
@@ -42,9 +57,9 @@ GeneralAI와 예약 실행 handler에 선택적 `onDecisionTrace` 관측 경계
|
||||
고정 seed3종에서16개 RNG utility 호출의 반환값·객체 identity·다음 RNG 결과가 같고,
|
||||
실제 NPC 선전포고→개전→점령 fixture에서도 수집 on/off 회귀가 통과했다.
|
||||
초기 관측 단계에서는 default daemon을 켜지 않았다. 이후 아래 저장 경계에서 현재 기수의 새 실행을 수집하도록 연결했다.
|
||||
이는 R5의 관측 기반일 뿐 완료가 아니다. 다음 작업은 불변 결정 ID·정책/code version,
|
||||
후보/조건별 실제 관측값, 실행 결과 연결, 같은 gameplay transaction의 pending/rollback,
|
||||
정식 migration·bounded 정리, 프로필 목록/상세 API와 GUI를 연결하는 것이다.
|
||||
이는 R5의 관측 기반일 뿐 완료가 아니다. 불변 결정 ID·기존 정책 참조·실행 결과·
|
||||
pending/rollback·migration·정리·목록/상세 API와 GUI는 위 절에서 연결했다.
|
||||
후보/조건별 실제 관측값, 합성 유효 정책과 code version 연결은 남는다.
|
||||
|
||||
### 전달 전 DB tick 정밀도 보완
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
| 장수 | 이름 부분 검색·장수 번호 정렬, 모든 국가·재야의 현재/월말 장수, 자원·능력·숙련·병력·훈련·사기·장비·특기·위치, 독립 로그 상세 | 현재 예약은 현재 조회에서만 제공. 과거 월말은 그달 모든 명령의 이력이 아님 |
|
||||
| 도시 | 현재/월말 소유·내정 상태와 국가별 주둔 장수 상세 연결 | 월말 주둔은 그달 모든 방문자가 아님. 지도 기반 탐색은 미완성 |
|
||||
| 외교 | 국가쌍·기간별 문서 제안/승인/철회/파기, 즉시 합의와 월간·명령 관계 전이, 생성/소멸 관계 | 문서 내용과 실제 관계는 별개. 최초 관측 이전 사건은 복원하지 않음 |
|
||||
| NPC 결정 | 장수별 개인·수뇌 판단, 절차 시도/차단, 관측한 RNG 결과와 선택·실제 실행 | 새 실행부터 수집하며 후보 내부 조건 전체는 미완성 |
|
||||
| 정책 | NPC 국가 값·국가/장수 우선순위·국방 설정의 기준 버전과 변경 전후, 당시 주체 | 수뇌용 공개 화면과 NPC 결정의 정책 참조 연결은 아직 없음 |
|
||||
|
||||
목록/그래프와 선택 상세를 분리해 읽는다. 표는 기본50건이며 더 보기를 명시적으로
|
||||
@@ -42,7 +43,7 @@
|
||||
## DB migration과 적용 순서
|
||||
|
||||
정식 game migration에 감사 테이블과 인덱스가 포함되어 있다. `prisma db push`나
|
||||
수동 CREATE TABLE로 대신 적용하지 않는다. 현재 game chain은57개이며 다음 감사
|
||||
수동 CREATE TABLE로 대신 적용하지 않는다. 현재 game chain은58개이며 다음 감사
|
||||
migration들을 포함한다. 기존 기록을 삭제하거나 지난달 상세를 역산하지 않는다.
|
||||
|
||||
| migration | 준비되는 저장소/제약 |
|
||||
@@ -55,6 +56,7 @@ migration들을 포함한다. 기존 기록을 삭제하거나 지난달 상세
|
||||
| `20260916050000_add_play_audit_diplomacy` | 방향·국가쌍·실행 순서 기반 외교 사건과 불변 원문 보호 |
|
||||
| `20260916060000_widen_play_audit_ticks` | 월 표본/정책 tick을 BIGINT로 확장하여 약60개월 이후 INTEGER 초과 방지 |
|
||||
| `20260916070000_add_play_audit_decision` | NPC/자동턴 결정 요약과 순서별 상세 chunk, bounded 정리용 FK/index |
|
||||
| `20260916080000_index_play_audit_decision_month` | 기존 장수 인덱스를 월 조건 포함 인덱스로 교체하여 기수 전체 조회 방지 |
|
||||
|
||||
운영은 [릴리스 절차](release-operations.md)의 **DB 보존 버전 업데이트**로 해당 고정
|
||||
commit을 적용한다. 이 기능을 켜기 위해 시나리오를 초기화할 필요는 없다. 수동 환경의
|
||||
@@ -73,7 +75,7 @@ Gateway의 새 진입/권한 catalog도 사용하려면 같은 commit의 Gateway
|
||||
재시작하면 전체 transaction 재시도가 가능하다. 기존 migration을 되돌리거나 수정하지 않는다. tick 확장 migration은 기존 감사 행의
|
||||
값과 hash를 보존하지만 열 형식 변경의 테이블 잠금/재작성 비용이 있다. 첫 감사 도입에서는
|
||||
앞 migration이 만든 빈 테이블에 적용되며, 시험판 감사 기록이 이미 많다면 기존 업데이트
|
||||
유지보수 구간에서 적용 시간을 확인한다. API의 tick은 정밀도 손실을 막기 위해 문자열로 반환한다.
|
||||
유지보수 구간에서 적용 시간을 확인한다. API의 tick은 정밀도 손실을 막기 위해 문자열로 반환한다. 월별 결정 인덱스 교체도 기존 결정 기록이 많으면 인덱스 생성 시간과 잠금을 업데이트 구간에 고려한다.
|
||||
|
||||
확인은 프로필 감사 진입 → 현재 장수/도시 → 초기 표본 → 정책/외교 기준 → 다음 정상
|
||||
월 경계 후 월말 표본 순서로 한다. 조기 수집 구간의 정산 coverage가 부분일 수 있으므로
|
||||
@@ -88,7 +90,7 @@ Gateway의 새 진입/권한 catalog도 사용하려면 같은 commit의 Gateway
|
||||
현재 감사 API도 사용할 수 없다.** 취소 후 다음 초기화까지 읽는 수명주기는 후속 작업이다.
|
||||
- NPC/유저 자동턴의 개인·수뇌 절차, 정책 차단, RNG utility 결과와 최종 실행 결과는
|
||||
migration 이후 새 실행부터 저장한다. 후보 내부 조건 전체는 아직 없으며 `PROCEDURES`
|
||||
coverage로 구분한다. 전용 조회 화면은 후속 작업이다. 과거 결정은 역산하지 않는다.
|
||||
coverage로 구분한다. 장수 상세의 **NPC 결정 기록 조회**에서 선택 월의 목록과 순서별 상세를 읽는다. 과거 결정은 역산하지 않는다.
|
||||
- 결정은 당시 확보된 정책 참조를 보존한다. 합성된 유효 정책 상세와 코드 버전 연결은
|
||||
아직 미완성이다. 코드 버전이 주입되지 않은 실행은 null로 남기며 현재 버전으로 메우지 않는다.
|
||||
- 계정/IP HMAC 조사, 상세 자원 이동, 예약 변경/실행 연결, 실패·rollback 조사와 알려진
|
||||
|
||||
@@ -1222,7 +1222,7 @@ model PlayAuditDecision {
|
||||
chunks PlayAuditDecisionChunk[]
|
||||
|
||||
@@unique([executionId, phase])
|
||||
@@index([serverId, generalId, tick, id])
|
||||
@@index([serverId, generalId, year, month, tick, id], map: "play_audit_decision_general_month_idx")
|
||||
@@index([serverId, nationId, tick, id])
|
||||
@@map("play_audit_decision")
|
||||
}
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
CREATE INDEX "play_audit_decision_general_month_idx" ON "play_audit_decision"("server_id", "general_id", "year", "month", "tick", "id");
|
||||
DROP INDEX "play_audit_decision_server_id_general_id_tick_id_idx";
|
||||
@@ -2,6 +2,6 @@
|
||||
"formatVersion": 1,
|
||||
"controllerProtocol": 2,
|
||||
"gatewaySchemaHead": "20260825000000_add_bulk_release_batches",
|
||||
"gameSchemaHead": "20260916070000_add_play_audit_decision",
|
||||
"gameSchemaHead": "20260916080000_index_play_audit_decision_month",
|
||||
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user