feat: 외교 문서 변경을 입력 원장과 함께 감사 이력으로 저장
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import type { ApiInputExecutionContext } from './inputEventBoundary.js';
|
||||||
import type { ChangeJournal } from '@sammo-ts/common';
|
import type { ChangeJournal } from '@sammo-ts/common';
|
||||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
import type { DatabaseClient as InfraDatabaseClient, RedisConnector, GamePrisma } from '@sammo-ts/infra';
|
import type { DatabaseClient as InfraDatabaseClient, RedisConnector, GamePrisma } from '@sammo-ts/infra';
|
||||||
@@ -99,6 +100,8 @@ export type InputJsonValue = GamePrisma.InputJsonValue;
|
|||||||
export type DatabaseClient = InfraDatabaseClient;
|
export type DatabaseClient = InfraDatabaseClient;
|
||||||
|
|
||||||
export interface GameApiContext {
|
export interface GameApiContext {
|
||||||
|
/** 현재 API 업무 transaction이 잠근 입력 원장의 인증된 식별자다. */
|
||||||
|
auditInput?: ApiInputExecutionContext;
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
generalAccessTracking?: boolean;
|
generalAccessTracking?: boolean;
|
||||||
/** Validated server-issued proof for one realtime refresh burst. */
|
/** Validated server-issued proof for one realtime refresh burst. */
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface ApiInputPayloadIdentity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface LockedInputEvent {
|
interface LockedInputEvent {
|
||||||
|
sequence: bigint;
|
||||||
target: 'API' | 'ENGINE';
|
target: 'API' | 'ENGINE';
|
||||||
eventType: string;
|
eventType: string;
|
||||||
payload: GamePrisma.JsonValue;
|
payload: GamePrisma.JsonValue;
|
||||||
@@ -27,6 +28,14 @@ interface LockedInputEvent {
|
|||||||
attempts: number;
|
attempts: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ApiInputExecutionContext {
|
||||||
|
requestId: string;
|
||||||
|
sequence: bigint;
|
||||||
|
actorUserId: string | null;
|
||||||
|
eventType: string;
|
||||||
|
attempt: number;
|
||||||
|
}
|
||||||
|
|
||||||
type InputEventOutcome<T> =
|
type InputEventOutcome<T> =
|
||||||
{ kind: 'executed'; value: T } | { kind: 'replayed'; value: T } | { kind: 'failed'; error: unknown };
|
{ kind: 'executed'; value: T } | { kind: 'replayed'; value: T } | { kind: 'failed'; error: unknown };
|
||||||
|
|
||||||
@@ -115,6 +124,7 @@ const lockInputEvent = async (db: DatabaseClient, requestId: string): Promise<Lo
|
|||||||
const rows = await db.$queryRaw<LockedInputEvent[]>(
|
const rows = await db.$queryRaw<LockedInputEvent[]>(
|
||||||
GamePrisma.sql`
|
GamePrisma.sql`
|
||||||
SELECT
|
SELECT
|
||||||
|
sequence,
|
||||||
target,
|
target,
|
||||||
event_type AS "eventType",
|
event_type AS "eventType",
|
||||||
payload,
|
payload,
|
||||||
@@ -235,7 +245,7 @@ export const executeInputEvent = async <T>(options: {
|
|||||||
payload: unknown;
|
payload: unknown;
|
||||||
actorUserId?: string | null;
|
actorUserId?: string | null;
|
||||||
acquireClockFence?: boolean;
|
acquireClockFence?: boolean;
|
||||||
execute(db: DatabaseClient): Promise<T>;
|
execute(db: DatabaseClient, context?: ApiInputExecutionContext): Promise<T>;
|
||||||
}): Promise<T> => {
|
}): Promise<T> => {
|
||||||
const { db, requestId, eventType, payload, execute } = options;
|
const { db, requestId, eventType, payload, execute } = options;
|
||||||
const actorUserId = options.actorUserId ?? null;
|
const actorUserId = options.actorUserId ?? null;
|
||||||
@@ -275,7 +285,13 @@ export const executeInputEvent = async <T>(options: {
|
|||||||
await savepointDb.$executeRawUnsafe(`SAVEPOINT ${BUSINESS_SAVEPOINT}`);
|
await savepointDb.$executeRawUnsafe(`SAVEPOINT ${BUSINESS_SAVEPOINT}`);
|
||||||
businessStarted = true;
|
businessStarted = true;
|
||||||
try {
|
try {
|
||||||
const value = await execute(transaction);
|
const value = await execute(transaction, {
|
||||||
|
requestId,
|
||||||
|
sequence: row.sequence,
|
||||||
|
actorUserId,
|
||||||
|
eventType,
|
||||||
|
attempt: row.attempts + 1,
|
||||||
|
});
|
||||||
const durableResult = canonicalJsonValue(value);
|
const durableResult = canonicalJsonValue(value);
|
||||||
await transaction.$executeRaw(GamePrisma.sql`
|
await transaction.$executeRaw(GamePrisma.sql`
|
||||||
UPDATE input_event
|
UPDATE input_event
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import type { GameApiContext, GeneralRow, NationRow } from '../../context.js';
|
import type { GameApiContext, GeneralRow, NationRow } from '../../context.js';
|
||||||
import { insertMessage } from '../../messages/store.js';
|
import { insertMessage } from '../../messages/store.js';
|
||||||
import { purifyDiplomacyHtml } from '../../security/diplomacyHtml.js';
|
import { purifyDiplomacyHtml } from '../../security/diplomacyHtml.js';
|
||||||
|
import { createDiplomacyDocumentAudit, projectAuditDocumentState } from '../../services/playAuditDiplomacy.js';
|
||||||
import { readDatabaseWallTime } from '../../services/wallClock.js';
|
import { readDatabaseWallTime } from '../../services/wallClock.js';
|
||||||
import { accessAuthedInputProcedure, accessAuthedProcedure, router } from '../../trpc.js';
|
import { accessAuthedInputProcedure, accessAuthedProcedure, router } from '../../trpc.js';
|
||||||
import { getMyGeneral } from '../shared/general.js';
|
import { getMyGeneral } from '../shared/general.js';
|
||||||
@@ -216,6 +217,7 @@ export const diplomacyRouter = router({
|
|||||||
assertNationAccess(me);
|
assertNationAccess(me);
|
||||||
|
|
||||||
const permission = await resolvePermissionLevel(ctx, me.nationId);
|
const permission = await resolvePermissionLevel(ctx, me.nationId);
|
||||||
|
const audit = createDiplomacyDocumentAudit(ctx, me, permission);
|
||||||
if (permission < 4) {
|
if (permission < 4) {
|
||||||
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||||
}
|
}
|
||||||
@@ -262,20 +264,21 @@ export const diplomacyRouter = router({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (prevLetter.state === 'PROPOSED') {
|
if (prevLetter.state === 'PROPOSED') {
|
||||||
|
const before = projectAuditDocumentState(prevLetter);
|
||||||
const aux = asRecord(prevLetter.aux);
|
const aux = asRecord(prevLetter.aux);
|
||||||
aux.reason = {
|
aux.reason = {
|
||||||
who: me.id,
|
who: me.id,
|
||||||
action: 'new_letter',
|
action: 'new_letter',
|
||||||
reason: 'new_letter',
|
reason: 'new_letter',
|
||||||
};
|
};
|
||||||
await ctx.db.diplomacyLetter.update({
|
const updated = await ctx.db.diplomacyLetter.update({
|
||||||
where: { id: prevId },
|
where: { id: prevId },
|
||||||
data: { state: 'REPLACED', aux: aux as GamePrisma.InputJsonValue },
|
data: { state: 'REPLACED', aux: aux as GamePrisma.InputJsonValue },
|
||||||
});
|
});
|
||||||
|
audit.record(updated, before, 'LETTER_REPLACED');
|
||||||
}
|
}
|
||||||
|
|
||||||
destNationId =
|
destNationId = prevLetter.srcNationId === me.nationId ? prevLetter.destNationId : prevLetter.srcNationId;
|
||||||
prevLetter.srcNationId === me.nationId ? prevLetter.destNationId : prevLetter.srcNationId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const nations = await ctx.db.nation.findMany({
|
const nations = await ctx.db.nation.findMany({
|
||||||
@@ -320,6 +323,7 @@ export const diplomacyRouter = router({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
audit.record(created, null, 'LETTER_PROPOSED');
|
||||||
const letterIdText = String(created.id);
|
const letterIdText = String(created.id);
|
||||||
const josaYi = JosaUtil.pick(letterIdText, '이');
|
const josaYi = JosaUtil.pick(letterIdText, '이');
|
||||||
const text = prevId
|
const text = prevId
|
||||||
@@ -333,6 +337,7 @@ export const diplomacyRouter = router({
|
|||||||
time: created.date,
|
time: created.date,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await audit.flush();
|
||||||
return { id: created.id };
|
return { id: created.id };
|
||||||
}),
|
}),
|
||||||
respondLetter: accessAuthedInputProcedure(
|
respondLetter: accessAuthedInputProcedure(
|
||||||
@@ -347,6 +352,7 @@ export const diplomacyRouter = router({
|
|||||||
assertNationAccess(me);
|
assertNationAccess(me);
|
||||||
|
|
||||||
const permission = await resolvePermissionLevel(ctx, me.nationId);
|
const permission = await resolvePermissionLevel(ctx, me.nationId);
|
||||||
|
const audit = createDiplomacyDocumentAudit(ctx, me, permission);
|
||||||
if (permission < 4) {
|
if (permission < 4) {
|
||||||
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||||
}
|
}
|
||||||
@@ -362,14 +368,11 @@ export const diplomacyRouter = router({
|
|||||||
throw new TRPCError({ code: 'NOT_FOUND', message: '서신이 없습니다.' });
|
throw new TRPCError({ code: 'NOT_FOUND', message: '서신이 없습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { srcNation, destNation } = await loadLetterNations(
|
const { srcNation, destNation } = await loadLetterNations(ctx, letter.srcNationId, letter.destNationId);
|
||||||
ctx,
|
|
||||||
letter.srcNationId,
|
|
||||||
letter.destNationId
|
|
||||||
);
|
|
||||||
const messageSrc = buildActorTarget(me, destNation);
|
const messageSrc = buildActorTarget(me, destNation);
|
||||||
const messageDest = buildNationTarget(srcNation);
|
const messageDest = buildNationTarget(srcNation);
|
||||||
const messageTime = await readDatabaseWallTime(ctx.db);
|
const messageTime = await readDatabaseWallTime(ctx.db);
|
||||||
|
const before = projectAuditDocumentState(letter);
|
||||||
const aux = asRecord(letter.aux);
|
const aux = asRecord(letter.aux);
|
||||||
let messageText: string;
|
let messageText: string;
|
||||||
if (input.agree) {
|
if (input.agree) {
|
||||||
@@ -379,7 +382,7 @@ export const diplomacyRouter = router({
|
|||||||
dest.generalIcon = messageSrc.icon;
|
dest.generalIcon = messageSrc.icon;
|
||||||
aux.dest = dest;
|
aux.dest = dest;
|
||||||
|
|
||||||
await ctx.db.diplomacyLetter.update({
|
const updated = await ctx.db.diplomacyLetter.update({
|
||||||
where: { id: letter.id },
|
where: { id: letter.id },
|
||||||
data: {
|
data: {
|
||||||
state: 'ACTIVATED',
|
state: 'ACTIVATED',
|
||||||
@@ -388,16 +391,20 @@ export const diplomacyRouter = router({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
audit.record(updated, before, 'LETTER_ACCEPTED');
|
||||||
let prevId = letter.prevId;
|
let prevId = letter.prevId;
|
||||||
while (prevId) {
|
while (prevId) {
|
||||||
const prevLetter = await ctx.db.diplomacyLetter.findFirst({ where: { id: prevId } });
|
const prevLetter = await ctx.db.diplomacyLetter.findFirst({ where: { id: prevId } });
|
||||||
if (!prevLetter) {
|
if (!prevLetter) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
await ctx.db.diplomacyLetter.update({
|
const updated = await ctx.db.diplomacyLetter.update({
|
||||||
where: { id: prevId },
|
where: { id: prevId },
|
||||||
data: { state: 'REPLACED' },
|
data: { state: 'REPLACED' },
|
||||||
});
|
});
|
||||||
|
if (prevLetter.state !== 'REPLACED') {
|
||||||
|
audit.record(updated, projectAuditDocumentState(prevLetter), 'LETTER_REPLACED');
|
||||||
|
}
|
||||||
prevId = prevLetter.prevId;
|
prevId = prevLetter.prevId;
|
||||||
}
|
}
|
||||||
messageText = `외교 서신( #${letter.id})이 승인되었습니다.`;
|
messageText = `외교 서신( #${letter.id})이 승인되었습니다.`;
|
||||||
@@ -407,10 +414,11 @@ export const diplomacyRouter = router({
|
|||||||
action: 'disagree',
|
action: 'disagree',
|
||||||
reason: input.reason ?? '',
|
reason: input.reason ?? '',
|
||||||
};
|
};
|
||||||
await ctx.db.diplomacyLetter.update({
|
const updated = await ctx.db.diplomacyLetter.update({
|
||||||
where: { id: letter.id },
|
where: { id: letter.id },
|
||||||
data: { state: 'CANCELLED', aux: aux as GamePrisma.InputJsonValue },
|
data: { state: 'CANCELLED', aux: aux as GamePrisma.InputJsonValue },
|
||||||
});
|
});
|
||||||
|
audit.record(updated, before, 'LETTER_REJECTED');
|
||||||
messageText = `외교 서신(#${letter.id})이 거부되었습니다.`;
|
messageText = `외교 서신(#${letter.id})이 거부되었습니다.`;
|
||||||
if (input.reason && input.reason !== '0') {
|
if (input.reason && input.reason !== '0') {
|
||||||
messageText += ` 이유 : ${input.reason}`;
|
messageText += ` 이유 : ${input.reason}`;
|
||||||
@@ -426,14 +434,16 @@ export const diplomacyRouter = router({
|
|||||||
includeNational: true,
|
includeNational: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await audit.flush();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}),
|
}),
|
||||||
rollbackLetter: accessAuthedInputProcedure(z.object({ letterId: z.number().int().positive() }))
|
rollbackLetter: accessAuthedInputProcedure(z.object({ letterId: z.number().int().positive() })).mutation(
|
||||||
.mutation(async ({ ctx, input }) => {
|
async ({ ctx, input }) => {
|
||||||
const me = await getMyGeneral(ctx);
|
const me = await getMyGeneral(ctx);
|
||||||
assertNationAccess(me);
|
assertNationAccess(me);
|
||||||
|
|
||||||
const permission = await resolvePermissionLevel(ctx, me.nationId);
|
const permission = await resolvePermissionLevel(ctx, me.nationId);
|
||||||
|
const audit = createDiplomacyDocumentAudit(ctx, me, permission);
|
||||||
if (permission < 4) {
|
if (permission < 4) {
|
||||||
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||||
}
|
}
|
||||||
@@ -449,14 +459,11 @@ export const diplomacyRouter = router({
|
|||||||
throw new TRPCError({ code: 'NOT_FOUND', message: '서신이 없습니다.' });
|
throw new TRPCError({ code: 'NOT_FOUND', message: '서신이 없습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { srcNation, destNation } = await loadLetterNations(
|
const { srcNation, destNation } = await loadLetterNations(ctx, letter.srcNationId, letter.destNationId);
|
||||||
ctx,
|
|
||||||
letter.srcNationId,
|
|
||||||
letter.destNationId
|
|
||||||
);
|
|
||||||
const messageSrc = buildActorTarget(me, srcNation);
|
const messageSrc = buildActorTarget(me, srcNation);
|
||||||
const messageDest = buildNationTarget(destNation);
|
const messageDest = buildNationTarget(destNation);
|
||||||
const messageTime = await readDatabaseWallTime(ctx.db);
|
const messageTime = await readDatabaseWallTime(ctx.db);
|
||||||
|
const before = projectAuditDocumentState(letter);
|
||||||
const aux = asRecord(letter.aux);
|
const aux = asRecord(letter.aux);
|
||||||
aux.reason = {
|
aux.reason = {
|
||||||
who: me.id,
|
who: me.id,
|
||||||
@@ -464,11 +471,12 @@ export const diplomacyRouter = router({
|
|||||||
reason: '회수',
|
reason: '회수',
|
||||||
};
|
};
|
||||||
|
|
||||||
await ctx.db.diplomacyLetter.update({
|
const updated = await ctx.db.diplomacyLetter.update({
|
||||||
where: { id: letter.id },
|
where: { id: letter.id },
|
||||||
data: { state: 'CANCELLED', aux: aux as GamePrisma.InputJsonValue },
|
data: { state: 'CANCELLED', aux: aux as GamePrisma.InputJsonValue },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
audit.record(updated, before, 'LETTER_WITHDRAWN');
|
||||||
await sendDocumentNotice({
|
await sendDocumentNotice({
|
||||||
ctx,
|
ctx,
|
||||||
src: messageSrc,
|
src: messageSrc,
|
||||||
@@ -477,14 +485,17 @@ export const diplomacyRouter = router({
|
|||||||
time: messageTime,
|
time: messageTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await audit.flush();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}),
|
}
|
||||||
destroyLetter: accessAuthedInputProcedure(z.object({ letterId: z.number().int().positive() }))
|
),
|
||||||
.mutation(async ({ ctx, input }) => {
|
destroyLetter: accessAuthedInputProcedure(z.object({ letterId: z.number().int().positive() })).mutation(
|
||||||
|
async ({ ctx, input }) => {
|
||||||
const me = await getMyGeneral(ctx);
|
const me = await getMyGeneral(ctx);
|
||||||
assertNationAccess(me);
|
assertNationAccess(me);
|
||||||
|
|
||||||
const permission = await resolvePermissionLevel(ctx, me.nationId);
|
const permission = await resolvePermissionLevel(ctx, me.nationId);
|
||||||
|
const audit = createDiplomacyDocumentAudit(ctx, me, permission);
|
||||||
if (permission < 4) {
|
if (permission < 4) {
|
||||||
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||||
}
|
}
|
||||||
@@ -500,6 +511,7 @@ export const diplomacyRouter = router({
|
|||||||
throw new TRPCError({ code: 'NOT_FOUND', message: '서신이 없습니다.' });
|
throw new TRPCError({ code: 'NOT_FOUND', message: '서신이 없습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const before = projectAuditDocumentState(letter);
|
||||||
const aux = asRecord(letter.aux);
|
const aux = asRecord(letter.aux);
|
||||||
const stateOpt = typeof aux.state_opt === 'string' ? aux.state_opt : null;
|
const stateOpt = typeof aux.state_opt === 'string' ? aux.state_opt : null;
|
||||||
const myStateOpt = letter.srcNationId === me.nationId ? 'try_destroy_src' : 'try_destroy_dest';
|
const myStateOpt = letter.srcNationId === me.nationId ? 'try_destroy_src' : 'try_destroy_dest';
|
||||||
@@ -508,11 +520,7 @@ export const diplomacyRouter = router({
|
|||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 파기 신청을 했습니다.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 파기 신청을 했습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { srcNation, destNation } = await loadLetterNations(
|
const { srcNation, destNation } = await loadLetterNations(ctx, letter.srcNationId, letter.destNationId);
|
||||||
ctx,
|
|
||||||
letter.srcNationId,
|
|
||||||
letter.destNationId
|
|
||||||
);
|
|
||||||
const actorNation = letter.srcNationId === me.nationId ? srcNation : destNation;
|
const actorNation = letter.srcNationId === me.nationId ? srcNation : destNation;
|
||||||
const otherNation = letter.srcNationId === me.nationId ? destNation : srcNation;
|
const otherNation = letter.srcNationId === me.nationId ? destNation : srcNation;
|
||||||
const messageSrc = buildActorTarget(me, actorNation);
|
const messageSrc = buildActorTarget(me, actorNation);
|
||||||
@@ -522,18 +530,20 @@ export const diplomacyRouter = router({
|
|||||||
let messageText: string;
|
let messageText: string;
|
||||||
|
|
||||||
if (stateOpt && stateOpt !== myStateOpt) {
|
if (stateOpt && stateOpt !== myStateOpt) {
|
||||||
await ctx.db.diplomacyLetter.update({
|
const updated = await ctx.db.diplomacyLetter.update({
|
||||||
where: { id: letter.id },
|
where: { id: letter.id },
|
||||||
data: { state: 'CANCELLED', aux: aux as GamePrisma.InputJsonValue },
|
data: { state: 'CANCELLED', aux: aux as GamePrisma.InputJsonValue },
|
||||||
});
|
});
|
||||||
|
audit.record(updated, before, 'LETTER_DESTROYED');
|
||||||
resultState = 'CANCELLED';
|
resultState = 'CANCELLED';
|
||||||
messageText = `외교 서신(#${letter.id})을 파기했습니다.`;
|
messageText = `외교 서신(#${letter.id})을 파기했습니다.`;
|
||||||
} else {
|
} else {
|
||||||
aux.state_opt = myStateOpt;
|
aux.state_opt = myStateOpt;
|
||||||
await ctx.db.diplomacyLetter.update({
|
const updated = await ctx.db.diplomacyLetter.update({
|
||||||
where: { id: letter.id },
|
where: { id: letter.id },
|
||||||
data: { aux: aux as GamePrisma.InputJsonValue },
|
data: { aux: aux as GamePrisma.InputJsonValue },
|
||||||
});
|
});
|
||||||
|
audit.record(updated, before, 'LETTER_DESTROY_REQUESTED');
|
||||||
resultState = 'ACTIVATED';
|
resultState = 'ACTIVATED';
|
||||||
messageText = `외교 서신(#${letter.id})을 파기 요청합니다.`;
|
messageText = `외교 서신(#${letter.id})을 파기 요청합니다.`;
|
||||||
}
|
}
|
||||||
@@ -545,6 +555,8 @@ export const diplomacyRouter = router({
|
|||||||
text: messageText,
|
text: messageText,
|
||||||
time: messageTime,
|
time: messageTime,
|
||||||
});
|
});
|
||||||
|
await audit.flush();
|
||||||
return { state: resultState };
|
return { state: resultState };
|
||||||
}),
|
}
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { asRecord, GameClock, inferClockPhase, parseGameClockPhase, readTurnRecovery } from '@sammo-ts/common';
|
||||||
|
import {
|
||||||
|
GamePrisma,
|
||||||
|
hashAuditDiplomacyDocument,
|
||||||
|
persistAuditDiplomacyEvents,
|
||||||
|
readTurnRuntimeReady,
|
||||||
|
type AuditDiplomacyEventDraft,
|
||||||
|
} from '@sammo-ts/infra';
|
||||||
|
import type { GameApiContext, GeneralRow } from '../context.js';
|
||||||
|
|
||||||
|
type Letter = GamePrisma.DiplomacyLetterGetPayload<Record<string, never>>;
|
||||||
|
type DocumentAction =
|
||||||
|
| 'LETTER_PROPOSED'
|
||||||
|
| 'LETTER_REPLACED'
|
||||||
|
| 'LETTER_ACCEPTED'
|
||||||
|
| 'LETTER_REJECTED'
|
||||||
|
| 'LETTER_WITHDRAWN'
|
||||||
|
| 'LETTER_DESTROY_REQUESTED'
|
||||||
|
| 'LETTER_DESTROYED';
|
||||||
|
|
||||||
|
export const projectAuditDocumentState = (letter: Letter): Record<string, unknown> => {
|
||||||
|
const aux = asRecord(letter.aux);
|
||||||
|
const src = asRecord(aux.src);
|
||||||
|
const dest = asRecord(aux.dest);
|
||||||
|
const reason = asRecord(aux.reason);
|
||||||
|
return {
|
||||||
|
state: letter.state,
|
||||||
|
srcSignerId: letter.srcSignerId,
|
||||||
|
destSignerId: letter.destSignerId,
|
||||||
|
srcNationName: typeof src.nationName === 'string' ? src.nationName : null,
|
||||||
|
destNationName: typeof dest.nationName === 'string' ? dest.nationName : null,
|
||||||
|
srcSignerName: typeof src.generalName === 'string' ? src.generalName : null,
|
||||||
|
destSignerName: typeof dest.generalName === 'string' ? dest.generalName : null,
|
||||||
|
stateOption: typeof aux.state_opt === 'string' ? aux.state_opt : null,
|
||||||
|
reason: typeof reason.reason === 'string' ? reason.reason : null,
|
||||||
|
reasonAction: typeof reason.action === 'string' ? reason.action : null,
|
||||||
|
reasonActorId: typeof reason.who === 'number' ? reason.who : null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AuditCoordinateRow {
|
||||||
|
serverId: string | null;
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
wallNow: Date;
|
||||||
|
clockBaseTime: Date | null;
|
||||||
|
clockTick: bigint | null;
|
||||||
|
clockMode: string;
|
||||||
|
clockWallAnchor: Date | null;
|
||||||
|
clockPhase: string;
|
||||||
|
clockRevision: bigint;
|
||||||
|
clockRecoveryStartTick: bigint | null;
|
||||||
|
clockRecoveryEndTick: bigint | null;
|
||||||
|
clockRecoveryStartWallAt: Date | null;
|
||||||
|
tickSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** API 입력 transaction의 기존 clock fence 안에서 작은 시계/기수 투영만 읽는다. */
|
||||||
|
const readCoordinate = async (ctx: GameApiContext) => {
|
||||||
|
const [row] = await ctx.db.$queryRaw<AuditCoordinateRow[]>(GamePrisma.sql`
|
||||||
|
SELECT meta->>'serverId' AS "serverId", current_year AS year, current_month AS month,
|
||||||
|
CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS "wallNow",
|
||||||
|
clock_base_time AS "clockBaseTime", clock_tick AS "clockTick", clock_mode AS "clockMode",
|
||||||
|
clock_wall_anchor AS "clockWallAnchor", clock_phase AS "clockPhase", clock_revision AS "clockRevision",
|
||||||
|
clock_recovery_start_tick AS "clockRecoveryStartTick", clock_recovery_end_tick AS "clockRecoveryEndTick",
|
||||||
|
clock_recovery_start_wall_at AS "clockRecoveryStartWallAt", tick_seconds AS "tickSeconds"
|
||||||
|
FROM world_state ORDER BY id LIMIT 1
|
||||||
|
`);
|
||||||
|
if (!row?.serverId?.trim()) return null;
|
||||||
|
let tick: bigint | null = null;
|
||||||
|
if (row.clockBaseTime && row.clockTick !== null && row.clockWallAnchor) {
|
||||||
|
const clockTick = Number(row.clockTick);
|
||||||
|
const revision = Number(row.clockRevision);
|
||||||
|
if (!Number.isSafeInteger(clockTick) || !Number.isSafeInteger(revision))
|
||||||
|
throw new Error('Play audit diplomacy clock outside safe integer range');
|
||||||
|
const mode = row.clockMode === 'manual' ? 'manual' : 'realtime';
|
||||||
|
const phase = row.clockPhase ? parseGameClockPhase(row.clockPhase) : inferClockPhase(mode);
|
||||||
|
const clock = new GameClock({
|
||||||
|
baseTime: row.clockBaseTime,
|
||||||
|
tick: clockTick,
|
||||||
|
mode,
|
||||||
|
wallAnchor: row.clockWallAnchor,
|
||||||
|
recovery: readTurnRecovery(row),
|
||||||
|
turnSeconds: row.tickSeconds,
|
||||||
|
phase,
|
||||||
|
revision,
|
||||||
|
});
|
||||||
|
const ready =
|
||||||
|
phase !== 'RUNNING' || mode !== 'realtime' || (await readTurnRuntimeReady(ctx.db, row.clockRevision));
|
||||||
|
tick = BigInt(ready ? clock.nowTick(row.wallNow) : clock.tick);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
serverId: row.serverId,
|
||||||
|
year: row.year,
|
||||||
|
month: row.month,
|
||||||
|
tick,
|
||||||
|
clockRevision: row.clockRevision,
|
||||||
|
wallAt: row.wallNow,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createDiplomacyDocumentAudit = (ctx: GameApiContext, actor: GeneralRow, permission: number) => {
|
||||||
|
const changes: {
|
||||||
|
letter: Letter;
|
||||||
|
before: Record<string, unknown> | null;
|
||||||
|
after: Record<string, unknown>;
|
||||||
|
eventType: DocumentAction;
|
||||||
|
}[] = [];
|
||||||
|
return {
|
||||||
|
record: (letter: Letter, before: Record<string, unknown> | null, eventType: DocumentAction) => {
|
||||||
|
if (!ctx.auditInput) return;
|
||||||
|
changes.push({ letter, before, after: projectAuditDocumentState(letter), eventType });
|
||||||
|
},
|
||||||
|
flush: async (): Promise<void> => {
|
||||||
|
// 무transaction legacy unit fixture에는 입력 원장 identity를 꾸며 넣지 않는다.
|
||||||
|
const input = ctx.auditInput;
|
||||||
|
if (!input || !changes.length) return;
|
||||||
|
if (input.actorUserId !== actor.userId || input.actorUserId !== ctx.auth?.user.id)
|
||||||
|
throw new Error('Play audit diplomacy actor mismatch');
|
||||||
|
const coordinate = await readCoordinate(ctx);
|
||||||
|
if (!coordinate) return;
|
||||||
|
const events: AuditDiplomacyEventDraft[] = changes.map((change, index) => ({
|
||||||
|
schemaVersion: 1,
|
||||||
|
serverId: coordinate.serverId,
|
||||||
|
srcNationId: change.letter.srcNationId,
|
||||||
|
destNationId: change.letter.destNationId,
|
||||||
|
category: 'DOCUMENT',
|
||||||
|
source: 'API',
|
||||||
|
eventType: change.eventType,
|
||||||
|
documentId: change.letter.id,
|
||||||
|
documentHash: hashAuditDiplomacyDocument(change.letter),
|
||||||
|
previousDocumentId: change.letter.prevId,
|
||||||
|
year: coordinate.year,
|
||||||
|
month: coordinate.month,
|
||||||
|
tick: coordinate.tick,
|
||||||
|
clockRevision: coordinate.clockRevision,
|
||||||
|
executionId: `api:${input.requestId}`,
|
||||||
|
ordinal: index + 1,
|
||||||
|
requestId: input.requestId,
|
||||||
|
inputSequence: input.sequence,
|
||||||
|
actor: {
|
||||||
|
userId: actor.userId,
|
||||||
|
generalId: actor.id,
|
||||||
|
name: actor.name,
|
||||||
|
nationId: actor.nationId,
|
||||||
|
officerLevel: actor.officerLevel,
|
||||||
|
npcState: actor.npcState,
|
||||||
|
permission,
|
||||||
|
},
|
||||||
|
before: change.before,
|
||||||
|
after: change.after,
|
||||||
|
}));
|
||||||
|
await persistAuditDiplomacyEvents(ctx.db, events);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -81,11 +81,12 @@ const createInputEventMiddleware = (acquireClockFence: boolean) =>
|
|||||||
payload,
|
payload,
|
||||||
actorUserId: ctx.auth?.user.id,
|
actorUserId: ctx.auth?.user.id,
|
||||||
acquireClockFence,
|
acquireClockFence,
|
||||||
execute: async (transaction) => {
|
execute: async (transaction, auditInput) => {
|
||||||
const result = await next({
|
const result = await next({
|
||||||
ctx: {
|
ctx: {
|
||||||
...ctx,
|
...ctx,
|
||||||
db: transaction,
|
db: transaction,
|
||||||
|
auditInput,
|
||||||
changeJournal,
|
changeJournal,
|
||||||
turnDaemon: new IdempotentTurnDaemonTransport(ctx.turnDaemon, requestId),
|
turnDaemon: new IdempotentTurnDaemonTransport(ctx.turnDaemon, requestId),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { JosaUtil, MAX_SAFE_GAME_TICK } from '@sammo-ts/common';
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
import { createGamePostgresConnector, type GamePrismaClient, type RedisConnector } from '@sammo-ts/infra';
|
import { createGamePostgresConnector, type GamePrismaClient, type RedisConnector } from '@sammo-ts/infra';
|
||||||
import {
|
import {
|
||||||
@@ -15,7 +15,7 @@ import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
|||||||
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
|
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
|
||||||
import type { GameApiContext } from '../src/context.js';
|
import type { GameApiContext } from '../src/context.js';
|
||||||
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
|
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
|
||||||
import { fetchMessagesFromMailbox, invalidateMessages } from '../src/messages/store.js';
|
import { fetchMessagesFromMailbox, tombstoneMessages } from '../src/messages/store.js';
|
||||||
import { appRouter } from '../src/router.js';
|
import { appRouter } from '../src/router.js';
|
||||||
|
|
||||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||||
@@ -82,6 +82,7 @@ integration('diplomacy document message persistence', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const cleanupRouteState = async (): Promise<void> => {
|
const cleanupRouteState = async (): Promise<void> => {
|
||||||
|
await db.playAuditDiplomacyEvent.deleteMany({ where: { serverId: requestPrefix } });
|
||||||
await db.message.deleteMany({ where: { mailbox: { in: [...fixtureMailboxes] } } });
|
await db.message.deleteMany({ where: { mailbox: { in: [...fixtureMailboxes] } } });
|
||||||
await db.diplomacyLetter.deleteMany({
|
await db.diplomacyLetter.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -231,8 +232,9 @@ integration('diplomacy document message persistence', () => {
|
|||||||
type,
|
type,
|
||||||
src: srcMailbox,
|
src: srcMailbox,
|
||||||
dest: destMailbox,
|
dest: destMailbox,
|
||||||
time: logicalGameTime,
|
time: expect.any(Date),
|
||||||
timeTick: logicalGameTick,
|
timeTick: null,
|
||||||
|
occurredGameTick: null,
|
||||||
});
|
});
|
||||||
expect(payload).toMatchObject({
|
expect(payload).toMatchObject({
|
||||||
src: options.src,
|
src: options.src,
|
||||||
@@ -295,7 +297,7 @@ integration('diplomacy document message persistence', () => {
|
|||||||
clockMode: 'manual',
|
clockMode: 'manual',
|
||||||
clockWallAnchor: new Date('2026-08-24T00:00:00.000Z'),
|
clockWallAnchor: new Date('2026-08-24T00:00:00.000Z'),
|
||||||
config: {},
|
config: {},
|
||||||
meta: {},
|
meta: { serverId: requestPrefix },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await db.nation.createMany({
|
await db.nation.createMany({
|
||||||
@@ -382,7 +384,7 @@ integration('diplomacy document message persistence', () => {
|
|||||||
await expectInputEvent(chainedRequestId, 'sendLetter', fixtureUserId);
|
await expectInputEvent(chainedRequestId, 'sendLetter', fixtureUserId);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps permanent messages readable without a clock and does not resurrect them after invalidation', async () => {
|
it('keeps permanent messages readable without a clock and preserves tombstoned content after clock recovery', async () => {
|
||||||
const created = await appRouter
|
const created = await appRouter
|
||||||
.createCaller(buildContext('legacy-clock-fallback', fixtureAuth))
|
.createCaller(buildContext('legacy-clock-fallback', fixtureAuth))
|
||||||
.diplomacy.sendLetter({
|
.diplomacy.sendLetter({
|
||||||
@@ -395,7 +397,7 @@ integration('diplomacy document message persistence', () => {
|
|||||||
where: { mailbox: receiverMailbox, type: 'diplomacy' },
|
where: { mailbox: receiverMailbox, type: 'diplomacy' },
|
||||||
orderBy: { id: 'desc' },
|
orderBy: { id: 'desc' },
|
||||||
});
|
});
|
||||||
expect(receiver.validUntilTick).toBe(BigInt(MAX_SAFE_GAME_TICK));
|
expect(receiver.validUntilTick).toBeNull();
|
||||||
|
|
||||||
await db.worldState.update({
|
await db.worldState.update({
|
||||||
where: { id: fixtureWorldStateId },
|
where: { id: fixtureWorldStateId },
|
||||||
@@ -416,10 +418,10 @@ integration('diplomacy document message persistence', () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
await invalidateMessages(db, [receiver.id]);
|
await tombstoneMessages(db, [receiver.id]);
|
||||||
await expect(
|
await expect(
|
||||||
db.message.findUniqueOrThrow({ where: { id: receiver.id }, select: { validUntilTick: true } })
|
db.message.findUniqueOrThrow({ where: { id: receiver.id }, select: { tombstonedAtWall: true } })
|
||||||
).resolves.toEqual({ validUntilTick: 0n });
|
).resolves.toEqual({ tombstonedAtWall: expect.any(Date) });
|
||||||
await expect(
|
await expect(
|
||||||
fetchMessagesFromMailbox({
|
fetchMessagesFromMailbox({
|
||||||
db,
|
db,
|
||||||
@@ -428,7 +430,7 @@ integration('diplomacy document message persistence', () => {
|
|||||||
limit: 15,
|
limit: 15,
|
||||||
fromSeq: 0,
|
fromSeq: 0,
|
||||||
})
|
})
|
||||||
).resolves.not.toContainEqual(expect.objectContaining({ id: receiver.id }));
|
).resolves.toContainEqual(expect.objectContaining({ id: receiver.id, text: '삭제된 메시지입니다.' }));
|
||||||
} finally {
|
} finally {
|
||||||
await db.worldState.update({
|
await db.worldState.update({
|
||||||
where: { id: fixtureWorldStateId },
|
where: { id: fixtureWorldStateId },
|
||||||
@@ -448,7 +450,7 @@ integration('diplomacy document message persistence', () => {
|
|||||||
limit: 15,
|
limit: 15,
|
||||||
fromSeq: 0,
|
fromSeq: 0,
|
||||||
})
|
})
|
||||||
).resolves.not.toContainEqual(expect.objectContaining({ id: receiver.id }));
|
).resolves.toContainEqual(expect.objectContaining({ id: receiver.id, text: '삭제된 메시지입니다.' }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stores diplomacy and national copies for both approval and rejection responses', async () => {
|
it('stores diplomacy and national copies for both approval and rejection responses', async () => {
|
||||||
@@ -561,6 +563,86 @@ integration('diplomacy document message persistence', () => {
|
|||||||
await expectInputEvent(completeRequestId, 'destroyLetter', foreignUserId);
|
await expectInputEvent(completeRequestId, 'destroyLetter', foreignUserId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('records ordered document transitions once, bound to durable input and immutable content', async () => {
|
||||||
|
const input = { destNationId: foreignNationId, brief: '감사 문서', detail: '불변 본문' };
|
||||||
|
const sender = appRouter.createCaller(buildContext('audit-send', fixtureAuth));
|
||||||
|
const created = await sender.diplomacy.sendLetter(input);
|
||||||
|
await expect(sender.diplomacy.sendLetter(input)).resolves.toEqual(created);
|
||||||
|
await appRouter.createCaller(buildContext('audit-accept', foreignAuth)).diplomacy.respondLetter({
|
||||||
|
letterId: created.id,
|
||||||
|
agree: true,
|
||||||
|
});
|
||||||
|
await appRouter
|
||||||
|
.createCaller(buildContext('audit-destroy-src', fixtureAuth))
|
||||||
|
.diplomacy.destroyLetter({ letterId: created.id });
|
||||||
|
await appRouter
|
||||||
|
.createCaller(buildContext('audit-destroy-dest', foreignAuth))
|
||||||
|
.diplomacy.destroyLetter({ letterId: created.id });
|
||||||
|
const events = await db.playAuditDiplomacyEvent.findMany({
|
||||||
|
where: { serverId: requestPrefix },
|
||||||
|
orderBy: { sequence: 'asc' },
|
||||||
|
});
|
||||||
|
expect(events.map((event) => event.eventType)).toEqual([
|
||||||
|
'LETTER_PROPOSED',
|
||||||
|
'LETTER_ACCEPTED',
|
||||||
|
'LETTER_DESTROY_REQUESTED',
|
||||||
|
'LETTER_DESTROYED',
|
||||||
|
]);
|
||||||
|
expect(events[0]).toMatchObject({
|
||||||
|
year: 208,
|
||||||
|
month: 4,
|
||||||
|
tick: logicalGameTick,
|
||||||
|
documentId: created.id,
|
||||||
|
before: null,
|
||||||
|
actor: { userId: fixtureUserId, generalId: fixtureGeneralId },
|
||||||
|
});
|
||||||
|
expect(events[1]).toMatchObject({
|
||||||
|
before: { state: 'PROPOSED', destSignerId: null },
|
||||||
|
after: { state: 'ACTIVATED', destSignerId: foreignGeneralId },
|
||||||
|
});
|
||||||
|
expect(events[2]).toMatchObject({ before: { stateOption: null }, after: { stateOption: 'try_destroy_src' } });
|
||||||
|
expect(events[3]).toMatchObject({ before: { state: 'ACTIVATED' }, after: { state: 'CANCELLED' } });
|
||||||
|
expect(new Set(events.map((event) => event.documentHash)).size).toBe(1);
|
||||||
|
for (const event of events) {
|
||||||
|
const journal = await db.inputEvent.findUniqueOrThrow({ where: { requestId: event.requestId! } });
|
||||||
|
expect(event.inputSequence).toBe(journal.sequence);
|
||||||
|
expect(JSON.stringify(event.after)).not.toContain('불변 본문');
|
||||||
|
}
|
||||||
|
await expect(
|
||||||
|
db.diplomacyLetter.update({ where: { id: created.id }, data: { textDetail: '변조' } })
|
||||||
|
).rejects.toThrow('Diplomacy document content is immutable');
|
||||||
|
expect((await db.diplomacyLetter.findUniqueOrThrow({ where: { id: created.id } })).textDetail).toBe(
|
||||||
|
'불변 본문'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls back the document and notices if the audit insert fails', async () => {
|
||||||
|
await db.$executeRawUnsafe(`CREATE FUNCTION reject_audit_document_fixture() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN RAISE EXCEPTION 'injected audit insert failure'; END; $$`);
|
||||||
|
await db.$executeRawUnsafe(`CREATE TRIGGER reject_audit_document_fixture BEFORE INSERT ON play_audit_diplomacy_event
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION reject_audit_document_fixture()`);
|
||||||
|
try {
|
||||||
|
await expect(
|
||||||
|
appRouter.createCaller(buildContext('audit-failure', fixtureAuth)).diplomacy.sendLetter({
|
||||||
|
destNationId: foreignNationId,
|
||||||
|
brief: '감사 실패',
|
||||||
|
detail: '롤백',
|
||||||
|
})
|
||||||
|
).rejects.toThrow('injected audit insert failure');
|
||||||
|
expect(await db.diplomacyLetter.count({ where: { textBrief: '감사 실패' } })).toBe(0);
|
||||||
|
expect(await db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).toBe(0);
|
||||||
|
expect(await db.playAuditDiplomacyEvent.count({ where: { serverId: requestPrefix } })).toBe(0);
|
||||||
|
expect(
|
||||||
|
await db.inputEvent.findUniqueOrThrow({
|
||||||
|
where: { requestId: `${requestPrefix}:audit-failure:diplomacy.sendLetter` },
|
||||||
|
})
|
||||||
|
).toMatchObject({ status: 'FAILED', attempts: 1 });
|
||||||
|
} finally {
|
||||||
|
await db.$executeRawUnsafe('DROP TRIGGER reject_audit_document_fixture ON play_audit_diplomacy_event');
|
||||||
|
await db.$executeRawUnsafe('DROP FUNCTION reject_audit_document_fixture()');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('rolls letter and message writes back together while retaining the failed API input event', async () => {
|
it('rolls letter and message writes back together while retaining the failed API input event', async () => {
|
||||||
const failure = new Error('injected diplomacy message transaction rollback');
|
const failure = new Error('injected diplomacy message transaction rollback');
|
||||||
const requestId = 'send-rollback';
|
const requestId = 'send-rollback';
|
||||||
@@ -576,6 +658,7 @@ integration('diplomacy document message persistence', () => {
|
|||||||
|
|
||||||
await expect(db.diplomacyLetter.findFirst({ where: { textBrief: 'rollback 외교문서' } })).resolves.toBeNull();
|
await expect(db.diplomacyLetter.findFirst({ where: { textBrief: 'rollback 외교문서' } })).resolves.toBeNull();
|
||||||
await expect(db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).resolves.toBe(0);
|
await expect(db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).resolves.toBe(0);
|
||||||
|
await expect(db.playAuditDiplomacyEvent.count({ where: { serverId: requestPrefix } })).resolves.toBe(0);
|
||||||
await expect(
|
await expect(
|
||||||
db.readModelRevision.count({
|
db.readModelRevision.count({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -20,6 +20,18 @@ export const prunePreviousAuditBatch = async (
|
|||||||
if (!lock?.locked) return { status: 'busy', deleted: 0 };
|
if (!lock?.locked) return { status: 'busy', deleted: 0 };
|
||||||
const world = await tx.worldState.findFirst({ orderBy: { id: 'asc' }, select: { meta: true } });
|
const world = await tx.worldState.findFirst({ orderBy: { id: 'asc' }, select: { meta: true } });
|
||||||
if (asRecord(world?.meta).serverId !== expectedServerId) return { status: 'identityChanged', deleted: 0 };
|
if (asRecord(world?.meta).serverId !== expectedServerId) return { status: 'identityChanged', deleted: 0 };
|
||||||
|
const diplomacyEvents = await tx.playAuditDiplomacyEvent.findMany({
|
||||||
|
where: { serverId: { not: expectedServerId } },
|
||||||
|
orderBy: { sequence: 'asc' },
|
||||||
|
take: AUDIT_RETENTION_BATCH_SIZE,
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (diplomacyEvents.length) {
|
||||||
|
const deleted = await tx.playAuditDiplomacyEvent.deleteMany({
|
||||||
|
where: { id: { in: diplomacyEvents.map((row) => row.id) } },
|
||||||
|
});
|
||||||
|
return { status: 'progress', deleted: deleted.count };
|
||||||
|
}
|
||||||
const policies = await tx.playAuditPolicy.findMany({
|
const policies = await tx.playAuditPolicy.findMany({
|
||||||
where: { serverId: { not: expectedServerId } },
|
where: { serverId: { not: expectedServerId } },
|
||||||
orderBy: { id: 'asc' },
|
orderBy: { id: 'asc' },
|
||||||
|
|||||||
@@ -215,4 +215,37 @@ integration('bounded previous-season audit retention', () => {
|
|||||||
expect(await prunePreviousAuditBatch(db, 'policy-active')).toEqual({ status: 'complete', deleted: 0 });
|
expect(await prunePreviousAuditBatch(db, 'policy-active')).toEqual({ status: 'complete', deleted: 0 });
|
||||||
expect(await db.playAuditPolicy.findUnique({ where: { id: 'active-policy' } })).not.toBeNull();
|
expect(await db.playAuditPolicy.findUnique({ where: { id: 'active-policy' } })).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
it('prunes diplomacy history in bounded batches without touching the active season', async () => {
|
||||||
|
await db.playAuditMonth.deleteMany();
|
||||||
|
await db.playAuditPolicy.deleteMany();
|
||||||
|
await db.playAuditDiplomacyEvent.deleteMany();
|
||||||
|
await db.worldState.updateMany({ data: { meta: { serverId: 'diplomacy-active' } } });
|
||||||
|
const row = (id: string, serverId: string, ordinal: number) => ({
|
||||||
|
id,
|
||||||
|
serverId,
|
||||||
|
ordinal,
|
||||||
|
executionId: 'fixture',
|
||||||
|
nationA: 1,
|
||||||
|
nationB: 2,
|
||||||
|
srcNationId: 1,
|
||||||
|
destNationId: 2,
|
||||||
|
category: 'DOCUMENT',
|
||||||
|
source: 'API',
|
||||||
|
eventType: 'LETTER_PROPOSED',
|
||||||
|
year: 190,
|
||||||
|
month: 1,
|
||||||
|
hash: id,
|
||||||
|
});
|
||||||
|
await db.playAuditDiplomacyEvent.createMany({
|
||||||
|
data: [
|
||||||
|
...Array.from({ length: 201 }, (_, index) => row(`old-diplomacy-${index}`, 'old-diplomacy', index + 1)),
|
||||||
|
row('active-diplomacy', 'diplomacy-active', 1),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(await prunePreviousAuditBatch(db, 'diplomacy-active')).toEqual({ status: 'progress', deleted: 200 });
|
||||||
|
expect(await prunePreviousAuditBatch(db, 'diplomacy-active')).toEqual({ status: 'progress', deleted: 1 });
|
||||||
|
expect(await prunePreviousAuditBatch(db, 'diplomacy-active')).toEqual({ status: 'complete', deleted: 0 });
|
||||||
|
expect(await db.playAuditDiplomacyEvent.findUnique({ where: { id: 'active-diplomacy' } })).not.toBeNull();
|
||||||
|
await db.playAuditDiplomacyEvent.deleteMany();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"tickPerTurn": 36000000,
|
"tickPerTurn": 36000000,
|
||||||
"policies": ["SHIFT", "KEEP", "REBUILD", "FORBID"],
|
"policies": ["SHIFT", "KEEP", "REBUILD", "FORBID"],
|
||||||
"coveredFields": [
|
"coveredFields": [
|
||||||
|
"play_audit_diplomacy_event.clock_revision",
|
||||||
"input_event.accepted_game_tick",
|
"input_event.accepted_game_tick",
|
||||||
"input_event.accepted_clock_revision",
|
"input_event.accepted_clock_revision",
|
||||||
"input_event.accepted_deadline_generation",
|
"input_event.accepted_deadline_generation",
|
||||||
@@ -84,6 +85,14 @@
|
|||||||
"board, authentication, account, audit, and operator timestamps"
|
"board, authentication, account, audit, and operator timestamps"
|
||||||
],
|
],
|
||||||
"participants": [
|
"participants": [
|
||||||
|
{
|
||||||
|
"key": "play-audit-diplomacy-occurrence",
|
||||||
|
"policy": "KEEP",
|
||||||
|
"authorityFields": ["play_audit_diplomacy_event.tick", "play_audit_diplomacy_event.clock_revision"],
|
||||||
|
"projectionFields": [],
|
||||||
|
"owner": "game-api/play-audit-diplomacy",
|
||||||
|
"migration": "Historical occurrence coordinates remain in their original revision; never shift them with future deadlines."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"key": "world-clock",
|
"key": "world-clock",
|
||||||
"policy": "REBUILD",
|
"policy": "REBUILD",
|
||||||
|
|||||||
@@ -88,6 +88,34 @@ R1을 완료했다고 판단하지 않는다.
|
|||||||
world metadata로 계산하며 추가 DB 조회·쓰기나 시나리오/AI 규칙 변경은 없다.
|
world metadata로 계산하며 추가 DB 조회·쓰기나 시나리오/AI 규칙 변경은 없다.
|
||||||
PREOPEN은 wall-clock 대기 상태이며, 검증하는 것은 공식 개방 때의 논리 게임 달력이다.
|
PREOPEN은 wall-clock 대기 상태이며, 검증하는 것은 공식 개방 때의 논리 게임 달력이다.
|
||||||
|
|
||||||
|
## 외교 문서 상태 이벤트 저장 기반
|
||||||
|
|
||||||
|
`diplomacy.sendLetter/respondLetter/rollbackLetter/destroyLetter`의 기존 입력 원장
|
||||||
|
transaction에 제안·교체·승인·거절·회수·파기 요청·파기를 연결했다. 기존 SELECT와
|
||||||
|
UPDATE 반환값에서 변경 전후 allowlist를 만들고, 원장 잠금 SELECT의 sequence를
|
||||||
|
재사용한다. 감사 실패는 문서/알림과 함께 rollback하며 실패한 입력 원장은 남는다.
|
||||||
|
성공 재요청은 기존 결과를 반환해 이벤트를 중복 저장하지 않는다.
|
||||||
|
|
||||||
|
- migration55의 `play_audit_diplomacy_event`는 기수, 방향 있는 국가쌍, 실행/로컬 순번,
|
||||||
|
DB sequence, 처리 당시 달력/tick/revision, actor와 작은 상태 전후 값을 저장한다.
|
||||||
|
DB sequence와 입력 접수 sequence는 다른 개념이며 숫자 간격은 허용한다.
|
||||||
|
- 본문은 기존 `diplomacy_letter`를 ID/hash로 참조한다. 작성 본문·작성자·작성 시각·
|
||||||
|
국가쌍·prevId의 UPDATE를 DB trigger로 거부하고, 기존 새 문서 작성 경로를 유지한다.
|
||||||
|
상태·서명·aux 갱신은 허용한다. 기존 문서의 과거 상태를 소급 생성하지 않는다.
|
||||||
|
- 문서 쓰기 요청당 작은 world/clock SELECT 1회, RUNNING realtime이면 readiness
|
||||||
|
SELECT 1회, 이벤트 200개당 bulk INSERT와 ID/hash 확인 SELECT 각 1회가 추가된다.
|
||||||
|
원문 SELECT와 본문 복사는 추가하지 않는다. 기존 알림 wall time 조회와 결합해
|
||||||
|
줄일 여지는 남으며, SQL/WAL 비용 gate의 실측 완료를 뜻하지 않는다.
|
||||||
|
- reset 뒤 이전 이벤트는 기존 retention 경로에서 ID 최대200개씩 정리한다.
|
||||||
|
과거 tick/revision은 시계 이동 대상이 아닌 KEEP 이력으로 등록한다.
|
||||||
|
- 기존 `rollbackLetter`는 회수다. 현재 복구 mutation은 없어 복구 이력을 꾸며내지 않는다.
|
||||||
|
|
||||||
|
실제 PostgreSQL에서 문서 8개 시나리오, unit 7건, retention 4건, 신규55개/
|
||||||
|
증분54→55/재실행 migration을 검증했다. 외교 상태의 API 즉시 응답·엔진 월간/턴
|
||||||
|
변경, 도입 당시 기준, 감사 조회 API/UI는 후속 구현이며 **R4 전체 완료가 아니다**.
|
||||||
|
일반 외교 알림은 WALL_TIME이고 제의 처리와 tombstone은 구분한다. 오래된 알림
|
||||||
|
테스트의 게임 tick 가정을 현행 envelope 계약에 맞췄다.
|
||||||
|
|
||||||
## NPC·국방 정책 버전 저장 기반
|
## NPC·국방 정책 버전 저장 기반
|
||||||
|
|
||||||
`PlayAuditPolicy`는 현재 기수/국가/영역별 불변 revision과 이전 버전 ID를 보존한다.
|
`PlayAuditPolicy`는 현재 기수/국가/영역별 불변 revision과 이전 버전 ID를 보존한다.
|
||||||
|
|||||||
@@ -1166,3 +1166,38 @@ model PlayAuditPolicy {
|
|||||||
@@index([serverId, nationId, year, month, ordinal])
|
@@index([serverId, nationId, year, month, ordinal])
|
||||||
@@map("play_audit_policy")
|
@@map("play_audit_policy")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model PlayAuditDiplomacyEvent {
|
||||||
|
id String @id
|
||||||
|
sequence BigInt @unique @default(autoincrement())
|
||||||
|
schemaVersion Int @default(1) @map("schema_version")
|
||||||
|
serverId String @map("server_id")
|
||||||
|
nationA Int @map("nation_a")
|
||||||
|
nationB Int @map("nation_b")
|
||||||
|
srcNationId Int @map("src_nation_id")
|
||||||
|
destNationId Int @map("dest_nation_id")
|
||||||
|
category String
|
||||||
|
source String
|
||||||
|
eventType String @map("event_type")
|
||||||
|
documentId Int? @map("document_id")
|
||||||
|
documentHash String? @map("document_hash")
|
||||||
|
previousDocumentId Int? @map("previous_document_id")
|
||||||
|
year Int
|
||||||
|
month Int
|
||||||
|
tick BigInt?
|
||||||
|
clockRevision BigInt? @map("clock_revision")
|
||||||
|
executionId String @map("execution_id")
|
||||||
|
ordinal Int
|
||||||
|
requestId String? @map("request_id")
|
||||||
|
inputSequence BigInt? @map("input_sequence")
|
||||||
|
actor Json?
|
||||||
|
before Json?
|
||||||
|
after Json?
|
||||||
|
hash String
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
|
|
||||||
|
@@unique([serverId, executionId, ordinal])
|
||||||
|
@@index([serverId, nationA, nationB, sequence], map: "audit_diplomacy_pair_sequence_idx")
|
||||||
|
@@index([serverId, documentId, sequence])
|
||||||
|
@@map("play_audit_diplomacy_event")
|
||||||
|
}
|
||||||
|
|||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
CREATE TABLE "play_audit_diplomacy_event" (
|
||||||
|
"id" TEXT PRIMARY KEY,
|
||||||
|
"sequence" BIGSERIAL NOT NULL UNIQUE,
|
||||||
|
"schema_version" INTEGER NOT NULL DEFAULT 1 CHECK ("schema_version" > 0),
|
||||||
|
"server_id" TEXT NOT NULL,
|
||||||
|
"nation_a" INTEGER NOT NULL,
|
||||||
|
"nation_b" INTEGER NOT NULL,
|
||||||
|
"src_nation_id" INTEGER NOT NULL,
|
||||||
|
"dest_nation_id" INTEGER NOT NULL,
|
||||||
|
"category" TEXT NOT NULL CHECK ("category" IN ('DOCUMENT', 'RELATION')),
|
||||||
|
"source" TEXT NOT NULL CHECK ("source" IN ('API', 'ENGINE', 'BASELINE')),
|
||||||
|
"event_type" TEXT NOT NULL,
|
||||||
|
"document_id" INTEGER,
|
||||||
|
"document_hash" TEXT,
|
||||||
|
"previous_document_id" INTEGER,
|
||||||
|
"year" INTEGER NOT NULL,
|
||||||
|
"month" INTEGER NOT NULL CHECK ("month" BETWEEN 1 AND 12),
|
||||||
|
"tick" BIGINT,
|
||||||
|
"clock_revision" BIGINT,
|
||||||
|
"execution_id" TEXT NOT NULL,
|
||||||
|
"ordinal" INTEGER NOT NULL CHECK ("ordinal" > 0),
|
||||||
|
"request_id" TEXT,
|
||||||
|
"input_sequence" BIGINT,
|
||||||
|
"actor" JSONB,
|
||||||
|
"before" JSONB,
|
||||||
|
"after" JSONB,
|
||||||
|
"hash" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CHECK ("nation_a" <= "nation_b")
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX "play_audit_diplomacy_event_server_id_execution_id_ordinal_key"
|
||||||
|
ON "play_audit_diplomacy_event" ("server_id", "execution_id", "ordinal");
|
||||||
|
CREATE INDEX "audit_diplomacy_pair_sequence_idx"
|
||||||
|
ON "play_audit_diplomacy_event" ("server_id", "nation_a", "nation_b", "sequence");
|
||||||
|
CREATE INDEX "play_audit_diplomacy_event_server_id_document_id_sequence_idx"
|
||||||
|
ON "play_audit_diplomacy_event" ("server_id", "document_id", "sequence");
|
||||||
|
|
||||||
|
-- 수정은 새 문서(prev_id)로 만든다. 원문을 한 번만 참조할 수 있도록 기존 작성 내용을 고정한다.
|
||||||
|
CREATE FUNCTION preserve_diplomacy_document_content() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
IF ROW(NEW.id, NEW.src_nation_id, NEW.dest_nation_id, NEW.prev_id, NEW.text_brief, NEW.text_detail, NEW.src_signer, NEW.date)
|
||||||
|
IS DISTINCT FROM
|
||||||
|
ROW(OLD.id, OLD.src_nation_id, OLD.dest_nation_id, OLD.prev_id, OLD.text_brief, OLD.text_detail, OLD.src_signer, OLD.date)
|
||||||
|
THEN
|
||||||
|
RAISE EXCEPTION 'Diplomacy document content is immutable; create a replacement document';
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
CREATE TRIGGER preserve_diplomacy_document_content BEFORE UPDATE ON "diplomacy_letter"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION preserve_diplomacy_document_content();
|
||||||
@@ -23,6 +23,7 @@ export interface DatabaseClient {
|
|||||||
playAuditNation: GamePrisma.PlayAuditNationDelegate;
|
playAuditNation: GamePrisma.PlayAuditNationDelegate;
|
||||||
playAuditCity: GamePrisma.PlayAuditCityDelegate;
|
playAuditCity: GamePrisma.PlayAuditCityDelegate;
|
||||||
playAuditGeneral: GamePrisma.PlayAuditGeneralDelegate;
|
playAuditGeneral: GamePrisma.PlayAuditGeneralDelegate;
|
||||||
|
playAuditDiplomacyEvent: GamePrisma.PlayAuditDiplomacyEventDelegate;
|
||||||
rankData: GamePrisma.RankDataDelegate;
|
rankData: GamePrisma.RankDataDelegate;
|
||||||
hallOfFame: GamePrisma.HallOfFameDelegate;
|
hallOfFame: GamePrisma.HallOfFameDelegate;
|
||||||
gameHistory: GamePrisma.GameHistoryDelegate;
|
gameHistory: GamePrisma.GameHistoryDelegate;
|
||||||
|
|||||||
@@ -11,5 +11,6 @@ export * from './readModelOutboxDispatcher.js';
|
|||||||
export * from './readModelCoverageActivation.js';
|
export * from './readModelCoverageActivation.js';
|
||||||
export * from './gameSchemaAdvisoryLock.js';
|
export * from './gameSchemaAdvisoryLock.js';
|
||||||
export * from './inputEventClock.js';
|
export * from './inputEventClock.js';
|
||||||
|
export * from './playAuditDiplomacy.js';
|
||||||
export * from './messageEnvelope.js';
|
export * from './messageEnvelope.js';
|
||||||
export * from './webPushOutbox.js';
|
export * from './webPushOutbox.js';
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { GamePrisma, type GamePrismaClient } from './gamePrisma.js';
|
||||||
|
|
||||||
|
export interface AuditDiplomacyEventDraft {
|
||||||
|
schemaVersion: 1;
|
||||||
|
serverId: string;
|
||||||
|
srcNationId: number;
|
||||||
|
destNationId: number;
|
||||||
|
category: 'DOCUMENT' | 'RELATION';
|
||||||
|
source: 'API' | 'ENGINE' | 'BASELINE';
|
||||||
|
eventType: string;
|
||||||
|
documentId: number | null;
|
||||||
|
documentHash: string | null;
|
||||||
|
previousDocumentId: number | null;
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
tick: bigint | null;
|
||||||
|
clockRevision: bigint | null;
|
||||||
|
executionId: string;
|
||||||
|
ordinal: number;
|
||||||
|
requestId: string | null;
|
||||||
|
inputSequence: bigint | null;
|
||||||
|
actor: Record<string, unknown> | null;
|
||||||
|
before: Record<string, unknown> | null;
|
||||||
|
after: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hashAuditDiplomacy = (value: unknown): string =>
|
||||||
|
createHash('sha256')
|
||||||
|
.update(
|
||||||
|
JSON.stringify(value, (_key, item: unknown) => {
|
||||||
|
if (typeof item === 'bigint') return item.toString();
|
||||||
|
if (item && typeof item === 'object' && !Array.isArray(item))
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(item).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||||
|
);
|
||||||
|
return item;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.digest('hex');
|
||||||
|
|
||||||
|
/** 본문은 불변 문서 행을 참조한다. event별로 HTML을 복제하지 않는다. */
|
||||||
|
export const hashAuditDiplomacyDocument = (letter: {
|
||||||
|
id: number;
|
||||||
|
srcNationId: number;
|
||||||
|
destNationId: number;
|
||||||
|
prevId: number | null;
|
||||||
|
textBrief: string;
|
||||||
|
textDetail: string;
|
||||||
|
srcSignerId: number;
|
||||||
|
date: Date;
|
||||||
|
}): string =>
|
||||||
|
hashAuditDiplomacy({
|
||||||
|
id: letter.id,
|
||||||
|
srcNationId: letter.srcNationId,
|
||||||
|
destNationId: letter.destNationId,
|
||||||
|
prevId: letter.prevId,
|
||||||
|
textBrief: letter.textBrief,
|
||||||
|
textDetail: letter.textDetail,
|
||||||
|
srcSignerId: letter.srcSignerId,
|
||||||
|
date: letter.date.toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const persistAuditDiplomacyEvents = async (
|
||||||
|
db: Pick<GamePrismaClient, 'playAuditDiplomacyEvent'>,
|
||||||
|
events: readonly AuditDiplomacyEventDraft[]
|
||||||
|
): Promise<void> => {
|
||||||
|
const json = (value: Record<string, unknown> | null) =>
|
||||||
|
value === null ? GamePrisma.DbNull : (JSON.parse(JSON.stringify(value)) as GamePrisma.InputJsonValue);
|
||||||
|
for (let offset = 0; offset < events.length; offset += 200) {
|
||||||
|
const batch = events.slice(offset, offset + 200).map((event) => ({
|
||||||
|
...event,
|
||||||
|
id: hashAuditDiplomacy([event.serverId, event.executionId, event.ordinal]),
|
||||||
|
nationA: Math.min(event.srcNationId, event.destNationId),
|
||||||
|
nationB: Math.max(event.srcNationId, event.destNationId),
|
||||||
|
actor: json(event.actor),
|
||||||
|
before: json(event.before),
|
||||||
|
after: json(event.after),
|
||||||
|
hash: hashAuditDiplomacy(event),
|
||||||
|
}));
|
||||||
|
await db.playAuditDiplomacyEvent.createMany({ data: batch, skipDuplicates: true });
|
||||||
|
const saved = await db.playAuditDiplomacyEvent.findMany({
|
||||||
|
where: { id: { in: batch.map((event) => event.id) } },
|
||||||
|
select: { id: true, hash: true },
|
||||||
|
});
|
||||||
|
const hashes = new Map(saved.map((row) => [row.id, row.hash]));
|
||||||
|
if (batch.some((event) => hashes.get(event.id) !== event.hash))
|
||||||
|
throw new Error('Play audit diplomacy replay payload conflict');
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user