감사 정책 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;
+20 -2
View File
@@ -10,7 +10,7 @@
## 지금 사용할 수 있는 기능 ## 지금 사용할 수 있는 기능
| 화면 | 사용할 수 있는 정보 | 읽을 때 주의할 점 | | 화면 | 사용할 수 있는 정보 | 읽을 때 주의할 점 |
| --- | --- | --- | | -------- | ---------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| 국가 | 월말 금·쌀·기술력, 세율·실제 정산, 유저/NPC/부대장 NPC별 자원·숙련 집계, 월/6개월 그래프 | 보유량은 마지막 월말, 수입/지급액은 기간 합. 부분 기간/미수집은 0과 다름 | | 국가 | 월말 금·쌀·기술력, 세율·실제 정산, 유저/NPC/부대장 NPC별 자원·숙련 집계, 월/6개월 그래프 | 보유량은 마지막 월말, 수입/지급액은 기간 합. 부분 기간/미수집은 0과 다름 |
| 장수 | 이름 부분 검색·장수 번호 정렬, 모든 국가·재야의 현재/월말 장수, 자원·능력·숙련·병력·훈련·사기·장비·특기·위치, 독립 로그 상세 | 현재 예약은 현재 조회에서만 제공. 과거 월말은 그달 모든 명령의 이력이 아님 | | 장수 | 이름 부분 검색·장수 번호 정렬, 모든 국가·재야의 현재/월말 장수, 자원·능력·숙련·병력·훈련·사기·장비·특기·위치, 독립 로그 상세 | 현재 예약은 현재 조회에서만 제공. 과거 월말은 그달 모든 명령의 이력이 아님 |
| 도시 | 현재/월말 소유·내정 상태와 국가별 주둔 장수 상세 연결 | 월말 주둔은 그달 모든 방문자가 아님. 지도 기반 탐색은 미완성 | | 도시 | 현재/월말 소유·내정 상태와 국가별 주둔 장수 상세 연결 | 월말 주둔은 그달 모든 방문자가 아님. 지도 기반 탐색은 미완성 |
@@ -61,7 +61,7 @@
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 |
@@ -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의 운영 데이터 영향은 배포 전에