기존 외교 문서를 플레이 감사 최초 관측으로 보존

This commit is contained in:
2026-09-16 06:53:16 +00:00
parent c719cecc80
commit ae0dfd503f
9 changed files with 314 additions and 39 deletions
@@ -1,8 +1,9 @@
import { asRecord, GameClock, inferClockPhase, parseGameClockPhase, readTurnRecovery } from '@sammo-ts/common'; import { GameClock, inferClockPhase, parseGameClockPhase, readTurnRecovery } from '@sammo-ts/common';
import { import {
GamePrisma, GamePrisma,
hashAuditDiplomacyDocument, hashAuditDiplomacyDocument,
persistAuditDiplomacyEvents, persistAuditDiplomacyEvents,
projectAuditDocumentState,
readTurnRuntimeReady, readTurnRuntimeReady,
type AuditDiplomacyEventDraft, type AuditDiplomacyEventDraft,
} from '@sammo-ts/infra'; } from '@sammo-ts/infra';
@@ -18,25 +19,7 @@ type DocumentAction =
| 'LETTER_DESTROY_REQUESTED' | 'LETTER_DESTROY_REQUESTED'
| 'LETTER_DESTROYED'; | 'LETTER_DESTROYED';
export const projectAuditDocumentState = (letter: Letter): Record<string, unknown> => { export { projectAuditDocumentState } from '@sammo-ts/infra';
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 { interface AuditCoordinateRow {
serverId: string | null; serverId: string | null;
@@ -0,0 +1,123 @@
import { asRecord } from '@sammo-ts/common';
import {
hashAuditDiplomacyDocument,
persistAuditDiplomacyEvents,
projectAuditDocumentState,
GamePrisma,
} from '@sammo-ts/infra';
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
export const hasAuditDocumentBaseline = (world: InMemoryTurnWorld): boolean => {
const state = world.getState();
const { meta } = state;
const serverId = meta.serverId;
if (typeof serverId !== 'string' || !serverId.trim()) return true;
const marker = asRecord(meta.playAuditDocuments);
if (marker.serverId !== serverId) return false;
if (
marker.schemaVersion !== 1 ||
typeof marker.documentCount !== 'number' ||
!Number.isSafeInteger(marker.documentCount) ||
marker.documentCount < 0 ||
typeof marker.year !== 'number' ||
!Number.isSafeInteger(marker.year) ||
marker.year < 0 ||
typeof marker.month !== 'number' ||
!Number.isInteger(marker.month) ||
marker.month < 1 ||
marker.month > 12 ||
marker.year * 12 + marker.month > state.currentYear * 12 + state.currentMonth ||
typeof marker.tick !== 'number' ||
!Number.isSafeInteger(marker.tick) ||
marker.tick < 0 ||
typeof marker.clockRevision !== 'number' ||
!Number.isSafeInteger(marker.clockRevision) ||
marker.clockRevision < 0 ||
typeof marker.observedAt !== 'string' ||
!Number.isFinite(Date.parse(marker.observedAt))
)
throw new Error('Invalid play audit document boundary');
return true;
};
/** CLOCK lock을 잡은 startup transaction 안에서만 호출한다. 과거 문서 사건을 복원하지 않는다. */
export const persistAuditDocumentBaseline = async (
db: GamePrisma.TransactionClient,
world: InMemoryTurnWorld,
observedAt: Date
): Promise<void> => {
if (hasAuditDocumentBaseline(world)) return;
const state = world.getState();
const serverId = state.meta.serverId;
if (typeof serverId !== 'string' || !serverId.trim()) return;
const observedAtIso = observedAt.toISOString();
const [identity] = await db.$queryRaw<Array<{ serverId: string | null }>>(GamePrisma.sql`
SELECT meta->>'serverId' AS "serverId" FROM world_state WHERE id = ${state.id} FOR UPDATE
`);
if (identity?.serverId !== serverId) throw new Error('Play audit document baseline season changed');
const clock = world.getGameClockState();
let cursor = 0;
let documentCount = 0;
while (true) {
// 문서 본문은 불변 참조의 해시에만 필요하다. 메모리에는 한 batch만 유지한다.
const letters = await db.diplomacyLetter.findMany({
where: { id: { gt: cursor } },
orderBy: { id: 'asc' },
take: 200,
select: {
id: true,
srcNationId: true,
destNationId: true,
prevId: true,
textBrief: true,
textDetail: true,
srcSignerId: true,
destSignerId: true,
state: true,
aux: true,
date: true,
},
});
await persistAuditDiplomacyEvents(
db,
letters.map((letter) => ({
schemaVersion: 1,
serverId,
srcNationId: letter.srcNationId,
destNationId: letter.destNationId,
category: 'DOCUMENT',
source: 'BASELINE',
eventType: 'LETTER_BASELINE',
documentId: letter.id,
documentHash: hashAuditDiplomacyDocument(letter),
previousDocumentId: letter.prevId,
year: state.currentYear,
month: state.currentMonth,
tick: BigInt(clock.tick),
clockRevision: BigInt(clock.revision),
executionId: 'document-baseline',
ordinal: letter.id,
requestId: null,
inputSequence: null,
actor: null,
before: null,
after: projectAuditDocumentState(letter),
}))
);
documentCount += letters.length;
if (letters.length < 200) break;
cursor = letters[letters.length - 1]!.id;
}
world.updateWorldMeta({
playAuditDocuments: {
schemaVersion: 1,
serverId,
year: state.currentYear,
month: state.currentMonth,
tick: clock.tick,
clockRevision: clock.revision,
observedAt: observedAtIso,
documentCount,
},
});
};
+28
View File
@@ -1,4 +1,5 @@
import { persistAuditDiplomacyEvents } from '@sammo-ts/infra'; import { persistAuditDiplomacyEvents } from '@sammo-ts/infra';
import { hasAuditDocumentBaseline, persistAuditDocumentBaseline } from '../playAudit/documentBaseline.js';
import { persistAuditPolicies } from '../playAudit/policyPersistence.js'; import { persistAuditPolicies } from '../playAudit/policyPersistence.js';
import { prunePreviousAuditBatch, type AuditRetentionResult } from '../playAudit/retention.js'; import { prunePreviousAuditBatch, type AuditRetentionResult } from '../playAudit/retention.js';
import { persistAuditMonth } from '../playAudit/persistence.js'; import { persistAuditMonth } from '../playAudit/persistence.js';
@@ -76,6 +77,7 @@ import { prepareRealtimeRecovery } from './prepareRealtimeRecovery.js';
export interface DatabaseTurnHooks { export interface DatabaseTurnHooks {
hooks: TurnDaemonHooks; hooks: TurnDaemonHooks;
flushChanges(): Promise<void>; flushChanges(): Promise<void>;
flushInitialAudit(observedAt: Date, force?: boolean): Promise<void>;
takeCommittedReadModelChanges(): RealtimeReadModelChanges | null; takeCommittedReadModelChanges(): RealtimeReadModelChanges | null;
takeCommittedReadModelChangeReceipt(): CommittedReadModelChangeReceipt | null; takeCommittedReadModelChangeReceipt(): CommittedReadModelChangeReceipt | null;
close(): Promise<void>; close(): Promise<void>;
@@ -2087,6 +2089,31 @@ export const createDatabaseTurnHooks = async (
committed.acknowledge(); committed.acknowledge();
enqueueCommittedReceipt(committed.readModelChanges, committed.journalWrite); enqueueCommittedReceipt(committed.readModelChanges, committed.journalWrite);
}; };
const flushInitialAudit = async (observedAt: Date, force = false): Promise<void> => {
if (hasAuditDocumentBaseline(world)) {
if (force || world.hasPendingAuditRecords()) await flushChanges();
return;
}
const checkpoint = world.captureState();
let committed: Awaited<ReturnType<typeof persistChanges>>;
try {
committed = await prisma.$transaction(async (transaction) => {
// seed/RESET과 같은 schema lock을 먼저 잡아 문서 scan 중 초기화를 막는다.
await transaction.$queryRaw`SELECT pg_advisory_xact_lock(hashtextextended(current_schema(), 0))::text AS lock_result`;
await options?.turnDaemonLease?.assertActive(transaction);
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
await acquireGameSchemaAdvisoryXactLock(transaction, GENERAL_ACCESS_PERSISTENCE_LOCK);
await synchronizeRuntimeClockAuthorityUnderHeldLock(transaction, world);
await persistAuditDocumentBaseline(transaction, world, observedAt);
return persistChanges(transaction);
}, transactionOptions);
} catch (error) {
world.restoreState(checkpoint);
throw error;
}
committed.acknowledge();
enqueueCommittedReceipt(committed.readModelChanges, committed.journalWrite);
};
const hooks: TurnDaemonHooks = { const hooks: TurnDaemonHooks = {
flushChanges, flushChanges,
commitCommand: async (requestId, result) => { commitCommand: async (requestId, result) => {
@@ -2133,6 +2160,7 @@ export const createDatabaseTurnHooks = async (
return { return {
hooks, hooks,
flushChanges, flushChanges,
flushInitialAudit,
takeCommittedReadModelChanges: () => { takeCommittedReadModelChanges: () => {
return takeCommittedReceipt()?.changes ?? null; return takeCommittedReceipt()?.changes ?? null;
}, },
+1 -3
View File
@@ -933,10 +933,8 @@ const createTurnDaemonRuntimeWithLease = async (
initializeAuditPolicies(world); initializeAuditPolicies(world);
const diplomacyInitialized = initializeAuditDiplomacy(world, new Date(clock.nowMs())); const diplomacyInitialized = initializeAuditDiplomacy(world, new Date(clock.nowMs()));
initializeAuditCollection(world, new Date(clock.nowMs())); initializeAuditCollection(world, new Date(clock.nowMs()));
if (world.hasPendingAuditRecords() || diplomacyInitialized) { await dbHooks.flushInitialAudit(new Date(clock.nowMs()), diplomacyInitialized);
await dbHooks.flushChanges();
dbHooks.takeCommittedReadModelChangeReceipt(); dbHooks.takeCommittedReadModelChangeReceipt();
}
} catch (error) { } catch (error) {
await Promise.allSettled([ await Promise.allSettled([
dbHooks.close(), dbHooks.close(),
@@ -1,6 +1,11 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { asRecord, GAME_TICKS_PER_TURN } from '@sammo-ts/common'; import { asRecord, GAME_TICKS_PER_TURN } from '@sammo-ts/common';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; import {
createGamePostgresConnector,
hashAuditDiplomacyDocument,
type GamePrismaClient,
type GamePrisma,
} from '@sammo-ts/infra';
import { seedScenarioToDatabase } from '../src/scenario/scenarioSeeder.js'; import { seedScenarioToDatabase } from '../src/scenario/scenarioSeeder.js';
import { createTurnDaemonRuntime, type TurnDaemonRuntime } from '../src/turn/turnDaemon.js'; import { createTurnDaemonRuntime, type TurnDaemonRuntime } from '../src/turn/turnDaemon.js';
@@ -66,6 +71,21 @@ integration('initial audit durability before runtime readiness', () => {
await db.diplomacy.create({ await db.diplomacy.create({
data: { srcNationId: 91990, destNationId: 91991, stateCode: 7, term: 12, meta: { dead: 34 } }, data: { srcNationId: 91990, destNationId: 91991, stateCode: 7, term: 12, meta: { dead: 34 } },
}); });
await db.diplomacyLetter.createMany({
data: Array.from({ length: 201 }, (_, index) => ({
id: 1000 + index,
srcNationId: 91990,
destNationId: 91991,
prevId: index ? 999 + index : null,
state: index === 200 ? ('ACTIVATED' as const) : ('REPLACED' as const),
textBrief: `도입 전 문서 ${index}`,
textDetail: `<p>보유 원문 ${index}</p>`,
date: new Date('2026-09-01T00:00:00Z'),
srcSignerId: 70001,
destSignerId: 70002,
aux: { src: { nationName: '옛 국명', generalName: '옛 서명자' }, debug: '비공개 임의 값' },
})),
});
}, 60_000); }, 60_000);
afterAll(async () => { afterAll(async () => {
await runtime?.close(); await runtime?.close();
@@ -92,6 +112,7 @@ integration('initial audit durability before runtime readiness', () => {
expect(await db.playAuditPolicy.count()).toBe(0); expect(await db.playAuditPolicy.count()).toBe(0);
expect(await db.playAuditDiplomacyEvent.count()).toBe(0); expect(await db.playAuditDiplomacyEvent.count()).toBe(0);
expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDiplomacy).toBeUndefined(); expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDiplomacy).toBeUndefined();
expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDocuments).toBeUndefined();
expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditCollection).toBeUndefined(); expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditCollection).toBeUndefined();
expect( expect(
(await db.nation.findMany()).every((nation) => asRecord(nation.meta)._playAuditPolicy === undefined) (await db.nation.findMany()).every((nation) => asRecord(nation.meta)._playAuditPolicy === undefined)
@@ -117,7 +138,7 @@ integration('initial audit durability before runtime readiness', () => {
const initial = await db.playAuditMonth.findFirstOrThrow({ where: { serverId, kind: 'INITIAL' } }); const initial = await db.playAuditMonth.findFirstOrThrow({ where: { serverId, kind: 'INITIAL' } });
const policies = await db.playAuditPolicy.findMany({ where: { serverId }, orderBy: { id: 'asc' } }); const policies = await db.playAuditPolicy.findMany({ where: { serverId }, orderBy: { id: 'asc' } });
const diplomacy = await db.playAuditDiplomacyEvent.findMany({ const diplomacy = await db.playAuditDiplomacyEvent.findMany({
where: { serverId }, where: { serverId, category: 'RELATION' },
orderBy: { ordinal: 'asc' }, orderBy: { ordinal: 'asc' },
}); });
expect(diplomacy).toHaveLength( expect(diplomacy).toHaveLength(
@@ -146,6 +167,26 @@ integration('initial audit durability before runtime readiness', () => {
).toBe(true); ).toBe(true);
const diplomacyMarker = asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDiplomacy; const diplomacyMarker = asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDiplomacy;
expect(diplomacyMarker).toMatchObject({ serverId, schemaVersion: 1, relationCount: diplomacy.length }); expect(diplomacyMarker).toMatchObject({ serverId, schemaVersion: 1, relationCount: diplomacy.length });
const documentEvents = await db.playAuditDiplomacyEvent.findMany({
where: { serverId, category: 'DOCUMENT' },
orderBy: { ordinal: 'asc' },
});
expect(documentEvents).toHaveLength(201);
const currentLetter = await db.diplomacyLetter.findUniqueOrThrow({ where: { id: 1200 } });
expect(documentEvents[200]).toMatchObject({
source: 'BASELINE',
eventType: 'LETTER_BASELINE',
documentId: 1200,
previousDocumentId: 1199,
documentHash: hashAuditDiplomacyDocument(currentLetter),
actor: null,
before: null,
after: { state: 'ACTIVATED', srcNationName: '옛 국명', srcSignerName: '옛 서명자' },
});
expect(documentEvents[0]).toMatchObject({ after: { state: 'REPLACED' } });
expect(JSON.stringify(documentEvents.map(({ after }) => after))).not.toContain('비공개 임의 값');
const documentMarker = asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDocuments;
expect(documentMarker).toMatchObject({ serverId, schemaVersion: 1, documentCount: 201 });
expect(policies).toHaveLength((await db.nation.count()) * 4); expect(policies).toHaveLength((await db.nation.count()) * 4);
expect( expect(
@@ -171,10 +212,20 @@ integration('initial audit durability before runtime readiness', () => {
expect(await db.playAuditPolicy.findMany({ where: { serverId }, orderBy: { id: 'asc' } })).toEqual(policies); expect(await db.playAuditPolicy.findMany({ where: { serverId }, orderBy: { id: 'asc' } })).toEqual(policies);
expect(await db.playAuditMonth.findMany({ where: { serverId, kind: 'INITIAL' } })).toEqual([initial]); expect(await db.playAuditMonth.findMany({ where: { serverId, kind: 'INITIAL' } })).toEqual([initial]);
expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditCollection).toEqual(marker); expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditCollection).toEqual(marker);
expect(await db.playAuditDiplomacyEvent.findMany({ where: { serverId }, orderBy: { ordinal: 'asc' } })).toEqual( expect(
diplomacy await db.playAuditDiplomacyEvent.findMany({
); where: { serverId, category: 'RELATION' },
orderBy: { ordinal: 'asc' },
})
).toEqual(diplomacy);
expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDiplomacy).toEqual(diplomacyMarker); expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDiplomacy).toEqual(diplomacyMarker);
expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDocuments).toEqual(documentMarker);
expect(
await db.playAuditDiplomacyEvent.findMany({
where: { serverId, category: 'DOCUMENT' },
orderBy: { ordinal: 'asc' },
})
).toEqual(documentEvents);
expect(await clock()).toEqual(beforeClock); expect(await clock()).toEqual(beforeClock);
expect(await db.inputEvent.count()).toBe(beforeInputs); expect(await db.inputEvent.count()).toBe(beforeInputs);
await expect( await expect(
@@ -208,4 +259,24 @@ integration('initial audit durability before runtime readiness', () => {
expect(policies.every((policy) => policy.tick === runtime!.world.getGameClockState().tick)).toBe(true); expect(policies.every((policy) => policy.tick === runtime!.world.getGameClockState().tick)).toBe(true);
expect(await db.playAuditMonth.findMany({ where: { serverId, kind: 'INITIAL' } })).toEqual([initial]); expect(await db.playAuditMonth.findMany({ where: { serverId, kind: 'INITIAL' } })).toEqual([initial]);
}, 30_000); }, 30_000);
it('adopts an empty document collection without duplicating an existing initial sample', async () => {
await runtime?.close();
runtime = undefined;
const original = await db.worldState.findFirstOrThrow();
const meta = asRecord(original.meta);
delete meta.playAuditDocuments;
await db.diplomacyLetter.deleteMany();
await db.playAuditDiplomacyEvent.deleteMany({ where: { category: 'DOCUMENT' } });
await db.worldState.update({ where: { id: original.id }, data: { meta: meta as GamePrisma.InputJsonObject } });
const samples = await db.playAuditMonth.findMany({ orderBy: { id: 'asc' } });
runtime = await start();
const marker = asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDocuments;
expect(marker).toMatchObject({ serverId, schemaVersion: 1, documentCount: 0 });
expect(await db.playAuditDiplomacyEvent.count({ where: { category: 'DOCUMENT' } })).toBe(0);
expect(await db.playAuditMonth.findMany({ orderBy: { id: 'asc' } })).toEqual(samples);
await runtime.close();
runtime = undefined;
runtime = await start();
expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDocuments).toEqual(marker);
}, 30_000);
}); });
+34 -10
View File
@@ -43,7 +43,7 @@ const general = {
items: { horse: null, weapon: null, book: null, item: null }, items: { horse: null, weapon: null, book: null, item: null },
}, },
}; };
const install = async (page: Page, denied = false, baseline = false) => { const install = async (page: Page, denied = false, baseline: boolean | 'document' = 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) => {
localStorage.setItem('sammo-game-token', 'ga_audit'); localStorage.setItem('sammo-game-token', 'ga_audit');
@@ -101,10 +101,10 @@ const install = async (page: Page, denied = false, baseline = false) => {
sequence: '1', sequence: '1',
srcNationId: 2, srcNationId: 2,
destNationId: 3, destNationId: 3,
category: 'RELATION', category: baseline === 'document' ? 'DOCUMENT' : 'RELATION',
source: 'BASELINE', source: 'BASELINE',
eventType: 'RELATION_BASELINE', eventType: baseline === 'document' ? 'LETTER_BASELINE' : 'RELATION_BASELINE',
documentId: null, documentId: baseline === 'document' ? 8 : null,
previousDocumentId: null, previousDocumentId: null,
year: 190, year: 190,
month: 1, month: 1,
@@ -150,25 +150,38 @@ const install = async (page: Page, denied = false, baseline = false) => {
sequence: '1', sequence: '1',
srcNationId: 2, srcNationId: 2,
destNationId: 3, destNationId: 3,
category: 'RELATION', category: baseline === 'document' ? 'DOCUMENT' : 'RELATION',
source: 'BASELINE', source: 'BASELINE',
eventType: 'RELATION_BASELINE', eventType: baseline === 'document' ? 'LETTER_BASELINE' : 'RELATION_BASELINE',
documentId: null, documentId: baseline === 'document' ? 8 : null,
previousDocumentId: null, previousDocumentId: null,
year: 190, year: 190,
month: 1, month: 1,
actor: null, actor: null,
createdAt: world.asOf, createdAt: world.asOf,
before: null, before: null,
after: { state: 2, term: 0, dead: 0 }, after:
baseline === 'document'
? { state: 'ACTIVATED' }
: { state: 2, term: 0, dead: 0 },
tick: '0', tick: '0',
clockRevision: '1', clockRevision: '1',
ordinal: 1, ordinal: 1,
executionId: 'relation-baseline', executionId: 'relation-baseline',
requestId: null, requestId: null,
inputSequence: null, inputSequence: null,
documentStatus: 'NOT_APPLICABLE', documentStatus: baseline === 'document' ? 'AVAILABLE' : 'NOT_APPLICABLE',
document: null, document:
baseline === 'document'
? {
id: 8,
writtenAt: '2026-09-01T00:00:00.000Z',
brief: '보유 협정',
briefHtml: '<p>보유 협정</p>',
detail: '<p>도입 전 본문</p>',
detailHtml: '<p>도입 전 본문</p>',
}
: null,
}, },
}); });
return result({ return result({
@@ -927,3 +940,14 @@ test('diplomacy baseline displays observed state without a fictional document or
await expect(page.getByRole('region', { name: '당시 외교 문서' })).toHaveCount(0); await expect(page.getByRole('region', { name: '당시 외교 문서' })).toHaveCount(0);
expect(requests.filter(({ operation }) => operation === 'playAudit.diplomacyHistory')).toHaveLength(1); expect(requests.filter(({ operation }) => operation === 'playAudit.diplomacyHistory')).toHaveLength(1);
}); });
test('existing diplomacy document is an initial observation with its preserved source', async ({ page }) => {
await install(page, false, 'document');
await page.goto(
gamePath('/play-audit?tab=diplomacy&nation=2&otherNation=3&fromYear=190&fromMonth=1&year=190&month=6')
);
await page.getByRole('button', { name: '문서 최초 관측', exact: true }).click();
await expect(page.getByLabel('외교 전후 값', { exact: true })).toContainText('미관측 / 없음');
await expect(page.getByRole('region', { name: '당시 외교 문서' })).toContainText('도입 전 본문');
await expect(page.getByRole('heading', { name: '문서 #8' })).toBeVisible();
});
@@ -22,6 +22,7 @@ let generation = 0;
let detailGeneration = 0; let detailGeneration = 0;
const selected = computed(() => (typeof route.query.event === 'string' ? route.query.event : null)); const selected = computed(() => (typeof route.query.event === 'string' ? route.query.event : null));
const labels: Record<string, string> = { const labels: Record<string, string> = {
LETTER_BASELINE: '문서 최초 관측',
RELATION_BASELINE: '관계 최초 관측', RELATION_BASELINE: '관계 최초 관측',
LETTER_PROPOSED: '문서 제안', LETTER_PROPOSED: '문서 제안',
LETTER_REPLACED: '문서 교체', LETTER_REPLACED: '문서 교체',
+21
View File
@@ -223,6 +223,27 @@ WAL 실측, 이후 신생국·소멸국 및 기존 문서의 도입 기준은
실제 격리 PostgreSQL startup 검증은 실패 rollback, PREOPEN 저장, 재시작의 동일 행과 실제 격리 PostgreSQL startup 검증은 실패 rollback, PREOPEN 저장, 재시작의 동일 행과
표식 및 readiness 경계를 확인한다. UI는 이를 '관계 최초 관측'으로 구분한다. 표식 및 readiness 경계를 확인한다. UI는 이를 '관계 최초 관측'으로 구분한다.
### 기존 외교 문서 도입 기준
`databaseHooks.flushInitialAudit`는 문서 도입 표식이 없는 기수에만 seed와 같은 schema lock → lease → CLOCK →
GENERAL_ACCESS 순서의 transaction을 열고 clock authority와 기수 identity를 확인한다.
`documentBaseline.ts`가 문서를 id cursor 200건씩 읽어 기존 불변 원문 참조/hash와
관측 당시 상태·서명·이전 문서 번호를 저장한다. 원문 자체를 사건에 복제하지 않는다.
현재 유효 문서뿐 아니라 보유 중인 교체·종료 문서도 보존하며 과거 승낙 시점이나
actor는 만들지 않는다. 기존 API가 쓰던 상태 projection을 infra로 옮겨 함께 사용한다.
문서 기준 사건·world 표식과 대기 중인 초기 상태/정책/관계가 같은 fenced transaction에서
확정되고 commit 뒤에만 ack한다. 실패는 memory checkpoint를 복원한다. 문서가 0건이어도
표식을 저장하고 일반 재시작에서는 문서 SELECT를 수행하지 않는다. 본문 mutation API와
같은 CLOCK lock 아래서 읽으며, 조회 준비가 되기 전에 종료한다.
비용 재검토: 최초에 작은 identity SELECT 1회, 문서 SELECT `floor(L/200)+1`회와 기존
writer의 batch INSERT/hash 확인 SELECT를 사용한다. 원문은 해시 계산에 필요한 한 batch만
유지하며 매 턴 다시 읽지 않는다. 초기 실패 복원을 위한 world memory checkpoint 1개의
비용과 대규모 도입 transaction 시간·WAL은 전체 COST gate에서 측정해야 한다.
실제 PG fixture는 201건/2 batch와 INITIAL 저장 실패의 전체 rollback, 원문 hash와 상태,
재시작의 동일 event/표식을 확인한다. 화면에서는 '문서 최초 관측'으로 구분한다.
## NPC·국방 정책 버전 저장 기반 ## NPC·국방 정책 버전 저장 기반
`PlayAuditPolicy`는 현재 기수/국가/영역별 불변 revision과 이전 버전 ID를 보존한다. `PlayAuditPolicy`는 현재 기수/국가/영역별 불변 revision과 이전 버전 ID를 보존한다.
+26
View File
@@ -1,3 +1,4 @@
import { asRecord } from '@sammo-ts/common';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { GamePrisma, type GamePrismaClient } from './gamePrisma.js'; import { GamePrisma, type GamePrismaClient } from './gamePrisma.js';
@@ -88,3 +89,28 @@ export const persistAuditDiplomacyEvents = async (
throw new Error('Play audit diplomacy replay payload conflict'); throw new Error('Play audit diplomacy replay payload conflict');
} }
}; };
export const projectAuditDocumentState = (
letter: Pick<
GamePrisma.DiplomacyLetterGetPayload<Record<string, never>>,
'state' | 'srcSignerId' | 'destSignerId' | 'aux'
>
): 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,
};
};