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(