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, year: samples[input.limit - 1]!.year,
month: samples[input.limit - 1]!.month, 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, : null,
}; };
+23 -1
View File
@@ -20,7 +20,7 @@ export const zAuditMonth = z
.object({ .object({
year: z.number().int().min(0).max(9999), year: z.number().int().min(0).max(9999),
month: z.number().int().min(1).max(12), 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(); .strict();
export const zAuditPage = z export const zAuditPage = z
@@ -89,12 +89,34 @@ export const readAuditWorld = async (tx: GamePrisma.TransactionClient) => {
? Math.min(scenarioStartYear, world.currentYear) ? Math.min(scenarioStartYear, world.currentYear)
: world.currentYear; : world.currentYear;
const startMonth = hasInitialCalendar ? Number(meta.initMonth) : 1; 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 { return {
serverId, serverId,
year: world.currentYear, year: world.currentYear,
month: world.currentMonth, month: world.currentMonth,
startYear, startYear,
startMonth, startMonth,
collectionStart,
tick: world.lastTurnTick?.toString() ?? null, tick: world.lastTurnTick?.toString() ?? null,
asOf: new Date().toISOString(), 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( expect(
( (
await get('nationSnapshot', admin, { await get('nationSnapshot', admin, {
@@ -167,6 +167,8 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
await db.inputEvent.deleteMany(); await db.inputEvent.deleteMany();
await db.logEntry.deleteMany(); await db.logEntry.deleteMany();
await db.playAuditPolicy.deleteMany({ where: { serverId: profile } }); 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; worldStateId = (await db.worldState.findFirstOrThrow()).id;
await db.playAuditMonth.deleteMany({ await db.playAuditMonth.deleteMany({
where: { id: { in: ['select-pool-audit-old', 'select-pool-audit-active'] } }, 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 }); 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 state = world.getState();
const serverId = state.meta.serverId; const serverId = state.meta.serverId;
// identity 없는 레거시 fixture/설치에서 profile명으로 가짜 기수를 만들지 않는다. // identity 없는 레거시 fixture/설치에서 profile명으로 가짜 기수를 만들지 않는다.
@@ -64,12 +67,52 @@ export const queueAuditMonth = (world: InMemoryTurnWorld, kind: 'MONTH_END' | 'F
serverId, serverId,
year: state.currentYear, year: state.currentYear,
month: state.currentMonth, month: state.currentMonth,
tick: state.lastTurnTick ?? null, tick: kind === 'INITIAL' ? world.getGameClockState().tick : (state.lastTurnTick ?? null),
kind, kind,
settlementsComplete: flows.complete, 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 => ({ export const createPlayAuditHandler = (getWorld: () => InMemoryTurnWorld | null): TurnCalendarHandler => ({
beforeMonthChanged: (context) => { beforeMonthChanged: (context) => {
const world = getWorld(); const world = getWorld();
+1 -1
View File
@@ -6,7 +6,7 @@ export interface PendingAuditMonth {
serverId: string; serverId: string;
year: number; year: number;
month: number; month: number;
kind: 'MONTH_END' | 'FINAL'; kind: 'MONTH_END' | 'FINAL' | 'INITIAL';
tick: number | null; tick: number | null;
settlementsComplete: boolean; settlementsComplete: boolean;
nations: AuditNationSnapshot[]; nations: AuditNationSnapshot[];
+8 -5
View File
@@ -74,6 +74,7 @@ import { prepareRealtimeRecovery } from './prepareRealtimeRecovery.js';
export interface DatabaseTurnHooks { export interface DatabaseTurnHooks {
hooks: TurnDaemonHooks; hooks: TurnDaemonHooks;
flushChanges(): Promise<void>;
takeCommittedReadModelChanges(): RealtimeReadModelChanges | null; takeCommittedReadModelChanges(): RealtimeReadModelChanges | null;
takeCommittedReadModelChangeReceipt(): CommittedReadModelChangeReceipt | null; takeCommittedReadModelChangeReceipt(): CommittedReadModelChangeReceipt | null;
close(): Promise<void>; 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 = { const hooks: TurnDaemonHooks = {
flushChanges: async () => { flushChanges,
const committed = await persistChanges();
committed.acknowledge();
enqueueCommittedReceipt(committed.readModelChanges, committed.journalWrite);
},
commitCommand: async (requestId, result) => { commitCommand: async (requestId, result) => {
const committed = await persistChanges(undefined, { requestId, result }); const committed = await persistChanges(undefined, { requestId, result });
committed.acknowledge(); committed.acknowledge();
@@ -2127,6 +2129,7 @@ export const createDatabaseTurnHooks = async (
return { return {
hooks, hooks,
flushChanges,
takeCommittedReadModelChanges: () => { takeCommittedReadModelChanges: () => {
return takeCommittedReceipt()?.changes ?? null; return takeCommittedReceipt()?.changes ?? null;
}, },
@@ -1377,6 +1377,10 @@ export class InMemoryTurnWorld {
this.pendingAuditPolicies.push(structuredClone(policy)); this.pendingAuditPolicies.push(structuredClone(policy));
} }
hasPendingAuditRecords(): boolean {
return this.pendingAuditPolicies.length > 0 || this.pendingAuditMonths.length > 0;
}
queueAuditMonth(snapshot: PendingAuditMonth): void { queueAuditMonth(snapshot: PendingAuditMonth): void {
this.pendingAuditMonths.push(structuredClone(snapshot)); this.pendingAuditMonths.push(structuredClone(snapshot));
} }
+13 -2
View File
@@ -1,6 +1,6 @@
import { initializeAuditPolicies } from '../playAudit/policy.js'; import { initializeAuditPolicies } from '../playAudit/policy.js';
import { startAuditRetentionWorker } from '../playAudit/retentionWorker.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 { randomUUID } from 'node:crypto';
import { createRuntimePauseGate } from './runtimePauseGate.js'; import { createRuntimePauseGate } from './runtimePauseGate.js';
@@ -804,7 +804,10 @@ const createTurnDaemonRuntimeWithLease = async (
}; };
const world = new InMemoryTurnWorld(resolvedState, snapshot, worldOptions); const world = new InMemoryTurnWorld(resolvedState, snapshot, worldOptions);
worldRef = world; worldRef = world;
initializeAuditPolicies(world); if (!databaseFlushEnabled) {
initializeAuditPolicies(world);
initializeAuditCollection(world, new Date(clock.nowMs()));
}
const stateManager = new EngineStateManager(); const stateManager = new EngineStateManager();
stateManager.register('world', { stateManager.register('world', {
@@ -923,6 +926,14 @@ const createTurnDaemonRuntimeWithLease = async (
}); });
try { try {
await dbHooks.prepareRealtimeRecovery({ paused: await gatewayGate?.shouldPause() }); 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) { } catch (error) {
await Promise.allSettled([ await Promise.allSettled([
dbHooks.close(), dbHooks.close(),
@@ -2,7 +2,12 @@ import { describe, expect, it } from 'vitest';
import type { City, Nation } from '@sammo-ts/logic'; import type { City, Nation } from '@sammo-ts/logic';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.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 turnTime = new Date('0200-01-01T00:00:00.000Z');
const buildGeneral = (id: number, nationId: number): TurnGeneral => ({ const buildGeneral = (id: number, nationId: number): TurnGeneral => ({
@@ -122,6 +127,31 @@ const buildWorld = () => {
return world; return world;
}; };
describe('play audit collection durability state', () => { 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', () => { it('restores pending snapshots and monthly flows on rollback and acknowledges only persisted rows', () => {
const world = buildWorld(); const world = buildWorld();
const before = world.captureState(); 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', serverId: 'audit-fixture',
tick: '100', tick: '100',
asOf: '2026-09-16T00:00:00.000Z', 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 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 }; 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({ return result({
...world, ...world,
collected: true, 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: { nation: {
id: 2, id: 2,
name: '촉', 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 expect(page.getByRole('heading', { name: '선택 장수 상세' })).toHaveCount(0);
await page.goto(gamePath('/play-audit?tab=generals&general=1&at=month&year=190&month=6')); 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.getByRole('heading', { name: '과거감사장수 (#1)' })).toBeVisible();
await expect(page.getByText('과거 예약 명령은 상태 표본에 포함되지 않습니다.', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '현재 예약 명령 조회', exact: true })).toHaveCount(0); await expect(page.getByRole('button', { name: '현재 예약 명령 조회', exact: true })).toHaveCount(0);
expect(requests.filter((request) => request.operation === 'playAudit.generalTurns')).toHaveLength(1); 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 }, 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 { ref, watch } from 'vue';
import PanelCard from '../ui/PanelCard.vue'; import PanelCard from '../ui/PanelCard.vue';
import { trpc } from '../../utils/trpc'; 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] }>(); defineEmits<{ close: []; generals: [cityId: number] }>();
type Detail = Awaited<ReturnType<typeof trpc.playAudit.cityDetail.query>>; type Detail = Awaited<ReturnType<typeof trpc.playAudit.cityDetail.query>>;
const data = ref<Detail | null>(null); const data = ref<Detail | null>(null);
@@ -37,7 +40,11 @@ watch(
<template> <template>
<PanelCard <PanelCard
title="선택 도시 상세" 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> <template #actions><button class="legacy-button" @click="$emit('close')">상세 닫기</button></template>
<p v-if="loading" role="status">도시 조회 </p> <p v-if="loading" role="status">도시 조회 </p>
@@ -3,7 +3,10 @@ import { ref, watch } from 'vue';
import PanelCard from '../ui/PanelCard.vue'; import PanelCard from '../ui/PanelCard.vue';
import AuditGeneralLogs from './AuditGeneralLogs.vue'; import AuditGeneralLogs from './AuditGeneralLogs.vue';
import { trpc } from '../../utils/trpc'; 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: [] }>(); defineEmits<{ close: [] }>();
type Detail = Awaited<ReturnType<typeof trpc.playAudit.generalDetail.query>>; type Detail = Awaited<ReturnType<typeof trpc.playAudit.generalDetail.query>>;
type Turns = Awaited<ReturnType<typeof trpc.playAudit.generalTurns.query>>; type Turns = Awaited<ReturnType<typeof trpc.playAudit.generalTurns.query>>;
@@ -69,7 +72,11 @@ watch(
<template> <template>
<PanelCard <PanelCard
title="선택 장수 상세" 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> <template #actions><button class="legacy-button" @click="$emit('close')">상세 닫기</button></template>
<p v-if="loading" role="status">상세 조회 </p> <p v-if="loading" role="status">상세 조회 </p>
@@ -93,7 +100,7 @@ watch(
{{ format(data.general.dedication) }} {{ format(data.general.dedication) }}
</p> </p>
<p>숙련 ( / / / / ): {{ Object.values(data.general.dex).map(format).join(' / ') }}</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 v-else class="legacy-button" :disabled="turnsLoading" @click="loadTurns()">
현재 예약 명령 조회 현재 예약 명령 조회
</button> </button>
@@ -1,7 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue';
import { trpc } from '../../utils/trpc'; import { trpc } from '../../utils/trpc';
type Snapshot = Awaited<ReturnType<typeof trpc.playAudit.nationSnapshot.query>>; 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) => const format = (value: number | null) =>
value === null ? '자료 없음' : value.toLocaleString('ko-KR', { maximumFractionDigits: 2 }); value === null ? '자료 없음' : value.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
const groups = [ const groups = [
@@ -12,13 +14,11 @@ const groups = [
</script> </script>
<template> <template>
<p v-if="!data.collected">선택한 시점의 최종 표본이 없습니다.</p> <p v-if="!data.collected">선택한 시점의 표본이 없습니다.</p>
<p v-else-if="!data.nation">최종 표본에 해당 국가가 없습니다.</p> <p v-else-if="!data.nation">표본에 해당 국가가 없습니다.</p>
<template v-else> <template v-else>
<h3>{{ data.nation.name }} · {{ data.sample?.year }} {{ data.sample?.month }} 최종 표본</h3> <h3>{{ data.nation.name }} · {{ data.sample?.year }} {{ data.sample?.month }} {{ label }}</h3>
<p> <p>월말 시계열과 구분되는 표본입니다. 아래 정산은 표본을 수집할 때까지 해당 월에 관측한 값입니다.</p>
최종 표본은 월말 시계열과 별도로 표시합니다. 아래 정산은 표본을 수집할 때까지 해당 월에 관측한 값입니다.
</p>
<p v-if="!data.sample?.settlementsComplete">정산 수집이 불완전한 월입니다.</p> <p v-if="!data.sample?.settlementsComplete">정산 수집이 불완전한 월입니다.</p>
<dl class="nation-values"> <dl class="nation-values">
<div> <div>
@@ -38,7 +38,7 @@ const groups = [
<dd>{{ format(data.nation.paidGold) }} / {{ format(data.nation.paidRice) }}</dd> <dd>{{ format(data.nation.paidGold) }} / {{ format(data.nation.paidRice) }}</dd>
</div> </div>
</dl> </dl>
<div class="table-scroll" tabindex="0" aria-label="최종 국가 장수 집계"> <div class="table-scroll" tabindex="0" :aria-label="`${label} 국가 장수 집계`">
<table> <table>
<thead> <thead>
<tr> <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 typeof route.query.general === 'string' && /^\d+$/.test(route.query.general) ? Number(route.query.general) : null
); );
const selectedAt = computed(() => 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), year: numeric(route.query.year, coverage.value?.year ?? 0),
month: numeric(route.query.month, coverage.value?.month ?? 1), 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 : undefined
); );
@@ -93,16 +98,21 @@ const at = computed(() =>
: { : {
year: year.value, year: year.value,
month: month.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 format = (value: number) => value.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
const nationName = (id: number) => const nationName = (id: number) =>
nations.value?.items.find((item) => item.id === id)?.name ?? (id === 0 ? '무소속' : `국가 #${id}`); nations.value?.items.find((item) => item.id === id)?.name ?? (id === 0 ? '무소속' : `국가 #${id}`);
const scopeLabel = computed(() => 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(() => const result = computed(() =>
tab.value === 'generals' tab.value === 'generals'
@@ -123,7 +133,7 @@ const readQuery = () => {
population.value = ['human', 'npc', 'troopNpc'].includes(String(route.query.population)) population.value = ['human', 'npc', 'troopNpc'].includes(String(route.query.population))
? 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); year.value = numeric(route.query.year, coverage.value?.year ?? 0);
month.value = numeric(route.query.month, coverage.value?.month ?? 1); month.value = numeric(route.query.month, coverage.value?.month ?? 1);
const defaultStart = Math.max( const defaultStart = Math.max(
@@ -175,10 +185,10 @@ const load = async (append = false) => {
}; };
} else if (tab.value === 'policies') { } else if (tab.value === 'policies') {
// 정책 목록/상세는 해당 component가 필요한 요청만 실행한다. // 정책 목록/상세는 해당 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({ const response = await trpc.playAudit.nationSnapshot.query({
nationId: Number(nationId.value), 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; if (request === generation) nationSnapshot.value = response;
} else if (nationId.value !== '') { } else if (nationId.value !== '') {
@@ -310,6 +320,10 @@ onMounted(async () => {
<p v-else-if="coverage.status === 'NOT_COLLECTED'"> <p v-else-if="coverage.status === 'NOT_COLLECTED'">
아직 수집된 월별 표본이 없습니다. 현재 상태는 조회할 있습니다. 아직 수집된 월별 표본이 없습니다. 현재 상태는 조회할 있습니다.
</p> </p>
<p v-if="coverage.collectionStart">
상태·정책 수집 시작: {{ coverage.collectionStart.year }} {{ coverage.collectionStart.month }} ·
{{ coverage.collectionStart.observedAt }}. 이전 상태를 소급 복원하지 않습니다.
</p>
<form class="filters" @submit.prevent="apply"> <form class="filters" @submit.prevent="apply">
<label <label
>조회 대상<select class="legacy-sort-select" v-model="tab"> >조회 대상<select class="legacy-sort-select" v-model="tab">
@@ -358,6 +372,7 @@ onMounted(async () => {
<option value="current">현재</option> <option value="current">현재</option>
<option value="month">월말</option> <option value="month">월말</option>
<option value="final">최종 표본</option> <option value="final">최종 표본</option>
<option value="initial">수집 시작 기준</option>
</select></label </select></label
> >
<label <label
@@ -377,7 +392,9 @@ onMounted(async () => {
:max="year === coverage.year ? coverage.month : 12" :max="year === coverage.year ? coverage.month : 12"
required required
/></label> /></label>
<template v-if="(tab === 'nations' && moment !== 'final') || tab === 'policies'"> <template
v-if="(tab === 'nations' && moment !== 'final' && moment !== 'initial') || tab === 'policies'"
>
<label <label
>시작 연도<input >시작 연도<input
v-model.number="fromYear" v-model.number="fromYear"
+30 -5
View File
@@ -2,7 +2,7 @@
[확정 설계](play-audit.md)의 P1~P6를 구현하는 작업 기록이다. 전체 기능은 진행 중이며, [확정 설계](play-audit.md)의 P1~P6를 구현하는 작업 기록이다. 전체 기능은 진행 중이며,
월별 projection과 runtime 수집·DB transaction 연결을 구현했다. 프로필 권한과 장수·도시 현재/월말 조회 API, 월별 projection과 runtime 수집·DB transaction 연결을 구현했다. 프로필 권한과 장수·도시 현재/월말 조회 API,
국가 월/반기 시계열과 `/play-audit` 기본 조회 화면을 연결했다. 정책 이력 저장과 목록/상세 조회 화면을 추가했으며 초기 기준 내구성, 외교, NPC trace와 조사 도구는 남아 있다. 국가 월/반기 시계열과 `/play-audit` 기본 조회 화면을 연결했다. 정책 이력 저장과 목록/상세 조회, 초기 기준 내구화를 추가했으며 전체 종료 경계, 외교, NPC trace와 조사 도구는 남아 있다.
Push 요청 이후 `feat/play-audit` 전용 worktree에서 계속 구현하며 전체 완료 후 main 통합·push한다. Push 요청 이후 `feat/play-audit` 전용 worktree에서 계속 구현하며 전체 완료 후 main 통합·push한다.
## 현재 구현 ## 현재 구현
@@ -100,8 +100,8 @@ NPC 설정은 기존 allowlist의 저장 값만 복사하며 setter/time이나
daemon world 구성과 신규 `addNation`에서 네 기준 버전을 pending에 담는다. 기존 정상 daemon world 구성과 신규 `addNation`에서 네 기준 버전을 pending에 담는다. 기존 정상
포인터가 있으면 재시작 때 재생성하지 않는다. 포인터 ID는 기수·국가·영역·revision으로 포인터가 있으면 재시작 때 재생성하지 않는다. 포인터 ID는 기수·국가·영역·revision으로
검증하므로 다른 국가 metadata를 복사해도 기존 국가 이력을 이어받지 않는다. 검증하므로 다른 국가 metadata를 복사해도 기존 국가 이력을 이어받지 않는다.
기준 버전은 현재 첫 gameplay flush에서 내구화된다. PREOPEN에서 아직 flush가 없을 때의 DB runtime의 기준 수집은 clock recovery/synchronization 뒤 수행하고 readiness 전에
초기 기준 버전 내구화/coverage는 후속 연결이 필요하며 현재 구현만으로 R6 완료가 아니다. startup flush로 내구화한다. PREOPEN에서 명령이 없어도 정책 기준을 저장한다.
기존 NPC mutation의 검증·CAS와 국가 설정의 권한·횟수 제한을 통과한 뒤 변경 전후를 비교한다. 기존 NPC mutation의 검증·CAS와 국가 설정의 권한·횟수 제한을 통과한 뒤 변경 전후를 비교한다.
setter/time 변경과 동일 설정 저장에는 새 적용 버전을 만들지 않는다. 기존 CAS token 갱신, setter/time 변경과 동일 설정 저장에는 새 적용 버전을 만들지 않는다. 기존 CAS token 갱신,
@@ -127,7 +127,7 @@ checksum은 수정하지 않고 schema_version 필드는 별도 증분 migration
정리 worker는 이전 기수 정책 ID도 최대200개씩 삭제한다. policy version의 self-reference는 정리 worker는 이전 기수 정책 ID도 최대200개씩 삭제한다. policy version의 self-reference는
삭제 FK로 강제하지 않아 과거 비공개 기수를 key batch로 정리할 수 있다. 현재 기수 포인터는 삭제 FK로 강제하지 않아 과거 비공개 기수를 key batch로 정리할 수 있다. 현재 기수 포인터는
같은 transaction으로 저장하고 조회 시 항상 현재 기수 범위를 검사해야 한다. 같은 transaction으로 저장하고 조회 시 항상 현재 기수 범위를 검사해야 한다.
완전한 초기 기준 내구화와 NPC 결정 연결은 아직 남았다. NPC 결정의 정책 참조와 거부/무변경 시도의 사건 연결은 아직 남았다.
`policyHistory`는 국가/영역/기간을 필수로 받고 기본50·최대200개의 버전 요약을 `policyHistory`는 국가/영역/기간을 필수로 받고 기본50·최대200개의 버전 요약을
revision 내림차순 cursor로 반환한다. 목록에서는 전후 정책 본문과 요청 자료를 읽지 않는다. revision 내림차순 cursor로 반환한다. 목록에서는 전후 정책 본문과 요청 자료를 읽지 않는다.
@@ -148,6 +148,31 @@ world identity와 자료는 기존 RepeatableRead/timeout 계약을 공유한다
실제 변경, 관측 누락 이후 기준과 자료 없음의 의미를 구분한다. 기존 PanelCard와 제어 실제 변경, 관측 누락 이후 기준과 자료 없음의 의미를 구분한다. 기존 PanelCard와 제어
스타일을 사용하고 NPC 설정 화면의 한국어 필드 이름을 따른다. 값은 텍스트로 출력한다. 스타일을 사용하고 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`를 고정해 이전 월별 감사 표본 정리를 시작한다. 새 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 삭제 | | 월별 내구성 | `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 누락 시 처리 | | 기수 identity | `scenario/scenarioSeeder.ts``install.serverId`, `GameHistory` 충돌 검사 | profile명으로 대체하지 않음. 외부 install 입력을 만드는 지점과 RESET 전체 경로를 추가 추적한 뒤 수집 활성화 | 신규 identity 생성, 재시도, 기존 설치에 identity 누락 시 처리 |
| 외교 | game-api `router/diplomacy/index.ts`, engine 월간 외교 처리 | 불변 문서는 참조, 갱신되는 내용만 당시 버전 저장. 현재 상태 월복사만으로 사건을 대신하지 않음 | 모든 API/engine mutation별 inventory | | 외교 | 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 전체 연결 | | 권한 | Gateway `adminCapabilities.ts`, `adminAuth.ts`; game-api `trpc.ts` 인증·제재 middleware | scoped 감사 권한과 공통 계정 추가 권한 분리. `getMyGeneral` 요구 없이 서버에서 검사 | catalog/token/flush/HTTP matrix 전체 연결 |
월간 실행은 이전 월 snapshot → 달 변경 → 새달 `onMonthChanged` 순서다. 월간 실행은 이전 월 snapshot → 달 변경 → 새달 `onMonthChanged` 순서다.
+1
View File
@@ -1084,6 +1084,7 @@ model VoteComment {
// 현재 기수 플레이 감사. gameplay 엔티티 삭제 뒤에도 당시 ID/이름을 유지한다. // 현재 기수 플레이 감사. gameplay 엔티티 삭제 뒤에도 당시 ID/이름을 유지한다.
model PlayAuditMonth { model PlayAuditMonth {
// INITIAL은 migration의 부분 unique index로 serverId당 한 건만 허용한다.
id String @id id String @id
serverId String @map("server_id") serverId String @map("server_id")
year Int 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 IMMEDIATE_ACTION_DATABASE_URL immediate_action
INPUT_EVENT_DATABASE_URL core INPUT_EVENT_DATABASE_URL core
PLAY_AUDIT_RETENTION_DATABASE_URL external_fixture PLAY_AUDIT_RETENTION_DATABASE_URL external_fixture
PLAY_AUDIT_STARTUP_DATABASE_URL external_fixture
LIVE_SORTIE_PERSISTENCE_DATABASE_URL reference_live_sortie LIVE_SORTIE_PERSISTENCE_DATABASE_URL reference_live_sortie
NPC_POSSESSION_DATABASE_URL npc_possession NPC_POSSESSION_DATABASE_URL npc_possession
NPC_POSSESSION_DIFFERENTIAL_DATABASE_URL reference_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