플레이 감사에 외교 관계 최초 관측을 기록

This commit is contained in:
2026-09-16 06:42:50 +00:00
parent 02ee706af7
commit c719cecc80
7 changed files with 247 additions and 2 deletions
@@ -1,3 +1,4 @@
import { asRecord } from '@sammo-ts/common';
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js'; import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
import type { TurnDiplomacy } from '../turn/types.js'; import type { TurnDiplomacy } from '../turn/types.js';
@@ -99,3 +100,81 @@ export const recordTurnAuditDiplomacy = (
after: nextState, after: nextState,
}); });
}; };
/** 현재 로드된 관계를 도입 시 한 번만 고정한다. 과거 발생 원인/주체는 추정하지 않는다. */
export const initializeAuditDiplomacy = (world: InMemoryTurnWorld, observedAt = new Date()): boolean => {
const state = world.getState();
const serverId = state.meta.serverId;
if (typeof serverId !== 'string' || !serverId.trim()) return false;
const previous = asRecord(state.meta.playAuditDiplomacy);
if (previous.serverId === serverId) {
if (
previous.schemaVersion !== 1 ||
typeof previous.year !== 'number' ||
previous.year < 0 ||
!Number.isInteger(previous.year) ||
!Number.isInteger(previous.month) ||
typeof previous.month !== 'number' ||
previous.month < 1 ||
previous.month > 12 ||
typeof previous.tick !== 'number' ||
!Number.isSafeInteger(previous.tick) ||
previous.tick < 0 ||
typeof previous.clockRevision !== 'number' ||
!Number.isSafeInteger(previous.clockRevision) ||
previous.clockRevision < 0 ||
typeof previous.relationCount !== 'number' ||
!Number.isInteger(previous.relationCount) ||
previous.relationCount < 0 ||
previous.year * 12 + previous.month > state.currentYear * 12 + state.currentMonth ||
typeof previous.observedAt !== 'string' ||
!Number.isFinite(Date.parse(previous.observedAt))
)
throw new Error('Invalid play audit diplomacy boundary');
return false;
}
const clock = world.getGameClockState();
const observedAtIso = observedAt.toISOString();
const relations = world
.listDiplomacy()
.filter((entry) => entry.fromNationId > 0 && entry.toNationId > 0)
.sort((left, right) => left.fromNationId - right.fromNationId || left.toNationId - right.toNationId);
for (const [index, entry] of relations.entries()) {
world.queueAuditDiplomacy({
schemaVersion: 1,
serverId,
srcNationId: entry.fromNationId,
destNationId: entry.toNationId,
category: 'RELATION',
source: 'BASELINE',
eventType: 'RELATION_BASELINE',
documentId: null,
documentHash: null,
previousDocumentId: null,
year: state.currentYear,
month: state.currentMonth,
tick: BigInt(clock.tick),
clockRevision: BigInt(clock.revision),
executionId: 'relation-baseline',
ordinal: index + 1,
requestId: null,
inputSequence: null,
actor: null,
before: null,
after: { state: entry.state, term: entry.term, dead: entry.dead },
});
}
world.updateWorldMeta({
playAuditDiplomacy: {
schemaVersion: 1,
serverId,
year: state.currentYear,
month: state.currentMonth,
tick: clock.tick,
clockRevision: clock.revision,
observedAt: observedAtIso,
relationCount: relations.length,
},
});
return true;
};
+4 -1
View File
@@ -1,3 +1,4 @@
import { initializeAuditDiplomacy } from '../playAudit/diplomacy.js';
import { initializeAuditPolicies } from '../playAudit/policy.js'; import { initializeAuditPolicies } from '../playAudit/policy.js';
import { startAuditRetentionWorker } from '../playAudit/retentionWorker.js'; import { startAuditRetentionWorker } from '../playAudit/retentionWorker.js';
import { createPlayAuditHandler, initializeAuditCollection } from '../playAudit/collection.js'; import { createPlayAuditHandler, initializeAuditCollection } from '../playAudit/collection.js';
@@ -806,6 +807,7 @@ const createTurnDaemonRuntimeWithLease = async (
worldRef = world; worldRef = world;
if (!databaseFlushEnabled) { if (!databaseFlushEnabled) {
initializeAuditPolicies(world); initializeAuditPolicies(world);
initializeAuditDiplomacy(world, new Date(clock.nowMs()));
initializeAuditCollection(world, new Date(clock.nowMs())); initializeAuditCollection(world, new Date(clock.nowMs()));
} }
@@ -929,8 +931,9 @@ const createTurnDaemonRuntimeWithLease = async (
// 복구된 clock에서 기준을 고정하고 readiness 공개 전에 원자적으로 저장한다. // 복구된 clock에서 기준을 고정하고 readiness 공개 전에 원자적으로 저장한다.
// 명령 없는 PREOPEN도 기록하며 input_event나 게임 RNG를 만들지 않는다. // 명령 없는 PREOPEN도 기록하며 input_event나 게임 RNG를 만들지 않는다.
initializeAuditPolicies(world); initializeAuditPolicies(world);
const diplomacyInitialized = initializeAuditDiplomacy(world, new Date(clock.nowMs()));
initializeAuditCollection(world, new Date(clock.nowMs())); initializeAuditCollection(world, new Date(clock.nowMs()));
if (world.hasPendingAuditRecords()) { if (world.hasPendingAuditRecords() || diplomacyInitialized) {
await dbHooks.flushChanges(); await dbHooks.flushChanges();
dbHooks.takeCommittedReadModelChangeReceipt(); dbHooks.takeCommittedReadModelChangeReceipt();
} }
@@ -1,3 +1,4 @@
import { initializeAuditDiplomacy } from '../src/playAudit/diplomacy.js';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { City, Nation } from '@sammo-ts/logic'; import type { City, Nation } from '@sammo-ts/logic';
import { InMemoryTurnWorld, type GeneralTurnHandler } from '../src/turn/inMemoryWorld.js'; import { InMemoryTurnWorld, type GeneralTurnHandler } from '../src/turn/inMemoryWorld.js';
@@ -128,6 +129,37 @@ const buildWorld = (generalTurnHandler?: GeneralTurnHandler) => {
return world; return world;
}; };
describe('play audit collection durability state', () => { describe('play audit collection durability state', () => {
it('marks an empty diplomacy baseline without inventing relations and validates before queuing', () => {
const world = buildWorld();
world.removeNation(1);
world.removeNation(2);
expect(() => initializeAuditDiplomacy(world, new Date('invalid'))).toThrow(RangeError);
expect(world.getState().meta.playAuditDiplomacy).toBeUndefined();
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual([]);
expect(initializeAuditDiplomacy(world)).toBe(true);
expect(world.getState().meta.playAuditDiplomacy).toMatchObject({ relationCount: 0 });
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual([]);
expect(initializeAuditDiplomacy(world)).toBe(false);
});
it('captures a single diplomacy baseline and restores its marker and queue together', () => {
const world = buildWorld();
const checkpoint = world.captureState();
expect(initializeAuditDiplomacy(world, new Date('2026-09-16T00:00:00Z'))).toBe(true);
const events = world.peekDirtyState().pendingAuditDiplomacy;
expect(events).toHaveLength(2);
expect(events.map((event) => [event.srcNationId, event.destNationId])).toEqual([
[1, 2],
[2, 1],
]);
expect(events[0]).toMatchObject({ source: 'BASELINE', before: null, after: { state: 2, term: 0, dead: 0 } });
expect(initializeAuditDiplomacy(world)).toBe(false);
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual(events);
world.restoreState(checkpoint);
expect(world.getState().meta.playAuditDiplomacy).toBeUndefined();
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual([]);
});
it('preserves consecutive diplomacy transitions in one turn and restores them with the checkpoint', () => { it('preserves consecutive diplomacy transitions in one turn and restores them with the checkpoint', () => {
const world = buildWorld({ const world = buildWorld({
execute: ({ general }) => ({ execute: ({ general }) => ({
@@ -43,6 +43,7 @@ integration('initial audit durability before runtime readiness', () => {
closeDb = () => connector.disconnect(); closeDb = () => connector.disconnect();
await db.playAuditMonth.deleteMany(); await db.playAuditMonth.deleteMany();
await db.playAuditPolicy.deleteMany(); await db.playAuditPolicy.deleteMany();
await db.playAuditDiplomacyEvent.deleteMany();
await seedScenarioToDatabase({ await seedScenarioToDatabase({
scenarioId: 903, scenarioId: 903,
databaseUrl: databaseUrl!, databaseUrl: databaseUrl!,
@@ -56,6 +57,15 @@ integration('initial audit durability before runtime readiness', () => {
season: 1, season: 1,
}, },
}); });
await db.nation.createMany({
data: [
{ id: 91990, name: '기준 발신국', color: '#ffffff' },
{ id: 91991, name: '기준 수신국', color: '#000000' },
],
});
await db.diplomacy.create({
data: { srcNationId: 91990, destNationId: 91991, stateCode: 7, term: 12, meta: { dead: 34 } },
});
}, 60_000); }, 60_000);
afterAll(async () => { afterAll(async () => {
await runtime?.close(); await runtime?.close();
@@ -80,6 +90,8 @@ integration('initial audit durability before runtime readiness', () => {
expect(String(error)).toContain('fixture initial audit failure'); expect(String(error)).toContain('fixture initial audit failure');
expect(await db.playAuditMonth.count()).toBe(0); expect(await db.playAuditMonth.count()).toBe(0);
expect(await db.playAuditPolicy.count()).toBe(0); 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).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)
@@ -104,6 +116,37 @@ integration('initial audit durability before runtime readiness', () => {
expect((await db.turnDaemonLease.findUniqueOrThrow({ where: { profile } })).clockReady).toBe(true); expect((await db.turnDaemonLease.findUniqueOrThrow({ where: { profile } })).clockReady).toBe(true);
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({
where: { serverId },
orderBy: { ordinal: 'asc' },
});
expect(diplomacy).toHaveLength(
await db.diplomacy.count({ where: { srcNationId: { gt: 0 }, destNationId: { gt: 0 } } })
);
expect(diplomacy).toHaveLength(2);
expect(diplomacy[0]).toMatchObject({
srcNationId: 91990,
destNationId: 91991,
before: null,
after: { state: 7, term: 12, dead: 34 },
});
expect(diplomacy[1]).toMatchObject({
srcNationId: 91991,
destNationId: 91990,
after: { state: 2, term: 0, dead: 0 },
});
expect(
diplomacy.every(
(event) =>
event.source === 'BASELINE' &&
event.before === null &&
event.actor === null &&
event.requestId === null
)
).toBe(true);
const diplomacyMarker = asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDiplomacy;
expect(diplomacyMarker).toMatchObject({ serverId, schemaVersion: 1, relationCount: diplomacy.length });
expect(policies).toHaveLength((await db.nation.count()) * 4); expect(policies).toHaveLength((await db.nation.count()) * 4);
expect( expect(
policies.every( policies.every(
@@ -128,6 +171,10 @@ 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(
diplomacy
);
expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDiplomacy).toEqual(diplomacyMarker);
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(
+65 -1
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) => { const install = async (page: Page, denied = false, baseline = 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');
@@ -90,6 +90,29 @@ const install = async (page: Page, denied = false) => {
nextCursor: null, nextCursor: null,
}); });
case 'playAudit.diplomacyHistory': case 'playAudit.diplomacyHistory':
if (baseline)
return result({
...world,
coverage: 'RECORDED_EVENTS_ONLY',
nextCursor: null,
items: [
{
id: 'c'.repeat(64),
sequence: '1',
srcNationId: 2,
destNationId: 3,
category: 'RELATION',
source: 'BASELINE',
eventType: 'RELATION_BASELINE',
documentId: null,
previousDocumentId: null,
year: 190,
month: 1,
actor: null,
createdAt: world.asOf,
},
],
});
return result({ return result({
...world, ...world,
coverage: 'RECORDED_EVENTS_ONLY', coverage: 'RECORDED_EVENTS_ONLY',
@@ -119,6 +142,35 @@ const install = async (page: Page, denied = false) => {
], ],
}); });
case 'playAudit.diplomacyEvent': case 'playAudit.diplomacyEvent':
if (baseline)
return result({
...world,
event: {
id: input.id,
sequence: '1',
srcNationId: 2,
destNationId: 3,
category: 'RELATION',
source: 'BASELINE',
eventType: 'RELATION_BASELINE',
documentId: null,
previousDocumentId: null,
year: 190,
month: 1,
actor: null,
createdAt: world.asOf,
before: null,
after: { state: 2, term: 0, dead: 0 },
tick: '0',
clockRevision: '1',
ordinal: 1,
executionId: 'relation-baseline',
requestId: null,
inputSequence: null,
documentStatus: 'NOT_APPLICABLE',
document: null,
},
});
return result({ return result({
...world, ...world,
event: { event: {
@@ -863,3 +915,15 @@ test('diplomacy detail failure retries independently', async ({ page }) => {
await expect(page.getByRole('heading', { name: '문서 #8' })).toBeVisible(); await expect(page.getByRole('heading', { name: '문서 #8' })).toBeVisible();
expect(requests.filter(({ operation }) => operation === 'playAudit.diplomacyHistory')).toHaveLength(count); expect(requests.filter(({ operation }) => operation === 'playAudit.diplomacyHistory')).toHaveLength(count);
}); });
test('diplomacy baseline displays observed state without a fictional document or actor', async ({ page }) => {
const requests = await install(page, false, true);
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.getByLabel('외교 전후 값', { exact: true })).toContainText('미관측 / 없음');
await expect(page.getByRole('region', { name: '당시 외교 문서' })).toHaveCount(0);
expect(requests.filter(({ operation }) => operation === 'playAudit.diplomacyHistory')).toHaveLength(1);
});
@@ -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> = {
RELATION_BASELINE: '관계 최초 관측',
LETTER_PROPOSED: '문서 제안', LETTER_PROPOSED: '문서 제안',
LETTER_REPLACED: '문서 교체', LETTER_REPLACED: '문서 교체',
LETTER_ACCEPTED: '문서 승인', LETTER_ACCEPTED: '문서 승인',
+19
View File
@@ -204,6 +204,25 @@ Chromium의 CHE/HWE에서 desktop1280×720/mobile390×844, DPR1로 검증했다.
원문 script 비실행을 검사한다. 외교 최초 기준과 최종 mutation inventory/전체 비용 원문 script 비실행을 검사한다. 외교 최초 기준과 최종 mutation inventory/전체 비용
검증은 남아 있으며 화면 추가만으로 R4 전체 완료를 판단하지 않는다. 검증은 남아 있으며 화면 추가만으로 R4 전체 완료를 판단하지 않는다.
### 외교 관계 최초 관측
`initializeAuditDiplomacy`는 복구된 clock과 이미 로드한 관계에서 실제 국가쌍의
방향별 state/term/dead를 한 번만 기록한다. 재야(0)는 제외하며 당시의 관측 값만
보존한다. 이전 상태·원인·actor는 null이고 과거 체결 시점을 추정하지 않는다.
`playAuditDiplomacy` 기수 표식과 사건을 readiness 전 기존 fenced flush에서 함께
저장한다. 관계가 없는 경우에도 표식은 flush하고, 정상 재시작은 다시 쓰지 않는다.
checkpoint rollback은 표식과 pending 사건을 함께 복원한다.
추가 DB 전체 SELECT는 없다. 초기 관계 목록의 짧은 필드만 기존 200행 batch writer로
저장하며 최초 저장량은 방향별 관계 수에 비례한다. 정상 재시작은 메모리 표식 검사만
수행한다. 기본 교역 관계도 최초에는 보존해 국가쌍 조회의 시작 값을 제공하지만,
월간 기본 matrix 생성은 계속 전이로 기록하지 않는다. 대규모 국가 수의 payload와
WAL 실측, 이후 신생국·소멸국 및 기존 문서의 도입 기준은 별도 검증이 남아 있다.
단위 검증은 재야 제외·빈 관계·잘못된 관측 시각·재호출·checkpoint 복원을 다룬다.
실제 격리 PostgreSQL startup 검증은 실패 rollback, PREOPEN 저장, 재시작의 동일 행과
표식 및 readiness 경계를 확인한다. UI는 이를 '관계 최초 관측'으로 구분한다.
## NPC·국방 정책 버전 저장 기반 ## NPC·국방 정책 버전 저장 기반
`PlayAuditPolicy`는 현재 기수/국가/영역별 불변 revision과 이전 버전 ID를 보존한다. `PlayAuditPolicy`는 현재 기수/국가/영역별 불변 revision과 이전 버전 ID를 보존한다.