diff --git a/app/game-api/src/router/playAudit/index.ts b/app/game-api/src/router/playAudit/index.ts
index 4a48c64d..87d9dc7b 100644
--- a/app/game-api/src/router/playAudit/index.ts
+++ b/app/game-api/src/router/playAudit/index.ts
@@ -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,
};
diff --git a/app/game-api/src/router/playAudit/shared.ts b/app/game-api/src/router/playAudit/shared.ts
index 9ec1375d..95291d90 100644
--- a/app/game-api/src/router/playAudit/shared.ts
+++ b/app/game-api/src/router/playAudit/shared.ts
@@ -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(),
};
diff --git a/app/game-api/test/securityTransport.integration.test.ts b/app/game-api/test/securityTransport.integration.test.ts
index 69b61bb0..85238365 100644
--- a/app/game-api/test/securityTransport.integration.test.ts
+++ b/app/game-api/test/securityTransport.integration.test.ts
@@ -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, {
diff --git a/app/game-api/test/selectPool.integration.test.ts b/app/game-api/test/selectPool.integration.test.ts
index b3a1080b..36dd9fd3 100644
--- a/app/game-api/test/selectPool.integration.test.ts
+++ b/app/game-api/test/selectPool.integration.test.ts
@@ -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'] } },
diff --git a/app/game-engine/src/playAudit/collection.ts b/app/game-engine/src/playAudit/collection.ts
index 29c5db8f..e749d0b3 100644
--- a/app/game-engine/src/playAudit/collection.ts
+++ b/app/game-engine/src/playAudit/collection.ts
@@ -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();
diff --git a/app/game-engine/src/playAudit/persistence.ts b/app/game-engine/src/playAudit/persistence.ts
index e88c085f..dfddd4a3 100644
--- a/app/game-engine/src/playAudit/persistence.ts
+++ b/app/game-engine/src/playAudit/persistence.ts
@@ -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[];
diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts
index 72a4ea96..05a5bc8d 100644
--- a/app/game-engine/src/turn/databaseHooks.ts
+++ b/app/game-engine/src/turn/databaseHooks.ts
@@ -74,6 +74,7 @@ import { prepareRealtimeRecovery } from './prepareRealtimeRecovery.js';
export interface DatabaseTurnHooks {
hooks: TurnDaemonHooks;
+ flushChanges(): Promise;
takeCommittedReadModelChanges(): RealtimeReadModelChanges | null;
takeCommittedReadModelChangeReceipt(): CommittedReadModelChangeReceipt | null;
close(): Promise;
@@ -2078,12 +2079,13 @@ export const createDatabaseTurnHooks = async (
};
};
+ const flushChanges = async (): Promise => {
+ 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;
},
diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts
index ae210b6e..32667ac1 100644
--- a/app/game-engine/src/turn/inMemoryWorld.ts
+++ b/app/game-engine/src/turn/inMemoryWorld.ts
@@ -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));
}
diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts
index 29b965e3..74f2681b 100644
--- a/app/game-engine/src/turn/turnDaemon.ts
+++ b/app/game-engine/src/turn/turnDaemon.ts
@@ -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(),
diff --git a/app/game-engine/test/playAuditCollection.test.ts b/app/game-engine/test/playAuditCollection.test.ts
index 4d53954e..c000c13c 100644
--- a/app/game-engine/test/playAuditCollection.test.ts
+++ b/app/game-engine/test/playAuditCollection.test.ts
@@ -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();
diff --git a/app/game-engine/test/playAuditStartup.integration.test.ts b/app/game-engine/test/playAuditStartup.integration.test.ts
new file mode 100644
index 00000000..23791ccc
--- /dev/null
+++ b/app/game-engine/test/playAuditStartup.integration.test.ts
@@ -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;
+ 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);
+});
diff --git a/app/game-frontend/e2e/playAudit.spec.ts b/app/game-frontend/e2e/playAudit.spec.ts
index 0ac4ec2c..c32a6448 100644
--- a/app/game-frontend/e2e/playAudit.spec.ts
+++ b/app/game-frontend/e2e/playAudit.spec.ts
@@ -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');
+});
diff --git a/app/game-frontend/src/components/playAudit/AuditCityDetail.vue b/app/game-frontend/src/components/playAudit/AuditCityDetail.vue
index 6506ca34..5b01160d 100644
--- a/app/game-frontend/src/components/playAudit/AuditCityDetail.vue
+++ b/app/game-frontend/src/components/playAudit/AuditCityDetail.vue
@@ -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>;
const data = ref(null);
@@ -37,7 +40,11 @@ watch(
도시 조회 중…
diff --git a/app/game-frontend/src/components/playAudit/AuditGeneralDetail.vue b/app/game-frontend/src/components/playAudit/AuditGeneralDetail.vue
index 1fac125f..cda266b5 100644
--- a/app/game-frontend/src/components/playAudit/AuditGeneralDetail.vue
+++ b/app/game-frontend/src/components/playAudit/AuditGeneralDetail.vue
@@ -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>;
type Turns = Awaited>;
@@ -69,7 +72,11 @@ watch(
상세 조회 중…
@@ -93,7 +100,7 @@ watch(
{{ format(data.general.dedication) }}
숙련 (보 / 궁 / 기 / 귀 / 차): {{ Object.values(data.general.dex).map(format).join(' / ') }}
- 과거 예약 명령은 월말 표본에 포함되지 않습니다.
+ 과거 예약 명령은 상태 표본에 포함되지 않습니다.
diff --git a/app/game-frontend/src/components/playAudit/AuditNationSnapshot.vue b/app/game-frontend/src/components/playAudit/AuditNationSnapshot.vue
index e6ec9772..87029e38 100644
--- a/app/game-frontend/src/components/playAudit/AuditNationSnapshot.vue
+++ b/app/game-frontend/src/components/playAudit/AuditNationSnapshot.vue
@@ -1,7 +1,9 @@
- 선택한 시점의 최종 표본이 없습니다.
- 최종 표본에 해당 국가가 없습니다.
+ 선택한 시점의 표본이 없습니다.
+ 표본에 해당 국가가 없습니다.
- {{ data.nation.name }} · {{ data.sample?.year }}년 {{ data.sample?.month }}월 최종 표본
-
- 최종 표본은 월말 시계열과 별도로 표시합니다. 아래 정산은 이 표본을 수집할 때까지 해당 월에 관측한 값입니다.
-
+ {{ data.nation.name }} · {{ data.sample?.year }}년 {{ data.sample?.month }}월 {{ label }}
+ 월말 시계열과 구분되는 표본입니다. 아래 정산은 이 표본을 수집할 때까지 해당 월에 관측한 값입니다.
정산 수집이 불완전한 월입니다.
@@ -38,7 +38,7 @@ const groups = [
- {{ format(data.nation.paidGold) }} / {{ format(data.nation.paidRice) }}
-