feat: 서버 준비 전에 감사 초기 상태와 정책 기준을 저장

This commit is contained in:
2026-09-16 05:22:01 +00:00
parent 3d60e6122d
commit 8faab62866
20 changed files with 481 additions and 41 deletions
+1 -1
View File
@@ -140,7 +140,7 @@ export const playAuditRouter = router({
? {
year: samples[input.limit - 1]!.year,
month: samples[input.limit - 1]!.month,
kind: z.enum(['MONTH_END', 'FINAL']).parse(samples[input.limit - 1]!.kind),
kind: z.enum(['MONTH_END', 'FINAL', 'INITIAL']).parse(samples[input.limit - 1]!.kind),
}
: null,
};
+23 -1
View File
@@ -20,7 +20,7 @@ export const zAuditMonth = z
.object({
year: z.number().int().min(0).max(9999),
month: z.number().int().min(1).max(12),
kind: z.enum(['MONTH_END', 'FINAL']).default('MONTH_END'),
kind: z.enum(['MONTH_END', 'FINAL', 'INITIAL']).default('MONTH_END'),
})
.strict();
export const zAuditPage = z
@@ -89,12 +89,34 @@ export const readAuditWorld = async (tx: GamePrisma.TransactionClient) => {
? Math.min(scenarioStartYear, world.currentYear)
: world.currentYear;
const startMonth = hasInitialCalendar ? Number(meta.initMonth) : 1;
const collection = z
.object({
schemaVersion: z.literal(1),
serverId: z.string(),
year: z.number().int().nonnegative(),
month: z.number().int().min(1).max(12),
tick: z.number().int().nonnegative(),
observedAt: z.string().datetime(),
})
.safeParse(meta.playAuditCollection);
const collectionStart =
collection.success &&
collection.data.serverId === serverId &&
monthOrdinal(collection.data.year, collection.data.month) <= monthOrdinal(world.currentYear, world.currentMonth)
? {
year: collection.data.year,
month: collection.data.month,
tick: String(collection.data.tick),
observedAt: collection.data.observedAt,
}
: null;
return {
serverId,
year: world.currentYear,
month: world.currentMonth,
startYear,
startMonth,
collectionStart,
tick: world.lastTurnTick?.toString() ?? null,
asOf: new Date().toISOString(),
};
@@ -2563,6 +2563,75 @@ integration('game API security over HTTP transport', () => {
},
},
});
await db.playAuditMonth.create({
data: {
id: `${seasonId}:adoption`,
serverId: seasonId,
year: 190,
month: 1,
kind: 'INITIAL',
settlementsComplete: false,
hash: 'http-initial-fixture',
nations: {
create: {
nationId: ownerNationId,
data: { ...asRecord(finalNation.data), gold: 777, incomeGold: null },
},
},
generals: {
create: {
generalId,
nationId: current.nationId,
cityId: 99123,
npcState: current.npcState,
data: { ...past, name: '도입장수' },
},
},
},
});
await db.worldState.update({
where: { id: fixtureWorldId },
data: {
meta: {
serverId: seasonId,
scenarioMeta: { startYear: 190 },
playAuditCollection: {
schemaVersion: 1,
serverId: seasonId,
year: 190,
month: 1,
tick: 0,
observedAt: '2026-09-16T00:00:00.000Z',
},
},
},
});
expect((await get('coverage', admin, { limit: 1 })).body).toMatchObject({
result: {
data: {
collectionStart: { year: 190, month: 1, observedAt: '2026-09-16T00:00:00.000Z' },
samples: [{ kind: 'INITIAL' }],
nextCursor: { year: 190, month: 1, kind: 'INITIAL' },
},
},
});
expect(
(await get('coverage', admin, { limit: 1, cursor: { year: 190, month: 1, kind: 'INITIAL' } })).body
).toMatchObject({ result: { data: { samples: [{ kind: 'MONTH_END' }] } } });
expect(
(
await get('nationSnapshot', admin, {
nationId: ownerNationId,
at: { year: 190, month: 1, kind: 'INITIAL' },
})
).body
).toMatchObject({
result: { data: { nation: { gold: 777, incomeGold: null }, sample: { kind: 'INITIAL' } } },
});
expect(
(await get('generalDetail', admin, { id: generalId, at: { year: 190, month: 1, kind: 'INITIAL' } }))
.body
).toMatchObject({ result: { data: { general: { name: '도입장수' } } } });
expect(
(
await get('nationSnapshot', admin, {
@@ -167,6 +167,8 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
await db.inputEvent.deleteMany();
await db.logEntry.deleteMany();
await db.playAuditPolicy.deleteMany({ where: { serverId: profile } });
// 이 fixture는 기수 ID를 재사용하므로 실제 RESET과 달리 해당 초기 표본도 비운다.
await db.playAuditMonth.deleteMany({ where: { serverId: profile, kind: 'INITIAL' } });
worldStateId = (await db.worldState.findFirstOrThrow()).id;
await db.playAuditMonth.deleteMany({
where: { id: { in: ['select-pool-audit-old', 'select-pool-audit-active'] } },
+45 -2
View File
@@ -46,7 +46,10 @@ export const recordAuditSettlement = (world: InMemoryTurnWorld, settlement: Audi
world.updateWorldMeta({ playAuditFlows: flows });
};
export const queueAuditMonth = (world: InMemoryTurnWorld, kind: 'MONTH_END' | 'FINAL' = 'MONTH_END'): void => {
export const queueAuditMonth = (
world: InMemoryTurnWorld,
kind: 'MONTH_END' | 'FINAL' | 'INITIAL' = 'MONTH_END'
): void => {
const state = world.getState();
const serverId = state.meta.serverId;
// identity 없는 레거시 fixture/설치에서 profile명으로 가짜 기수를 만들지 않는다.
@@ -64,12 +67,52 @@ export const queueAuditMonth = (world: InMemoryTurnWorld, kind: 'MONTH_END' | 'F
serverId,
year: state.currentYear,
month: state.currentMonth,
tick: state.lastTurnTick ?? null,
tick: kind === 'INITIAL' ? world.getGameClockState().tick : (state.lastTurnTick ?? null),
kind,
settlementsComplete: flows.complete,
});
};
/** 도입 당시 상태는 월말로 가장하지 않고 기수별 최초 기준으로 한 번 고정한다. */
export const initializeAuditCollection = (world: InMemoryTurnWorld, observedAt = new Date()): boolean => {
const state = world.getState();
const serverId = state.meta.serverId;
if (typeof serverId !== 'string' || !serverId.trim()) return false;
const previous = asRecord(state.meta.playAuditCollection);
if (previous.serverId === serverId) {
if (
previous.schemaVersion !== 1 ||
typeof previous.year !== 'number' ||
!Number.isInteger(previous.year) ||
previous.year < 0 ||
typeof previous.month !== 'number' ||
!Number.isInteger(previous.month) ||
previous.month < 1 ||
previous.month > 12 ||
typeof previous.tick !== 'number' ||
!Number.isSafeInteger(previous.tick) ||
previous.tick < 0 ||
typeof previous.observedAt !== 'string' ||
!Number.isFinite(Date.parse(previous.observedAt)) ||
previous.year * 12 + previous.month > state.currentYear * 12 + state.currentMonth
)
throw new Error('Invalid play audit collection boundary');
return false;
}
queueAuditMonth(world, 'INITIAL');
world.updateWorldMeta({
playAuditCollection: {
schemaVersion: 1,
serverId,
year: state.currentYear,
month: state.currentMonth,
tick: world.getGameClockState().tick,
observedAt: observedAt.toISOString(),
},
});
return true;
};
export const createPlayAuditHandler = (getWorld: () => InMemoryTurnWorld | null): TurnCalendarHandler => ({
beforeMonthChanged: (context) => {
const world = getWorld();
+1 -1
View File
@@ -6,7 +6,7 @@ export interface PendingAuditMonth {
serverId: string;
year: number;
month: number;
kind: 'MONTH_END' | 'FINAL';
kind: 'MONTH_END' | 'FINAL' | 'INITIAL';
tick: number | null;
settlementsComplete: boolean;
nations: AuditNationSnapshot[];
+8 -5
View File
@@ -74,6 +74,7 @@ import { prepareRealtimeRecovery } from './prepareRealtimeRecovery.js';
export interface DatabaseTurnHooks {
hooks: TurnDaemonHooks;
flushChanges(): Promise<void>;
takeCommittedReadModelChanges(): RealtimeReadModelChanges | null;
takeCommittedReadModelChangeReceipt(): CommittedReadModelChangeReceipt | null;
close(): Promise<void>;
@@ -2078,12 +2079,13 @@ export const createDatabaseTurnHooks = async (
};
};
const flushChanges = async (): Promise<void> => {
const committed = await persistChanges();
committed.acknowledge();
enqueueCommittedReceipt(committed.readModelChanges, committed.journalWrite);
};
const hooks: TurnDaemonHooks = {
flushChanges: async () => {
const committed = await persistChanges();
committed.acknowledge();
enqueueCommittedReceipt(committed.readModelChanges, committed.journalWrite);
},
flushChanges,
commitCommand: async (requestId, result) => {
const committed = await persistChanges(undefined, { requestId, result });
committed.acknowledge();
@@ -2127,6 +2129,7 @@ export const createDatabaseTurnHooks = async (
return {
hooks,
flushChanges,
takeCommittedReadModelChanges: () => {
return takeCommittedReceipt()?.changes ?? null;
},
@@ -1377,6 +1377,10 @@ export class InMemoryTurnWorld {
this.pendingAuditPolicies.push(structuredClone(policy));
}
hasPendingAuditRecords(): boolean {
return this.pendingAuditPolicies.length > 0 || this.pendingAuditMonths.length > 0;
}
queueAuditMonth(snapshot: PendingAuditMonth): void {
this.pendingAuditMonths.push(structuredClone(snapshot));
}
+13 -2
View File
@@ -1,6 +1,6 @@
import { initializeAuditPolicies } from '../playAudit/policy.js';
import { startAuditRetentionWorker } from '../playAudit/retentionWorker.js';
import { createPlayAuditHandler } from '../playAudit/collection.js';
import { createPlayAuditHandler, initializeAuditCollection } from '../playAudit/collection.js';
import { randomUUID } from 'node:crypto';
import { createRuntimePauseGate } from './runtimePauseGate.js';
@@ -804,7 +804,10 @@ const createTurnDaemonRuntimeWithLease = async (
};
const world = new InMemoryTurnWorld(resolvedState, snapshot, worldOptions);
worldRef = world;
initializeAuditPolicies(world);
if (!databaseFlushEnabled) {
initializeAuditPolicies(world);
initializeAuditCollection(world, new Date(clock.nowMs()));
}
const stateManager = new EngineStateManager();
stateManager.register('world', {
@@ -923,6 +926,14 @@ const createTurnDaemonRuntimeWithLease = async (
});
try {
await dbHooks.prepareRealtimeRecovery({ paused: await gatewayGate?.shouldPause() });
// 복구된 clock에서 기준을 고정하고 readiness 공개 전에 원자적으로 저장한다.
// 명령 없는 PREOPEN도 기록하며 input_event나 게임 RNG를 만들지 않는다.
initializeAuditPolicies(world);
initializeAuditCollection(world, new Date(clock.nowMs()));
if (world.hasPendingAuditRecords()) {
await dbHooks.flushChanges();
dbHooks.takeCommittedReadModelChangeReceipt();
}
} catch (error) {
await Promise.allSettled([
dbHooks.close(),
@@ -2,7 +2,12 @@ import { describe, expect, it } from 'vitest';
import type { City, Nation } from '@sammo-ts/logic';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { createPlayAuditHandler, queueAuditMonth, recordAuditSettlement } from '../src/playAudit/collection.js';
import {
createPlayAuditHandler,
initializeAuditCollection,
queueAuditMonth,
recordAuditSettlement,
} from '../src/playAudit/collection.js';
const turnTime = new Date('0200-01-01T00:00:00.000Z');
const buildGeneral = (id: number, nationId: number): TurnGeneral => ({
@@ -122,6 +127,31 @@ const buildWorld = () => {
return world;
};
describe('play audit collection durability state', () => {
it('freezes the initial observation separately from month-end and restores its marker on rollback', () => {
const world = buildWorld();
const before = world.captureState();
const observedAt = new Date('2026-09-16T00:00:00.000Z');
expect(initializeAuditCollection(world, observedAt)).toBe(true);
expect(world.peekDirtyState().pendingAuditMonths).toMatchObject([
{ kind: 'INITIAL', year: 200, month: 1, settlementsComplete: false },
]);
const marker = world.getState().meta.playAuditCollection;
expect(initializeAuditCollection(world, new Date(observedAt.getTime() + 1000))).toBe(false);
expect(world.getState().meta.playAuditCollection).toEqual(marker);
expect(world.peekDirtyState().pendingAuditMonths).toHaveLength(1);
const reloaded = buildWorld();
reloaded.restoreState(world.captureState());
expect(initializeAuditCollection(reloaded)).toBe(false);
world.restoreState(before);
expect(world.hasPendingAuditRecords()).toBe(false);
expect(world.getState().meta.playAuditCollection).toBeUndefined();
expect(initializeAuditCollection(world, observedAt)).toBe(true);
queueAuditMonth(world);
expect(world.peekDirtyState().pendingAuditMonths.map((sample) => sample.kind)).toEqual([
'INITIAL',
'MONTH_END',
]);
});
it('restores pending snapshots and monthly flows on rollback and acknowledges only persisted rows', () => {
const world = buildWorld();
const before = world.captureState();
@@ -0,0 +1,164 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { asRecord, GAME_TICKS_PER_TURN } from '@sammo-ts/common';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import { seedScenarioToDatabase } from '../src/scenario/scenarioSeeder.js';
import { createTurnDaemonRuntime, type TurnDaemonRuntime } from '../src/turn/turnDaemon.js';
const databaseUrl = process.env.PLAY_AUDIT_STARTUP_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const profile = 'hwe:903';
const serverId = 'audit-startup-fixture';
integration('initial audit durability before runtime readiness', () => {
let db: GamePrismaClient;
let closeDb: () => Promise<void>;
let runtime: TurnDaemonRuntime | undefined;
const start = () =>
createTurnDaemonRuntime({
profile,
databaseUrl: databaseUrl!,
enableDatabaseFlush: true,
enableLeaseHeartbeat: false,
leaseOwnerId: 'audit-startup-fixture',
});
const clock = () =>
db.worldState.findFirstOrThrow({
select: {
clockPhase: true,
clockTick: true,
clockRevision: true,
deadlineGeneration: true,
clockWallAnchor: true,
lastTurnTick: true,
currentYear: true,
currentMonth: true,
},
});
beforeAll(async () => {
if (!new URL(databaseUrl!).searchParams.get('schema')?.endsWith('_audit_startup_fixture'))
throw new Error('Initial audit test requires its dedicated fixture schema');
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
closeDb = () => connector.disconnect();
await db.playAuditMonth.deleteMany();
await db.playAuditPolicy.deleteMany();
await seedScenarioToDatabase({
scenarioId: 903,
databaseUrl: databaseUrl!,
now: new Date(),
installOptions: {
openAt: new Date(Date.now() + 86_400_000),
turnTermMinutes: 5,
npcMode: 2,
showImgLevel: 3,
serverId,
season: 1,
},
});
}, 60_000);
afterAll(async () => {
await runtime?.close();
await closeDb?.();
});
it('rolls initial policies, sample and collection marker back before readiness on persistence failure', async () => {
const before = await clock();
await db.$executeRawUnsafe(
"CREATE OR REPLACE FUNCTION audit_initial_fail() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'fixture initial audit failure'; END $$"
);
await db.$executeRawUnsafe(
"CREATE TRIGGER audit_initial_fail BEFORE INSERT ON play_audit_month FOR EACH ROW WHEN (NEW.kind = 'INITIAL') EXECUTE FUNCTION audit_initial_fail()"
);
try {
let error: unknown;
try {
runtime = await start();
} catch (cause) {
error = cause;
}
expect(String(error)).toContain('fixture initial audit failure');
expect(await db.playAuditMonth.count()).toBe(0);
expect(await db.playAuditPolicy.count()).toBe(0);
expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditCollection).toBeUndefined();
expect(
(await db.nation.findMany()).every((nation) => asRecord(nation.meta)._playAuditPolicy === undefined)
).toBe(true);
expect((await db.turnDaemonLease.findMany()).every((lease) => !lease.clockReady)).toBe(true);
expect(await clock()).toEqual(before);
} finally {
await db.$executeRawUnsafe('DROP TRIGGER IF EXISTS audit_initial_fail ON play_audit_month');
await db.$executeRawUnsafe('DROP FUNCTION IF EXISTS audit_initial_fail()');
}
});
it('persists PREOPEN baseline without starting the lifecycle and reuses it after restart', async () => {
const beforeClock = await clock();
const beforeGenerals = await db.general.findMany({ orderBy: { id: 'asc' } });
const beforeInputs = await db.inputEvent.count();
expect(beforeClock.clockPhase).toBe('PREOPEN');
runtime = await start();
expect(await clock()).toEqual(beforeClock);
expect(await db.general.findMany({ orderBy: { id: 'asc' } })).toEqual(beforeGenerals);
expect(await db.inputEvent.count()).toBe(beforeInputs);
expect((await db.turnDaemonLease.findUniqueOrThrow({ where: { profile } })).clockReady).toBe(true);
const initial = await db.playAuditMonth.findFirstOrThrow({ where: { serverId, kind: 'INITIAL' } });
const policies = await db.playAuditPolicy.findMany({ where: { serverId }, orderBy: { id: 'asc' } });
expect(policies).toHaveLength((await db.nation.count()) * 4);
expect(
policies.every(
(policy) =>
policy.source === 'BASELINE' &&
policy.tick === Number(beforeClock.clockTick) &&
policy.inputSequence === null
)
).toBe(true);
expect(await db.playAuditGeneral.count({ where: { sampleId: initial.id } })).toBe(beforeGenerals.length);
const marker = asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditCollection;
expect(marker).toMatchObject({
serverId,
schemaVersion: 1,
year: beforeClock.currentYear,
month: beforeClock.currentMonth,
});
expect(runtime.world.hasPendingAuditRecords()).toBe(false);
await runtime.close();
runtime = undefined;
runtime = await start();
expect(await db.playAuditPolicy.findMany({ where: { serverId }, orderBy: { id: 'asc' } })).toEqual(policies);
expect(await db.playAuditMonth.findMany({ where: { serverId, kind: 'INITIAL' } })).toEqual([initial]);
expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditCollection).toEqual(marker);
expect(await clock()).toEqual(beforeClock);
expect(await db.inputEvent.count()).toBe(beforeInputs);
await expect(
db.playAuditMonth.create({
data: { ...initial, id: 'second-initial', month: initial.month === 12 ? 1 : initial.month + 1 },
})
).rejects.toMatchObject({ code: 'P2002' });
}, 30_000);
it('captures newly observed policies after durable clock recovery without replacing the initial sample', async () => {
await runtime?.close();
runtime = undefined;
const original = await db.worldState.findFirstOrThrow();
const initial = await db.playAuditMonth.findFirstOrThrow({ where: { serverId, kind: 'INITIAL' } });
await db.nation.create({ data: { id: 91992, name: '복구 관측국', color: '#ffffff' } });
await db.worldState.update({
where: { id: original.id },
data: {
clockPhase: 'RUNNING',
clockMode: 'realtime',
clockTick: BigInt(GAME_TICKS_PER_TURN / 6),
clockWallAnchor: new Date(Date.now() - 115 * 60_000),
lastTurnTick: 0n,
},
});
runtime = await start();
const recovered = await db.worldState.findFirstOrThrow();
expect(recovered.clockRevision).toBeGreaterThan(original.clockRevision);
const policies = await db.playAuditPolicy.findMany({ where: { serverId, nationId: 91992 } });
expect(policies).toHaveLength(4);
expect(policies.every((policy) => policy.tick === runtime!.world.getGameClockState().tick)).toBe(true);
expect(await db.playAuditMonth.findMany({ where: { serverId, kind: 'INITIAL' } })).toEqual([initial]);
}, 30_000);
});
+30 -1
View File
@@ -11,6 +11,7 @@ const world = {
serverId: 'audit-fixture',
tick: '100',
asOf: '2026-09-16T00:00:00.000Z',
collectionStart: { year: 190, month: 1, tick: '0', observedAt: '2026-09-16T00:00:00.000Z' },
};
const dex = { dex1: 100, dex2: 200, dex3: 300, dex4: 400, dex5: 500 };
const population = { count: 2, gold: 200, rice: 400, dex, averageGold: 100, averageRice: 200, averageDex: dex };
@@ -201,7 +202,12 @@ const install = async (page: Page, denied = false) => {
return result({
...world,
collected: true,
sample: { year: 190, month: 6, kind: 'FINAL', settlementsComplete: true },
sample: {
year: 190,
month: 6,
kind: (input.at as { kind: string }).kind,
settlementsComplete: (input.at as { kind: string }).kind !== 'INITIAL',
},
nation: {
id: 2,
name: '촉',
@@ -463,6 +469,7 @@ test('selected general reads detail on demand and separates current reservations
await expect(page.getByRole('heading', { name: '선택 장수 상세' })).toHaveCount(0);
await page.goto(gamePath('/play-audit?tab=generals&general=1&at=month&year=190&month=6'));
await expect(page.getByRole('heading', { name: '과거감사장수 (#1)' })).toBeVisible();
await expect(page.getByText('과거 예약 명령은 상태 표본에 포함되지 않습니다.', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '현재 예약 명령 조회', exact: true })).toHaveCount(0);
expect(requests.filter((request) => request.operation === 'playAudit.generalTurns')).toHaveLength(1);
});
@@ -682,3 +689,25 @@ test('policy filter drafts do not read until applied, including default dates',
from: { year: 190, month: 3 },
});
});
test('initial observation is separate from month-end and final snapshots', async ({ page }) => {
const requests = await install(page);
await page.goto(gamePath('/play-audit?tab=nations&nation=2&at=initial&year=190&month=6'));
await expect(page.getByRole('heading', { name: '촉 · 190년 6월 수집 시작 기준' })).toBeVisible();
await expect(page.getByText(/상태·정책 수집 시작: 190년 1월/)).toBeVisible();
expect(requests.some(({ operation }) => operation === 'playAudit.nationSeries')).toBe(false);
expect(requests.find(({ operation }) => operation === 'playAudit.nationSnapshot')?.input).toMatchObject({
at: { kind: 'INITIAL' },
});
await page.getByLabel('조회 대상').selectOption('generals');
await page.getByRole('button', { name: '조회', exact: true }).click();
await page.getByRole('button', { name: '감사장수 (#1)', exact: true }).click();
await expect(page.getByText('190년 6월 수집 시작 기준', { exact: true })).toBeVisible();
await expect(page.getByRole('heading', { name: '과거감사장수 (#1)' })).toBeVisible();
expect(requests.filter(({ operation }) => operation === 'playAudit.generalDetail').at(-1)?.input).toMatchObject({
at: { kind: 'INITIAL' },
});
await capture(page, 'initial-observation');
await page.setViewportSize({ width: 390, height: 844 });
await capture(page, 'mobile-initial-observation');
});
@@ -2,7 +2,10 @@
import { ref, watch } from 'vue';
import PanelCard from '../ui/PanelCard.vue';
import { trpc } from '../../utils/trpc';
const props = defineProps<{ cityId: number; at?: { year: number; month: number; kind: 'MONTH_END' | 'FINAL' } }>();
const props = defineProps<{
cityId: number;
at?: { year: number; month: number; kind: 'MONTH_END' | 'FINAL' | 'INITIAL' };
}>();
defineEmits<{ close: []; generals: [cityId: number] }>();
type Detail = Awaited<ReturnType<typeof trpc.playAudit.cityDetail.query>>;
const data = ref<Detail | null>(null);
@@ -37,7 +40,11 @@ watch(
<template>
<PanelCard
title="선택 도시 상세"
:subtitle="at ? `${at.year}년 ${at.month}월 ${at.kind === 'FINAL' ? '최종 표본' : '월말'}` : '현재 상태'"
:subtitle="
at
? `${at.year}년 ${at.month}월 ${at.kind === 'FINAL' ? '최종 표본' : at.kind === 'INITIAL' ? '수집 시작 기준' : '월말'}`
: '현재 상태'
"
>
<template #actions><button class="legacy-button" @click="$emit('close')">상세 닫기</button></template>
<p v-if="loading" role="status">도시 조회 </p>
@@ -3,7 +3,10 @@ import { ref, watch } from 'vue';
import PanelCard from '../ui/PanelCard.vue';
import AuditGeneralLogs from './AuditGeneralLogs.vue';
import { trpc } from '../../utils/trpc';
const props = defineProps<{ generalId: number; at?: { year: number; month: number; kind: 'MONTH_END' | 'FINAL' } }>();
const props = defineProps<{
generalId: number;
at?: { year: number; month: number; kind: 'MONTH_END' | 'FINAL' | 'INITIAL' };
}>();
defineEmits<{ close: [] }>();
type Detail = Awaited<ReturnType<typeof trpc.playAudit.generalDetail.query>>;
type Turns = Awaited<ReturnType<typeof trpc.playAudit.generalTurns.query>>;
@@ -69,7 +72,11 @@ watch(
<template>
<PanelCard
title="선택 장수 상세"
:subtitle="at ? `${at.year}년 ${at.month}월 ${at.kind === 'FINAL' ? '최종 표본' : '월말'}` : '현재 상태'"
:subtitle="
at
? `${at.year}년 ${at.month}월 ${at.kind === 'FINAL' ? '최종 표본' : at.kind === 'INITIAL' ? '수집 시작 기준' : '월말'}`
: '현재 상태'
"
>
<template #actions><button class="legacy-button" @click="$emit('close')">상세 닫기</button></template>
<p v-if="loading" role="status">상세 조회 </p>
@@ -93,7 +100,7 @@ watch(
{{ format(data.general.dedication) }}
</p>
<p>숙련 ( / / / / ): {{ Object.values(data.general.dex).map(format).join(' / ') }}</p>
<p v-if="at">과거 예약 명령은 월말 표본에 포함되지 않습니다.</p>
<p v-if="at">과거 예약 명령은 상태 표본에 포함되지 않습니다.</p>
<button v-else class="legacy-button" :disabled="turnsLoading" @click="loadTurns()">
현재 예약 명령 조회
</button>
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue';
import { trpc } from '../../utils/trpc';
type Snapshot = Awaited<ReturnType<typeof trpc.playAudit.nationSnapshot.query>>;
defineProps<{ data: Snapshot }>();
const props = defineProps<{ data: Snapshot }>();
const label = computed(() => (props.data.sample?.kind === 'INITIAL' ? '수집 시작 기준' : '최종 표본'));
const format = (value: number | null) =>
value === null ? '자료 없음' : value.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
const groups = [
@@ -12,13 +14,11 @@ const groups = [
</script>
<template>
<p v-if="!data.collected">선택한 시점의 최종 표본이 없습니다.</p>
<p v-else-if="!data.nation">최종 표본에 해당 국가가 없습니다.</p>
<p v-if="!data.collected">선택한 시점의 표본이 없습니다.</p>
<p v-else-if="!data.nation">표본에 해당 국가가 없습니다.</p>
<template v-else>
<h3>{{ data.nation.name }} · {{ data.sample?.year }} {{ data.sample?.month }} 최종 표본</h3>
<p>
최종 표본은 월말 시계열과 별도로 표시합니다. 아래 정산은 표본을 수집할 때까지 해당 월에 관측한 값입니다.
</p>
<h3>{{ data.nation.name }} · {{ data.sample?.year }} {{ data.sample?.month }} {{ label }}</h3>
<p>월말 시계열과 구분되는 표본입니다. 아래 정산은 표본을 수집할 때까지 해당 월에 관측한 값입니다.</p>
<p v-if="!data.sample?.settlementsComplete">정산 수집이 불완전한 월입니다.</p>
<dl class="nation-values">
<div>
@@ -38,7 +38,7 @@ const groups = [
<dd>{{ format(data.nation.paidGold) }} / {{ format(data.nation.paidRice) }}</dd>
</div>
</dl>
<div class="table-scroll" tabindex="0" aria-label="최종 국가 장수 집계">
<div class="table-scroll" tabindex="0" :aria-label="`${label} 국가 장수 집계`">
<table>
<thead>
<tr>
+26 -9
View File
@@ -68,11 +68,16 @@ const selectedGeneral = computed(() =>
typeof route.query.general === 'string' && /^\d+$/.test(route.query.general) ? Number(route.query.general) : null
);
const selectedAt = computed(() =>
route.query.at === 'month' || route.query.at === 'final'
route.query.at === 'month' || route.query.at === 'final' || route.query.at === 'initial'
? {
year: numeric(route.query.year, coverage.value?.year ?? 0),
month: numeric(route.query.month, coverage.value?.month ?? 1),
kind: route.query.at === 'final' ? ('FINAL' as const) : ('MONTH_END' as const),
kind:
route.query.at === 'final'
? ('FINAL' as const)
: route.query.at === 'initial'
? ('INITIAL' as const)
: ('MONTH_END' as const),
}
: undefined
);
@@ -93,16 +98,21 @@ const at = computed(() =>
: {
year: year.value,
month: month.value,
kind: moment.value === 'final' ? ('FINAL' as const) : ('MONTH_END' as const),
kind:
moment.value === 'final'
? ('FINAL' as const)
: moment.value === 'initial'
? ('INITIAL' as const)
: ('MONTH_END' as const),
}
);
const format = (value: number) => value.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
const nationName = (id: number) =>
nations.value?.items.find((item) => item.id === id)?.name ?? (id === 0 ? '무소속' : `국가 #${id}`);
const scopeLabel = computed(() =>
route.query.at !== 'month' && route.query.at !== 'final'
route.query.at !== 'month' && route.query.at !== 'final' && route.query.at !== 'initial'
? '현재 상태'
: `${route.query.year}${route.query.month}${route.query.at === 'final' ? '최종 표본' : '월말'}`
: `${route.query.year}${route.query.month}${route.query.at === 'final' ? '최종 표본' : route.query.at === 'initial' ? '수집 시작 기준' : '월말'}`
);
const result = computed(() =>
tab.value === 'generals'
@@ -123,7 +133,7 @@ const readQuery = () => {
population.value = ['human', 'npc', 'troopNpc'].includes(String(route.query.population))
? String(route.query.population)
: '';
moment.value = ['month', 'final'].includes(String(route.query.at)) ? String(route.query.at) : 'current';
moment.value = ['month', 'final', 'initial'].includes(String(route.query.at)) ? String(route.query.at) : 'current';
year.value = numeric(route.query.year, coverage.value?.year ?? 0);
month.value = numeric(route.query.month, coverage.value?.month ?? 1);
const defaultStart = Math.max(
@@ -175,10 +185,10 @@ const load = async (append = false) => {
};
} else if (tab.value === 'policies') {
// 정책 목록/상세는 해당 component가 필요한 요청만 실행한다.
} else if (nationId.value !== '' && moment.value === 'final') {
} else if (nationId.value !== '' && (moment.value === 'final' || moment.value === 'initial')) {
const response = await trpc.playAudit.nationSnapshot.query({
nationId: Number(nationId.value),
at: { year: year.value, month: month.value, kind: 'FINAL' },
at: { year: year.value, month: month.value, kind: moment.value === 'initial' ? 'INITIAL' : 'FINAL' },
});
if (request === generation) nationSnapshot.value = response;
} else if (nationId.value !== '') {
@@ -310,6 +320,10 @@ onMounted(async () => {
<p v-else-if="coverage.status === 'NOT_COLLECTED'">
아직 수집된 월별 표본이 없습니다. 현재 상태는 조회할 있습니다.
</p>
<p v-if="coverage.collectionStart">
상태·정책 수집 시작: {{ coverage.collectionStart.year }} {{ coverage.collectionStart.month }} ·
{{ coverage.collectionStart.observedAt }}. 이전 상태를 소급 복원하지 않습니다.
</p>
<form class="filters" @submit.prevent="apply">
<label
>조회 대상<select class="legacy-sort-select" v-model="tab">
@@ -358,6 +372,7 @@ onMounted(async () => {
<option value="current">현재</option>
<option value="month">월말</option>
<option value="final">최종 표본</option>
<option value="initial">수집 시작 기준</option>
</select></label
>
<label
@@ -377,7 +392,9 @@ onMounted(async () => {
:max="year === coverage.year ? coverage.month : 12"
required
/></label>
<template v-if="(tab === 'nations' && moment !== 'final') || tab === 'policies'">
<template
v-if="(tab === 'nations' && moment !== 'final' && moment !== 'initial') || tab === 'policies'"
>
<label
>시작 연도<input
v-model.number="fromYear"
+30 -5
View File
@@ -2,7 +2,7 @@
[확정 설계](play-audit.md)의 P1~P6를 구현하는 작업 기록이다. 전체 기능은 진행 중이며,
월별 projection과 runtime 수집·DB transaction 연결을 구현했다. 프로필 권한과 장수·도시 현재/월말 조회 API,
국가 월/반기 시계열과 `/play-audit` 기본 조회 화면을 연결했다. 정책 이력 저장과 목록/상세 조회 화면을 추가했으며 초기 기준 내구성, 외교, NPC trace와 조사 도구는 남아 있다.
국가 월/반기 시계열과 `/play-audit` 기본 조회 화면을 연결했다. 정책 이력 저장과 목록/상세 조회, 초기 기준 내구화를 추가했으며 전체 종료 경계, 외교, NPC trace와 조사 도구는 남아 있다.
Push 요청 이후 `feat/play-audit` 전용 worktree에서 계속 구현하며 전체 완료 후 main 통합·push한다.
## 현재 구현
@@ -100,8 +100,8 @@ NPC 설정은 기존 allowlist의 저장 값만 복사하며 setter/time이나
daemon world 구성과 신규 `addNation`에서 네 기준 버전을 pending에 담는다. 기존 정상
포인터가 있으면 재시작 때 재생성하지 않는다. 포인터 ID는 기수·국가·영역·revision으로
검증하므로 다른 국가 metadata를 복사해도 기존 국가 이력을 이어받지 않는다.
기준 버전은 현재 첫 gameplay flush에서 내구화된다. PREOPEN에서 아직 flush가 없을 때의
초기 기준 버전 내구화/coverage는 후속 연결이 필요하며 현재 구현만으로 R6 완료가 아니다.
DB runtime의 기준 수집은 clock recovery/synchronization 뒤 수행하고 readiness 전에
startup flush로 내구화한다. PREOPEN에서 명령이 없어도 정책 기준을 저장한다.
기존 NPC mutation의 검증·CAS와 국가 설정의 권한·횟수 제한을 통과한 뒤 변경 전후를 비교한다.
setter/time 변경과 동일 설정 저장에는 새 적용 버전을 만들지 않는다. 기존 CAS token 갱신,
@@ -127,7 +127,7 @@ checksum은 수정하지 않고 schema_version 필드는 별도 증분 migration
정리 worker는 이전 기수 정책 ID도 최대200개씩 삭제한다. policy version의 self-reference는
삭제 FK로 강제하지 않아 과거 비공개 기수를 key batch로 정리할 수 있다. 현재 기수 포인터는
같은 transaction으로 저장하고 조회 시 항상 현재 기수 범위를 검사해야 한다.
완전한 초기 기준 내구화와 NPC 결정 연결은 아직 남았다.
NPC 결정의 정책 참조와 거부/무변경 시도의 사건 연결은 아직 남았다.
`policyHistory`는 국가/영역/기간을 필수로 받고 기본50·최대200개의 버전 요약을
revision 내림차순 cursor로 반환한다. 목록에서는 전후 정책 본문과 요청 자료를 읽지 않는다.
@@ -148,6 +148,31 @@ world identity와 자료는 기존 RepeatableRead/timeout 계약을 공유한다
실제 변경, 관측 누락 이후 기준과 자료 없음의 의미를 구분한다. 기존 PanelCard와 제어
스타일을 사용하고 NPC 설정 화면의 한국어 필드 이름을 따른다. 값은 텍스트로 출력한다.
## 초기 도입 기준과 즉시 내구성
`initializeAuditCollection`은 기수별 첫 관측에서만 INITIAL 표본과 world의
`playAuditCollection` 시작 좌표/실제 관측 시각을 pending에 담는다. INITIAL은 기존
국가·도시·장수 projection과 batch 저장을 재사용하며 월말과 FINAL을 대체하지 않는다.
새 migration은 INITIAL kind를 허용하고 부분 unique index로 기수당 한 행만 허용한다.
재시작은 저장된 marker를 사용하며 초기 상태를 현재 값으로 갱신하지 않는다.
runtime은 기존 lease 획득·world load·clock 복구/동기화 후 정책/상태 기준을 수집한다.
기존 fenced persistence로 기준과 국가 포인터/world marker를 함께 commit한 뒤에만
clockReady를 공개한다. 별도 input_event를 만들지 않는다. 초기 저장 실패는 기존 startup
오류 경로로 lease/연결을 정리하고 준비 완료를 알리지 않는다. 정상 재시작은 pending이
없으면 추가 flush가 없다. pending 확인은 배열 길이만 검사하여 큰 snapshot을 복사하지 않는다.
초기 수집은 이미 로드된 world를 각 한 번 순회한다. 추가 전체 엔티티 SELECT 없이 기존
header/child batch transaction을 한 번 수행한다. readiness 전에 저장하므로 큰 기수의
startup latency/WAL/heap 비용은 P6에서 함께 측정해야 한다. 값이나 표본을 생략하는
방식으로 비용을 줄이지 않는다. 초기 read-model receipt는 첫 실제 명령에 섞지 않는다.
조회 API의 표본 kind와 coverage cursor에 INITIAL을 포함한다. 현재 world meta에서
`collectionStart`만 allowlist projection하여 추가 DB 조회 없이 시작 시점을 표시한다.
국가·장수·도시의 수집 시작 기준 조회는 같은 snapshot identity를 유지하며 국가 시계열에는
MONTH_END만 포함한다. INITIAL의 흐름을 정규 월/반기 합계에 더하지 않는다.
시나리오 개방 달력과 실제 상세 수집 시작은 서로 다른 값이다.
## 이전 기수 월별 표본 정리
새 daemon runtime은 실제 `serverId`를 고정해 이전 월별 감사 표본 정리를 시작한다.
@@ -282,7 +307,7 @@ SQL/bytes는 아직 실측하지 않았으며 아래는 현재 소스에서 확
| 월별 내구성 | `turn/inMemoryWorld.ts`의 capture/restore, peek/acknowledge와 pending yearbook; `turn/databaseHooks.ts``persistChanges` | 별도 audit pending을 같은 transaction과 savepoint에 포함. 기존 연감의 장기보존 테이블에 상세 감사를 넣지 않음 | 실패·중복·재시작, bounded 삭제 |
| 기수 identity | `scenario/scenarioSeeder.ts``install.serverId`, `GameHistory` 충돌 검사 | profile명으로 대체하지 않음. 외부 install 입력을 만드는 지점과 RESET 전체 경로를 추가 추적한 뒤 수집 활성화 | 신규 identity 생성, 재시도, 기존 설치에 identity 누락 시 처리 |
| 외교 | game-api `router/diplomacy/index.ts`, engine 월간 외교 처리 | 불변 문서는 참조, 갱신되는 내용만 당시 버전 저장. 현재 상태 월복사만으로 사건을 대신하지 않음 | 모든 API/engine mutation별 inventory |
| NPC 정책 | `turn/worldCommandHandler.ts``turn/npcPolicyMutation.ts` | CAS 성공하고 실제 값이 달라진 경우에만 불변 버전. 무변경/거부는 적용 버전에서 제외 | 초기 버전, actor/직책, 국방 mutation inventory |
| NPC 정책 | `turn/worldCommandHandler.ts``turn/npcPolicyMutation.ts` | CAS 성공하고 실제 값이 달라진 경우에만 불변 버전. 무변경/거부는 적용 버전에서 제외 | NPC 결정의 버전 참조, 거부/무변경 시도 원장 |
| 권한 | Gateway `adminCapabilities.ts`, `adminAuth.ts`; game-api `trpc.ts` 인증·제재 middleware | scoped 감사 권한과 공통 계정 추가 권한 분리. `getMyGeneral` 요구 없이 서버에서 검사 | catalog/token/flush/HTTP matrix 전체 연결 |
월간 실행은 이전 월 snapshot → 달 변경 → 새달 `onMonthChanged` 순서다.
+1
View File
@@ -1084,6 +1084,7 @@ model VoteComment {
// 현재 기수 플레이 감사. gameplay 엔티티 삭제 뒤에도 당시 ID/이름을 유지한다.
model PlayAuditMonth {
// INITIAL은 migration의 부분 unique index로 serverId당 한 건만 허용한다.
id String @id
serverId String @map("server_id")
year Int
@@ -0,0 +1,5 @@
ALTER TABLE "play_audit_month" DROP CONSTRAINT "play_audit_month_kind_check";
ALTER TABLE "play_audit_month" ADD CONSTRAINT "play_audit_month_kind_check"
CHECK ("kind" IN ('MONTH_END', 'FINAL', 'INITIAL'));
CREATE UNIQUE INDEX "play_audit_month_initial_server_key" ON "play_audit_month" ("server_id")
WHERE "kind" = 'INITIAL';
@@ -9,6 +9,7 @@ GENERAL_LIFECYCLE_DATABASE_URL core
IMMEDIATE_ACTION_DATABASE_URL immediate_action
INPUT_EVENT_DATABASE_URL core
PLAY_AUDIT_RETENTION_DATABASE_URL external_fixture
PLAY_AUDIT_STARTUP_DATABASE_URL external_fixture
LIVE_SORTIE_PERSISTENCE_DATABASE_URL reference_live_sortie
NPC_POSSESSION_DATABASE_URL npc_possession
NPC_POSSESSION_DIFFERENTIAL_DATABASE_URL reference_npc_possession
1 # Environment variable Execution mode
9 IMMEDIATE_ACTION_DATABASE_URL immediate_action
10 INPUT_EVENT_DATABASE_URL core
11 PLAY_AUDIT_RETENTION_DATABASE_URL external_fixture
12 PLAY_AUDIT_STARTUP_DATABASE_URL external_fixture
13 LIVE_SORTIE_PERSISTENCE_DATABASE_URL reference_live_sortie
14 NPC_POSSESSION_DATABASE_URL npc_possession
15 NPC_POSSESSION_DIFFERENTIAL_DATABASE_URL reference_npc_possession