fix: 플레이 감사 기간을 실제 초기 달력으로 제한
This commit is contained in:
@@ -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: '현재 기수 안의 로그 월을 선택해 주세요.' });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<typeof zAuditMonth>
|
||||
) => {
|
||||
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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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!,
|
||||
|
||||
@@ -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 } });
|
||||
});
|
||||
|
||||
@@ -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
|
||||
/></label>
|
||||
<label>월<input v-model.number="month" type="number" min="1" max="12" required /></label>
|
||||
<label
|
||||
>월<input
|
||||
v-model.number="month"
|
||||
type="number"
|
||||
:min="year === coverage.startYear ? coverage.startMonth : 1"
|
||||
:max="year === coverage.year ? coverage.month : 12"
|
||||
required
|
||||
/></label>
|
||||
<template v-if="tab === 'nations' && moment !== 'final'">
|
||||
<label
|
||||
>시작 연도<input
|
||||
@@ -344,7 +354,12 @@ onMounted(async () => {
|
||||
required
|
||||
/></label>
|
||||
<label
|
||||
>시작 월<input v-model.number="fromMonth" type="number" min="1" max="12" required
|
||||
>시작 월<input
|
||||
v-model.number="fromMonth"
|
||||
type="number"
|
||||
:min="fromYear === coverage.startYear ? coverage.startMonth : 1"
|
||||
:max="fromYear === coverage.year ? coverage.month : 12"
|
||||
required
|
||||
/></label>
|
||||
<label
|
||||
>간격<select class="legacy-sort-select" v-model="resolution">
|
||||
|
||||
@@ -75,6 +75,19 @@ triggerState, credential과 전체 world는 복사하지 않는다.
|
||||
국가 전후값·적용 세율·보정액은 이후 원장 구현에서 보존해야 하며 이 projection만으로
|
||||
R1을 완료했다고 판단하지 않는다.
|
||||
|
||||
### 실제 초기 달력의 조회 범위
|
||||
|
||||
감사 응답의 `startYear/startMonth`는 유효한 `world.meta.initYear/initMonth`를 우선한다.
|
||||
동기화 개방은 `scenarioMeta.startYear`의 전년도에 시작할 수 있으므로 시나리오 규칙 연도를
|
||||
조회 하한으로 고정하지 않는다. 두 metadata가 없거나 유효하지 않으면 시나리오 시작 연도와
|
||||
현재 연도 중 이른 연도의 1월을 호환 fallback으로 사용한다. 이것은 최초 수집 증거가 아니며
|
||||
자료 존재는 월 header로 별도 확인한다. 불변 기수 식별자 필터도 계속 적용한다.
|
||||
|
||||
월말/최종 상세, 장수 로그, 국가 시계열의 범위 검증과 기본 최근 6개월 기간은 같은 연월
|
||||
하한을 사용한다. UI도 시작 연도의 최소 월과 현재 연도의 최대 월을 제한한다. 이미 읽던
|
||||
world metadata로 계산하며 추가 DB 조회·쓰기나 시나리오/AI 규칙 변경은 없다.
|
||||
PREOPEN은 wall-clock 대기 상태이며, 검증하는 것은 공식 개방 때의 논리 게임 달력이다.
|
||||
|
||||
## 기존 장수 로그의 기수별 조회
|
||||
|
||||
`generalLogs`는 기존 `LogEntry`에서 현재 기수와 장수·기록 종류를 제한하고
|
||||
|
||||
Reference in New Issue
Block a user