감사 정책 head 유실을 복구하고 데몬 준비 상태로 배포를 검증한다

This commit is contained in:
2026-09-26 15:21:17 +00:00
parent 11d0a4afdd
commit daf7a87799
14 changed files with 323 additions and 31 deletions
+4 -5
View File
@@ -51,7 +51,7 @@ import { ReadModelOutboxWorker } from './realtime/outboxWorker.js';
import { DeferredGeneralAccessWorker } from './services/deferredGeneralAccess.js'; import { DeferredGeneralAccessWorker } from './services/deferredGeneralAccess.js';
import { WebPushOutboxWorker } from './services/webPushOutboxWorker.js'; import { WebPushOutboxWorker } from './services/webPushOutboxWorker.js';
import { scopeHttpIdempotencyKey } from './requestId.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 => { const extractBearerToken = (value: string | string[] | undefined): string | null => {
if (!value) { if (!value) {
@@ -436,14 +436,13 @@ export const createGameApiServer = async () => {
}); });
app.get('/healthz', async (_request, reply) => { app.get('/healthz', async (_request, reply) => {
const clock = await loadClockReadiness(postgres.prisma); const readiness = await loadProfileReadiness(postgres.prisma, config.profileName);
if (!clock.reconciliationComplete) reply.code(503); if (!readiness.ok) reply.code(503);
return { return {
ok: clock.reconciliationComplete, ...readiness,
profile: config.profileName, profile: config.profileName,
postgresPool: postgres.getPoolStats(), postgresPool: postgres.getPoolStats(),
accountIconReconciliation: accountIconResetReconciler.getHealth(), accountIconReconciliation: accountIconResetReconciler.getHealth(),
clock,
}; };
}); });
@@ -1,4 +1,5 @@
import { parseGameClockPhase } from '@sammo-ts/common'; import { parseGameClockPhase } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra';
import type { DatabaseClient } from '../context.js'; 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<Array<{ ready: boolean }>>(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 };
};
+16 -1
View File
@@ -1,9 +1,24 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import type { DatabaseClient } from '../src/context.js'; 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', () => { 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 () => { it('fails closed when the reconciliation schema is not available', async () => {
const db = {} as DatabaseClient; const db = {} as DatabaseClient;
await expect(loadClockReadiness(db)).resolves.toEqual({ await expect(loadClockReadiness(db)).resolves.toEqual({
@@ -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<void>;
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);
});
});
@@ -1,5 +1,7 @@
import { GamePrisma, type InputJsonValue } from '@sammo-ts/infra'; 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 ( export const persistAuditPolicies = async (
tx: GamePrisma.TransactionClient, tx: GamePrisma.TransactionClient,
@@ -43,3 +45,48 @@ export const persistAuditPolicies = async (
throw new Error('Play audit policy replay payload conflict'); throw new Error('Play audit policy replay payload conflict');
} }
}; };
/** 원장은 그대로 두고, 구버전 NPC 생성이 유실한 head projection만 복구한다. */
export const restoreMissingAuditPolicyHeads = async (
tx: GamePrisma.TransactionClient,
world: InMemoryTurnWorld
): Promise<boolean> => {
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;
};
+3 -1
View File
@@ -1,7 +1,7 @@
import { persistAuditDecisions } from '../playAudit/decisionPersistence.js'; import { persistAuditDecisions } from '../playAudit/decisionPersistence.js';
import { persistAuditDiplomacyEvents } from '@sammo-ts/infra'; import { persistAuditDiplomacyEvents } from '@sammo-ts/infra';
import { hasAuditDocumentBaseline, persistAuditDocumentBaseline } from '../playAudit/documentBaseline.js'; 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 { prunePreviousAuditBatch, type AuditRetentionResult } from '../playAudit/retention.js';
import { persistAuditMonth } from '../playAudit/persistence.js'; import { persistAuditMonth } from '../playAudit/persistence.js';
import { persistGeneralAccessScores, persistGeneralUpdates } from './generalBatchPersistence.js'; import { persistGeneralAccessScores, persistGeneralUpdates } from './generalBatchPersistence.js';
@@ -78,6 +78,7 @@ import { prepareRealtimeRecovery } from './prepareRealtimeRecovery.js';
export interface DatabaseTurnHooks { export interface DatabaseTurnHooks {
hooks: TurnDaemonHooks; hooks: TurnDaemonHooks;
flushChanges(): Promise<void>; flushChanges(): Promise<void>;
restoreMissingAuditPolicyHeads(): Promise<boolean>;
flushInitialAudit(observedAt: Date, force?: boolean): Promise<void>; flushInitialAudit(observedAt: Date, force?: boolean): Promise<void>;
takeCommittedReadModelChanges(): RealtimeReadModelChanges | null; takeCommittedReadModelChanges(): RealtimeReadModelChanges | null;
takeCommittedReadModelChangeReceipt(): CommittedReadModelChangeReceipt | null; takeCommittedReadModelChangeReceipt(): CommittedReadModelChangeReceipt | null;
@@ -2164,6 +2165,7 @@ export const createDatabaseTurnHooks = async (
hooks, hooks,
flushChanges, flushChanges,
flushInitialAudit, flushInitialAudit,
restoreMissingAuditPolicyHeads: () => restoreMissingAuditPolicyHeads(prisma, world),
takeCommittedReadModelChanges: () => { takeCommittedReadModelChanges: () => {
return takeCommittedReceipt()?.changes ?? null; return takeCommittedReceipt()?.changes ?? null;
}, },
@@ -396,7 +396,8 @@ export const createRaiseInvaderHandler = (options: {
} }
world.updateNation(nationId, { world.updateNation(nationId, {
chiefGeneralId: ruler.id, 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]) { for (const officerLevel of [12, 11, 10, 9]) {
options.reservedTurns.ensureNationTurns(nationId, officerLevel); options.reservedTurns.ensureNationTurns(nationId, officerLevel);
@@ -395,7 +395,8 @@ export const createRaiseNpcNationHandler = (options: {
} }
world.updateNation(nationId, { world.updateNation(nationId, {
chiefGeneralId: ruler.id, 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, 12);
options.reservedTurns.ensureNationTurns(nationId, 11); options.reservedTurns.ensureNationTurns(nationId, 11);
+2 -1
View File
@@ -937,10 +937,11 @@ const createTurnDaemonRuntimeWithLease = async (
await dbHooks.prepareRealtimeRecovery({ paused: await gatewayGate?.shouldPause() }); await dbHooks.prepareRealtimeRecovery({ paused: await gatewayGate?.shouldPause() });
// 복구된 clock에서 기준을 고정하고 readiness 공개 전에 원자적으로 저장한다. // 복구된 clock에서 기준을 고정하고 readiness 공개 전에 원자적으로 저장한다.
// 명령 없는 PREOPEN도 기록하며 input_event나 게임 RNG를 만들지 않는다. // 명령 없는 PREOPEN도 기록하며 input_event나 게임 RNG를 만들지 않는다.
const policyHeadsRestored = await dbHooks.restoreMissingAuditPolicyHeads();
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()));
await dbHooks.flushInitialAudit(new Date(clock.nowMs()), diplomacyInitialized); await dbHooks.flushInitialAudit(new Date(clock.nowMs()), diplomacyInitialized || policyHeadsRestored);
dbHooks.takeCommittedReadModelChangeReceipt(); dbHooks.takeCommittedReadModelChangeReceipt();
} catch (error) { } catch (error) {
await Promise.allSettled([ await Promise.allSettled([
@@ -1,3 +1,4 @@
import { initializeAuditPolicies } from '../src/playAudit/policy.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { City, Nation } from '@sammo-ts/logic'; import type { City, Nation } from '@sammo-ts/logic';
@@ -186,6 +187,31 @@ describe('invader monthly actions', () => {
vi.restoreAllMocks(); 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 () => { it('creates the invader nation, generals, diplomacy, follow-up events, and city state', async () => {
const harness = buildHarness(); const harness = buildHarness();
const handler = createRaiseInvaderHandler({ const handler = createRaiseInvaderHandler({
@@ -1,3 +1,4 @@
import { initializeAuditPolicies } from '../src/playAudit/policy.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { import {
parseScenarioGeneralPoolCandidate, parseScenarioGeneralPoolCandidate,
@@ -228,6 +229,21 @@ describe('RaiseNPCNation monthly action', () => {
vi.restoreAllMocks(); 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 () => { it('uses a U30 subordinate name/dex/special while preserving RaiseNPCNation random stats', async () => {
const info = { const info = {
generalName: '부장후보', generalName: '부장후보',
@@ -337,6 +353,32 @@ describe('RaiseNPCNation monthly action', () => {
"id": 2, "id": 2,
"level": 2, "level": 2,
"meta": { "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, "bill": 100,
"can_국기변경": 1, "can_국기변경": 1,
"gennum": 1, "gennum": 1,
@@ -235,6 +235,64 @@ integration('initial audit durability before runtime readiness', () => {
).rejects.toMatchObject({ code: 'P2002' }); ).rejects.toMatchObject({ code: 'P2002' });
}, 30_000); }, 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 () => { it('captures newly observed policies after durable clock recovery without replacing the initial sample', async () => {
await runtime?.close(); await runtime?.close();
runtime = undefined; runtime = undefined;
+37 -19
View File
@@ -9,14 +9,14 @@
## 지금 사용할 수 있는 기능 ## 지금 사용할 수 있는 기능
| 화면 | 사용할 수 있는 정보 | 읽을 때 주의할 점 | | 화면 | 사용할 수 있는 정보 | 읽을 때 주의할 점 |
| --- | --- | --- | | -------- | ---------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| 국가 | 월말 금·쌀·기술력, 세율·실제 정산, 유저/NPC/부대장 NPC별 자원·숙련 집계, 월/6개월 그래프 | 보유량은 마지막 월말, 수입/지급액은 기간 합. 부분 기간/미수집은 0과 다름 | | 국가 | 월말 금·쌀·기술력, 세율·실제 정산, 유저/NPC/부대장 NPC별 자원·숙련 집계, 월/6개월 그래프 | 보유량은 마지막 월말, 수입/지급액은 기간 합. 부분 기간/미수집은 0과 다름 |
| 장수 | 이름 부분 검색·장수 번호 정렬, 모든 국가·재야의 현재/월말 장수, 자원·능력·숙련·병력·훈련·사기·장비·특기·위치, 독립 로그 상세 | 현재 예약은 현재 조회에서만 제공. 과거 월말은 그달 모든 명령의 이력이 아님 | | 장수 | 이름 부분 검색·장수 번호 정렬, 모든 국가·재야의 현재/월말 장수, 자원·능력·숙련·병력·훈련·사기·장비·특기·위치, 독립 로그 상세 | 현재 예약은 현재 조회에서만 제공. 과거 월말은 그달 모든 명령의 이력이 아님 |
| 도시 | 현재/월말 소유·내정 상태와 국가별 주둔 장수 상세 연결 | 월말 주둔은 그달 모든 방문자가 아님. 지도 기반 탐색은 미완성 | | 도시 | 현재/월말 소유·내정 상태와 국가별 주둔 장수 상세 연결 | 월말 주둔은 그달 모든 방문자가 아님. 지도 기반 탐색은 미완성 |
| 외교 | 국가쌍·기간별 문서 제안/승인/철회/파기, 즉시 합의와 월간·명령 관계 전이, 생성/소멸 관계 | 문서 내용과 실제 관계는 별개. 최초 관측 이전 사건은 복원하지 않음 | | 외교 | 국가쌍·기간별 문서 제안/승인/철회/파기, 즉시 합의와 월간·명령 관계 전이, 생성/소멸 관계 | 문서 내용과 실제 관계는 별개. 최초 관측 이전 사건은 복원하지 않음 |
| NPC 결정 | 장수별 개인·수뇌 판단, 절차 시도/차단, 관측한 RNG 결과와 선택·실제 실행·대체 시도 | 새 실행부터 수집하며 후보 내부 조건 전체는 미완성 | | NPC 결정 | 장수별 개인·수뇌 판단, 절차 시도/차단, 관측한 RNG 결과와 선택·실제 실행·대체 시도 | 새 실행부터 수집하며 후보 내부 조건 전체는 미완성 |
| 정책 | NPC 국가 값·국가/장수 우선순위·국방 설정의 기준 버전과 변경 전후, 당시 주체 | 수뇌용 공개 화면은 아직 없음. NPC 결정에서 저장된 당시 버전을 직접 조회 가능 | | 정책 | NPC 국가 값·국가/장수 우선순위·국방 설정의 기준 버전과 변경 전후, 당시 주체 | 수뇌용 공개 화면은 아직 없음. NPC 결정에서 저장된 당시 버전을 직접 조회 가능 |
목록/그래프와 선택 상세를 분리해 읽는다. 표는 기본50건이며 더 보기를 명시적으로 목록/그래프와 선택 상세를 분리해 읽는다. 표는 기본50건이며 더 보기를 명시적으로
누른다. 현재 상태는 수동으로 조회하며 백그라운드 polling은 하지 않는다. 필터·월·선택 누른다. 현재 상태는 수동으로 조회하며 백그라운드 polling은 하지 않는다. 필터·월·선택
@@ -60,17 +60,17 @@
수동 CREATE TABLE로 대신 적용하지 않는다. 현재 game chain은58개이며 다음 감사 수동 CREATE TABLE로 대신 적용하지 않는다. 현재 game chain은58개이며 다음 감사
migration들을 포함한다. 기존 기록을 삭제하거나 지난달 상세를 역산하지 않는다. migration들을 포함한다. 기존 기록을 삭제하거나 지난달 상세를 역산하지 않는다.
| migration | 준비되는 저장소/제약 | | migration | 준비되는 저장소/제약 |
| --- | --- | | ----------------------------------------------------- | --------------------------------------------------------------------- |
| `20260916010000_add_play_audit_month` | 월 표본과 국가·도시·장수 projection 4개 테이블 | | `20260916010000_add_play_audit_month` | 월 표본과 국가·도시·장수 projection 4개 테이블 |
| `20260916020000_add_log_entry_server_id` | 새 장수 로그의 불변 기수 identity | | `20260916020000_add_log_entry_server_id` | 새 장수 로그의 불변 기수 identity |
| `20260916030000_add_play_audit_policy` | 국가별 불변 정책 revision | | `20260916030000_add_play_audit_policy` | 국가별 불변 정책 revision |
| `20260916031000_add_play_audit_policy_schema_version` | 정책 payload 버전 | | `20260916031000_add_play_audit_policy_schema_version` | 정책 payload 버전 |
| `20260916040000_add_play_audit_initial` | 기수당 INITIAL 표본 1개 제약 | | `20260916040000_add_play_audit_initial` | 기수당 INITIAL 표본 1개 제약 |
| `20260916050000_add_play_audit_diplomacy` | 방향·국가쌍·실행 순서 기반 외교 사건과 불변 원문 보호 | | `20260916050000_add_play_audit_diplomacy` | 방향·국가쌍·실행 순서 기반 외교 사건과 불변 원문 보호 |
| `20260916060000_widen_play_audit_ticks` | 월 표본/정책 tick을 BIGINT로 확장하여 약60개월 이후 INTEGER 초과 방지 | | `20260916060000_widen_play_audit_ticks` | 월 표본/정책 tick을 BIGINT로 확장하여 약60개월 이후 INTEGER 초과 방지 |
| `20260916070000_add_play_audit_decision` | NPC/자동턴 결정 요약과 순서별 상세 chunk, bounded 정리용 FK/index | | `20260916070000_add_play_audit_decision` | NPC/자동턴 결정 요약과 순서별 상세 chunk, bounded 정리용 FK/index |
| `20260916080000_index_play_audit_decision_month` | 기존 장수 인덱스를 월 조건 포함 인덱스로 교체하여 기수 전체 조회 방지 | | `20260916080000_index_play_audit_decision_month` | 기존 장수 인덱스를 월 조건 포함 인덱스로 교체하여 기수 전체 조회 방지 |
운영은 [릴리스 절차](release-operations.md)의 **DB 보존 버전 업데이트**로 해당 고정 운영은 [릴리스 절차](release-operations.md)의 **DB 보존 버전 업데이트**로 해당 고정
commit을 적용한다. 이 기능을 켜기 위해 시나리오를 초기화할 필요는 없다. 수동 환경의 commit을 적용한다. 이 기능을 켜기 위해 시나리오를 초기화할 필요는 없다. 수동 환경의
@@ -115,3 +115,21 @@ Gateway의 새 진입/권한 catalog도 사용하려면 같은 commit의 Gateway
후속 NPC/행위/계정 저장소의 필드·순서·보존·인덱스 요구는 설계의 R5/조사 A~F와 비용 후속 NPC/행위/계정 저장소의 필드·순서·보존·인덱스 요구는 설계의 R5/조사 A~F와 비용
표를 유지한다. 미구현 저장소를 현재 수집 중이라고 표시하지 않는다. 다음 작업은 해당 표를 유지한다. 미구현 저장소를 현재 수집 중이라고 표시하지 않는다. 다음 작업은 해당
writer와 rollback·정리 경계를 함께 추가하는 정식 migration으로 이어가야 한다. 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도
데몬 초기화가 완료되면 준비 완료이며, 턴 진행 여부와 준비 상태는 별개다.
+3 -1
View File
@@ -193,7 +193,9 @@ Gateway process 전환이 진행 중인 profile migration·seed 실행자를 중
3. profile game schema에 `prisma migrate deploy`를 실행합니다. 3. profile game schema에 `prisma migrate deploy`를 실행합니다.
4. Scenario seed를 실행하지 않고 API, daemon과 worker를 시작하고 새 정적 frontend 4. Scenario seed를 실행하지 않고 API, daemon과 worker를 시작하고 새 정적 frontend
artifact를 원자적으로 활성화합니다. 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이 이 모드는 현재 scenario, status와 인게임 DB를 유지합니다. Migration이
데이터를 변환할 수 있으므로 대상 migration의 운영 데이터 영향은 배포 전에 데이터를 변환할 수 있으므로 대상 migration의 운영 데이터 영향은 배포 전에