From daf7a8779938fe406c99c8240bc59d6a2dd2ff6e Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 26 Sep 2026 15:21:17 +0000 Subject: [PATCH] =?UTF-8?q?=EA=B0=90=EC=82=AC=20=EC=A0=95=EC=B1=85=20head?= =?UTF-8?q?=20=EC=9C=A0=EC=8B=A4=EC=9D=84=20=EB=B3=B5=EA=B5=AC=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20=EB=8D=B0=EB=AA=AC=20=EC=A4=80=EB=B9=84=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=EB=A1=9C=20=EB=B0=B0=ED=8F=AC=EB=A5=BC=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/server.ts | 9 ++- app/game-api/src/services/clockReadiness.ts | 18 ++++++ app/game-api/test/clockReadiness.test.ts | 17 ++++- .../test/profileReadiness.integration.test.ts | 62 +++++++++++++++++++ .../src/playAudit/policyPersistence.ts | 49 ++++++++++++++- app/game-engine/src/turn/databaseHooks.ts | 4 +- .../src/turn/monthlyInvaderAction.ts | 3 +- .../src/turn/monthlyRaiseNpcNationAction.ts | 3 +- app/game-engine/src/turn/turnDaemon.ts | 3 +- .../test/monthlyInvaderAction.test.ts | 26 ++++++++ .../test/monthlyRaiseNpcNationAction.test.ts | 42 +++++++++++++ .../test/playAuditStartup.integration.test.ts | 58 +++++++++++++++++ docs/play-audit-operations.md | 56 +++++++++++------ docs/release-operations.md | 4 +- 14 files changed, 323 insertions(+), 31 deletions(-) create mode 100644 app/game-api/test/profileReadiness.integration.test.ts diff --git a/app/game-api/src/server.ts b/app/game-api/src/server.ts index 3fdcfe7b..c82830c7 100644 --- a/app/game-api/src/server.ts +++ b/app/game-api/src/server.ts @@ -51,7 +51,7 @@ import { ReadModelOutboxWorker } from './realtime/outboxWorker.js'; import { DeferredGeneralAccessWorker } from './services/deferredGeneralAccess.js'; import { WebPushOutboxWorker } from './services/webPushOutboxWorker.js'; import { scopeHttpIdempotencyKey } from './requestId.js'; -import { loadClockReadiness } from './services/clockReadiness.js'; +import { loadProfileReadiness } from './services/clockReadiness.js'; const extractBearerToken = (value: string | string[] | undefined): string | null => { if (!value) { @@ -436,14 +436,13 @@ export const createGameApiServer = async () => { }); app.get('/healthz', async (_request, reply) => { - const clock = await loadClockReadiness(postgres.prisma); - if (!clock.reconciliationComplete) reply.code(503); + const readiness = await loadProfileReadiness(postgres.prisma, config.profileName); + if (!readiness.ok) reply.code(503); return { - ok: clock.reconciliationComplete, + ...readiness, profile: config.profileName, postgresPool: postgres.getPoolStats(), accountIconReconciliation: accountIconResetReconciler.getHealth(), - clock, }; }); diff --git a/app/game-api/src/services/clockReadiness.ts b/app/game-api/src/services/clockReadiness.ts index b457245a..3fcae660 100644 --- a/app/game-api/src/services/clockReadiness.ts +++ b/app/game-api/src/services/clockReadiness.ts @@ -1,4 +1,5 @@ import { parseGameClockPhase } from '@sammo-ts/common'; +import { GamePrisma } from '@sammo-ts/infra'; import type { DatabaseClient } from '../context.js'; @@ -90,3 +91,20 @@ export const loadClockAdminStatus = async (db: DatabaseClient) => { }, }; }; + +/** API 기동과 별개로 데몬의 초기 감사 저장 및 clock 복구가 끝나야 배포 준비 완료다. */ +export const loadProfileReadiness = async (db: DatabaseClient, profileName: string) => { + const [clock, leases] = await Promise.all([ + loadClockReadiness(db), + db.$queryRaw>(GamePrisma.sql` + SELECT EXISTS ( + SELECT 1 FROM turn_daemon_lease + WHERE profile = ${profileName} + AND clock_ready = TRUE + AND lease_until > (clock_timestamp() AT TIME ZONE 'UTC') + ) AS ready + `), + ]); + const daemonReady = leases[0]?.ready === true; + return { ok: clock.reconciliationComplete && daemonReady, clock, daemonReady }; +}; diff --git a/app/game-api/test/clockReadiness.test.ts b/app/game-api/test/clockReadiness.test.ts index e910b5f8..86b7329c 100644 --- a/app/game-api/test/clockReadiness.test.ts +++ b/app/game-api/test/clockReadiness.test.ts @@ -1,9 +1,24 @@ import { describe, expect, it, vi } from 'vitest'; import type { DatabaseClient } from '../src/context.js'; -import { loadClockAdminStatus, loadClockReadiness } from '../src/services/clockReadiness.js'; +import { loadClockAdminStatus, loadClockReadiness, loadProfileReadiness } from '../src/services/clockReadiness.js'; describe('clock reconciliation readiness', () => { + it.each([false, true])('requires the profile daemon lease to be ready: %s', async (ready) => { + const db = { + worldState: { + findFirst: vi.fn(async () => ({ clockPhase: 'PREOPEN', clockRevision: 1n, deadlineGeneration: 0n })), + }, + clockProjectionOutbox: { count: vi.fn(async () => 0) }, + $queryRaw: vi.fn(async () => [{ ready }]), + } as unknown as DatabaseClient; + await expect(loadProfileReadiness(db, 'che:default')).resolves.toMatchObject({ + ok: ready, + daemonReady: ready, + clock: { reconciliationComplete: true, gameplayEnabled: false }, + }); + }); + it('fails closed when the reconciliation schema is not available', async () => { const db = {} as DatabaseClient; await expect(loadClockReadiness(db)).resolves.toEqual({ diff --git a/app/game-api/test/profileReadiness.integration.test.ts b/app/game-api/test/profileReadiness.integration.test.ts new file mode 100644 index 00000000..fa71b410 --- /dev/null +++ b/app/game-api/test/profileReadiness.integration.test.ts @@ -0,0 +1,62 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; +import { loadProfileReadiness } from '../src/services/clockReadiness.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +integration('profile deployment readiness', () => { + let db: GamePrismaClient; + let close: () => Promise; + beforeAll(async () => { + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + close = () => connector.disconnect(); + }); + afterAll(async () => { + await close?.(); + }); + it('rejects absent, initializing, expired and other-profile leases; accepts ready paused/preopen runtime', async () => { + const rollback = new Error('readiness fixture rollback'); + await expect( + db.$transaction(async (tx) => { + // Shared integration fixtures may leave projection work; rollback restores it. + await tx.clockProjectionOutbox.deleteMany(); + await tx.worldState.create({ + data: { + id: -998901, + scenarioCode: 'readiness-fixture', + currentYear: 190, + currentMonth: 1, + tickSeconds: 300, + clockPhase: 'PREOPEN', + config: {}, + meta: {}, + }, + }); + const profile = 'readiness:fixture'; + expect((await loadProfileReadiness(tx, profile)).ok).toBe(false); + await tx.turnDaemonLease.create({ + data: { + profile, + ownerId: 'fixture', + fencingEpoch: 1n, + heartbeatAt: new Date(), + leaseUntil: new Date(Date.now() + 60_000), + clockReady: false, + }, + }); + expect((await loadProfileReadiness(tx, profile)).ok).toBe(false); + await tx.turnDaemonLease.update({ where: { profile }, data: { clockReady: true } }); + expect((await loadProfileReadiness(tx, profile)).ok).toBe(true); + expect((await loadProfileReadiness(tx, 'readiness:other')).ok).toBe(false); + await tx.turnDaemonLease.update({ + where: { profile }, + data: { leaseUntil: new Date(Date.now() - 60_000) }, + }); + expect((await loadProfileReadiness(tx, profile)).ok).toBe(false); + throw rollback; + }) + ).rejects.toBe(rollback); + }); +}); diff --git a/app/game-engine/src/playAudit/policyPersistence.ts b/app/game-engine/src/playAudit/policyPersistence.ts index 74e58c9f..5bb00c73 100644 --- a/app/game-engine/src/playAudit/policyPersistence.ts +++ b/app/game-engine/src/playAudit/policyPersistence.ts @@ -1,5 +1,7 @@ import { GamePrisma, type InputJsonValue } from '@sammo-ts/infra'; -import { auditPolicyHash, type PendingAuditPolicy } from './policy.js'; +import { asRecord } from '@sammo-ts/common'; +import { AUDIT_POLICY_AREAS, auditPolicyHash, type PendingAuditPolicy } from './policy.js'; +import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js'; export const persistAuditPolicies = async ( tx: GamePrisma.TransactionClient, @@ -43,3 +45,48 @@ export const persistAuditPolicies = async ( throw new Error('Play audit policy replay payload conflict'); } }; + +/** 원장은 그대로 두고, 구버전 NPC 생성이 유실한 head projection만 복구한다. */ +export const restoreMissingAuditPolicyHeads = async ( + tx: GamePrisma.TransactionClient, + world: InMemoryTurnWorld +): Promise => { + const serverId = world.getState().meta.serverId; + if (typeof serverId !== 'string' || !serverId.trim()) return false; + const missing = world.listNations().flatMap((nation) => { + const heads = asRecord(nation.meta._playAuditPolicy); + return AUDIT_POLICY_AREAS.filter((area) => heads[area] === undefined).map( + (area) => GamePrisma.sql`(nation_id = ${nation.id} AND area = ${area})` + ); + }); + if (!missing.length) return false; + // DISTINCT ON으로 각 국가/영역의 마지막 revision만 읽는다. 과거 기수는 복구하지 않는다. + const rows = await tx.$queryRaw< + { id: string; nationId: number; area: string; revision: number; after: InputJsonValue }[] + >(GamePrisma.sql` + SELECT DISTINCT ON (nation_id, area) + id, nation_id AS "nationId", area, revision, "after" + FROM play_audit_policy + WHERE server_id = ${serverId} AND (${GamePrisma.join(missing, ' OR ')}) + ORDER BY nation_id, area, revision DESC + `); + for (const row of rows) { + if (row.revision < 1 || row.id !== auditPolicyHash([serverId, row.nationId, row.area, row.revision])) { + throw new Error('Invalid persisted play audit policy head'); + } + const nation = world.getNationById(row.nationId)!; + const heads = nation.meta._playAuditPolicy; + world.updateNation(nation.id, { + meta: { + ...nation.meta, + _playAuditPolicy: { + ...(heads && typeof heads === 'object' && !Array.isArray(heads) ? heads : {}), + [row.area]: { id: row.id, revision: row.revision, hash: auditPolicyHash(row.after), serverId }, + }, + }, + }); + } + // 실제 정책이 원장 after와 다르면 후속 initializeAuditPolicies가 OBSERVED_GAP을 남긴다. + // 이 메타데이터와 새 이력은 기존 lease/fencing을 거친 startup flush에서 함께 commit된다. + return rows.length > 0; +}; diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index c156faf6..442b65d0 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -1,7 +1,7 @@ import { persistAuditDecisions } from '../playAudit/decisionPersistence.js'; import { persistAuditDiplomacyEvents } from '@sammo-ts/infra'; import { hasAuditDocumentBaseline, persistAuditDocumentBaseline } from '../playAudit/documentBaseline.js'; -import { persistAuditPolicies } from '../playAudit/policyPersistence.js'; +import { persistAuditPolicies, restoreMissingAuditPolicyHeads } from '../playAudit/policyPersistence.js'; import { prunePreviousAuditBatch, type AuditRetentionResult } from '../playAudit/retention.js'; import { persistAuditMonth } from '../playAudit/persistence.js'; import { persistGeneralAccessScores, persistGeneralUpdates } from './generalBatchPersistence.js'; @@ -78,6 +78,7 @@ import { prepareRealtimeRecovery } from './prepareRealtimeRecovery.js'; export interface DatabaseTurnHooks { hooks: TurnDaemonHooks; flushChanges(): Promise; + restoreMissingAuditPolicyHeads(): Promise; flushInitialAudit(observedAt: Date, force?: boolean): Promise; takeCommittedReadModelChanges(): RealtimeReadModelChanges | null; takeCommittedReadModelChangeReceipt(): CommittedReadModelChangeReceipt | null; @@ -2164,6 +2165,7 @@ export const createDatabaseTurnHooks = async ( hooks, flushChanges, flushInitialAudit, + restoreMissingAuditPolicyHeads: () => restoreMissingAuditPolicyHeads(prisma, world), takeCommittedReadModelChanges: () => { return takeCommittedReceipt()?.changes ?? null; }, diff --git a/app/game-engine/src/turn/monthlyInvaderAction.ts b/app/game-engine/src/turn/monthlyInvaderAction.ts index f60f9dde..93644a22 100644 --- a/app/game-engine/src/turn/monthlyInvaderAction.ts +++ b/app/game-engine/src/turn/monthlyInvaderAction.ts @@ -396,7 +396,8 @@ export const createRaiseInvaderHandler = (options: { } world.updateNation(nationId, { chiefGeneralId: ruler.id, - meta: { ...nation.meta, gennum: npcEachCount }, + // addNation이 붙인 감사 이력 head를 오래된 생성 객체로 덮어쓰지 않는다. + meta: { ...world.getNationById(nationId)!.meta, gennum: npcEachCount }, }); for (const officerLevel of [12, 11, 10, 9]) { options.reservedTurns.ensureNationTurns(nationId, officerLevel); diff --git a/app/game-engine/src/turn/monthlyRaiseNpcNationAction.ts b/app/game-engine/src/turn/monthlyRaiseNpcNationAction.ts index c706a2c1..bdfc8b1b 100644 --- a/app/game-engine/src/turn/monthlyRaiseNpcNationAction.ts +++ b/app/game-engine/src/turn/monthlyRaiseNpcNationAction.ts @@ -395,7 +395,8 @@ export const createRaiseNpcNationHandler = (options: { } world.updateNation(nationId, { chiefGeneralId: ruler.id, - meta: { ...nation.meta, gennum: 1 + subordinateCandidates.length }, + // addNation이 붙인 감사 이력 head를 오래된 생성 객체로 덮어쓰지 않는다. + meta: { ...world.getNationById(nationId)!.meta, gennum: 1 + subordinateCandidates.length }, }); options.reservedTurns.ensureNationTurns(nationId, 12); options.reservedTurns.ensureNationTurns(nationId, 11); diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 1f78e4dc..393fbd7c 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -937,10 +937,11 @@ const createTurnDaemonRuntimeWithLease = async ( await dbHooks.prepareRealtimeRecovery({ paused: await gatewayGate?.shouldPause() }); // 복구된 clock에서 기준을 고정하고 readiness 공개 전에 원자적으로 저장한다. // 명령 없는 PREOPEN도 기록하며 input_event나 게임 RNG를 만들지 않는다. + const policyHeadsRestored = await dbHooks.restoreMissingAuditPolicyHeads(); initializeAuditPolicies(world); const diplomacyInitialized = initializeAuditDiplomacy(world, new Date(clock.nowMs())); initializeAuditCollection(world, new Date(clock.nowMs())); - await dbHooks.flushInitialAudit(new Date(clock.nowMs()), diplomacyInitialized); + await dbHooks.flushInitialAudit(new Date(clock.nowMs()), diplomacyInitialized || policyHeadsRestored); dbHooks.takeCommittedReadModelChangeReceipt(); } catch (error) { await Promise.allSettled([ diff --git a/app/game-engine/test/monthlyInvaderAction.test.ts b/app/game-engine/test/monthlyInvaderAction.test.ts index bc0d5b15..9805350d 100644 --- a/app/game-engine/test/monthlyInvaderAction.test.ts +++ b/app/game-engine/test/monthlyInvaderAction.test.ts @@ -1,3 +1,4 @@ +import { initializeAuditPolicies } from '../src/playAudit/policy.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { City, Nation } from '@sammo-ts/logic'; @@ -186,6 +187,31 @@ describe('invader monthly actions', () => { vi.restoreAllMocks(); }); + it('preserves new nation audit heads through ruler initialization and restart', async () => { + const harness = buildHarness(); + const { world, environment } = harness; + world.updateWorldMeta({ serverId: 'new-nation-audit' }); + initializeAuditPolicies(world); + const handler = createRaiseInvaderHandler({ + getWorld: () => world, + reservedTurns: harness.reservedTurns, + env: buildCommandEnv(scenarioConfig), + }); + await handler([10, 150, 100, 20], environment, event); + const created = world.peekDirtyState().createdNations; + expect(created.length).toBeGreaterThan(0); + for (const nation of created) { + expect(Object.keys(nation.meta._playAuditPolicy ?? {})).toHaveLength(4); + } + world.consumeDirtyState(); + initializeAuditPolicies(world); + expect( + world + .peekDirtyState() + .pendingAuditPolicies.filter((policy) => created.some((nation) => nation.id === policy.nationId)) + ).toEqual([]); + }); + it('creates the invader nation, generals, diplomacy, follow-up events, and city state', async () => { const harness = buildHarness(); const handler = createRaiseInvaderHandler({ diff --git a/app/game-engine/test/monthlyRaiseNpcNationAction.test.ts b/app/game-engine/test/monthlyRaiseNpcNationAction.test.ts index eca17664..80d20135 100644 --- a/app/game-engine/test/monthlyRaiseNpcNationAction.test.ts +++ b/app/game-engine/test/monthlyRaiseNpcNationAction.test.ts @@ -1,3 +1,4 @@ +import { initializeAuditPolicies } from '../src/playAudit/policy.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { parseScenarioGeneralPoolCandidate, @@ -228,6 +229,21 @@ describe('RaiseNPCNation monthly action', () => { vi.restoreAllMocks(); }); + it('preserves new nation audit heads through ruler initialization and restart', async () => { + const { world, handler, environment } = buildHarness(); + world.updateWorldMeta({ serverId: 'new-nation-audit' }); + initializeAuditPolicies(world); + await handler([], environment, event); + const created = world.peekDirtyState().createdNations; + expect(created.length).toBeGreaterThan(0); + for (const nation of created) { + expect(Object.keys(nation.meta._playAuditPolicy ?? {})).toHaveLength(4); + } + world.consumeDirtyState(); + initializeAuditPolicies(world); + expect(world.peekDirtyState().pendingAuditPolicies).toEqual([]); + }); + it('uses a U30 subordinate name/dex/special while preserving RaiseNPCNation random stats', async () => { const info = { generalName: '부장후보', @@ -337,6 +353,32 @@ describe('RaiseNPCNation monthly action', () => { "id": 2, "level": 2, "meta": { + "_playAuditPolicy": { + "DEFENCE": { + "hash": "ffb1e0b6bbf1680af65800cfc1b166afbd68149217e2b039c95b4ce4db0d20aa", + "id": "6329bea23c4db57a8c6bfb7d4c81e9f5a3e59171c883e9d22c5ba97dfb7d486c", + "revision": 1, + "serverId": "fixture-server", + }, + "NPC_GENERAL_PRIORITY": { + "hash": "0a6202f188859cb41dee0f08388e9da21babf6f4b569f7b18abf0453caaa1062", + "id": "28bc6ff38cc5b1b250f6a738a6745b5d581badf34bbcaf191e5e8710c17263ec", + "revision": 1, + "serverId": "fixture-server", + }, + "NPC_NATION_PRIORITY": { + "hash": "0a6202f188859cb41dee0f08388e9da21babf6f4b569f7b18abf0453caaa1062", + "id": "feefa645ba10e0fb8c2c78e0c189f9effe8a1c3d2adf4c0d866300d4123f3748", + "revision": 1, + "serverId": "fixture-server", + }, + "NPC_VALUES": { + "hash": "1906fb445a401a46f1279dbba3f3d572e9b213daf6c0f0d31cf8beeee3c77104", + "id": "c6fe336ffe5870cc90e2abab797d050810400404f7ad51b3ceea987b21eb3baa", + "revision": 1, + "serverId": "fixture-server", + }, + }, "bill": 100, "can_국기변경": 1, "gennum": 1, diff --git a/app/game-engine/test/playAuditStartup.integration.test.ts b/app/game-engine/test/playAuditStartup.integration.test.ts index ab3848c7..d20604f3 100644 --- a/app/game-engine/test/playAuditStartup.integration.test.ts +++ b/app/game-engine/test/playAuditStartup.integration.test.ts @@ -235,6 +235,64 @@ integration('initial audit durability before runtime readiness', () => { ).rejects.toMatchObject({ code: 'P2002' }); }, 30_000); + it('restores missing policy heads from immutable history before restart without rewriting history', async () => { + await runtime?.close(); + runtime = undefined; + const policies = await db.playAuditPolicy.findMany({ where: { serverId }, orderBy: { id: 'asc' } }); + const nation = await db.nation.findUniqueOrThrow({ where: { id: 91990 } }); + const { _playAuditPolicy: heads, ...meta } = asRecord(nation.meta); + await db.nation.update({ where: { id: nation.id }, data: { meta: meta as GamePrisma.InputJsonObject } }); + runtime = await start(); + expect( + asRecord((await db.nation.findUniqueOrThrow({ where: { id: nation.id } })).meta)._playAuditPolicy + ).toEqual(heads); + expect(await db.playAuditPolicy.findMany({ where: { serverId }, orderBy: { id: 'asc' } })).toEqual(policies); + expect((await db.turnDaemonLease.findUniqueOrThrow({ where: { profile } })).clockReady).toBe(true); + await runtime.close(); + runtime = undefined; + runtime = await start(); + expect(await db.playAuditPolicy.findMany({ where: { serverId }, orderBy: { id: 'asc' } })).toEqual(policies); + }, 30_000); + + it('links an observed policy gap to the recovered head and keeps it idempotent', async () => { + await runtime?.close(); + runtime = undefined; + const nation = await db.nation.findUniqueOrThrow({ where: { id: 91990 } }); + const meta = asRecord(nation.meta); + const heads = asRecord(meta._playAuditPolicy); + const previous = asRecord(heads.DEFENCE); + const { DEFENCE: _lost, ...remainingHeads } = heads; + await db.nation.update({ + where: { id: nation.id }, + data: { + meta: { ...meta, scout: 1, _playAuditPolicy: remainingHeads } as GamePrisma.InputJsonObject, + }, + }); + runtime = await start(); + const rows = await db.playAuditPolicy.findMany({ + where: { serverId, nationId: nation.id, area: 'DEFENCE' }, + orderBy: { revision: 'asc' }, + }); + expect(rows.at(-1)).toMatchObject({ + source: 'OBSERVED_GAP', + previousId: previous.id, + revision: Number(previous.revision) + 1, + actor: null, + before: null, + after: { scout: 1 }, + }); + expect(asRecord((await db.nation.findUniqueOrThrow({ where: { id: nation.id } })).meta).scout).toBe(1); + await runtime.close(); + runtime = undefined; + runtime = await start(); + expect( + await db.playAuditPolicy.findMany({ + where: { serverId, nationId: nation.id, area: 'DEFENCE' }, + orderBy: { revision: 'asc' }, + }) + ).toEqual(rows); + }, 30_000); + it('captures newly observed policies after durable clock recovery without replacing the initial sample', async () => { await runtime?.close(); runtime = undefined; diff --git a/docs/play-audit-operations.md b/docs/play-audit-operations.md index d65f3f00..e82520c5 100644 --- a/docs/play-audit-operations.md +++ b/docs/play-audit-operations.md @@ -9,14 +9,14 @@ ## 지금 사용할 수 있는 기능 -| 화면 | 사용할 수 있는 정보 | 읽을 때 주의할 점 | -| --- | --- | --- | -| 국가 | 월말 금·쌀·기술력, 세율·실제 정산, 유저/NPC/부대장 NPC별 자원·숙련 집계, 월/6개월 그래프 | 보유량은 마지막 월말, 수입/지급액은 기간 합. 부분 기간/미수집은 0과 다름 | -| 장수 | 이름 부분 검색·장수 번호 정렬, 모든 국가·재야의 현재/월말 장수, 자원·능력·숙련·병력·훈련·사기·장비·특기·위치, 독립 로그 상세 | 현재 예약은 현재 조회에서만 제공. 과거 월말은 그달 모든 명령의 이력이 아님 | -| 도시 | 현재/월말 소유·내정 상태와 국가별 주둔 장수 상세 연결 | 월말 주둔은 그달 모든 방문자가 아님. 지도 기반 탐색은 미완성 | -| 외교 | 국가쌍·기간별 문서 제안/승인/철회/파기, 즉시 합의와 월간·명령 관계 전이, 생성/소멸 관계 | 문서 내용과 실제 관계는 별개. 최초 관측 이전 사건은 복원하지 않음 | -| NPC 결정 | 장수별 개인·수뇌 판단, 절차 시도/차단, 관측한 RNG 결과와 선택·실제 실행·대체 시도 | 새 실행부터 수집하며 후보 내부 조건 전체는 미완성 | -| 정책 | NPC 국가 값·국가/장수 우선순위·국방 설정의 기준 버전과 변경 전후, 당시 주체 | 수뇌용 공개 화면은 아직 없음. NPC 결정에서 저장된 당시 버전을 직접 조회 가능 | +| 화면 | 사용할 수 있는 정보 | 읽을 때 주의할 점 | +| -------- | ---------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| 국가 | 월말 금·쌀·기술력, 세율·실제 정산, 유저/NPC/부대장 NPC별 자원·숙련 집계, 월/6개월 그래프 | 보유량은 마지막 월말, 수입/지급액은 기간 합. 부분 기간/미수집은 0과 다름 | +| 장수 | 이름 부분 검색·장수 번호 정렬, 모든 국가·재야의 현재/월말 장수, 자원·능력·숙련·병력·훈련·사기·장비·특기·위치, 독립 로그 상세 | 현재 예약은 현재 조회에서만 제공. 과거 월말은 그달 모든 명령의 이력이 아님 | +| 도시 | 현재/월말 소유·내정 상태와 국가별 주둔 장수 상세 연결 | 월말 주둔은 그달 모든 방문자가 아님. 지도 기반 탐색은 미완성 | +| 외교 | 국가쌍·기간별 문서 제안/승인/철회/파기, 즉시 합의와 월간·명령 관계 전이, 생성/소멸 관계 | 문서 내용과 실제 관계는 별개. 최초 관측 이전 사건은 복원하지 않음 | +| NPC 결정 | 장수별 개인·수뇌 판단, 절차 시도/차단, 관측한 RNG 결과와 선택·실제 실행·대체 시도 | 새 실행부터 수집하며 후보 내부 조건 전체는 미완성 | +| 정책 | NPC 국가 값·국가/장수 우선순위·국방 설정의 기준 버전과 변경 전후, 당시 주체 | 수뇌용 공개 화면은 아직 없음. NPC 결정에서 저장된 당시 버전을 직접 조회 가능 | 목록/그래프와 선택 상세를 분리해 읽는다. 표는 기본50건이며 더 보기를 명시적으로 누른다. 현재 상태는 수동으로 조회하며 백그라운드 polling은 하지 않는다. 필터·월·선택 @@ -60,17 +60,17 @@ 수동 CREATE TABLE로 대신 적용하지 않는다. 현재 game chain은58개이며 다음 감사 migration들을 포함한다. 기존 기록을 삭제하거나 지난달 상세를 역산하지 않는다. -| migration | 준비되는 저장소/제약 | -| --- | --- | -| `20260916010000_add_play_audit_month` | 월 표본과 국가·도시·장수 projection 4개 테이블 | -| `20260916020000_add_log_entry_server_id` | 새 장수 로그의 불변 기수 identity | -| `20260916030000_add_play_audit_policy` | 국가별 불변 정책 revision | -| `20260916031000_add_play_audit_policy_schema_version` | 정책 payload 버전 | -| `20260916040000_add_play_audit_initial` | 기수당 INITIAL 표본 1개 제약 | -| `20260916050000_add_play_audit_diplomacy` | 방향·국가쌍·실행 순서 기반 외교 사건과 불변 원문 보호 | -| `20260916060000_widen_play_audit_ticks` | 월 표본/정책 tick을 BIGINT로 확장하여 약60개월 이후 INTEGER 초과 방지 | -| `20260916070000_add_play_audit_decision` | NPC/자동턴 결정 요약과 순서별 상세 chunk, bounded 정리용 FK/index | -| `20260916080000_index_play_audit_decision_month` | 기존 장수 인덱스를 월 조건 포함 인덱스로 교체하여 기수 전체 조회 방지 | +| migration | 준비되는 저장소/제약 | +| ----------------------------------------------------- | --------------------------------------------------------------------- | +| `20260916010000_add_play_audit_month` | 월 표본과 국가·도시·장수 projection 4개 테이블 | +| `20260916020000_add_log_entry_server_id` | 새 장수 로그의 불변 기수 identity | +| `20260916030000_add_play_audit_policy` | 국가별 불변 정책 revision | +| `20260916031000_add_play_audit_policy_schema_version` | 정책 payload 버전 | +| `20260916040000_add_play_audit_initial` | 기수당 INITIAL 표본 1개 제약 | +| `20260916050000_add_play_audit_diplomacy` | 방향·국가쌍·실행 순서 기반 외교 사건과 불변 원문 보호 | +| `20260916060000_widen_play_audit_ticks` | 월 표본/정책 tick을 BIGINT로 확장하여 약60개월 이후 INTEGER 초과 방지 | +| `20260916070000_add_play_audit_decision` | NPC/자동턴 결정 요약과 순서별 상세 chunk, bounded 정리용 FK/index | +| `20260916080000_index_play_audit_decision_month` | 기존 장수 인덱스를 월 조건 포함 인덱스로 교체하여 기수 전체 조회 방지 | 운영은 [릴리스 절차](release-operations.md)의 **DB 보존 버전 업데이트**로 해당 고정 commit을 적용한다. 이 기능을 켜기 위해 시나리오를 초기화할 필요는 없다. 수동 환경의 @@ -115,3 +115,21 @@ Gateway의 새 진입/권한 catalog도 사용하려면 같은 commit의 Gateway 후속 NPC/행위/계정 저장소의 필드·순서·보존·인덱스 요구는 설계의 R5/조사 A~F와 비용 표를 유지한다. 미구현 저장소를 현재 수집 중이라고 표시하지 않는다. 다음 작업은 해당 writer와 rollback·정리 경계를 함께 추가하는 정식 migration으로 이어가야 한다. + +## 정책 이력 연결과 재시작 + +신규 NPC 국가와 이민족 국가의 `addNation`은 정책 기준 원장과 국가 meta의 +`_playAuditPolicy` head를 함께 만든다. 후속 군주·장수 수 갱신은 world에 저장된 +최신 meta를 사용해야 한다. 생성 전에 만든 객체를 다시 저장하면 head가 유실되어 +다음 배포 재시작에서 기준 원장의 같은 ID를 다른 시점으로 기록하려다 충돌한다. + +데몬 startup은 현재 기수의 누락된 head만 정책 원장의 마지막 revision으로 복원한다. +기존 원장과 hash는 수정하지 않는다. 현재 정책이 원장과 다르면 `OBSERVED_GAP`을 +다음 revision으로 남기고, 복원 meta와 새 기록은 기존 lease/fencing transaction에서 +함께 저장한다. 이력이 없는 국가는 일반 BASELINE을 만들며 다른 기수 이력은 사용하지 않는다. +실제 replay payload 충돌 검사는 계속 적용한다. + +`/healthz`는 clock reconciliation뿐 아니라 해당 profile의 만료되지 않은 +`clock_ready=true` 데몬 lease까지 확인한다. 감사 startup 실패 중인 API는 503을 +반환하므로 profile 배포 readiness가 이를 성공으로 처리하지 않는다. PREOPEN과 PAUSED도 +데몬 초기화가 완료되면 준비 완료이며, 턴 진행 여부와 준비 상태는 별개다. diff --git a/docs/release-operations.md b/docs/release-operations.md index b881464a..07178123 100644 --- a/docs/release-operations.md +++ b/docs/release-operations.md @@ -193,7 +193,9 @@ Gateway process 전환이 진행 중인 profile migration·seed 실행자를 중 3. profile game schema에 `prisma migrate deploy`를 실행합니다. 4. Scenario seed를 실행하지 않고 API, daemon과 worker를 시작하고 새 정적 frontend artifact를 원자적으로 활성화합니다. -5. HTTP와 모든 PM2 role의 readiness가 확인된 뒤 build commit을 게시합니다. +5. HTTP와 모든 PM2 role의 readiness가 확인된 뒤 build commit을 게시합니다. Game API + `/healthz`는 clock reconciliation과 profile의 유효한 `clock_ready=true` 데몬 lease를 + 함께 검사하므로 감사 초기화가 실패한 API 기동만으로 배포 성공을 게시하지 않습니다. 이 모드는 현재 scenario, status와 인게임 DB를 유지합니다. Migration이 데이터를 변환할 수 있으므로 대상 migration의 운영 데이터 영향은 배포 전에