From 88be40f729a5caf745bef9e5c8e20044c5fc5f1f Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 21 Aug 2026 13:49:07 +0000 Subject: [PATCH] =?UTF-8?q?feat(gateway):=20=ED=94=84=EB=A1=9C=ED=95=84?= =?UTF-8?q?=EB=B3=84=20=EC=B2=AB=20=EA=B8=B0=EC=88=98=20=EB=B2=88=ED=98=B8?= =?UTF-8?q?=EB=A5=BC=20=EA=B4=80=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 첫 기수 번호를 완료 게임 수의 기준값으로 적용하고 0기를 API와 화면에서 보존한다. 시즌 번호와 독립된 RESET 계약을 관리 패널, 테스트, 운영 문서에 반영한다. --- app/game-api/src/context.ts | 3 +- app/game-api/test/lobbyRouter.test.ts | 6 +++ .../src/scenario/scenarioSeeder.ts | 9 +++- app/game-engine/test/scenarioSeeder.test.ts | 51 ++++++++++++++----- app/game-frontend/e2e/mainNavigation.spec.ts | 31 +++++++++++ app/game-frontend/src/views/MainView.vue | 2 +- app/gateway-api/src/adminRouter.ts | 1 + .../src/orchestrator/gatewayOrchestrator.ts | 8 +++ app/gateway-api/test/adminOperations.test.ts | 24 +++++++++ app/gateway-api/test/profileGameIndex.test.ts | 16 ++++++ .../test/profileSeedCli.integration.test.ts | 6 ++- .../e2e/server-operations.spec.ts | 12 ++++- app/gateway-frontend/src/views/AdminView.vue | 30 +++++++++++ docs/admin-console.md | 7 +++ docs/architecture/runtime.md | 5 ++ 15 files changed, 192 insertions(+), 19 deletions(-) create mode 100644 app/gateway-api/test/profileGameIndex.test.ts diff --git a/app/game-api/src/context.ts b/app/game-api/src/context.ts index 0cd9df52..95a80255 100644 --- a/app/game-api/src/context.ts +++ b/app/game-api/src/context.ts @@ -44,7 +44,8 @@ export type WorldStateConfig = z.infer; 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(), diff --git a/app/game-api/test/lobbyRouter.test.ts b/app/game-api/test/lobbyRouter.test.ts index 144570c8..fe228c12 100644 --- a/app/game-api/test/lobbyRouter.test.ts +++ b/app/game-api/test/lobbyRouter.test.ts @@ -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( diff --git a/app/game-engine/src/scenario/scenarioSeeder.ts b/app/game-engine/src/scenario/scenarioSeeder.ts index 3a8b17b5..5bf328fe 100644 --- a/app/game-engine/src/scenario/scenarioSeeder.ts +++ b/app/game-engine/src/scenario/scenarioSeeder.ts @@ -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; diff --git a/app/game-engine/test/scenarioSeeder.test.ts b/app/game-engine/test/scenarioSeeder.test.ts index 46b06dff..10ea88b4 100644 --- a/app/game-engine/test/scenarioSeeder.test.ts +++ b/app/game-engine/test/scenarioSeeder.test.ts @@ -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(); diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index b5eb870d..641c7d6b 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -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) => { diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index 01d3ae2c..32861578 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -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; }); diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index 0a03629e..6d170cca 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -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(), diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index 0c5dfdf2..9f653a0c 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -271,6 +271,12 @@ const readMetaNumber = (meta: Record, key: string): number | nu return null; }; +export const resolveProfileFirstGameIdx = (meta: Record): 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, }, diff --git a/app/gateway-api/test/adminOperations.test.ts b/app/gateway-api/test/adminOperations.test.ts index 0d5402fd..ccadf931 100644 --- a/app/gateway-api/test/adminOperations.test.ts +++ b/app/gateway-api/test/adminOperations.test.ts @@ -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 () => { diff --git a/app/gateway-api/test/profileGameIndex.test.ts b/app/gateway-api/test/profileGameIndex.test.ts new file mode 100644 index 00000000..b643c035 --- /dev/null +++ b/app/gateway-api/test/profileGameIndex.test.ts @@ -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); + }); +}); diff --git a/app/gateway-api/test/profileSeedCli.integration.test.ts b/app/gateway-api/test/profileSeedCli.integration.test.ts index 9e79c009..d0c26e8d 100644 --- a/app/gateway-api/test/profileSeedCli.integration.test.ts +++ b/app/gateway-api/test/profileSeedCli.integration.test.ts @@ -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', }, diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index 2fb6f730..a531b73b 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -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(); }); diff --git a/app/gateway-frontend/src/views/AdminView.vue b/app/gateway-frontend/src/views/AdminView.vue index 7c26f61b..a9ae4a46 100644 --- a/app/gateway-frontend/src/views/AdminView.vue +++ b/app/gateway-frontend/src/views/AdminView.vue @@ -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" />
리셋 시 적용할 시즌 번호를 지정합니다.
+ + +
+ 완료된 게임 수에 더할 첫 기수 번호입니다. 다음 리셋부터 적용되며, 완료 이력이 + 생긴 뒤 바꾸면 이후 기수 번호가 이동합니다. +
서버 리셋 기본 옵션 diff --git a/docs/admin-console.md b/docs/admin-console.md index 3ee1c0c8..230a694e 100644 --- a/docs/admin-console.md +++ b/docs/admin-console.md @@ -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:` 권한과 3자 이상의 사유가 있을 때 하나의 diff --git a/docs/architecture/runtime.md b/docs/architecture/runtime.md index 5e1faf54..aeeb1c56 100644 --- a/docs/architecture/runtime.md +++ b/docs/architecture/runtime.md @@ -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에 넣지 않습니다.