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 { diplomacyHistory, diplomacyEvent } from './diplomacy.js';
|
||||||
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';
|
||||||
@@ -25,6 +26,8 @@ import {
|
|||||||
} from './projection.js';
|
} from './projection.js';
|
||||||
|
|
||||||
export const playAuditRouter = router({
|
export const playAuditRouter = router({
|
||||||
|
decisionHistory,
|
||||||
|
decisionDetail,
|
||||||
diplomacyHistory,
|
diplomacyHistory,
|
||||||
diplomacyEvent,
|
diplomacyEvent,
|
||||||
policyHistory,
|
policyHistory,
|
||||||
|
|||||||
@@ -2243,6 +2243,129 @@ 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 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 = {
|
const diplomacyInput = {
|
||||||
nationId: 99121,
|
nationId: 99121,
|
||||||
otherNationId: 99122,
|
otherNationId: 99122,
|
||||||
@@ -3002,6 +3125,10 @@ integration('game API security over HTTP transport', () => {
|
|||||||
result: { data: { items: [] } },
|
result: { data: { items: [] } },
|
||||||
});
|
});
|
||||||
expect((await get('diplomacyEvent', admin, { id: policyId(101) })).status).toBe(404);
|
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);
|
expect(await db.inputEvent.count()).toBe(beforeInputs);
|
||||||
await redis!.client.publish(
|
await redis!.client.publish(
|
||||||
`${redisPrefix}:flush`,
|
`${redisPrefix}:flush`,
|
||||||
@@ -3013,6 +3140,8 @@ 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.playAuditDecisionChunk.deleteMany({ where: { decision: { serverId: seasonId } } });
|
||||||
|
await db.playAuditDecision.deleteMany({ where: { serverId: seasonId } });
|
||||||
await db.playAuditPolicy.deleteMany({ where: { serverId: seasonId } });
|
await db.playAuditPolicy.deleteMany({ where: { serverId: seasonId } });
|
||||||
await db.playAuditDiplomacyEvent.deleteMany({ where: { serverId: seasonId } });
|
await db.playAuditDiplomacyEvent.deleteMany({ where: { serverId: seasonId } });
|
||||||
await db.diplomacyLetter.deleteMany({ where: { srcNationId: 99121, destNationId: 99122 } });
|
await db.diplomacyLetter.deleteMany({ where: { srcNationId: 99121, destNationId: 99122 } });
|
||||||
|
|||||||
@@ -43,6 +43,34 @@ const general = {
|
|||||||
items: { horse: null, weapon: null, book: null, item: null },
|
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 install = async (page: Page, denied = false, baseline: boolean | 'document' | 'created' | 'removed' = false) => {
|
||||||
const requests: { operation: string; input: Record<string, unknown> }[] = [];
|
const requests: { operation: string; input: Record<string, unknown> }[] = [];
|
||||||
await page.addInitScript((profile) => {
|
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 });
|
: 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':
|
case 'playAudit.generalLogs':
|
||||||
return result({
|
return result({
|
||||||
...world,
|
...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 expect(page.getByRole('rowheader', { name: /감사장수/ })).toBeVisible();
|
||||||
await capture(page, 'mobile-general-search');
|
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">
|
<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 PanelCard from '../ui/PanelCard.vue';
|
||||||
import AuditGeneralLogs from './AuditGeneralLogs.vue';
|
import AuditGeneralLogs from './AuditGeneralLogs.vue';
|
||||||
import { trpc } from '../../utils/trpc';
|
import { trpc } from '../../utils/trpc';
|
||||||
@@ -17,6 +19,16 @@ const turnsLoading = ref(false);
|
|||||||
const error = ref('');
|
const error = ref('');
|
||||||
const turnsError = ref('');
|
const turnsError = ref('');
|
||||||
const showLogs = ref(false);
|
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;
|
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 () => {
|
||||||
@@ -126,6 +138,14 @@ watch(
|
|||||||
</template>
|
</template>
|
||||||
</template>
|
</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">
|
<button class="legacy-button" @click="showLogs = !showLogs">
|
||||||
{{ showLogs ? '장수 기록 닫기' : '장수 기록 조회' }}
|
{{ showLogs ? '장수 기록 닫기' : '장수 기록 조회' }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
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 Series = Awaited<ReturnType<typeof trpc.playAudit.nationSeries.query>>;
|
||||||
type Point = Series['items'][number];
|
type Point = Series['items'][number];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { trpc } from '../../utils/trpc';
|
import type { trpc } from '../../utils/trpc';
|
||||||
type Snapshot = Awaited<ReturnType<typeof trpc.playAudit.nationSnapshot.query>>;
|
type Snapshot = Awaited<ReturnType<typeof trpc.playAudit.nationSnapshot.query>>;
|
||||||
const props = defineProps<{ data: Snapshot }>();
|
const props = defineProps<{ data: Snapshot }>();
|
||||||
const label = computed(() => (props.data.sample?.kind === 'INITIAL' ? '수집 시작 기준' : '최종 표본'));
|
const label = computed(() => (props.data.sample?.kind === 'INITIAL' ? '수집 시작 기준' : '최종 표본'));
|
||||||
|
|||||||
@@ -97,10 +97,10 @@ const selectedCity = computed(() =>
|
|||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
const selectGeneral = (id: number) =>
|
const selectGeneral = (id: number) =>
|
||||||
router.push({ query: { ...route.query, general: String(id), cityRecord: undefined } });
|
router.push({ query: { ...route.query, general: String(id), cityRecord: undefined, decision: undefined } });
|
||||||
const closeGeneral = () => router.push({ query: { ...route.query, general: undefined } });
|
const closeGeneral = () => router.push({ query: { ...route.query, general: undefined, decision: undefined } });
|
||||||
const selectCity = (id: number) =>
|
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 closeCity = () => router.push({ query: { ...route.query, cityRecord: undefined } });
|
||||||
const at = computed(() =>
|
const at = computed(() =>
|
||||||
moment.value === 'current'
|
moment.value === 'current'
|
||||||
@@ -296,7 +296,12 @@ watch(
|
|||||||
() =>
|
() =>
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
Object.entries(route.query).filter(
|
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({
|
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
|
||||||
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
|
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
|
||||||
gatewaySchemaHead: '20260825000000_add_bulk_release_batches',
|
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 결정 저장·복구 기반
|
### NPC 결정 저장·복구 기반
|
||||||
|
|
||||||
새 migration57은 `play_audit_decision` 요약과 `play_audit_decision_chunk` 상세를 분리한다.
|
새 migration57은 `play_audit_decision` 요약과 `play_audit_decision_chunk` 상세를 분리한다.
|
||||||
@@ -27,7 +42,7 @@ rollback되고 commit 뒤에만 pending prefix를 비운다. 실패한 실행의
|
|||||||
정리는 이전 기수 header를 잠그고200개 chunk씩 삭제한 후 header를 제거한다. FK RESTRICT로
|
정리는 이전 기수 header를 잠그고200개 chunk씩 삭제한 후 header를 제거한다. FK RESTRICT로
|
||||||
무제한 cascade를 막으며 현재 기수는 기존 schema lock/identity 재검사로 보호한다.
|
무제한 cascade를 막으며 현재 기수는 기존 schema lock/identity 재검사로 보호한다.
|
||||||
빈57·기존56→57·재실행 no-op,302 event/3chunk 복원, 삽입 실패/재시도/충돌 및 실제
|
빈57·기존56→57·재실행 no-op,302 event/3chunk 복원, 삽입 실패/재시도/충돌 및 실제
|
||||||
DB hooks와 정리 회귀를 검증했다. 전체 비용 gate와 전용 API/GUI는 아직 후속이다.
|
DB hooks와 정리 회귀를 검증했다. 전체 비용 gate는 후속이며 전용 API/GUI는 위 조회 절에 연결했다.
|
||||||
|
|
||||||
### NPC 판단 관측 기반
|
### NPC 판단 관측 기반
|
||||||
|
|
||||||
@@ -42,9 +57,9 @@ GeneralAI와 예약 실행 handler에 선택적 `onDecisionTrace` 관측 경계
|
|||||||
고정 seed3종에서16개 RNG utility 호출의 반환값·객체 identity·다음 RNG 결과가 같고,
|
고정 seed3종에서16개 RNG utility 호출의 반환값·객체 identity·다음 RNG 결과가 같고,
|
||||||
실제 NPC 선전포고→개전→점령 fixture에서도 수집 on/off 회귀가 통과했다.
|
실제 NPC 선전포고→개전→점령 fixture에서도 수집 on/off 회귀가 통과했다.
|
||||||
초기 관측 단계에서는 default daemon을 켜지 않았다. 이후 아래 저장 경계에서 현재 기수의 새 실행을 수집하도록 연결했다.
|
초기 관측 단계에서는 default daemon을 켜지 않았다. 이후 아래 저장 경계에서 현재 기수의 새 실행을 수집하도록 연결했다.
|
||||||
이는 R5의 관측 기반일 뿐 완료가 아니다. 다음 작업은 불변 결정 ID·정책/code version,
|
이는 R5의 관측 기반일 뿐 완료가 아니다. 불변 결정 ID·기존 정책 참조·실행 결과·
|
||||||
후보/조건별 실제 관측값, 실행 결과 연결, 같은 gameplay transaction의 pending/rollback,
|
pending/rollback·migration·정리·목록/상세 API와 GUI는 위 절에서 연결했다.
|
||||||
정식 migration·bounded 정리, 프로필 목록/상세 API와 GUI를 연결하는 것이다.
|
후보/조건별 실제 관측값, 합성 유효 정책과 code version 연결은 남는다.
|
||||||
|
|
||||||
### 전달 전 DB tick 정밀도 보완
|
### 전달 전 DB tick 정밀도 보완
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
| 장수 | 이름 부분 검색·장수 번호 정렬, 모든 국가·재야의 현재/월말 장수, 자원·능력·숙련·병력·훈련·사기·장비·특기·위치, 독립 로그 상세 | 현재 예약은 현재 조회에서만 제공. 과거 월말은 그달 모든 명령의 이력이 아님 |
|
| 장수 | 이름 부분 검색·장수 번호 정렬, 모든 국가·재야의 현재/월말 장수, 자원·능력·숙련·병력·훈련·사기·장비·특기·위치, 독립 로그 상세 | 현재 예약은 현재 조회에서만 제공. 과거 월말은 그달 모든 명령의 이력이 아님 |
|
||||||
| 도시 | 현재/월말 소유·내정 상태와 국가별 주둔 장수 상세 연결 | 월말 주둔은 그달 모든 방문자가 아님. 지도 기반 탐색은 미완성 |
|
| 도시 | 현재/월말 소유·내정 상태와 국가별 주둔 장수 상세 연결 | 월말 주둔은 그달 모든 방문자가 아님. 지도 기반 탐색은 미완성 |
|
||||||
| 외교 | 국가쌍·기간별 문서 제안/승인/철회/파기, 즉시 합의와 월간·명령 관계 전이, 생성/소멸 관계 | 문서 내용과 실제 관계는 별개. 최초 관측 이전 사건은 복원하지 않음 |
|
| 외교 | 국가쌍·기간별 문서 제안/승인/철회/파기, 즉시 합의와 월간·명령 관계 전이, 생성/소멸 관계 | 문서 내용과 실제 관계는 별개. 최초 관측 이전 사건은 복원하지 않음 |
|
||||||
|
| NPC 결정 | 장수별 개인·수뇌 판단, 절차 시도/차단, 관측한 RNG 결과와 선택·실제 실행 | 새 실행부터 수집하며 후보 내부 조건 전체는 미완성 |
|
||||||
| 정책 | NPC 국가 값·국가/장수 우선순위·국방 설정의 기준 버전과 변경 전후, 당시 주체 | 수뇌용 공개 화면과 NPC 결정의 정책 참조 연결은 아직 없음 |
|
| 정책 | NPC 국가 값·국가/장수 우선순위·국방 설정의 기준 버전과 변경 전후, 당시 주체 | 수뇌용 공개 화면과 NPC 결정의 정책 참조 연결은 아직 없음 |
|
||||||
|
|
||||||
목록/그래프와 선택 상세를 분리해 읽는다. 표는 기본50건이며 더 보기를 명시적으로
|
목록/그래프와 선택 상세를 분리해 읽는다. 표는 기본50건이며 더 보기를 명시적으로
|
||||||
@@ -42,7 +43,7 @@
|
|||||||
## DB migration과 적용 순서
|
## DB migration과 적용 순서
|
||||||
|
|
||||||
정식 game migration에 감사 테이블과 인덱스가 포함되어 있다. `prisma db push`나
|
정식 game migration에 감사 테이블과 인덱스가 포함되어 있다. `prisma db push`나
|
||||||
수동 CREATE TABLE로 대신 적용하지 않는다. 현재 game chain은57개이며 다음 감사
|
수동 CREATE TABLE로 대신 적용하지 않는다. 현재 game chain은58개이며 다음 감사
|
||||||
migration들을 포함한다. 기존 기록을 삭제하거나 지난달 상세를 역산하지 않는다.
|
migration들을 포함한다. 기존 기록을 삭제하거나 지난달 상세를 역산하지 않는다.
|
||||||
|
|
||||||
| migration | 준비되는 저장소/제약 |
|
| migration | 준비되는 저장소/제약 |
|
||||||
@@ -55,6 +56,7 @@ migration들을 포함한다. 기존 기록을 삭제하거나 지난달 상세
|
|||||||
| `20260916050000_add_play_audit_diplomacy` | 방향·국가쌍·실행 순서 기반 외교 사건과 불변 원문 보호 |
|
| `20260916050000_add_play_audit_diplomacy` | 방향·국가쌍·실행 순서 기반 외교 사건과 불변 원문 보호 |
|
||||||
| `20260916060000_widen_play_audit_ticks` | 월 표본/정책 tick을 BIGINT로 확장하여 약60개월 이후 INTEGER 초과 방지 |
|
| `20260916060000_widen_play_audit_ticks` | 월 표본/정책 tick을 BIGINT로 확장하여 약60개월 이후 INTEGER 초과 방지 |
|
||||||
| `20260916070000_add_play_audit_decision` | NPC/자동턴 결정 요약과 순서별 상세 chunk, bounded 정리용 FK/index |
|
| `20260916070000_add_play_audit_decision` | NPC/자동턴 결정 요약과 순서별 상세 chunk, bounded 정리용 FK/index |
|
||||||
|
| `20260916080000_index_play_audit_decision_month` | 기존 장수 인덱스를 월 조건 포함 인덱스로 교체하여 기수 전체 조회 방지 |
|
||||||
|
|
||||||
운영은 [릴리스 절차](release-operations.md)의 **DB 보존 버전 업데이트**로 해당 고정
|
운영은 [릴리스 절차](release-operations.md)의 **DB 보존 버전 업데이트**로 해당 고정
|
||||||
commit을 적용한다. 이 기능을 켜기 위해 시나리오를 초기화할 필요는 없다. 수동 환경의
|
commit을 적용한다. 이 기능을 켜기 위해 시나리오를 초기화할 필요는 없다. 수동 환경의
|
||||||
@@ -73,7 +75,7 @@ Gateway의 새 진입/권한 catalog도 사용하려면 같은 commit의 Gateway
|
|||||||
재시작하면 전체 transaction 재시도가 가능하다. 기존 migration을 되돌리거나 수정하지 않는다. tick 확장 migration은 기존 감사 행의
|
재시작하면 전체 transaction 재시도가 가능하다. 기존 migration을 되돌리거나 수정하지 않는다. tick 확장 migration은 기존 감사 행의
|
||||||
값과 hash를 보존하지만 열 형식 변경의 테이블 잠금/재작성 비용이 있다. 첫 감사 도입에서는
|
값과 hash를 보존하지만 열 형식 변경의 테이블 잠금/재작성 비용이 있다. 첫 감사 도입에서는
|
||||||
앞 migration이 만든 빈 테이블에 적용되며, 시험판 감사 기록이 이미 많다면 기존 업데이트
|
앞 migration이 만든 빈 테이블에 적용되며, 시험판 감사 기록이 이미 많다면 기존 업데이트
|
||||||
유지보수 구간에서 적용 시간을 확인한다. API의 tick은 정밀도 손실을 막기 위해 문자열로 반환한다.
|
유지보수 구간에서 적용 시간을 확인한다. API의 tick은 정밀도 손실을 막기 위해 문자열로 반환한다. 월별 결정 인덱스 교체도 기존 결정 기록이 많으면 인덱스 생성 시간과 잠금을 업데이트 구간에 고려한다.
|
||||||
|
|
||||||
확인은 프로필 감사 진입 → 현재 장수/도시 → 초기 표본 → 정책/외교 기준 → 다음 정상
|
확인은 프로필 감사 진입 → 현재 장수/도시 → 초기 표본 → 정책/외교 기준 → 다음 정상
|
||||||
월 경계 후 월말 표본 순서로 한다. 조기 수집 구간의 정산 coverage가 부분일 수 있으므로
|
월 경계 후 월말 표본 순서로 한다. 조기 수집 구간의 정산 coverage가 부분일 수 있으므로
|
||||||
@@ -88,7 +90,7 @@ Gateway의 새 진입/권한 catalog도 사용하려면 같은 commit의 Gateway
|
|||||||
현재 감사 API도 사용할 수 없다.** 취소 후 다음 초기화까지 읽는 수명주기는 후속 작업이다.
|
현재 감사 API도 사용할 수 없다.** 취소 후 다음 초기화까지 읽는 수명주기는 후속 작업이다.
|
||||||
- NPC/유저 자동턴의 개인·수뇌 절차, 정책 차단, RNG utility 결과와 최종 실행 결과는
|
- NPC/유저 자동턴의 개인·수뇌 절차, 정책 차단, RNG utility 결과와 최종 실행 결과는
|
||||||
migration 이후 새 실행부터 저장한다. 후보 내부 조건 전체는 아직 없으며 `PROCEDURES`
|
migration 이후 새 실행부터 저장한다. 후보 내부 조건 전체는 아직 없으며 `PROCEDURES`
|
||||||
coverage로 구분한다. 전용 조회 화면은 후속 작업이다. 과거 결정은 역산하지 않는다.
|
coverage로 구분한다. 장수 상세의 **NPC 결정 기록 조회**에서 선택 월의 목록과 순서별 상세를 읽는다. 과거 결정은 역산하지 않는다.
|
||||||
- 결정은 당시 확보된 정책 참조를 보존한다. 합성된 유효 정책 상세와 코드 버전 연결은
|
- 결정은 당시 확보된 정책 참조를 보존한다. 합성된 유효 정책 상세와 코드 버전 연결은
|
||||||
아직 미완성이다. 코드 버전이 주입되지 않은 실행은 null로 남기며 현재 버전으로 메우지 않는다.
|
아직 미완성이다. 코드 버전이 주입되지 않은 실행은 null로 남기며 현재 버전으로 메우지 않는다.
|
||||||
- 계정/IP HMAC 조사, 상세 자원 이동, 예약 변경/실행 연결, 실패·rollback 조사와 알려진
|
- 계정/IP HMAC 조사, 상세 자원 이동, 예약 변경/실행 연결, 실패·rollback 조사와 알려진
|
||||||
|
|||||||
@@ -1222,7 +1222,7 @@ model PlayAuditDecision {
|
|||||||
chunks PlayAuditDecisionChunk[]
|
chunks PlayAuditDecisionChunk[]
|
||||||
|
|
||||||
@@unique([executionId, phase])
|
@@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])
|
@@index([serverId, nationId, tick, id])
|
||||||
@@map("play_audit_decision")
|
@@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,
|
"formatVersion": 1,
|
||||||
"controllerProtocol": 2,
|
"controllerProtocol": 2,
|
||||||
"gatewaySchemaHead": "20260825000000_add_bulk_release_batches",
|
"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"]
|
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user