feat: 서버 준비 전에 감사 초기 상태와 정책 기준을 저장
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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'] } },
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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` 순서다.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Reference in New Issue
Block a user