플레이 감사에 외교 관계 최초 관측을 기록
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
|
||||
import type { TurnDiplomacy } from '../turn/types.js';
|
||||
|
||||
@@ -99,3 +100,81 @@ export const recordTurnAuditDiplomacy = (
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { initializeAuditDiplomacy } from '../playAudit/diplomacy.js';
|
||||
import { initializeAuditPolicies } from '../playAudit/policy.js';
|
||||
import { startAuditRetentionWorker } from '../playAudit/retentionWorker.js';
|
||||
import { createPlayAuditHandler, initializeAuditCollection } from '../playAudit/collection.js';
|
||||
@@ -806,6 +807,7 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
worldRef = world;
|
||||
if (!databaseFlushEnabled) {
|
||||
initializeAuditPolicies(world);
|
||||
initializeAuditDiplomacy(world, new Date(clock.nowMs()));
|
||||
initializeAuditCollection(world, new Date(clock.nowMs()));
|
||||
}
|
||||
|
||||
@@ -929,8 +931,9 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
// 복구된 clock에서 기준을 고정하고 readiness 공개 전에 원자적으로 저장한다.
|
||||
// 명령 없는 PREOPEN도 기록하며 input_event나 게임 RNG를 만들지 않는다.
|
||||
initializeAuditPolicies(world);
|
||||
const diplomacyInitialized = initializeAuditDiplomacy(world, new Date(clock.nowMs()));
|
||||
initializeAuditCollection(world, new Date(clock.nowMs()));
|
||||
if (world.hasPendingAuditRecords()) {
|
||||
if (world.hasPendingAuditRecords() || diplomacyInitialized) {
|
||||
await dbHooks.flushChanges();
|
||||
dbHooks.takeCommittedReadModelChangeReceipt();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { initializeAuditDiplomacy } from '../src/playAudit/diplomacy.js';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { City, Nation } from '@sammo-ts/logic';
|
||||
import { InMemoryTurnWorld, type GeneralTurnHandler } from '../src/turn/inMemoryWorld.js';
|
||||
@@ -128,6 +129,37 @@ const buildWorld = (generalTurnHandler?: GeneralTurnHandler) => {
|
||||
return world;
|
||||
};
|
||||
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', () => {
|
||||
const world = buildWorld({
|
||||
execute: ({ general }) => ({
|
||||
|
||||
@@ -43,6 +43,7 @@ integration('initial audit durability before runtime readiness', () => {
|
||||
closeDb = () => connector.disconnect();
|
||||
await db.playAuditMonth.deleteMany();
|
||||
await db.playAuditPolicy.deleteMany();
|
||||
await db.playAuditDiplomacyEvent.deleteMany();
|
||||
await seedScenarioToDatabase({
|
||||
scenarioId: 903,
|
||||
databaseUrl: databaseUrl!,
|
||||
@@ -56,6 +57,15 @@ integration('initial audit durability before runtime readiness', () => {
|
||||
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);
|
||||
afterAll(async () => {
|
||||
await runtime?.close();
|
||||
@@ -80,6 +90,8 @@ integration('initial audit durability before runtime readiness', () => {
|
||||
expect(String(error)).toContain('fixture initial audit failure');
|
||||
expect(await db.playAuditMonth.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(
|
||||
(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);
|
||||
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 },
|
||||
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.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.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(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDiplomacy).toEqual(diplomacyMarker);
|
||||
expect(await clock()).toEqual(beforeClock);
|
||||
expect(await db.inputEvent.count()).toBe(beforeInputs);
|
||||
await expect(
|
||||
|
||||
@@ -43,7 +43,7 @@ const general = {
|
||||
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> }[] = [];
|
||||
await page.addInitScript((profile) => {
|
||||
localStorage.setItem('sammo-game-token', 'ga_audit');
|
||||
@@ -90,6 +90,29 @@ const install = async (page: Page, denied = false) => {
|
||||
nextCursor: null,
|
||||
});
|
||||
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({
|
||||
...world,
|
||||
coverage: 'RECORDED_EVENTS_ONLY',
|
||||
@@ -119,6 +142,35 @@ const install = async (page: Page, denied = false) => {
|
||||
],
|
||||
});
|
||||
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({
|
||||
...world,
|
||||
event: {
|
||||
@@ -863,3 +915,15 @@ test('diplomacy detail failure retries independently', async ({ page }) => {
|
||||
await expect(page.getByRole('heading', { name: '문서 #8' })).toBeVisible();
|
||||
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;
|
||||
const selected = computed(() => (typeof route.query.event === 'string' ? route.query.event : null));
|
||||
const labels: Record<string, string> = {
|
||||
RELATION_BASELINE: '관계 최초 관측',
|
||||
LETTER_PROPOSED: '문서 제안',
|
||||
LETTER_REPLACED: '문서 교체',
|
||||
LETTER_ACCEPTED: '문서 승인',
|
||||
|
||||
@@ -204,6 +204,25 @@ Chromium의 CHE/HWE에서 desktop1280×720/mobile390×844, DPR1로 검증했다.
|
||||
원문 script 비실행을 검사한다. 외교 최초 기준과 최종 mutation inventory/전체 비용
|
||||
검증은 남아 있으며 화면 추가만으로 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·국방 정책 버전 저장 기반
|
||||
|
||||
`PlayAuditPolicy`는 현재 기수/국가/영역별 불변 revision과 이전 버전 ID를 보존한다.
|
||||
|
||||
Reference in New Issue
Block a user