Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c877b4a6f | ||
|
|
b3f7a419a9 | ||
|
|
af51968782 | ||
|
|
b377250fb9 | ||
|
|
d4a87c981f | ||
|
|
e42eaa4e58 | ||
|
|
2662091a6a | ||
|
|
236f1bc4ed | ||
|
|
8e5bd94cb8 | ||
|
|
e5a39cb714 | ||
|
|
039c5d2a59 | ||
|
|
856187b315 | ||
|
|
b67924a71d | ||
|
|
4e668b9fb6 |
@@ -38,6 +38,11 @@ GATEWAY_INTERNAL_API_URL=http://127.0.0.1:13000
|
||||
ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS=30000
|
||||
WEB_PUSH_OUTBOX_POLL_MS=1000
|
||||
GATEWAY_PUBLIC_URL=http://localhost:13000
|
||||
# Browser origins default to GATEWAY_PUBLIC_URL (Game API also accepts GAME_PUBLIC_URL).
|
||||
# Add exact HTTP(S) origins for a separate frontend; wildcards/null/path entries are rejected.
|
||||
# Requests with a different Origin fail with 403 before mutation. No-Origin clients still need auth.
|
||||
# API_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:5174
|
||||
# GAME_PUBLIC_URL=http://localhost:14000
|
||||
# Gateway authentication protection shares atomic 60s counters under this prefix.
|
||||
# Defaults: 10 login attempts/account, 600 auth requests/socket peer, 1200 globally;
|
||||
# expensive auth work is capped at 4/process. Forwarded headers are not trusted.
|
||||
|
||||
@@ -204,10 +204,13 @@ HTTPS 검증과 구분합니다.
|
||||
|
||||
## 코드 스타일
|
||||
|
||||
- Vite, Vue/Volar와 compiler API 소비자는 workspace 전체에서 정확히
|
||||
- Vite, ESLint와 일반 compiler API 소비자는 workspace 전체에서 정확히
|
||||
TypeScript `6.0.3`을 사용해 주세요. CLI project build/typecheck는 root의
|
||||
`@typescript/native` alias로 고정한 TypeScript 7 `tsc`를 사용하며 package-local
|
||||
다른 compiler version이나 직접 `tsc` 호출을 추가하지 말아 주세요.
|
||||
Vue CLI만 `tools/vue-typecheck.mjs`로 고정한
|
||||
`typescript-native-bridge@6.0.3-bridge.18.tsgo.7.0.2`를 사용합니다. 이 예외는
|
||||
Vue 타입 검사에 한하며 workspace `typescript` override를 교체하지 않습니다.
|
||||
- TypeScript/JSON/Vue SFC는 기존 4-space 스타일을 유지해 주세요.
|
||||
- public API는 명시적 타입을 사용하고 `any`, 불필요하게 넓은 `unknown`,
|
||||
`as unknown as` 우회를 피해 주세요.
|
||||
|
||||
@@ -87,8 +87,9 @@ input-event 결과를 transaction으로 반영합니다. Redis pub/sub과 SSE는
|
||||
|
||||
## 도구 체인
|
||||
|
||||
- Node.js `24.21.0`, pnpm `12.5.1`, Turbo
|
||||
- Node.js `24.22.1`, pnpm `12.5.1`, Turbo
|
||||
- TypeScript `6.0.3` compiler API와 TypeScript `7.0.2` native `tsc`
|
||||
- Vue CLI 전용 `typescript-native-bridge` (`6.0.3-bridge.18.tsgo.7.0.2`)
|
||||
- Fastify, tRPC, zod
|
||||
- Vue 3, Pinia, Vue Router, Vite
|
||||
- PostgreSQL, Prisma, Redis
|
||||
@@ -99,7 +100,8 @@ Node.js는 `.nvmrc`에서 24.x로 고정합니다. 의존성 설치와 검증에
|
||||
`package.json`과 `pnpm-lock.yaml`을 함께 사용해 주세요. 모든 workspace package는
|
||||
내부 전용(`private`)이므로 manifest의 `0.0.0`은 배포 버전이 아닙니다. 배포 source는
|
||||
full Git commit으로 고정하고, 실험 릴리스 같은 milestone은 annotated Git tag로
|
||||
식별합니다. Profile frontend는 같은 commit의 정적 버전 문서를 고정 URL과 ETag로
|
||||
식별합니다. 선언 파일 생성·Vue 검사 범위와 캐시 계약은 [빌드 도구](docs/build-performance.md)를
|
||||
확인해 주세요. Profile frontend는 같은 commit의 정적 버전 문서를 고정 URL과 ETag로
|
||||
주기적으로 재검증하고, 현재 열린 bundle과 달라졌을 때만 강제 reload 없이 공용
|
||||
toast로 한 번 안내합니다.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { parseNumberWithFallback } from '@sammo-ts/common';
|
||||
import { parseNumberWithFallback, resolveApiAllowedOrigins } from '@sammo-ts/common';
|
||||
|
||||
const parseReconcileInterval = (value: string | undefined): number => {
|
||||
const parsed = parseNumberWithFallback(value, 30_000, 'ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS');
|
||||
@@ -10,6 +10,7 @@ const parseReconcileInterval = (value: string | undefined): number => {
|
||||
|
||||
export interface GameApiConfig {
|
||||
host: string;
|
||||
corsAllowedOrigins: string[];
|
||||
port: number;
|
||||
trpcPath: string;
|
||||
eventsPath: string;
|
||||
@@ -48,6 +49,10 @@ export const resolveGameApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.env
|
||||
|
||||
return {
|
||||
host: env.GAME_API_HOST ?? '0.0.0.0',
|
||||
corsAllowedOrigins: resolveApiAllowedOrigins(env.API_ALLOWED_ORIGINS, [
|
||||
env.GATEWAY_PUBLIC_URL,
|
||||
env.GAME_PUBLIC_URL,
|
||||
]),
|
||||
port: parseNumberWithFallback(env.GAME_API_PORT, 14000, 'GAME_API_PORT'),
|
||||
trpcPath: env.GAME_TRPC_PATH ?? env.TRPC_PATH ?? '/trpc',
|
||||
eventsPath: env.GAME_API_EVENTS_PATH ?? '/events',
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import type { GamePrisma } from '@sammo-ts/infra';
|
||||
import type { GameApiContext } from '../../context.js';
|
||||
import { authedProcedure } from '../../trpc.js';
|
||||
import { getAuthenticatedUserId } from '../shared/general.js';
|
||||
import { resolveSecretPermission } from '../shared/secretPermission.js';
|
||||
import { projectPolicyConfiguration, projectPolicySummary } from '../playAudit/policies.js';
|
||||
import { readAudit, readAuditWorld } from '../playAudit/shared.js';
|
||||
|
||||
const area = z.enum(['NPC_VALUES', 'NPC_NATION_PRIORITY', 'NPC_GENERAL_PRIORITY']);
|
||||
const summarySelect = {
|
||||
id: true,
|
||||
schemaVersion: true,
|
||||
nationId: true,
|
||||
area: true,
|
||||
revision: true,
|
||||
previousId: true,
|
||||
source: true,
|
||||
year: true,
|
||||
month: true,
|
||||
actor: true,
|
||||
createdAt: true,
|
||||
} satisfies GamePrisma.PlayAuditPolicySelect;
|
||||
|
||||
// 소속·기밀 권한과 이력을 같은 snapshot에서 읽어 이적/권한 변경 경합을 막는다.
|
||||
const readNationId = async (ctx: GameApiContext, tx: GamePrisma.TransactionClient): Promise<number> => {
|
||||
const general = await tx.general.findFirst({ where: { userId: getAuthenticatedUserId(ctx) } });
|
||||
if (!general) throw new TRPCError({ code: 'NOT_FOUND', message: '장수 정보를 찾을 수 없습니다.' });
|
||||
if (general.nationId <= 0)
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '국가에 소속되어있지 않습니다.' });
|
||||
const nation = await tx.nation.findUnique({ where: { id: general.nationId }, select: { meta: true } });
|
||||
if (!nation) throw new TRPCError({ code: 'NOT_FOUND', message: '국가 정보를 찾을 수 없습니다.' });
|
||||
if (resolveSecretPermission(general, nation.meta) < 1)
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||
return general.nationId;
|
||||
};
|
||||
|
||||
export const policyHistory = authedProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
area,
|
||||
cursor: z.number().int().positive().optional(),
|
||||
limit: z.number().int().min(1).max(50).default(20),
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
.query(({ ctx, input }) =>
|
||||
readAudit(ctx, async (tx) => {
|
||||
const nationId = await readNationId(ctx, tx);
|
||||
const world = await readAuditWorld(tx);
|
||||
const rows = world.serverId
|
||||
? await tx.playAuditPolicy.findMany({
|
||||
where: {
|
||||
serverId: world.serverId,
|
||||
nationId,
|
||||
area: input.area,
|
||||
revision: input.cursor === undefined ? undefined : { lt: input.cursor },
|
||||
},
|
||||
orderBy: { revision: 'desc' },
|
||||
take: input.limit + 1,
|
||||
select: summarySelect,
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
coverage: world.serverId ? ('RECORDED_VERSIONS_ONLY' as const) : ('IDENTITY_MISSING' as const),
|
||||
historyGap: world.historyGap,
|
||||
items: rows.slice(0, input.limit).map(projectPolicySummary),
|
||||
nextCursor: rows.length > input.limit ? rows[input.limit - 1]!.revision : null,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
export const policyVersion = authedProcedure
|
||||
.input(z.object({ id: z.string().regex(/^[a-f0-9]{64}$/) }).strict())
|
||||
.query(({ ctx, input }) =>
|
||||
readAudit(ctx, async (tx) => {
|
||||
const nationId = await readNationId(ctx, tx);
|
||||
const world = await readAuditWorld(tx);
|
||||
const row = world.serverId
|
||||
? await tx.playAuditPolicy.findFirst({
|
||||
where: { id: input.id, serverId: world.serverId, nationId, area: { in: area.options } },
|
||||
select: { ...summarySelect, before: true, after: true },
|
||||
})
|
||||
: null;
|
||||
if (!row)
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '자국의 현재 기수 정책 기록을 찾을 수 없습니다.' });
|
||||
return projectPolicyConfiguration(row);
|
||||
})
|
||||
);
|
||||
@@ -15,6 +15,7 @@ import { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unit
|
||||
import type { GameApiContext } from '../../context.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
import { resolveSecretPermission } from '../shared/secretPermission.js';
|
||||
import { policyHistory, policyVersion } from './history.js';
|
||||
|
||||
type SetterInfo = {
|
||||
setter: string | null;
|
||||
@@ -356,6 +357,8 @@ const requestNpcPolicyMutation = async (
|
||||
};
|
||||
|
||||
export const npcRouter = router({
|
||||
policyHistory,
|
||||
policyVersion,
|
||||
getPolicy: authedProcedure.query(async ({ ctx }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
if (general.nationId <= 0) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { asRecord, type TurnDaemonCommandResult } from '@sammo-ts/common';
|
||||
import type { TournamentType } from '@sammo-ts/logic';
|
||||
import type { TournamentState } from '../../tournament/types.js';
|
||||
|
||||
@@ -18,6 +18,16 @@ import {
|
||||
} from '../../services/redisClockFence.js';
|
||||
import { loadClockAdminStatus } from '../../services/clockReadiness.js';
|
||||
|
||||
const assertTournamentAdjustment = (result: TurnDaemonCommandResult | null): void => {
|
||||
if (result?.type === 'commandRejected') {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: result.reason });
|
||||
}
|
||||
if (!result || result.type !== 'tournamentAdjustGeneral') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected tournament response' });
|
||||
}
|
||||
if (!result.ok) throw new TRPCError({ code: result.code, message: result.reason });
|
||||
};
|
||||
|
||||
const hasAdminRole = (roles: string[], profileName: string): boolean => {
|
||||
if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) {
|
||||
return true;
|
||||
@@ -197,6 +207,7 @@ const zMatch = z.object({
|
||||
|
||||
const zBetEntry = z.object({
|
||||
generalId: z.number().int().positive(),
|
||||
userId: z.string().min(1).nullable().optional(),
|
||||
targetId: z.number().int().positive(),
|
||||
amount: z.number().int().positive(),
|
||||
});
|
||||
@@ -458,7 +469,7 @@ export const tournamentRouter = router({
|
||||
});
|
||||
}),
|
||||
getBettingSummary: authedProcedure.query(async ({ ctx }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
await getMyGeneral(ctx);
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
const [state, entries, matches] = await Promise.all([
|
||||
store.getState(),
|
||||
@@ -488,7 +499,7 @@ export const tournamentRouter = router({
|
||||
}
|
||||
totals[entry.targetId] = (totals[entry.targetId] ?? 0) + entry.amount;
|
||||
totalAmount += entry.amount;
|
||||
if (entry.generalId === general.id) {
|
||||
if (entry.userId === ctx.auth!.user.id) {
|
||||
myTotals[entry.targetId] = (myTotals[entry.targetId] ?? 0) + entry.amount;
|
||||
myAmount += entry.amount;
|
||||
}
|
||||
@@ -523,20 +534,15 @@ export const tournamentRouter = router({
|
||||
|
||||
const develCost = resolveCurrentDevelCost(worldState);
|
||||
const feeResult = await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'resources'),
|
||||
reason: 'tournamentJoin',
|
||||
adjustments: [{ generalId: general.id, goldDelta: -develCost, minGoldAfter: 0 }],
|
||||
userId: ctx.auth!.user.id,
|
||||
generalId: general.id,
|
||||
goldDelta: -develCost,
|
||||
minGoldAfter: 0,
|
||||
});
|
||||
if (!feeResult || feeResult.type !== 'adjustGeneralResources') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
if (!feeResult.ok || feeResult.processed !== 1) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: feeResult.ok ? '금이 부족합니다.' : feeResult.reason,
|
||||
});
|
||||
}
|
||||
assertTournamentAdjustment(feeResult);
|
||||
|
||||
const meta = asRecord(general.meta);
|
||||
const level = typeof meta.explevel === 'number' ? meta.explevel : 0;
|
||||
@@ -558,12 +564,17 @@ export const tournamentRouter = router({
|
||||
try {
|
||||
await store.setParticipants(next);
|
||||
} catch (error) {
|
||||
assertTournamentAdjustment(
|
||||
await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
|
||||
reason: 'tournamentJoinRollback',
|
||||
adjustments: [{ generalId: general.id, goldDelta: develCost }],
|
||||
});
|
||||
userId: ctx.auth!.user.id,
|
||||
generalId: general.id,
|
||||
goldDelta: develCost,
|
||||
minGoldAfter: 0,
|
||||
})
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
return { ok: true, count: next.length };
|
||||
@@ -663,8 +674,13 @@ export const tournamentRouter = router({
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '올바르지 않은 베팅 대상입니다.' });
|
||||
}
|
||||
|
||||
// 구형 row의 당시 actor는 현재 장수 소유자로 추정할 수 없다.
|
||||
// 운영 전환은 베팅 종료 뒤 하거나 검증한 actor 정보를 관리자가 복원해야 한다.
|
||||
if (entries.some((entry) => entry.userId === undefined)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이전 베팅의 사용자 확인이 필요합니다.' });
|
||||
}
|
||||
const previousBetAmount = entries
|
||||
.filter((entry) => entry.generalId === general.id)
|
||||
.filter((entry) => entry.userId === ctx.auth!.user.id)
|
||||
.reduce((sum, entry) => sum + entry.amount, 0);
|
||||
if (previousBetAmount + input.amount > 1_000) {
|
||||
throw new TRPCError({
|
||||
@@ -674,68 +690,37 @@ export const tournamentRouter = router({
|
||||
}
|
||||
|
||||
const adjustResult = await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'resources'),
|
||||
reason: 'tournamentBet',
|
||||
adjustments: [{ generalId: general.id, goldDelta: -input.amount, minGoldAfter: 500 }],
|
||||
});
|
||||
if (!adjustResult || adjustResult.type !== 'adjustGeneralResources') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
if (!adjustResult.ok || adjustResult.processed !== 1) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: adjustResult.ok ? '금이 부족합니다.' : adjustResult.reason,
|
||||
});
|
||||
}
|
||||
|
||||
const rankResult = await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralMeta',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank'),
|
||||
reason: 'tournamentBet',
|
||||
adjustments: [
|
||||
{
|
||||
userId: ctx.auth!.user.id,
|
||||
generalId: general.id,
|
||||
metaDelta: { betgold: input.amount },
|
||||
},
|
||||
],
|
||||
goldDelta: -input.amount,
|
||||
betGoldDelta: input.amount,
|
||||
minGoldAfter: 500,
|
||||
});
|
||||
if (!rankResult || rankResult.type !== 'adjustGeneralMeta' || !rankResult.ok) {
|
||||
await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank-rollback-resources'),
|
||||
reason: 'tournamentBetRollback',
|
||||
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
|
||||
});
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '베팅 기록을 저장하지 못했습니다.' });
|
||||
}
|
||||
assertTournamentAdjustment(adjustResult);
|
||||
|
||||
try {
|
||||
await store.appendBettingEntry({
|
||||
generalId: general.id,
|
||||
userId: ctx.auth!.user.id,
|
||||
targetId: input.targetId,
|
||||
amount: input.amount,
|
||||
});
|
||||
} catch (error) {
|
||||
await Promise.all([
|
||||
ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
assertTournamentAdjustment(
|
||||
await ctx.turnDaemon.requestCommand({
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
|
||||
reason: 'tournamentBetRollback',
|
||||
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
|
||||
}),
|
||||
ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralMeta',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-rank'),
|
||||
reason: 'tournamentBetRollback',
|
||||
adjustments: [
|
||||
{
|
||||
userId: ctx.auth!.user.id,
|
||||
generalId: general.id,
|
||||
metaDelta: { betgold: -input.amount },
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
goldDelta: input.amount,
|
||||
betGoldDelta: -input.amount,
|
||||
minGoldAfter: 0,
|
||||
})
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
return { ok: true };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { safeHttpLoggerOptions, safeHttpErrorHandler } from '@sammo-ts/common';
|
||||
import { safeHttpLoggerOptions, safeHttpErrorHandler, createApiOriginGuard } from '@sammo-ts/common';
|
||||
import fastify, { type FastifyRequest } from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
@@ -76,13 +76,14 @@ const extractBearerToken = (value: string | string[] | undefined): string | null
|
||||
const resolveAuthFromToken = async (
|
||||
token: string | null,
|
||||
accessTokenStore: RedisAccessTokenStore,
|
||||
flushStore: FlushStore
|
||||
flushStore: FlushStore,
|
||||
profileName: string
|
||||
): Promise<GameSessionTokenPayload | null> => {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
const stored = await accessTokenStore.get(token);
|
||||
if (!stored) {
|
||||
if (!stored || stored.profile !== profileName) {
|
||||
return null;
|
||||
}
|
||||
let flushedAt: Date | null;
|
||||
@@ -273,8 +274,9 @@ export const createGameApiServer = async () => {
|
||||
|
||||
app.addHook('onClose', closeResources);
|
||||
|
||||
app.addHook('onRequest', createApiOriginGuard(config.corsAllowedOrigins));
|
||||
await app.register(cors, {
|
||||
origin: true,
|
||||
origin: config.corsAllowedOrigins,
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
@@ -290,7 +292,7 @@ export const createGameApiServer = async () => {
|
||||
...trpcJsonBodyHttpServerOptions,
|
||||
createContext: async ({ req }: { req: FastifyRequest }) => {
|
||||
const token = extractBearerToken(req.headers.authorization);
|
||||
const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore);
|
||||
const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore, config.profileName);
|
||||
const rawIdempotencyKey = Array.isArray(req.headers['idempotency-key'])
|
||||
? req.headers['idempotency-key'][0]
|
||||
: req.headers['idempotency-key'];
|
||||
@@ -334,7 +336,7 @@ export const createGameApiServer = async () => {
|
||||
});
|
||||
|
||||
const resolveRealtimeAuth = async (token: string | null): Promise<GameSessionTokenPayload | null> => {
|
||||
const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore);
|
||||
const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore, config.profileName);
|
||||
if (
|
||||
!auth ||
|
||||
auth.profile !== config.profileName ||
|
||||
|
||||
@@ -31,6 +31,7 @@ export class CorruptTournamentProjectionError extends Error {
|
||||
const zTournamentBet = z
|
||||
.object({
|
||||
generalId: z.number().int(),
|
||||
userId: z.string().min(1).nullable().optional(),
|
||||
targetId: z.number().int(),
|
||||
amount: z.number(),
|
||||
})
|
||||
@@ -286,7 +287,10 @@ export class TournamentStore {
|
||||
async appendBettingEntry(entry: TournamentBetEntry): Promise<TournamentBetEntry[]> {
|
||||
const entries = await this.getBettingEntries();
|
||||
const existing = entries.find(
|
||||
(candidate) => candidate.generalId === entry.generalId && candidate.targetId === entry.targetId
|
||||
(candidate) =>
|
||||
candidate.generalId === entry.generalId &&
|
||||
candidate.targetId === entry.targetId &&
|
||||
candidate.userId === entry.userId
|
||||
);
|
||||
if (existing) {
|
||||
existing.amount += entry.amount;
|
||||
|
||||
@@ -74,6 +74,8 @@ export interface TournamentMatchEntry {
|
||||
|
||||
export interface TournamentBetEntry {
|
||||
generalId: number;
|
||||
// 로그인 actor는 장수 빙의와 독립적이다. null은 서버가 만든 NPC, 생략은 구형 row다.
|
||||
userId?: string | null;
|
||||
targetId: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
@@ -760,7 +760,7 @@ const buildNpcBettingPlan = async (options: {
|
||||
for (const npc of npcBetList) {
|
||||
const targetId = rng.choice(candidateIds);
|
||||
if (!existingBettors.has(npc.id as number)) {
|
||||
entries.push({ generalId: npc.id as number, targetId, amount: betGold });
|
||||
entries.push({ generalId: npc.id as number, userId: null, targetId, amount: betGold });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
@@ -789,19 +789,24 @@ export const seedNpcBets = async (options: {
|
||||
}
|
||||
|
||||
const requestPrefix = `tournament:${state.bettingId ?? `${state.openYear}:${state.openMonth}:${state.type}`}:npc-bet`;
|
||||
await daemonTransport.sendCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: `${requestPrefix}:resources`,
|
||||
reason: 'tournamentNpcBet',
|
||||
adjustments: plan.map((entry) => ({ generalId: entry.generalId, goldDelta: -entry.amount })),
|
||||
});
|
||||
await daemonTransport.sendCommand({
|
||||
type: 'adjustGeneralMeta',
|
||||
requestId: `${requestPrefix}:meta`,
|
||||
reason: 'tournamentNpcBet',
|
||||
adjustments: plan.map((entry) => ({ generalId: entry.generalId, metaDelta: { betgold: entry.amount } })),
|
||||
// Wait for one durable ENGINE result before publishing entries or opening stage 6.
|
||||
// The engine skips NPCs whose owner changed after this deterministic plan was stored.
|
||||
const result = await daemonTransport.requestCommand({
|
||||
type: 'tournamentSeedNpcBets',
|
||||
requestId: `${requestPrefix}:atomic`,
|
||||
bets: plan.map((entry) => ({ generalId: entry.generalId, amount: entry.amount })),
|
||||
});
|
||||
if (!result || result.type !== 'tournamentSeedNpcBets' || !result.ok) {
|
||||
throw new Error('NPC tournament betting did not commit.');
|
||||
}
|
||||
const processed = new Set(result.processedGeneralIds);
|
||||
const existing = await store.getBettingEntries();
|
||||
const existingBettors = new Set(existing.map((entry) => entry.generalId));
|
||||
await store.setBettingEntries(existing.concat(plan.filter((entry) => !existingBettors.has(entry.generalId))));
|
||||
await store.setBettingEntries(
|
||||
existing.concat(
|
||||
plan
|
||||
.filter((entry) => processed.has(entry.generalId) && !existingBettors.has(entry.generalId))
|
||||
.map((entry) => ({ ...entry, userId: null }))
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@ import { recordGeneralActivity } from './services/generalActivity.js';
|
||||
const t = initTRPC.context<GameApiContext>().create({ errorFormatter: safeTrpcErrorFormatter });
|
||||
|
||||
const requireAuthMiddleware = t.middleware(({ ctx, next }) => {
|
||||
if (!ctx.auth) {
|
||||
if (!ctx.auth || ctx.auth.profile !== ctx.profile.name) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Unauthorized',
|
||||
|
||||
@@ -387,7 +387,7 @@ describe('general access tracking', () => {
|
||||
db,
|
||||
generalAccessTracking: true,
|
||||
requestId: 'access-boundary-test',
|
||||
profile: { id: 'che:default', name: 'che' },
|
||||
profile: { id: 'che', name: 'che:default', scenario: 'default' },
|
||||
profileStatusSource,
|
||||
} as unknown as GameApiContext;
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ const buildContext = (executeRaw = vi.fn(async (_query: unknown) => 1), token =
|
||||
auth: token,
|
||||
db: { $executeRaw: executeRaw },
|
||||
generalAccessTracking: true,
|
||||
profile: { id: 'che:default', name: 'che', scenario: 'default' },
|
||||
profile: { id: 'che', name: 'che:default', scenario: 'default' },
|
||||
}) as unknown as GameApiContext;
|
||||
|
||||
const activityRouter = router({
|
||||
|
||||
@@ -31,6 +31,7 @@ const buildContext = (
|
||||
) =>
|
||||
({
|
||||
auth: options.auth === undefined ? auth : options.auth,
|
||||
profile: { id: 'che', name: 'che:default', scenario: 'default' },
|
||||
db: {
|
||||
general: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
|
||||
@@ -36,6 +36,7 @@ type LogQuery = {
|
||||
const buildContext = (findMany: (query: LogQuery) => Promise<Array<{ id: number; text: string }>>) =>
|
||||
({
|
||||
auth,
|
||||
profile: { id: 'che', name: 'che:default', scenario: 'default' },
|
||||
db: {
|
||||
general: {
|
||||
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => ({
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
import { afterAll, beforeAll, expect, it } from 'vitest';
|
||||
import fastify from 'fastify';
|
||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { createGamePostgresConnector, createRedisConnector, GamePrisma } from '@sammo-ts/infra';
|
||||
import { createTurnDaemonRuntime, seedScenarioToDatabase, type TurnDaemonRuntime } from '@sammo-ts/game-engine';
|
||||
import { appRouter } from '../src/router.js';
|
||||
import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js';
|
||||
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||
import type { GameApiContext } from '../src/context.js';
|
||||
|
||||
const databaseUrl = process.env.NPC_POLICY_DATABASE_URL;
|
||||
const integration = it.skipIf(!databaseUrl || !process.env.REDIS_URL);
|
||||
const connector = databaseUrl ? createGamePostgresConnector({ url: databaseUrl }) : null;
|
||||
const redis = process.env.REDIS_URL ? createRedisConnector({ url: process.env.REDIS_URL }) : null;
|
||||
let runtime: TurnDaemonRuntime | undefined;
|
||||
let loop: Promise<void> | undefined;
|
||||
const profile = `npc-policy:${databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : 'fixture'}`;
|
||||
const baseTime = new Date('0185-01-01T00:00:00Z');
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!databaseUrl) return;
|
||||
if (!new URL(databaseUrl).searchParams.get('schema')?.endsWith('_npc_policy_fixture'))
|
||||
throw new Error('Dedicated NPC policy fixture schema required');
|
||||
await connector!.connect();
|
||||
await redis!.connect();
|
||||
});
|
||||
afterAll(async () => {
|
||||
if (loop) {
|
||||
await runtime?.lifecycle.stop();
|
||||
await loop;
|
||||
}
|
||||
await runtime?.close();
|
||||
await connector?.disconnect();
|
||||
await redis?.disconnect();
|
||||
});
|
||||
|
||||
integration(
|
||||
'HTTP user edits survive daemon restart and real NPC turns, with readable actor and before/after history',
|
||||
async () => {
|
||||
const db = connector!.prisma;
|
||||
await db.playAuditPolicy.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ serverId: 'npc-policy-lifecycle' },
|
||||
{ id: { in: ['c'.repeat(64), 'd'.repeat(64), 'e'.repeat(64)] } },
|
||||
],
|
||||
},
|
||||
});
|
||||
await db.playAuditMonth.deleteMany({ where: { serverId: 'npc-policy-lifecycle' } });
|
||||
await db.playAuditDecisionChunk.deleteMany({ where: { decision: { serverId: 'npc-policy-lifecycle' } } });
|
||||
await db.playAuditDecision.deleteMany({ where: { serverId: 'npc-policy-lifecycle' } });
|
||||
await db.playAuditDiplomacyEvent.deleteMany({ where: { serverId: 'npc-policy-lifecycle' } });
|
||||
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: 'npc-policy-http-' } } });
|
||||
await seedScenarioToDatabase({
|
||||
scenarioId: 903,
|
||||
databaseUrl: databaseUrl!,
|
||||
now: new Date(),
|
||||
installOptions: {
|
||||
openAt: new Date(Date.now() + 86_400_000),
|
||||
turnTermMinutes: 5,
|
||||
npcMode: 2,
|
||||
showImgLevel: 3,
|
||||
serverId: 'npc-policy-lifecycle',
|
||||
season: 1,
|
||||
},
|
||||
});
|
||||
await db.generalTurn.deleteMany();
|
||||
await db.nationTurn.deleteMany();
|
||||
await db.general.deleteMany();
|
||||
await db.nation.deleteMany();
|
||||
await db.diplomacy.deleteMany();
|
||||
// 185년 고정 fixture에 180년 시나리오의 지연 등장 이벤트를 이월하지 않는다.
|
||||
// 일반턴·월간 재정/도시 갱신은 실제 runtime handler를 그대로 실행한다.
|
||||
await db.event.deleteMany();
|
||||
const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } });
|
||||
const world = await db.worldState.findFirstOrThrow();
|
||||
await db.worldState.update({
|
||||
where: { id: world.id },
|
||||
data: {
|
||||
currentYear: 185,
|
||||
currentMonth: 1,
|
||||
clockBaseTime: baseTime,
|
||||
clockTick: 0n,
|
||||
lastTurnTick: 0n,
|
||||
clockPhase: 'RUNNING',
|
||||
clockMode: 'realtime',
|
||||
clockWallAnchor: new Date(),
|
||||
config: { ...asRecord(world.config), tournamentTrig: false },
|
||||
},
|
||||
});
|
||||
await db.nation.create({
|
||||
data: {
|
||||
id: 1,
|
||||
name: '정책국',
|
||||
color: '#ffffff',
|
||||
level: 3,
|
||||
chiefGeneralId: 1,
|
||||
capitalCityId: city.id,
|
||||
gold: 100_000,
|
||||
rice: 100_000,
|
||||
tech: 3000,
|
||||
meta: {},
|
||||
},
|
||||
});
|
||||
await db.city.updateMany({ data: { nationId: 0 } });
|
||||
await db.city.update({
|
||||
where: { id: city.id },
|
||||
data: {
|
||||
nationId: 1,
|
||||
population: 100_000,
|
||||
populationMax: 200_000,
|
||||
supplyState: 1,
|
||||
frontState: 1,
|
||||
meta: { trust: 80, trade: 100 },
|
||||
},
|
||||
});
|
||||
await db.general.createMany({
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
userId: 'npc-policy-owner',
|
||||
name: '정책군주',
|
||||
nationId: 1,
|
||||
cityId: city.id,
|
||||
officerLevel: 12,
|
||||
npcState: 0,
|
||||
turnTime: new Date('0185-02-01Z'),
|
||||
turnTick: 100_000_000n,
|
||||
meta: { belong: 5, killturn: 24 },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '징병검증장',
|
||||
nationId: 1,
|
||||
cityId: city.id,
|
||||
officerLevel: 5,
|
||||
npcState: 2,
|
||||
leadership: 80,
|
||||
strength: 80,
|
||||
intel: 30,
|
||||
gold: 100_000,
|
||||
rice: 100_000,
|
||||
crew: 0,
|
||||
turnTime: baseTime,
|
||||
turnTick: 0n,
|
||||
meta: { killturn: 24, armType: 1, dex1: 1000 },
|
||||
},
|
||||
],
|
||||
});
|
||||
const transport = new DatabaseTurnDaemonTransport(db, 10_000);
|
||||
const auth: NonNullable<GameApiContext['auth']> = {
|
||||
version: 1,
|
||||
profile,
|
||||
sessionId: 'fixture-session',
|
||||
issuedAt: new Date().toISOString(),
|
||||
expiresAt: '2999-01-01T00:00:00Z',
|
||||
user: { id: 'npc-policy-owner', username: 'fixture', displayName: 'fixture', roles: [] },
|
||||
sanctions: {},
|
||||
};
|
||||
const app = fastify();
|
||||
let request = 0;
|
||||
await app.register(fastifyTRPCPlugin, {
|
||||
prefix: '/che/api/trpc',
|
||||
trpcOptions: {
|
||||
router: appRouter,
|
||||
createContext: ({
|
||||
req,
|
||||
}: {
|
||||
req: { headers: Record<string, string | string[] | undefined> };
|
||||
}): GameApiContext => ({
|
||||
db,
|
||||
redis: redis!.client,
|
||||
profile: { id: 'hwe', scenario: '903', name: profile },
|
||||
auth: req.headers['x-session-token'] === 'fixture-session' ? auth : null,
|
||||
requestId: `npc-policy-http-${++request}`,
|
||||
turnDaemon: transport,
|
||||
accessTokenStore: new RedisAccessTokenStore(redis!.client, profile),
|
||||
flushStore: new InMemoryFlushStore(),
|
||||
battleSim: {} as GameApiContext['battleSim'],
|
||||
uploadDir: '/tmp',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
gameTokenSecret: 'fixture-only',
|
||||
}),
|
||||
},
|
||||
});
|
||||
const actions: Array<{ kind: string; actionKey: string; generalId: number; completed?: boolean }> = [];
|
||||
const start = async () => {
|
||||
const instance = await createTurnDaemonRuntime({
|
||||
profile,
|
||||
databaseUrl: databaseUrl!,
|
||||
enableLeaseHeartbeat: false,
|
||||
onActionResolved: (action) => actions.push(action),
|
||||
});
|
||||
// 자연 진행은 정지하고, 사용자 command와 명시한 due 턴만 처리한다.
|
||||
instance.lifecycle.pause('NPC policy fixture');
|
||||
return instance;
|
||||
};
|
||||
const call = async (endpoint: string, input: unknown, mutation = false) => {
|
||||
const response = await app.inject({
|
||||
method: mutation ? 'POST' : 'GET',
|
||||
url: `/che/api/trpc/npc.${endpoint}${mutation ? '' : `?input=${encodeURIComponent(JSON.stringify(input))}`}`,
|
||||
headers: {
|
||||
'x-session-token': 'fixture-session',
|
||||
...(mutation ? { 'content-type': 'application/json' } : {}),
|
||||
},
|
||||
...(mutation ? { payload: JSON.stringify(input) } : {}),
|
||||
});
|
||||
expect(response.statusCode, response.body).toBe(200);
|
||||
return response.json<{ result: { data: unknown } }>().result.data;
|
||||
};
|
||||
const configuration = (value: unknown) => {
|
||||
const data = asRecord(value);
|
||||
return {
|
||||
values: data.currentNationPolicy,
|
||||
nation: data.currentNationPriority,
|
||||
general: data.currentGeneralActionPriority,
|
||||
setters: data.lastSetters,
|
||||
};
|
||||
};
|
||||
try {
|
||||
runtime = await start();
|
||||
loop = runtime.lifecycle.start();
|
||||
// 발령과 징병을 목록에서 빼면 실제 턴에서도 수행하지 않아야 한다.
|
||||
await call('setNationPriority', ['NPC포상'], true);
|
||||
await call('setGeneralPriority', ['출병', '일반내정'], true);
|
||||
await call(
|
||||
'setNationPolicy',
|
||||
{ minWarCrew: 2000, minNPCRecruitCityPopulation: 30000, safeRecruitCityPopulationRatio: 0 },
|
||||
true
|
||||
);
|
||||
const withoutRecruitment = await call('getPolicy', {});
|
||||
expect(withoutRecruitment).toMatchObject({
|
||||
currentNationPriority: ['NPC포상'],
|
||||
currentGeneralActionPriority: ['출병', '일반내정'],
|
||||
});
|
||||
await runtime.lifecycle.stop();
|
||||
await loop;
|
||||
loop = undefined;
|
||||
await runtime.close();
|
||||
runtime = await start();
|
||||
expect(configuration(await call('getPolicy', {}))).toEqual(configuration(withoutRecruitment));
|
||||
let result = await runtime.processor.run(baseTime, { budgetMs: 10_000, maxGenerals: 1, catchUpCap: 1 });
|
||||
expect(result.processedGenerals).toBe(1);
|
||||
expect(runtime.world.getGeneralById(2)!.crew).toBe(0);
|
||||
expect(actions).toContainEqual(expect.objectContaining({ generalId: 2, kind: 'nation' }));
|
||||
expect(
|
||||
actions.filter((action) => action.generalId === 2).some((action) => action.actionKey === 'che_징병')
|
||||
).toBe(false);
|
||||
await runtime.hooks!.flushChanges!(result);
|
||||
const nationDecision = await db.playAuditDecision.findFirstOrThrow({
|
||||
where: { generalId: 2, phase: 'nation' },
|
||||
include: { chunks: { orderBy: { ordinal: 'asc' } } },
|
||||
});
|
||||
const procedures = nationDecision.chunks.flatMap((chunk) =>
|
||||
Array.isArray(chunk.steps)
|
||||
? chunk.steps
|
||||
.map(asRecord)
|
||||
.filter((step) => step.kind === 'PROCEDURE_START')
|
||||
.map((step) => step.procedure)
|
||||
: []
|
||||
);
|
||||
expect(procedures).toEqual(['NPC포상']);
|
||||
expect(configuration(await call('getPolicy', {}))).toEqual(configuration(withoutRecruitment));
|
||||
// 같은 장수·자원으로 징병 우선순위를 맨 앞에 저장한 뒤 다시 시작한다.
|
||||
loop = runtime.lifecycle.start();
|
||||
await call('setGeneralPriority', ['징병', '출병', '일반내정'], true);
|
||||
const saved = await call('getPolicy', {});
|
||||
await runtime.lifecycle.stop();
|
||||
await loop;
|
||||
loop = undefined;
|
||||
await runtime.close();
|
||||
runtime = await start();
|
||||
expect(configuration(await call('getPolicy', {}))).toEqual(configuration(saved));
|
||||
const beforeMeta = asRecord(runtime.world.getNationById(1)!.meta);
|
||||
// 실제 due 턴과 command를 실행한다. 전방의 중립 도시가 전쟁 상태를 만든다.
|
||||
actions.length = 0;
|
||||
result = await runtime.processor.run(runtime.world.getGeneralById(2)!.turnTime, {
|
||||
budgetMs: 10_000,
|
||||
maxGenerals: 1,
|
||||
catchUpCap: 1,
|
||||
});
|
||||
expect(result.processedGenerals).toBe(1);
|
||||
expect(runtime.world.getGeneralById(2)!.crew).toBeGreaterThan(0);
|
||||
expect(actions).toContainEqual(
|
||||
expect.objectContaining({ generalId: 2, actionKey: 'che_징병', completed: true })
|
||||
);
|
||||
await runtime.hooks!.flushChanges!(result);
|
||||
expect((await db.general.findUniqueOrThrow({ where: { id: 2 } })).crew).toBeGreaterThan(0);
|
||||
expect(asRecord(runtime.world.getNationById(1)!.meta).npc_nation_policy).toEqual(
|
||||
beforeMeta.npc_nation_policy
|
||||
);
|
||||
expect(configuration(await call('getPolicy', {}))).toEqual(configuration(saved));
|
||||
// 병력이 소진되고 도시 인구가 정책 하한 미만이면 징병을 거른다.
|
||||
runtime.world.updateGeneral(2, { crew: 0 });
|
||||
runtime.world.updateCity(city.id, { population: 30_100 });
|
||||
actions.length = 0;
|
||||
result = await runtime.processor.run(runtime.world.getGeneralById(2)!.turnTime, {
|
||||
budgetMs: 10_000,
|
||||
maxGenerals: 1,
|
||||
catchUpCap: 1,
|
||||
});
|
||||
expect(result.processedGenerals).toBe(1);
|
||||
expect(runtime.world.getGeneralById(2)!.crew).toBe(0);
|
||||
expect(actions.some((action) => action.actionKey === 'che_징병')).toBe(false);
|
||||
await runtime.hooks!.flushChanges!(result);
|
||||
expect(configuration(await call('getPolicy', {}))).toEqual(configuration(saved));
|
||||
// 이후의 개인턴/월간 갱신도 정책을 덮어쓰면 안 된다.
|
||||
const target = new Date(runtime.world.getGeneralById(2)!.turnTime.getTime() + 30 * 60_000);
|
||||
result = await runtime.processor.run(target, { budgetMs: 10_000, maxGenerals: 100, catchUpCap: 12 });
|
||||
expect(result.processedGenerals).toBeGreaterThan(1);
|
||||
await runtime.hooks!.flushChanges!(result);
|
||||
expect(await call('getPolicy', {})).toMatchObject({
|
||||
currentNationPriority: ['NPC포상'],
|
||||
currentGeneralActionPriority: ['징병', '출병', '일반내정'],
|
||||
currentNationPolicy: { minWarCrew: 2000, minNPCRecruitCityPopulation: 30000 },
|
||||
});
|
||||
expect(runtime.world.getState().meta.playAuditGap).toBeUndefined();
|
||||
const history = await call('policyHistory', { area: 'NPC_NATION_PRIORITY' });
|
||||
expect(history).toMatchObject({
|
||||
items: [
|
||||
expect.objectContaining({ source: 'CHANGE', actor: expect.objectContaining({ name: '정책군주' }) }),
|
||||
expect.anything(),
|
||||
],
|
||||
});
|
||||
const row = await db.playAuditPolicy.findFirstOrThrow({
|
||||
where: { nationId: 1, area: 'NPC_NATION_PRIORITY', source: 'CHANGE' },
|
||||
});
|
||||
expect(await call('policyVersion', { id: row.id })).toMatchObject({
|
||||
actor: { name: '정책군주' },
|
||||
fields: [{ key: 'priority', beforeJson: 'null', afterJson: '["NPC포상"]', changed: true }],
|
||||
});
|
||||
expect(row.inputSequence).not.toBeNull();
|
||||
expect((await db.inputEvent.findUniqueOrThrow({ where: { sequence: row.inputSequence! } })).status).toBe(
|
||||
'SUCCEEDED'
|
||||
);
|
||||
// 외국/이전 기수/국방 정책 ID를 알아도 자국 NPC 정책으로 조회할 수 없다.
|
||||
for (const [id, patch] of [
|
||||
['c'.repeat(64), { nationId: 2 }],
|
||||
['d'.repeat(64), { serverId: 'previous-season' }],
|
||||
['e'.repeat(64), { area: 'DEFENCE' }],
|
||||
] as const) {
|
||||
await db.playAuditPolicy.create({
|
||||
data: {
|
||||
...row,
|
||||
id,
|
||||
...patch,
|
||||
actor: row.actor ?? GamePrisma.DbNull,
|
||||
before: row.before ?? GamePrisma.DbNull,
|
||||
after: row.after ?? GamePrisma.JsonNull,
|
||||
},
|
||||
});
|
||||
const response = await app.inject({
|
||||
url: `/che/api/trpc/npc.policyVersion?input=${encodeURIComponent(JSON.stringify({ id }))}`,
|
||||
headers: { 'x-session-token': 'fixture-session' },
|
||||
});
|
||||
expect(response.statusCode).toBe(404);
|
||||
}
|
||||
await db.general.update({ where: { id: 1 }, data: { penalty: { noChief: true } } });
|
||||
const denied = await app.inject({
|
||||
url: `/che/api/trpc/npc.policyHistory?input=${encodeURIComponent(JSON.stringify({ area: 'NPC_VALUES' }))}`,
|
||||
headers: { 'x-session-token': 'fixture-session' },
|
||||
});
|
||||
expect(denied.statusCode).toBe(403);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
},
|
||||
90_000
|
||||
);
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import fastify from 'fastify';
|
||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import type { RedisConnector } from '@sammo-ts/infra';
|
||||
@@ -96,10 +98,16 @@ const createContext = (
|
||||
options: {
|
||||
me?: GeneralRow;
|
||||
nation?: typeof baseNation;
|
||||
world?: typeof baseWorld;
|
||||
world?: {
|
||||
config: typeof baseWorld.config;
|
||||
meta: Record<string, unknown>;
|
||||
currentYear?: number;
|
||||
currentMonth?: number;
|
||||
};
|
||||
requestCommand?: ReturnType<typeof vi.fn>;
|
||||
troopRows?: Array<{ troopLeaderId: number }>;
|
||||
cityRows?: Array<{ id: number }>;
|
||||
historyRows?: Array<Record<string, unknown>>;
|
||||
} = {}
|
||||
): { context: GameApiContext; findFirst: ReturnType<typeof vi.fn>; requestCommand: ReturnType<typeof vi.fn> } => {
|
||||
const requestCommand =
|
||||
@@ -117,6 +125,11 @@ const createContext = (
|
||||
worldState: { findFirst: vi.fn(async () => options.world ?? baseWorld) },
|
||||
troop: { findMany: vi.fn(async () => options.troopRows ?? [{ troopLeaderId: 101 }]) },
|
||||
city: { findMany: vi.fn(async () => options.cityRows ?? [{ id: 1 }, { id: 2 }]) },
|
||||
playAuditPolicy: {
|
||||
findMany: vi.fn(async () => options.historyRows ?? []),
|
||||
findFirst: vi.fn(async () => options.historyRows?.[0] ?? null),
|
||||
},
|
||||
$transaction: async <T>(read: (tx: object) => Promise<T>): Promise<T> => read(db),
|
||||
};
|
||||
const redisClient = { get: async () => null, set: async () => null };
|
||||
return {
|
||||
@@ -140,6 +153,146 @@ const createContext = (
|
||||
};
|
||||
|
||||
describe('NPC policy router', () => {
|
||||
const historyRow = {
|
||||
id: 'a'.repeat(64),
|
||||
schemaVersion: 1,
|
||||
nationId: 1,
|
||||
area: 'NPC_NATION_PRIORITY',
|
||||
revision: 2,
|
||||
previousId: 'b'.repeat(64),
|
||||
source: 'CHANGE',
|
||||
year: 185,
|
||||
month: 1,
|
||||
createdAt: new Date('2026-01-01Z'),
|
||||
actor: {
|
||||
userId: 'private-user',
|
||||
generalId: 22,
|
||||
name: '정책담당',
|
||||
nationId: 1,
|
||||
officerLevel: 12,
|
||||
npcState: 0,
|
||||
permission: 4,
|
||||
},
|
||||
before: { priority: ['NPC전방발령', 'NPC포상'] },
|
||||
after: { priority: ['NPC포상'] },
|
||||
};
|
||||
const historyContext = (options: Parameters<typeof createContext>[0] = {}) =>
|
||||
createContext({
|
||||
...options,
|
||||
world: {
|
||||
...baseWorld,
|
||||
currentYear: 185,
|
||||
currentMonth: 1,
|
||||
meta: { ...baseWorld.meta, serverId: 'current-season' },
|
||||
},
|
||||
historyRows: options.historyRows ?? [historyRow],
|
||||
});
|
||||
|
||||
it('lets an ordinary nation reader inspect the actor and changes without admin request/account fields', async () => {
|
||||
const fixture = historyContext({ me: { ...baseGeneral, officerLevel: 2 } });
|
||||
const caller = appRouter.createCaller(fixture.context);
|
||||
const page = await caller.npc.policyHistory({ area: 'NPC_NATION_PRIORITY', limit: 1 });
|
||||
expect(page.items[0]).toMatchObject({ actor: { name: '정책담당' }, revision: 2 });
|
||||
const detail = await caller.npc.policyVersion({ id: historyRow.id });
|
||||
expect(detail.fields).toEqual([
|
||||
{ key: 'priority', beforeJson: '["NPC전방발령","NPC포상"]', afterJson: '["NPC포상"]', changed: true },
|
||||
]);
|
||||
expect(JSON.stringify(detail)).not.toMatch(/private-user|permission|requestId|inputSequence/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ nationId: 0 },
|
||||
{ officerLevel: 0 },
|
||||
{ officerLevel: 1, meta: { belong: 0 } },
|
||||
{ penalty: { noChief: true } },
|
||||
])('denies history when current nation read permission is missing: %j', async (patch) => {
|
||||
const fixture = historyContext({ me: { ...baseGeneral, ...patch } });
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).npc.policyHistory({ area: 'NPC_VALUES' })
|
||||
).rejects.toBeDefined();
|
||||
});
|
||||
|
||||
it('scopes cursor and version lookups to the authenticated nation and current season', async () => {
|
||||
const fixture = historyContext();
|
||||
const caller = appRouter.createCaller(fixture.context);
|
||||
await caller.npc.policyHistory({ area: 'NPC_VALUES', cursor: 7, limit: 1 });
|
||||
const tx = await fixture.context.db.$transaction!(async (db) => db);
|
||||
expect(tx.playAuditPolicy.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { serverId: 'current-season', nationId: 1, area: 'NPC_VALUES', revision: { lt: 7 } },
|
||||
take: 2,
|
||||
})
|
||||
);
|
||||
await caller.npc.policyVersion({ id: historyRow.id });
|
||||
expect(tx.playAuditPolicy.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
id: historyRow.id,
|
||||
serverId: 'current-season',
|
||||
nationId: 1,
|
||||
area: { in: ['NPC_VALUES', 'NPC_NATION_PRIORITY', 'NPC_GENERAL_PRIORITY'] },
|
||||
},
|
||||
})
|
||||
);
|
||||
await expect(caller.npc.policyHistory({ area: 'NPC_VALUES', limit: 51 })).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
await expect(caller.npc.policyVersion({ id: 'bad' })).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||
});
|
||||
|
||||
it('requires authentication and rejects caller-supplied nation or defence history over HTTP', async () => {
|
||||
const fixture = historyContext();
|
||||
const app = fastify();
|
||||
await app.register(fastifyTRPCPlugin, {
|
||||
prefix: '/che/api/trpc',
|
||||
trpcOptions: {
|
||||
router: appRouter,
|
||||
createContext: ({ req }: { req: { headers: Record<string, string | string[] | undefined> } }) => ({
|
||||
...fixture.context,
|
||||
auth: req.headers['x-session-token'] === 'fixture-session' ? auth : null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
try {
|
||||
const query = (input: object, authenticated = true) =>
|
||||
app.inject({
|
||||
url: `/che/api/trpc/npc.policyHistory?input=${encodeURIComponent(JSON.stringify(input))}`,
|
||||
headers: authenticated ? { 'x-session-token': 'fixture-session' } : {},
|
||||
});
|
||||
expect((await query({ area: 'NPC_VALUES' }, false)).statusCode).toBe(401);
|
||||
expect((await query({ area: 'NPC_VALUES' })).statusCode).toBe(200);
|
||||
expect((await query({ area: 'DEFENCE' })).statusCode).toBe(400);
|
||||
expect((await query({ area: 'NPC_VALUES', nationId: 2 })).statusCode).toBe(400);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('denies sanctioned sessions before reading history', async () => {
|
||||
const fixture = historyContext();
|
||||
fixture.context.auth = { ...auth, sanctions: { suspendedUntil: '2999-01-01T00:00:00Z' } };
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).npc.policyHistory({ area: 'NPC_VALUES' })
|
||||
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||
expect(fixture.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('paginates revisions without exposing a missing or unavailable version', async () => {
|
||||
const fixture = historyContext({ historyRows: [historyRow, { ...historyRow, revision: 1 }] });
|
||||
const page = await appRouter
|
||||
.createCaller(fixture.context)
|
||||
.npc.policyHistory({ area: 'NPC_NATION_PRIORITY', limit: 1 });
|
||||
expect(page.items).toHaveLength(1);
|
||||
expect(page.nextCursor).toBe(2);
|
||||
const missing = historyContext({ historyRows: [] });
|
||||
await expect(
|
||||
appRouter.createCaller(missing.context).npc.policyVersion({ id: historyRow.id })
|
||||
).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
||||
const noIdentity = createContext();
|
||||
await expect(
|
||||
appRouter.createCaller(noIdentity.context).npc.policyHistory({ area: 'NPC_VALUES' })
|
||||
).resolves.toMatchObject({ coverage: 'IDENTITY_MISSING', items: [], nextCursor: null });
|
||||
});
|
||||
it('loads server and nation overrides while calculating legacy zero-value hints from nation tech', async () => {
|
||||
const fixture = createContext();
|
||||
const result = await appRouter.createCaller(fixture.context).npc.getPolicy();
|
||||
|
||||
@@ -20,7 +20,7 @@ const profile: GameProfile = {
|
||||
|
||||
const auth: GameSessionTokenPayload = {
|
||||
version: 1,
|
||||
profile: 'che',
|
||||
profile: 'che:default',
|
||||
issuedAt: '2026-07-26T00:00:00.000Z',
|
||||
expiresAt: '2026-07-27T00:00:00.000Z',
|
||||
sessionId: 'ranking-session',
|
||||
@@ -239,7 +239,7 @@ const buildContext = (options?: {
|
||||
turnDaemon: new InMemoryTurnDaemonTransport(),
|
||||
battleSim: new InMemoryBattleSimTransport(),
|
||||
profile: selectedProfile,
|
||||
auth: options?.authenticated === false ? null : auth,
|
||||
auth: options?.authenticated === false ? null : { ...auth, profile: selectedProfile.name },
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
|
||||
@@ -70,6 +70,7 @@ const envKeys = [
|
||||
'GATEWAY_REDIS_PREFIX',
|
||||
'GATEWAY_INTERNAL_API_URL',
|
||||
'GAME_UPLOAD_DIR',
|
||||
'API_ALLOWED_ORIGINS',
|
||||
] as const;
|
||||
const originalEnv = new Map(envKeys.map((key) => [key, process.env[key]]));
|
||||
|
||||
@@ -642,6 +643,7 @@ integration('game API security over HTTP transport', () => {
|
||||
process.env.GATEWAY_REDIS_PREFIX = redisPrefix;
|
||||
process.env.GATEWAY_INTERNAL_API_URL = await listenGatewayStatusStub();
|
||||
process.env.GAME_UPLOAD_DIR = uploadDir;
|
||||
process.env.API_ALLOWED_ORIGINS = 'https://sam.hided.net';
|
||||
|
||||
const connector = createGamePostgresConnector({ url: dedicatedTarget.databaseUrl });
|
||||
await connector.connect();
|
||||
@@ -3774,6 +3776,58 @@ integration('game API security over HTTP transport', () => {
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
it('rejects an untrusted browser Origin before an authenticated logout mutation', async () => {
|
||||
const token = await createAccessToken('origin-mutation', {});
|
||||
const response = await fetch(`${baseUrl}/trpc/auth.logout`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
origin: 'https://untrusted.example',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.headers.get('access-control-allow-origin')).toBeNull();
|
||||
expect(await accessTokenStore.get(token)).not.toBeNull();
|
||||
for (const origin of ['https://sam.hided.net', 'https://untrusted.example', 'null']) {
|
||||
const preflight = await fetch(`${baseUrl}/trpc/auth.logout`, {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
origin,
|
||||
'access-control-request-method': 'POST',
|
||||
'access-control-request-headers': 'authorization,content-type',
|
||||
},
|
||||
});
|
||||
expect(preflight.status).toBe(origin === 'https://sam.hided.net' ? 204 : 403);
|
||||
expect(preflight.headers.get('access-control-allow-origin')).toBe(
|
||||
origin === 'https://sam.hided.net' ? origin : null
|
||||
);
|
||||
}
|
||||
await accessTokenStore.revoke(token);
|
||||
});
|
||||
|
||||
it('rejects a same-namespace foreign-profile token over tRPC before any DB mutation', async () => {
|
||||
const foreign = await accessTokenStore.create({
|
||||
...buildPayload('trpc-foreign', {}),
|
||||
profile: 'other:default',
|
||||
});
|
||||
if (!foreign) throw new Error('Foreign token fixture failed');
|
||||
const before = await readReservedMutationState();
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${baseUrl}/trpc/turns.reserved.getGeneral?input=${encodeURIComponent(JSON.stringify({ generalId }))}`,
|
||||
{
|
||||
headers: { authorization: `Bearer ${foreign.accessToken}` },
|
||||
}
|
||||
);
|
||||
expect(response.status).toBe(401);
|
||||
expect(await readReservedMutationState()).toEqual(before);
|
||||
} finally {
|
||||
await accessTokenStore.revoke(foreign.accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it('SSE accepts header auth and rejects query or cross-profile credentials', async () => {
|
||||
const token = await createAccessToken('sse-query', {});
|
||||
const queryOnly = await fetch(`${baseUrl}${server!.config.eventsPath}?token=${encodeURIComponent(token)}`);
|
||||
|
||||
@@ -76,35 +76,21 @@ class TournamentTransport implements TurnDaemonTransport {
|
||||
|
||||
async requestCommand(command: TurnDaemonCommand): Promise<TurnDaemonCommandResult | null> {
|
||||
this.commands.push(command);
|
||||
if (command.type === 'adjustGeneralResources') {
|
||||
const adjustment = command.adjustments[0];
|
||||
if (!adjustment) {
|
||||
return { type: 'adjustGeneralResources', ok: false, reason: '조정 대상이 없습니다.' };
|
||||
if (command.type === 'tournamentAdjustGeneral') {
|
||||
if (command.betGoldDelta !== undefined && this.failNextRankUpdate) {
|
||||
this.failNextRankUpdate = false;
|
||||
return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: 'rank update failed' };
|
||||
}
|
||||
const nextGold = (this.gold.get(adjustment.generalId) ?? 0) + (adjustment.goldDelta ?? 0);
|
||||
if (nextGold < (adjustment.minGoldAfter ?? 0)) {
|
||||
return { type: 'adjustGeneralResources', ok: false, reason: '자원이 부족합니다.' };
|
||||
const nextGold = (this.gold.get(command.generalId) ?? 0) + command.goldDelta;
|
||||
if (nextGold < command.minGoldAfter) {
|
||||
return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: '자원이 부족합니다.' };
|
||||
}
|
||||
this.gold.set(adjustment.generalId, nextGold);
|
||||
return {
|
||||
type: 'adjustGeneralResources',
|
||||
ok: true,
|
||||
processed: 1,
|
||||
missing: 0,
|
||||
totalGoldDelta: adjustment.goldDelta ?? 0,
|
||||
totalRiceDelta: 0,
|
||||
};
|
||||
this.gold.set(command.generalId, nextGold);
|
||||
return { type: command.type, ok: true, generalId: command.generalId };
|
||||
}
|
||||
if (command.type === 'setMySetting') {
|
||||
return { type: 'setMySetting', ok: true, generalId: command.generalId };
|
||||
}
|
||||
if (command.type === 'adjustGeneralMeta') {
|
||||
if (this.failNextRankUpdate) {
|
||||
this.failNextRankUpdate = false;
|
||||
return { type: 'adjustGeneralMeta', ok: false, reason: 'rank update failed' };
|
||||
}
|
||||
return { type: 'adjustGeneralMeta', ok: true, processed: 1, missing: 0 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -404,12 +390,12 @@ describe('tournament router permissions and mutations', () => {
|
||||
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
|
||||
|
||||
expect(transport.gold.get(general.id)).toBe(1_800);
|
||||
expect(transport.commands.filter((command) => command.type === 'adjustGeneralResources')).toHaveLength(1);
|
||||
expect(transport.commands.filter((command) => command.type === 'tournamentAdjustGeneral')).toHaveLength(1);
|
||||
expect(transport.commands).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'adjustGeneralResources',
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: 'http:tournament-join:tournamentJoin:resources',
|
||||
reason: 'tournamentJoin',
|
||||
userId: 'user-1',
|
||||
})
|
||||
);
|
||||
expect(outerApiTransaction).not.toHaveBeenCalled();
|
||||
@@ -459,9 +445,12 @@ describe('tournament router permissions and mutations', () => {
|
||||
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
|
||||
expect(transport.gold.get(general.id)).toBe(1_936);
|
||||
expect(transport.commands).toContainEqual({
|
||||
type: 'adjustGeneralResources',
|
||||
type: 'tournamentAdjustGeneral',
|
||||
userId: 'user-1',
|
||||
reason: 'tournamentJoin',
|
||||
adjustments: [{ generalId: general.id, goldDelta: -64, minGoldAfter: 0 }],
|
||||
generalId: general.id,
|
||||
goldDelta: -64,
|
||||
minGoldAfter: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -534,21 +523,96 @@ describe('tournament router permissions and mutations', () => {
|
||||
expect((await caller.tournament.getBettingSummary()).myAmount).toBe(600);
|
||||
expect(transport.commands).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'adjustGeneralResources',
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: 'http:suspended-tournament-bet:tournamentBet:resources',
|
||||
reason: 'tournamentBet',
|
||||
userId: 'user-1',
|
||||
})
|
||||
);
|
||||
expect(outerApiTransaction).not.toHaveBeenCalled();
|
||||
expect(transport.commands).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'adjustGeneralMeta',
|
||||
requestId: 'http:suspended-tournament-bet:tournamentBet:rank',
|
||||
reason: 'tournamentBet',
|
||||
type: 'tournamentAdjustGeneral',
|
||||
userId: 'user-1',
|
||||
betGoldDelta: 600,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the per-user cap across possession and separates a previous owner's bets", async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
const general = buildGeneral(1, 'user-2', 3_000);
|
||||
transport.gold.set(general.id, general.gold);
|
||||
await setTournamentFixture(redis, {
|
||||
stage: 6,
|
||||
phase: 0,
|
||||
type: 0,
|
||||
auto: true,
|
||||
openYear: 193,
|
||||
openMonth: 1,
|
||||
termSeconds: 60,
|
||||
nextAt: '2026-07-26T01:00:00.000Z',
|
||||
bettingCloseAt: '2099-01-01T00:00:00.000Z',
|
||||
});
|
||||
await redis.set(
|
||||
'sammo:che:default:tournament:betting',
|
||||
JSON.stringify([
|
||||
{ generalId: 1, userId: 'user-1', targetId: 11, amount: 900 },
|
||||
{ generalId: 99, userId: 'user-2', targetId: 12, amount: 800 },
|
||||
{ generalId: 98, userId: null, targetId: 11, amount: 100 },
|
||||
])
|
||||
);
|
||||
const caller = appRouter.createCaller(
|
||||
buildContext({ redis, transport, generals: [general], userId: 'user-2' })
|
||||
);
|
||||
expect(await caller.tournament.getBettingSummary()).toMatchObject({
|
||||
myAmount: 800,
|
||||
myTotals: { 12: 800 },
|
||||
totalAmount: 1_800,
|
||||
});
|
||||
await expect(caller.tournament.placeBet({ targetId: 11, amount: 300 })).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
expect(transport.commands).toHaveLength(0);
|
||||
await expect(caller.tournament.placeBet({ targetId: 11, amount: 200 })).resolves.toEqual({ ok: true });
|
||||
expect(await caller.tournament.getBettingSummary()).toMatchObject({
|
||||
myAmount: 1_000,
|
||||
myTotals: { 11: 200, 12: 800 },
|
||||
});
|
||||
expect(transport.gold.get(1)).toBe(2_800);
|
||||
});
|
||||
|
||||
it('does not infer a legacy bet actor from the current general owner', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
const general = buildGeneral(1, 'user-2', 3_000);
|
||||
transport.gold.set(1, 3_000);
|
||||
await setTournamentFixture(redis, {
|
||||
stage: 6,
|
||||
phase: 0,
|
||||
type: 0,
|
||||
auto: true,
|
||||
openYear: 193,
|
||||
openMonth: 1,
|
||||
termSeconds: 60,
|
||||
nextAt: '2026-07-26T01:00:00.000Z',
|
||||
bettingCloseAt: '2099-01-01T00:00:00.000Z',
|
||||
});
|
||||
await redis.set(
|
||||
'sammo:che:default:tournament:betting',
|
||||
JSON.stringify([{ generalId: 1, targetId: 11, amount: 900 }])
|
||||
);
|
||||
const caller = appRouter.createCaller(
|
||||
buildContext({ redis, transport, generals: [general], userId: 'user-2' })
|
||||
);
|
||||
expect(await caller.tournament.getBettingSummary()).toMatchObject({ myAmount: 0, totalAmount: 900 });
|
||||
await expect(caller.tournament.placeBet({ targetId: 11, amount: 10 })).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
expect(transport.commands).toHaveLength(0);
|
||||
expect(transport.gold.get(1)).toBe(3_000);
|
||||
});
|
||||
|
||||
it('rejects a tournament bet during reconciliation without debiting gold', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
@@ -796,7 +860,7 @@ describe('tournament router permissions and mutations', () => {
|
||||
expect(snapshot.sourceRevision).toBe('41');
|
||||
});
|
||||
|
||||
it('refunds gold when the tournament bet rank update fails', async () => {
|
||||
it('does not debit gold when the atomic tournament adjustment is rejected', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
const general = buildGeneral(1, 'user-1', 2_000);
|
||||
@@ -818,7 +882,7 @@ describe('tournament router permissions and mutations', () => {
|
||||
);
|
||||
|
||||
await expect(caller.tournament.placeBet({ targetId: 11, amount: 100 })).rejects.toMatchObject({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
expect(transport.gold.get(general.id)).toBe(2_000);
|
||||
await expect(caller.tournament.getBettingSummary()).resolves.toMatchObject({
|
||||
|
||||
@@ -53,7 +53,15 @@ const isRecord = (value: unknown): value is Record<string, unknown> => typeof va
|
||||
|
||||
const createNoopDaemonTransport = (): TurnDaemonTransport => ({
|
||||
sendCommand: async () => 'ok',
|
||||
requestCommand: async () => null,
|
||||
requestCommand: async (command) =>
|
||||
command.type === 'tournamentSeedNpcBets'
|
||||
? {
|
||||
type: command.type,
|
||||
ok: true,
|
||||
processedGeneralIds: command.bets.map((bet) => bet.generalId),
|
||||
skippedGeneralIds: [],
|
||||
}
|
||||
: null,
|
||||
requestStatus: async () => null,
|
||||
});
|
||||
|
||||
@@ -440,10 +448,10 @@ describe('tournament worker (in-memory)', () => {
|
||||
const commands: TurnDaemonCommand[] = [];
|
||||
const daemonTransport: TurnDaemonTransport = {
|
||||
...createNoopDaemonTransport(),
|
||||
sendCommand: async (command) => {
|
||||
requestCommand: async (command) => {
|
||||
expect((await store.getState())?.stage).toBe(5);
|
||||
commands.push(command);
|
||||
return 'ok';
|
||||
return createNoopDaemonTransport().requestCommand(command);
|
||||
},
|
||||
};
|
||||
const opened = await applyPreBattleStage(store, prisma, state, 'opening-seed', daemonTransport);
|
||||
@@ -455,21 +463,14 @@ describe('tournament worker (in-memory)', () => {
|
||||
expect(new Set(bets.slice(1).map((bet) => bet.targetId)).size).toBeGreaterThan(1);
|
||||
expect(commands).toEqual([
|
||||
{
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: 'tournament:123:npc-bet:resources',
|
||||
reason: 'tournamentNpcBet',
|
||||
adjustments: [3, 4, 5, 6, 7].map((generalId) => ({ generalId, goldDelta: -amount })),
|
||||
},
|
||||
{
|
||||
type: 'adjustGeneralMeta',
|
||||
requestId: 'tournament:123:npc-bet:meta',
|
||||
reason: 'tournamentNpcBet',
|
||||
adjustments: [3, 4, 5, 6, 7].map((generalId) => ({ generalId, metaDelta: { betgold: amount } })),
|
||||
type: 'tournamentSeedNpcBets',
|
||||
requestId: 'tournament:123:npc-bet:atomic',
|
||||
bets: [3, 4, 5, 6, 7].map((generalId) => ({ generalId, amount })),
|
||||
},
|
||||
]);
|
||||
await seedNpcBets({ prisma, store, state: opened, baseSeed: 'opening-seed', daemonTransport });
|
||||
expect(await store.getBettingEntries()).toEqual(bets);
|
||||
expect(commands).toHaveLength(2);
|
||||
expect(commands).toHaveLength(1);
|
||||
// A fresh projection with the same seed and DB inputs has the same choices.
|
||||
await store.setBettingEntries([bets[0]!]);
|
||||
await seedNpcBets({
|
||||
@@ -482,6 +483,32 @@ describe('tournament worker (in-memory)', () => {
|
||||
expect(await store.getBettingEntries()).toEqual(bets);
|
||||
});
|
||||
|
||||
it('publishes only NPC bets acknowledged after the current-owner check', async () => {
|
||||
const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-owned-after-plan'));
|
||||
const state = createTournamentState({
|
||||
stage: 5,
|
||||
bettingId: 789,
|
||||
npcBettingPlan: [
|
||||
{ generalId: 3, userId: null, targetId: 11, amount: 10 },
|
||||
{ generalId: 4, userId: null, targetId: 12, amount: 10 },
|
||||
],
|
||||
});
|
||||
await store.setState(state);
|
||||
await store.setMatches([{ id: 1, stage: 7, roundIndex: 0, attackerId: 11, defenderId: 12 }]);
|
||||
const transport: TurnDaemonTransport = {
|
||||
...createNoopDaemonTransport(),
|
||||
requestCommand: async (command) => {
|
||||
expect(command.type).toBe('tournamentSeedNpcBets');
|
||||
expect((await store.getState())?.stage).toBe(5);
|
||||
expect(await store.getBettingEntries()).toEqual([]);
|
||||
return { type: 'tournamentSeedNpcBets', ok: true, processedGeneralIds: [4], skippedGeneralIds: [3] };
|
||||
},
|
||||
};
|
||||
const opened = await applyPreBattleStage(store, createPrismaMock({}), state, 'seed', transport);
|
||||
expect(opened.stage).toBe(6);
|
||||
expect(await store.getBettingEntries()).toEqual([{ generalId: 4, userId: null, targetId: 12, amount: 10 }]);
|
||||
});
|
||||
|
||||
it('retries an opening query failure without advancing stage or changing the betting identity', async () => {
|
||||
const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-opening-retry'));
|
||||
const state = createTournamentState({ stage: 5 });
|
||||
@@ -509,7 +536,7 @@ describe('tournament worker (in-memory)', () => {
|
||||
expect(await store.getBettingEntries()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reuses durable command identities after a partial enqueue failure', async () => {
|
||||
it('reuses durable command identities after an uncertain atomic command response', async () => {
|
||||
const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-enqueue-retry'));
|
||||
const state = createTournamentState({ stage: 5, bettingId: 456 });
|
||||
await store.setState(state);
|
||||
@@ -523,15 +550,15 @@ describe('tournament worker (in-memory)', () => {
|
||||
let fail = true;
|
||||
const transport: TurnDaemonTransport = {
|
||||
...createNoopDaemonTransport(),
|
||||
sendCommand: async (command) => {
|
||||
requestCommand: async (command) => {
|
||||
commands.push(command);
|
||||
if (command.type === 'adjustGeneralMeta' && fail) {
|
||||
if (command.type === 'tournamentSeedNpcBets' && fail) {
|
||||
fail = false;
|
||||
// The accepted debit may already have committed before retry.
|
||||
prisma.general.findMany = async () => [];
|
||||
throw new Error('enqueue unavailable');
|
||||
}
|
||||
return 'ok';
|
||||
return createNoopDaemonTransport().requestCommand(command);
|
||||
},
|
||||
};
|
||||
await expect(applyPreBattleStage(store, prisma, state, 'seed', transport)).rejects.toThrow(
|
||||
@@ -539,16 +566,14 @@ describe('tournament worker (in-memory)', () => {
|
||||
);
|
||||
expect(await store.getState()).toMatchObject({
|
||||
stage: 5,
|
||||
npcBettingPlan: [{ generalId: 3, targetId: expect.any(Number), amount: 10 }],
|
||||
npcBettingPlan: [{ generalId: 3, userId: null, targetId: expect.any(Number), amount: 10 }],
|
||||
});
|
||||
expect(await store.getBettingEntries()).toEqual([]);
|
||||
await applyPreBattleStage(store, prisma, (await store.getState())!, 'seed', transport);
|
||||
expect(commands.slice(2)).toEqual(commands.slice(0, 2));
|
||||
expect(commands.slice(1)).toEqual(commands.slice(0, 1));
|
||||
expect(commands.map((command) => command.requestId)).toEqual([
|
||||
'tournament:456:npc-bet:resources',
|
||||
'tournament:456:npc-bet:meta',
|
||||
'tournament:456:npc-bet:resources',
|
||||
'tournament:456:npc-bet:meta',
|
||||
'tournament:456:npc-bet:atomic',
|
||||
'tournament:456:npc-bet:atomic',
|
||||
]);
|
||||
expect(await store.getBettingEntries()).toHaveLength(1);
|
||||
});
|
||||
@@ -577,7 +602,10 @@ describe('tournament worker (in-memory)', () => {
|
||||
commands.push(command);
|
||||
return 'ok';
|
||||
},
|
||||
requestCommand: async () => null,
|
||||
requestCommand: async (command) => {
|
||||
commands.push(command);
|
||||
return createNoopDaemonTransport().requestCommand(command);
|
||||
},
|
||||
requestStatus: async () => null,
|
||||
};
|
||||
|
||||
@@ -598,11 +626,8 @@ describe('tournament worker (in-memory)', () => {
|
||||
expect(rankedGeneralIds.size).toBeGreaterThanOrEqual(10);
|
||||
expect(commands).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'adjustGeneralMeta',
|
||||
reason: 'tournamentNpcBet',
|
||||
adjustments: expect.arrayContaining([
|
||||
expect.objectContaining({ metaDelta: { betgold: expect.any(Number) } }),
|
||||
]),
|
||||
type: 'tournamentSeedNpcBets',
|
||||
bets: expect.arrayContaining([expect.objectContaining({ amount: expect.any(Number) })]),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": false
|
||||
"composite": false,
|
||||
"paths": {
|
||||
"@sammo-ts/game-api": ["./src/index.ts"],
|
||||
"@sammo-ts/game-api/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -120,7 +120,13 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
||||
const claimCoordinate = await readInputEventClockCoordinate(transaction);
|
||||
const world = await transaction.worldState.findFirst({
|
||||
orderBy: { id: 'asc' },
|
||||
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true, clockTick: true, meta: true },
|
||||
select: {
|
||||
clockPhase: true,
|
||||
clockRevision: true,
|
||||
deadlineGeneration: true,
|
||||
clockTick: true,
|
||||
meta: true,
|
||||
},
|
||||
});
|
||||
// 가오픈도 장수 생성·삭제·거병·예약 등 사용자 명령은 처리한다.
|
||||
// 자동 턴의 RUNNING/MANUAL gate는 TurnDaemonLifecycle이 별도로 지킨다.
|
||||
@@ -184,7 +190,7 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
||||
OR (${postUnificationSurvey} AND "event_type" = 'voteReward')
|
||||
OR (
|
||||
${suspendedTournamentBetCommand}
|
||||
AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta')
|
||||
AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta', 'tournamentAdjustGeneral')
|
||||
AND "payload" ->> 'reason' IN ('tournamentBet', 'tournamentBetRollback')
|
||||
)
|
||||
OR (
|
||||
|
||||
@@ -269,6 +269,21 @@ const zSetNpcPolicy = z.object({
|
||||
]),
|
||||
});
|
||||
|
||||
const zTournamentAdjustGeneral = z.object({
|
||||
type: z.literal('tournamentAdjustGeneral'),
|
||||
reason: z.enum(['tournamentJoin', 'tournamentJoinRollback', 'tournamentBet', 'tournamentBetRollback']),
|
||||
userId: z.string().min(1),
|
||||
generalId: zSafeInteger.positive(),
|
||||
goldDelta: zFiniteNumber,
|
||||
betGoldDelta: zFiniteNumber.optional(),
|
||||
minGoldAfter: zFiniteNumber.nonnegative(),
|
||||
});
|
||||
|
||||
const zTournamentSeedNpcBets = z.object({
|
||||
type: z.literal('tournamentSeedNpcBets'),
|
||||
bets: z.array(z.object({ generalId: zSafeInteger.positive(), amount: zSafeInteger.positive() })).min(1),
|
||||
});
|
||||
|
||||
const zAdjustGeneralResources = z.object({
|
||||
type: z.literal('adjustGeneralResources'),
|
||||
reason: z.string().optional(),
|
||||
@@ -737,6 +752,16 @@ const normalizeSetNpcPolicy: CommandNormalizer<'setNpcPolicy'> = (envelope) => {
|
||||
return { ...command, requestId: envelope.requestId };
|
||||
};
|
||||
|
||||
const normalizeTournamentAdjustGeneral: CommandNormalizer<'tournamentAdjustGeneral'> = (envelope) => {
|
||||
const command = parseWith(zTournamentAdjustGeneral, envelope.command);
|
||||
return command ? { ...command, requestId: envelope.requestId } : null;
|
||||
};
|
||||
|
||||
const normalizeTournamentSeedNpcBets: CommandNormalizer<'tournamentSeedNpcBets'> = (envelope) => {
|
||||
const command = parseWith(zTournamentSeedNpcBets, envelope.command);
|
||||
return command ? { ...command, requestId: envelope.requestId } : null;
|
||||
};
|
||||
|
||||
const normalizeAdjustGeneralResources: CommandNormalizer<'adjustGeneralResources'> = (envelope) => {
|
||||
const command = parseWith(zAdjustGeneralResources, envelope.command);
|
||||
if (!command) {
|
||||
@@ -905,6 +930,8 @@ const normalizers: CommandNormalizerMap = {
|
||||
voteReward: normalizeVoteReward,
|
||||
setNationSetting: normalizeSetNationSetting,
|
||||
setNpcPolicy: normalizeSetNpcPolicy,
|
||||
tournamentAdjustGeneral: normalizeTournamentAdjustGeneral,
|
||||
tournamentSeedNpcBets: normalizeTournamentSeedNpcBets,
|
||||
adjustGeneralResources: normalizeAdjustGeneralResources,
|
||||
adjustGeneralMeta: normalizeAdjustGeneralMeta,
|
||||
tournamentMatchResult: normalizeTournamentMatchResult,
|
||||
|
||||
@@ -175,6 +175,7 @@ const requireCommandDatabase = (ctx: CommandHandlerContext): DatabaseClient => {
|
||||
};
|
||||
|
||||
const ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST = [
|
||||
'tournamentAdjustGeneral',
|
||||
'troopCreate',
|
||||
'troopJoin',
|
||||
'troopExit',
|
||||
@@ -627,11 +628,84 @@ async function handleSetNpcPolicy(
|
||||
return applyNpcPolicyMutation({ world: ctx.world, command, acceptedAt });
|
||||
}
|
||||
|
||||
async function handleTournamentAdjustGeneral(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'tournamentAdjustGeneral' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const general = ctx.world.getGeneralById(command.generalId);
|
||||
// Ref의 참가/베팅 actor는 로그인 장수다. 큐 대기 중 빙의 소유자가 바뀐 경우도 막는다.
|
||||
if (!general || general.userId !== command.userId) {
|
||||
return { type: command.type, ok: false, code: 'FORBIDDEN', reason: '장수의 현재 소유자가 일치하지 않습니다.' };
|
||||
}
|
||||
const gold = general.gold + command.goldDelta;
|
||||
if (!Number.isFinite(gold) || gold < command.minGoldAfter) {
|
||||
return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: '금이 부족합니다.' };
|
||||
}
|
||||
const meta = { ...general.meta };
|
||||
if (command.betGoldDelta !== undefined) {
|
||||
const current = typeof meta.betgold === 'number' ? meta.betgold : 0;
|
||||
const betgold = current + command.betGoldDelta;
|
||||
if (!Number.isFinite(betgold) || betgold < 0) {
|
||||
return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: '베팅 기록이 일치하지 않습니다.' };
|
||||
}
|
||||
meta.betgold = betgold;
|
||||
}
|
||||
// 금과 통계는 하나의 ENGINE event에서 함께 commit하거나 함께 거부한다.
|
||||
ctx.world.updateGeneral(command.generalId, { gold, meta });
|
||||
return { type: command.type, ok: true, generalId: command.generalId };
|
||||
}
|
||||
|
||||
async function handleTournamentSeedNpcBets(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'tournamentSeedNpcBets' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
if (ctx.commandDb) {
|
||||
const event = command.requestId
|
||||
? await ctx.commandDb.inputEvent.findUnique({
|
||||
where: { requestId: command.requestId },
|
||||
select: { actorUserId: true, target: true, eventType: true },
|
||||
})
|
||||
: null;
|
||||
if (!event || event.actorUserId !== null || event.target !== 'ENGINE' || event.eventType !== command.type) {
|
||||
return { type: command.type, ok: false, reason: 'NPC 베팅 입력 이벤트가 일치하지 않습니다.' };
|
||||
}
|
||||
}
|
||||
if (new Set(command.bets.map((bet) => bet.generalId)).size !== command.bets.length) {
|
||||
return { type: command.type, ok: false, reason: 'NPC 베팅 대상이 중복됩니다.' };
|
||||
}
|
||||
const processedGeneralIds: number[] = [];
|
||||
const skippedGeneralIds: number[] = [];
|
||||
for (const bet of command.bets) {
|
||||
const general = ctx.world.getGeneralById(bet.generalId);
|
||||
// 계획 이후 유저가 빙의한 장수는 NPC 비용·통계를 변경하지 않는다.
|
||||
if (!general || general.userId != null || general.npcState < 2 || general.gold - bet.amount < 500) {
|
||||
skippedGeneralIds.push(bet.generalId);
|
||||
continue;
|
||||
}
|
||||
const current = typeof general.meta.betgold === 'number' ? general.meta.betgold : 0;
|
||||
ctx.world.updateGeneral(bet.generalId, {
|
||||
gold: general.gold - bet.amount,
|
||||
meta: { ...general.meta, betgold: current + bet.amount },
|
||||
});
|
||||
processedGeneralIds.push(bet.generalId);
|
||||
}
|
||||
return { type: command.type, ok: true, processedGeneralIds, skippedGeneralIds };
|
||||
}
|
||||
|
||||
const isLegacyTournamentAdjustment = (reason: string | undefined): boolean =>
|
||||
reason !== undefined &&
|
||||
['tournamentJoin', 'tournamentJoinRollback', 'tournamentBet', 'tournamentBetRollback', 'tournamentNpcBet'].includes(
|
||||
reason
|
||||
);
|
||||
|
||||
async function handleAdjustGeneralResources(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralResources' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world } = ctx;
|
||||
if (isLegacyTournamentAdjustment(command.reason)) {
|
||||
return { type: command.type, ok: false, reason: '소유권 없는 이전 토너먼트 조정 명령은 허용하지 않습니다.' };
|
||||
}
|
||||
if (!command.adjustments || command.adjustments.length === 0) {
|
||||
return { type: 'adjustGeneralResources', ok: false, reason: '조정 대상이 없습니다.' };
|
||||
}
|
||||
@@ -688,6 +762,9 @@ async function handleAdjustGeneralMeta(
|
||||
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralMeta' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world } = ctx;
|
||||
if (isLegacyTournamentAdjustment(command.reason)) {
|
||||
return { type: command.type, ok: false, reason: '소유권 없는 이전 토너먼트 조정 명령은 허용하지 않습니다.' };
|
||||
}
|
||||
if (!command.adjustments || command.adjustments.length === 0) {
|
||||
return {
|
||||
type: 'adjustGeneralMeta',
|
||||
@@ -2004,8 +2081,10 @@ async function handleSetMySetting(
|
||||
const scenarioEffect = world.getScenarioConfig().environment.scenarioEffect;
|
||||
const ignoresPenalty = isDefenceTrainPenaltyWaivedByScenarioEffect(scenarioEffect);
|
||||
const constValues = asRecord(world.getScenarioConfig().const);
|
||||
const maxTrain = readMetaNumber(constValues, 'maxTrainByWar', 100);
|
||||
const maxAtmos = readMetaNumber(constValues, 'maxAtmosByWar', 100);
|
||||
// Ref GameConstBase의 전투 상한을 사용한다. 명령 상한(100)으로 제한하면
|
||||
// 전투에서 오른 훈련·사기가 수비끔 전환 한 번에 소실된다.
|
||||
const maxTrain = readMetaNumber(constValues, 'maxTrainByWar', 110);
|
||||
const maxAtmos = readMetaNumber(constValues, 'maxAtmosByWar', 150);
|
||||
const trainDelta = ignoresPenalty ? 0 : -3;
|
||||
const atmosDelta = ignoresPenalty ? 0 : -6;
|
||||
nextTrain = Math.max(20, Math.min(maxTrain, general.train + trainDelta));
|
||||
@@ -3267,6 +3346,13 @@ export const createTurnDaemonCommandHandler = (options: {
|
||||
handleSetNationSetting(ctx, command as Extract<TurnDaemonCommand, { type: 'setNationSetting' }>),
|
||||
setNpcPolicy: (command) =>
|
||||
handleSetNpcPolicy(ctx, command as Extract<TurnDaemonCommand, { type: 'setNpcPolicy' }>),
|
||||
tournamentAdjustGeneral: (command) =>
|
||||
handleTournamentAdjustGeneral(
|
||||
ctx,
|
||||
command as Extract<TurnDaemonCommand, { type: 'tournamentAdjustGeneral' }>
|
||||
),
|
||||
tournamentSeedNpcBets: (command) =>
|
||||
handleTournamentSeedNpcBets(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentSeedNpcBets' }>),
|
||||
adjustGeneralResources: (command) =>
|
||||
handleAdjustGeneralResources(
|
||||
ctx,
|
||||
|
||||
@@ -7,6 +7,16 @@ import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js';
|
||||
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
||||
|
||||
const buildActorBoundCommands = (userId = 'old-owner'): TurnDaemonCommand[] => [
|
||||
{
|
||||
type: 'tournamentAdjustGeneral',
|
||||
reason: 'tournamentBet',
|
||||
requestId: 'tournamentAdjustGeneral',
|
||||
userId,
|
||||
generalId: 7,
|
||||
goldDelta: -100,
|
||||
betGoldDelta: 100,
|
||||
minGoldAfter: 500,
|
||||
},
|
||||
{ type: 'troopCreate', requestId: 'troopCreate', userId, generalId: 7, troopName: '백마대' },
|
||||
{ type: 'troopJoin', requestId: 'troopJoin', userId, generalId: 7, troopId: 8 },
|
||||
{ type: 'troopExit', requestId: 'troopExit', userId, generalId: 7 },
|
||||
|
||||
@@ -274,13 +274,22 @@ integration('database command queue', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('stores a stale-owner rejection once and never redispatches the exact durable request', async () => {
|
||||
const requestId = 'integration:engine:stale-owner-replay';
|
||||
const command: TurnDaemonCommand = {
|
||||
type: 'vacation',
|
||||
it.each(['vacation', 'tournamentAdjustGeneral'] as const)(
|
||||
'stores a stale-owner %s rejection once and never redispatches it',
|
||||
async (type) => {
|
||||
const requestId = `integration:engine:stale-owner-replay:${type}`;
|
||||
const command: TurnDaemonCommand =
|
||||
type === 'vacation'
|
||||
? { type, requestId, userId: 'old-owner', generalId: 7 }
|
||||
: {
|
||||
type,
|
||||
reason: 'tournamentBet',
|
||||
requestId,
|
||||
userId: 'old-owner',
|
||||
generalId: 7,
|
||||
goldDelta: -100,
|
||||
betGoldDelta: 100,
|
||||
minGoldAfter: 500,
|
||||
};
|
||||
await db.inputEvent.create({
|
||||
data: {
|
||||
@@ -336,7 +345,8 @@ integration('database command queue', () => {
|
||||
await expect(new DatabaseTurnDaemonCommandQueue(db).drain()).resolves.toEqual([]);
|
||||
expect(handle).toHaveBeenCalledOnce();
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['PREOPEN', 'RUNNING', 'MANUAL', 'SUSPENDED', 'RECONCILING', 'COMPLETED'])(
|
||||
'handles pre-opening user commands in %s without treating them as scheduled turns',
|
||||
@@ -630,6 +640,8 @@ integration('database command queue', () => {
|
||||
const metaId = 'integration:engine:suspended-tournament-bet-meta';
|
||||
const rollbackId = 'integration:engine:suspended-tournament-bet-rollback';
|
||||
const unrelatedId = 'integration:engine:suspended-resource-adjustment';
|
||||
const atomicBetId = 'integration:engine:suspended-atomic-bet';
|
||||
const atomicJoinId = 'integration:engine:suspended-atomic-join';
|
||||
await db.inputEvent.createMany({
|
||||
data: [
|
||||
{
|
||||
@@ -679,6 +691,41 @@ integration('database command queue', () => {
|
||||
],
|
||||
});
|
||||
|
||||
await db.inputEvent.createMany({
|
||||
data: [
|
||||
{
|
||||
requestId: atomicBetId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'tournamentAdjustGeneral',
|
||||
actorUserId: 'user-7',
|
||||
payload: {
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: atomicBetId,
|
||||
userId: 'user-7',
|
||||
generalId: 7,
|
||||
reason: 'tournamentBet',
|
||||
goldDelta: -100,
|
||||
betGoldDelta: 100,
|
||||
minGoldAfter: 500,
|
||||
},
|
||||
},
|
||||
{
|
||||
requestId: atomicJoinId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'tournamentAdjustGeneral',
|
||||
actorUserId: 'user-7',
|
||||
payload: {
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: atomicJoinId,
|
||||
userId: 'user-7',
|
||||
generalId: 7,
|
||||
reason: 'tournamentJoin',
|
||||
goldDelta: -200,
|
||||
minGoldAfter: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const queue = new DatabaseTurnDaemonCommandQueue(db);
|
||||
await expect(queue.drain()).resolves.toEqual([
|
||||
expect.objectContaining({ type: 'adjustGeneralResources', requestId: resourceId, reason: 'tournamentBet' }),
|
||||
@@ -688,7 +735,15 @@ integration('database command queue', () => {
|
||||
requestId: rollbackId,
|
||||
reason: 'tournamentBetRollback',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: atomicBetId,
|
||||
reason: 'tournamentBet',
|
||||
}),
|
||||
]);
|
||||
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId: atomicJoinId } })).resolves.toMatchObject({
|
||||
status: 'PENDING',
|
||||
});
|
||||
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId: resourceId } })).resolves.toMatchObject({
|
||||
status: 'PROCESSING',
|
||||
processingGameTick: 321n,
|
||||
|
||||
@@ -87,7 +87,11 @@ const buildGeneral = (overrides: Partial<TurnGeneral> = {}): TurnGeneral => ({
|
||||
|
||||
const buildWorld = (
|
||||
general = buildGeneral(),
|
||||
options: { autorunLimit?: boolean; scenarioEffect?: ScenarioEffectKey | null } = {}
|
||||
options: {
|
||||
autorunLimit?: boolean;
|
||||
scenarioEffect?: ScenarioEffectKey | null;
|
||||
scenarioConst?: Record<string, unknown>;
|
||||
} = {}
|
||||
) => {
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
@@ -126,7 +130,7 @@ const buildWorld = (
|
||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 65 },
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: { maxTrainByWar: 100, maxAtmosByWar: 100 },
|
||||
const: options.scenarioConst ?? {},
|
||||
environment: {
|
||||
mapName: 'test',
|
||||
unitSet: 'test',
|
||||
@@ -277,6 +281,53 @@ describe('my information world commands', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ train: 110, atmos: 150, expectedTrain: 107, expectedAtmos: 144 },
|
||||
{ train: 104, atmos: 107, expectedTrain: 101, expectedAtmos: 101 },
|
||||
{ train: 90, atmos: 90, expectedTrain: 87, expectedAtmos: 84 },
|
||||
{ train: 21, atmos: 25, expectedTrain: 20, expectedAtmos: 20 },
|
||||
{
|
||||
train: 140,
|
||||
atmos: 180,
|
||||
scenarioConst: { maxTrainByWar: 120, maxAtmosByWar: 160 },
|
||||
expectedTrain: 120,
|
||||
expectedAtmos: 160,
|
||||
},
|
||||
{
|
||||
train: 110,
|
||||
atmos: 150,
|
||||
scenarioConst: { maxTrainByWar: 100, maxAtmosByWar: 100 },
|
||||
expectedTrain: 100,
|
||||
expectedAtmos: 100,
|
||||
},
|
||||
])('applies war caps to no-defence penalties: $train/$atmos', async (testCase) => {
|
||||
const fixture = buildWorld(buildGeneral({ train: testCase.train, atmos: testCase.atmos }), {
|
||||
scenarioConst: testCase.scenarioConst,
|
||||
});
|
||||
const command = {
|
||||
type: 'setMySetting' as const,
|
||||
userId: 'user-7',
|
||||
generalId: 7,
|
||||
settings: { defence_train: 999 },
|
||||
};
|
||||
await expect(fixture.handler.handle(command)).resolves.toMatchObject({ ok: true });
|
||||
const expected = {
|
||||
train: testCase.expectedTrain,
|
||||
atmos: testCase.expectedAtmos,
|
||||
meta: { defence_train: 999, myset: 2 },
|
||||
};
|
||||
expect(fixture.world.getGeneralById(7)).toMatchObject(expected);
|
||||
|
||||
// 같은 설정의 재저장과 수비 재개는 훈련·사기를 다시 차감하지 않는다.
|
||||
await fixture.handler.handle(command);
|
||||
expect(fixture.world.getGeneralById(7)).toMatchObject(expected);
|
||||
await fixture.handler.handle({ ...command, settings: { defence_train: 90 } });
|
||||
expect(fixture.world.getGeneralById(7)).toMatchObject({
|
||||
...expected,
|
||||
meta: { defence_train: 90, myset: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
'event_UnlimitedDefenceThresholdChange',
|
||||
'event_StrongAttacker',
|
||||
@@ -284,14 +335,18 @@ describe('my information world commands', () => {
|
||||
] satisfies ScenarioEffectKey[])(
|
||||
'preserves the %s scenario that waives the no-defence penalty',
|
||||
async (scenarioEffect) => {
|
||||
const fixture = buildWorld(buildGeneral(), { scenarioEffect });
|
||||
const fixture = buildWorld(buildGeneral({ train: 110, atmos: 150 }), { scenarioEffect });
|
||||
await fixture.handler.handle({
|
||||
type: 'setMySetting',
|
||||
userId: 'user-7',
|
||||
generalId: 7,
|
||||
settings: { defence_train: 999 },
|
||||
});
|
||||
expect(fixture.world.getGeneralById(7)).toMatchObject({ train: 90, atmos: 90 });
|
||||
expect(fixture.world.getGeneralById(7)).toMatchObject({
|
||||
train: 110,
|
||||
atmos: 150,
|
||||
meta: { defence_train: 999, myset: 2 },
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -79,6 +79,83 @@ const buildWorld = (
|
||||
return new InMemoryTurnWorld(state, snapshot, { schedule });
|
||||
};
|
||||
|
||||
describe('tournament mutation ownership', () => {
|
||||
it('checks the current owner and commits gold plus bet statistics together', async () => {
|
||||
const general = { ...buildGeneral(1), userId: 'owner' };
|
||||
const world = buildWorld([general]);
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
const command = {
|
||||
type: 'tournamentAdjustGeneral' as const,
|
||||
reason: 'tournamentBet' as const,
|
||||
userId: 'owner',
|
||||
generalId: 1,
|
||||
goldDelta: -100,
|
||||
betGoldDelta: 100,
|
||||
minGoldAfter: 500,
|
||||
};
|
||||
await expect(handler.handle(command)).resolves.toEqual({ type: command.type, ok: true, generalId: 1 });
|
||||
expect(world.getGeneralById(1)).toMatchObject({ gold: 900, meta: { betgold: 100 } });
|
||||
const before = structuredClone(world.getGeneralById(1));
|
||||
await expect(handler.handle({ ...command, goldDelta: -500 })).resolves.toMatchObject({
|
||||
ok: false,
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
expect(world.getGeneralById(1)).toEqual(before);
|
||||
world.updateGeneral(1, { userId: 'new-owner' });
|
||||
const transferred = structuredClone(world.getGeneralById(1));
|
||||
for (const goldDelta of [-100, 100]) {
|
||||
await expect(handler.handle({ ...command, goldDelta })).resolves.toMatchObject({
|
||||
ok: false,
|
||||
code: 'FORBIDDEN',
|
||||
});
|
||||
expect(world.getGeneralById(1)).toEqual(transferred);
|
||||
}
|
||||
});
|
||||
|
||||
it('skips possessed NPCs and commits only eligible NPC fees and statistics', async () => {
|
||||
const world = buildWorld([
|
||||
{ ...buildGeneral(1, {}, 1), userId: 'new-owner' },
|
||||
buildGeneral(2, {}, 2),
|
||||
{ ...buildGeneral(3, {}, 2), gold: 509 },
|
||||
]);
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
const possessed = structuredClone(world.getGeneralById(1));
|
||||
await expect(
|
||||
handler.handle({
|
||||
type: 'tournamentSeedNpcBets',
|
||||
bets: [1, 2, 3].map((generalId) => ({ generalId, amount: 10 })),
|
||||
})
|
||||
).resolves.toEqual({
|
||||
type: 'tournamentSeedNpcBets',
|
||||
ok: true,
|
||||
processedGeneralIds: [2],
|
||||
skippedGeneralIds: [1, 3],
|
||||
});
|
||||
expect(world.getGeneralById(1)).toEqual(possessed);
|
||||
expect(world.getGeneralById(2)).toMatchObject({ gold: 990, meta: { betgold: 10 } });
|
||||
expect(world.getGeneralById(3)).toMatchObject({ gold: 509, meta: { killturn: 24 } });
|
||||
});
|
||||
|
||||
it.each(['tournamentJoin', 'tournamentJoinRollback', 'tournamentBet', 'tournamentBetRollback', 'tournamentNpcBet'])(
|
||||
'rejects actorless legacy %s commands before mutation',
|
||||
async (reason) => {
|
||||
const world = buildWorld([{ ...buildGeneral(1), userId: 'new-owner' }]);
|
||||
const before = structuredClone(world.getGeneralById(1));
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
for (const command of [
|
||||
{ type: 'adjustGeneralResources' as const, reason, adjustments: [{ generalId: 1, goldDelta: -100 }] },
|
||||
{
|
||||
type: 'adjustGeneralMeta' as const,
|
||||
reason,
|
||||
adjustments: [{ generalId: 1, metaDelta: { betgold: 100 } }],
|
||||
},
|
||||
])
|
||||
await expect(handler.handle(command)).resolves.toMatchObject({ ok: false });
|
||||
expect(world.getGeneralById(1)).toEqual(before);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('tournament world commands', () => {
|
||||
it('uses the current world develcost for Ref-compatible tournament prizes', () => {
|
||||
const currentWorld = buildWorld([], {
|
||||
@@ -187,7 +264,7 @@ describe('tournament world commands', () => {
|
||||
});
|
||||
|
||||
it('records all four tournament types and NPC betting for at least ten generals', async () => {
|
||||
const generals = Array.from({ length: 12 }, (_, index) => buildGeneral(index + 1));
|
||||
const generals = Array.from({ length: 12 }, (_, index) => ({ ...buildGeneral(index + 1, {}, 2), gold: 2_000 }));
|
||||
const world = buildWorld(generals);
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
|
||||
@@ -204,13 +281,11 @@ describe('tournament world commands', () => {
|
||||
}
|
||||
}
|
||||
await handler.handle({
|
||||
type: 'adjustGeneralMeta',
|
||||
reason: 'tournamentNpcBet',
|
||||
adjustments: generals.map((general) => ({
|
||||
generalId: general.id,
|
||||
metaDelta: { betgold: 1_000 },
|
||||
})),
|
||||
type: 'tournamentSeedNpcBets',
|
||||
bets: generals.map((general) => ({ generalId: general.id, amount: 1_000 })),
|
||||
});
|
||||
// Ref의 betwin 통계 대상은 npcState <= 1이다. 개방 시 NPC 비용 검증과 분리한다.
|
||||
for (const general of generals) world.updateGeneral(general.id, { npcState: 0 });
|
||||
await handler.handle({
|
||||
type: 'tournamentBettingPayout',
|
||||
bettingId: 1,
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": false
|
||||
"composite": false,
|
||||
"paths": {
|
||||
"@sammo-ts/game-engine": ["./src/index.ts"],
|
||||
"@sammo-ts/game-engine/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "../../packages/common" }, { "path": "../../packages/logic" }]
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineConfig } from 'tsdown';
|
||||
import { emitDeclarations } from '../../tools/build-scripts/emit-declarations.mjs';
|
||||
|
||||
export default defineConfig({
|
||||
entry: {
|
||||
@@ -30,9 +31,8 @@ export default defineConfig({
|
||||
},
|
||||
format: 'es',
|
||||
outDir: 'dist',
|
||||
dts: {
|
||||
build: true,
|
||||
},
|
||||
dts: false,
|
||||
hooks: { 'build:done': emitDeclarations },
|
||||
sourcemap: true,
|
||||
target: 'node22',
|
||||
platform: 'node',
|
||||
|
||||
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { expect, test, type Browser, type Page } from '@playwright/test';
|
||||
import { encryptGameSessionToken } from '../../../packages/common/dist/auth/gameToken.js';
|
||||
import { createGamePostgresConnector } from '../../../packages/infra/dist/index.js';
|
||||
import { createGamePostgresConnector, GamePrisma } from '../../../packages/infra/dist/index.js';
|
||||
|
||||
const databaseUrl = process.env.CHIEF_CENTER_LIVE_DATABASE_URL;
|
||||
const gameTokenSecret = process.env.CHIEF_CENTER_LIVE_GAME_SECRET;
|
||||
@@ -174,7 +174,13 @@ test('persists one chief command and exposes it to a normal nation user and anot
|
||||
await transaction.nationTurn.deleteMany({
|
||||
where: { nationId: editor.nationId, officerLevel: editor.officerLevel },
|
||||
});
|
||||
if (originalTurns.length) await transaction.nationTurn.createMany({ data: originalTurns });
|
||||
if (originalTurns.length)
|
||||
await transaction.nationTurn.createMany({
|
||||
data: originalTurns.map((row: (typeof originalTurns)[number]) => ({
|
||||
...row,
|
||||
arg: row.arg ?? GamePrisma.JsonNull,
|
||||
})),
|
||||
});
|
||||
await transaction.nationTurnRevision.deleteMany({
|
||||
where: { nationId: editor.nationId, officerLevel: editor.officerLevel },
|
||||
});
|
||||
@@ -185,8 +191,8 @@ test('persists one chief command and exposes it to a normal nation user and anot
|
||||
userId: viewer.userId,
|
||||
officerLevel: viewer.officerLevel,
|
||||
npcState: viewer.npcState,
|
||||
meta: viewer.meta,
|
||||
penalty: viewer.penalty,
|
||||
meta: viewer.meta ?? GamePrisma.JsonNull,
|
||||
penalty: viewer.penalty ?? GamePrisma.JsonNull,
|
||||
},
|
||||
});
|
||||
await transaction.general.update({
|
||||
@@ -195,8 +201,8 @@ test('persists one chief command and exposes it to a normal nation user and anot
|
||||
userId: otherChief.userId,
|
||||
officerLevel: otherChief.officerLevel,
|
||||
npcState: otherChief.npcState,
|
||||
meta: otherChief.meta,
|
||||
penalty: otherChief.penalty,
|
||||
meta: otherChief.meta ?? GamePrisma.JsonNull,
|
||||
penalty: otherChief.penalty ?? GamePrisma.JsonNull,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { expect, test, type Locator, type Page } from '@playwright/test';
|
||||
import { createGamePostgresConnector } from '../../../packages/infra/dist/index.js';
|
||||
import { createGamePostgresConnector, GamePrisma } from '../../../packages/infra/dist/index.js';
|
||||
|
||||
const bootstrapFile = process.env.COMMAND_PANEL_LIVE_BOOTSTRAP_FILE;
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
@@ -166,14 +166,26 @@ test('reserves every requested general and chief command through Chromium and re
|
||||
} finally {
|
||||
await db.$transaction(async (transaction) => {
|
||||
await transaction.generalTurn.deleteMany({ where: { generalId: general.id } });
|
||||
if (originalGeneralTurns.length) await transaction.generalTurn.createMany({ data: originalGeneralTurns });
|
||||
if (originalGeneralTurns.length)
|
||||
await transaction.generalTurn.createMany({
|
||||
data: originalGeneralTurns.map((row: (typeof originalGeneralTurns)[number]) => ({
|
||||
...row,
|
||||
arg: row.arg ?? GamePrisma.JsonNull,
|
||||
})),
|
||||
});
|
||||
await transaction.generalTurnRevision.deleteMany({ where: { generalId: general.id } });
|
||||
if (originalGeneralRevision)
|
||||
await transaction.generalTurnRevision.create({ data: originalGeneralRevision });
|
||||
await transaction.nationTurn.deleteMany({
|
||||
where: { nationId: general.nationId, officerLevel: general.officerLevel },
|
||||
});
|
||||
if (originalNationTurns.length) await transaction.nationTurn.createMany({ data: originalNationTurns });
|
||||
if (originalNationTurns.length)
|
||||
await transaction.nationTurn.createMany({
|
||||
data: originalNationTurns.map((row: (typeof originalNationTurns)[number]) => ({
|
||||
...row,
|
||||
arg: row.arg ?? GamePrisma.JsonNull,
|
||||
})),
|
||||
});
|
||||
await transaction.nationTurnRevision.deleteMany({
|
||||
where: { nationId: general.nationId, officerLevel: general.officerLevel },
|
||||
});
|
||||
|
||||
@@ -710,6 +710,106 @@ test('trait and injury explanations appear on pointer hover and keyboard focus',
|
||||
await page.screenshot({ path: testInfo.outputPath('directory-trait-injury-tooltips.png'), fullPage: true });
|
||||
});
|
||||
|
||||
for (const width of [1200, 500]) {
|
||||
test(`catalog description line breaks stay safe on hover and focus at ${width}px`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
const warInfo =
|
||||
'[계략] 화계·탈취·파괴·선동 : 성공률 +10%p<br>[전투] 계략 시도 확률 +20%p, 계략 성공 확률 +20%p';
|
||||
const literalMarkup = '<img src=x onerror="alert(1)"><b>설명</b>';
|
||||
const directoryRows = generals.map((general) =>
|
||||
general.id === 10
|
||||
? {
|
||||
...general,
|
||||
specialDomestic: {
|
||||
...general.specialDomestic,
|
||||
info: `상업 특기<br/>수익 증가<BR />매매 효율\r\n${literalMarkup}`,
|
||||
},
|
||||
specialWar: { key: 'che_신산', name: '신산', info: warInfo },
|
||||
}
|
||||
: general
|
||||
);
|
||||
await install(page, 'general', [], [], directoryRows);
|
||||
await page.goto('general-list');
|
||||
await page.reload();
|
||||
const prefix = width <= 600 ? 'card-' : '';
|
||||
const container = page
|
||||
.locator(width <= 600 ? '[data-general-card-id="10"]' : 'tr[data-general-id="10"]')
|
||||
.first();
|
||||
await expect(container).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '갱 신' })).not.toHaveAttribute('aria-busy');
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
await Promise.all(Array.from(document.images, (image) => image.decode().catch(() => undefined)));
|
||||
});
|
||||
const before = await container.boundingBox();
|
||||
const evidence: Record<string, unknown> = { width, url: page.url(), before };
|
||||
for (const [kind, expected] of [
|
||||
['special-domestic', ['상업 특기', '수익 증가', '매매 효율', literalMarkup]],
|
||||
['special-war', warInfo.split('<br>')],
|
||||
] as const) {
|
||||
const trigger = page.locator(`[data-directory-tooltip="${prefix}${kind}-10"]`);
|
||||
const tooltip = trigger.getByRole('tooltip');
|
||||
await page.mouse.move(0, 0);
|
||||
await trigger.hover();
|
||||
await expect(tooltip).toBeVisible();
|
||||
await trigger.focus();
|
||||
await expect(tooltip).toBeVisible();
|
||||
await expect(trigger).toHaveCSS('text-decoration-line', 'none');
|
||||
await expect(trigger).toHaveCSS('outline-style', 'solid');
|
||||
const description = tooltip.locator('.directory-tooltip__description');
|
||||
await expect(description.locator('br')).toHaveCount(expected.length - 1);
|
||||
await expect(description.locator('img, b, script')).toHaveCount(0);
|
||||
expect(await description.innerText()).not.toMatch(/<br\s*\/?\s*>/iu);
|
||||
const metrics = await description.evaluate((element) => {
|
||||
const lines = Array.from(element.childNodes).filter(
|
||||
(node) => node.nodeType === Node.TEXT_NODE && node.textContent?.trim()
|
||||
);
|
||||
return {
|
||||
lines: lines.map((node) => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(node);
|
||||
return { text: node.textContent?.trim(), top: range.getBoundingClientRect().top };
|
||||
}),
|
||||
html: element.outerHTML,
|
||||
};
|
||||
});
|
||||
expect(metrics.lines.map((line) => line.text)).toEqual(expected);
|
||||
for (let index = 1; index < metrics.lines.length; index += 1) {
|
||||
expect(metrics.lines[index]!.top).toBeGreaterThan(metrics.lines[index - 1]!.top);
|
||||
}
|
||||
const geometry = await tooltip.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
font: style.font,
|
||||
background: style.backgroundColor,
|
||||
position: style.position,
|
||||
};
|
||||
});
|
||||
expect(geometry.left).toBeGreaterThanOrEqual(8);
|
||||
expect(geometry.right).toBeLessThanOrEqual(width - 8);
|
||||
expect(geometry.top).toBeGreaterThanOrEqual(0);
|
||||
expect(geometry.bottom).toBeLessThanOrEqual(900);
|
||||
expect(await container.boundingBox()).toEqual(before);
|
||||
evidence[kind] = { ...metrics, geometry };
|
||||
await page.screenshot({ path: testInfo.outputPath(`catalog-${kind}-${width}.png`), fullPage: true });
|
||||
await trigger.blur();
|
||||
}
|
||||
const empty = page.locator(`[data-directory-tooltip="${prefix}special-war-20"]`);
|
||||
await expect(empty).not.toHaveAttribute('tabindex');
|
||||
await expect(empty.getByRole('tooltip')).toHaveCount(0);
|
||||
await writeFile(testInfo.outputPath('catalog-description.json'), JSON.stringify(evidence, null, 2));
|
||||
await writeFile(
|
||||
testInfo.outputPath('catalog-description.html'),
|
||||
await page.locator('main').evaluate((element) => element.outerHTML)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('npc directory reuses the dark sort controls and sorts from a table header', async ({ page }, testInfo) => {
|
||||
await install(page);
|
||||
await page.goto('npc-list');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js';
|
||||
import { devices, expect, test, type Page, type Route } from '@playwright/test';
|
||||
import { mkdir, readFile } from 'node:fs/promises';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
|
||||
@@ -14,6 +14,8 @@ type FixtureState = {
|
||||
mutations: string[];
|
||||
nationPriorityInput?: string[];
|
||||
currentNationPriority?: string[];
|
||||
currentGeneralPriority?: string[];
|
||||
historyVersion?: number;
|
||||
};
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
@@ -131,7 +133,7 @@ const policyFixture = (state: FixtureState) => ({
|
||||
currentNationPriority: state.currentNationPriority ?? nationPriority,
|
||||
availableNationPriorityItems: nationPriority,
|
||||
defaultGeneralActionPriority: generalPriority,
|
||||
currentGeneralActionPriority: generalPriority,
|
||||
currentGeneralActionPriority: state.currentGeneralPriority ?? generalPriority,
|
||||
availableGeneralActionPriorityItems: generalPriority,
|
||||
lastSetters: {
|
||||
policy: { setter: null, date: null },
|
||||
@@ -175,6 +177,41 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
||||
? errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN')
|
||||
: response(policyFixture(state));
|
||||
}
|
||||
if (operation === 'npc.policyHistory') {
|
||||
return response({
|
||||
coverage: 'RECORDED_VERSIONS_ONLY',
|
||||
nextCursor: null,
|
||||
items: [
|
||||
{
|
||||
id: 'a'.repeat(64),
|
||||
revision: state.historyVersion ?? 2,
|
||||
year: 185,
|
||||
month: 1,
|
||||
source: 'CHANGE',
|
||||
actor: { name: '정책담당', generalId: 22 },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
if (operation === 'npc.policyVersion') {
|
||||
return response({
|
||||
id: 'a'.repeat(64),
|
||||
revision: state.historyVersion ?? 2,
|
||||
year: 185,
|
||||
month: 1,
|
||||
source: 'CHANGE',
|
||||
createdAt: '2026-10-22T00:00:00Z',
|
||||
actor: { name: '정책담당', generalId: 22 },
|
||||
fields: [
|
||||
{
|
||||
key: 'priority',
|
||||
beforeJson: '["NPC전방발령","NPC포상"]',
|
||||
afterJson: '["NPC포상"]',
|
||||
changed: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
if (
|
||||
operation === 'npc.setNationPolicy' ||
|
||||
operation === 'npc.setNationPriority' ||
|
||||
@@ -188,6 +225,11 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
||||
state.failNextMutation = false;
|
||||
return errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN');
|
||||
}
|
||||
state.historyVersion = (state.historyVersion ?? 2) + 1;
|
||||
if (operation === 'npc.setNationPriority' && Array.isArray(jsonInput))
|
||||
state.currentNationPriority = jsonInput.map(String);
|
||||
if (operation === 'npc.setGeneralPriority' && Array.isArray(jsonInput))
|
||||
state.currentGeneralPriority = jsonInput.map(String);
|
||||
return response({ ok: true });
|
||||
}
|
||||
return errorResponse(operation, `Unhandled fixture operation: ${operation}`);
|
||||
@@ -206,6 +248,103 @@ const screenshot = async (page: Page, name: string) => {
|
||||
await page.screenshot({ path: resolve(artifactRoot, name), fullPage: true });
|
||||
};
|
||||
|
||||
for (const width of [500, 1000]) {
|
||||
test(`일반 사용자가 정책 변경자와 전후 이력을 조회하고 새로고침한다 ${width}px`, async ({ page }, testInfo) => {
|
||||
const state: FixtureState = { permissionLevel: 1, mutations: [], currentNationPriority: ['NPC포상'] };
|
||||
await installFixture(page, state);
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await gotoPolicy(page);
|
||||
await page.getByRole('button', { name: '정책 변경 이력 조회' }).click();
|
||||
const history = page.getByRole('region', { name: 'NPC 정책 변경 이력', exact: true });
|
||||
await expect(history).toContainText('정책담당 (#22)');
|
||||
await history.getByLabel('이력 종류').selectOption('NPC_NATION_PRIORITY');
|
||||
await history.getByRole('button', { name: '버전 2', exact: true }).click();
|
||||
const detail = page.getByRole('region', { name: '정책 변경 내용', exact: true });
|
||||
await expect(detail).toContainText('["NPC전방발령","NPC포상"]');
|
||||
await expect(detail).toContainText('["NPC포상"]');
|
||||
await history.getByLabel('이력 종류').focus();
|
||||
await expect(history.getByLabel('이력 종류')).toBeFocused();
|
||||
const measurements = await history.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const cell = element.querySelector('td')!;
|
||||
return {
|
||||
rect: rect.toJSON(),
|
||||
style: {
|
||||
verticalAlign: getComputedStyle(cell).verticalAlign,
|
||||
whiteSpace: getComputedStyle(cell.querySelector('pre')!).whiteSpace,
|
||||
},
|
||||
html: element.outerHTML,
|
||||
overflow: document.documentElement.scrollWidth > innerWidth,
|
||||
};
|
||||
});
|
||||
expect(measurements.style).toEqual({ verticalAlign: 'middle', whiteSpace: 'pre-wrap' });
|
||||
expect(measurements.overflow).toBe(false);
|
||||
await testInfo.attach('history-dom-geometry-style', {
|
||||
body: JSON.stringify(measurements, null, 2),
|
||||
contentType: 'application/json',
|
||||
});
|
||||
await writeFile(testInfo.outputPath('history-dom-geometry-style.json'), JSON.stringify(measurements, null, 2));
|
||||
await page.screenshot({ path: testInfo.outputPath(`npc-history-${width}.png`), fullPage: true });
|
||||
state.historyVersion = 3;
|
||||
await history.getByRole('button', { name: '이력 새로고침' }).click();
|
||||
await expect(history.getByRole('button', { name: '버전 3', exact: true })).toBeVisible();
|
||||
await page.reload();
|
||||
const nationPanel = page.locator('.priority-panel').first();
|
||||
await expect(
|
||||
nationPanel.locator('.priority-column').nth(1).getByText('NPC전방발령', { exact: true })
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
nationPanel.locator('.priority-column').first().getByText('NPC전방발령', { exact: true })
|
||||
).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
test('발령 비활성화와 징병 우선순위를 저장한 뒤 새로고침해도 유지되고 열린 이력이 갱신된다', async ({ page }) => {
|
||||
const state: FixtureState = {
|
||||
permissionLevel: 4,
|
||||
mutations: [],
|
||||
currentNationPriority: ['NPC전방발령', 'NPC포상'],
|
||||
currentGeneralPriority: ['출병', '일반내정', '징병'],
|
||||
};
|
||||
await installFixture(page, state);
|
||||
await page.setViewportSize({ width: 1000, height: 900 });
|
||||
await gotoPolicy(page);
|
||||
await page.getByRole('button', { name: '정책 변경 이력 조회' }).click();
|
||||
const history = page.getByRole('region', { name: 'NPC 정책 변경 이력', exact: true });
|
||||
await expect(history.getByRole('button', { name: '버전 2', exact: true })).toBeVisible();
|
||||
const nationPanel = page.locator('.priority-panel').first();
|
||||
await nationPanel
|
||||
.locator('.priority-column')
|
||||
.nth(1)
|
||||
.getByText('NPC전방발령', { exact: true })
|
||||
.dragTo(nationPanel.locator('.priority-column').first().locator('.priority-list'));
|
||||
await nationPanel.locator('.submit_btn').click();
|
||||
await acceptAppConfirmation(page, '저장할까요?');
|
||||
await expect(history.getByRole('button', { name: '버전 3', exact: true })).toBeVisible();
|
||||
const generalPanel = page.locator('.priority-panel').nth(1);
|
||||
const active = generalPanel.locator('.priority-column').nth(1);
|
||||
// 목록을 화면 중앙에 놓아 창 가장자리의 Sortable auto-scroll을 피한다.
|
||||
await active
|
||||
.locator('.priority-list')
|
||||
.evaluate((element) => element.scrollIntoView({ block: 'center', behavior: 'instant' }));
|
||||
await touchDrag(page, active.getByText('징병', { exact: true }), active.getByText('출병', { exact: true }), {
|
||||
targetYRatio: 0.1,
|
||||
});
|
||||
await expect(active.locator('.priority_info > span:nth-child(2)').first()).toHaveText('징병');
|
||||
await generalPanel.locator('.submit_btn').click();
|
||||
await acceptAppConfirmation(page, '저장할까요?');
|
||||
await expect(history.getByRole('button', { name: '버전 4', exact: true })).toBeVisible();
|
||||
expect(state.currentNationPriority).toEqual(['NPC포상']);
|
||||
expect(state.currentGeneralPriority).toEqual(['징병', '출병', '일반내정']);
|
||||
await page.reload();
|
||||
await expect(
|
||||
nationPanel.locator('.priority-column').first().getByText('NPC전방발령', { exact: true })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
generalPanel.locator('.priority-column').nth(1).locator('.priority_info > span:nth-child(2)').first()
|
||||
).toHaveText('징병');
|
||||
});
|
||||
|
||||
test('desktop geometry, typography, textures, drag, focus, tooltip, and successful save match the reference', async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc && vite build",
|
||||
"build": "node ../../tools/vue-typecheck.mjs && vite build",
|
||||
"build:release": "vite build --outDir .release-build",
|
||||
"typecheck:release": "vue-tsc --noEmit",
|
||||
"typecheck:release": "node ../../tools/vue-typecheck.mjs --noEmit",
|
||||
"preview": "vite preview",
|
||||
"test": "pnpm test:unit",
|
||||
"test:unit": "node --test test/**/*.test.ts",
|
||||
@@ -30,7 +30,7 @@
|
||||
"test:e2e:default-recruit-commands-live": "pnpm --filter @sammo-ts/infra prisma:generate && pnpm --filter @sammo-ts/common build && pnpm --filter @sammo-ts/logic build && pnpm --filter @sammo-ts/infra build && pnpm --filter @sammo-ts/game-engine build && pnpm --filter @sammo-ts/game-api build && playwright test --config e2e/defaultRecruitCommands.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json",
|
||||
"lint": "pnpm -w lint:target app/game-frontend",
|
||||
"lint:fix": "pnpm -w lint:target:fix app/game-frontend",
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
"typecheck": "node ../../tools/vue-typecheck.mjs --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sammo-ts/common": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { fieldLabels } from './playAudit/policyLabels';
|
||||
|
||||
const props = defineProps<{ revision: number }>();
|
||||
type History = Awaited<ReturnType<typeof trpc.npc.policyHistory.query>>;
|
||||
type Version = Awaited<ReturnType<typeof trpc.npc.policyVersion.query>>;
|
||||
const open = ref(false);
|
||||
const area = ref<'NPC_VALUES' | 'NPC_NATION_PRIORITY' | 'NPC_GENERAL_PRIORITY'>('NPC_VALUES');
|
||||
const history = ref<History | null>(null);
|
||||
const version = ref<Version | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const labels = { BASELINE: '최초 관측', CHANGE: '변경', OBSERVED_GAP: '관측 누락 이후 기준' };
|
||||
let generation = 0;
|
||||
const load = async (append = false) => {
|
||||
const request = ++generation;
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
if (!append) {
|
||||
history.value = null;
|
||||
version.value = null;
|
||||
}
|
||||
try {
|
||||
const response = await trpc.npc.policyHistory.query({
|
||||
area: area.value,
|
||||
cursor: append ? (history.value?.nextCursor ?? undefined) : undefined,
|
||||
});
|
||||
if (request === generation)
|
||||
history.value = {
|
||||
...response,
|
||||
items: append ? [...(history.value?.items ?? []), ...response.items] : response.items,
|
||||
};
|
||||
} catch (cause) {
|
||||
if (request === generation)
|
||||
error.value = cause instanceof Error ? cause.message : '이력을 조회하지 못했습니다.';
|
||||
} finally {
|
||||
if (request === generation) loading.value = false;
|
||||
}
|
||||
};
|
||||
const select = async (id: string) => {
|
||||
const request = ++generation;
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
version.value = null;
|
||||
try {
|
||||
const response = await trpc.npc.policyVersion.query({ id });
|
||||
if (request === generation) version.value = response;
|
||||
} catch (cause) {
|
||||
if (request === generation)
|
||||
error.value = cause instanceof Error ? cause.message : '변경 내용을 조회하지 못했습니다.';
|
||||
} finally {
|
||||
if (request === generation) loading.value = false;
|
||||
}
|
||||
};
|
||||
watch([open, area, () => props.revision], () => {
|
||||
if (open.value) void load();
|
||||
else {
|
||||
++generation;
|
||||
loading.value = false;
|
||||
history.value = null;
|
||||
version.value = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="npc-policy-history" aria-label="NPC 정책 변경 이력">
|
||||
<button class="legacy-button" :aria-expanded="open" @click="open = !open">
|
||||
정책 변경 이력 {{ open ? '닫기' : '조회' }}
|
||||
</button>
|
||||
<div v-if="open">
|
||||
<p>자국의 현재 기수 기록입니다. 최초 관측 이전의 변경은 복원하지 않습니다.</p>
|
||||
<p>우선순위는 실행을 시도하는 순서입니다. 징병은 전쟁 상태·병력·인구·자원 조건을 만족해야 합니다.</p>
|
||||
<p>0이면 자동 계산하는 항목은 저장값이 같아도 기술·내정 비용에 따라 기준이 달라질 수 있습니다.</p>
|
||||
<label
|
||||
>이력 종류
|
||||
<select v-model="area">
|
||||
<option value="NPC_VALUES">국가 정책</option>
|
||||
<option value="NPC_NATION_PRIORITY">국가턴 우선순위</option>
|
||||
<option value="NPC_GENERAL_PRIORITY">일반턴 우선순위</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="legacy-button" :disabled="loading" @click="load()">이력 새로고침</button>
|
||||
<p v-if="loading" role="status">조회 중…</p>
|
||||
<p v-if="error" role="alert">{{ error }}</p>
|
||||
<template v-if="history">
|
||||
<p v-if="history.historyGap" role="status">
|
||||
{{ history.historyGap.firstYear }}년 {{ history.historyGap.firstMonth }}월 이후 일부 기록에 누락이
|
||||
있습니다. 이력이 없다는 사실만으로 변경이 없었다고 판단할 수 없습니다.
|
||||
</p>
|
||||
<p v-if="history.coverage === 'IDENTITY_MISSING'">기수 식별자가 없어 이력을 조회할 수 없습니다.</p>
|
||||
<p v-else-if="!history.items.length">기록된 정책 이력이 없습니다.</p>
|
||||
<ol v-else>
|
||||
<li v-for="item in history.items" :key="item.id">
|
||||
<button class="legacy-button" :disabled="loading" @click="select(item.id)">
|
||||
버전 {{ item.revision }}
|
||||
</button>
|
||||
{{ item.year }}년 {{ item.month }}월 · {{ labels[item.source] }} ·
|
||||
{{ item.actor ? `${item.actor.name} (#${item.actor.generalId})` : '변경 주체 미상' }}
|
||||
</li>
|
||||
</ol>
|
||||
<button
|
||||
v-if="history.nextCursor !== null"
|
||||
class="legacy-button"
|
||||
:disabled="loading"
|
||||
@click="load(true)"
|
||||
>
|
||||
이전 기록 더 보기
|
||||
</button>
|
||||
</template>
|
||||
<section v-if="version" aria-label="정책 변경 내용">
|
||||
<h3>버전 {{ version.revision }} · {{ version.actor?.name ?? '변경 주체 미상' }}</h3>
|
||||
<p>{{ version.year }}년 {{ version.month }}월 · 기록 시각 {{ version.createdAt }}</p>
|
||||
<p v-if="version.source !== 'CHANGE'">관측 기준입니다. 이전 값과 변경 주체를 추정하지 않습니다.</p>
|
||||
<p>null은 개별 설정 없이 기본값을 상속함을 뜻합니다.</p>
|
||||
<div class="table-scroll" tabindex="0" aria-label="정책 변경 전후 값">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>설정</th>
|
||||
<th>변경 전</th>
|
||||
<th>변경 후</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="field in version.fields" :key="field.key" :class="{ changed: field.changed }">
|
||||
<th scope="row">
|
||||
{{ fieldLabels[field.key] ?? field.key }}{{ field.changed ? ' (변경)' : '' }}
|
||||
</th>
|
||||
<td>
|
||||
<pre>{{ field.beforeJson ?? '관측하지 않음' }}</pre>
|
||||
</td>
|
||||
<td>
|
||||
<pre>{{ field.afterJson }}</pre>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.npc-policy-history {
|
||||
margin-top: 16px;
|
||||
padding: 8px;
|
||||
border: 1px solid gray;
|
||||
}
|
||||
select,
|
||||
button {
|
||||
margin: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
li {
|
||||
margin-block: 6px;
|
||||
}
|
||||
.table-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
border: 1px solid gray;
|
||||
padding: 6px;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
min-width: 100px;
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
}
|
||||
.changed {
|
||||
background: #343425;
|
||||
}
|
||||
[role='alert'] {
|
||||
color: #ffb9b9;
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--sammo-font-size-normal);
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
import { computed } from 'vue';
|
||||
import { splitLegacyLineBreaks } from '../../utils/legacyDescription';
|
||||
|
||||
const props = defineProps<{
|
||||
title: string;
|
||||
description?: string | null;
|
||||
testId?: string;
|
||||
}>();
|
||||
|
||||
const descriptionLines = computed(() => splitLegacyLineBreaks(props.description ?? ''));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -16,7 +21,10 @@ defineProps<{
|
||||
<slot />
|
||||
<span v-if="description" class="directory-tooltip__content" role="tooltip">
|
||||
<strong>{{ title }}</strong>
|
||||
<span>{{ description }}</span>
|
||||
<span class="directory-tooltip__description"
|
||||
><template v-for="(line, index) in descriptionLines" :key="index"
|
||||
>{{ line }}<br v-if="index < descriptionLines.length - 1" /></template
|
||||
></span>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useSlots, watch } from 'vue';
|
||||
import { splitLegacyLineBreaks } from '../../utils/legacyDescription';
|
||||
import tippy, { type Instance, type Placement } from 'tippy.js';
|
||||
import 'tippy.js/dist/tippy.css';
|
||||
|
||||
@@ -28,7 +29,7 @@ let instance: Instance | null = null;
|
||||
const descriptionLines = computed(() => {
|
||||
const source = Array.isArray(props.description) ? props.description : [props.description ?? ''];
|
||||
return source
|
||||
.flatMap((line) => line.split(/<br\s*\/?\s*>|\r?\n/giu))
|
||||
.flatMap(splitLegacyLineBreaks)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* 카탈로그 설명의 Ref 줄바꿈만 해석한다. 반환값은 반드시 text interpolation으로
|
||||
* 렌더링하며 HTML로 삽입하지 않는다. 빈 줄과 공백의 표시 정책은 소비자가 정한다.
|
||||
*/
|
||||
export const splitLegacyLineBreaks = (value: string): string[] => value.split(/<br\s*\/?\s*>|\r\n?|\n/giu);
|
||||
@@ -4,6 +4,7 @@ import { usePageExit } from '../composables/usePageExit';
|
||||
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
|
||||
import { JosaUtil } from '@sammo-ts/common/util/JosaUtil';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { splitLegacyLineBreaks } from '../utils/legacyDescription';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||
|
||||
@@ -207,7 +208,6 @@ const specialNameMap = computed(() => {
|
||||
}
|
||||
return map;
|
||||
});
|
||||
const splitLegacyLineBreaks = (value: string): string[] => value.split(/<br\s*\/?\s*>|\r?\n/giu);
|
||||
const selectedSpecialWarInfo = computed(
|
||||
() => status.value?.availableSpecialWar.find((entry) => entry.key === nextSpecialKey.value)?.info ?? ''
|
||||
);
|
||||
|
||||
@@ -3,12 +3,14 @@ import { usePageExit } from '../composables/usePageExit';
|
||||
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import NpcPolicyHistory from '../components/NpcPolicyHistory.vue';
|
||||
import SortableStringList from '../components/ui/SortableStringList';
|
||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||
import { npcPriorityHelp } from '../utils/npcPriorityHelp';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
const { pageExitLabel, exitPage } = usePageExit();
|
||||
const historyRevision = ref(0);
|
||||
|
||||
type NpcPolicyResponse = Awaited<ReturnType<typeof trpc.npc.getPolicy.query>>;
|
||||
type NationPolicy = NpcPolicyResponse['currentNationPolicy'];
|
||||
@@ -308,6 +310,7 @@ const submitPolicy = async () => {
|
||||
try {
|
||||
await trpc.npc.setNationPolicy.mutate(policyDraft.value);
|
||||
lastSavedPolicy.value = clonePolicy(policyDraft.value);
|
||||
historyRevision.value += 1;
|
||||
showSuccessToast('NPC 정책이 반영되었습니다.');
|
||||
} catch (caught) {
|
||||
showErrorToast(`설정하지 못했습니다: ${resolveErrorMessage(caught)}`);
|
||||
@@ -357,6 +360,7 @@ const submitPriority = async (section: PrioritySectionKey) => {
|
||||
await trpc.npc.setGeneralPriority.mutate(state.active);
|
||||
lastSavedGeneralPriority.value = [...state.active];
|
||||
}
|
||||
historyRevision.value += 1;
|
||||
showSuccessToast('NPC 정책이 반영되었습니다.');
|
||||
} catch (caught) {
|
||||
showErrorToast(`설정하지 못했습니다: ${resolveErrorMessage(caught)}`);
|
||||
@@ -547,6 +551,7 @@ const submitPriority = async (section: PrioritySectionKey) => {
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
<NpcPolicyHistory v-if="data" :key="data.nationId" :revision="historyRevision" />
|
||||
<div class="sortable-compat-controls" aria-hidden="true">
|
||||
<button type="button" tabindex="-1" />
|
||||
<input v-for="index in 20" :key="index" type="hidden" />
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { splitLegacyLineBreaks } from '../src/utils/legacyDescription.ts';
|
||||
|
||||
void describe('legacy catalog description line breaks', () => {
|
||||
void it('accepts catalog br variants and platform newlines', () => {
|
||||
assert.deepEqual(splitLegacyLineBreaks('가<br>나<BR/>다<br />라\r\n마\n바\r사'), [
|
||||
'가',
|
||||
'나',
|
||||
'다',
|
||||
'라',
|
||||
'마',
|
||||
'바',
|
||||
'사',
|
||||
]);
|
||||
});
|
||||
|
||||
void it('preserves empty lines, whitespace, and unformatted descriptions', () => {
|
||||
assert.deepEqual(splitLegacyLineBreaks(''), ['']);
|
||||
assert.deepEqual(splitLegacyLineBreaks(' 일반 설명 '), [' 일반 설명 ']);
|
||||
assert.deepEqual(splitLegacyLineBreaks('<br>첫 줄<br><br>끝 줄\n'), ['', '첫 줄', '', '끝 줄', '']);
|
||||
});
|
||||
|
||||
void it('leaves other markup, attributes, and entities as literal text', () => {
|
||||
const text = '<img src=x onerror="alert(1)"><b>문구</b><br><br onclick="alert(1)">';
|
||||
assert.deepEqual(splitLegacyLineBreaks(text), [text]);
|
||||
});
|
||||
});
|
||||
@@ -30,16 +30,11 @@
|
||||
"@sammo-ts/game-api/*": ["../../app/game-api/src/*"],
|
||||
"@sammo-ts/gateway-api": ["../../app/gateway-api/src/index.ts"],
|
||||
"@sammo-ts/gateway-api/*": ["../../app/gateway-api/src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.vue",
|
||||
"test/**/*.ts",
|
||||
"e2e/**/*.ts"
|
||||
],
|
||||
"declaration": false,
|
||||
"declarationMap": false
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", "test/**/*.ts", "e2e/**/*.ts"],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { parseBooleanWithFallback, parseNumberWithFallback } from '@sammo-ts/common';
|
||||
import { parseBooleanWithFallback, parseNumberWithFallback, resolveApiAllowedOrigins } from '@sammo-ts/common';
|
||||
import { resolveFrontendServeMode, type FrontendServeMode } from './orchestrator/frontendArtifactManager.js';
|
||||
|
||||
export interface GatewayApiConfig {
|
||||
@@ -19,6 +19,7 @@ export interface GatewayApiConfig {
|
||||
kakaoAdminKey?: string;
|
||||
kakaoRedirectUri: string;
|
||||
publicBaseUrl: string;
|
||||
corsAllowedOrigins: string[];
|
||||
userIconDir: string;
|
||||
userIconPublicUrl: string;
|
||||
imageUploadBaseUrl: string;
|
||||
@@ -129,6 +130,7 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.
|
||||
kakaoAdminKey: env.KAKAO_ADMIN_KEY,
|
||||
kakaoRedirectUri,
|
||||
publicBaseUrl,
|
||||
corsAllowedOrigins: resolveApiAllowedOrigins(env.API_ALLOWED_ORIGINS, [publicBaseUrl]),
|
||||
userIconDir: env.GATEWAY_USER_ICON_DIR ?? 'uploads/user-icons',
|
||||
userIconPublicUrl: env.GATEWAY_USER_ICON_PUBLIC_URL ?? 'https://sam-image.hided.net/icons',
|
||||
imageUploadBaseUrl: env.GATEWAY_IMAGE_UPLOAD_URL ?? 'https://sam-image.hided.net',
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import type { GatewayProfileRecord } from '../orchestrator/profileRepository.js';
|
||||
import { readProfileReleaseSource } from '../orchestrator/profileReleaseSource.js';
|
||||
import { resolveGitBranchCommitSha, resolveGitCommitSha } from '../scenario/scenarioCatalog.js';
|
||||
|
||||
// 이미 배포된 서버는 그 catalog를 보존하고, 첫 오픈 전 서버만 운영자가 정한 source를 사용한다.
|
||||
// 일반 사용자는 이 조회에도 branch/commit을 전달할 수 없다.
|
||||
export const resolveOpeningCatalogCommit = async (
|
||||
profile: Pick<GatewayProfileRecord, 'meta' | 'buildCommitSha'>
|
||||
): Promise<string> => {
|
||||
if (profile.buildCommitSha?.trim()) return profile.buildCommitSha.trim();
|
||||
const source = readProfileReleaseSource(profile);
|
||||
if (!source) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '서버의 배포 버전 또는 패치 정책이 필요합니다.' });
|
||||
}
|
||||
try {
|
||||
return source.mode === 'BRANCH'
|
||||
? await resolveGitBranchCommitSha(source.ref)
|
||||
: await resolveGitCommitSha(source.ref);
|
||||
} catch {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: '서버 패치 정책의 시나리오 버전을 확인하지 못했습니다.',
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,396 @@
|
||||
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';
|
||||
import { resolveOpeningCatalogCommit } from './catalogSource.js';
|
||||
|
||||
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');
|
||||
const catalogCommit = await resolveOpeningCatalogCommit({
|
||||
meta: profile.meta as GatewayPrisma.JsonObject,
|
||||
buildCommitSha: profile.buildCommitSha ?? undefined,
|
||||
});
|
||||
const scenario = (await listScenarioPreviews({ gitRef: catalogCommit })).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';
|
||||
@@ -1855,6 +1856,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.');
|
||||
}
|
||||
@@ -1959,7 +1976,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();
|
||||
@@ -2414,7 +2434,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,
|
||||
@@ -2526,6 +2547,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.
|
||||
@@ -2569,6 +2608,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() : '';
|
||||
|
||||
@@ -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(', ');
|
||||
};
|
||||
@@ -11,6 +11,8 @@ 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 { resolveOpeningCatalogCommit } from './openRequests/catalogSource.js';
|
||||
import { accountRouter } from './account/router.js';
|
||||
import {
|
||||
hasActiveSpecialAccountGrant,
|
||||
@@ -131,6 +133,7 @@ const finishKakaoLoginOrRequestPasswordSetup = async <T extends 'login' | 'verif
|
||||
};
|
||||
|
||||
export const appRouter = router({
|
||||
openRequests: openRequestsRouter,
|
||||
navigation: router({
|
||||
get: procedure.query(({ ctx }) => ctx.navigationConfig.get()),
|
||||
}),
|
||||
@@ -216,16 +219,11 @@ export const appRouter = router({
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
|
||||
}
|
||||
const profile = await ctx.profiles.getProfile(input.profileName);
|
||||
const activeBuildCommit = profile?.buildCommitSha?.trim();
|
||||
if (!profile || !activeBuildCommit) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'The profile has no active build commit.',
|
||||
});
|
||||
}
|
||||
if (!profile) throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
|
||||
const catalogCommit = await resolveOpeningCatalogCommit(profile);
|
||||
|
||||
try {
|
||||
return await listScenarioPreviews({ gitRef: activeBuildCommit });
|
||||
return await listScenarioPreviews({ gitRef: catalogCommit });
|
||||
} catch {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { safeHttpLoggerOptions, safeHttpErrorHandler } from '@sammo-ts/common';
|
||||
import { safeHttpLoggerOptions, safeHttpErrorHandler, createApiOriginGuard } from '@sammo-ts/common';
|
||||
import fastify, { type FastifyRequest } from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
@@ -125,8 +125,9 @@ export const createGatewayApiServer = async () => {
|
||||
(error) => app.log.error({ err: error }, 'web push delivery failed')
|
||||
);
|
||||
|
||||
app.addHook('onRequest', createApiOriginGuard(config.corsAllowedOrigins));
|
||||
await app.register(cors, {
|
||||
origin: true,
|
||||
origin: config.corsAllowedOrigins,
|
||||
credentials: true,
|
||||
});
|
||||
await fs.mkdir(path.resolve(process.cwd(), config.userIconDir), { recursive: true });
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { z } from 'zod';
|
||||
import { decryptGameSessionToken } from '@sammo-ts/common/auth/gameToken';
|
||||
import fastify, { type FastifyRequest } from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { trpcJsonBodyHttpServerOptions } from '@sammo-ts/common';
|
||||
import { trpcJsonBodyHttpServerOptions, createApiOriginGuard, resolveApiAllowedOrigins } from '@sammo-ts/common';
|
||||
import type { GatewayPrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js';
|
||||
@@ -93,6 +94,9 @@ const createHarness = async (adminRoles = ['user', 'admin.users.manage', 'admin.
|
||||
const targetSession = await sessions.createSession(target);
|
||||
const flushes: Array<{ userId: string; reason?: string }> = [];
|
||||
const app = fastify({ logger: false });
|
||||
const origins = resolveApiAllowedOrigins(undefined, ['https://sam.hided.net/gateway/']);
|
||||
app.addHook('onRequest', createApiOriginGuard(origins));
|
||||
await app.register(cors, { origin: origins, credentials: true });
|
||||
await app.register(fastifyTRPCPlugin, {
|
||||
prefix: '/trpc',
|
||||
trpcOptions: {
|
||||
@@ -176,6 +180,29 @@ const postTrpc = async (
|
||||
};
|
||||
|
||||
describe('admin security over HTTP transport', () => {
|
||||
it('rejects an untrusted browser origin before a valid session can log out', async () => {
|
||||
const harness = await createHarness();
|
||||
for (const origin of ['https://untrusted.example', 'null']) {
|
||||
const response = await fetch(`${harness.baseUrl}/trpc/auth.logout`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-session-token': harness.adminSessionToken, origin },
|
||||
body: JSON.stringify({ sessionToken: harness.adminSessionToken }),
|
||||
});
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.headers.get('access-control-allow-origin')).toBeNull();
|
||||
expect((await postTrpc(harness.baseUrl, 'me', null, harness.adminSessionToken)).response.status).toBe(200);
|
||||
}
|
||||
const allowed = await fetch(`${harness.baseUrl}/trpc/auth.logout`, {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
origin: 'https://sam.hided.net',
|
||||
'access-control-request-method': 'POST',
|
||||
'access-control-request-headers': 'x-session-token,content-type',
|
||||
},
|
||||
});
|
||||
expect(allowed.status).toBe(204);
|
||||
expect(allowed.headers.get('access-control-allow-origin')).toBe('https://sam.hided.net');
|
||||
});
|
||||
it('protects runtime diagnostics at the HTTP authentication and profile scope boundaries', async () => {
|
||||
const harness = await createHarness(['admin.profiles.runtime:che:default']);
|
||||
const input = { profileName: 'che:default' };
|
||||
|
||||
@@ -0,0 +1,580 @@
|
||||
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('supports the first opening of a closed server using its configured source without an active build', async () => {
|
||||
await prisma.gatewayProfile.update({
|
||||
where: { profileName },
|
||||
data: { buildCommitSha: null, meta: { releaseSource: { mode: 'BRANCH', ref: 'main' } } },
|
||||
});
|
||||
expect(await (await caller(1)).lobby.scenarios({ profileName })).toHaveLength(1);
|
||||
expect(catalog.resolveGitBranchCommitSha).toHaveBeenCalledWith('main');
|
||||
const request = await create();
|
||||
await (await caller(3)).admin.openRequests.decide({ id: request.id, action: 'ACCEPT' });
|
||||
expect((await prisma.gatewayOperation.findFirstOrThrow({ where: { profileName } })).sourceMode).toBe('BRANCH');
|
||||
});
|
||||
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) => {
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": false
|
||||
"composite": false,
|
||||
"paths": {
|
||||
"@sammo-ts/gateway-api": ["./src/index.ts"],
|
||||
"@sammo-ts/gateway-api/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -152,6 +152,7 @@ const installGameFixture = async (page: Page, profile: ProfileFixture, userCnt:
|
||||
}
|
||||
if (operation === 'lobby.info') {
|
||||
return response({
|
||||
scenarioTitle: '공개 지도 테스트 시나리오',
|
||||
year: 200,
|
||||
month: profile.profile === 'che' ? 1 : 2,
|
||||
userCnt,
|
||||
@@ -448,8 +449,7 @@ test('renders the Gateway profile order returned by the API', async ({ page }, t
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
await page.goto('lobby');
|
||||
|
||||
const renderedProfiles = await page.locator('tbody tr td:first-child > div:first-child').allTextContents();
|
||||
expect(renderedProfiles.map((name) => name.trim())).toEqual([
|
||||
await expect(page.locator('tbody tr td:first-child > div:first-child')).toHaveText([
|
||||
'체섭',
|
||||
'퀘섭',
|
||||
'풰섭',
|
||||
|
||||
@@ -9,13 +9,13 @@
|
||||
"test:e2e:operations": "playwright test --config e2e/playwright.config.mjs",
|
||||
"test:e2e:hwe-lifecycle": "playwright test --config e2e/hwe-lifecycle.playwright.config.mjs",
|
||||
"test:e2e:general-icons": "playwright test --config e2e/general-icon-lifecycle.playwright.config.mjs",
|
||||
"build": "vue-tsc && vite build",
|
||||
"build": "node ../../tools/vue-typecheck.mjs && vite build",
|
||||
"build:release": "vite build",
|
||||
"typecheck:release": "vue-tsc --noEmit",
|
||||
"typecheck:release": "node ../../tools/vue-typecheck.mjs --noEmit",
|
||||
"preview": "vite preview",
|
||||
"lint": "pnpm -w lint:target app/gateway-frontend",
|
||||
"lint:fix": "pnpm -w lint:target:fix app/gateway-frontend",
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
"typecheck": "node ../../tools/vue-typecheck.mjs --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sammo-ts/common": "workspace:*",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -4,11 +4,7 @@
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ESNext",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
@@ -21,55 +17,24 @@
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"@sammo-ts/common": [
|
||||
"../../packages/common/src/index.ts"
|
||||
],
|
||||
"@sammo-ts/common/*": [
|
||||
"../../packages/common/src/*"
|
||||
],
|
||||
"@sammo-ts/infra": [
|
||||
"../../packages/infra/src/index.ts"
|
||||
],
|
||||
"@sammo-ts/infra/*": [
|
||||
"../../packages/infra/src/*"
|
||||
],
|
||||
"@sammo-ts/logic": [
|
||||
"../../packages/logic/src/index.ts"
|
||||
],
|
||||
"@sammo-ts/logic/*": [
|
||||
"../../packages/logic/src/*"
|
||||
],
|
||||
"@sammo-ts/game-engine": [
|
||||
"../../app/game-engine/src/index.ts"
|
||||
],
|
||||
"@sammo-ts/game-engine/*": [
|
||||
"../../app/game-engine/src/*"
|
||||
],
|
||||
"@sammo-ts/game-api": [
|
||||
"../../app/game-api/src/index.ts"
|
||||
],
|
||||
"@sammo-ts/game-api/*": [
|
||||
"../../app/game-api/src/*"
|
||||
],
|
||||
"@sammo-ts/gateway-api": [
|
||||
"../../app/gateway-api/src/index.ts"
|
||||
],
|
||||
"@sammo-ts/gateway-api/*": [
|
||||
"../../app/gateway-api/src/*"
|
||||
]
|
||||
}
|
||||
"@/*": ["./src/*"],
|
||||
"@sammo-ts/common": ["../../packages/common/src/index.ts"],
|
||||
"@sammo-ts/common/*": ["../../packages/common/src/*"],
|
||||
"@sammo-ts/infra": ["../../packages/infra/src/index.ts"],
|
||||
"@sammo-ts/infra/*": ["../../packages/infra/src/*"],
|
||||
"@sammo-ts/logic": ["../../packages/logic/src/index.ts"],
|
||||
"@sammo-ts/logic/*": ["../../packages/logic/src/*"],
|
||||
"@sammo-ts/game-engine": ["../../app/game-engine/src/index.ts"],
|
||||
"@sammo-ts/game-engine/*": ["../../app/game-engine/src/*"],
|
||||
"@sammo-ts/game-api": ["../../app/game-api/src/index.ts"],
|
||||
"@sammo-ts/game-api/*": ["../../app/game-api/src/*"],
|
||||
"@sammo-ts/gateway-api": ["../../app/gateway-api/src/index.ts"],
|
||||
"@sammo-ts/gateway-api/*": ["../../app/gateway-api/src/*"]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.vue",
|
||||
"test/**/*.ts",
|
||||
"e2e/**/*.ts"
|
||||
],
|
||||
"declaration": false,
|
||||
"declarationMap": false
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", "test/**/*.ts", "e2e/**/*.ts"],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": false,
|
||||
"paths": {
|
||||
"@sammo-ts/release-controller": ["./src/index.ts"],
|
||||
"@sammo-ts/release-controller/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
# 빌드 도구와 캐시 계약
|
||||
|
||||
## 타입 검사기 분리
|
||||
|
||||
- Vite, ESLint와 일반 compiler API는 workspace의 TypeScript `6.0.3`을 사용한다.
|
||||
- server project 검사와 선언 생성은 root `pnpm tsc7`의 TypeScript `7.0.2`를 사용한다.
|
||||
- 두 frontend의 Vue CLI만 `tools/vue-typecheck.mjs`가
|
||||
`typescript-native-bridge@6.0.3-bridge.18.tsgo.7.0.2`의 compiler 경로를
|
||||
`vue-tsc.run()`에 전달한다. 전역 `typescript` override를 bridge로 바꾸지 않는다.
|
||||
- frontend는 `noEmit`이며 `declaration`과 `declarationMap`을 명시적으로 끈다.
|
||||
선언 파일을 내지 않는 검사에서 선언 이식성 진단을 요구하지 않으며, 일반 TS와
|
||||
Vue template의 타입 검사는 유지한다.
|
||||
|
||||
`pnpm check:typescript-toolchain`은 API, native CLI와 bridge의 정확한 버전을 검사한다.
|
||||
bridge의 Linux native binary는 glibc 환경을 요구한다. 운영 builder의 Debian
|
||||
Bookworm과 같은 환경을 사용하며 Alpine/musl은 지원 대상으로 간주하지 않는다.
|
||||
platform binary가 없으면 검사를 건너뛰거나 성공으로 처리하지 않고 실패한다.
|
||||
|
||||
일반 `pnpm --filter <frontend> build`는 Vue 검사 후 Vite bundle을 생성한다.
|
||||
Turbo의 frontend `build` override는 `^build`만 선행하므로 이 내장 검사와 별도
|
||||
`typecheck`를 중복 실행하지 않는다. `pnpm typecheck`는 기존처럼 두 frontend를
|
||||
검사한다. 운영 `build:release`는 cached `typecheck:release`를 선행한다.
|
||||
|
||||
## 서버 선언 파일
|
||||
|
||||
tsdown은 기존 entry, target, external dependency와 JS source map 설정으로 JS를
|
||||
bundle한다. `build:done` hook은 `tools/build-scripts/emit-declarations.mjs`를 호출한다.
|
||||
이 hook은 package의 `tsconfig.build.json`과 root의 `pnpm tsc7`로 선언 파일과 선언
|
||||
source map을 생성한다. compiler 실패와 signal은 전체 build 실패로 전파한다.
|
||||
|
||||
Prisma와 tRPC의 큰 inferred type graph를 한 `.d.ts`로 다시 묶지 않는다. 각
|
||||
package의 공개 `dist/index.d.ts`와 명시된 subpath는 유지하고, 그 선언이 참조하는
|
||||
파일도 `dist`에 함께 둔다. 이 저장소의 package는 private이며 배포 시 선언 파일
|
||||
하나만 복사해서는 안 된다.
|
||||
|
||||
`logic`의 기존 `./* → ./dist/src/*` export는 별도 계약이다. 이 package는 native
|
||||
compiler가 `dist/src`에 JS, 선언과 양쪽 source map을 함께 생성한다.
|
||||
`dist/index.d.ts`는 `./src/index.js`를 재수출하는 작은 진입점이다. 루트와 하위
|
||||
경로의 class가 같은 선언을 참조하므로 private member의 타입 정체성이 갈라지지
|
||||
않는다. 실제 선언과 source map은 `dist/src`에 있다.
|
||||
|
||||
선언 생성용 config는 test를 포함하는 project-reference 검사와 분리한다. 자체
|
||||
package alias는 source로, 선행 package는 공개 dist로 해석한다. 기존 타입 검사
|
||||
config의 references와 test 포함 범위는 유지한다. Turbo가 dependency build 순서를
|
||||
보장하며 직접 package를 빌드하기 전에는 [테스트 준비](testing-policy.md#새-worktree-준비)를
|
||||
완료해야 한다.
|
||||
|
||||
## 캐시와 검증
|
||||
|
||||
일반 build key에는 root tsconfig, tsdown config와 선언/Vue 검사 도구가 포함된다.
|
||||
frontend build key에는 모든 `VITE_*` 공개 입력이 포함된다. 타입 검사 task는
|
||||
`!VITE_*`로 [자동 framework 환경 추론](https://turborepo.dev/docs/crafting-your-repository/using-environment-variables)을
|
||||
제외한다. prefix나 배포 SHA만 바뀌어도 같은 source를 다시 타입 검사하지 않으며,
|
||||
실제 Vite bundle의 공개 입력은 계속 cache key에 포함한다. 운영 frontend의 source
|
||||
입력, 고정 commit, prefix와 공유 artifact 계약은
|
||||
[릴리스 운영](release-operations.md)을 따른다. source map과 타입 검사를 끄는 방식으로
|
||||
속도를 측정하지 않는다.
|
||||
|
||||
```sh
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm check:typescript-toolchain
|
||||
pnpm --filter @sammo-ts/build-scripts test
|
||||
CI=1 pnpm typecheck
|
||||
CI=1 RAYON_NUM_THREADS=2 NODE_OPTIONS=--max-old-space-size=8192 pnpm build --force --concurrency=1
|
||||
pnpm test
|
||||
```
|
||||
|
||||
`--force`는 Turbo cache를 우회하지만 TypeScript의 기존 `.tsbuildinfo`까지 지우지
|
||||
않는다. 콜드 비교에서는 격리된 checkout의 generated dist와 `.tsbuildinfo` 조건도
|
||||
맞추고, 같은 Node/pnpm, CPU·메모리 상한과 환경을 사용한다. 반복 빌드와 source
|
||||
수정 후 빌드를 구분하고 cache hit 수, compiler 실패와 peak RSS를 함께 기록한다.
|
||||
|
||||
운영 적용은 로컬 측정과 별도다. frozen install, 실제 builder 메모리 제한, 배포
|
||||
operation, 공개 prefix의 API·asset·Chromium과 기존 profile 상태를 확인한 뒤
|
||||
완료로 기록한다.
|
||||
@@ -4,17 +4,18 @@
|
||||
|
||||
| 환경 | 공개 주소·prefix | 접속·연결 계약 |
|
||||
| ------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| Ref | `dev-sam-ref.hided.net` | 개발 호스트 `172.30.1.54:3400`의 PHP 기준 구현입니다. |
|
||||
| Ref | `dev-sam-ref.hided.net` | 공개 서비스 종료(410). 작업공간의 loopback 비교 Ref와 별개입니다. |
|
||||
| 로컬 E2E | `dev-sam-e2e.hided.net` | 외부 Caddy TLS → 개발 호스트 `172.30.1.54:14999` HTTP → Docker Caddy입니다. |
|
||||
| 공개 개발 | `dev-sam2026.hided.net` | `ssh serv`의 `core2026-dev-sam2026`/`hidche_ng_my`입니다. 로컬 E2E `14999`와 다른 호스트입니다. |
|
||||
| sam Core 운영 | `sam.hided.net/gateway/`와 일곱 profile prefix | `ssh serv`의 `core2026-sam-production`/`hidche_core2026_my`입니다. |
|
||||
| sam PHP 운영 | `sam.hided.net/sam/`과 기존 PHP 경로 | `ssh serv`의 별도 `sam_hided_net` project입니다. |
|
||||
| sam PHP 게임 | `sam.hided.net/sam/` | 2026-10-10 종료(410). `sam_hided_net`의 게시판·위키·DB는 유지합니다. |
|
||||
|
||||
`dev-sam2026.hided.net`과 `sam.hided.net` Core prefix는 같은 Git 구현을 사용할 수
|
||||
있지만 PostgreSQL, Redis, named volume, release queue와 active commit이 분리된
|
||||
배포 환경입니다. 한 환경의 release/API/Chromium 결과를 다른 환경의 반영 근거로
|
||||
사용하지 않습니다. 상위 `sam_rebuild` 작업공간에서는
|
||||
`docs/docker-environment-routing.md`의 전체 결정 절차도 함께 따릅니다.
|
||||
현재 기기가 운영 host `serv-2204`이면 SSH 없이 해당 stack 경로에서 작업합니다.
|
||||
|
||||
외부 Caddy는 E2E 호스트의 모든 경로를 `172.30.1.54:14999`로 전달하고 원래
|
||||
`Host` header와 path prefix를 보존합니다. `handle_path`처럼 prefix를 제거하는
|
||||
@@ -81,6 +82,23 @@ GAME_TRPC_PATH=/hwe/api/trpc
|
||||
GAME_API_EVENTS_PATH=/hwe/api/events
|
||||
```
|
||||
|
||||
## API의 browser Origin 경계
|
||||
|
||||
Gateway는 `GATEWAY_PUBLIC_URL`, game API는 `GATEWAY_PUBLIC_URL`과
|
||||
`GAME_PUBLIC_URL`의 origin을 기본으로 허용합니다. 공개 URL의 `/gateway/`, `/che/`
|
||||
같은 path는 origin에 포함하지 않습니다. 추가 frontend host가 필요하면 runtime의
|
||||
`API_ALLOWED_ORIGINS`에 정확한 origin을 쉼표로 지정합니다. 예:
|
||||
`http://localhost:14997,http://127.0.0.1:14997`. scheme·host·port 모두 일치해야 하며
|
||||
wildcard·`null`·URL credential·path가 있는 추가 항목은 거부합니다.
|
||||
|
||||
허용하지 않은 `Origin`은 body parsing과 mutation 이전에 403으로 거부합니다.
|
||||
허용 목록이 비어 있으면 Origin이 있는 요청은 모두 거부하므로 standalone game
|
||||
개발 서버도 공개 URL 또는 명시 목록을 설정해야 합니다. Origin 없는 내부 client는
|
||||
기존 session/Bearer/HMAC 인증을 통과해야 합니다. Origin 허용은 권한을 부여하지
|
||||
않으며 비브라우저 client가 header를 조작하는 공격은 인증·resource ACL이 막습니다.
|
||||
PM2 managed API는 runtime base 환경을 상속하므로 설정 변경 뒤 해당 API process의
|
||||
환경까지 갱신합니다. 공개 운영·개발 목록을 서로 복사하지 않습니다.
|
||||
|
||||
## Frontend build
|
||||
|
||||
Gateway:
|
||||
|
||||
@@ -41,6 +41,14 @@ tooltip trigger는 hover/focus 동작과 `cursor: help`를 유지하되, tooltip
|
||||
텍스트에 점선 밑줄을 반복하지 않습니다. keyboard `focus-visible` outline은 별도의
|
||||
접근성 상태로 유지합니다.
|
||||
|
||||
카탈로그 설명의 줄바꿈은 `src/utils/legacyDescription.ts`의
|
||||
`splitLegacyLineBreaks()`가 해석합니다. `DirectoryTooltip`, `RichTooltip`, 유산의
|
||||
특기·유니크 설명은 `<br>`, `<br/>`, `<br />`(대소문자 무관)와 실제 개행을 이 함수로
|
||||
나누고 각 줄을 Vue text interpolation으로 렌더링합니다. 다른 HTML이나 entity는
|
||||
텍스트로 유지합니다. 일람·유산은 빈 줄을 보존하며, `RichTooltip`은 기존대로 각 줄의
|
||||
앞뒤 공백과 빈 줄을 제거합니다. 일람의 표와 모바일 카드에는 같은 tooltip을 쓰며
|
||||
줄바꿈 때문에 trigger의 geometry나 많은 행을 위한 lazy 구조를 바꾸지 않습니다.
|
||||
|
||||
## Button composition
|
||||
|
||||
Choose the Ref visual family before choosing a semantic color. Buttons from
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# Gateway 오픈 요청
|
||||
|
||||
`/gateway/open-suggestion`에서 정식 사용자가 다음 기수의 설정과 가오픈·정식 오픈
|
||||
시각을 제출한다. 이전 문구 복사 도구를 확장한 Core 전용 기능이다. 게임 초기화,
|
||||
서버 패치와 공개 예약 공지는 기존 RESET 경로를 사용한다.
|
||||
|
||||
## 참여와 운영 권한
|
||||
|
||||
- 요청·동의는 유효한 Gateway session의 actor로 처리한다. 카카오 연결 ID와 인증
|
||||
완료 시각이 모두 있거나, 기존 계정 정책의 해당 profile에 유효한 특수 접근
|
||||
허용이 있어야 한다. 단순 인증 유예만 있는 계정은 참여하지 못한다.
|
||||
- 로그인·게임 제재, 해당 서버의 접근 제한, 예약 탈퇴 계정은 참여하지 못한다.
|
||||
예약 전환 때 요청자와 동의자의 현재 자격을 다시 확인한다.
|
||||
- 공개 목록은 로그인 사용자에게 제공한다. 요청자의 표시명, 요청 설정과 유효
|
||||
동의 수를 표시하고, 계정 ID·배포 source·내부 오류·동의자 목록은 공개하지 않는다.
|
||||
- 해당 profile의 `admin.scenarios.reset` 권한자가 오프너로서 수락·거절한다.
|
||||
요청에서 파생한 예약에는 별도 deploy 권한이나 임의 시각 지정 권한을 부여하지
|
||||
않는다. 정책 저장은 `admin.profiles.settings` 권한이 필요하다. 기존 superuser
|
||||
판정과 profile scope 검사를 그대로 사용하며 운영 mutation은 감사 원장에 남긴다.
|
||||
|
||||
배포 이력이 있는 서버의 시나리오는 active build에서 읽는다. 아직 첫 build가 없는
|
||||
닫힌 서버는 운영자가 설정한 releaseSource에서 시나리오를 읽어 첫 오픈도 요청할 수
|
||||
있다. active build와 patch 정책이 모두 없으면 운영 설정 준비를 요구한다.
|
||||
|
||||
## 서버 정책
|
||||
|
||||
정책은 `GatewayProfile.meta.openRequestPolicy`에 저장한다. 요청 화면의 권한자용
|
||||
`서버 오픈 요청 정책`에서 변경하며 변경 사유를 요구한다.
|
||||
|
||||
| key | 기본값 | 허용 범위 / 의미 |
|
||||
| ---------------------- | ------ | ------------------------------------------------------------------- |
|
||||
| `enabled` | `true` | 요청과 동의·수락 접수 여부 |
|
||||
| `lifetimeDays` | `7` | 1~365일, 새 요청의 최대 모집 기간 |
|
||||
| `buildLeadMinutes` | `10` | 1~10080분, 가오픈 이전 준비 시작 간격 |
|
||||
| `endorsementThreshold` | `null` | `null`이면 오프너 수락만, 1~10000이면 요청자 외 필요한 정식 동의 수 |
|
||||
|
||||
CHE를 포함해 별도 설정이 없는 모든 서버는 자동 예약을 사용하지 않으며, 목록에
|
||||
동의 목표 숫자와 동의 버튼도 표시하지 않는다. 필요한 서버에서만 숫자를 설정한다.
|
||||
기준을 낮추거나 자동 예약을 켜면 이미 모인 유효 동의도 즉시 평가한다. 여러 요청이
|
||||
기준을 충족하면 생성 시각·ID 순서상 가장 오래된 유효 요청 하나가 예약된다.
|
||||
예약 전환에 실패하면 정책 변경 transaction도 취소되어 부분 적용을 숨기지 않는다.
|
||||
|
||||
요청 모집은 생성 시각 + 모집 기간과 가오픈 - 준비 간격 중 이른 시각에 끝난다.
|
||||
미래 가오픈에는 현재 시점부터 준비 간격 이상의 여유가 있어야 하고 정식 오픈은
|
||||
가오픈보다 뒤여야 한다. 준비 간격을 늘리면 기존 대기 요청도 새 준비 시각이 지난
|
||||
경우 즉시 만료로 판정한다. 모집 기간 변경은 새 요청에 적용하고, 이미 수락된
|
||||
예약의 실행 시각과 patch source는 정책 변경으로 소급 변경하지 않는다.
|
||||
|
||||
## 저장·예약·실행
|
||||
|
||||
`GatewayOpenRequest`는 원본 설치 설정, 시나리오 표시 snapshot, 요청자, 기수
|
||||
식별자, 만료·가오픈·오픈 시각과 결정 정보를 저장한다. `GatewayOpenEndorsement`는
|
||||
(request, user) PK로 한 사람의 중복 동의를 막는다. 요청자는 자기 요청에 동의할
|
||||
수 없다. 한 사람은 한 서버에 유효 대기 요청 하나만 둘 수 있으며, 수정하려면
|
||||
철회 후 다시 제출한다. 같은 설정의 제출 재시도는 기존 요청 ID를 반환한다.
|
||||
|
||||
```text
|
||||
PENDING -> RESERVED 오프너 수락 또는 유효 동의 기준 충족
|
||||
-> WITHDRAWN 요청자 철회
|
||||
-> REJECTED 오프너 거절
|
||||
-> SUPERSEDED 다른 요청 예약 / 서버 재오픈 / 기수 변경
|
||||
-> EXPIRED 모집 또는 준비 시작 시각 도달
|
||||
```
|
||||
|
||||
목록과 mutation은 현재 시각·서버·기수를 기준으로 실효 상태를 판정한다. 만료된
|
||||
행은 목록에서 즉시 빠지며 DB에서는 삭제하지 않는다. 다음 요청 접수 때 오래된
|
||||
PENDING 행의 저장 상태도 정리한다. 서버별 유효 대기 요청은 최대 100개다.
|
||||
|
||||
수락·동의, 요청 상태와 연결 RESET 생성, 등록 로그와 나머지 대기 요청의 종료는
|
||||
하나의 PostgreSQL transaction이다. 기존 operation admission/claim과 같은 advisory
|
||||
lock을 먼저 잡고 profile row를 잠근다. 다른 활성 서버 작업이나 예약이 있으면
|
||||
새 예약을 만들지 않는다. 수락 재시도와 수락·동의 경합도 RESET 하나만 만든다.
|
||||
|
||||
예약의 `scheduledAt`은 서버가 **가오픈 - buildLeadMinutes**로 계산한다. 요청자는
|
||||
build 시각, branch, commit, actor ID를 지정할 수 없다. 수락 시 현재 서버의
|
||||
`releaseSource`를 읽고 시나리오와 release build 호환성을 검증한다. BRANCH는 기존
|
||||
RESET과 같이 실행 시점 branch head를 해석하여 고정하고 COMMIT은 고정 SHA를
|
||||
사용한다. 기존 active workspace를 재사용할 수 있으면 build를 생략하며 migration과
|
||||
seed는 예정된 준비 시각부터 진행한다. 예약 공지는 기존 `publicAnnouncement`
|
||||
projection으로 즉시 공개된다. 대기 중 profile을 `RESERVED`로 바꾸지 않으며 기존
|
||||
RESERVED의 build·migration·seed 완료 후 가오픈 대기 의미를 보존한다.
|
||||
|
||||
worker는 실행 진입, build 이후와 초기화 전 서버 상태·기수·정식 오픈 deadline을
|
||||
다시 확인한다. 서버가 다시 열렸거나 기수가 바뀌었거나 정식 오픈까지 지나면 오래된
|
||||
요청으로 게임을 초기화하지 않고 실패로 남긴다. build가 가오픈을 넘기면 기존 예약
|
||||
공지의 지연 판정을 사용한다. 준비 실패·취소 상태는 요청 목록과 관리자 operation
|
||||
이력에서 확인하며 기존 취소·lease·재시도 경로로 처리한다. 새 기수 게시 이후 실패는
|
||||
오래된 예약의 재실행을 차단하므로 현 상태를 확인한 후 기존 운영 복구 경로를 쓴다.
|
||||
|
||||
## 주요 경계 사례
|
||||
|
||||
| 경우 | 결과 |
|
||||
| ------------------------------------------------ | ------------------------------------------------------ |
|
||||
| COMPLETED / STOPPED / CANCELLED | 정식 사용자의 요청 허용 |
|
||||
| RUNNING / PREOPEN / PAUSED / RESERVED / DISABLED | 새 요청과 예약 전환 거부 |
|
||||
| 무인증·오래된 session·타 서버 오프너 | 거부, 요청·동의·operation mutation 없음 |
|
||||
| 유예 계정·만료/타 서버 특수 허용·제재·탈퇴 예약 | 참여 거부 |
|
||||
| 한 사람이 여러 session에서 동의 | 같은 동의 한 개, 추가 가산 없음 |
|
||||
| 동의자 자격 회수 | 유효 숫자·자동 예약 집계에서 제외 |
|
||||
| 요청자 자격 회수 | 수락·자동 예약 전환 거부 |
|
||||
| 동시 임계 동의·동시 수락·서로 다른 요청 수락 | RESET 하나만 생성 |
|
||||
| build source/시나리오 불일치·호환성 실패 | 예약 없음, 마지막 동의도 rollback; 수정 후 재시도 가능 |
|
||||
| 이미 예약된 요청 철회 | 거부; 오프너가 기존 서버 작업 취소 경로 사용 |
|
||||
| DB/API 재시작·worker 재시작 | DB 요청·고유 동의·기존 durable RESET/lease로 복구 |
|
||||
| 오래된 예약과 새 게임 / 정식 오픈 deadline 경과 | worker가 초기화 전 거부 |
|
||||
|
||||
## Migration과 검증
|
||||
|
||||
추가 migration은 `20261022000000_add_open_requests`이다. Gateway migration을 먼저
|
||||
적용하고 API·orchestrator·frontend를 함께 갱신한다. additive 변경이므로 기존 계정과
|
||||
예약을 변환하거나 삭제하지 않는다. downgrade할 때는 기존 community 예약을 먼저
|
||||
확인하고 취소·처리한다. 구형 worker에는 새 기수/deadline 보호가 없기 때문이다.
|
||||
DB rollback은 신규 테이블 삭제가 아니라 사전 backup/restore 절차를 따른다.
|
||||
|
||||
격리된 개발 DB/Redis instance에서 다음을 실행한다. URL은 환경으로 전달하고 실제
|
||||
credential을 CLI argument나 log로 출력하지 않는다.
|
||||
|
||||
```sh
|
||||
pnpm --filter @sammo-ts/infra prisma:migrate:deploy:gateway
|
||||
pnpm --filter @sammo-ts/infra prisma:migrate:deploy:gateway
|
||||
# OPEN_REQUEST_DATABASE_URL: 이 검증 전용 DB/schema
|
||||
pnpm --filter @sammo-ts/infra verify:migration:open-requests
|
||||
pnpm --filter @sammo-ts/gateway-api test openRequests.integration.test.ts
|
||||
# 먼저 /gateway prefix의 production frontend를 build한 뒤 실제 HTTP+DB Chromium 검증
|
||||
OPEN_REQUEST_BROWSER=1 pnpm --filter @sammo-ts/gateway-api test openRequests.integration.test.ts
|
||||
PLAYWRIGHT_GATEWAY_FRONTEND_PORT=15272 pnpm --filter @sammo-ts/gateway-frontend exec playwright test open-suggestion.spec.ts --config e2e/playwright.config.mjs
|
||||
```
|
||||
|
||||
Integration의 Git catalog/호환성 adapter는 fixture다. 실제 PostgreSQL, Fastify/tRPC,
|
||||
session 검증, 요청·동의·수락과 operation persistence를 실행하고, 선택적으로 같은
|
||||
서버에 production frontend와 실제 Chromium을 연결한다. screenshot·DOM·geometry와
|
||||
computed style은 `test-results/open-request-live/`, UI fixture 결과는
|
||||
`test-results/server-operations/`에 보존한다. fixture 성공은 운영 배포나 실제 외부
|
||||
builder 실행의 증거가 아니다. 조건부 CI runner는 gateway_runtime 전용 schema에서
|
||||
이 integration을 실행하며 browser는 별도 opt-in이다.
|
||||
@@ -4,6 +4,9 @@
|
||||
되돌리는 현재 운영 경계를 설명합니다. Profile은 Gateway orchestrator가,
|
||||
Gateway 전체는 별도 release-controller가 처리합니다.
|
||||
|
||||
일반 사용자의 다음 기수 요청·동의·오프너 수락과 서버별 준비 정책은
|
||||
[Gateway 오픈 요청](gateway-open-requests.md)을 참조합니다.
|
||||
|
||||
## 구성과 권한
|
||||
|
||||
| 대상 | 요청 경로 | 실행자 | 영속 상태 |
|
||||
@@ -87,7 +90,10 @@ server build는 기존 Turbo cache를 재사용할 수 있지만, 자원 보호
|
||||
source와 공개 build-time 값이 다른 frontend artifact를 cache hit로 잘못 복원하지
|
||||
않습니다. Profile base path와 API/SSE URL은 공용 bundle 입력이 아니라 게시 시점의
|
||||
runtime JSON config이므로 profile 사이에서 의도적으로 같은 cache key를 사용합니다.
|
||||
일반 개발용 `pnpm --filter <frontend> build`의 typecheck 계약은 그대로입니다.
|
||||
일반 개발용 `pnpm --filter <frontend> build`도 Vue 타입 검사를 한 번 실행합니다.
|
||||
일반 Turbo build는 이 내장 검사와 별도 frontend `typecheck`를 중복 실행하지 않습니다.
|
||||
Vue CLI는 [빌드 도구](build-performance.md)의 전용 bridge를 사용하고, server 선언 파일은
|
||||
고정 native TypeScript가 파일별로 생성합니다. 선언·JS source map은 유지합니다.
|
||||
`NODE_OPTIONS`와 `RAYON_NUM_THREADS`는 출력에는 영향을 주지 않는 resource 제한으로
|
||||
build child에 전달됩니다.
|
||||
- `TURN_DAEMON_NODE_OPTIONS`가 설정되어 있으면 Gateway orchestrator는 그 값을
|
||||
|
||||
@@ -43,7 +43,7 @@ typecheck·unit·Chromium 검증을 실행한 것으로 설명하지 않습니
|
||||
## 새 worktree 준비
|
||||
|
||||
Playwright 설정의 `webServer`는 frontend package의 `dev` 또는
|
||||
`vue-tsc && vite build`를 직접 실행합니다. 이 경로는 root Turbo task가
|
||||
Vue 타입 검사와 `vite build`를 직접 실행합니다. 이 경로는 root Turbo task가
|
||||
보장하는 Prisma 생성과 upstream package build를 거치지 않으므로, 새 worktree에서
|
||||
브라우저 test를 먼저 실행하면 제품 코드와 무관한 module/type 오류가 발생할 수
|
||||
있습니다.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# 토너먼트의 사용자·장수 권한과 운영 전환
|
||||
|
||||
참가와 베팅의 actor는 인증된 사용자이며 장수 ID는 actor의 현재 소유 자원입니다.
|
||||
API 조회와 ENGINE 실행 사이에 소유권이 바뀔 수 있으므로
|
||||
`tournamentAdjustGeneral`은 `userId`와 `generalId`를 함께 전달하고 durable
|
||||
`InputEvent.actorUserId` 및 실행 시점 world의 소유자를 다시 검사합니다. 다른
|
||||
소유자의 금·통계는 차감과 보상 모두 변경하지 않습니다.
|
||||
|
||||
참가비는 현재 개발비, 베팅은 최소 10금·사용자별 합계 1000금·차감 후 500금
|
||||
이상을 유지합니다. 금과 `betgold`는 한 ENGINE event에서 갱신합니다. 베팅은
|
||||
기존처럼 SUSPENDED의 동결된 게임 시각에도 허용하고 참가 명령은 허용하지
|
||||
않습니다. RECONCILING에서는 베팅 금융 명령을 새로 claim하지 않습니다.
|
||||
|
||||
Redis 베팅 row의 `userId`는 서버가 인증 session에서 기록합니다. 사용자별
|
||||
한도와 개인 합계는 장수 교체 후에도 이 ID로 검사합니다. `generalId`·`targetId`가
|
||||
같아도 사용자가 다르면 row를 합치지 않습니다. NPC row는 `userId: null`, 구형
|
||||
row는 field 생략으로 구분합니다. 당첨금·취소 환급의 장수 ID 기준 자산 정산은
|
||||
Ref의 기존 계약을 유지합니다. 당시 사용자의 개인 표시와 장수의 자산 귀속은
|
||||
동일한 개념이 아닙니다.
|
||||
|
||||
NPC worker는 deterministic 계획을 먼저 저장하고 `tournamentSeedNpcBets`의
|
||||
durable 결과를 기다립니다. ENGINE은 유저가 소유하게 된 NPC를 건너뛰고 남은
|
||||
NPC의 금·통계를 함께 처리합니다. worker는 실제 처리된 ID만 베팅 projection에
|
||||
기록한 뒤 stage 6을 공개합니다. 응답이 불확실하면 같은 request ID와 계획으로
|
||||
재시도하며 NPC를 새로 선택하지 않습니다. 계획/RNG 생성 순서는 유지합니다.
|
||||
|
||||
## 구형 설치에서 전환
|
||||
|
||||
API·ENGINE·tournament worker를 같은 변경 세대로 전환해야 합니다. 새 API만
|
||||
배포하면 구형 ENGINE은 새 명령을 처리할 수 없고, 구형 API만 남기면 새 ENGINE은
|
||||
actor 없는 구형 토너먼트 `adjustGeneralResources/Meta` 명령을 거부합니다.
|
||||
종료된 베팅과 정상 완료된 큐를 확인한 뒤 전환하는 것을 기본으로 합니다.
|
||||
|
||||
현재 owner로 구형 row의 당시 사용자를 추정하지 않습니다. 사용자 ID가 없는 row는
|
||||
전체 풀·기존 정산에 유지하지만 개인 합계에 표시하지 않으며, 해당 베팅에 신규
|
||||
베팅을 허용하지 않습니다. 활성 베팅을 유지해야 한다면 관리자가 당시 사용자
|
||||
정보를 확인한 row만 `setBettingEntries`로 복원하고 NPC에는 null을 지정합니다.
|
||||
근거가 없는 row를 현재 owner로 자동 backfill하거나 큐/베팅을 임의 삭제하지 않습니다.
|
||||
|
||||
이전 버전의 NPC 비용/통계 두 명령이 일부만 실행된 상태라면 동일 계획의 새
|
||||
atomic 명령을 바로 재실행하지 않습니다. 기존 InputEvent 결과와 자원·통계를
|
||||
대조하여 완료 여부를 먼저 복구해야 합니다. 이 변경은 자동 saga migration을
|
||||
제공하지 않습니다.
|
||||
|
||||
ENGINE transaction과 Redis projection은 별도 저장소입니다. projection 쓰기 실패
|
||||
뒤 compensation을 시도하지만 그 사이 소유자가 바뀌면 다른 owner의 자원을
|
||||
수정하지 않도록 거부합니다. 전체 PostgreSQL/Redis crash recovery와 자동
|
||||
reconciliation을 보장하지 않으며 기존 saga의 한계는 유지됩니다.
|
||||
+2
-1
@@ -56,6 +56,7 @@
|
||||
"@types/node": "^26.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.67.0",
|
||||
"@typescript-eslint/parser": "^8.67.0",
|
||||
"@typescript/native": "npm:typescript@7.0.2",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
@@ -67,9 +68,9 @@
|
||||
"prisma": "^7.10.0",
|
||||
"tsdown": "^0.23.0",
|
||||
"turbo": "^2.11.3",
|
||||
"@typescript/native": "npm:typescript@7.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "^8.67.0",
|
||||
"typescript-native-bridge": "6.0.3-bridge.18.tsgo.7.0.2",
|
||||
"vitepress": "1.6.4",
|
||||
"vue-eslint-parser": "^10.4.1"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
const MAX_ORIGINS = 32;
|
||||
const MAX_ORIGIN_LENGTH = 2048;
|
||||
|
||||
const parsePublicUrl = (value: string): URL => {
|
||||
if (value.length > MAX_ORIGIN_LENGTH) throw new Error('API origin configuration is too long.');
|
||||
const url = new URL(value);
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
|
||||
throw new Error('API origins require a public HTTP(S) URL without credentials.');
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
/** Public application URLs supply the default origins; extra origins must be explicit. */
|
||||
export const resolveApiAllowedOrigins = (
|
||||
extraOrigins: string | undefined,
|
||||
publicUrls: readonly (string | undefined)[]
|
||||
): string[] => {
|
||||
const origins = new Set<string>();
|
||||
for (const value of publicUrls) {
|
||||
if (value?.trim()) origins.add(parsePublicUrl(value.trim()).origin);
|
||||
}
|
||||
if (extraOrigins?.trim()) {
|
||||
const entries = extraOrigins.split(',');
|
||||
if (entries.length > MAX_ORIGINS) throw new Error('Too many API allowed origins.');
|
||||
for (const value of entries) {
|
||||
const url = parsePublicUrl(value.trim());
|
||||
if (url.pathname !== '/' || url.search || url.hash) {
|
||||
throw new Error('API_ALLOWED_ORIGINS entries must be origins, without paths or queries.');
|
||||
}
|
||||
origins.add(url.origin);
|
||||
}
|
||||
}
|
||||
if (origins.size > MAX_ORIGINS) throw new Error('Too many API allowed origins.');
|
||||
return [...origins];
|
||||
};
|
||||
|
||||
class ApiOriginForbiddenError extends Error {
|
||||
readonly statusCode = 403;
|
||||
|
||||
constructor() {
|
||||
super('Request origin is not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
/** Reject browser requests before parsing bodies or dispatching mutations, not just CORS responses. */
|
||||
export const createApiOriginGuard = (allowedOrigins: readonly string[]) => {
|
||||
const allowed = new Set(allowedOrigins);
|
||||
return async (request: { headers: { origin?: string | string[] } }): Promise<void> => {
|
||||
const origin = request.headers.origin;
|
||||
// Internal machine clients have no Origin and still require their ordinary auth/HMAC.
|
||||
if (origin === undefined) return;
|
||||
if (typeof origin !== 'string' || origin.length > MAX_ORIGIN_LENGTH || !allowed.has(origin)) {
|
||||
throw new ApiOriginForbiddenError();
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -33,6 +33,7 @@ export * from './game/accessPenalty.js';
|
||||
export * from './http/trpcTransport.js';
|
||||
export * from './http/safeLogger.js';
|
||||
export * from './http/errorPrivacy.js';
|
||||
export * from './http/apiOrigin.js';
|
||||
export * from './webPush/types.js';
|
||||
export { canReadPlayAudit, canReadPlayAuditAccounts } from './auth/playAudit.js';
|
||||
|
||||
|
||||
@@ -307,6 +307,21 @@ export type TurnDaemonCommand =
|
||||
| { kind: 'nationPriority'; priority: string[] }
|
||||
| { kind: 'generalPriority'; priority: string[] };
|
||||
}
|
||||
| {
|
||||
type: 'tournamentAdjustGeneral';
|
||||
requestId?: string;
|
||||
reason: 'tournamentJoin' | 'tournamentJoinRollback' | 'tournamentBet' | 'tournamentBetRollback';
|
||||
userId: string;
|
||||
generalId: number;
|
||||
goldDelta: number;
|
||||
betGoldDelta?: number;
|
||||
minGoldAfter: number;
|
||||
}
|
||||
| {
|
||||
type: 'tournamentSeedNpcBets';
|
||||
requestId?: string;
|
||||
bets: Array<{ generalId: number; amount: number }>;
|
||||
}
|
||||
| {
|
||||
type: 'adjustGeneralResources';
|
||||
requestId?: string;
|
||||
@@ -701,6 +716,28 @@ export type TurnDaemonCommandResult =
|
||||
nationId?: number;
|
||||
currentUpdatedAt?: string | null;
|
||||
}
|
||||
| {
|
||||
type: 'tournamentAdjustGeneral';
|
||||
ok: true;
|
||||
generalId: number;
|
||||
}
|
||||
| {
|
||||
type: 'tournamentAdjustGeneral';
|
||||
ok: false;
|
||||
code: 'FORBIDDEN' | 'BAD_REQUEST';
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: 'tournamentSeedNpcBets';
|
||||
ok: true;
|
||||
processedGeneralIds: number[];
|
||||
skippedGeneralIds: number[];
|
||||
}
|
||||
| {
|
||||
type: 'tournamentSeedNpcBets';
|
||||
ok: false;
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: 'adjustGeneralResources';
|
||||
ok: true;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createApiOriginGuard, resolveApiAllowedOrigins } from '../src/http/apiOrigin.js';
|
||||
|
||||
describe('public API origin policy', () => {
|
||||
it('derives exact origins from application URLs and explicit extra origins', async () => {
|
||||
const origins = resolveApiAllowedOrigins('http://localhost:5173,https://sam.hided.net', [
|
||||
'https://sam.hided.net/gateway/',
|
||||
'https://sam.hided.net/che/',
|
||||
]);
|
||||
expect(origins).toEqual(['https://sam.hided.net', 'http://localhost:5173']);
|
||||
const guard = createApiOriginGuard(origins);
|
||||
await expect(guard({ headers: {} })).resolves.toBeUndefined();
|
||||
for (const origin of origins) await expect(guard({ headers: { origin } })).resolves.toBeUndefined();
|
||||
for (const origin of [
|
||||
'https://untrusted.example',
|
||||
'https://sam.hided.net.untrusted.example',
|
||||
'http://sam.hided.net',
|
||||
'https://sam.hided.net:8443',
|
||||
'null',
|
||||
'',
|
||||
'https://sam.hided.net,https://untrusted.example',
|
||||
'x'.repeat(2049),
|
||||
['https://sam.hided.net', 'https://untrusted.example'],
|
||||
]) {
|
||||
await expect(guard({ headers: { origin } })).rejects.toMatchObject({ statusCode: 403 });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
'*',
|
||||
'null',
|
||||
'https://user:password@example.com',
|
||||
'file:///tmp/example',
|
||||
'https://x.test/path',
|
||||
'https://x.test?query',
|
||||
'https://x.test#fragment',
|
||||
'https://x.test,',
|
||||
])('rejects unsafe configuration %s', (value) => {
|
||||
expect(() => resolveApiAllowedOrigins(value, [])).toThrow();
|
||||
});
|
||||
|
||||
it('bounds configuration and fails closed when no public origin is configured', async () => {
|
||||
expect(() =>
|
||||
resolveApiAllowedOrigins(Array.from({ length: 33 }, (_, i) => `https://${i}.test`).join(','), [])
|
||||
).toThrow();
|
||||
expect(() => resolveApiAllowedOrigins('https://' + 'a'.repeat(2048) + '.test', [])).toThrow();
|
||||
await expect(
|
||||
createApiOriginGuard([])({ headers: { origin: 'https://untrusted.example' } })
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,11 @@
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": false
|
||||
"composite": false,
|
||||
"paths": {
|
||||
"@sammo-ts/common": ["./src/index.ts"],
|
||||
"@sammo-ts/common/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineConfig } from 'tsdown';
|
||||
import { emitDeclarations } from '../../tools/build-scripts/emit-declarations.mjs';
|
||||
|
||||
export default defineConfig({
|
||||
entry: {
|
||||
@@ -20,9 +21,8 @@ export default defineConfig({
|
||||
},
|
||||
format: 'es',
|
||||
outDir: 'dist',
|
||||
dts: {
|
||||
build: true,
|
||||
},
|
||||
dts: false,
|
||||
hooks: { 'build:done': emitDeclarations },
|
||||
sourcemap: true,
|
||||
target: 'node22',
|
||||
platform: 'node',
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"prisma:migrate:deploy:game": "prisma migrate deploy --schema prisma/game.prisma",
|
||||
"prisma:migrate:deploy:gateway": "PRISMA_SCHEMA=prisma/gateway.prisma prisma migrate deploy --schema prisma/gateway.prisma --config prisma.gateway.config.ts",
|
||||
"prisma:migrate:status:game": "prisma migrate status --schema prisma/game.prisma",
|
||||
"verify:migration:open-requests": "node scripts/verify-open-request-migration.mjs",
|
||||
"verify:migration:account-icon": "sh scripts/verify-account-icon-migration.sh",
|
||||
"verify:migration:kakao-talk": "sh scripts/verify-kakao-talk-migration.sh",
|
||||
"verify:migration:npc-selection": "sh scripts/verify-npc-selection-token-migration.sh",
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
CREATE TABLE "gateway_open_request" (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"profile_name" TEXT NOT NULL REFERENCES "gateway_profile"("profile_name") ON DELETE CASCADE,
|
||||
"requested_by" TEXT NOT NULL REFERENCES "app_user"("id") ON DELETE CASCADE,
|
||||
"status" TEXT NOT NULL DEFAULT 'PENDING',
|
||||
"install" JSONB NOT NULL,
|
||||
"announcement" JSONB NOT NULL,
|
||||
"season_key" TEXT NOT NULL,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"preopen_at" TIMESTAMP(3) NOT NULL,
|
||||
"open_at" TIMESTAMP(3) NOT NULL,
|
||||
"operation_id" TEXT UNIQUE REFERENCES "gateway_operation"("id") ON DELETE RESTRICT,
|
||||
"decided_by" TEXT,
|
||||
"decided_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "gateway_open_request_status_check" CHECK ("status" IN ('PENDING','RESERVED','EXPIRED','WITHDRAWN','REJECTED','SUPERSEDED')),
|
||||
CONSTRAINT "gateway_open_request_dates_check" CHECK ("created_at" < "expires_at" AND "expires_at" <= "preopen_at" AND "preopen_at" < "open_at"),
|
||||
CONSTRAINT "gateway_open_request_decision_check" CHECK (("status" = 'RESERVED') = ("operation_id" IS NOT NULL))
|
||||
);
|
||||
CREATE INDEX "gateway_open_request_profile_name_status_expires_at_idx" ON "gateway_open_request"("profile_name", "status", "expires_at");
|
||||
CREATE UNIQUE INDEX "gateway_open_request_one_pending_per_user" ON "gateway_open_request"("profile_name", "requested_by") WHERE "status" = 'PENDING';
|
||||
CREATE TABLE "gateway_open_endorsement" (
|
||||
"request_id" TEXT NOT NULL REFERENCES "gateway_open_request"("id") ON DELETE CASCADE,
|
||||
"user_id" TEXT NOT NULL REFERENCES "app_user"("id") ON DELETE CASCADE,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY ("request_id", "user_id")
|
||||
);
|
||||
@@ -115,6 +115,8 @@ model AppUser {
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
lastLoginAt DateTime? @map("last_login_at")
|
||||
legacyData Json @default(dbgenerated("'{}'::jsonb")) @map("legacy_data")
|
||||
openRequests GatewayOpenRequest[]
|
||||
openEndorsements GatewayOpenEndorsement[]
|
||||
icons UserIcon[]
|
||||
specialAccessGrants SpecialAccountAccessGrant[]
|
||||
webPushSubscriptions WebPushSubscription[]
|
||||
@@ -254,6 +256,7 @@ model GatewayProfile {
|
||||
meta Json @default(dbgenerated("'{}'::jsonb"))
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
openRequests GatewayOpenRequest[]
|
||||
operations GatewayOperation[]
|
||||
runtimeActions GatewayRuntimeAction[]
|
||||
|
||||
@@ -407,6 +410,7 @@ model GatewayOperation {
|
||||
bulkRelease GatewayBulkRelease? @relation(fields: [bulkReleaseId], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
openRequest GatewayOpenRequest?
|
||||
logs GatewayOperationLog[]
|
||||
|
||||
@@index([status, scheduledAt, createdAt])
|
||||
@@ -513,3 +517,40 @@ model SystemSetting {
|
||||
|
||||
@@map("system")
|
||||
}
|
||||
|
||||
// Immutable proposal options; approval and operation creation commit atomically.
|
||||
model GatewayOpenRequest {
|
||||
id String @id @default(uuid())
|
||||
profileName String @map("profile_name")
|
||||
profile GatewayProfile @relation(fields: [profileName], references: [profileName], onDelete: Cascade, onUpdate: NoAction)
|
||||
requestedBy String @map("requested_by")
|
||||
requester AppUser @relation(fields: [requestedBy], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
status String @default("PENDING")
|
||||
install Json
|
||||
announcement Json
|
||||
seasonKey String @map("season_key")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
preopenAt DateTime @map("preopen_at")
|
||||
openAt DateTime @map("open_at")
|
||||
operationId String? @unique @map("operation_id")
|
||||
operation GatewayOperation? @relation(fields: [operationId], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
decidedBy String? @map("decided_by")
|
||||
decidedAt DateTime? @map("decided_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
endorsements GatewayOpenEndorsement[]
|
||||
|
||||
@@index([profileName, status, expiresAt])
|
||||
@@map("gateway_open_request")
|
||||
}
|
||||
|
||||
model GatewayOpenEndorsement {
|
||||
requestId String @map("request_id")
|
||||
request GatewayOpenRequest @relation(fields: [requestId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
userId String @map("user_id")
|
||||
user AppUser @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@id([requestId, userId])
|
||||
@@map("gateway_open_endorsement")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import assert from 'node:assert/strict';
|
||||
import pg from 'pg';
|
||||
|
||||
const sourceUrl = process.env.OPEN_REQUEST_DATABASE_URL;
|
||||
if (!sourceUrl) throw new Error('OPEN_REQUEST_DATABASE_URL must point to a disposable fixture instance.');
|
||||
const target = '20261022000000_add_open_requests';
|
||||
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const prismaRoot = path.join(packageRoot, 'prisma');
|
||||
const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'open-request-migration-'));
|
||||
const schema = `open_request_upgrade_${Date.now()}`;
|
||||
const url = new URL(sourceUrl);
|
||||
url.searchParams.set('schema', schema);
|
||||
const client = new pg.Client({ connectionString: sourceUrl });
|
||||
const deploy = (config) => {
|
||||
const result = spawnSync('pnpm', ['exec', 'prisma', 'migrate', 'deploy', '--config', config], {
|
||||
cwd: packageRoot,
|
||||
env: { ...process.env, GATEWAY_DATABASE_URL: url.href },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
if (result.status !== 0)
|
||||
throw new Error(`Migration deploy failed (exit ${result.status}); datasource output suppressed.`);
|
||||
};
|
||||
try {
|
||||
await fs.copyFile(path.join(prismaRoot, 'gateway.prisma'), path.join(temp, 'gateway.prisma'));
|
||||
await fs.cp(path.join(prismaRoot, 'gateway-migrations'), path.join(temp, 'gateway-migrations'), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.rm(path.join(temp, 'gateway-migrations', target), { recursive: true });
|
||||
// Keep config inside this package so Prisma's config import resolves normally.
|
||||
const config = path.join(packageRoot, `.open-request-migration-${process.pid}.config.mjs`);
|
||||
const configText = `export default { schema: ${JSON.stringify(path.join(temp, 'gateway.prisma'))}, migrations: { path: ${JSON.stringify(path.join(temp, 'gateway-migrations'))} }, datasource: { url: process.env.GATEWAY_DATABASE_URL } };\n`;
|
||||
await fs.writeFile(config, configText);
|
||||
try {
|
||||
deploy(config);
|
||||
} finally {
|
||||
await fs.rm(config);
|
||||
}
|
||||
await client.connect();
|
||||
await client.query(`SET search_path TO "${schema}", public`);
|
||||
const userId = randomUUID(),
|
||||
profileName = 'opening-upgrade:default';
|
||||
await client.query(
|
||||
'INSERT INTO app_user(id,login_id,display_name,password_hash,password_salt,updated_at) VALUES($1,$2,$2,$3,$3,NOW())',
|
||||
[userId, 'fixture-user', 'fixture-only']
|
||||
);
|
||||
await client.query(
|
||||
'INSERT INTO gateway_profile(profile_name,profile,instance_key,scenario,api_port,status,updated_at) VALUES($1,$2,$3,$3,15995,$4,NOW())',
|
||||
[profileName, 'opening-upgrade', 'default', 'STOPPED']
|
||||
);
|
||||
deploy(path.join(packageRoot, 'prisma.gateway.config.ts'));
|
||||
const after = await client.query('SELECT COUNT(*)::int AS count FROM _prisma_migrations');
|
||||
deploy(path.join(packageRoot, 'prisma.gateway.config.ts'));
|
||||
assert.deepEqual((await client.query('SELECT COUNT(*)::int AS count FROM _prisma_migrations')).rows, after.rows);
|
||||
assert.equal((await client.query('SELECT id FROM app_user WHERE id=$1', [userId])).rowCount, 1);
|
||||
const requestId = randomUUID();
|
||||
await client.query(
|
||||
`INSERT INTO gateway_open_request(id,profile_name,requested_by,install,announcement,season_key,expires_at,preopen_at,open_at,updated_at)
|
||||
VALUES($1,$2,$3,'{}','{}','fixture',NOW()+INTERVAL '1 day',NOW()+INTERVAL '2 days',NOW()+INTERVAL '3 days',NOW())`,
|
||||
[requestId, profileName, userId]
|
||||
);
|
||||
await client.query('INSERT INTO gateway_open_endorsement(request_id,user_id) VALUES($1,$2)', [requestId, userId]);
|
||||
await assert.rejects(
|
||||
() =>
|
||||
client.query('INSERT INTO gateway_open_endorsement(request_id,user_id) VALUES($1,$2)', [requestId, userId]),
|
||||
{ code: '23505' }
|
||||
);
|
||||
await assert.rejects(
|
||||
() =>
|
||||
client.query(
|
||||
`INSERT INTO gateway_open_request(id,profile_name,requested_by,install,announcement,season_key,expires_at,preopen_at,open_at,updated_at)
|
||||
SELECT $1,profile_name,requested_by,install,announcement,season_key,expires_at,preopen_at,open_at,NOW() FROM gateway_open_request WHERE id=$2`,
|
||||
[randomUUID(), requestId]
|
||||
),
|
||||
{ code: '23505' }
|
||||
);
|
||||
await assert.rejects(
|
||||
() => client.query("UPDATE gateway_open_request SET status='RESERVED' WHERE id=$1", [requestId]),
|
||||
{ code: '23514' }
|
||||
);
|
||||
await assert.rejects(
|
||||
() => client.query('UPDATE gateway_open_request SET open_at=preopen_at WHERE id=$1', [requestId]),
|
||||
{ code: '23514' }
|
||||
);
|
||||
await assert.rejects(
|
||||
() =>
|
||||
client.query('INSERT INTO gateway_open_endorsement(request_id,user_id) VALUES($1,$2)', [
|
||||
randomUUID(),
|
||||
userId,
|
||||
]),
|
||||
{ code: '23503' }
|
||||
);
|
||||
console.log(
|
||||
`Opening migration: predecessor upgrade, preserved rows, repeated deploy no-op, unique/FK/CHECK constraints passed (${schema}).`
|
||||
);
|
||||
} finally {
|
||||
await client.end();
|
||||
await fs.rm(temp, { recursive: true, force: true });
|
||||
}
|
||||
@@ -3,7 +3,11 @@
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": false
|
||||
"composite": false,
|
||||
"paths": {
|
||||
"@sammo-ts/infra": ["./src/index.ts"],
|
||||
"@sammo-ts/infra/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -16,8 +16,11 @@
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"useUnknownInCatchVariables": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
"noUnusedParameters": true,
|
||||
"paths": {
|
||||
"@sammo-ts/logic": ["./src/index.ts"],
|
||||
"@sammo-ts/logic/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "../common" }]
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
Generated
+74
@@ -241,6 +241,9 @@ importers:
|
||||
typescript-eslint:
|
||||
specifier: ^8.67.0
|
||||
version: 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
|
||||
typescript-native-bridge:
|
||||
specifier: 6.0.3-bridge.18.tsgo.7.0.2
|
||||
version: 6.0.3-bridge.18.tsgo.7.0.2
|
||||
vitepress:
|
||||
specifier: 1.6.4
|
||||
version: 1.6.4(@algolia/client-search@5.56.0)(@types/node@26.2.0)(@types/react@19.2.18)(jiti@2.7.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(search-insights@2.17.3)(sortablejs@1.14.0)(tsx@4.23.12)(typescript@6.0.3)
|
||||
@@ -2713,6 +2716,41 @@ packages:
|
||||
resolution: {integrity: sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-native-bridge/darwin-arm64@6.0.3-bridge.18.tsgo.7.0.2':
|
||||
resolution: {integrity: sha512-cuLVc0OVh1u4xyo9fQcJAxMca3MJqEJs3TSN0QhGPbYv5T4gYBiHSe69HWL7A27PyaSFuPMTgzN9RqoqlYALAw==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@typescript-native-bridge/darwin-x64@6.0.3-bridge.18.tsgo.7.0.2':
|
||||
resolution: {integrity: sha512-Wj/DkI9BcWESykr2DhWqxxwhQBzaKaRcoRGxa67gD/VkJWpVkbbeLyxobjnNDF7e6xnGleHKAm/3fPKzfbqMWg==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@typescript-native-bridge/linux-arm64@6.0.3-bridge.18.tsgo.7.0.2':
|
||||
resolution: {integrity: sha512-4oxA689R+Ht1ikOCAGCIqIdZSk7orbNiSGV0eqGw7h9Q6+xrqYlnvcPtETayqLp6Q9gzEP3wCraDV9y+tSrA3Q==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@typescript-native-bridge/linux-arm@6.0.3-bridge.18.tsgo.7.0.2':
|
||||
resolution: {integrity: sha512-PuvMjc/pq41L28jUMpODJdknKSEqL1MfBT2mg8QiB1SAGTzuGd38Zsy1h6a+eGHqKU8M4laEZEHdkM39U54pTw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@typescript-native-bridge/linux-x64@6.0.3-bridge.18.tsgo.7.0.2':
|
||||
resolution: {integrity: sha512-XWAX1N0n1lV6pfMT0Bu7m12SN2iyMNCuNXD5LvjMzErzFR22goNNMG+91jm5HeCSNnZzLA6nvrHXv84/XAVi0g==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@typescript-native-bridge/win32-arm64@6.0.3-bridge.18.tsgo.7.0.2':
|
||||
resolution: {integrity: sha512-agYZUX12eAPBFXP5mihuOQ1liH5Yrdt98uGe2kMY2+slinsFOdf1gjoGCHIykCZ8862Xtz92SkmIr9o2VDZRPw==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@typescript-native-bridge/win32-x64@6.0.3-bridge.18.tsgo.7.0.2':
|
||||
resolution: {integrity: sha512-nJu277PWZIb4/bmWN9ZREgZG+BwEVRcnnfVwpD5QI0n32G9ZgU7x0Q4PpvvxLmZz9xB4XcBAyoV3grw4RtRRNQ==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@typescript/typescript-aix-ppc64@7.0.2':
|
||||
resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
@@ -5032,6 +5070,11 @@ packages:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: 6.0.3
|
||||
|
||||
typescript-native-bridge@6.0.3-bridge.18.tsgo.7.0.2:
|
||||
resolution: {integrity: sha512-otfFv4X9MRIBjwFAcPJYtr+S79xd/eE0nJjJ5CoU4KfYDh7+ve5nDfuu1tiGETPYUUnlPvWxGKDNqdv2w34ECg==}
|
||||
engines: {node: '>=20.19'}
|
||||
hasBin: true
|
||||
|
||||
typescript@6.0.3:
|
||||
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
|
||||
engines: {node: '>=14.17'}
|
||||
@@ -6917,6 +6960,27 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.67.0
|
||||
eslint-visitor-keys: 5.0.1
|
||||
|
||||
'@typescript-native-bridge/darwin-arm64@6.0.3-bridge.18.tsgo.7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript-native-bridge/darwin-x64@6.0.3-bridge.18.tsgo.7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript-native-bridge/linux-arm64@6.0.3-bridge.18.tsgo.7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript-native-bridge/linux-arm@6.0.3-bridge.18.tsgo.7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript-native-bridge/linux-x64@6.0.3-bridge.18.tsgo.7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript-native-bridge/win32-arm64@6.0.3-bridge.18.tsgo.7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript-native-bridge/win32-x64@6.0.3-bridge.18.tsgo.7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-aix-ppc64@7.0.2':
|
||||
optional: true
|
||||
|
||||
@@ -9225,6 +9289,16 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
typescript-native-bridge@6.0.3-bridge.18.tsgo.7.0.2:
|
||||
optionalDependencies:
|
||||
'@typescript-native-bridge/darwin-arm64': 6.0.3-bridge.18.tsgo.7.0.2
|
||||
'@typescript-native-bridge/darwin-x64': 6.0.3-bridge.18.tsgo.7.0.2
|
||||
'@typescript-native-bridge/linux-arm': 6.0.3-bridge.18.tsgo.7.0.2
|
||||
'@typescript-native-bridge/linux-arm64': 6.0.3-bridge.18.tsgo.7.0.2
|
||||
'@typescript-native-bridge/linux-x64': 6.0.3-bridge.18.tsgo.7.0.2
|
||||
'@typescript-native-bridge/win32-arm64': 6.0.3-bridge.18.tsgo.7.0.2
|
||||
'@typescript-native-bridge/win32-x64': 6.0.3-bridge.18.tsgo.7.0.2
|
||||
|
||||
typescript@6.0.3: {}
|
||||
|
||||
typescript@7.0.2:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"controllerProtocol": 2,
|
||||
"gatewaySchemaHead": "20260825000000_add_bulk_release_batches",
|
||||
"gatewaySchemaHead": "20261022000000_add_open_requests",
|
||||
"gameSchemaHead": "20260916080000_index_play_audit_decision_month",
|
||||
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { BuildContext } from 'tsdown';
|
||||
|
||||
export declare const emitDeclarations: (context: BuildContext) => Promise<void>;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
|
||||
// JS bundling stays in tsdown. Native TypeScript emits the complete declaration
|
||||
// tree instead of repeatedly bundling large inferred Prisma/tRPC type graphs.
|
||||
/**
|
||||
* @param {import('tsdown').BuildContext} context
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const emitDeclarations = async ({ options }) => {
|
||||
const manifest = JSON.parse(await readFile(path.join(options.cwd, 'package.json'), 'utf8'));
|
||||
// logic's existing wildcard export exposes dist/src/*. Keep one canonical
|
||||
// declaration tree so private class members retain their identity across
|
||||
// root and subpath imports, and keep the wildcard JS entry points working.
|
||||
const sourceExports = manifest.exports?.['./*'] === './dist/src/*';
|
||||
const args = [
|
||||
'-w',
|
||||
'tsc7',
|
||||
'-p',
|
||||
path.join(options.cwd, 'tsconfig.build.json'),
|
||||
...(sourceExports ? [] : ['--emitDeclarationOnly']),
|
||||
'--types',
|
||||
'node',
|
||||
'--outDir',
|
||||
sourceExports ? path.join(options.outDir, 'src') : options.outDir,
|
||||
];
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn('pnpm', args, { cwd: options.cwd, stdio: 'inherit' });
|
||||
child.once('error', reject);
|
||||
child.once('close', (code, signal) => {
|
||||
if (code === 0) resolve(undefined);
|
||||
else reject(new Error(`Declaration emission failed (${signal ?? code ?? 'unknown'}): ${options.cwd}`));
|
||||
});
|
||||
});
|
||||
if (sourceExports) {
|
||||
await writeFile(path.join(options.outDir, 'index.d.ts'), "export * from './src/index.js';\n");
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { emitDeclarations } from './emit-declarations.mjs';
|
||||
|
||||
const workspaceRoot = path.resolve(import.meta.dirname, '../..');
|
||||
|
||||
test('native declarations preserve relative imports, subpaths and maps, and fail on type errors', async () => {
|
||||
const temporaryRoot = path.join(workspaceRoot, '.turbo');
|
||||
await mkdir(temporaryRoot, { recursive: true });
|
||||
const fixture = await mkdtemp(path.join(temporaryRoot, 'declaration-test-'));
|
||||
const outDir = path.join(fixture, 'dist');
|
||||
try {
|
||||
await mkdir(path.join(fixture, 'src'));
|
||||
await writeFile(path.join(fixture, 'package.json'), JSON.stringify({ type: 'module' }));
|
||||
await writeFile(
|
||||
path.join(fixture, 'tsconfig.build.json'),
|
||||
JSON.stringify({
|
||||
extends: path.join(workspaceRoot, 'tsconfig.base.json'),
|
||||
compilerOptions: { rootDir: 'src', outDir: 'dist' },
|
||||
include: ['src'],
|
||||
})
|
||||
);
|
||||
await writeFile(
|
||||
path.join(fixture, 'src/model.ts'),
|
||||
'export class Model { private readonly id = 1; getId(): number { return this.id; } }\n'
|
||||
);
|
||||
await writeFile(path.join(fixture, 'src/index.ts'), "export { Model } from './model.js';\n");
|
||||
await emitDeclarations({ options: { cwd: fixture, outDir } });
|
||||
assert.match(await readFile(path.join(outDir, 'index.d.ts'), 'utf8'), /from ['"]\.\/model\.js['"]/u);
|
||||
assert.match(await readFile(path.join(outDir, 'model.d.ts'), 'utf8'), /getId\(\): number/u);
|
||||
const map = JSON.parse(await readFile(path.join(outDir, 'model.d.ts.map'), 'utf8'));
|
||||
assert.deepEqual(map.sources, ['../src/model.ts']);
|
||||
await assert.rejects(readFile(path.join(outDir, 'index.js')), { code: 'ENOENT' });
|
||||
|
||||
await writeFile(
|
||||
path.join(fixture, 'package.json'),
|
||||
JSON.stringify({ type: 'module', exports: { './*': './dist/src/*' } })
|
||||
);
|
||||
await emitDeclarations({ options: { cwd: fixture, outDir } });
|
||||
assert.equal(await readFile(path.join(outDir, 'index.d.ts'), 'utf8'), "export * from './src/index.js';\n");
|
||||
const { Model } = await import(pathToFileURL(path.join(outDir, 'src/model.js')).href);
|
||||
assert.equal(new Model().getId(), 1);
|
||||
await readFile(path.join(outDir, 'src/model.d.ts.map'));
|
||||
await readFile(path.join(outDir, 'src/model.js.map'));
|
||||
await writeFile(
|
||||
path.join(fixture, 'consumer.ts'),
|
||||
"import { Model } from './dist/index.js';\n" +
|
||||
"import { Model as SubpathModel } from './dist/src/model.js';\n" +
|
||||
'export const same: Model = new SubpathModel();\n'
|
||||
);
|
||||
await writeFile(
|
||||
path.join(fixture, 'tsconfig.consumer.json'),
|
||||
JSON.stringify({ extends: path.join(workspaceRoot, 'tsconfig.base.json'), files: ['consumer.ts'] })
|
||||
);
|
||||
execFileSync(
|
||||
'pnpm',
|
||||
['-w', 'tsc7', '-p', path.join(fixture, 'tsconfig.consumer.json'), '--noEmit', '--types', 'node'],
|
||||
{
|
||||
cwd: fixture,
|
||||
stdio: 'pipe',
|
||||
}
|
||||
);
|
||||
|
||||
await writeFile(path.join(fixture, 'src/model.ts'), 'export const id: number = "invalid";\n');
|
||||
await assert.rejects(emitDeclarations({ options: { cwd: fixture, outDir } }), /Declaration emission failed/u);
|
||||
} finally {
|
||||
await rm(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -7,7 +7,7 @@
|
||||
"build": "pnpm -w tsc7 -p tools/build-scripts/tsconfig.json",
|
||||
"dev": "node -e \"console.log('dev not configured')\"",
|
||||
"lint": "node -e \"console.log('lint not configured')\"",
|
||||
"test": "node -e \"console.log('test not configured')\"",
|
||||
"test": "node --test *.test.mjs",
|
||||
"typecheck": "pnpm -w tsc7 -b tools/build-scripts/tsconfig.json",
|
||||
"build:server": "node ./build-server.mjs"
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url';
|
||||
const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const expectedApiVersion = '6.0.3';
|
||||
const expectedCliVersion = '7.0.2';
|
||||
const expectedVueBridgeVersion = '6.0.3-bridge.18.tsgo.7.0.2';
|
||||
|
||||
const consumers = [
|
||||
['root', 'package.json'],
|
||||
@@ -35,4 +36,10 @@ if (cliVersion !== expectedCliVersion) {
|
||||
throw new Error(`native tsc resolved ${cliVersion}; expected ${expectedCliVersion}`);
|
||||
}
|
||||
|
||||
console.log(`TypeScript API ${expectedApiVersion}; native tsc ${expectedCliVersion}`);
|
||||
const requireFromRoot = createRequire(path.join(workspaceRoot, 'package.json'));
|
||||
const vueBridgeVersion = requireFromRoot('typescript-native-bridge/package.json').version;
|
||||
if (vueBridgeVersion !== expectedVueBridgeVersion) {
|
||||
throw new Error(`Vue bridge resolved ${vueBridgeVersion}; expected ${expectedVueBridgeVersion}`);
|
||||
}
|
||||
|
||||
console.log(`TypeScript API ${expectedApiVersion}; native tsc ${expectedCliVersion}; Vue bridge ${vueBridgeVersion}`);
|
||||
|
||||
@@ -5,6 +5,7 @@ CURRENT_SEASON_FIXTURE_DATABASE_URL external_fixture
|
||||
GATEWAY_RUNTIME_ACTION_DATABASE_URL gateway_runtime
|
||||
GATEWAY_OPERATION_DATABASE_URL gateway_runtime
|
||||
GATEWAY_RELEASE_DATABASE_URL gateway_runtime
|
||||
OPEN_REQUEST_DATABASE_URL gateway_runtime
|
||||
GENERAL_LIFECYCLE_DATABASE_URL core
|
||||
IMMEDIATE_ACTION_DATABASE_URL immediate_action
|
||||
INPUT_EVENT_DATABASE_URL core
|
||||
@@ -30,3 +31,4 @@ TURN_DIFFERENTIAL_DATABASE_URL core
|
||||
TURN_FULL_LIFECYCLE_PERSISTENCE_DATABASE_URL reference_full_lifecycle
|
||||
WEB_PUSH_GATEWAY_DATABASE_URL web_push_gateway
|
||||
TOURNAMENT_RECOVERY_DATABASE_URL external_fixture
|
||||
NPC_POLICY_DATABASE_URL external_fixture
|
||||
|
||||
|
@@ -712,6 +712,7 @@ gateway_runtime_database_url=$(build_database_url "$gateway_runtime_schema")
|
||||
)
|
||||
export GATEWAY_RUNTIME_ACTION_DATABASE_URL=$gateway_runtime_database_url
|
||||
export GATEWAY_OPERATION_DATABASE_URL=$gateway_runtime_database_url
|
||||
export OPEN_REQUEST_DATABASE_URL=$gateway_runtime_database_url
|
||||
export GATEWAY_RELEASE_DATABASE_URL=$gateway_runtime_database_url
|
||||
export GATEWAY_RUNTIME_INTEGRATION_SCHEMA=$gateway_runtime_schema
|
||||
run_marked_tests app/gateway-api \
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
// 개발 전용 .env.ci를 node --env-file로 전달한다. 매번 새 schema를 만들며
|
||||
// 기존 게임 schema·Redis key를 초기화하거나 volume을 삭제하지 않는다.
|
||||
for (const key of [
|
||||
'POSTGRES_HOST',
|
||||
'POSTGRES_PORT',
|
||||
'POSTGRES_DB',
|
||||
'POSTGRES_USER',
|
||||
'POSTGRES_PASSWORD',
|
||||
'REDIS_URL',
|
||||
]) {
|
||||
if (!process.env[key]) throw new Error(`Missing environment variable: ${key}`);
|
||||
}
|
||||
const schema = `ci_${Date.now()}_${process.pid}_npc_policy_fixture`;
|
||||
const url = new URL('postgresql://localhost');
|
||||
url.hostname = process.env.POSTGRES_HOST;
|
||||
url.port = process.env.POSTGRES_PORT;
|
||||
url.username = process.env.POSTGRES_USER;
|
||||
url.password = process.env.POSTGRES_PASSWORD;
|
||||
url.pathname = `/${process.env.POSTGRES_DB}`;
|
||||
url.searchParams.set('schema', schema);
|
||||
const env = { ...process.env, DATABASE_URL: url.href, NPC_POLICY_DATABASE_URL: url.href, POSTGRES_SCHEMA: schema };
|
||||
console.info(`NPC policy verification schema: ${schema}`);
|
||||
for (const args of [
|
||||
['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:game'],
|
||||
['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:game'],
|
||||
['--filter', '@sammo-ts/game-api', 'test', 'npcPolicyLifecycle.integration.test.ts'],
|
||||
]) {
|
||||
const result = spawnSync('pnpm', args, { env, stdio: 'inherit' });
|
||||
if (result.error) throw new Error(`Could not start verification command: ${result.error.code}`);
|
||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user