From 9e14306fe459d5ab5f9e52a6933a7c82e953d8c8 Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 16 Sep 2026 04:07:56 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=ED=94=8C=EB=A0=88=EC=9D=B4=20=EA=B0=90?= =?UTF-8?q?=EC=82=AC=20=EA=B8=B0=EA=B0=84=EC=9D=84=20=EC=8B=A4=EC=A0=9C=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=20=EB=8B=AC=EB=A0=A5=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=9C=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/router/playAudit/logs.ts | 2 +- .../src/router/playAudit/nationSeries.ts | 4 +- app/game-api/src/router/playAudit/shared.ts | 22 ++++++- .../securityTransport.integration.test.ts | 65 +++++++++++++++++++ app/game-engine/test/scenarioSeeder.test.ts | 5 ++ app/game-frontend/e2e/playAudit.spec.ts | 36 ++++++++++ app/game-frontend/src/views/PlayAuditView.vue | 21 +++++- docs/design/play-audit-implementation.md | 13 ++++ 8 files changed, 160 insertions(+), 8 deletions(-) diff --git a/app/game-api/src/router/playAudit/logs.ts b/app/game-api/src/router/playAudit/logs.ts index 35f3ee94..23065132 100644 --- a/app/game-api/src/router/playAudit/logs.ts +++ b/app/game-api/src/router/playAudit/logs.ts @@ -28,7 +28,7 @@ export const generalLogs = auditProcedure const world = await readAuditWorld(tx); if ( input.month && - (input.month.year < world.startYear || + (monthOrdinal(input.month.year, input.month.month) < monthOrdinal(world.startYear, world.startMonth) || monthOrdinal(input.month.year, input.month.month) > monthOrdinal(world.year, world.month)) ) { throw new TRPCError({ code: 'BAD_REQUEST', message: '현재 기수 안의 로그 월을 선택해 주세요.' }); diff --git a/app/game-api/src/router/playAudit/nationSeries.ts b/app/game-api/src/router/playAudit/nationSeries.ts index 6ecdd4bf..80386335 100644 --- a/app/game-api/src/router/playAudit/nationSeries.ts +++ b/app/game-api/src/router/playAudit/nationSeries.ts @@ -112,9 +112,9 @@ export const nationSeries = auditProcedure const current = monthOrdinal(world.year, world.month); const from = input.from ? monthOrdinal(input.from.year, input.from.month) - : Math.max(world.startYear * 12, current - 5); + : Math.max(monthOrdinal(world.startYear, world.startMonth), current - 5); const to = input.to ? monthOrdinal(input.to.year, input.to.month) : current; - if (from < world.startYear * 12 || to > current || from > to) { + if (from < monthOrdinal(world.startYear, world.startMonth) || to > current || from > to) { throw new TRPCError({ code: 'BAD_REQUEST', message: '현재 기수 안에서 시작·종료 월을 선택해 주세요.' }); } const width = input.resolution === 'month' ? 1 : 6; diff --git a/app/game-api/src/router/playAudit/shared.ts b/app/game-api/src/router/playAudit/shared.ts index fcf68e8e..9ec1375d 100644 --- a/app/game-api/src/router/playAudit/shared.ts +++ b/app/game-api/src/router/playAudit/shared.ts @@ -66,17 +66,35 @@ export const readAuditWorld = async (tx: GamePrisma.TransactionClient) => { const serverId = typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId : null; const scenario = asRecord(meta.scenarioMeta); const config = asRecord(world.config); - const startYear = + const scenarioStartYear = typeof scenario.startYear === 'number' ? scenario.startYear : typeof asRecord(config.scenarioMeta).startYear === 'number' ? Number(asRecord(config.scenarioMeta).startYear) : world.currentYear; + // 동기화 개방은 시나리오 시작 전년도에 시작할 수 있다. 저장된 실제 달력을 + // 조회 경계로 쓰며 gameplay 규칙인 scenario.startYear는 변경하지 않는다. + const hasInitialCalendar = + typeof meta.initYear === 'number' && + Number.isInteger(meta.initYear) && + meta.initYear >= 0 && + typeof meta.initMonth === 'number' && + Number.isInteger(meta.initMonth) && + meta.initMonth >= 1 && + meta.initMonth <= 12 && + monthOrdinal(meta.initYear, meta.initMonth) <= monthOrdinal(world.currentYear, world.currentMonth); + const startYear = hasInitialCalendar + ? Number(meta.initYear) + : Number.isInteger(scenarioStartYear) && scenarioStartYear >= 0 + ? Math.min(scenarioStartYear, world.currentYear) + : world.currentYear; + const startMonth = hasInitialCalendar ? Number(meta.initMonth) : 1; return { serverId, year: world.currentYear, month: world.currentMonth, startYear, + startMonth, tick: world.lastTurnTick?.toString() ?? null, asOf: new Date().toISOString(), }; @@ -89,7 +107,7 @@ export const findAuditMonth = async ( at: z.infer ) => { const ordinal = monthOrdinal(at.year, at.month); - if (at.year < world.startYear || ordinal > monthOrdinal(world.year, world.month)) { + if (ordinal < monthOrdinal(world.startYear, world.startMonth) || ordinal > monthOrdinal(world.year, world.month)) { throw new TRPCError({ code: 'BAD_REQUEST', message: '현재 기수의 게임 연월을 선택해 주세요.' }); } if (!world.serverId) return null; diff --git a/app/game-api/test/securityTransport.integration.test.ts b/app/game-api/test/securityTransport.integration.test.ts index 1d6a8844..61b73f12 100644 --- a/app/game-api/test/securityTransport.integration.test.ts +++ b/app/game-api/test/securityTransport.integration.test.ts @@ -2570,6 +2570,71 @@ integration('game API security over HTTP transport', () => { result: { data: { items: [{ complete: false, stock: null, flows: { incomeGold: null } }] } }, }); + // A synchronized opening may start before the scenario's gameplay year. + await db.worldState.update({ + where: { id: fixtureWorldId }, + data: { + currentYear: 189, + currentMonth: 10, + meta: { serverId: seasonId, initYear: 189, initMonth: 10, scenarioMeta: { startYear: 190 } }, + }, + }); + await db.playAuditMonth.create({ + data: { + id: `${seasonId}:initial`, + serverId: seasonId, + year: 189, + month: 10, + kind: 'MONTH_END', + settlementsComplete: false, + hash: 'initial-calendar', + }, + }); + await db.logEntry.create({ + data: { + serverId: seasonId, + scope: 'GENERAL', + category: 'HISTORY', + generalId: logGeneralId, + year: 189, + month: 10, + text: `${seasonId}:initial-log`, + }, + }); + const initial = { year: 189, month: 10 }; + for (const [path, input] of [ + ['nationSnapshot', { nationId: ownerNationId, at: initial }], + ['generalDetail', { id: generalId, at: initial }], + ['cityDetail', { id: 99123, at: initial }], + ] as const) { + const response = await get(path, admin, input); + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + result: { data: { startYear: 189, startMonth: 10, collected: true } }, + }); + expect((await get(path, admin, { ...input, at: { year: 189, month: 9 } })).status).toBe(400); + } + expect((await get('nationSeries', admin, { nationId: ownerNationId })).body).toMatchObject({ + result: { data: { items: [{ from: initial, to: initial }] } }, + }); + expect( + (await get('nationSeries', admin, { nationId: ownerNationId, from: { year: 189, month: 9 } })).status + ).toBe(400); + expect( + (await get('generalLogs', admin, { generalId: logGeneralId, type: 'generalHistory', month: initial })) + .body + ).toMatchObject({ result: { data: { items: [{ text: `${seasonId}:initial-log` }] } } }); + expect( + ( + await get('generalLogs', admin, { + generalId: logGeneralId, + type: 'generalHistory', + month: { year: 189, month: 9 }, + }) + ).status + ).toBe(400); + await db.worldState.update({ where: { id: fixtureWorldId }, data: { currentYear: 190, currentMonth: 7 } }); + await db.worldState.update({ where: { id: fixtureWorldId }, data: { diff --git a/app/game-engine/test/scenarioSeeder.test.ts b/app/game-engine/test/scenarioSeeder.test.ts index 696b24ce..a39db8c9 100644 --- a/app/game-engine/test/scenarioSeeder.test.ts +++ b/app/game-engine/test/scenarioSeeder.test.ts @@ -226,6 +226,11 @@ describeDb('scenario database seed', () => { clockTick: 0n, lastTurnTick: 0n, clockPhase: 'PREOPEN', + meta: { + initYear: (scenario.startYear ?? 0) + yearOffset, + initMonth: month, + scenarioMeta: { startYear: scenario.startYear }, + }, }); const clock = new GameClock({ baseTime: world.clockBaseTime!, diff --git a/app/game-frontend/e2e/playAudit.spec.ts b/app/game-frontend/e2e/playAudit.spec.ts index a12263ba..51087cc3 100644 --- a/app/game-frontend/e2e/playAudit.spec.ts +++ b/app/game-frontend/e2e/playAudit.spec.ts @@ -7,6 +7,7 @@ const world = { year: 190, month: 7, startYear: 190, + startMonth: 1, serverId: 'audit-fixture', tick: '100', asOf: '2026-09-16T00:00:00.000Z', @@ -474,3 +475,38 @@ test('historical log failure retries independently and sends only the selected m expect(requests.filter((r) => r.operation === 'playAudit.generalTurns')).toHaveLength(0); await capture(page, 'historical-general-logs-mobile'); }); + +test('initial calendar before the scenario year bounds default periods and month controls', async ({ page }) => { + const requests = await install(page); + await page.route(gameTrpcRoute, async (route) => { + if (decodeURIComponent(route.request().url()).includes('playAudit.coverage')) { + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify([ + { + result: { + data: { + ...world, + year: 189, + month: 10, + startYear: 189, + startMonth: 10, + status: 'COLLECTED', + samples: [], + nextCursor: null, + }, + }, + }, + ]), + }); + } else await route.fallback(); + }); + await page.goto(gamePath('/play-audit?tab=nations&nation=2')); + await expect(page.getByLabel('시작 연도', { exact: true })).toHaveValue('189'); + await expect(page.getByLabel('시작 월', { exact: true })).toHaveValue('10'); + await expect(page.getByLabel('시작 월', { exact: true })).toHaveAttribute('min', '10'); + await expect(page.getByLabel('월', { exact: true })).toHaveAttribute('min', '10'); + await expect + .poll(() => requests.find((r) => r.operation === 'playAudit.nationSeries')?.input) + .toMatchObject({ from: { year: 189, month: 10 }, to: { year: 189, month: 10 } }); +}); diff --git a/app/game-frontend/src/views/PlayAuditView.vue b/app/game-frontend/src/views/PlayAuditView.vue index d8950295..9b26a240 100644 --- a/app/game-frontend/src/views/PlayAuditView.vue +++ b/app/game-frontend/src/views/PlayAuditView.vue @@ -102,7 +102,10 @@ const readQuery = () => { moment.value = ['month', 'final'].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((coverage.value?.startYear ?? year.value) * 12, year.value * 12 + month.value - 6); + const defaultStart = Math.max( + (coverage.value?.startYear ?? year.value) * 12 + (coverage.value?.startMonth ?? 1) - 1, + year.value * 12 + month.value - 6 + ); fromYear.value = numeric(route.query.fromYear, Math.floor(defaultStart / 12)); fromMonth.value = numeric(route.query.fromMonth, (defaultStart % 12) + 1); resolution.value = route.query.resolution === 'month' ? 'month' : 'halfYear'; @@ -333,7 +336,14 @@ onMounted(async () => { :max="coverage.year" required /> - +