기존 외교 문서를 플레이 감사 최초 관측으로 보존
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { asRecord, GameClock, inferClockPhase, parseGameClockPhase, readTurnRecovery } from '@sammo-ts/common';
|
||||
import { GameClock, inferClockPhase, parseGameClockPhase, readTurnRecovery } from '@sammo-ts/common';
|
||||
import {
|
||||
GamePrisma,
|
||||
hashAuditDiplomacyDocument,
|
||||
persistAuditDiplomacyEvents,
|
||||
projectAuditDocumentState,
|
||||
readTurnRuntimeReady,
|
||||
type AuditDiplomacyEventDraft,
|
||||
} from '@sammo-ts/infra';
|
||||
@@ -18,25 +19,7 @@ type DocumentAction =
|
||||
| '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,
|
||||
};
|
||||
};
|
||||
export { projectAuditDocumentState } from '@sammo-ts/infra';
|
||||
|
||||
interface AuditCoordinateRow {
|
||||
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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { persistAuditDiplomacyEvents } from '@sammo-ts/infra';
|
||||
import { hasAuditDocumentBaseline, persistAuditDocumentBaseline } from '../playAudit/documentBaseline.js';
|
||||
import { persistAuditPolicies } from '../playAudit/policyPersistence.js';
|
||||
import { prunePreviousAuditBatch, type AuditRetentionResult } from '../playAudit/retention.js';
|
||||
import { persistAuditMonth } from '../playAudit/persistence.js';
|
||||
@@ -76,6 +77,7 @@ import { prepareRealtimeRecovery } from './prepareRealtimeRecovery.js';
|
||||
export interface DatabaseTurnHooks {
|
||||
hooks: TurnDaemonHooks;
|
||||
flushChanges(): Promise<void>;
|
||||
flushInitialAudit(observedAt: Date, force?: boolean): Promise<void>;
|
||||
takeCommittedReadModelChanges(): RealtimeReadModelChanges | null;
|
||||
takeCommittedReadModelChangeReceipt(): CommittedReadModelChangeReceipt | null;
|
||||
close(): Promise<void>;
|
||||
@@ -2087,6 +2089,31 @@ export const createDatabaseTurnHooks = async (
|
||||
committed.acknowledge();
|
||||
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 = {
|
||||
flushChanges,
|
||||
commitCommand: async (requestId, result) => {
|
||||
@@ -2133,6 +2160,7 @@ export const createDatabaseTurnHooks = async (
|
||||
return {
|
||||
hooks,
|
||||
flushChanges,
|
||||
flushInitialAudit,
|
||||
takeCommittedReadModelChanges: () => {
|
||||
return takeCommittedReceipt()?.changes ?? null;
|
||||
},
|
||||
|
||||
@@ -933,10 +933,8 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
initializeAuditPolicies(world);
|
||||
const diplomacyInitialized = initializeAuditDiplomacy(world, new Date(clock.nowMs()));
|
||||
initializeAuditCollection(world, new Date(clock.nowMs()));
|
||||
if (world.hasPendingAuditRecords() || diplomacyInitialized) {
|
||||
await dbHooks.flushChanges();
|
||||
dbHooks.takeCommittedReadModelChangeReceipt();
|
||||
}
|
||||
await dbHooks.flushInitialAudit(new Date(clock.nowMs()), diplomacyInitialized);
|
||||
dbHooks.takeCommittedReadModelChangeReceipt();
|
||||
} catch (error) {
|
||||
await Promise.allSettled([
|
||||
dbHooks.close(),
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
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 { createTurnDaemonRuntime, type TurnDaemonRuntime } from '../src/turn/turnDaemon.js';
|
||||
|
||||
@@ -66,6 +71,21 @@ integration('initial audit durability before runtime readiness', () => {
|
||||
await db.diplomacy.create({
|
||||
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);
|
||||
afterAll(async () => {
|
||||
await runtime?.close();
|
||||
@@ -92,6 +112,7 @@ integration('initial audit durability before runtime readiness', () => {
|
||||
expect(await db.playAuditPolicy.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).playAuditDocuments).toBeUndefined();
|
||||
expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditCollection).toBeUndefined();
|
||||
expect(
|
||||
(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 policies = await db.playAuditPolicy.findMany({ where: { serverId }, orderBy: { id: 'asc' } });
|
||||
const diplomacy = await db.playAuditDiplomacyEvent.findMany({
|
||||
where: { serverId },
|
||||
where: { serverId, category: 'RELATION' },
|
||||
orderBy: { ordinal: 'asc' },
|
||||
});
|
||||
expect(diplomacy).toHaveLength(
|
||||
@@ -146,6 +167,26 @@ integration('initial audit durability before runtime readiness', () => {
|
||||
).toBe(true);
|
||||
const diplomacyMarker = asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDiplomacy;
|
||||
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(
|
||||
@@ -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.playAuditMonth.findMany({ where: { serverId, kind: 'INITIAL' } })).toEqual([initial]);
|
||||
expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditCollection).toEqual(marker);
|
||||
expect(await db.playAuditDiplomacyEvent.findMany({ where: { serverId }, orderBy: { ordinal: 'asc' } })).toEqual(
|
||||
diplomacy
|
||||
);
|
||||
expect(
|
||||
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).playAuditDocuments).toEqual(documentMarker);
|
||||
expect(
|
||||
await db.playAuditDiplomacyEvent.findMany({
|
||||
where: { serverId, category: 'DOCUMENT' },
|
||||
orderBy: { ordinal: 'asc' },
|
||||
})
|
||||
).toEqual(documentEvents);
|
||||
expect(await clock()).toEqual(beforeClock);
|
||||
expect(await db.inputEvent.count()).toBe(beforeInputs);
|
||||
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(await db.playAuditMonth.findMany({ where: { serverId, kind: 'INITIAL' } })).toEqual([initial]);
|
||||
}, 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);
|
||||
});
|
||||
|
||||
@@ -43,7 +43,7 @@ const general = {
|
||||
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> }[] = [];
|
||||
await page.addInitScript((profile) => {
|
||||
localStorage.setItem('sammo-game-token', 'ga_audit');
|
||||
@@ -101,10 +101,10 @@ const install = async (page: Page, denied = false, baseline = false) => {
|
||||
sequence: '1',
|
||||
srcNationId: 2,
|
||||
destNationId: 3,
|
||||
category: 'RELATION',
|
||||
category: baseline === 'document' ? 'DOCUMENT' : 'RELATION',
|
||||
source: 'BASELINE',
|
||||
eventType: 'RELATION_BASELINE',
|
||||
documentId: null,
|
||||
eventType: baseline === 'document' ? 'LETTER_BASELINE' : 'RELATION_BASELINE',
|
||||
documentId: baseline === 'document' ? 8 : null,
|
||||
previousDocumentId: null,
|
||||
year: 190,
|
||||
month: 1,
|
||||
@@ -150,25 +150,38 @@ const install = async (page: Page, denied = false, baseline = false) => {
|
||||
sequence: '1',
|
||||
srcNationId: 2,
|
||||
destNationId: 3,
|
||||
category: 'RELATION',
|
||||
category: baseline === 'document' ? 'DOCUMENT' : 'RELATION',
|
||||
source: 'BASELINE',
|
||||
eventType: 'RELATION_BASELINE',
|
||||
documentId: null,
|
||||
eventType: baseline === 'document' ? 'LETTER_BASELINE' : 'RELATION_BASELINE',
|
||||
documentId: baseline === 'document' ? 8 : null,
|
||||
previousDocumentId: null,
|
||||
year: 190,
|
||||
month: 1,
|
||||
actor: null,
|
||||
createdAt: world.asOf,
|
||||
before: null,
|
||||
after: { state: 2, term: 0, dead: 0 },
|
||||
after:
|
||||
baseline === 'document'
|
||||
? { state: 'ACTIVATED' }
|
||||
: { state: 2, term: 0, dead: 0 },
|
||||
tick: '0',
|
||||
clockRevision: '1',
|
||||
ordinal: 1,
|
||||
executionId: 'relation-baseline',
|
||||
requestId: null,
|
||||
inputSequence: null,
|
||||
documentStatus: 'NOT_APPLICABLE',
|
||||
document: null,
|
||||
documentStatus: baseline === 'document' ? 'AVAILABLE' : 'NOT_APPLICABLE',
|
||||
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({
|
||||
@@ -927,3 +940,14 @@ test('diplomacy baseline displays observed state without a fictional document or
|
||||
await expect(page.getByRole('region', { name: '당시 외교 문서' })).toHaveCount(0);
|
||||
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;
|
||||
const selected = computed(() => (typeof route.query.event === 'string' ? route.query.event : null));
|
||||
const labels: Record<string, string> = {
|
||||
LETTER_BASELINE: '문서 최초 관측',
|
||||
RELATION_BASELINE: '관계 최초 관측',
|
||||
LETTER_PROPOSED: '문서 제안',
|
||||
LETTER_REPLACED: '문서 교체',
|
||||
|
||||
@@ -223,6 +223,27 @@ WAL 실측, 이후 신생국·소멸국 및 기존 문서의 도입 기준은
|
||||
실제 격리 PostgreSQL startup 검증은 실패 rollback, PREOPEN 저장, 재시작의 동일 행과
|
||||
표식 및 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·국방 정책 버전 저장 기반
|
||||
|
||||
`PlayAuditPolicy`는 현재 기수/국가/영역별 불변 revision과 이전 버전 ID를 보존한다.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { GamePrisma, type GamePrismaClient } from './gamePrisma.js';
|
||||
|
||||
@@ -88,3 +89,28 @@ export const persistAuditDiplomacyEvents = async (
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user