감사 정책 head 유실을 복구하고 데몬 준비 상태로 배포를 검증한다
This commit is contained in:
@@ -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 };
|
||||||
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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도
|
||||||
|
데몬 초기화가 완료되면 준비 완료이며, 턴 진행 여부와 준비 상태는 별개다.
|
||||||
|
|||||||
@@ -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의 운영 데이터 영향은 배포 전에
|
||||||
|
|||||||
Reference in New Issue
Block a user