feat: 즉시 외교 응답의 양방향 관계 전이를 감사 기록에 연결
This commit is contained in:
@@ -1,4 +1,6 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
|
import { persistAuditDiplomacyEvents, type AuditDiplomacyEventDraft, type GamePrisma } from '@sammo-ts/infra';
|
||||||
|
import type { ApiInputExecutionContext } from '../inputEventBoundary.js';
|
||||||
|
|
||||||
import { asRecord, JosaUtil } from '@sammo-ts/common';
|
import { asRecord, JosaUtil } from '@sammo-ts/common';
|
||||||
import {
|
import {
|
||||||
@@ -95,12 +97,25 @@ const persistEffects = async (
|
|||||||
year: number,
|
year: number,
|
||||||
month: number,
|
month: number,
|
||||||
at: Date,
|
at: Date,
|
||||||
serverId: string | null
|
serverId: string | null,
|
||||||
|
audit?: {
|
||||||
|
before: GamePrisma.DiplomacyGetPayload<Record<string, never>>[];
|
||||||
|
base: Omit<AuditDiplomacyEventDraft, 'srcNationId' | 'destNationId' | 'ordinal' | 'before' | 'after'>;
|
||||||
|
}
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
|
const changes: AuditDiplomacyEventDraft[] = [];
|
||||||
|
const states = new Map(audit?.before.map((row) => [`${row.srcNationId}:${row.destNationId}`, row]));
|
||||||
|
const project = (row: GamePrisma.DiplomacyGetPayload<Record<string, never>>) => ({
|
||||||
|
state: row.stateCode,
|
||||||
|
term: row.term,
|
||||||
|
isDead: row.isDead,
|
||||||
|
isShowing: row.isShowing,
|
||||||
|
dead: typeof asRecord(row.meta).dead === 'number' ? asRecord(row.meta).dead : 0,
|
||||||
|
});
|
||||||
const logs: LogEntryDraft[] = [];
|
const logs: LogEntryDraft[] = [];
|
||||||
for (const effect of effects) {
|
for (const effect of effects) {
|
||||||
if (effect.type === 'diplomacy:patch') {
|
if (effect.type === 'diplomacy:patch') {
|
||||||
await db.diplomacy.update({
|
const updated = await db.diplomacy.update({
|
||||||
where: {
|
where: {
|
||||||
srcNationId_destNationId: {
|
srcNationId_destNationId: {
|
||||||
srcNationId: effect.srcNationId,
|
srcNationId: effect.srcNationId,
|
||||||
@@ -113,6 +128,24 @@ const persistEffects = async (
|
|||||||
...(effect.patch.meta !== undefined ? { meta: effect.patch.meta as InputJsonValue } : {}),
|
...(effect.patch.meta !== undefined ? { meta: effect.patch.meta as InputJsonValue } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (audit) {
|
||||||
|
const key = `${effect.srcNationId}:${effect.destNationId}`;
|
||||||
|
const previous = states.get(key);
|
||||||
|
if (!previous) throw new Error('Missing locked diplomacy audit state');
|
||||||
|
const before = project(previous);
|
||||||
|
const after = project(updated);
|
||||||
|
if (JSON.stringify(before) !== JSON.stringify(after)) {
|
||||||
|
changes.push({
|
||||||
|
...audit.base,
|
||||||
|
srcNationId: effect.srcNationId,
|
||||||
|
destNationId: effect.destNationId,
|
||||||
|
ordinal: changes.length + 1,
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
states.set(key, updated);
|
||||||
|
}
|
||||||
} else if (effect.type === 'nation:patch' && effect.targetId !== undefined) {
|
} else if (effect.type === 'nation:patch' && effect.targetId !== undefined) {
|
||||||
const patch = effect.patch;
|
const patch = effect.patch;
|
||||||
await db.nation.update({
|
await db.nation.update({
|
||||||
@@ -126,6 +159,7 @@ const persistEffects = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
await persistLogs(db, orderLegacyActionLoggerFlush(logs), year, month, at, serverId);
|
await persistLogs(db, orderLegacyActionLoggerFlush(logs), year, month, at, serverId);
|
||||||
|
await persistAuditDiplomacyEvents(db, changes);
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshFrontStates = async (db: DatabaseClient, mapName: string, nationIds: number[]): Promise<number[]> => {
|
const refreshFrontStates = async (db: DatabaseClient, mapName: string, nationIds: number[]): Promise<number[]> => {
|
||||||
@@ -182,6 +216,7 @@ export const respondToDiplomaticMessage = async (options: {
|
|||||||
actor: GeneralRow;
|
actor: GeneralRow;
|
||||||
messageId: number;
|
messageId: number;
|
||||||
response: boolean;
|
response: boolean;
|
||||||
|
auditInput?: ApiInputExecutionContext;
|
||||||
}): Promise<DiplomaticMessageResponseResult> => {
|
}): Promise<DiplomaticMessageResponseResult> => {
|
||||||
const { db, actor, messageId, response } = options;
|
const { db, actor, messageId, response } = options;
|
||||||
const message = await fetchMessageByIdForUpdate(db, messageId);
|
const message = await fetchMessageByIdForUpdate(db, messageId);
|
||||||
@@ -197,7 +232,8 @@ export const respondToDiplomaticMessage = async (options: {
|
|||||||
}
|
}
|
||||||
const serverIdValue = asRecord(world.meta).serverId;
|
const serverIdValue = asRecord(world.meta).serverId;
|
||||||
const serverId = typeof serverIdValue === 'string' && serverIdValue.trim() ? serverIdValue : null;
|
const serverId = typeof serverIdValue === 'string' && serverIdValue.trim() ? serverIdValue : null;
|
||||||
const now = (await loadCurrentGameTime(db)).now;
|
const gameTime = await loadCurrentGameTime(db);
|
||||||
|
const now = gameTime.now;
|
||||||
const action = parseAction(message.payload.option?.action);
|
const action = parseAction(message.payload.option?.action);
|
||||||
if (message.msgType !== 'diplomacy' || !action || message.payload.option?.used) {
|
if (message.msgType !== 'diplomacy' || !action || message.payload.option?.used) {
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '응답할 수 없는 메시지입니다.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '응답할 수 없는 메시지입니다.' });
|
||||||
@@ -421,7 +457,49 @@ export const respondToDiplomaticMessage = async (options: {
|
|||||||
...(action === 'noAggression' ? { treatyYear: treatyYear!, treatyMonth: treatyMonth! } : {}),
|
...(action === 'noAggression' ? { treatyYear: treatyYear!, treatyMonth: treatyMonth! } : {}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
await persistEffects(db, resolution.effects, world.currentYear, world.currentMonth, now, serverId);
|
const auditInput = options.auditInput;
|
||||||
|
if (auditInput && auditInput.actorUserId !== actor.userId) throw new Error('Diplomacy audit actor mismatch');
|
||||||
|
await persistEffects(
|
||||||
|
db,
|
||||||
|
resolution.effects,
|
||||||
|
world.currentYear,
|
||||||
|
world.currentMonth,
|
||||||
|
now,
|
||||||
|
serverId,
|
||||||
|
auditInput && serverId
|
||||||
|
? {
|
||||||
|
before: [actorDiplomacy, reverseDiplomacy],
|
||||||
|
base: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
serverId,
|
||||||
|
category: 'RELATION',
|
||||||
|
source: 'API',
|
||||||
|
eventType: `MESSAGE_ACCEPTED_${action}`,
|
||||||
|
documentId: null,
|
||||||
|
documentHash: null,
|
||||||
|
previousDocumentId: null,
|
||||||
|
year: world.currentYear,
|
||||||
|
month: world.currentMonth,
|
||||||
|
tick: gameTime.tick === null ? null : BigInt(gameTime.tick),
|
||||||
|
clockRevision: gameTime.revision == null ? null : BigInt(gameTime.revision),
|
||||||
|
executionId: `api:${auditInput.requestId}`,
|
||||||
|
requestId: auditInput.requestId,
|
||||||
|
inputSequence: auditInput.sequence,
|
||||||
|
actor: {
|
||||||
|
userId: actor.userId,
|
||||||
|
generalId: actor.id,
|
||||||
|
name: actor.name,
|
||||||
|
nationId: actor.nationId,
|
||||||
|
officerLevel: actor.officerLevel,
|
||||||
|
npcState: actor.npcState,
|
||||||
|
permission: resolveNationPermission(actor, actorNation.meta, false),
|
||||||
|
messageId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
|
||||||
let affectedCityIds: number[] = [];
|
let affectedCityIds: number[] = [];
|
||||||
if (resolution.refreshFront) {
|
if (resolution.refreshFront) {
|
||||||
const worldConfig = asRecord(world.config);
|
const worldConfig = asRecord(world.config);
|
||||||
|
|||||||
@@ -371,7 +371,7 @@ export const messagesRouter = router({
|
|||||||
eventType: 'messages.respond.diplomatic',
|
eventType: 'messages.respond.diplomatic',
|
||||||
payload: input,
|
payload: input,
|
||||||
actorUserId: ctx.auth?.user.id,
|
actorUserId: ctx.auth?.user.id,
|
||||||
execute: async (transaction) => {
|
execute: async (transaction, auditInput) => {
|
||||||
const transactionContext = { ...ctx, db: transaction, changeJournal };
|
const transactionContext = { ...ctx, db: transaction, changeJournal };
|
||||||
const transactionGeneral = ownsChangeJournal
|
const transactionGeneral = ownsChangeJournal
|
||||||
? await getOwnedGeneral(transactionContext, input.generalId)
|
? await getOwnedGeneral(transactionContext, input.generalId)
|
||||||
@@ -379,6 +379,7 @@ export const messagesRouter = router({
|
|||||||
const result = await respondToDiplomaticMessage({
|
const result = await respondToDiplomaticMessage({
|
||||||
db: transaction,
|
db: transaction,
|
||||||
actor: transactionGeneral,
|
actor: transactionGeneral,
|
||||||
|
auditInput,
|
||||||
messageId: input.messageId,
|
messageId: input.messageId,
|
||||||
response: input.response,
|
response: input.response,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { JosaUtil } 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';
|
||||||
@@ -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, tombstoneMessages } from '../src/messages/store.js';
|
import { fetchMessagesFromMailbox, insertMessage, 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;
|
||||||
@@ -25,6 +25,7 @@ const fixtureNationId = 841;
|
|||||||
const foreignNationId = fixtureNationId + 1;
|
const foreignNationId = fixtureNationId + 1;
|
||||||
const fixtureGeneralId = 8_864_243;
|
const fixtureGeneralId = 8_864_243;
|
||||||
const foreignGeneralId = fixtureGeneralId + 1;
|
const foreignGeneralId = fixtureGeneralId + 1;
|
||||||
|
const fixtureCityId = 8_864_246;
|
||||||
const fixtureWorldStateId = -8_864_241;
|
const fixtureWorldStateId = -8_864_241;
|
||||||
const fixtureUserId = 'diplomacy-document-message-src-user';
|
const fixtureUserId = 'diplomacy-document-message-src-user';
|
||||||
const foreignUserId = 'diplomacy-document-message-dest-user';
|
const foreignUserId = 'diplomacy-document-message-dest-user';
|
||||||
@@ -82,7 +83,23 @@ integration('diplomacy document message persistence', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const cleanupRouteState = async (): Promise<void> => {
|
const cleanupRouteState = async (): Promise<void> => {
|
||||||
|
await db.logEntry.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ generalId: { in: [fixtureGeneralId, foreignGeneralId] } },
|
||||||
|
{ nationId: { in: [fixtureNationId, foreignNationId] } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
await db.playAuditDiplomacyEvent.deleteMany({ where: { serverId: requestPrefix } });
|
await db.playAuditDiplomacyEvent.deleteMany({ where: { serverId: requestPrefix } });
|
||||||
|
const proposalIds = await db.message.findMany({
|
||||||
|
where: { mailbox: { in: [...fixtureMailboxes] } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
await db.inputEvent.deleteMany({
|
||||||
|
where: { requestId: { in: proposalIds.map(({ id }) => `messages.respond.diplomatic:${id}`) } },
|
||||||
|
});
|
||||||
|
await db.diplomacy.deleteMany({ where: { srcNationId: { in: [fixtureNationId, foreignNationId] } } });
|
||||||
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: {
|
||||||
@@ -106,6 +123,7 @@ integration('diplomacy document message persistence', () => {
|
|||||||
await cleanupRouteState();
|
await cleanupRouteState();
|
||||||
await db.general.deleteMany({ where: { id: { in: [fixtureGeneralId, foreignGeneralId] } } });
|
await db.general.deleteMany({ where: { id: { in: [fixtureGeneralId, foreignGeneralId] } } });
|
||||||
await db.nation.deleteMany({ where: { id: { in: [fixtureNationId, foreignNationId] } } });
|
await db.nation.deleteMany({ where: { id: { in: [fixtureNationId, foreignNationId] } } });
|
||||||
|
await db.city.deleteMany({ where: { id: fixtureCityId } });
|
||||||
await db.worldState.deleteMany({ where: { id: fixtureWorldStateId } });
|
await db.worldState.deleteMany({ where: { id: fixtureWorldStateId } });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -306,6 +324,27 @@ integration('diplomacy document message persistence', () => {
|
|||||||
{ id: foreignNationId, name: '상대국', color: '#654321' },
|
{ id: foreignNationId, name: '상대국', color: '#654321' },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
await db.city.create({
|
||||||
|
data: {
|
||||||
|
id: fixtureCityId,
|
||||||
|
name: '감사 도시',
|
||||||
|
level: 1,
|
||||||
|
nationId: foreignNationId,
|
||||||
|
population: 1000,
|
||||||
|
populationMax: 1000,
|
||||||
|
agriculture: 100,
|
||||||
|
agricultureMax: 100,
|
||||||
|
commerce: 100,
|
||||||
|
commerceMax: 100,
|
||||||
|
security: 100,
|
||||||
|
securityMax: 100,
|
||||||
|
defence: 100,
|
||||||
|
defenceMax: 100,
|
||||||
|
wall: 100,
|
||||||
|
wallMax: 100,
|
||||||
|
region: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
await db.general.createMany({
|
await db.general.createMany({
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
@@ -320,6 +359,7 @@ integration('diplomacy document message persistence', () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: foreignGeneralId,
|
id: foreignGeneralId,
|
||||||
|
cityId: fixtureCityId,
|
||||||
userId: foreignUserId,
|
userId: foreignUserId,
|
||||||
name: '상대수뇌',
|
name: '상대수뇌',
|
||||||
nationId: foreignNationId,
|
nationId: foreignNationId,
|
||||||
@@ -616,6 +656,92 @@ integration('diplomacy document message persistence', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(['noAggression', 'cancelNA', 'stopWar'] as const)(
|
||||||
|
'records both directions of %s with no duplicate on replay',
|
||||||
|
async (action) => {
|
||||||
|
const initialState = action === 'noAggression' ? 2 : action === 'cancelNA' ? 7 : 0;
|
||||||
|
await db.diplomacy.createMany({
|
||||||
|
data: [
|
||||||
|
{ srcNationId: fixtureNationId, destNationId: foreignNationId, stateCode: initialState, term: 12 },
|
||||||
|
{ srcNationId: foreignNationId, destNationId: fixtureNationId, stateCode: initialState, term: 12 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const messageId = await insertMessage(db, {
|
||||||
|
mailbox: fixtureMailboxes[1],
|
||||||
|
msgType: 'diplomacy',
|
||||||
|
srcId: fixtureMailboxes[0],
|
||||||
|
destId: fixtureMailboxes[1],
|
||||||
|
time: logicalGameTime,
|
||||||
|
validUntil: new Date('9999-12-31T00:00:00Z'),
|
||||||
|
payload: {
|
||||||
|
text: '불가침 파기 제의',
|
||||||
|
option: { action, year: 209, month: 4 },
|
||||||
|
src: {
|
||||||
|
generalId: fixtureGeneralId,
|
||||||
|
generalName: '원민수뇌',
|
||||||
|
nationId: fixtureNationId,
|
||||||
|
nationName: '원민국',
|
||||||
|
color: '#123456',
|
||||||
|
icon: '',
|
||||||
|
},
|
||||||
|
dest: {
|
||||||
|
generalId: foreignGeneralId,
|
||||||
|
generalName: '상대수뇌',
|
||||||
|
nationId: foreignNationId,
|
||||||
|
nationName: '상대국',
|
||||||
|
color: '#654321',
|
||||||
|
icon: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const context = buildContext('audit-relation', foreignAuth);
|
||||||
|
vi.spyOn(context.turnDaemon, 'requestCommand').mockResolvedValueOnce(null).mockResolvedValue({
|
||||||
|
type: 'syncDiplomaticResponse',
|
||||||
|
ok: true,
|
||||||
|
generalId: foreignGeneralId,
|
||||||
|
messageId,
|
||||||
|
nations: 2,
|
||||||
|
diplomacy: 2,
|
||||||
|
cities: 0,
|
||||||
|
});
|
||||||
|
const caller = appRouter.createCaller(context);
|
||||||
|
const input = { generalId: foreignGeneralId, messageId, response: true };
|
||||||
|
await expect(caller.messages.respond(input)).rejects.toThrow(
|
||||||
|
'외교 상태를 게임 엔진에 동기화하지 못했습니다.'
|
||||||
|
);
|
||||||
|
expect(await caller.messages.respond(input)).toEqual({ result: true, reason: 'success' });
|
||||||
|
expect(await caller.messages.respond(input)).toEqual({ result: true, reason: 'success' });
|
||||||
|
const events = await db.playAuditDiplomacyEvent.findMany({
|
||||||
|
where: { serverId: requestPrefix },
|
||||||
|
orderBy: { sequence: 'asc' },
|
||||||
|
});
|
||||||
|
expect(events).toHaveLength(2);
|
||||||
|
expect(new Set(events.map((event) => `${event.srcNationId}:${event.destNationId}`))).toEqual(
|
||||||
|
new Set([`${fixtureNationId}:${foreignNationId}`, `${foreignNationId}:${fixtureNationId}`])
|
||||||
|
);
|
||||||
|
for (const event of events) {
|
||||||
|
expect(event).toMatchObject({
|
||||||
|
category: 'RELATION',
|
||||||
|
eventType: `MESSAGE_ACCEPTED_${action}`,
|
||||||
|
before: { state: initialState, term: 12 },
|
||||||
|
actor: { generalId: foreignGeneralId, messageId },
|
||||||
|
});
|
||||||
|
const current = await db.diplomacy.findUniqueOrThrow({
|
||||||
|
where: {
|
||||||
|
srcNationId_destNationId: {
|
||||||
|
srcNationId: event.srcNationId,
|
||||||
|
destNationId: event.destNationId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(event.after).toMatchObject({ state: current.stateCode, term: current.term });
|
||||||
|
}
|
||||||
|
expect(await db.messageAction.findUniqueOrThrow({ where: { messageId } })).toMatchObject({
|
||||||
|
status: 'RESOLVED',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
it('rolls back the document and notices if the audit insert fails', async () => {
|
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 $$
|
await db.$executeRawUnsafe(`CREATE FUNCTION reject_audit_document_fixture() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
BEGIN RAISE EXCEPTION 'injected audit insert failure'; END; $$`);
|
BEGIN RAISE EXCEPTION 'injected audit insert failure'; END; $$`);
|
||||||
|
|||||||
@@ -116,6 +116,23 @@ UPDATE 반환값에서 변경 전후 allowlist를 만들고, 원장 잠금 SELEC
|
|||||||
일반 외교 알림은 WALL_TIME이고 제의 처리와 tombstone은 구분한다. 오래된 알림
|
일반 외교 알림은 WALL_TIME이고 제의 처리와 tombstone은 구분한다. 오래된 알림
|
||||||
테스트의 게임 tick 가정을 현행 envelope 계약에 맞췄다.
|
테스트의 게임 tick 가정을 현행 envelope 계약에 맞췄다.
|
||||||
|
|
||||||
|
### 즉시 외교 응답의 관계 전이
|
||||||
|
|
||||||
|
`messages.respond`의 별도 `executeInputEvent` 경로에도 인증된 입력 context를 전달한다.
|
||||||
|
불가침 체결·불가침 파기·종전 수락에서 이미 잠근 양방향 관계 행을 before로 사용하고,
|
||||||
|
각 diplomacy UPDATE의 반환값을 after로 기록한다. state/term/dead/isDead/isShowing만
|
||||||
|
투영하며 임의 meta를 복사하지 않는다. 같은 값의 재적용은 상태 전이에 포함하지 않는다.
|
||||||
|
|
||||||
|
추가 상태/clock/입력 SELECT는 없고, 두 방향의 전이를 한 bulk INSERT와 ID/hash
|
||||||
|
확인 SELECT로 저장한다. 원장·관계·로그·알림과 같은 transaction이다. API commit 뒤
|
||||||
|
엔진 메모리 동기화가 실패해도 재요청은 원장 결과를 재사용해 감사 이력을 중복 쓰지
|
||||||
|
않는다. 엔진 동기화에서 같은 사건을 다시 수집하지 않는다.
|
||||||
|
|
||||||
|
실제 PG에서 세 응답의 양방향 before/after, 처리 순서, RESOLVED 제의 상태와 동기화
|
||||||
|
실패 후 재요청을 검증했다. 엔진 transport만 fixture 응답이므로 엔진 runtime 동기화
|
||||||
|
완료의 증거는 아니다. 거절/실패/무변경은 관계 전이와 구분할 시도 원장 구현에 남겼다.
|
||||||
|
엔진 턴·월간 변화와 기준 수집, 외교 조회 화면은 아직 남았다.
|
||||||
|
|
||||||
## NPC·국방 정책 버전 저장 기반
|
## NPC·국방 정책 버전 저장 기반
|
||||||
|
|
||||||
`PlayAuditPolicy`는 현재 기수/국가/영역별 불변 revision과 이전 버전 ID를 보존한다.
|
`PlayAuditPolicy`는 현재 기수/국가/영역별 불변 revision과 이전 버전 ID를 보존한다.
|
||||||
|
|||||||
Reference in New Issue
Block a user