feat(gateway): 프로필별 첫 기수 번호를 관리한다
첫 기수 번호를 완료 게임 수의 기준값으로 적용하고 0기를 API와 화면에서 보존한다. 시즌 번호와 독립된 RESET 계약을 관리 패널, 테스트, 운영 문서에 반영한다.
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
서버 리셋 기본 옵션
|
||||
|
||||
@@ -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자 이상의 사유가 있을 때 하나의
|
||||
|
||||
@@ -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에 넣지 않습니다.
|
||||
|
||||
Reference in New Issue
Block a user