feat(gateway): 프로필별 첫 기수 번호를 관리한다

첫 기수 번호를 완료 게임 수의 기준값으로 적용하고 0기를 API와 화면에서 보존한다. 시즌 번호와 독립된 RESET 계약을 관리 패널, 테스트, 운영 문서에 반영한다.
This commit is contained in:
2026-08-21 13:49:11 +00:00
parent 32d69d7049
commit 88be40f729
15 changed files with 192 additions and 19 deletions
+2 -1
View File
@@ -44,7 +44,8 @@ export type WorldStateConfig = z.infer<typeof zWorldStateConfig>;
export const zWorldStateMeta = z.object({
serverId: z.string().optional(),
gameIdx: z.number().int().positive().optional(),
firstGameIdx: z.number().int().nonnegative().optional(),
gameIdx: z.number().int().nonnegative().optional(),
starttime: z.string().optional(),
opentime: z.string().optional(),
preopenAt: z.string().optional(),
+6
View File
@@ -70,6 +70,12 @@ describe('lobby season state', () => {
expect(result.clockMode).toBe('manual');
});
it('preserves zero as the first official game index', async () => {
const result = await appRouter.createCaller(buildContext({ gameIdx: 0 })).lobby.info();
expect(result.gameIdx).toBe(0);
});
it('projects the Ref-compatible opening announcement settings without exposing disabled autorun options', async () => {
const result = await appRouter
.createCaller(
@@ -51,6 +51,7 @@ export interface ScenarioInstallOptions {
autorunUser?: ScenarioAutorunOptions | null;
preopenAt?: Date | null;
season?: number;
firstGameIdx?: number;
serverId?: string;
installOperationId?: string;
installCommitSha?: string;
@@ -298,6 +299,12 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
lastTurnTime: formatDateTime(now),
};
const firstGameIdx =
typeof install?.firstGameIdx === 'number' && Number.isFinite(install.firstGameIdx) && install.firstGameIdx >= 0
? Math.floor(install.firstGameIdx)
: 1;
worldMeta.firstGameIdx = firstGameIdx;
if (typeof install?.season === 'number' && Number.isFinite(install.season)) {
worldMeta.season = Math.floor(install.season);
}
@@ -390,7 +397,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
// Ref fixes server_cnt once during ResetHelper initialization. Keep the
// frequently rendered game index in the same persisted read model and
// exclude abandoned or unfinished rows from the official sequence.
worldMeta.gameIdx = completedGameCount + 1;
worldMeta.gameIdx = completedGameCount + firstGameIdx;
const archivedWorldMeta = { ...worldMeta };
delete archivedWorldMeta.hiddenSeed;
+37 -14
View File
@@ -171,22 +171,33 @@ describeDb('scenario database seed', () => {
}
});
test('persists the next official game index without counting cancelled or unfinished games', async () => {
test('adds the configured first game index without counting cancelled or unfinished games', async () => {
const marker = `scenario-seeder-game-index-${Date.now()}`;
const connector = createGamePostgresConnector({ url: databaseUrl });
await connector.connect();
try {
const completedBefore = await connector.prisma.gameHistory.count({ where: { status: 'COMPLETED' } });
expect(completedBefore).toBe(0);
await seedScenarioToDatabase({
scenarioId: 1010,
databaseUrl,
installOptions: { serverId: `${marker}-zero`, firstGameIdx: 0 },
});
const zeroWorldState = await connector.prisma.worldState.findFirstOrThrow();
expect(zeroWorldState.meta).toMatchObject({ firstGameIdx: 0, gameIdx: 0 });
const zeroBasedHistory = await connector.prisma.gameHistory.findUniqueOrThrow({
where: { serverId: `${marker}-zero` },
});
expect(zeroBasedHistory).toMatchObject({ status: 'OPEN' });
expect(zeroBasedHistory.env).toMatchObject({ meta: { firstGameIdx: 0, gameIdx: 0 } });
await connector.prisma.gameHistory.update({
where: { serverId: `${marker}-zero` },
data: { status: 'COMPLETED' },
});
await connector.prisma.gameHistory.createMany({
data: [
{
serverId: `${marker}-completed`,
date: new Date('2026-08-01T00:00:00.000Z'),
season: 1,
scenario: 1010,
scenarioName: '정상 종료 fixture',
status: 'COMPLETED',
},
{
serverId: `${marker}-abandoned`,
date: new Date('2026-08-02T00:00:00.000Z'),
@@ -209,14 +220,26 @@ describeDb('scenario database seed', () => {
await seedScenarioToDatabase({
scenarioId: 1010,
databaseUrl,
installOptions: { serverId: marker },
installOptions: { serverId: `${marker}-one`, firstGameIdx: 0 },
});
const worldState = await connector.prisma.worldState.findFirstOrThrow();
expect(worldState.meta).toMatchObject({ gameIdx: completedBefore + 2 });
await expect(
connector.prisma.gameHistory.findUniqueOrThrow({ where: { serverId: marker } })
).resolves.toMatchObject({ status: 'OPEN' });
expect(worldState.meta).toMatchObject({ firstGameIdx: 0, gameIdx: completedBefore + 1 });
const oneBasedHistory = await connector.prisma.gameHistory.findUniqueOrThrow({
where: { serverId: `${marker}-one` },
});
expect(oneBasedHistory).toMatchObject({ status: 'OPEN' });
expect(oneBasedHistory.env).toMatchObject({
meta: { firstGameIdx: 0, gameIdx: completedBefore + 1 },
});
await seedScenarioToDatabase({
scenarioId: 1010,
databaseUrl,
installOptions: { serverId: `${marker}-default` },
});
const defaultWorldState = await connector.prisma.worldState.findFirstOrThrow();
expect(defaultWorldState.meta).toMatchObject({ firstGameIdx: 1, gameIdx: completedBefore + 2 });
} finally {
await connector.prisma.gameHistory.deleteMany({ where: { serverId: { startsWith: marker } } });
await connector.disconnect();
@@ -1376,6 +1376,37 @@ test('shows the persisted official game index beside the scenario title without
}
});
test('shows game index zero for a profile whose first game starts at zero', async ({ page }) => {
const state: NavigationFixture = {
officerLevel: 5,
permission: 2,
nationLevel: 3,
stage: 0,
npcMode: 1,
profile: 'che',
gameIdx: 0,
scenarioTitle: '코어 검증 시나리오',
generalMeCalls: 0,
operations: [],
};
await installFixture(page, state);
for (const viewport of [
{ width: 1200, height: 900 },
{ width: 500, height: 900 },
]) {
await page.setViewportSize(viewport);
if (page.url() === 'about:blank') await waitForMain(page);
const title = page.getByRole('heading', { name: '코어 검증 시나리오 체섭 0기', exact: true });
await expect(title).toBeVisible();
const overflow = await title.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth
);
expect(overflow).toBeLessThanOrEqual(0);
}
});
test('nation split buttons keep square inner corners and a single divider in every interaction state', async ({
page,
}, testInfo) => {
+1 -1
View File
@@ -113,7 +113,7 @@ const gameTitle = computed(() => {
const scenarioTitle = lobbyInfo.value?.scenarioTitle || '전장 현황';
const profileLabel = gameProfileLabel.value;
const gameIdx = lobbyInfo.value?.gameIdx;
return profileLabel && typeof gameIdx === 'number' && Number.isInteger(gameIdx) && gameIdx > 0
return profileLabel && typeof gameIdx === 'number' && Number.isInteger(gameIdx) && gameIdx >= 0
? `${scenarioTitle} ${profileLabel}${gameIdx}`
: scenarioTitle;
});
+1
View File
@@ -1876,6 +1876,7 @@ export const adminRouter = router({
inGameNotice: z.string().max(4000).nullable().optional(),
profileImageUrl: z.string().max(2048).nullable().optional(),
nextSeasonIdx: z.number().int().min(0).nullable().optional(),
firstGameIdx: z.number().int().min(0).nullable().optional(),
localAccountAccessGraceDays: z.number().int().min(0).max(365).nullable().optional(),
localAccountGeneralCreationGraceDays: z.number().int().min(0).max(365).nullable().optional(),
resetDefaults: zProfileResetDefaults.nullable().optional(),
@@ -271,6 +271,12 @@ const readMetaNumber = (meta: Record<string, unknown>, key: string): number | nu
return null;
};
export const resolveProfileFirstGameIdx = (meta: Record<string, unknown>): number => {
const raw = meta.firstGameIdx;
const configured = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : Number.NaN;
return Number.isInteger(configured) && configured >= 0 ? configured : 1;
};
const normalizeStatus = (value: unknown): GatewayAdminActionStatus | null => {
if (typeof value === 'string') {
return value as GatewayAdminActionStatus;
@@ -1882,6 +1888,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
);
const profileMeta = normalizeMeta(profile.meta);
const nextSeasonIdx = readMetaNumber(profileMeta, 'nextSeasonIdx');
const firstGameIdx = resolveProfileFirstGameIdx(profileMeta);
const baseSeason = readMetaNumber(normalizeMeta(seedInfo.meta), 'season');
const season = nextSeasonIdx ?? baseSeason ?? 1;
await updateClaimedProfile({ status: 'STOPPED' }, () =>
@@ -1902,6 +1909,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
installOptions: {
...(installOptions ?? {}),
season,
firstGameIdx,
serverId,
installCommitSha: commitSha,
},
@@ -877,6 +877,30 @@ describe('admin operation API', () => {
).rejects.toBeDefined();
});
it('stores zero as the first game index and rejects negative values', async () => {
const harness = await buildCaller(
async () => {
throw new Error('not used');
},
{ adminRoles: ['admin.profiles.settings:che:2'], firstUserIsAdmin: false }
);
await harness.caller.admin.profiles.updateMeta({
profileName: 'che:2',
patch: { firstGameIdx: 0 },
reason: 'start core series at zero',
});
expect(harness.updatedMetas.at(-1)).toMatchObject({ firstGameIdx: 0 });
await expect(
harness.caller.admin.profiles.updateMeta({
profileName: 'che:2',
patch: { firstGameIdx: -1 },
reason: 'reject negative game index',
})
).rejects.toBeDefined();
});
it('does not let a scenario-only operator combine a Git update with reset', async () => {
const harness = await buildCaller(
async () => {
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { resolveProfileFirstGameIdx } from '../src/orchestrator/gatewayOrchestrator.js';
describe('profile first game index', () => {
it('preserves an explicitly configured zero', () => {
expect(resolveProfileFirstGameIdx({ firstGameIdx: 0 })).toBe(0);
});
it('defaults missing or invalid metadata to one', () => {
expect(resolveProfileFirstGameIdx({})).toBe(1);
expect(resolveProfileFirstGameIdx({ firstGameIdx: -1 })).toBe(1);
expect(resolveProfileFirstGameIdx({ firstGameIdx: 0.5 })).toBe(1);
expect(resolveProfileFirstGameIdx({ firstGameIdx: 'invalid' })).toBe(1);
});
});
@@ -41,6 +41,8 @@ describeDatabase('selected workspace profile seed CLI', () => {
const requestFile = path.join(tempDirectory, 'request.json');
const connector = createGamePostgresConnector({ url: databaseUrl });
try {
await connector.connect();
const completedGameCount = await connector.prisma.gameHistory.count({ where: { status: 'COMPLETED' } });
await fs.writeFile(
requestFile,
JSON.stringify({
@@ -49,6 +51,7 @@ describeDatabase('selected workspace profile seed CLI', () => {
now: '2036-03-03T00:00:00.000Z',
installOptions: {
serverId: 'selected-cli-seed',
firstGameIdx: 0,
installOperationId: 'selected-cli-operation',
installCommitSha: 'selected-cli-commit',
},
@@ -63,11 +66,12 @@ describeDatabase('selected workspace profile seed CLI', () => {
const result = await runSeedCli(requestFile);
expect(result, result.output).toMatchObject({ code: 0 });
await connector.connect();
const world = await connector.prisma.worldState.findFirstOrThrow();
expect(world).toMatchObject({
scenarioCode: '1010',
meta: {
firstGameIdx: 0,
gameIdx: completedGameCount,
installOperationId: 'selected-cli-operation',
installCommitSha: 'selected-cli-commit',
},
@@ -1176,20 +1176,29 @@ test('edits server reset defaults through profile metadata settings', async ({ p
expect(request).toContain('"npcMode":2');
});
test('stores event season zero from server metadata settings', async ({ page }) => {
test('stores event season zero and first game zero from server metadata settings', async ({ page }) => {
const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [] };
await installFixture(page, state);
await page.goto('admin/servers/che%3Adefault');
const nextSeasonInput = page.getByTestId('next-season-idx');
const firstGameInput = page.getByTestId('first-game-idx');
await nextSeasonInput.fill('0');
await firstGameInput.fill('0');
await expect(nextSeasonInput).toHaveValue('0');
await expect(firstGameInput).toHaveValue('0');
expect(
await nextSeasonInput.evaluate((element: HTMLInputElement) => ({
valid: element.validity.valid,
valueAsNumber: element.valueAsNumber,
}))
).toEqual({ valid: true, valueAsNumber: 0 });
expect(
await firstGameInput.evaluate((element: HTMLInputElement) => ({
valid: element.validity.valid,
valueAsNumber: element.valueAsNumber,
}))
).toEqual({ valid: true, valueAsNumber: 0 });
await page.getByPlaceholder('변경 사유 (필수)').fill('prepare event season');
await page.getByRole('button', { name: '메타 저장' }).click();
@@ -1200,6 +1209,7 @@ test('stores event season zero from server metadata settings', async ({ page })
state.requestBodies.find((entry) => entry.operation === 'admin.profiles.updateMeta')?.body
);
expect(request).toContain('"nextSeasonIdx":0');
expect(request).toContain('"firstGameIdx":0');
await expect(page.getByTestId('action-toast').filter({ hasText: '메타 저장 완료' })).toBeVisible();
});
@@ -353,6 +353,7 @@ type AdminClient = {
inGameNotice?: string | null;
profileImageUrl?: string | null;
nextSeasonIdx?: number | null;
firstGameIdx?: number | null;
localAccountAccessGraceDays?: number | null;
localAccountGeneralCreationGraceDays?: number | null;
resetDefaults?: ProfileResetDefaults | null;
@@ -409,6 +410,7 @@ const profileEdits = ref<
inGameNotice: string;
profileImageUrl: string;
nextSeasonIdx: string | number;
firstGameIdx: string | number;
localAccountAccessGraceDays: string;
localAccountGeneralCreationGraceDays: string;
resetDefaults: ProfileResetDefaults;
@@ -657,6 +659,10 @@ const ensureProfileBuffers = (profile: AdminProfile) => {
typeof meta.nextSeasonIdx === 'number' && Number.isFinite(meta.nextSeasonIdx)
? String(Math.floor(meta.nextSeasonIdx))
: '',
firstGameIdx:
typeof meta.firstGameIdx === 'number' && Number.isFinite(meta.firstGameIdx)
? String(Math.floor(meta.firstGameIdx))
: '1',
localAccountAccessGraceDays:
typeof meta.localAccountAccessGraceDays === 'number'
? String(Math.floor(meta.localAccountAccessGraceDays))
@@ -774,6 +780,15 @@ const updateProfileMeta = async (profileName: string) => {
};
return;
}
const firstGameIdxRaw = String(edit.firstGameIdx).trim();
const firstGameIdx = firstGameIdxRaw === '' ? null : Number(firstGameIdxRaw);
if (firstGameIdx !== null && (!Number.isInteger(firstGameIdx) || firstGameIdx < 0)) {
profileActionStatus.value = {
...profileActionStatus.value,
[profileName]: '첫 기수 번호는 0 이상 정수여야 합니다.',
};
return;
}
const readGraceDays = (value: string): number | null => {
if (!value.trim()) return null;
const parsed = Number(value);
@@ -811,6 +826,7 @@ const updateProfileMeta = async (profileName: string) => {
inGameNotice: edit.inGameNotice.trim() || null,
profileImageUrl: edit.profileImageUrl.trim() || null,
nextSeasonIdx: nextSeasonIdx === null ? null : Math.floor(nextSeasonIdx),
firstGameIdx,
localAccountAccessGraceDays: accessGraceDays,
localAccountGeneralCreationGraceDays: creationGraceDays,
resetDefaults: {
@@ -2229,6 +2245,20 @@ onMounted(() => {
placeholder="예: 12"
/>
<div class="text-xs text-zinc-500">리셋 시 적용할 시즌 번호를 지정합니다.</div>
<label class="text-xs text-zinc-400">첫 기수 번호</label>
<input
v-model="profileEdits[profile.profileName].firstGameIdx"
type="number"
min="0"
step="1"
data-testid="first-game-idx"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="기본값: 1"
/>
<div class="text-xs text-zinc-500">
완료된 게임 수에 더할 첫 기수 번호입니다. 다음 리셋부터 적용되며, 완료 이력이
생긴 뒤 바꾸면 이후 기수 번호가 이동합니다.
</div>
<details class="rounded border border-zinc-700 bg-zinc-950/60 p-3">
<summary class="cursor-pointer text-sm font-semibold text-zinc-200">
서버 리셋 기본 옵션
+7
View File
@@ -81,6 +81,13 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
기수는 0을 저장한 뒤 RESET하고, 이벤트 종료 뒤 정상 기수 번호를 다시 저장한
다음 RESET합니다. 빈 값은 강제 번호를 해제하여 기존 게임의 season 또는 신규
기본값 1을 사용한다는 뜻입니다.
- `첫 기수 번호``GatewayProfile.meta.firstGameIdx`이며 기본값은 1입니다.
RESET은 `GameHistory.status=COMPLETED`인 이력 수에 이 값을 더해 현재
`WorldState.meta.gameIdx`를 확정합니다. `season`은 이벤트 분류와 연차를 위한 별도
값이므로 계산에 참여하지 않고, `OPEN`·`ABANDONED` 이력도 세지 않습니다. 예를 들어
첫 기수 번호가 0인 profile은 완료 이력이 없을 때 0기, 0기가 완료된 다음 RESET에서
1기가 됩니다. 변경은 다음 RESET부터 적용되며 완료 이력이 생긴 뒤 값을 바꾸면 이후
번호가 이동하므로 서버 최초 번호를 정할 때만 설정하는 것을 원칙으로 합니다.
- 같은 화면의 `실행 중 게임 옵션`은 리셋 기본값과 별개로 현재 기수 DB의 턴
간격, 장수 생성 제한, 유저 자동턴 제한·동작을 읽어 표시합니다. 세 값은
`admin.profiles.runtime:<name>` 권한과 3자 이상의 사유가 있을 때 하나의
+5
View File
@@ -111,6 +111,11 @@ Gateway session을 발급합니다. 게임 profile 진입에서는 다시 제재
Kakao 확인, 운영자 role, 유효한 `SpecialAccountAccessGrant`, 기존 일반 계정 유예
순으로 접근과 장수 생성 가능 여부를 계산합니다. grant의 빈 `profiles`는 전체,
base profile(`che`)은 모든 기수, profile name(`che:2`)은 정확한 기수를 뜻합니다.
Gateway profile meta의 `firstGameIdx`는 profile별 첫 표시 기수 번호를 정합니다. game
seed는 `firstGameIdx + COMPLETED GameHistory 수``WorldState.meta.gameIdx`에 저장하며,
`season`, 미완료 `OPEN`, 취소된 `ABANDONED` 이력은 이 계산의 기준이 아닙니다. 값이
없거나 유효하지 않으면 1을 사용합니다.
결과는 AES-256-GCM game token의 `identity.specialAccess`
`identity.canCreateGeneral`에 서명되어 game API가 장수 생성 mutation 전에 다시
검사합니다. grant 사유나 부여자 정보는 game token에 넣지 않습니다.