feat: 정식 사용자의 오픈 요청과 동의 기반 예약을 영속화한다

This commit is contained in:
2026-10-23 00:18:45 +09:00
parent 856187b315
commit 236f1bc4ed
24 changed files with 2333 additions and 142 deletions
+2
View File
@@ -131,6 +131,8 @@ export const getAdminCapability = (permission: string): AdminCapabilityDefinitio
CAPABILITY_BY_PERMISSION.get(permission);
export const resolveAdminActionCapability = (path: string, rawInput?: unknown): string | undefined => {
if (path.endsWith('.openRequests.decide')) return 'admin.scenarios.reset';
if (path.endsWith('.openRequests.updatePolicy')) return 'admin.profiles.settings';
if (path.endsWith('.users.createLocal')) return 'admin.users.create';
if (path.includes('.users.')) return 'admin.users.manage';
if (path.includes('.system.')) return 'admin.notice.manage';
+48 -64
View File
@@ -7,6 +7,16 @@ import { gatewayProfileCapabilities } from '@sammo-ts/common';
import type { GatewayPrisma } from '@sammo-ts/infra';
import { procedure, router } from './trpc.js';
import { zOpenRequestPolicy } from './openRequests/policy.js';
import { actOnOpenRequest, updateOpenRequestPolicy } from './openRequests/service.js';
import {
isAllowedTurnTerm,
zInstallOptions,
zOperationInstallOptions,
zProfileResetDefaults,
buildResetOtherTextInfo,
SYSTEM_PROFILE_RESET_DEFAULTS,
} from './resetOptions.js';
import {
listScenarioPreviews,
resolveGitBranchCommitSha,
@@ -51,7 +61,6 @@ const zSpecialAccessProfile = z
.min(1)
.max(64)
.regex(/^[a-z0-9_-]+(?::[a-zA-Z0-9._-]+)?$/);
const zJoinMode = z.enum(['full', 'onlyRandom']);
const zServerAction = z.enum([
'RESUME',
'PAUSE',
@@ -441,15 +450,6 @@ const zLocalAccountInput = z.object({
displayName: z.string().min(2).max(40).optional(),
});
const zInstallAutorun = z.object({
limitMinutes: z.number().int().min(0).max(43200),
options: z.array(z.enum(AUTORUN_USER_OPTIONS)),
});
// Ref aligns a 12-month game year inside a 24-hour wall-clock day. That makes
// every positive divisor of 120 valid, including 3 and 8 minutes.
const isAllowedTurnTerm = (value: number): boolean => Number.isInteger(value) && value > 0 && 120 % value === 0;
const zRuntimeSettings = z
.object({
turnTermMinutes: z
@@ -479,60 +479,6 @@ const isUniqueConstraintError = (error: unknown): boolean =>
const isProfileOperationConflictError = (error: unknown): boolean =>
error instanceof GatewayProfileOperationConflictError || isUniqueConstraintError(error);
const zInstallOptions = z.object({
scenarioId: z.number().int().min(0),
turnTermMinutes: z
.number()
.int()
.refine((value) => isAllowedTurnTerm(value), {
message: '턴 시간은 120의 약수여야 합니다.',
}),
sync: z.boolean(),
fiction: z.number().int().min(0).max(1),
extend: z.boolean(),
blockGeneralCreate: z.number().int().min(0).max(2),
npcMode: z.number().int().min(0).max(2),
showImgLevel: z.number().int().min(0).max(3),
tournamentTrig: z.boolean(),
joinMode: zJoinMode,
autorunUser: zInstallAutorun.nullable().optional(),
openAt: z.string().datetime().optional(),
preopenAt: z.string().datetime().optional(),
gitRef: z.string().min(1).max(128).optional(),
});
const zOperationInstallOptions = zInstallOptions.omit({ gitRef: true });
const zProfileResetDefaults = zInstallOptions.omit({
scenarioId: true,
openAt: true,
preopenAt: true,
gitRef: true,
});
const SYSTEM_PROFILE_RESET_DEFAULTS: z.infer<typeof zProfileResetDefaults> = {
turnTermMinutes: 60,
sync: true,
fiction: 1,
extend: true,
blockGeneralCreate: 0,
npcMode: 0,
showImgLevel: 3,
tournamentTrig: true,
joinMode: 'full',
autorunUser: null,
};
const buildResetOtherTextInfo = (install: z.infer<typeof zOperationInstallOptions>): string => {
const settings: string[] = [];
if (!install.sync) settings.push('시간동기화 없음');
if (!install.extend) settings.push('확장 NPC 미포함');
if (install.blockGeneralCreate === 1) settings.push('장수 생성 불가');
if (install.blockGeneralCreate === 2) settings.push('장수명 무작위');
if (install.joinMode === 'onlyRandom') settings.push('랜덤 임관');
if (install.showImgLevel !== SYSTEM_PROFILE_RESET_DEFAULTS.showImgLevel) {
settings.push(['이미지 표시 안함', '전콘 표시', '전콘/병종 표시'][install.showImgLevel] ?? '이미지 표시');
}
if (!install.tournamentTrig) settings.push('토너먼트 수동 시작');
return settings.join(', ');
};
const zSourceMode = z.enum(['BRANCH', 'COMMIT']);
const zResetSourceMode = z.enum(['CURRENT', 'BRANCH', 'COMMIT']);
@@ -631,6 +577,44 @@ const applyMetaPatch = (
};
export const adminRouter = router({
openRequests: router({
permissions: adminProcedure
.input(z.object({ profileName: z.string().min(1).max(64) }))
.query(({ ctx, input }) => ({
canAccept: hasScopedPermission(requireAdminAuth(ctx), ROLE_ADMIN_SCENARIO_RESET, input.profileName),
canManagePolicy: hasScopedPermission(
requireAdminAuth(ctx),
ROLE_ADMIN_PROFILE_SETTINGS,
input.profileName
),
})),
decide: adminProcedure
.input(z.object({ id: z.string().uuid(), action: z.enum(['ACCEPT', 'REJECT']) }).strict())
.mutation(async ({ ctx, input }) => {
const request = await ctx.prisma.gatewayOpenRequest.findUnique({
where: { id: input.id },
select: { profileName: true },
});
if (!request) throw new TRPCError({ code: 'NOT_FOUND', message: '오픈 요청을 찾을 수 없습니다.' });
const auth = requireAdminAuth(ctx);
assertPermission(auth, ROLE_ADMIN_SCENARIO_RESET, request.profileName);
return actOnOpenRequest(ctx, auth.user, input.id, input.action);
}),
updatePolicy: adminProcedure
.input(
z
.object({
profileName: z.string().min(1).max(64),
policy: zOpenRequestPolicy,
reason: z.string().trim().min(3).max(200),
})
.strict()
)
.mutation(async ({ ctx, input }) => {
assertPermission(requireAdminAuth(ctx), ROLE_ADMIN_PROFILE_SETTINGS, input.profileName);
return updateOpenRequestPolicy(ctx, input.profileName, input.policy, requireAdminAuth(ctx).user.id);
}),
}),
capabilities: router({
list: adminProcedure.query(({ ctx }) => {
const adminAuth = requireAdminAuth(ctx);
@@ -0,0 +1,38 @@
import { z } from 'zod';
export const zOpenRequestPolicy = z
.object({
enabled: z.boolean().default(true),
lifetimeDays: z.number().int().min(1).max(365).default(7),
buildLeadMinutes: z.number().int().min(1).max(10080).default(10),
// null means opener approval only, including CHE by default.
endorsementThreshold: z.number().int().min(1).max(10000).nullable().default(null),
})
.strict();
export interface OpenRequestPolicy {
enabled: boolean;
lifetimeDays: number;
buildLeadMinutes: number;
endorsementThreshold: number | null;
}
export const readOpenRequestPolicy = (meta: Record<string, unknown>): OpenRequestPolicy =>
zOpenRequestPolicy.parse(meta.openRequestPolicy ?? {});
export const canRequestOpening = (status: string): boolean => ['COMPLETED', 'STOPPED', 'CANCELLED'].includes(status);
export const openingSeasonKey = (profile: { currentScenario: string | null; openAt?: string | Date | null }): string =>
JSON.stringify([profile.currentScenario, profile.openAt ? new Date(profile.openAt).toISOString() : null]);
export const canExecuteOpeningReservation = (
profile: { status: string; currentScenario: string | null; openAt?: string | Date | null },
seasonKey: unknown,
openAt: unknown,
now: Date
): boolean =>
canRequestOpening(profile.status) &&
openingSeasonKey(profile) === seasonKey &&
typeof openAt === 'string' &&
Number.isFinite(Date.parse(openAt)) &&
Date.parse(openAt) > now.getTime();
@@ -0,0 +1,48 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { procedure, router } from '../trpc.js';
import { isGatewaySessionCurrent } from '../auth/sessionValidity.js';
import type { GatewayApiContext } from '../context.js';
import { actOnOpenRequest, createOpenRequest, listOpenRequests, zOpeningInstall } from './service.js';
const memberProcedure = procedure.use(async ({ ctx, next }) => {
const header = ctx.requestHeaders['x-session-token'];
const token = Array.isArray(header) ? header[0] : header;
const session = token ? await ctx.sessions.getSession(token) : null;
const user = session ? await ctx.users.findById(session.userId) : null;
if (!session || !user || !isGatewaySessionCurrent(session, user)) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: '로그인이 필요합니다.' });
}
return next({ ctx: { ...ctx, openingUser: user } });
});
const visibleProfile = async (ctx: GatewayApiContext, userId: string, profileName: string): Promise<void> => {
const profiles = await ctx.profileStatus.listLobbyProfiles({ userId });
if (!profiles.some((profile) => profile.profileName === profileName))
throw new TRPCError({ code: 'NOT_FOUND', message: '서버를 찾을 수 없습니다.' });
};
export const openRequestsRouter = router({
list: memberProcedure
.input(z.object({ profileName: z.string().min(1).max(64) }).strict())
.query(async ({ ctx, input }) => {
await visibleProfile(ctx, ctx.openingUser.id, input.profileName);
return listOpenRequests(ctx, ctx.openingUser, input.profileName);
}),
create: memberProcedure
.input(z.object({ profileName: z.string().min(1).max(64), install: zOpeningInstall }).strict())
.mutation(async ({ ctx, input }) => {
await visibleProfile(ctx, ctx.openingUser.id, input.profileName);
return createOpenRequest(ctx, ctx.openingUser, input.profileName, input.install);
}),
act: memberProcedure
.input(z.object({ id: z.string().uuid(), action: z.enum(['ENDORSE', 'WITHDRAW']) }).strict())
.mutation(async ({ ctx, input }) => {
const request = await ctx.prisma.gatewayOpenRequest.findUnique({
where: { id: input.id },
select: { profileName: true },
});
if (!request) throw new TRPCError({ code: 'NOT_FOUND', message: '요청을 찾을 수 없습니다.' });
await visibleProfile(ctx, ctx.openingUser.id, request.profileName);
return actOnOpenRequest(ctx, ctx.openingUser, input.id, input.action);
}),
});
+392
View File
@@ -0,0 +1,392 @@
import { isDeepStrictEqual } from 'node:util';
import { TRPCError } from '@trpc/server';
import { isGameAccessBlocked } from '@sammo-ts/common/auth/sanctions';
import type { GatewayPrisma, GatewayPrismaClient } from '@sammo-ts/infra';
import type { GatewayApiContext } from '../context.js';
import type { UserRecord } from '../auth/userRepository.js';
import { resolveLocalAccountProfilePolicy } from '../auth/localAccountPolicy.js';
import { CONTROL_PLANE_OPERATION_CLAIM_LOCK } from '../orchestrator/profileRepository.js';
import { readProfileReleaseSource } from '../orchestrator/profileReleaseSource.js';
import { buildResetOtherTextInfo, zOperationInstallOptions } from '../resetOptions.js';
import {
listScenarioPreviews,
resolveGitBranchCommitSha,
resolveGitCommitSha,
supportsProfileReleaseBuild,
} from '../scenario/scenarioCatalog.js';
import { canRequestOpening, openingSeasonKey, readOpenRequestPolicy, type OpenRequestPolicy } from './policy.js';
import { z } from 'zod';
export const zOpeningInstall = zOperationInstallOptions.required({ preopenAt: true, openAt: true }).strict();
export type OpeningInstall = z.infer<typeof zOpeningInstall>;
export interface OpenRequestResult {
id: string;
status: string;
}
export interface OpenRequestList {
policy: OpenRequestPolicy;
canRequest: boolean;
requests: Array<{
id: string;
status: string;
requesterName: string;
isMine: boolean;
announcement: { scenarioTitle: string; defaultStatTotal: number };
install: OpeningInstall;
expiresAt: string;
createdAt: string;
endorsementCount: number;
endorsed: boolean;
canEndorse: boolean;
operationStatus: string | null;
}>;
}
type Profile = NonNullable<Awaited<ReturnType<GatewayPrismaClient['gatewayProfile']['findUnique']>>>;
type Request = NonNullable<Awaited<ReturnType<GatewayPrismaClient['gatewayOpenRequest']['findUnique']>>>;
const fail = (message: string, code: 'BAD_REQUEST' | 'CONFLICT' | 'FORBIDDEN' | 'NOT_FOUND' = 'BAD_REQUEST'): never => {
throw new TRPCError({ code, message });
};
// Authentication grace is deliberately not full membership. Special grants must apply to this profile.
export const isOpeningMember = async (
ctx: GatewayApiContext,
user: UserRecord,
profile: Profile,
now: Date
): Promise<boolean> => {
if (user.deleteAfter || isGameAccessBlocked(user.sanctions, [profile.profile, profile.profileName], now))
return false;
if (user.oauthType === 'KAKAO' && user.oauthId?.trim() && user.kakaoVerifiedAt) return true;
const policy = resolveLocalAccountProfilePolicy({
profile: profile.profile,
profileName: profile.profileName,
profileMeta: profile.meta as Record<string, unknown>,
defaultGraceDays: ctx.localAccountGraceDays,
user,
specialAccessGrants: await ctx.users.listSpecialAccessGrants(user.id),
now,
});
return policy.kakaoVerified || policy.specialAccess !== null;
};
const assertMember = async (ctx: GatewayApiContext, user: UserRecord, profile: Profile, now: Date): Promise<void> => {
const currentUser = await ctx.users.findById(user.id);
if (!currentUser || !(await isOpeningMember(ctx, currentUser, profile, now)))
fail('카카오 인증 또는 이 서버의 정식 접근 허용이 필요합니다.', 'FORBIDDEN');
};
const buildAt = (preopenAt: Date, lead: number): Date => new Date(preopenAt.getTime() - lead * 60_000);
const activeStatus = (request: Request, profile: Profile, now: Date): string => {
if (request.status !== 'PENDING') return request.status;
if (!canRequestOpening(profile.status) || request.seasonKey !== openingSeasonKey(profile)) return 'SUPERSEDED';
const policy = readOpenRequestPolicy(profile.meta as Record<string, unknown>);
if (request.expiresAt <= now || buildAt(request.preopenAt, policy.buildLeadMinutes) <= now) return 'EXPIRED';
return request.status;
};
export const listOpenRequests = async (
ctx: GatewayApiContext,
user: UserRecord,
profileName: string
): Promise<OpenRequestList> => {
const profile = await ctx.prisma.gatewayProfile.findUnique({ where: { profileName } });
if (!profile) return fail('서버를 찾을 수 없습니다.', 'NOT_FOUND');
const now = new Date();
const policy = readOpenRequestPolicy(profile.meta as Record<string, unknown>);
const member = await isOpeningMember(ctx, user, profile, now);
const requests = await ctx.prisma.gatewayOpenRequest.findMany({
where: {
profileName,
OR: [
{ status: 'PENDING', expiresAt: { gt: now } },
{ status: 'RESERVED', openAt: { gt: now } },
],
},
orderBy: [{ createdAt: 'desc' }, { id: 'asc' }],
take: 100,
include: {
requester: { select: { displayName: true } },
endorsements: { select: { userId: true } },
operation: { select: { status: true } },
},
});
const voters = await ctx.users.findByIds([
...new Set(requests.flatMap((request) => request.endorsements.map((vote) => vote.userId))),
]);
const qualifiedVoters = new Set<string>();
for (const voter of voters) {
if (await isOpeningMember(ctx, voter, profile, now)) qualifiedVoters.add(voter.id);
}
const activeOperation = await ctx.prisma.gatewayOperation.findFirst({
where: { profileName, status: { in: ['QUEUED', 'RUNNING'] } },
select: { id: true },
});
return {
policy,
canRequest: member && policy.enabled && canRequestOpening(profile.status) && !activeOperation,
requests: requests
.filter((request) => ['PENDING', 'RESERVED'].includes(activeStatus(request, profile, now)))
.map((request) => ({
id: request.id,
status: request.status,
requesterName: request.requester.displayName,
isMine: request.requestedBy === user.id,
announcement: z
.object({ scenarioTitle: z.string(), defaultStatTotal: z.number() })
.parse(request.announcement),
install: zOpeningInstall.parse(request.install),
expiresAt: request.expiresAt.toISOString(),
createdAt: request.createdAt.toISOString(),
endorsementCount: request.endorsements.filter((vote) => qualifiedVoters.has(vote.userId)).length,
endorsed: request.endorsements.some((vote) => vote.userId === user.id),
canEndorse:
member &&
policy.enabled &&
policy.endorsementThreshold !== null &&
request.requestedBy !== user.id &&
request.status === 'PENDING',
operationStatus: request.operation?.status ?? null,
})),
};
};
const inProfileTransaction = async <T>(
ctx: GatewayApiContext,
profileName: string,
run: (tx: GatewayPrisma.TransactionClient, profile: Profile, now: Date) => Promise<T>
): Promise<T> =>
ctx.prisma.$transaction(
async (tx) => {
// Same ordering as RESET admission/claim. This serializes consensus with operator operations.
await tx.$queryRaw`SELECT pg_advisory_xact_lock(hashtextextended(${CONTROL_PLANE_OPERATION_CLAIM_LOCK}, 0))::text`;
await tx.$queryRaw`SELECT "profile_name" FROM "gateway_profile" WHERE "profile_name" = ${profileName} FOR UPDATE`;
const profile = await tx.gatewayProfile.findUnique({ where: { profileName } });
if (!profile) return fail('서버를 찾을 수 없습니다.', 'NOT_FOUND');
const [{ now }] = await tx.$queryRaw<Array<{ now: Date }>>`SELECT clock_timestamp() AS now`;
return run(tx, profile, now!);
},
{ timeout: 60_000 }
);
export const createOpenRequest = async (
ctx: GatewayApiContext,
user: UserRecord,
profileName: string,
install: OpeningInstall
): Promise<OpenRequestResult> =>
inProfileTransaction(ctx, profileName, async (tx, profile, now) => {
await assertMember(ctx, user, profile, now);
const policy = readOpenRequestPolicy(profile.meta as Record<string, unknown>);
if (!policy.enabled || !canRequestOpening(profile.status))
return fail('통일되었거나 닫힌 서버에만 오픈 요청을 할 수 있습니다.', 'CONFLICT');
const preopenAt = new Date(install.preopenAt),
openAt = new Date(install.openAt);
const deadline = buildAt(preopenAt, policy.buildLeadMinutes);
if (deadline <= now || preopenAt >= openAt)
return fail('가오픈은 빌드 선행 시간 이후, 정식 오픈은 가오픈 이후여야 합니다.');
if (install.autorunUser && install.autorunUser.limitMinutes > 0 !== install.autorunUser.options.length > 0)
return fail('자율행동 시간과 항목을 함께 지정해 주세요.');
if (await tx.gatewayOperation.findFirst({ where: { profileName, status: { in: ['QUEUED', 'RUNNING'] } } }))
return fail('진행 중이거나 예약된 서버 작업이 있습니다.', 'CONFLICT');
const old = await tx.gatewayOpenRequest.findMany({ where: { profileName, status: 'PENDING' } });
for (const request of old) {
const status = activeStatus(request, profile, now);
if (status !== 'PENDING')
await tx.gatewayOpenRequest.update({ where: { id: request.id }, data: { status } });
else if (request.requestedBy === user.id) {
if (isDeepStrictEqual(request.install, install)) return { id: request.id, status: request.status };
return fail('기존 요청을 철회한 뒤 새 요청을 등록해 주세요.', 'CONFLICT');
}
}
if (old.filter((request) => activeStatus(request, profile, now) === 'PENDING').length >= 100)
return fail('이 서버의 대기 요청이 너무 많습니다.', 'CONFLICT');
if (!profile.buildCommitSha) return fail('서버의 시나리오 버전이 준비되지 않았습니다.');
const scenario = (await listScenarioPreviews({ gitRef: profile.buildCommitSha })).find(
(entry) => entry.id === install.scenarioId
);
if (!scenario) return fail('현재 서버에 없는 시나리오입니다.');
const expiresAt = new Date(Math.min(now.getTime() + policy.lifetimeDays * 86_400_000, deadline.getTime()));
const request = await tx.gatewayOpenRequest.create({
data: {
profileName,
requestedBy: user.id,
install,
preopenAt,
openAt,
expiresAt,
seasonKey: openingSeasonKey(profile),
announcement: { scenarioTitle: scenario.title, defaultStatTotal: scenario.defaultStatTotal },
},
});
return { id: request.id, status: request.status };
});
// Called only while holding the shared admission lock. All mutations, including the winning
// endorsement, sibling supersession, operation, and log either commit together or roll back.
const reserve = async (
ctx: GatewayApiContext,
tx: GatewayPrisma.TransactionClient,
profile: Profile,
request: Request,
actorId: string,
now: Date
) => {
const requester = await ctx.users.findById(request.requestedBy);
if (!requester || !(await isOpeningMember(ctx, requester, profile, now)))
return fail('요청자의 정식 참여 자격을 확인해 주세요.', 'CONFLICT');
if (
await tx.gatewayOperation.findFirst({
where: { profileName: profile.profileName, status: { in: ['QUEUED', 'RUNNING'] } },
})
)
return fail('진행 중이거나 예약된 서버 작업이 있습니다.', 'CONFLICT');
const policy = readOpenRequestPolicy(profile.meta as Record<string, unknown>);
const install = zOpeningInstall.parse(request.install);
const source = readProfileReleaseSource({
meta: profile.meta as GatewayPrisma.JsonObject,
buildCommitSha: profile.buildCommitSha ?? undefined,
});
if (!source) return fail('서버 패치 정책이 준비되지 않았습니다.');
const commit =
source.mode === 'BRANCH' ? await resolveGitBranchCommitSha(source.ref) : await resolveGitCommitSha(source.ref);
const scenario = (await listScenarioPreviews({ gitRef: commit })).find((entry) => entry.id === install.scenarioId);
if (!scenario || !(await supportsProfileReleaseBuild(commit)))
return fail('패치 정책의 버전에서 시나리오 또는 배포 빌드를 사용할 수 없습니다.');
const scheduledAt = buildAt(request.preopenAt, policy.buildLeadMinutes);
const [{ now: admissionNow }] = await tx.$queryRaw<Array<{ now: Date }>>`SELECT clock_timestamp() AS now`;
if (scheduledAt <= admissionNow! || request.expiresAt <= admissionNow!)
return fail('빌드 시작 시각이 지나 요청을 수락할 수 없습니다.', 'CONFLICT');
const operation = await tx.gatewayOperation.create({
data: {
profileName: profile.profileName,
type: 'RESET',
sourceMode: source.mode,
sourceRef: source.mode === 'COMMIT' ? commit : source.ref,
requestedBy: actorId,
scheduledAt,
payload: {
install,
requestedSource: 'CURRENT',
releaseSource: { ...source },
openRequestId: request.id,
openRequestSeasonKey: request.seasonKey,
publicAnnouncement: {
enabled: true,
scenarioId: scenario.id,
scenarioTitle: scenario.title,
defaultStatTotal: scenario.defaultStatTotal,
scheduledAt: scheduledAt.toISOString(),
preopenAt: install.preopenAt,
openAt: install.openAt,
turnTermMinutes: install.turnTermMinutes,
fictionMode: install.fiction === 1 ? '가상' : '사실',
npcMode: install.npcMode,
otherTextInfo: buildResetOtherTextInfo(install),
autorunUser: install.autorunUser ?? null,
},
},
reason: '사용자 오픈 요청 수락',
},
});
await tx.gatewayOperationLog.create({
data: {
operationId: operation.id,
level: 'INFO',
phase: 'queue',
message: '오픈 요청이 서버 패치 정책을 따르는 초기화 예약으로 전환되었습니다.',
},
});
await tx.gatewayOpenRequest.update({
where: { id: request.id },
data: { status: 'RESERVED', operationId: operation.id, decidedBy: actorId, decidedAt: now },
});
await tx.gatewayOpenRequest.updateMany({
where: { profileName: profile.profileName, status: 'PENDING', id: { not: request.id } },
data: { status: 'SUPERSEDED', decidedAt: now },
});
return { id: request.id, status: 'RESERVED' };
};
export const actOnOpenRequest = async (
ctx: GatewayApiContext,
user: UserRecord,
id: string,
action: 'ENDORSE' | 'WITHDRAW' | 'ACCEPT' | 'REJECT'
): Promise<OpenRequestResult> => {
const initial = await ctx.prisma.gatewayOpenRequest.findUnique({ where: { id } });
if (!initial) return fail('오픈 요청을 찾을 수 없습니다.', 'NOT_FOUND');
return inProfileTransaction(ctx, initial.profileName, async (tx, profile, now) => {
const request = await tx.gatewayOpenRequest.findUniqueOrThrow({ where: { id } });
if (action === 'ENDORSE') await assertMember(ctx, user, profile, now);
if (action === 'WITHDRAW' && request.requestedBy !== user.id)
return fail('자신의 요청만 철회할 수 있습니다.', 'FORBIDDEN');
if (request.status === 'RESERVED' && ['ACCEPT', 'ENDORSE'].includes(action))
return { id, status: request.status };
if (action === 'WITHDRAW' && request.status === 'WITHDRAWN') return { id, status: request.status };
if (activeStatus(request, profile, now) !== 'PENDING')
return fail('만료되었거나 이미 처리된 요청입니다.', 'CONFLICT');
if (action === 'WITHDRAW' || action === 'REJECT') {
const status = action === 'WITHDRAW' ? 'WITHDRAWN' : 'REJECTED';
await tx.gatewayOpenRequest.update({ where: { id }, data: { status, decidedBy: user.id, decidedAt: now } });
return { id, status };
}
const policy = readOpenRequestPolicy(profile.meta as Record<string, unknown>);
if (!policy.enabled) return fail('이 서버는 오픈 요청을 받지 않습니다.', 'CONFLICT');
if (action === 'ACCEPT') return reserve(ctx, tx, profile, request, user.id, now);
if (request.requestedBy === user.id) return fail('요청자는 자신의 요청에 동의할 수 없습니다.');
if (policy.endorsementThreshold === null) return fail('이 서버는 오프너 수락으로만 예약할 수 있습니다.');
await tx.gatewayOpenEndorsement.upsert({
where: { requestId_userId: { requestId: id, userId: user.id } },
create: { requestId: id, userId: user.id },
update: {},
});
const votes = await tx.gatewayOpenEndorsement.findMany({ where: { requestId: id } });
if (votes.length >= policy.endorsementThreshold) {
// Revoked, banned, deleted or unverified voters no longer contribute to automatic approval.
let valid = 0;
for (const voter of await ctx.users.findByIds(votes.map((vote) => vote.userId))) {
if (await isOpeningMember(ctx, voter, profile, now)) valid++;
}
if (valid >= policy.endorsementThreshold) return reserve(ctx, tx, profile, request, user.id, now);
}
return { id, status: 'PENDING' };
});
};
// Lowering the threshold also evaluates accumulated agreements immediately. Older eligible
// proposals win deterministically; the same admission lock prevents a parallel endorsement winner.
export const updateOpenRequestPolicy = async (
ctx: GatewayApiContext,
profileName: string,
policy: OpenRequestPolicy,
actorId: string
): Promise<OpenRequestPolicy> =>
inProfileTransaction(ctx, profileName, async (tx, profile, now) => {
const updated = await tx.gatewayProfile.update({
where: { profileName },
data: { meta: { ...(profile.meta as GatewayPrisma.JsonObject), openRequestPolicy: { ...policy } } },
});
if (policy.enabled && policy.endorsementThreshold !== null && canRequestOpening(updated.status)) {
const pending = await tx.gatewayOpenRequest.findMany({
where: { profileName, status: 'PENDING' },
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
include: { endorsements: { select: { userId: true } } },
});
for (const request of pending) {
if (
activeStatus(request, updated, now) !== 'PENDING' ||
request.endorsements.length < policy.endorsementThreshold
)
continue;
let valid = 0;
for (const voter of await ctx.users.findByIds(request.endorsements.map((vote) => vote.userId))) {
if (await isOpeningMember(ctx, voter, updated, now)) valid++;
}
if (valid >= policy.endorsementThreshold) {
await reserve(ctx, tx, updated, request, actorId, now);
break;
}
}
}
return policy;
});
@@ -1,3 +1,4 @@
import { canExecuteOpeningReservation } from '../openRequests/policy.js';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
@@ -1854,6 +1855,22 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
return;
}
if (operation.type === 'RESET' && typeof operation.payload.openRequestId === 'string') {
const install = isRecord(operation.payload.install) ? operation.payload.install : {};
if (
!canExecuteOpeningReservation(
profile,
operation.payload.openRequestSeasonKey,
install.openAt,
this.now()
)
) {
throw new Error(
'오픈 요청 이후 서버가 다시 열렸거나 기수가 변경되었거나 오픈 시각이 지나 예약을 실행할 수 없습니다.'
);
}
}
if (!operation.sourceMode || !operation.sourceRef) {
throw new Error('Operation source mode and ref are required.');
}
@@ -1958,7 +1975,10 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
commitSha,
assertLease,
operation.id,
readOperationReleaseSource(operation)
readOperationReleaseSource(operation),
typeof payload.openRequestId === 'string'
? { seasonKey: payload.openRequestSeasonKey, openAt: install.openAt }
: undefined
);
if (result.status === 'REQUESTED') {
const retryAt = new Date(this.now().getTime() + this.adminActionIntervalMs).toISOString();
@@ -2413,7 +2433,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
commitShaOverride?: string,
assertLease?: () => Promise<void>,
operationId?: string,
releaseSource?: ProfileReleaseSource
releaseSource?: ProfileReleaseSource,
communityReservation?: { seasonKey: unknown; openAt: unknown }
): Promise<GatewayAdminActionResult> {
const appendLog = async (
phase: string,
@@ -2525,6 +2546,24 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
);
return { status: 'FAILED', detail: 'selected workspace build failed' };
}
const assertCommunityReservation = async (): Promise<void> => {
if (!communityReservation) return;
const current = await this.repository.getProfile(profile.profileName);
if (
!current ||
!canExecuteOpeningReservation(
current,
communityReservation.seasonKey,
communityReservation.openAt,
this.now()
)
) {
throw new Error(
'오픈 요청 이후 서버 상태·기수가 변경되었거나 오픈 시각이 지나 초기화를 중단했습니다.'
);
}
};
await assertCommunityReservation();
await appendLog('seed', '선택 버전의 profile seed CLI를 확인합니다.');
await this.assertProfileSeedCli(workspace.root);
// A newly provisioned profile schema has no world_state row (or table) yet.
@@ -2568,6 +2607,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
const firstGameIdx = resolveProfileFirstGameIdx(profileMeta);
const baseSeason = readMetaNumber(normalizeMeta(seedInfo.meta), 'season');
const season = nextSeasonIdx ?? baseSeason ?? 1;
await assertCommunityReservation();
await updateClaimedProfile({ status: 'STOPPED' }, () =>
this.repository.updateStatus(profile.profileName, 'STOPPED')
);
@@ -13,7 +13,9 @@ export interface ProfileReleaseSource {
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === 'object' && !Array.isArray(value));
export const readProfileReleaseSource = (profile: GatewayProfileRecord): ProfileReleaseSource | null => {
export const readProfileReleaseSource = (
profile: Pick<GatewayProfileRecord, 'meta' | 'buildCommitSha'>
): ProfileReleaseSource | null => {
const stored = isRecord(profile.meta) && isRecord(profile.meta.releaseSource) ? profile.meta.releaseSource : null;
const mode = stored?.mode;
const ref = typeof stored?.ref === 'string' ? stored.ref.trim() : '';
+68
View File
@@ -0,0 +1,68 @@
import { z } from 'zod';
const AUTORUN_USER_OPTIONS = ['develop', 'warp', 'recruit', 'recruit_high', 'train', 'battle', 'chief'] as const;
const zJoinMode = z.enum(['full', 'onlyRandom']);
const zInstallAutorun = z.object({
limitMinutes: z.number().int().min(0).max(43200),
options: z.array(z.enum(AUTORUN_USER_OPTIONS)),
});
// Ref aligns a 12-month game year inside a 24-hour wall-clock day. That makes
// every positive divisor of 120 valid, including 3 and 8 minutes.
export const isAllowedTurnTerm = (value: number): boolean => Number.isInteger(value) && value > 0 && 120 % value === 0;
export const zInstallOptions = z.object({
scenarioId: z.number().int().min(0),
turnTermMinutes: z
.number()
.int()
.refine((value) => isAllowedTurnTerm(value), {
message: '턴 시간은 120의 약수여야 합니다.',
}),
sync: z.boolean(),
fiction: z.number().int().min(0).max(1),
extend: z.boolean(),
blockGeneralCreate: z.number().int().min(0).max(2),
npcMode: z.number().int().min(0).max(2),
showImgLevel: z.number().int().min(0).max(3),
tournamentTrig: z.boolean(),
joinMode: zJoinMode,
autorunUser: zInstallAutorun.nullable().optional(),
openAt: z.string().datetime().optional(),
preopenAt: z.string().datetime().optional(),
gitRef: z.string().min(1).max(128).optional(),
});
export const zOperationInstallOptions = zInstallOptions.omit({ gitRef: true });
export const zProfileResetDefaults = zInstallOptions.omit({
scenarioId: true,
openAt: true,
preopenAt: true,
gitRef: true,
});
export const SYSTEM_PROFILE_RESET_DEFAULTS: z.infer<typeof zProfileResetDefaults> = {
turnTermMinutes: 60,
sync: true,
fiction: 1,
extend: true,
blockGeneralCreate: 0,
npcMode: 0,
showImgLevel: 3,
tournamentTrig: true,
joinMode: 'full',
autorunUser: null,
};
export const buildResetOtherTextInfo = (install: z.infer<typeof zOperationInstallOptions>): string => {
const settings: string[] = [];
if (!install.sync) settings.push('시간동기화 없음');
if (!install.extend) settings.push('확장 NPC 미포함');
if (install.blockGeneralCreate === 1) settings.push('장수 생성 불가');
if (install.blockGeneralCreate === 2) settings.push('장수명 무작위');
if (install.joinMode === 'onlyRandom') settings.push('랜덤 임관');
if (install.showImgLevel !== SYSTEM_PROFILE_RESET_DEFAULTS.showImgLevel) {
settings.push(['이미지 표시 안함', '전콘 표시', '전콘/병종 표시'][install.showImgLevel] ?? '이미지 표시');
}
if (!install.tournamentTrig) settings.push('토너먼트 수동 시작');
return settings.join(', ');
};
+2
View File
@@ -11,6 +11,7 @@ import { procedure, router } from './trpc.js';
import { toPublicUser } from './auth/userRepository.js';
import type { UserOAuthInfo, UserRecord } from './auth/userRepository.js';
import { adminRouter } from './adminRouter.js';
import { openRequestsRouter } from './openRequests/router.js';
import { accountRouter } from './account/router.js';
import {
hasActiveSpecialAccountGrant,
@@ -130,6 +131,7 @@ const finishKakaoLoginOrRequestPasswordSetup = async <T extends 'login' | 'verif
};
export const appRouter = router({
openRequests: openRequestsRouter,
navigation: router({
get: procedure.query(({ ctx }) => ctx.navigationConfig.get()),
}),
@@ -0,0 +1,569 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import fastifyStatic from '@fastify/static';
import { randomUUID } from 'node:crypto';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import fastify, { type FastifyRequest } from 'fastify';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import { trpcJsonBodyHttpServerOptions } from '@sammo-ts/common';
import { createGatewayPostgresConnector } from '@sammo-ts/infra';
import { createPostgresUserRepository } from '../src/auth/postgresUserRepository.js';
import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js';
import { createPasswordEnvelopeService } from '../src/auth/passwordEnvelope.js';
import { createGatewayApiContext } from '../src/context.js';
import { createGatewayProfileRepository } from '../src/orchestrator/profileRepository.js';
import { InMemoryProfileStatusService } from '../src/lobby/profileStatusService.js';
import { appRouter } from '../src/router.js';
import { createOpenRequest, actOnOpenRequest, listOpenRequests } from '../src/openRequests/service.js';
import { readOpenRequestPolicy } from '../src/openRequests/policy.js';
import * as catalog from '../src/scenario/scenarioCatalog.js';
vi.mock('../src/scenario/scenarioCatalog.js', () => ({
listScenarioPreviews: vi.fn(async () => [
{
id: 2,
title: '기본 시나리오',
defaultStatTotal: 165,
year: 184,
fiction: 1,
npcCount: 100,
npcExCount: 0,
npcNeutralCount: 0,
nations: [],
},
]),
resolveGitCommitSha: vi.fn(async () => 'a'.repeat(40)),
resolveGitBranchCommitSha: vi.fn(async () => 'b'.repeat(40)),
supportsProfileReleaseBuild: vi.fn(async () => true),
}));
const repositoryRoot = fileURLToPath(new URL('../../../', import.meta.url));
const databaseUrl = process.env.OPEN_REQUEST_DATABASE_URL;
const suite = describe.runIf(Boolean(databaseUrl));
const profileName = 'opening:default';
const install = () => ({
scenarioId: 2,
turnTermMinutes: 3,
sync: true,
fiction: 1,
extend: true,
blockGeneralCreate: 0,
npcMode: 0,
showImgLevel: 3,
tournamentTrig: true,
joinMode: 'full' as const,
autorunUser: null,
preopenAt: new Date(Date.now() + 86_400_000).toISOString(),
openAt: new Date(Date.now() + 90_000_000).toISOString(),
});
suite('durable opening requests (isolated PostgreSQL and HTTP)', () => {
const connector = createGatewayPostgresConnector({ url: databaseUrl ?? '' });
const prisma = connector.prisma;
const users = createPostgresUserRepository(prisma);
const profiles = createGatewayProfileRepository(prisma);
const sessions = new InMemoryGatewaySessionService({ sessionTtlSeconds: 3600, gameSessionTtlSeconds: 600 });
const ids = [randomUUID(), randomUUID(), randomUUID(), randomUUID(), randomUUID()];
const passwordEnvelope = createPasswordEnvelopeService();
const profileStatus = new InMemoryProfileStatusService([
{
profileName,
profile: 'opening',
instanceKey: 'default',
currentScenario: null,
scenario: 'default',
status: 'STOPPED',
apiPort: 15991,
korName: '테스트',
color: '#fff',
runtime: {
apiRunning: false,
daemonRunning: false,
auctionRunning: false,
battleSimRunning: false,
tournamentRunning: false,
},
lifecycle: {
runtimeExpected: false,
userAccessible: false,
turnsRunning: false,
operatorResumable: true,
dataInitialized: false,
},
},
]);
const context = (headers: Record<string, string | string[] | undefined> = {}) =>
createGatewayApiContext({
prisma,
users,
profiles,
sessions,
passwordEnvelope,
profileStatus,
requestHeaders: headers,
flushPublisher: { publishUserFlush: async () => {} },
gameTokenSecret: 'fixture-only',
gameSessionTtlSeconds: 600,
publicBaseUrl: 'http://127.0.0.1',
localAccountGraceDays: 7,
adminLocalAccountEnabled: false,
localRegistrationEnabled: false,
kakaoClient: {} as never,
oauthSessions: {} as never,
orchestrator: {} as never,
});
const user = async (index: number) => (await users.findById(ids[index]!))!;
const caller = async (index: number) => {
const session = await sessions.createSession(await user(index));
return appRouter.createCaller(context({ 'x-session-token': session.sessionToken }));
};
const app = fastify();
beforeAll(async () => {
// This database is created exclusively for this suite by the verification runner.
if (
!databaseUrl?.includes('open_request') &&
new URL(databaseUrl!).searchParams.get('schema') !== process.env.GATEWAY_RUNTIME_INTEGRATION_SCHEMA
)
throw new Error('Use an isolated open_request database/schema.');
await connector.connect();
await app.register(fastifyTRPCPlugin, {
prefix: '/gateway/api/trpc',
trpcOptions: {
router: appRouter,
...trpcJsonBodyHttpServerOptions,
createContext: ({ req }: { req: FastifyRequest }) => context(req.headers),
},
});
if (process.env.OPEN_REQUEST_BROWSER === '1') {
await app.register(fastifyStatic, {
root: path.join(repositoryRoot, 'app/gateway-frontend/dist'),
prefix: '/gateway/',
});
app.get('/gateway/api/navigation', async () =>
JSON.parse(await fs.readFile(path.join(repositoryRoot, 'resources/navigation.json'), 'utf8'))
);
app.setNotFoundHandler((request, reply) =>
request.url.startsWith('/gateway/') && !request.url.includes('/api/')
? reply.sendFile('index.html')
: reply.code(404).send()
);
}
await app.ready();
});
beforeEach(async () => {
await prisma.gatewayOpenRequest.deleteMany({ where: { profileName } });
await prisma.gatewayOperation.deleteMany({ where: { profileName } });
await prisma.specialAccountAccessGrant.deleteMany({ where: { userId: { in: ids } } });
await prisma.appUser.deleteMany({ where: { id: { in: ids } } });
for (let index = 0; index < ids.length; index++) {
await prisma.appUser.create({
data: {
id: ids[index],
loginId: `opening-${ids[0]}-${index}`,
displayName: `사용자${index}-${ids[0]!.slice(0, 6)}`,
passwordHash: 'fixture',
passwordSalt: 'fixture',
oauthType: index === 4 ? 'NONE' : 'KAKAO',
oauthId: index === 4 ? null : `opening-${ids[0]}-${index}`,
kakaoVerifiedAt: index === 4 ? null : new Date(),
createdAt: new Date(2020 + index, 0, 1),
roles: index === 3 ? [`admin.scenarios.reset:${profileName}`] : [],
},
});
}
await profiles.upsertProfile({
profile: 'opening',
instanceKey: 'default',
apiPort: 15991,
status: 'STOPPED',
buildCommitSha: 'a'.repeat(40),
meta: {},
});
await prisma.gatewayProfile.update({
where: { profileName },
data: { openAt: null, preopenAt: null, currentScenario: null, meta: {} },
});
vi.clearAllMocks();
vi.mocked(catalog.supportsProfileReleaseBuild).mockResolvedValue(true);
});
afterAll(async () => {
await app.close();
await connector.disconnect();
});
const create = async (index = 1) => createOpenRequest(context(), await user(index), profileName, install());
const policy = async (threshold: number | null, extra: Record<string, number> = {}) =>
prisma.gatewayProfile.update({
where: { profileName },
data: { meta: { openRequestPolicy: { endorsementThreshold: threshold, ...extra } } },
});
it('persists immutable settings and expires before build; repeated submission is idempotent', async () => {
const input = install();
const first = await createOpenRequest(context(), await user(1), profileName, input);
const replay = await createOpenRequest(context(), await user(1), profileName, input);
expect(replay.id).toBe(first.id);
const row = await prisma.gatewayOpenRequest.findUniqueOrThrow({ where: { id: first.id } });
expect(row.install).toEqual(input);
expect(row.expiresAt.getTime()).toBe(new Date(input.preopenAt).getTime() - 600_000);
expect(await prisma.gatewayOperation.count({ where: { profileName } })).toBe(0);
expect((await listOpenRequests(context(), await user(2), profileName)).requests).toHaveLength(1);
expect(JSON.stringify(await listOpenRequests(context(), await user(2), profileName))).not.toContain('password');
});
it('uses the configured lifetime for distant proposals', async () => {
await policy(null, { lifetimeDays: 2 });
const input = install();
input.preopenAt = new Date(Date.now() + 10 * 86_400_000).toISOString();
input.openAt = new Date(Date.now() + 11 * 86_400_000).toISOString();
const r = await createOpenRequest(context(), await user(1), profileName, input);
const row = await prisma.gatewayOpenRequest.findUniqueOrThrow({ where: { id: r.id } });
expect(row.expiresAt.getTime() - row.createdAt.getTime()).toBeCloseTo(2 * 86_400_000, -3);
});
it.each(['RUNNING', 'PREOPEN', 'PAUSED', 'RESERVED', 'DISABLED'] as const)(
'rejects requests in %s',
async (status) => {
await prisma.gatewayProfile.update({ where: { profileName }, data: { status } });
await expect(create()).rejects.toMatchObject({ code: 'CONFLICT' });
expect(await prisma.gatewayOpenRequest.count({ where: { profileName } })).toBe(0);
}
);
it.each(['COMPLETED', 'STOPPED', 'CANCELLED'] as const)('accepts a request in %s', async (status) => {
await prisma.gatewayProfile.update({ where: { profileName }, data: { status } });
await expect(create()).resolves.toMatchObject({ status: 'PENDING' });
});
it('rejects grace-only and sanctioned accounts, accepts a scoped active special grant', async () => {
await expect(create(4)).rejects.toMatchObject({ code: 'FORBIDDEN' });
await prisma.specialAccountAccessGrant.create({
data: {
userId: ids[4]!,
kind: 'TESTER',
profiles: ['different'],
allowsGeneralCreation: true,
reason: 'fixture',
grantedByUserId: ids[0]!,
},
});
await expect(create(4)).rejects.toMatchObject({ code: 'FORBIDDEN' });
await prisma.specialAccountAccessGrant.updateMany({
where: { userId: ids[4] },
data: { profiles: [profileName] },
});
await expect(create(4)).resolves.toMatchObject({ status: 'PENDING' });
await prisma.appUser.update({
where: { id: ids[1] },
data: { sanctions: { serverRestrictions: { [profileName]: { blockedFeatures: ['game'] } } } },
});
await expect(create()).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('deduplicates concurrent endorsements and excludes self agreement', async () => {
await policy(3);
const r = await create();
await expect(actOnOpenRequest(context(), await user(1), r.id, 'ENDORSE')).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
await Promise.all(
Array.from({ length: 5 }, async () => actOnOpenRequest(context(), await user(2), r.id, 'ENDORSE'))
);
expect(await prisma.gatewayOpenEndorsement.count({ where: { requestId: r.id } })).toBe(1);
expect(await prisma.gatewayOperation.count({ where: { profileName } })).toBe(0);
});
it('atomically reserves once at consensus, follows branch policy, supersedes other proposals', async () => {
await policy(2, { buildLeadMinutes: 25 });
await prisma.gatewayProfile.update({
where: { profileName },
data: {
meta: {
releaseSource: { mode: 'BRANCH', ref: 'main' },
openRequestPolicy: { endorsementThreshold: 2, buildLeadMinutes: 25 },
},
},
});
const r = await create();
const other = await create(2);
await Promise.all([
actOnOpenRequest(context(), await user(2), r.id, 'ENDORSE'),
actOnOpenRequest(context(), await user(3), r.id, 'ENDORSE'),
]);
const row = await prisma.gatewayOpenRequest.findUniqueOrThrow({ where: { id: r.id } });
const ops = await prisma.gatewayOperation.findMany({ where: { profileName } });
expect(row.status).toBe('RESERVED');
expect(ops).toHaveLength(1);
expect(ops[0]).toMatchObject({
sourceMode: 'BRANCH',
sourceRef: 'main',
resolvedCommitSha: null,
status: 'QUEUED',
});
expect(ops[0]!.scheduledAt!.getTime()).toBe(row.preopenAt.getTime() - 25 * 60_000);
expect((await prisma.gatewayOpenRequest.findUniqueOrThrow({ where: { id: other.id } })).status).toBe(
'SUPERSEDED'
);
expect(await profiles.claimNextOperation(new Date())).toBeNull();
});
it('keeps CHE-style manual approval and validates scoped opener capability', async () => {
expect(readOpenRequestPolicy({}).endorsementThreshold).toBeNull();
const r = await create();
await expect((await caller(2)).admin.openRequests.decide({ id: r.id, action: 'ACCEPT' })).rejects.toMatchObject(
{ code: 'FORBIDDEN' }
);
await expect(actOnOpenRequest(context(), await user(2), r.id, 'ENDORSE')).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
await Promise.all([
(await caller(3)).admin.openRequests.decide({ id: r.id, action: 'ACCEPT' }),
(await caller(3)).admin.openRequests.decide({ id: r.id, action: 'ACCEPT' }),
]);
expect(await prisma.gatewayOperation.count({ where: { profileName } })).toBe(1);
});
it('does not count a voter whose membership has been revoked', async () => {
await policy(2);
const r = await create();
await actOnOpenRequest(context(), await user(2), r.id, 'ENDORSE');
await prisma.appUser.update({ where: { id: ids[2] }, data: { kakaoVerifiedAt: null } });
await actOnOpenRequest(context(), await user(3), r.id, 'ENDORSE');
expect(await prisma.gatewayOperation.count({ where: { profileName } })).toBe(0);
});
it('rejects withdrawal by another user, permits own withdrawal, and rejects post-reservation withdrawal', async () => {
const r = await create();
await expect(actOnOpenRequest(context(), await user(2), r.id, 'WITHDRAW')).rejects.toMatchObject({
code: 'FORBIDDEN',
});
await actOnOpenRequest(context(), await user(1), r.id, 'WITHDRAW');
expect((await listOpenRequests(context(), await user(1), profileName)).requests).toHaveLength(0);
const next = await create();
await (await caller(3)).admin.openRequests.decide({ id: next.id, action: 'ACCEPT' });
await expect(actOnOpenRequest(context(), await user(1), next.id, 'WITHDRAW')).rejects.toMatchObject({
code: 'CONFLICT',
});
});
it('hides expired requests and refuses late approval without operation side effects', async () => {
const r = await create();
// Keep DB temporal constraints valid while simulating an expired proposal.
await prisma.gatewayOpenRequest.update({
where: { id: r.id },
data: { createdAt: new Date(Date.now() - 2 * 86_400_000), expiresAt: new Date(Date.now() - 86_400_000) },
});
expect((await listOpenRequests(context(), await user(1), profileName)).requests).toHaveLength(0);
await expect((await caller(3)).admin.openRequests.decide({ id: r.id, action: 'ACCEPT' })).rejects.toMatchObject(
{ code: 'CONFLICT' }
);
expect(await prisma.gatewayOperation.count({ where: { profileName } })).toBe(0);
});
it('refuses stale seasons and a game reopened before approval', async () => {
const r = await create();
await prisma.gatewayProfile.update({ where: { profileName }, data: { openAt: new Date() } });
await expect((await caller(3)).admin.openRequests.decide({ id: r.id, action: 'ACCEPT' })).rejects.toMatchObject(
{ code: 'CONFLICT' }
);
await prisma.gatewayProfile.update({ where: { profileName }, data: { openAt: null, status: 'RUNNING' } });
await expect((await caller(3)).admin.openRequests.decide({ id: r.id, action: 'ACCEPT' })).rejects.toMatchObject(
{ code: 'CONFLICT' }
);
});
it('serializes approval against existing operations and leaves proposal pending on build incompatibility', async () => {
const r = await create();
vi.mocked(catalog.supportsProfileReleaseBuild).mockResolvedValue(false);
await expect((await caller(3)).admin.openRequests.decide({ id: r.id, action: 'ACCEPT' })).rejects.toMatchObject(
{ code: 'BAD_REQUEST' }
);
expect((await prisma.gatewayOpenRequest.findUniqueOrThrow({ where: { id: r.id } })).status).toBe('PENDING');
vi.mocked(catalog.supportsProfileReleaseBuild).mockResolvedValue(true);
await profiles.createOperation({ profileName, type: 'START', requestedBy: ids[3]! });
await expect((await caller(3)).admin.openRequests.decide({ id: r.id, action: 'ACCEPT' })).rejects.toMatchObject(
{ code: 'CONFLICT' }
);
});
it('immediately applies a lowered consensus threshold to existing agreements', async () => {
await policy(3);
const r = await create();
await actOnOpenRequest(context(), await user(2), r.id, 'ENDORSE');
await prisma.appUser.update({
where: { id: ids[3] },
data: { roles: [`admin.profiles.settings:${profileName}`] },
});
await (
await caller(3)
).admin.openRequests.updatePolicy({
profileName,
policy: { enabled: true, lifetimeDays: 7, buildLeadMinutes: 10, endorsementThreshold: 1 },
reason: '동의 기준 조정',
});
expect((await prisma.gatewayOpenRequest.findUniqueOrThrow({ where: { id: r.id } })).status).toBe('RESERVED');
});
it('rolls back a deciding endorsement if reservation fails and supports retry', async () => {
await policy(1);
const r = await create();
vi.mocked(catalog.supportsProfileReleaseBuild).mockResolvedValue(false);
await expect(actOnOpenRequest(context(), await user(2), r.id, 'ENDORSE')).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
expect(await prisma.gatewayOpenEndorsement.count({ where: { requestId: r.id } })).toBe(0);
vi.mocked(catalog.supportsProfileReleaseBuild).mockResolvedValue(true);
await expect(actOnOpenRequest(context(), await user(2), r.id, 'ENDORSE')).resolves.toMatchObject({
status: 'RESERVED',
});
});
it('rejects equal, past and insufficient-lead dates and conflicting edits', async () => {
const input = install();
await expect(
createOpenRequest(context(), await user(1), profileName, { ...input, preopenAt: input.openAt })
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
await expect(
createOpenRequest(context(), await user(1), profileName, {
...input,
preopenAt: new Date(Date.now() + 60_000).toISOString(),
})
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
await create();
await expect(
createOpenRequest(context(), await user(1), profileName, { ...input, turnTermMinutes: 8 })
).rejects.toMatchObject({ code: 'CONFLICT' });
});
it('rejects a different-profile opener and policy changes by an ordinary member', async () => {
const r = await create();
await prisma.appUser.update({
where: { id: ids[3] },
data: { roles: ['admin.scenarios.reset:other:default'] },
});
await expect((await caller(3)).admin.openRequests.decide({ id: r.id, action: 'ACCEPT' })).rejects.toMatchObject(
{ code: 'FORBIDDEN' }
);
await expect(
(await caller(2)).admin.openRequests.updatePolicy({
profileName,
policy: { enabled: true, lifetimeDays: 7, buildLeadMinutes: 10, endorsementThreshold: 1 },
reason: '권한 거부 확인',
})
).rejects.toMatchObject({ code: 'FORBIDDEN' });
expect(await prisma.gatewayOperation.count({ where: { profileName } })).toBe(0);
});
it.runIf(process.env.OPEN_REQUEST_BROWSER === '1')(
'persists and approves through Chromium, actual HTTP and PostgreSQL',
async () => {
const { chromium } = await import('@playwright/test');
await policy(2);
const address = await app.listen({ host: '127.0.0.1', port: 0 });
const browser = await chromium.launch({ headless: true });
const browserContext = await browser.newContext({
viewport: { width: 1200, height: 900 },
deviceScaleFactor: 1,
locale: 'ko-KR',
timezoneId: 'Asia/Seoul',
colorScheme: 'dark',
});
const session = await sessions.createSession(await user(1));
await browserContext.addInitScript((token) => {
if (!localStorage.getItem('sammo-session-token')) localStorage.setItem('sammo-session-token', token);
}, session.sessionToken);
const page = await browserContext.newPage();
const artifactDir = path.join(repositoryRoot, 'test-results/open-request-live');
await fs.mkdir(artifactDir, { recursive: true });
try {
await page.goto(`${address}/gateway/open-suggestion`);
await page.getByTestId('proposal-preopen').fill('2099-08-18T11:30');
await page.getByTestId('proposal-open').fill('2099-08-18T12:00');
await page.getByTestId('submit-open-request').click();
await page.locator('.request-card').waitFor();
const row = await prisma.gatewayOpenRequest.findFirstOrThrow({ where: { profileName } });
expect(row.preopenAt.toISOString()).toBe('2099-08-18T02:30:00.000Z');
expect(row.requestedBy).toBe(ids[1]);
await page.reload();
await page.locator('.request-card').waitFor();
const voterSession = await sessions.createSession(await user(2));
await page.evaluate(
(token) => localStorage.setItem('sammo-session-token', token),
voterSession.sessionToken
);
await page.reload();
const endorse = page.getByRole('button', { name: '동의', exact: true });
await endorse.waitFor();
await endorse.hover();
await endorse.focus();
await endorse.click();
await page.getByRole('button', { name: '동의 완료', exact: true }).waitFor();
expect(await prisma.gatewayOpenEndorsement.count({ where: { requestId: row.id } })).toBe(1);
await page.screenshot({ path: path.join(artifactDir, 'pending-endorsement.png'), fullPage: true });
const openerSession = await sessions.createSession(await user(3));
await page.evaluate(
(token) => localStorage.setItem('sammo-session-token', token),
openerSession.sessionToken
);
await page.reload();
const accept = page.getByRole('button', { name: '수락하여 오픈 예약' });
await accept.waitFor();
await accept.hover();
await accept.focus();
await accept.click();
await page.locator('.request-card').filter({ hasText: '준비 예약' }).waitFor();
const reserved = await prisma.gatewayOpenRequest.findUniqueOrThrow({ where: { id: row.id } });
expect(reserved.status).toBe('RESERVED');
expect(await prisma.gatewayOperation.count({ where: { id: reserved.operationId! } })).toBe(1);
for (const viewport of [
{ width: 1200, height: 900 },
{ width: 390, height: 844 },
]) {
await page.setViewportSize(viewport);
await page.evaluate('document.fonts.ready');
await page.locator('.request-card summary').click();
const measurements = await page.locator('.request-list').evaluate((element) => {
const rect = element.getBoundingClientRect(),
style = element.ownerDocument.defaultView!.getComputedStyle(element);
return {
rect: rect.toJSON(),
font: style.font,
color: style.color,
documentWidth: element.ownerDocument.documentElement.scrollWidth,
viewportWidth: element.ownerDocument.defaultView!.innerWidth,
html: element.outerHTML,
};
});
expect(measurements.documentWidth).toBe(viewport.width);
await fs.writeFile(
path.join(artifactDir, `requests-${viewport.width}.json`),
JSON.stringify(measurements, null, 2)
);
await page.screenshot({
path: path.join(artifactDir, `requests-${viewport.width}.png`),
fullPage: true,
});
}
} catch (error) {
await fs.writeFile(path.join(artifactDir, 'failure.html'), await page.content());
await page.screenshot({ path: path.join(artifactDir, 'failure.png'), fullPage: true });
throw error;
} finally {
await browser.close();
}
},
60_000
);
it('enforces authentication and rejects user-supplied actor, source and build time over real HTTP', async () => {
const unauth = await app.inject({
method: 'POST',
url: '/gateway/api/trpc/openRequests.create',
payload: { profileName, install: install() },
});
expect(unauth.statusCode).toBe(401);
const session = await sessions.createSession(await user(1));
for (const extra of [
{ requestedBy: ids[3] },
{ scheduledAt: new Date().toISOString() },
{ sourceMode: 'BRANCH', sourceRef: 'evil' },
]) {
const response = await app.inject({
method: 'POST',
url: '/gateway/api/trpc/openRequests.create',
headers: { 'x-session-token': session.sessionToken },
payload: { profileName, install: install(), ...extra },
});
expect(response.statusCode).toBe(400);
}
const success = await app.inject({
method: 'POST',
url: '/gateway/api/trpc/openRequests.create',
headers: { 'x-session-token': session.sessionToken },
payload: { profileName, install: install() },
});
expect(success.statusCode).toBe(200);
expect(await prisma.gatewayOpenRequest.count({ where: { profileName } })).toBe(1);
});
});
@@ -669,6 +669,75 @@ describe('GatewayOrchestrator first-class operations', () => {
]);
});
it.each([
{ ...profile, status: 'RUNNING' as const },
{ ...profile, openAt: '2026-10-22T00:00:00.000Z' },
])('refuses a stale community reset before resolving or building code', async (changedProfile) => {
const reset: GatewayOperationRecord = {
...buildOperation('START'),
type: 'RESET',
sourceMode: 'COMMIT',
sourceRef: profile.buildCommitSha,
payload: {
openRequestId: 'request',
openRequestSeasonKey: JSON.stringify([profile.currentScenario, null]),
},
};
const harness = createHarness(reset, false, false, true, false, undefined, undefined, {
profile: changedProfile,
});
await harness.orchestrator.runOperationsNow();
expect(harness.completions).toEqual(['FAILED']);
expect(harness.started).toHaveLength(0);
expect(harness.stopped).toHaveLength(0);
expect(harness.buildStatuses).toHaveLength(0);
expect(harness.completionFields[0]?.error).toContain('서버가 다시 열렸거나');
});
it('allows an eligible community reservation to proceed into the existing build path', async () => {
const reset: GatewayOperationRecord = {
...buildOperation('START'),
type: 'RESET',
sourceMode: 'COMMIT',
sourceRef: profile.buildCommitSha,
payload: {
openRequestId: 'request',
openRequestSeasonKey: JSON.stringify([profile.currentScenario, null]),
install: { scenarioId: 2, openAt: '2099-01-01T00:00:00.000Z' },
},
};
const workspaceManager = {
resolveCommit: async () => profile.buildCommitSha!,
prepare: async () => {
throw new Error('fixture reached community build preparation');
},
remove: async () => {},
} as unknown as GitWorkspaceManager;
const harness = createHarness(reset, false, false, true, false, workspaceManager);
await harness.orchestrator.runOperationsNow();
expect(harness.completions).toEqual(['FAILED']);
expect(harness.completionFields[0]?.error).toBe('fixture reached community build preparation');
});
it('refuses a community reservation after its opening deadline', async () => {
const reset: GatewayOperationRecord = {
...buildOperation('START'),
type: 'RESET',
sourceMode: 'COMMIT',
sourceRef: profile.buildCommitSha,
payload: {
openRequestId: 'request',
openRequestSeasonKey: JSON.stringify([profile.currentScenario, null]),
install: { openAt: '2020-01-01T00:00:00.000Z' },
},
};
const harness = createHarness(reset);
await harness.orchestrator.runOperationsNow();
expect(harness.completions).toEqual(['FAILED']);
expect(harness.buildStatuses).toHaveLength(0);
expect(harness.completionFields[0]?.error).toContain('오픈 시각이 지나');
});
it('drains an in-flight operation before shutdown completes', async () => {
let releaseStart: (() => void) | undefined;
const startGate = new Promise<void>((resolve) => {
+1 -1
View File
@@ -38,7 +38,7 @@ describe('readReleaseManifest', () => {
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
gatewaySchemaHead: '20260825000000_add_bulk_release_batches',
gatewaySchemaHead: '20261022000000_add_open_requests',
gameSchemaHead: '20260916080000_index_play_audit_decision_month',
});
});
@@ -8,14 +8,22 @@ const operationNames = (route: Route): string[] => {
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
};
const installFixture = async (page: Page): Promise<string[]> => {
const installFixture = async (page: Page, opener = false): Promise<string[]> => {
const operations: string[] = [];
let savedRequest: Record<string, unknown> | null = null;
let policy = { enabled: true, lifetimeDays: 7, buildLeadMinutes: 10, endorsementThreshold: 2 as number | null };
await page.addInitScript(() => {
window.localStorage.setItem('sammo-session-token', 'regular-user-session');
});
await page.route('**/gateway/api/trpc/**', async (route) => {
expect(route.request().headers()['x-session-token']).toBe('regular-user-session');
const results = operationNames(route).map((operation) => {
const url = new URL(route.request().url());
const raw =
route.request().method() === 'POST'
? route.request().postDataJSON()
: JSON.parse(url.searchParams.get('input') ?? '{}');
const results = operationNames(route).map((operation, index) => {
const input = url.searchParams.get('batch') === '1' ? raw[String(index)] : raw;
operations.push(operation);
if (operation === 'me') {
return response({
@@ -52,6 +60,7 @@ const installFixture = async (page: Page): Promise<string[]> => {
]);
}
if (operation === 'lobby.scenarios') {
expect(input).toEqual({ profileName: 'pya:default' });
return response([
{
id: 2701,
@@ -77,6 +86,48 @@ const installFixture = async (page: Page): Promise<string[]> => {
},
]);
}
if (operation === 'openRequests.list') {
expect(input).toEqual({ profileName: 'pya:default' });
return response({ policy, canRequest: true, requests: savedRequest ? [savedRequest] : [] });
}
if (operation === 'admin.openRequests.permissions') {
return response({ canAccept: opener, canManagePolicy: opener });
}
if (operation === 'openRequests.create') {
expect(Object.keys(input).sort()).toEqual(['install', 'profileName']);
expect(input.profileName).toBe('pya:default');
expect(input.install.preopenAt).toBe('2099-08-18T02:30:00.000Z');
expect(input.install.openAt).toBe('2099-08-18T03:00:00.000Z');
expect(input.install.scenarioId).toBe(2701);
expect(input.install).not.toHaveProperty('gitRef');
savedRequest = {
id: '11111111-1111-4111-8111-111111111111',
status: 'PENDING',
requesterName: '일반유저',
isMine: true,
announcement: { scenarioTitle: '아시아 명장전', defaultStatTotal: 310 },
install: input.install,
expiresAt: '2099-08-18T02:20:00.000Z',
createdAt: '2099-08-17T00:00:00.000Z',
endorsementCount: 0,
endorsed: false,
canEndorse: false,
operationStatus: null,
};
return response({ id: savedRequest.id, status: savedRequest.status });
}
if (operation === 'admin.openRequests.decide') {
expect(opener).toBe(true);
expect(input).toEqual({ id: savedRequest?.id, action: 'ACCEPT' });
savedRequest = { ...savedRequest, status: 'RESERVED', operationStatus: 'QUEUED' };
return response({ id: savedRequest.id, status: 'RESERVED' });
}
if (operation === 'admin.openRequests.updatePolicy') {
expect(opener).toBe(true);
expect(input.reason).toBe('테스트 서버 정책 변경');
policy = input.policy;
return response(policy);
}
throw new Error(`Unhandled tRPC operation: ${operation}`);
});
const batched = new URL(route.request().url()).searchParams.get('batch') === '1';
@@ -95,7 +146,7 @@ test('lets a regular user inspect active-build scenarios and copy an open sugges
const operations = await installFixture(page);
await page.goto('lobby');
const suggestionLink = page.getByRole('link', { name: '오픈 건의 양식 작성' });
const suggestionLink = page.getByRole('link', { name: '오픈 요청 · 목록' });
await expect(suggestionLink).toBeVisible();
await suggestionLink.hover();
await suggestionLink.focus();
@@ -103,8 +154,10 @@ test('lets a regular user inspect active-build scenarios and copy an open sugges
await suggestionLink.click();
await expect(page).toHaveURL(/\/gateway\/open-suggestion$/);
await expect(page.getByRole('heading', { name: '오픈 건의 양식' })).toBeVisible();
await expect(page.getByText('서버 설정, 시나리오, 오픈 시각은 변경되지 않습니다.')).toBeVisible();
await expect(page.getByRole('heading', { name: '오픈 요청', exact: true })).toBeVisible();
await expect(
page.getByText('복사는 문구만 복사하며, 오픈 요청 등록 버튼을 눌러야 요청이 저장됩니다.')
).toBeVisible();
await expect(page.getByTestId('scenario-summary')).toContainText('310');
await page.getByTestId('proposal-open').fill('2026-08-18T12:00');
@@ -166,7 +219,77 @@ test('lets a regular user inspect active-build scenarios and copy an open sugges
await page.screenshot({ path: testInfo.outputPath('open-suggestion-mobile.png'), fullPage: true });
expect(operations).toContain('lobby.scenarios');
expect(operations.every((operation) => ['me', 'lobby.notice', 'lobby.profiles', 'lobby.scenarios'].includes(operation))).toBe(
true
);
expect(
operations.every((operation) =>
[
'me',
'lobby.notice',
'lobby.profiles',
'lobby.scenarios',
'openRequests.list',
'admin.openRequests.permissions',
].includes(operation)
)
).toBe(true);
});
test('registers a durable proposal and lets the opener accept and configure it across desktop/mobile', async ({
page,
}, testInfo) => {
const operations = await installFixture(page, true);
await page.goto('open-suggestion');
await expect(page.getByRole('heading', { name: '오픈 요청 목록' })).toBeVisible();
await page.getByTestId('proposal-preopen').fill('2099-08-18T11:30');
await page.getByTestId('proposal-open').fill('2099-08-18T12:00');
const submit = page.getByTestId('submit-open-request');
await submit.hover();
await submit.focus();
await expect(submit).toBeEnabled();
await submit.click();
const card = page.locator('.request-card');
await expect(card).toContainText('일반유저');
await expect(card).toContainText('수락 대기');
await page.reload();
await expect(card).toContainText('아시아 명장전');
await card.getByText('요청 설정 전체').click();
await expect(card).toContainText('시간 동기화');
await card.getByRole('button', { name: '수락하여 오픈 예약' }).hover();
await card.getByRole('button', { name: '수락하여 오픈 예약' }).click();
await expect(card).toContainText('오픈 예약');
await expect(card).toContainText('준비 예약');
await expect(card.getByRole('button', { name: '요청 철회' })).toHaveCount(0);
await page.getByText('서버 오픈 요청 정책', { exact: true }).click();
const policy = page.locator('.request-policy');
await policy.getByLabel('가오픈 전 준비 시간(분)').fill('25');
await policy.getByLabel('동의로 자동 예약').uncheck();
await expect(policy.getByLabel('필요 동의 수(요청자 제외)')).toHaveCount(0);
await policy.getByLabel('변경 사유').fill('테스트 서버 정책 변경');
await policy.getByRole('button', { name: '정책 저장' }).click();
await expect(page.getByText('이 서버는 오프너가 수락하면 예약됩니다.')).toBeVisible();
for (const viewport of [
{ width: 1200, height: 900 },
{ width: 390, height: 844 },
]) {
await page.setViewportSize(viewport);
await page.evaluate(() => document.fonts.ready);
const measurements = await page.locator('.request-list').evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
rect: rect.toJSON(),
color: style.color,
font: style.font,
width: innerWidth,
documentWidth: document.documentElement.scrollWidth,
html: element.outerHTML,
};
});
expect(measurements.documentWidth).toBe(viewport.width);
expect(measurements.rect.right).toBeLessThanOrEqual(viewport.width);
await writeFile(testInfo.outputPath(`requests-${viewport.width}.json`), JSON.stringify(measurements, null, 2));
await page.screenshot({ path: testInfo.outputPath(`requests-${viewport.width}.png`), fullPage: true });
}
expect(operations).toContain('openRequests.create');
expect(operations).toContain('admin.openRequests.decide');
expect(operations).toContain('admin.openRequests.updatePolicy');
});
+2 -2
View File
@@ -802,13 +802,13 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
</div>
<div class="p-6 text-center">
<p class="m-0 text-sm text-zinc-400">
시나리오와 빌드 옵션을 확인하고 운영자에게 전달할 오픈 건의 문구를 만들 수 있습니다.
다음 오픈을 요청하고, 다른 사용자의 요청에 동의하거나 진행 상황을 확인할 수 있습니다.
</p>
<RouterLink
to="/open-suggestion"
class="open-suggestion-link mt-4 inline-flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-white px-6 py-2 rounded border border-zinc-700 transition-colors"
>
오픈 건의 양식 작성
오픈 요청 · 목록
</RouterLink>
</div>
</div>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime, serverDateTimeInputToIso } from '@sammo-ts/common/time/ServerDateTime';
import type { AppRouter } from '@sammo-ts/gateway-api';
import type { inferRouterOutputs } from '@trpc/server';
import { computed, onMounted, reactive, ref, watch } from 'vue';
@@ -24,6 +25,112 @@ const scenarios = ref<Scenario[]>([]);
const catalogLoading = ref(false);
const catalogError = ref('');
const copiedMessage = ref('');
const requestState = ref<GatewayOutput['openRequests']['list'] | null>(null);
const requestMessage = ref('');
const requestError = ref('');
const requestBusy = ref(false);
const requestLoading = ref(false);
const canAccept = ref(false);
const canManagePolicy = ref(false);
const policyForm = reactive({
enabled: true,
lifetimeDays: 7,
buildLeadMinutes: 10,
endorsementThreshold: null as number | null,
});
const policyReason = ref('');
let requestListId = 0;
const loadRequests = async (): Promise<void> => {
const profileName = form.profileName;
const requestId = ++requestListId;
requestState.value = null;
canAccept.value = false;
canManagePolicy.value = false;
if (!profileName) return;
requestLoading.value = true;
try {
const result = await directTrpc.openRequests.list.query({ profileName });
const permissions = await directTrpc.admin.openRequests.permissions.query({ profileName }).catch(() => null);
if (requestId !== requestListId) return;
requestState.value = result;
Object.assign(policyForm, result.policy);
canAccept.value = permissions?.canAccept ?? false;
canManagePolicy.value = permissions?.canManagePolicy ?? false;
} catch (error) {
if (requestId === requestListId)
requestError.value = error instanceof Error ? error.message : '요청 목록을 불러오지 못했습니다.';
} finally {
if (requestId === requestListId) requestLoading.value = false;
}
};
const performRequest = async (work: () => Promise<unknown>, message: string): Promise<void> => {
if (requestBusy.value) return;
requestBusy.value = true;
requestError.value = '';
requestMessage.value = '';
try {
await work();
requestMessage.value = message;
await loadRequests();
} catch (error) {
requestError.value = error instanceof Error ? error.message : '요청을 처리하지 못했습니다.';
} finally {
requestBusy.value = false;
}
};
const submitRequest = (): Promise<void> =>
performRequest(async () => {
const preopenAt = serverDateTimeInputToIso(form.preopenAt);
const openAt = serverDateTimeInputToIso(form.openAt);
if (!preopenAt || !openAt || form.scenarioId === null)
throw new Error('가오픈·오픈 시각과 시나리오를 확인해 주세요.');
return directTrpc.openRequests.create.mutate({
profileName: form.profileName,
install: {
scenarioId: form.scenarioId,
preopenAt,
openAt,
turnTermMinutes: form.turnTermMinutes,
fiction: form.fiction,
npcMode: form.npcMode,
sync: form.sync,
extend: form.extend,
blockGeneralCreate: form.blockGeneralCreate,
showImgLevel: form.showImgLevel,
tournamentTrig: form.tournamentTrig,
joinMode: form.joinMode,
autorunUser: form.autorunEnabled
? { limitMinutes: form.autorunLimitMinutes, options: [...form.autorunOptions] }
: null,
},
});
}, '오픈 요청을 등록했습니다.');
const actOnRequest = (id: string, action: 'ENDORSE' | 'WITHDRAW' | 'ACCEPT' | 'REJECT'): Promise<void> =>
performRequest(
() =>
action === 'ACCEPT' || action === 'REJECT'
? directTrpc.admin.openRequests.decide.mutate({ id, action })
: directTrpc.openRequests.act.mutate({ id, action }),
action === 'ACCEPT'
? '오픈 예약으로 전환했습니다.'
: action === 'ENDORSE'
? '동의했습니다. 동의 수가 충족되면 자동 예약됩니다.'
: '요청을 처리했습니다.'
);
const savePolicy = (): Promise<void> =>
performRequest(
() =>
directTrpc.admin.openRequests.updatePolicy.mutate({
profileName: form.profileName,
policy: { ...policyForm },
reason: policyReason.value,
}),
'서버 오픈 요청 정책을 저장했습니다.'
);
const outputElement = ref<HTMLTextAreaElement | null>(null);
let catalogRequestId = 0;
@@ -48,9 +155,7 @@ const form = reactive({
const selectedProfile = computed(() => profiles.value.find((profile) => profile.profileName === form.profileName));
const selectedScenario = computed(() => scenarios.value.find((scenario) => scenario.id === form.scenarioId));
const dateOrderValid = computed(
() => !form.preopenAt || !form.openAt || new Date(form.preopenAt).getTime() <= new Date(form.openAt).getTime()
);
const dateOrderValid = computed(() => !form.preopenAt || !form.openAt || form.preopenAt < form.openAt);
const canCopy = computed(
() =>
Boolean(selectedProfile.value && selectedScenario.value && form.preopenAt && form.openAt) &&
@@ -64,6 +169,14 @@ const formatProposalDate = (value: string): string => {
return normalized.length === 16 ? `${normalized}:00` : normalized;
};
const operationStatusText = (status: string): string =>
({
QUEUED: '준비 예약',
RUNNING: '준비 중',
SUCCEEDED: '준비 완료',
FAILED: '준비 실패 · 오프너 확인 필요',
CANCELLED: '예약 취소',
})[status] ?? status;
const npcModeText = (value: number): string => ['불가', '가능', '선택 생성 가능'][value] ?? '불가';
const fictionText = (value: number): string => (value === 1 ? '가상' : '사실');
const autorunText = computed(() => {
@@ -138,7 +251,12 @@ const loadScenarios = async (): Promise<void> => {
watch(
() => form.profileName,
() => void loadScenarios()
() => {
requestError.value = '';
requestMessage.value = '';
void loadScenarios();
void loadRequests();
}
);
watch(selectedScenario, (scenario) => {
if (scenario?.fiction === 0 || scenario?.fiction === 1) form.fiction = scenario.fiction;
@@ -174,16 +292,185 @@ onMounted(async () => {
<header class="page-header">
<div>
<p class="eyebrow">GATEWAY COMMUNITY TOOL</p>
<h1>오픈 건의 양식</h1>
<p>현재 서버 빌드의 시나리오와 빌드 옵션을 살펴보고, 운영자에게 전달할 문구를 만듭니다.</p>
<h1>오픈 요청</h1>
<p>통일되었거나 닫힌 서버의 다음 오픈을 요청하고, 다른 사용자의 요청에 동의할 수 있습니다.</p>
</div>
<RouterLink class="back-link" to="/lobby">서버 목록으로</RouterLink>
</header>
<p class="read-only-notice" role="note">
이 화면은 조회와 문구 작성만 합니다. 서버 설정, 시나리오, 오픈 시각은 변경되지 않습니다.
이 화면은 조회와 문구 작성만 합니다. 복사는 문구만 복사하며, 오픈 요청 등록 버튼을 눌러야 요청이
저장됩니다.
</p>
<section class="panel request-list" aria-labelledby="requests-heading">
<div class="output-heading">
<h2 id="requests-heading">오픈 요청 목록</h2>
<button type="button" :disabled="requestLoading || requestBusy" @click="loadRequests">
새로고침
</button>
</div>
<p v-if="requestMessage" role="status">{{ requestMessage }}</p>
<p v-if="requestError" class="field-error" role="alert">{{ requestError }}</p>
<template v-if="requestState">
<p class="section-help">
요청은 {{ requestState.policy.lifetimeDays }}일 동안, 늦어도 빌드 시작 전까지 모집합니다. 가오픈
{{ requestState.policy.buildLeadMinutes }}분 전 서버 패치 정책에 따라 준비합니다.
<span v-if="requestState.policy.endorsementThreshold !== null"
>요청자 외 정식 사용자 {{ requestState.policy.endorsementThreshold }}명의 동의로 자동
예약됩니다.</span
>
<span v-else>이 서버는 오프너가 수락하면 예약됩니다.</span>
</p>
<p v-if="!requestState.canRequest" class="section-help">
현재 오픈 요청을 등록할 수 없습니다. 서버 상태와 정식 참여 자격을 확인해 주세요.
</p>
<p v-if="requestState.requests.length === 0">대기 중인 오픈 요청이 없습니다.</p>
<article
v-for="request in requestState.requests"
:key="request.id"
class="request-card"
:data-request-id="request.id"
>
<h3>{{ request.requesterName }} · {{ request.announcement.scenarioTitle }}</h3>
<p>
{{ request.status === 'RESERVED' ? '오픈 예약' : '수락 대기'
}}<span v-if="request.operationStatus">
· {{ operationStatusText(request.operationStatus) }}</span
>
</p>
<p>
가오픈 {{ formatServerDateTime(request.install.preopenAt) }} / 오픈
{{ formatServerDateTime(request.install.openAt) }} (서버 시간 UTC+9)
</p>
<p>
{{ request.install.turnTermMinutes }}분 턴 · 상성
{{ request.install.fiction === 1 ? '가상' : '사실' }} · 빙의
{{ npcModeText(request.install.npcMode) }} · 최대 스탯
{{ request.announcement.defaultStatTotal }}
</p>
<details>
<summary>요청 설정 전체</summary>
<dl class="request-settings">
<dt>시간 동기화</dt>
<dd>{{ request.install.sync ? '사용' : '사용 안 함' }}</dd>
<dt>확장 NPC</dt>
<dd>{{ request.install.extend ? '포함' : '미포함' }}</dd>
<dt>장수 생성</dt>
<dd>{{ ['가능', '불가', '장수명 무작위'][request.install.blockGeneralCreate] }}</dd>
<dt>임관</dt>
<dd>{{ request.install.joinMode === 'full' ? '자유 임관' : '랜덤 임관' }}</dd>
<dt>이미지 표시</dt>
<dd>{{ ['표시 안 함', '전콘', '전콘·병종', '전체'][request.install.showImgLevel] }}</dd>
<dt>토너먼트</dt>
<dd>{{ request.install.tournamentTrig ? '자동 시작' : '수동 시작' }}</dd>
<dt>자율행동</dt>
<dd>
{{
request.install.autorunUser
? `${request.install.autorunUser.limitMinutes}분`
: '사용 안 함'
}}
<template v-if="request.install.autorunUser">
·
{{
RESET_AUTORUN_LABELS.filter((entry) =>
request.install.autorunUser?.options.includes(entry.value)
)
.map((entry) => entry.label)
.join(', ')
}}</template
>
</dd>
</dl>
</details>
<p v-if="request.status === 'PENDING'">
모집 종료 {{ formatServerDateTime(request.expiresAt) }}
</p>
<p v-if="requestState.policy.endorsementThreshold !== null">
동의 {{ request.endorsementCount }}명 /
{{ requestState.policy.endorsementThreshold }}명<span v-if="request.endorsed">
· 동의 완료</span
>
</p>
<div class="request-actions" v-if="request.status === 'PENDING'">
<button
v-if="request.canEndorse"
type="button"
:disabled="requestBusy || request.endorsed"
@click="actOnRequest(request.id, 'ENDORSE')"
>
{{ request.endorsed ? '동의 완료' : '동의' }}
</button>
<button
v-if="request.isMine"
type="button"
:disabled="requestBusy"
@click="actOnRequest(request.id, 'WITHDRAW')"
>
요청 철회
</button>
<button
v-if="canAccept"
type="button"
:disabled="requestBusy"
@click="actOnRequest(request.id, 'ACCEPT')"
>
수락하여 오픈 예약
</button>
<button
v-if="canAccept"
type="button"
:disabled="requestBusy"
@click="actOnRequest(request.id, 'REJECT')"
>
요청 거절
</button>
</div>
</article>
<details v-if="canManagePolicy" class="request-policy">
<summary>서버 오픈 요청 정책</summary>
<div class="field-grid">
<label><span>요청 접수</span><input v-model="policyForm.enabled" type="checkbox" /></label>
<label
><span>모집 기간(일)</span
><input v-model.number="policyForm.lifetimeDays" type="number" min="1" max="365"
/></label>
<label
><span>가오픈 전 준비 시간(분)</span
><input v-model.number="policyForm.buildLeadMinutes" type="number" min="1" max="10080"
/></label>
<label
><span>동의로 자동 예약</span
><input
:checked="policyForm.endorsementThreshold !== null"
type="checkbox"
@change="
policyForm.endorsementThreshold =
policyForm.endorsementThreshold === null ? 5 : null
"
/></label>
<label v-if="policyForm.endorsementThreshold !== null"
><span>필요 동의 수(요청자 제외)</span
><input
v-model.number="policyForm.endorsementThreshold"
type="number"
min="1"
max="10000"
/></label>
<label><span>변경 사유</span><input v-model="policyReason" maxlength="200" /></label>
</div>
<button
type="button"
:disabled="requestBusy || policyReason.trim().length < 3"
@click="savePolicy"
>
정책 저장
</button>
</details>
</template>
</section>
<section class="panel" aria-labelledby="proposal-basic-heading">
<h2 id="proposal-basic-heading">기본 정보</h2>
<div class="field-grid">
@@ -208,34 +495,58 @@ onMounted(async () => {
</select>
</label>
<label>
<span>가오픈 일시</span>
<span>가오픈 일시 (서버 시간 UTC+9)</span>
<input v-model="form.preopenAt" type="datetime-local" step="1" data-testid="proposal-preopen" />
</label>
<label>
<span>오픈 일시</span>
<span>오픈 일시 (서버 시간 UTC+9)</span>
<input v-model="form.openAt" type="datetime-local" step="1" data-testid="proposal-open" />
</label>
</div>
<p v-if="catalogLoading" class="field-status" role="status">활성 빌드의 시나리오를 확인하고 있습니다.</p>
<p v-if="catalogLoading" class="field-status" role="status">
활성 빌드의 시나리오를 확인하고 있습니다.
</p>
<p v-else-if="catalogError" class="field-error" role="alert">
{{ catalogError }}
<button type="button" @click="loadScenarios">다시 확인</button>
</p>
<p v-if="!dateOrderValid" class="field-error" role="alert">가오픈 일시는 오픈 일시보다 늦을 수 없습니다.</p>
<p v-if="!dateOrderValid" class="field-error" role="alert">
가오픈 일시는 오픈 일시보다 늦을 수 없습니다.
</p>
<dl v-if="selectedScenario" class="scenario-summary" data-testid="scenario-summary">
<div><dt>시작 연도</dt><dd>{{ selectedScenario.year ?? '-' }}년</dd></div>
<div><dt>최대 스탯</dt><dd>{{ selectedScenario.defaultStatTotal }}</dd></div>
<div><dt>기본 NPC</dt><dd>{{ selectedScenario.npcCount }}명</dd></div>
<div><dt>확장 NPC</dt><dd>{{ selectedScenario.npcExCount }}명</dd></div>
<div><dt>중립 NPC</dt><dd>{{ selectedScenario.npcNeutralCount }}명</dd></div>
<div><dt>국가</dt><dd>{{ selectedScenario.nations.length }}개</dd></div>
<div>
<dt>시작 연도</dt>
<dd>{{ selectedScenario.year ?? '-' }}년</dd>
</div>
<div>
<dt>최대 스탯</dt>
<dd>{{ selectedScenario.defaultStatTotal }}</dd>
</div>
<div>
<dt>기본 NPC</dt>
<dd>{{ selectedScenario.npcCount }}명</dd>
</div>
<div>
<dt>확장 NPC</dt>
<dd>{{ selectedScenario.npcExCount }}명</dd>
</div>
<div>
<dt>중립 NPC</dt>
<dd>{{ selectedScenario.npcNeutralCount }}명</dd>
</div>
<div>
<dt>국가</dt>
<dd>{{ selectedScenario.nations.length }}개</dd>
</div>
</dl>
</section>
<section class="panel" aria-labelledby="proposal-options-heading">
<h2 id="proposal-options-heading">빌드 옵션</h2>
<p class="section-help">선택은 아래 미리보기에만 반영됩니다. 각 설명은 실제 초기화 옵션의 의미입니다.</p>
<p class="section-help">
선택한 설정은 오픈 요청에 저장됩니다. 각 설명은 실제 초기화 옵션의 의미입니다.
</p>
<div class="field-grid option-grid">
<label>
<span>{{ RESET_OPTION_COPY.turnTerm.label }}</span>
@@ -345,11 +656,35 @@ onMounted(async () => {
<p class="copy-status" role="status">{{ copiedMessage }}</p>
</section>
<section class="panel">
<p>
요청자는 빌드 시각이나 배포 버전을 지정하지 않습니다. 서버 정책에 따라 가오픈 전에 준비하며, 수락된
뒤에는 요청을 철회할 수 없습니다.
</p>
<button
type="button"
data-testid="submit-open-request"
:disabled="!canCopy || !requestState?.canRequest || requestBusy || requestLoading"
@click="submitRequest"
>
오픈 요청 등록
</button>
</section>
<details class="panel catalog-panel">
<summary>시나리오 목록 {{ scenarios.length }}개 보기</summary>
<div class="catalog-table-frame">
<table>
<thead><tr><th>ID</th><th>시나리오</th><th>시작</th><th>최대 스탯</th><th>NPC</th><th>국가</th></tr></thead>
<thead>
<tr>
<th>ID</th>
<th>시나리오</th>
<th>시작</th>
<th>최대 스탯</th>
<th>NPC</th>
<th>국가</th>
</tr>
</thead>
<tbody>
<tr v-for="scenario in scenarios" :key="scenario.id">
<td>{{ scenario.id }}</td>
@@ -368,6 +703,50 @@ onMounted(async () => {
</template>
<style scoped>
.request-card {
margin: 16px 0;
padding: 16px;
border: 1px solid #52525b;
border-radius: 6px;
overflow-wrap: anywhere;
}
.request-card h3 {
margin: 0 0 8px;
}
.request-settings {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 12px;
}
.request-card pre {
overflow: auto;
max-width: 100%;
white-space: pre-wrap;
}
.request-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.request-policy {
margin-top: 16px;
}
button {
min-height: 36px;
padding: 6px 12px;
border: 1px solid #71717a;
border-radius: 4px;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button:focus-visible {
outline: 2px solid #a5b4fc;
outline-offset: 2px;
}
.suggestion-page {
box-sizing: border-box;
width: min(100% - 32px, 960px);
@@ -390,10 +769,28 @@ onMounted(async () => {
color: #fff;
}
.page-header h1 { font-size: 28px; line-height: 1.25; }
.page-header p:not(.eyebrow), .section-help, .output-heading p { margin: 8px 0 0; color: #a1a1aa; }
.eyebrow { margin: 0 0 6px; color: #fb923c; font-size: 12px; font-weight: 700; letter-spacing: .12em; }
.back-link { flex: 0 0 auto; color: #fdba74; text-underline-offset: 3px; }
.page-header h1 {
font-size: 28px;
line-height: 1.25;
}
.page-header p:not(.eyebrow),
.section-help,
.output-heading p {
margin: 8px 0 0;
color: #a1a1aa;
}
.eyebrow {
margin: 0 0 6px;
color: #fb923c;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.12em;
}
.back-link {
flex: 0 0 auto;
color: #fdba74;
text-underline-offset: 3px;
}
.read-only-notice {
margin: 22px 0;
@@ -413,13 +810,37 @@ onMounted(async () => {
padding: 20px;
}
.panel h2 { font-size: 20px; }
.field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-top: 18px; }
.field-grid label, .autorun-limit { display: flex; min-width: 0; flex-direction: column; gap: 6px; }
.field-grid label > span, .autorun-limit > span { color: #fafafa; font-size: 14px; font-weight: 700; }
.field-grid small { color: #a1a1aa; font-size: 12px; line-height: 1.45; }
.panel h2 {
font-size: 20px;
}
.field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
margin-top: 18px;
}
.field-grid label,
.autorun-limit {
display: flex;
min-width: 0;
flex-direction: column;
gap: 6px;
}
.field-grid label > span,
.autorun-limit > span {
color: #fafafa;
font-size: 14px;
font-weight: 700;
}
.field-grid small {
color: #a1a1aa;
font-size: 12px;
line-height: 1.45;
}
select, input[type='datetime-local'], textarea {
select,
input[type='datetime-local'],
textarea {
box-sizing: border-box;
width: 100%;
border: 1px solid #52525b;
@@ -430,30 +851,94 @@ select, input[type='datetime-local'], textarea {
font: inherit;
}
select:focus-visible, input:focus-visible, textarea:focus-visible, button:focus-visible, summary:focus-visible, .back-link:focus-visible {
select:focus-visible,
input:focus-visible,
textarea:focus-visible,
button:focus-visible,
summary:focus-visible,
.back-link:focus-visible {
outline: 2px solid #fb923c;
outline-offset: 2px;
}
.field-status { color: #a1a1aa; }
.field-error { color: #fca5a5; }
.field-error button { border: 0; background: transparent; color: #fdba74; text-decoration: underline; cursor: pointer; }
.field-status {
color: #a1a1aa;
}
.field-error {
color: #fca5a5;
}
.field-error button {
border: 0;
background: transparent;
color: #fdba74;
text-decoration: underline;
cursor: pointer;
}
.scenario-summary { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); margin: 18px 0 0; border: 1px solid #3f3f46; }
.scenario-summary div { min-width: 0; border-right: 1px solid #3f3f46; padding: 10px; text-align: center; }
.scenario-summary div:last-child { border-right: 0; }
.scenario-summary dt { color: #a1a1aa; font-size: 12px; }
.scenario-summary dd { margin: 4px 0 0; color: #fff; font-weight: 700; }
.scenario-summary {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
margin: 18px 0 0;
border: 1px solid #3f3f46;
}
.scenario-summary div {
min-width: 0;
border-right: 1px solid #3f3f46;
padding: 10px;
text-align: center;
}
.scenario-summary div:last-child {
border-right: 0;
}
.scenario-summary dt {
color: #a1a1aa;
font-size: 12px;
}
.scenario-summary dd {
margin: 4px 0 0;
color: #fff;
font-weight: 700;
}
.toggle-grid, .checkbox-list { display: flex; flex-wrap: wrap; gap: 10px 18px; }
.toggle-grid { margin-top: 20px; border-top: 1px solid #3f3f46; padding-top: 16px; }
.toggle-grid label, .checkbox-list label { display: inline-flex; align-items: center; gap: 7px; }
.autorun-options { margin-top: 18px; border: 1px solid #3f3f46; border-radius: 4px; padding: 16px; }
.autorun-options legend { padding: 0 8px; color: #fff; font-weight: 700; }
.autorun-limit { width: min(100%, 260px); margin-top: 16px; }
.toggle-grid,
.checkbox-list {
display: flex;
flex-wrap: wrap;
gap: 10px 18px;
}
.toggle-grid {
margin-top: 20px;
border-top: 1px solid #3f3f46;
padding-top: 16px;
}
.toggle-grid label,
.checkbox-list label {
display: inline-flex;
align-items: center;
gap: 7px;
}
.autorun-options {
margin-top: 18px;
border: 1px solid #3f3f46;
border-radius: 4px;
padding: 16px;
}
.autorun-options legend {
padding: 0 8px;
color: #fff;
font-weight: 700;
}
.autorun-limit {
width: min(100%, 260px);
margin-top: 16px;
}
.output-heading { align-items: center; }
.output-heading h2 { margin: 0; }
.output-heading {
align-items: center;
}
.output-heading h2 {
margin: 0;
}
.output-heading button {
flex: 0 0 auto;
border: 1px solid #f97316;
@@ -464,25 +949,84 @@ select:focus-visible, input:focus-visible, textarea:focus-visible, button:focus-
cursor: pointer;
font-weight: 700;
}
.output-heading button:disabled { border-color: #3f3f46; background: #27272a; color: #71717a; cursor: default; }
.suggestion-output { margin-top: 16px; resize: vertical; line-height: 1.6; white-space: pre-wrap; }
.copy-status { min-height: 20px; margin: 8px 0 0; color: #86efac; }
.output-heading button:disabled {
border-color: #3f3f46;
background: #27272a;
color: #71717a;
cursor: default;
}
.suggestion-output {
margin-top: 16px;
resize: vertical;
line-height: 1.6;
white-space: pre-wrap;
}
.copy-status {
min-height: 20px;
margin: 8px 0 0;
color: #86efac;
}
.catalog-panel summary { cursor: pointer; color: #fdba74; font-weight: 700; }
.catalog-table-frame { margin-top: 14px; overflow-x: auto; }
.catalog-table-frame table { width: 100%; min-width: 680px; border-collapse: collapse; }
.catalog-table-frame th, .catalog-table-frame td { border-bottom: 1px solid #3f3f46; padding: 9px; text-align: left; }
.catalog-table-frame th { color: #a1a1aa; font-size: 12px; }
.catalog-panel summary {
cursor: pointer;
color: #fdba74;
font-weight: 700;
}
.catalog-table-frame {
margin-top: 14px;
overflow-x: auto;
}
.catalog-table-frame table {
width: 100%;
min-width: 680px;
border-collapse: collapse;
}
.catalog-table-frame th,
.catalog-table-frame td {
border-bottom: 1px solid #3f3f46;
padding: 9px;
text-align: left;
}
.catalog-table-frame th {
color: #a1a1aa;
font-size: 12px;
}
@media (max-width: 700px) {
.suggestion-page { width: min(100% - 24px, 960px); padding-top: 96px; }
.page-header { flex-direction: column; gap: 10px; }
.field-grid, .scenario-summary { grid-template-columns: minmax(0, 1fr); }
.scenario-summary div { display: flex; justify-content: space-between; border-right: 0; border-bottom: 1px solid #3f3f46; text-align: left; }
.scenario-summary div:last-child { border-bottom: 0; }
.scenario-summary dd { margin: 0; }
.panel { padding: 16px; }
.output-heading { align-items: stretch; flex-direction: column; }
.output-heading button { width: 100%; }
.suggestion-page {
width: min(100% - 24px, 960px);
padding-top: 96px;
}
.page-header {
flex-direction: column;
gap: 10px;
}
.field-grid,
.scenario-summary {
grid-template-columns: minmax(0, 1fr);
}
.scenario-summary div {
display: flex;
justify-content: space-between;
border-right: 0;
border-bottom: 1px solid #3f3f46;
text-align: left;
}
.scenario-summary div:last-child {
border-bottom: 0;
}
.scenario-summary dd {
margin: 0;
}
.panel {
padding: 16px;
}
.output-heading {
align-items: stretch;
flex-direction: column;
}
.output-heading button {
width: 100%;
}
}
</style>