feat(gateway): 프로필별 첫 기수 번호를 관리한다
첫 기수 번호를 완료 게임 수의 기준값으로 적용하고 0기를 API와 화면에서 보존한다. 시즌 번호와 독립된 RESET 계약을 관리 패널, 테스트, 운영 문서에 반영한다.
This commit is contained in:
@@ -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',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user