Author SHA1 Message Date
Hide_D 8c877b4a6f fix: 장수일람 특기 설명 줄바꿈을 공통 처리한다 2026-10-23 09:55:51 +09:00
Hide_D b3f7a419a9 운영 보안을 보존하여 빌드 개선 배포본 통합 2026-10-23 01:33:42 +09:00
Hide_D af51968782 빌드 선언 생성 병목과 Vue 중복 타입 검사를 개선 2026-10-23 01:33:34 +09:00
Hide_D b377250fb9 fix: 첫 빌드 전 닫힌 서버도 패치 정책으로 오픈 요청을 받는다 2026-10-23 00:42:11 +09:00
Hide_D d4a87c981f fix: 오픈 요청 화면의 복사 전용 안내를 실제 등록 흐름에 맞춘다 2026-10-23 00:35:54 +09:00
Hide_D e42eaa4e58 fix: 오픈 요청 관계의 갱신 규칙을 migration 제약과 맞춘다 2026-10-23 00:23:33 +09:00
Hide_D 2662091a6a merge: 최신 main의 NPC 정책과 운영 보완을 오픈 요청에 통합한다 2026-10-23 00:18:45 +09:00
Hide_D 236f1bc4ed feat: 정식 사용자의 오픈 요청과 동의 기반 예약을 영속화한다 2026-10-23 00:18:45 +09:00
Hide_D 8e5bd94cb8 NPC 사령턴에서 비활성 발령 절차를 건너뛰는지 검증한다 2026-10-23 00:01:51 +09:00
Hide_D e5a39cb714 최신 main의 수비 설정 수정을 NPC 정책 작업에 통합한다 2026-10-22 23:50:35 +09:00
Hide_D 039c5d2a59 NPC 정책 적용을 검증하고 자국 변경 이력 조회를 제공한다 2026-10-22 23:50:35 +09:00
Hide_D 856187b315 수비끔 전환에서 전투 훈련·사기 상한을 보존한다 2026-10-22 23:11:52 +09:00
Hide_D b67924a71d chore: Node.js 24.22.1 보안 릴리스로 개발 버전을 고정한다 2026-10-22 17:18:48 +09:00
73 changed files with 4028 additions and 274 deletions
+1 -1
View File
@@ -1 +1 @@
24.21.0 24.22.1
+4 -1
View File
@@ -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 `6.0.3`을 사용해 주세요. CLI project build/typecheck는 root의
`@typescript/native` alias로 고정한 TypeScript 7 `tsc`를 사용하며 package-local `@typescript/native` alias로 고정한 TypeScript 7 `tsc`를 사용하며 package-local
다른 compiler version이나 직접 `tsc` 호출을 추가하지 말아 주세요. 다른 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 스타일을 유지해 주세요. - TypeScript/JSON/Vue SFC는 기존 4-space 스타일을 유지해 주세요.
- public API는 명시적 타입을 사용하고 `any`, 불필요하게 넓은 `unknown`, - public API는 명시적 타입을 사용하고 `any`, 불필요하게 넓은 `unknown`,
`as unknown as` 우회를 피해 주세요. `as unknown as` 우회를 피해 주세요.
+4 -2
View File
@@ -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` - 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 - Fastify, tRPC, zod
- Vue 3, Pinia, Vue Router, Vite - Vue 3, Pinia, Vue Router, Vite
- PostgreSQL, Prisma, Redis - PostgreSQL, Prisma, Redis
@@ -99,7 +100,8 @@ Node.js는 `.nvmrc`에서 24.x로 고정합니다. 의존성 설치와 검증에
`package.json`과 `pnpm-lock.yaml`을 함께 사용해 주세요. 모든 workspace package는 `package.json`과 `pnpm-lock.yaml`을 함께 사용해 주세요. 모든 workspace package는
내부 전용(`private`)이므로 manifest의 `0.0.0`은 배포 버전이 아닙니다. 배포 source는 내부 전용(`private`)이므로 manifest의 `0.0.0`은 배포 버전이 아닙니다. 배포 source는
full Git commit으로 고정하고, 실험 릴리스 같은 milestone은 annotated Git tag로 full Git commit으로 고정하고, 실험 릴리스 같은 milestone은 annotated Git tag로
식별합니다. Profile frontend는 같은 commit의 정적 버전 문서를 고정 URL과 ETag로 식별합니다. 선언 파일 생성·Vue 검사 범위와 캐시 계약은 [빌드 도구](docs/build-performance.md)를
확인해 주세요. Profile frontend는 같은 commit의 정적 버전 문서를 고정 URL과 ETag로
주기적으로 재검증하고, 현재 열린 bundle과 달라졌을 때만 강제 reload 없이 공용 주기적으로 재검증하고, 현재 열린 bundle과 달라졌을 때만 강제 reload 없이 공용
toast로 한 번 안내합니다. toast로 한 번 안내합니다.
+91
View File
@@ -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);
})
);
+3
View File
@@ -15,6 +15,7 @@ import { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unit
import type { GameApiContext } from '../../context.js'; import type { GameApiContext } from '../../context.js';
import { getMyGeneral } from '../shared/general.js'; import { getMyGeneral } from '../shared/general.js';
import { resolveSecretPermission } from '../shared/secretPermission.js'; import { resolveSecretPermission } from '../shared/secretPermission.js';
import { policyHistory, policyVersion } from './history.js';
type SetterInfo = { type SetterInfo = {
setter: string | null; setter: string | null;
@@ -356,6 +357,8 @@ const requestNpcPolicyMutation = async (
}; };
export const npcRouter = router({ export const npcRouter = router({
policyHistory,
policyVersion,
getPolicy: authedProcedure.query(async ({ ctx }) => { getPolicy: authedProcedure.query(async ({ ctx }) => {
const general = await getMyGeneral(ctx); const general = await getMyGeneral(ctx);
if (general.nationId <= 0) { if (general.nationId <= 0) {
@@ -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
);
+154 -1
View File
@@ -1,4 +1,6 @@
import { describe, expect, it, vi } from 'vitest'; 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 { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { RedisConnector } from '@sammo-ts/infra'; import type { RedisConnector } from '@sammo-ts/infra';
@@ -96,10 +98,16 @@ const createContext = (
options: { options: {
me?: GeneralRow; me?: GeneralRow;
nation?: typeof baseNation; nation?: typeof baseNation;
world?: typeof baseWorld; world?: {
config: typeof baseWorld.config;
meta: Record<string, unknown>;
currentYear?: number;
currentMonth?: number;
};
requestCommand?: ReturnType<typeof vi.fn>; requestCommand?: ReturnType<typeof vi.fn>;
troopRows?: Array<{ troopLeaderId: number }>; troopRows?: Array<{ troopLeaderId: number }>;
cityRows?: Array<{ id: number }>; cityRows?: Array<{ id: number }>;
historyRows?: Array<Record<string, unknown>>;
} = {} } = {}
): { context: GameApiContext; findFirst: ReturnType<typeof vi.fn>; requestCommand: ReturnType<typeof vi.fn> } => { ): { context: GameApiContext; findFirst: ReturnType<typeof vi.fn>; requestCommand: ReturnType<typeof vi.fn> } => {
const requestCommand = const requestCommand =
@@ -117,6 +125,11 @@ const createContext = (
worldState: { findFirst: vi.fn(async () => options.world ?? baseWorld) }, worldState: { findFirst: vi.fn(async () => options.world ?? baseWorld) },
troop: { findMany: vi.fn(async () => options.troopRows ?? [{ troopLeaderId: 101 }]) }, troop: { findMany: vi.fn(async () => options.troopRows ?? [{ troopLeaderId: 101 }]) },
city: { findMany: vi.fn(async () => options.cityRows ?? [{ id: 1 }, { id: 2 }]) }, 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 }; const redisClient = { get: async () => null, set: async () => null };
return { return {
@@ -140,6 +153,146 @@ const createContext = (
}; };
describe('NPC policy router', () => { 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 () => { it('loads server and nation overrides while calculating legacy zero-value hints from nation tech', async () => {
const fixture = createContext(); const fixture = createContext();
const result = await appRouter.createCaller(fixture.context).npc.getPolicy(); const result = await appRouter.createCaller(fixture.context).npc.getPolicy();
+5 -1
View File
@@ -3,7 +3,11 @@
"compilerOptions": { "compilerOptions": {
"rootDir": "src", "rootDir": "src",
"outDir": "dist", "outDir": "dist",
"composite": false "composite": false,
"paths": {
"@sammo-ts/game-api": ["./src/index.ts"],
"@sammo-ts/game-api/*": ["./src/*"]
}
}, },
"include": ["src"] "include": ["src"]
} }
@@ -2081,8 +2081,10 @@ async function handleSetMySetting(
const scenarioEffect = world.getScenarioConfig().environment.scenarioEffect; const scenarioEffect = world.getScenarioConfig().environment.scenarioEffect;
const ignoresPenalty = isDefenceTrainPenaltyWaivedByScenarioEffect(scenarioEffect); const ignoresPenalty = isDefenceTrainPenaltyWaivedByScenarioEffect(scenarioEffect);
const constValues = asRecord(world.getScenarioConfig().const); const constValues = asRecord(world.getScenarioConfig().const);
const maxTrain = readMetaNumber(constValues, 'maxTrainByWar', 100); // Ref GameConstBase의 전투 상한을 사용한다. 명령 상한(100)으로 제한하면
const maxAtmos = readMetaNumber(constValues, 'maxAtmosByWar', 100); // 전투에서 오른 훈련·사기가 수비끔 전환 한 번에 소실된다.
const maxTrain = readMetaNumber(constValues, 'maxTrainByWar', 110);
const maxAtmos = readMetaNumber(constValues, 'maxAtmosByWar', 150);
const trainDelta = ignoresPenalty ? 0 : -3; const trainDelta = ignoresPenalty ? 0 : -3;
const atmosDelta = ignoresPenalty ? 0 : -6; const atmosDelta = ignoresPenalty ? 0 : -6;
nextTrain = Math.max(20, Math.min(maxTrain, general.train + trainDelta)); nextTrain = Math.max(20, Math.min(maxTrain, general.train + trainDelta));
@@ -87,7 +87,11 @@ const buildGeneral = (overrides: Partial<TurnGeneral> = {}): TurnGeneral => ({
const buildWorld = ( const buildWorld = (
general = buildGeneral(), general = buildGeneral(),
options: { autorunLimit?: boolean; scenarioEffect?: ScenarioEffectKey | null } = {} options: {
autorunLimit?: boolean;
scenarioEffect?: ScenarioEffectKey | null;
scenarioConst?: Record<string, unknown>;
} = {}
) => { ) => {
const state: TurnWorldState = { const state: TurnWorldState = {
id: 1, id: 1,
@@ -126,7 +130,7 @@ const buildWorld = (
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 65 }, stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 65 },
iconPath: '', iconPath: '',
map: {}, map: {},
const: { maxTrainByWar: 100, maxAtmosByWar: 100 }, const: options.scenarioConst ?? {},
environment: { environment: {
mapName: 'test', mapName: 'test',
unitSet: '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([ it.each([
'event_UnlimitedDefenceThresholdChange', 'event_UnlimitedDefenceThresholdChange',
'event_StrongAttacker', 'event_StrongAttacker',
@@ -284,14 +335,18 @@ describe('my information world commands', () => {
] satisfies ScenarioEffectKey[])( ] satisfies ScenarioEffectKey[])(
'preserves the %s scenario that waives the no-defence penalty', 'preserves the %s scenario that waives the no-defence penalty',
async (scenarioEffect) => { async (scenarioEffect) => {
const fixture = buildWorld(buildGeneral(), { scenarioEffect }); const fixture = buildWorld(buildGeneral({ train: 110, atmos: 150 }), { scenarioEffect });
await fixture.handler.handle({ await fixture.handler.handle({
type: 'setMySetting', type: 'setMySetting',
userId: 'user-7', userId: 'user-7',
generalId: 7, generalId: 7,
settings: { defence_train: 999 }, 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 },
});
} }
); );
+6 -3
View File
@@ -3,8 +3,11 @@
"compilerOptions": { "compilerOptions": {
"rootDir": "src", "rootDir": "src",
"outDir": "dist", "outDir": "dist",
"composite": false "composite": false,
"paths": {
"@sammo-ts/game-engine": ["./src/index.ts"],
"@sammo-ts/game-engine/*": ["./src/*"]
}
}, },
"include": ["src"], "include": ["src"]
"references": [{ "path": "../../packages/common" }, { "path": "../../packages/logic" }]
} }
+3 -3
View File
@@ -1,4 +1,5 @@
import { defineConfig } from 'tsdown'; import { defineConfig } from 'tsdown';
import { emitDeclarations } from '../../tools/build-scripts/emit-declarations.mjs';
export default defineConfig({ export default defineConfig({
entry: { entry: {
@@ -30,9 +31,8 @@ export default defineConfig({
}, },
format: 'es', format: 'es',
outDir: 'dist', outDir: 'dist',
dts: { dts: false,
build: true, hooks: { 'build:done': emitDeclarations },
},
sourcemap: true, sourcemap: true,
target: 'node22', target: 'node22',
platform: 'node', platform: 'node',
+12 -6
View File
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
import { expect, test, type Browser, type Page } from '@playwright/test'; import { expect, test, type Browser, type Page } from '@playwright/test';
import { encryptGameSessionToken } from '../../../packages/common/dist/auth/gameToken.js'; 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 databaseUrl = process.env.CHIEF_CENTER_LIVE_DATABASE_URL;
const gameTokenSecret = process.env.CHIEF_CENTER_LIVE_GAME_SECRET; 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({ await transaction.nationTurn.deleteMany({
where: { nationId: editor.nationId, officerLevel: editor.officerLevel }, 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({ await transaction.nationTurnRevision.deleteMany({
where: { nationId: editor.nationId, officerLevel: editor.officerLevel }, 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, userId: viewer.userId,
officerLevel: viewer.officerLevel, officerLevel: viewer.officerLevel,
npcState: viewer.npcState, npcState: viewer.npcState,
meta: viewer.meta, meta: viewer.meta ?? GamePrisma.JsonNull,
penalty: viewer.penalty, penalty: viewer.penalty ?? GamePrisma.JsonNull,
}, },
}); });
await transaction.general.update({ 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, userId: otherChief.userId,
officerLevel: otherChief.officerLevel, officerLevel: otherChief.officerLevel,
npcState: otherChief.npcState, npcState: otherChief.npcState,
meta: otherChief.meta, meta: otherChief.meta ?? GamePrisma.JsonNull,
penalty: otherChief.penalty, penalty: otherChief.penalty ?? GamePrisma.JsonNull,
}, },
}); });
}); });
@@ -1,6 +1,6 @@
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { expect, test, type Locator, type Page } from '@playwright/test'; 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 bootstrapFile = process.env.COMMAND_PANEL_LIVE_BOOTSTRAP_FILE;
const databaseUrl = process.env.DATABASE_URL; const databaseUrl = process.env.DATABASE_URL;
@@ -166,14 +166,26 @@ test('reserves every requested general and chief command through Chromium and re
} finally { } finally {
await db.$transaction(async (transaction) => { await db.$transaction(async (transaction) => {
await transaction.generalTurn.deleteMany({ where: { generalId: general.id } }); 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 } }); await transaction.generalTurnRevision.deleteMany({ where: { generalId: general.id } });
if (originalGeneralRevision) if (originalGeneralRevision)
await transaction.generalTurnRevision.create({ data: originalGeneralRevision }); await transaction.generalTurnRevision.create({ data: originalGeneralRevision });
await transaction.nationTurn.deleteMany({ await transaction.nationTurn.deleteMany({
where: { nationId: general.nationId, officerLevel: general.officerLevel }, 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({ await transaction.nationTurnRevision.deleteMany({
where: { nationId: general.nationId, officerLevel: general.officerLevel }, 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 }); 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) => { test('npc directory reuses the dark sort controls and sorts from a table header', async ({ page }, testInfo) => {
await install(page); await install(page);
await page.goto('npc-list'); await page.goto('npc-list');
+141 -2
View File
@@ -1,6 +1,6 @@
import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js'; import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js';
import { devices, expect, test, type Page, type Route } from '@playwright/test'; 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 { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js'; import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
@@ -14,6 +14,8 @@ type FixtureState = {
mutations: string[]; mutations: string[];
nationPriorityInput?: string[]; nationPriorityInput?: string[];
currentNationPriority?: string[]; currentNationPriority?: string[];
currentGeneralPriority?: string[];
historyVersion?: number;
}; };
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
@@ -131,7 +133,7 @@ const policyFixture = (state: FixtureState) => ({
currentNationPriority: state.currentNationPriority ?? nationPriority, currentNationPriority: state.currentNationPriority ?? nationPriority,
availableNationPriorityItems: nationPriority, availableNationPriorityItems: nationPriority,
defaultGeneralActionPriority: generalPriority, defaultGeneralActionPriority: generalPriority,
currentGeneralActionPriority: generalPriority, currentGeneralActionPriority: state.currentGeneralPriority ?? generalPriority,
availableGeneralActionPriorityItems: generalPriority, availableGeneralActionPriorityItems: generalPriority,
lastSetters: { lastSetters: {
policy: { setter: null, date: null }, policy: { setter: null, date: null },
@@ -175,6 +177,41 @@ const installFixture = async (page: Page, state: FixtureState) => {
? errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN') ? errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN')
: response(policyFixture(state)); : 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 ( if (
operation === 'npc.setNationPolicy' || operation === 'npc.setNationPolicy' ||
operation === 'npc.setNationPriority' || operation === 'npc.setNationPriority' ||
@@ -188,6 +225,11 @@ const installFixture = async (page: Page, state: FixtureState) => {
state.failNextMutation = false; state.failNextMutation = false;
return errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN'); 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 response({ ok: true });
} }
return errorResponse(operation, `Unhandled fixture operation: ${operation}`); 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 }); 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 ({ test('desktop geometry, typography, textures, drag, focus, tooltip, and successful save match the reference', async ({
page, page,
}) => { }) => {
+3 -3
View File
@@ -5,9 +5,9 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc && vite build", "build": "node ../../tools/vue-typecheck.mjs && vite build",
"build:release": "vite build --outDir .release-build", "build:release": "vite build --outDir .release-build",
"typecheck:release": "vue-tsc --noEmit", "typecheck:release": "node ../../tools/vue-typecheck.mjs --noEmit",
"preview": "vite preview", "preview": "vite preview",
"test": "pnpm test:unit", "test": "pnpm test:unit",
"test:unit": "node --test test/**/*.test.ts", "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", "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": "pnpm -w lint:target app/game-frontend",
"lint:fix": "pnpm -w lint:target:fix 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": { "dependencies": {
"@sammo-ts/common": "workspace:*", "@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"> <script setup lang="ts">
defineProps<{ import { computed } from 'vue';
import { splitLegacyLineBreaks } from '../../utils/legacyDescription';
const props = defineProps<{
title: string; title: string;
description?: string | null; description?: string | null;
testId?: string; testId?: string;
}>(); }>();
const descriptionLines = computed(() => splitLegacyLineBreaks(props.description ?? ''));
</script> </script>
<template> <template>
@@ -16,7 +21,10 @@ defineProps<{
<slot /> <slot />
<span v-if="description" class="directory-tooltip__content" role="tooltip"> <span v-if="description" class="directory-tooltip__content" role="tooltip">
<strong>{{ title }}</strong> <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>
</span> </span>
</template> </template>
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useSlots, watch } from 'vue'; 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, { type Instance, type Placement } from 'tippy.js';
import 'tippy.js/dist/tippy.css'; import 'tippy.js/dist/tippy.css';
@@ -28,7 +29,7 @@ let instance: Instance | null = null;
const descriptionLines = computed(() => { const descriptionLines = computed(() => {
const source = Array.isArray(props.description) ? props.description : [props.description ?? '']; const source = Array.isArray(props.description) ? props.description : [props.description ?? ''];
return source return source
.flatMap((line) => line.split(/<br\s*\/?\s*>|\r?\n/giu)) .flatMap(splitLegacyLineBreaks)
.map((line) => line.trim()) .map((line) => line.trim())
.filter(Boolean); .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);
+1 -1
View File
@@ -4,6 +4,7 @@ import { usePageExit } from '../composables/usePageExit';
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime'; import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { JosaUtil } from '@sammo-ts/common/util/JosaUtil'; import { JosaUtil } from '@sammo-ts/common/util/JosaUtil';
import { computed, onMounted, reactive, ref } from 'vue'; import { computed, onMounted, reactive, ref } from 'vue';
import { splitLegacyLineBreaks } from '../utils/legacyDescription';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
import { useGameFeedback } from '../composables/useGameFeedback'; import { useGameFeedback } from '../composables/useGameFeedback';
@@ -207,7 +208,6 @@ const specialNameMap = computed(() => {
} }
return map; return map;
}); });
const splitLegacyLineBreaks = (value: string): string[] => value.split(/<br\s*\/?\s*>|\r?\n/giu);
const selectedSpecialWarInfo = computed( const selectedSpecialWarInfo = computed(
() => status.value?.availableSpecialWar.find((entry) => entry.key === nextSpecialKey.value)?.info ?? '' () => 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 { computed, onMounted, ref, watch } from 'vue';
import NpcPolicyHistory from '../components/NpcPolicyHistory.vue';
import SortableStringList from '../components/ui/SortableStringList'; import SortableStringList from '../components/ui/SortableStringList';
import { useGameFeedback } from '../composables/useGameFeedback'; import { useGameFeedback } from '../composables/useGameFeedback';
import { npcPriorityHelp } from '../utils/npcPriorityHelp'; import { npcPriorityHelp } from '../utils/npcPriorityHelp';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
const { pageExitLabel, exitPage } = usePageExit(); const { pageExitLabel, exitPage } = usePageExit();
const historyRevision = ref(0);
type NpcPolicyResponse = Awaited<ReturnType<typeof trpc.npc.getPolicy.query>>; type NpcPolicyResponse = Awaited<ReturnType<typeof trpc.npc.getPolicy.query>>;
type NationPolicy = NpcPolicyResponse['currentNationPolicy']; type NationPolicy = NpcPolicyResponse['currentNationPolicy'];
@@ -308,6 +310,7 @@ const submitPolicy = async () => {
try { try {
await trpc.npc.setNationPolicy.mutate(policyDraft.value); await trpc.npc.setNationPolicy.mutate(policyDraft.value);
lastSavedPolicy.value = clonePolicy(policyDraft.value); lastSavedPolicy.value = clonePolicy(policyDraft.value);
historyRevision.value += 1;
showSuccessToast('NPC 정책이 반영되었습니다.'); showSuccessToast('NPC 정책이 반영되었습니다.');
} catch (caught) { } catch (caught) {
showErrorToast(`설정하지 못했습니다: ${resolveErrorMessage(caught)}`); showErrorToast(`설정하지 못했습니다: ${resolveErrorMessage(caught)}`);
@@ -357,6 +360,7 @@ const submitPriority = async (section: PrioritySectionKey) => {
await trpc.npc.setGeneralPriority.mutate(state.active); await trpc.npc.setGeneralPriority.mutate(state.active);
lastSavedGeneralPriority.value = [...state.active]; lastSavedGeneralPriority.value = [...state.active];
} }
historyRevision.value += 1;
showSuccessToast('NPC 정책이 반영되었습니다.'); showSuccessToast('NPC 정책이 반영되었습니다.');
} catch (caught) { } catch (caught) {
showErrorToast(`설정하지 못했습니다: ${resolveErrorMessage(caught)}`); showErrorToast(`설정하지 못했습니다: ${resolveErrorMessage(caught)}`);
@@ -547,6 +551,7 @@ const submitPriority = async (section: PrioritySectionKey) => {
</section> </section>
</div> </div>
</section> </section>
<NpcPolicyHistory v-if="data" :key="data.nationId" :revision="historyRevision" />
<div class="sortable-compat-controls" aria-hidden="true"> <div class="sortable-compat-controls" aria-hidden="true">
<button type="button" tabindex="-1" /> <button type="button" tabindex="-1" />
<input v-for="index in 20" :key="index" type="hidden" /> <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>&lt;br&gt;<br onclick="alert(1)">';
assert.deepEqual(splitLegacyLineBreaks(text), [text]);
});
});
+4 -9
View File
@@ -30,16 +30,11 @@
"@sammo-ts/game-api/*": ["../../app/game-api/src/*"], "@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/index.ts"],
"@sammo-ts/gateway-api/*": ["../../app/gateway-api/src/*"] "@sammo-ts/gateway-api/*": ["../../app/gateway-api/src/*"]
}
}, },
"include": [ "declaration": false,
"src/**/*.ts", "declarationMap": false
"src/**/*.d.ts", },
"src/**/*.tsx", "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", "test/**/*.ts", "e2e/**/*.ts"],
"src/**/*.vue",
"test/**/*.ts",
"e2e/**/*.ts"
],
"references": [ "references": [
{ {
"path": "./tsconfig.node.json" "path": "./tsconfig.node.json"
+2
View File
@@ -131,6 +131,8 @@ export const getAdminCapability = (permission: string): AdminCapabilityDefinitio
CAPABILITY_BY_PERMISSION.get(permission); CAPABILITY_BY_PERMISSION.get(permission);
export const resolveAdminActionCapability = (path: string, rawInput?: unknown): string | undefined => { 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.endsWith('.users.createLocal')) return 'admin.users.create';
if (path.includes('.users.')) return 'admin.users.manage'; if (path.includes('.users.')) return 'admin.users.manage';
if (path.includes('.system.')) return 'admin.notice.manage'; if (path.includes('.system.')) return 'admin.notice.manage';
+48 -64
View File
@@ -7,6 +7,16 @@ import { gatewayProfileCapabilities } from '@sammo-ts/common';
import type { GatewayPrisma } from '@sammo-ts/infra'; import type { GatewayPrisma } from '@sammo-ts/infra';
import { procedure, router } from './trpc.js'; 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 { import {
listScenarioPreviews, listScenarioPreviews,
resolveGitBranchCommitSha, resolveGitBranchCommitSha,
@@ -51,7 +61,6 @@ const zSpecialAccessProfile = z
.min(1) .min(1)
.max(64) .max(64)
.regex(/^[a-z0-9_-]+(?::[a-zA-Z0-9._-]+)?$/); .regex(/^[a-z0-9_-]+(?::[a-zA-Z0-9._-]+)?$/);
const zJoinMode = z.enum(['full', 'onlyRandom']);
const zServerAction = z.enum([ const zServerAction = z.enum([
'RESUME', 'RESUME',
'PAUSE', 'PAUSE',
@@ -441,15 +450,6 @@ const zLocalAccountInput = z.object({
displayName: z.string().min(2).max(40).optional(), 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 const zRuntimeSettings = z
.object({ .object({
turnTermMinutes: z turnTermMinutes: z
@@ -479,60 +479,6 @@ const isUniqueConstraintError = (error: unknown): boolean =>
const isProfileOperationConflictError = (error: unknown): boolean => const isProfileOperationConflictError = (error: unknown): boolean =>
error instanceof GatewayProfileOperationConflictError || isUniqueConstraintError(error); 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 zSourceMode = z.enum(['BRANCH', 'COMMIT']);
const zResetSourceMode = z.enum(['CURRENT', 'BRANCH', 'COMMIT']); const zResetSourceMode = z.enum(['CURRENT', 'BRANCH', 'COMMIT']);
@@ -631,6 +577,44 @@ const applyMetaPatch = (
}; };
export const adminRouter = router({ 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({ capabilities: router({
list: adminProcedure.query(({ ctx }) => { list: adminProcedure.query(({ ctx }) => {
const adminAuth = requireAdminAuth(ctx); const adminAuth = requireAdminAuth(ctx);
@@ -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);
}),
});
+396
View File
@@ -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 fs from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
@@ -1855,6 +1856,22 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
return; 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) { if (!operation.sourceMode || !operation.sourceRef) {
throw new Error('Operation source mode and ref are required.'); throw new Error('Operation source mode and ref are required.');
} }
@@ -1959,7 +1976,10 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
commitSha, commitSha,
assertLease, assertLease,
operation.id, operation.id,
readOperationReleaseSource(operation) readOperationReleaseSource(operation),
typeof payload.openRequestId === 'string'
? { seasonKey: payload.openRequestSeasonKey, openAt: install.openAt }
: undefined
); );
if (result.status === 'REQUESTED') { if (result.status === 'REQUESTED') {
const retryAt = new Date(this.now().getTime() + this.adminActionIntervalMs).toISOString(); const retryAt = new Date(this.now().getTime() + this.adminActionIntervalMs).toISOString();
@@ -2414,7 +2434,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
commitShaOverride?: string, commitShaOverride?: string,
assertLease?: () => Promise<void>, assertLease?: () => Promise<void>,
operationId?: string, operationId?: string,
releaseSource?: ProfileReleaseSource releaseSource?: ProfileReleaseSource,
communityReservation?: { seasonKey: unknown; openAt: unknown }
): Promise<GatewayAdminActionResult> { ): Promise<GatewayAdminActionResult> {
const appendLog = async ( const appendLog = async (
phase: string, phase: string,
@@ -2526,6 +2547,24 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
); );
return { status: 'FAILED', detail: 'selected workspace build failed' }; 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 appendLog('seed', '선택 버전의 profile seed CLI를 확인합니다.');
await this.assertProfileSeedCli(workspace.root); await this.assertProfileSeedCli(workspace.root);
// A newly provisioned profile schema has no world_state row (or table) yet. // 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 firstGameIdx = resolveProfileFirstGameIdx(profileMeta);
const baseSeason = readMetaNumber(normalizeMeta(seedInfo.meta), 'season'); const baseSeason = readMetaNumber(normalizeMeta(seedInfo.meta), 'season');
const season = nextSeasonIdx ?? baseSeason ?? 1; const season = nextSeasonIdx ?? baseSeason ?? 1;
await assertCommunityReservation();
await updateClaimedProfile({ status: 'STOPPED' }, () => await updateClaimedProfile({ status: 'STOPPED' }, () =>
this.repository.updateStatus(profile.profileName, 'STOPPED') this.repository.updateStatus(profile.profileName, 'STOPPED')
); );
@@ -13,7 +13,9 @@ export interface ProfileReleaseSource {
const isRecord = (value: unknown): value is Record<string, unknown> => const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === 'object' && !Array.isArray(value)); 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 stored = isRecord(profile.meta) && isRecord(profile.meta.releaseSource) ? profile.meta.releaseSource : null;
const mode = stored?.mode; const mode = stored?.mode;
const ref = typeof stored?.ref === 'string' ? stored.ref.trim() : ''; const ref = typeof stored?.ref === 'string' ? stored.ref.trim() : '';
+68
View File
@@ -0,0 +1,68 @@
import { z } from 'zod';
const AUTORUN_USER_OPTIONS = ['develop', 'warp', 'recruit', 'recruit_high', 'train', 'battle', 'chief'] as const;
const zJoinMode = z.enum(['full', 'onlyRandom']);
const zInstallAutorun = z.object({
limitMinutes: z.number().int().min(0).max(43200),
options: z.array(z.enum(AUTORUN_USER_OPTIONS)),
});
// Ref aligns a 12-month game year inside a 24-hour wall-clock day. That makes
// every positive divisor of 120 valid, including 3 and 8 minutes.
export const isAllowedTurnTerm = (value: number): boolean => Number.isInteger(value) && value > 0 && 120 % value === 0;
export const zInstallOptions = z.object({
scenarioId: z.number().int().min(0),
turnTermMinutes: z
.number()
.int()
.refine((value) => isAllowedTurnTerm(value), {
message: '턴 시간은 120의 약수여야 합니다.',
}),
sync: z.boolean(),
fiction: z.number().int().min(0).max(1),
extend: z.boolean(),
blockGeneralCreate: z.number().int().min(0).max(2),
npcMode: z.number().int().min(0).max(2),
showImgLevel: z.number().int().min(0).max(3),
tournamentTrig: z.boolean(),
joinMode: zJoinMode,
autorunUser: zInstallAutorun.nullable().optional(),
openAt: z.string().datetime().optional(),
preopenAt: z.string().datetime().optional(),
gitRef: z.string().min(1).max(128).optional(),
});
export const zOperationInstallOptions = zInstallOptions.omit({ gitRef: true });
export const zProfileResetDefaults = zInstallOptions.omit({
scenarioId: true,
openAt: true,
preopenAt: true,
gitRef: true,
});
export const SYSTEM_PROFILE_RESET_DEFAULTS: z.infer<typeof zProfileResetDefaults> = {
turnTermMinutes: 60,
sync: true,
fiction: 1,
extend: true,
blockGeneralCreate: 0,
npcMode: 0,
showImgLevel: 3,
tournamentTrig: true,
joinMode: 'full',
autorunUser: null,
};
export const buildResetOtherTextInfo = (install: z.infer<typeof zOperationInstallOptions>): string => {
const settings: string[] = [];
if (!install.sync) settings.push('시간동기화 없음');
if (!install.extend) settings.push('확장 NPC 미포함');
if (install.blockGeneralCreate === 1) settings.push('장수 생성 불가');
if (install.blockGeneralCreate === 2) settings.push('장수명 무작위');
if (install.joinMode === 'onlyRandom') settings.push('랜덤 임관');
if (install.showImgLevel !== SYSTEM_PROFILE_RESET_DEFAULTS.showImgLevel) {
settings.push(['이미지 표시 안함', '전콘 표시', '전콘/병종 표시'][install.showImgLevel] ?? '이미지 표시');
}
if (!install.tournamentTrig) settings.push('토너먼트 수동 시작');
return settings.join(', ');
};
+6 -8
View File
@@ -11,6 +11,8 @@ import { procedure, router } from './trpc.js';
import { toPublicUser } from './auth/userRepository.js'; import { toPublicUser } from './auth/userRepository.js';
import type { UserOAuthInfo, UserRecord } from './auth/userRepository.js'; import type { UserOAuthInfo, UserRecord } from './auth/userRepository.js';
import { adminRouter } from './adminRouter.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 { accountRouter } from './account/router.js';
import { import {
hasActiveSpecialAccountGrant, hasActiveSpecialAccountGrant,
@@ -131,6 +133,7 @@ const finishKakaoLoginOrRequestPasswordSetup = async <T extends 'login' | 'verif
}; };
export const appRouter = router({ export const appRouter = router({
openRequests: openRequestsRouter,
navigation: router({ navigation: router({
get: procedure.query(({ ctx }) => ctx.navigationConfig.get()), 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.' }); throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
} }
const profile = await ctx.profiles.getProfile(input.profileName); const profile = await ctx.profiles.getProfile(input.profileName);
const activeBuildCommit = profile?.buildCommitSha?.trim(); if (!profile) throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
if (!profile || !activeBuildCommit) { const catalogCommit = await resolveOpeningCatalogCommit(profile);
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'The profile has no active build commit.',
});
}
try { try {
return await listScenarioPreviews({ gitRef: activeBuildCommit }); return await listScenarioPreviews({ gitRef: catalogCommit });
} catch { } catch {
throw new TRPCError({ throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR', code: 'INTERNAL_SERVER_ERROR',
@@ -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 () => { it('drains an in-flight operation before shutdown completes', async () => {
let releaseStart: (() => void) | undefined; let releaseStart: (() => void) | undefined;
const startGate = new Promise<void>((resolve) => { const startGate = new Promise<void>((resolve) => {
+1 -1
View File
@@ -38,7 +38,7 @@ describe('readReleaseManifest', () => {
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({ await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL, controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
gatewaySchemaHead: '20260825000000_add_bulk_release_batches', gatewaySchemaHead: '20261022000000_add_open_requests',
gameSchemaHead: '20260916080000_index_play_audit_decision_month', gameSchemaHead: '20260916080000_index_play_audit_decision_month',
}); });
}); });
+5 -1
View File
@@ -3,7 +3,11 @@
"compilerOptions": { "compilerOptions": {
"rootDir": "src", "rootDir": "src",
"outDir": "dist", "outDir": "dist",
"composite": false "composite": false,
"paths": {
"@sammo-ts/gateway-api": ["./src/index.ts"],
"@sammo-ts/gateway-api/*": ["./src/*"]
}
}, },
"include": ["src"] "include": ["src"]
} }
@@ -8,14 +8,22 @@ const operationNames = (route: Route): string[] => {
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(','); 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[] = []; 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(() => { await page.addInitScript(() => {
window.localStorage.setItem('sammo-session-token', 'regular-user-session'); window.localStorage.setItem('sammo-session-token', 'regular-user-session');
}); });
await page.route('**/gateway/api/trpc/**', async (route) => { await page.route('**/gateway/api/trpc/**', async (route) => {
expect(route.request().headers()['x-session-token']).toBe('regular-user-session'); 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); operations.push(operation);
if (operation === 'me') { if (operation === 'me') {
return response({ return response({
@@ -52,6 +60,7 @@ const installFixture = async (page: Page): Promise<string[]> => {
]); ]);
} }
if (operation === 'lobby.scenarios') { if (operation === 'lobby.scenarios') {
expect(input).toEqual({ profileName: 'pya:default' });
return response([ return response([
{ {
id: 2701, 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}`); throw new Error(`Unhandled tRPC operation: ${operation}`);
}); });
const batched = new URL(route.request().url()).searchParams.get('batch') === '1'; 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); const operations = await installFixture(page);
await page.goto('lobby'); await page.goto('lobby');
const suggestionLink = page.getByRole('link', { name: '오픈 건의 양식 작성' }); const suggestionLink = page.getByRole('link', { name: '오픈 요청 · 목록' });
await expect(suggestionLink).toBeVisible(); await expect(suggestionLink).toBeVisible();
await suggestionLink.hover(); await suggestionLink.hover();
await suggestionLink.focus(); 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 suggestionLink.click();
await expect(page).toHaveURL(/\/gateway\/open-suggestion$/); await expect(page).toHaveURL(/\/gateway\/open-suggestion$/);
await expect(page.getByRole('heading', { name: '오픈 건의 양식' })).toBeVisible(); await expect(page.getByRole('heading', { name: '오픈 요청', exact: true })).toBeVisible();
await expect(page.getByText('서버 설정, 시나리오, 오픈 시각은 변경되지 않습니다.')).toBeVisible(); await expect(
page.getByText('복사는 문구만 복사하며, 오픈 요청 등록 버튼을 눌러야 요청이 저장됩니다.')
).toBeVisible();
await expect(page.getByTestId('scenario-summary')).toContainText('310'); await expect(page.getByTestId('scenario-summary')).toContainText('310');
await page.getByTestId('proposal-open').fill('2026-08-18T12:00'); 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 }); await page.screenshot({ path: testInfo.outputPath('open-suggestion-mobile.png'), fullPage: true });
expect(operations).toContain('lobby.scenarios'); expect(operations).toContain('lobby.scenarios');
expect(operations.every((operation) => ['me', 'lobby.notice', 'lobby.profiles', 'lobby.scenarios'].includes(operation))).toBe( expect(
true 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') { if (operation === 'lobby.info') {
return response({ return response({
scenarioTitle: '공개 지도 테스트 시나리오',
year: 200, year: 200,
month: profile.profile === 'che' ? 1 : 2, month: profile.profile === 'che' ? 1 : 2,
userCnt, 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.setViewportSize({ width: 1200, height: 900 });
await page.goto('lobby'); await page.goto('lobby');
const renderedProfiles = await page.locator('tbody tr td:first-child > div:first-child').allTextContents(); await expect(page.locator('tbody tr td:first-child > div:first-child')).toHaveText([
expect(renderedProfiles.map((name) => name.trim())).toEqual([
'체섭', '체섭',
'퀘섭', '퀘섭',
'풰섭', '풰섭',
+3 -3
View File
@@ -9,13 +9,13 @@
"test:e2e:operations": "playwright test --config e2e/playwright.config.mjs", "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: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", "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", "build:release": "vite build",
"typecheck:release": "vue-tsc --noEmit", "typecheck:release": "node ../../tools/vue-typecheck.mjs --noEmit",
"preview": "vite preview", "preview": "vite preview",
"lint": "pnpm -w lint:target app/gateway-frontend", "lint": "pnpm -w lint:target app/gateway-frontend",
"lint:fix": "pnpm -w lint:target:fix 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": { "dependencies": {
"@sammo-ts/common": "workspace:*", "@sammo-ts/common": "workspace:*",
+2 -2
View File
@@ -802,13 +802,13 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
</div> </div>
<div class="p-6 text-center"> <div class="p-6 text-center">
<p class="m-0 text-sm text-zinc-400"> <p class="m-0 text-sm text-zinc-400">
시나리오와 빌드 옵션을 확인하고 운영자에게 전달할 오픈 건의 문구를 만들 수 있습니다. 다음 오픈을 요청하고, 다른 사용자의 요청에 동의하거나 진행 상황을 확인할 수 있습니다.
</p> </p>
<RouterLink <RouterLink
to="/open-suggestion" 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" 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> </RouterLink>
</div> </div>
</div> </div>
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime, serverDateTimeInputToIso } from '@sammo-ts/common/time/ServerDateTime';
import type { AppRouter } from '@sammo-ts/gateway-api'; import type { AppRouter } from '@sammo-ts/gateway-api';
import type { inferRouterOutputs } from '@trpc/server'; import type { inferRouterOutputs } from '@trpc/server';
import { computed, onMounted, reactive, ref, watch } from 'vue'; import { computed, onMounted, reactive, ref, watch } from 'vue';
@@ -24,6 +25,112 @@ const scenarios = ref<Scenario[]>([]);
const catalogLoading = ref(false); const catalogLoading = ref(false);
const catalogError = ref(''); const catalogError = ref('');
const copiedMessage = 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); const outputElement = ref<HTMLTextAreaElement | null>(null);
let catalogRequestId = 0; let catalogRequestId = 0;
@@ -48,9 +155,7 @@ const form = reactive({
const selectedProfile = computed(() => profiles.value.find((profile) => profile.profileName === form.profileName)); const selectedProfile = computed(() => profiles.value.find((profile) => profile.profileName === form.profileName));
const selectedScenario = computed(() => scenarios.value.find((scenario) => scenario.id === form.scenarioId)); const selectedScenario = computed(() => scenarios.value.find((scenario) => scenario.id === form.scenarioId));
const dateOrderValid = computed( const dateOrderValid = computed(() => !form.preopenAt || !form.openAt || form.preopenAt < form.openAt);
() => !form.preopenAt || !form.openAt || new Date(form.preopenAt).getTime() <= new Date(form.openAt).getTime()
);
const canCopy = computed( const canCopy = computed(
() => () =>
Boolean(selectedProfile.value && selectedScenario.value && form.preopenAt && form.openAt) && 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; 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 npcModeText = (value: number): string => ['불가', '가능', '선택 생성 가능'][value] ?? '불가';
const fictionText = (value: number): string => (value === 1 ? '가상' : '사실'); const fictionText = (value: number): string => (value === 1 ? '가상' : '사실');
const autorunText = computed(() => { const autorunText = computed(() => {
@@ -138,7 +251,12 @@ const loadScenarios = async (): Promise<void> => {
watch( watch(
() => form.profileName, () => form.profileName,
() => void loadScenarios() () => {
requestError.value = '';
requestMessage.value = '';
void loadScenarios();
void loadRequests();
}
); );
watch(selectedScenario, (scenario) => { watch(selectedScenario, (scenario) => {
if (scenario?.fiction === 0 || scenario?.fiction === 1) form.fiction = scenario.fiction; if (scenario?.fiction === 0 || scenario?.fiction === 1) form.fiction = scenario.fiction;
@@ -174,16 +292,185 @@ onMounted(async () => {
<header class="page-header"> <header class="page-header">
<div> <div>
<p class="eyebrow">GATEWAY COMMUNITY TOOL</p> <p class="eyebrow">GATEWAY COMMUNITY TOOL</p>
<h1>오픈 건의 양식</h1> <h1>오픈 요청</h1>
<p>현재 서버 빌드의 시나리오와 빌드 옵션을 살펴보고, 운영자에게 전달할 문구를 만듭니다.</p> <p>통일되었거나 닫힌 서버의 다음 오픈을 요청하고, 다른 사용자의 요청에 동의할 수 있습니다.</p>
</div> </div>
<RouterLink class="back-link" to="/lobby">서버 목록으로</RouterLink> <RouterLink class="back-link" to="/lobby">서버 목록으로</RouterLink>
</header> </header>
<p class="read-only-notice" role="note"> <p class="read-only-notice" role="note">
이 화면은 조회와 문구 작성만 합니다. 서버 설정, 시나리오, 오픈 시각은 변경되지 않습니다. 등록한 오픈 요청은 목록에서 확인할 수 있습니다. 복사는 문구만 복사하며, 오픈 요청 등록 버튼을 눌러야 요청이
저장됩니다.
</p> </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"> <section class="panel" aria-labelledby="proposal-basic-heading">
<h2 id="proposal-basic-heading">기본 정보</h2> <h2 id="proposal-basic-heading">기본 정보</h2>
<div class="field-grid"> <div class="field-grid">
@@ -208,34 +495,58 @@ onMounted(async () => {
</select> </select>
</label> </label>
<label> <label>
<span>가오픈 일시</span> <span>가오픈 일시 (서버 시간 UTC+9)</span>
<input v-model="form.preopenAt" type="datetime-local" step="1" data-testid="proposal-preopen" /> <input v-model="form.preopenAt" type="datetime-local" step="1" data-testid="proposal-preopen" />
</label> </label>
<label> <label>
<span>오픈 일시</span> <span>오픈 일시 (서버 시간 UTC+9)</span>
<input v-model="form.openAt" type="datetime-local" step="1" data-testid="proposal-open" /> <input v-model="form.openAt" type="datetime-local" step="1" data-testid="proposal-open" />
</label> </label>
</div> </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"> <p v-else-if="catalogError" class="field-error" role="alert">
{{ catalogError }} {{ catalogError }}
<button type="button" @click="loadScenarios">다시 확인</button> <button type="button" @click="loadScenarios">다시 확인</button>
</p> </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"> <dl v-if="selectedScenario" class="scenario-summary" data-testid="scenario-summary">
<div><dt>시작 연도</dt><dd>{{ selectedScenario.year ?? '-' }}년</dd></div> <div>
<div><dt>최대 스탯</dt><dd>{{ selectedScenario.defaultStatTotal }}</dd></div> <dt>시작 연도</dt>
<div><dt>기본 NPC</dt><dd>{{ selectedScenario.npcCount }}명</dd></div> <dd>{{ selectedScenario.year ?? '-' }}년</dd>
<div><dt>확장 NPC</dt><dd>{{ selectedScenario.npcExCount }}명</dd></div> </div>
<div><dt>중립 NPC</dt><dd>{{ selectedScenario.npcNeutralCount }}명</dd></div> <div>
<div><dt>국가</dt><dd>{{ selectedScenario.nations.length }}개</dd></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> </dl>
</section> </section>
<section class="panel" aria-labelledby="proposal-options-heading"> <section class="panel" aria-labelledby="proposal-options-heading">
<h2 id="proposal-options-heading">빌드 옵션</h2> <h2 id="proposal-options-heading">빌드 옵션</h2>
<p class="section-help">선택은 아래 미리보기에만 반영됩니다. 각 설명은 실제 초기화 옵션의 의미입니다.</p> <p class="section-help">
선택한 설정은 오픈 요청에 저장됩니다. 각 설명은 실제 초기화 옵션의 의미입니다.
</p>
<div class="field-grid option-grid"> <div class="field-grid option-grid">
<label> <label>
<span>{{ RESET_OPTION_COPY.turnTerm.label }}</span> <span>{{ RESET_OPTION_COPY.turnTerm.label }}</span>
@@ -345,11 +656,35 @@ onMounted(async () => {
<p class="copy-status" role="status">{{ copiedMessage }}</p> <p class="copy-status" role="status">{{ copiedMessage }}</p>
</section> </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"> <details class="panel catalog-panel">
<summary>시나리오 목록 {{ scenarios.length }}개 보기</summary> <summary>시나리오 목록 {{ scenarios.length }}개 보기</summary>
<div class="catalog-table-frame"> <div class="catalog-table-frame">
<table> <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> <tbody>
<tr v-for="scenario in scenarios" :key="scenario.id"> <tr v-for="scenario in scenarios" :key="scenario.id">
<td>{{ scenario.id }}</td> <td>{{ scenario.id }}</td>
@@ -368,6 +703,50 @@ onMounted(async () => {
</template> </template>
<style scoped> <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 { .suggestion-page {
box-sizing: border-box; box-sizing: border-box;
width: min(100% - 32px, 960px); width: min(100% - 32px, 960px);
@@ -390,10 +769,28 @@ onMounted(async () => {
color: #fff; color: #fff;
} }
.page-header h1 { font-size: 28px; line-height: 1.25; } .page-header h1 {
.page-header p:not(.eyebrow), .section-help, .output-heading p { margin: 8px 0 0; color: #a1a1aa; } font-size: 28px;
.eyebrow { margin: 0 0 6px; color: #fb923c; font-size: 12px; font-weight: 700; letter-spacing: .12em; } line-height: 1.25;
.back-link { flex: 0 0 auto; color: #fdba74; text-underline-offset: 3px; } }
.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 { .read-only-notice {
margin: 22px 0; margin: 22px 0;
@@ -413,13 +810,37 @@ onMounted(async () => {
padding: 20px; padding: 20px;
} }
.panel h2 { font-size: 20px; } .panel h2 {
.field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-top: 18px; } font-size: 20px;
.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 {
.field-grid small { color: #a1a1aa; font-size: 12px; line-height: 1.45; } 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; box-sizing: border-box;
width: 100%; width: 100%;
border: 1px solid #52525b; border: 1px solid #52525b;
@@ -430,30 +851,94 @@ select, input[type='datetime-local'], textarea {
font: inherit; 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: 2px solid #fb923c;
outline-offset: 2px; outline-offset: 2px;
} }
.field-status { color: #a1a1aa; } .field-status {
.field-error { color: #fca5a5; } color: #a1a1aa;
.field-error button { border: 0; background: transparent; color: #fdba74; text-decoration: underline; cursor: pointer; } }
.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 {
.scenario-summary div { min-width: 0; border-right: 1px solid #3f3f46; padding: 10px; text-align: center; } display: grid;
.scenario-summary div:last-child { border-right: 0; } grid-template-columns: repeat(6, minmax(0, 1fr));
.scenario-summary dt { color: #a1a1aa; font-size: 12px; } margin: 18px 0 0;
.scenario-summary dd { margin: 4px 0 0; color: #fff; font-weight: 700; } 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,
.toggle-grid { margin-top: 20px; border-top: 1px solid #3f3f46; padding-top: 16px; } .checkbox-list {
.toggle-grid label, .checkbox-list label { display: inline-flex; align-items: center; gap: 7px; } display: flex;
.autorun-options { margin-top: 18px; border: 1px solid #3f3f46; border-radius: 4px; padding: 16px; } flex-wrap: wrap;
.autorun-options legend { padding: 0 8px; color: #fff; font-weight: 700; } gap: 10px 18px;
.autorun-limit { width: min(100%, 260px); margin-top: 16px; } }
.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 {
.output-heading h2 { margin: 0; } align-items: center;
}
.output-heading h2 {
margin: 0;
}
.output-heading button { .output-heading button {
flex: 0 0 auto; flex: 0 0 auto;
border: 1px solid #f97316; border: 1px solid #f97316;
@@ -464,25 +949,84 @@ select:focus-visible, input:focus-visible, textarea:focus-visible, button:focus-
cursor: pointer; cursor: pointer;
font-weight: 700; font-weight: 700;
} }
.output-heading button:disabled { border-color: #3f3f46; background: #27272a; color: #71717a; cursor: default; } .output-heading button:disabled {
.suggestion-output { margin-top: 16px; resize: vertical; line-height: 1.6; white-space: pre-wrap; } border-color: #3f3f46;
.copy-status { min-height: 20px; margin: 8px 0 0; color: #86efac; } 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-panel summary {
.catalog-table-frame { margin-top: 14px; overflow-x: auto; } cursor: pointer;
.catalog-table-frame table { width: 100%; min-width: 680px; border-collapse: collapse; } color: #fdba74;
.catalog-table-frame th, .catalog-table-frame td { border-bottom: 1px solid #3f3f46; padding: 9px; text-align: left; } font-weight: 700;
.catalog-table-frame th { color: #a1a1aa; font-size: 12px; } }
.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) { @media (max-width: 700px) {
.suggestion-page { width: min(100% - 24px, 960px); padding-top: 96px; } .suggestion-page {
.page-header { flex-direction: column; gap: 10px; } width: min(100% - 24px, 960px);
.field-grid, .scenario-summary { grid-template-columns: minmax(0, 1fr); } padding-top: 96px;
.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; } .page-header {
.scenario-summary dd { margin: 0; } flex-direction: column;
.panel { padding: 16px; } gap: 10px;
.output-heading { align-items: stretch; flex-direction: column; } }
.output-heading button { width: 100%; } .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> </style>
+18 -53
View File
@@ -4,11 +4,7 @@
"target": "ESNext", "target": "ESNext",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"module": "ESNext", "module": "ESNext",
"lib": [ "lib": ["ESNext", "DOM", "DOM.Iterable"],
"ESNext",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true, "skipLibCheck": true,
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
@@ -21,55 +17,24 @@
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"paths": { "paths": {
"@/*": [ "@/*": ["./src/*"],
"./src/*" "@sammo-ts/common": ["../../packages/common/src/index.ts"],
], "@sammo-ts/common/*": ["../../packages/common/src/*"],
"@sammo-ts/common": [ "@sammo-ts/infra": ["../../packages/infra/src/index.ts"],
"../../packages/common/src/index.ts" "@sammo-ts/infra/*": ["../../packages/infra/src/*"],
], "@sammo-ts/logic": ["../../packages/logic/src/index.ts"],
"@sammo-ts/common/*": [ "@sammo-ts/logic/*": ["../../packages/logic/src/*"],
"../../packages/common/src/*" "@sammo-ts/game-engine": ["../../app/game-engine/src/index.ts"],
], "@sammo-ts/game-engine/*": ["../../app/game-engine/src/*"],
"@sammo-ts/infra": [ "@sammo-ts/game-api": ["../../app/game-api/src/index.ts"],
"../../packages/infra/src/index.ts" "@sammo-ts/game-api/*": ["../../app/game-api/src/*"],
], "@sammo-ts/gateway-api": ["../../app/gateway-api/src/index.ts"],
"@sammo-ts/infra/*": [ "@sammo-ts/gateway-api/*": ["../../app/gateway-api/src/*"]
"../../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": [ "declaration": false,
"src/**/*.ts", "declarationMap": false
"src/**/*.d.ts", },
"src/**/*.tsx", "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", "test/**/*.ts", "e2e/**/*.ts"],
"src/**/*.vue",
"test/**/*.ts",
"e2e/**/*.ts"
],
"references": [ "references": [
{ {
"path": "./tsconfig.node.json" "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"]
}
+75
View File
@@ -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 상태를 확인한 뒤
완료로 기록한다.
+8
View File
@@ -41,6 +41,14 @@ tooltip trigger는 hover/focus 동작과 `cursor: help`를 유지하되, tooltip
텍스트에 점선 밑줄을 반복하지 않습니다. keyboard `focus-visible` outline은 별도의 텍스트에 점선 밑줄을 반복하지 않습니다. 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 ## Button composition
Choose the Ref visual family before choosing a semantic color. Buttons from Choose the Ref visual family before choosing a semantic color. Buttons from
+135
View File
@@ -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이다.
+7 -1
View File
@@ -4,6 +4,9 @@
되돌리는 현재 운영 경계를 설명합니다. Profile은 Gateway orchestrator가, 되돌리는 현재 운영 경계를 설명합니다. Profile은 Gateway orchestrator가,
Gateway 전체는 별도 release-controller가 처리합니다. Gateway 전체는 별도 release-controller가 처리합니다.
일반 사용자의 다음 기수 요청·동의·오프너 수락과 서버별 준비 정책은
[Gateway 오픈 요청](gateway-open-requests.md)을 참조합니다.
## 구성과 권한 ## 구성과 권한
| 대상 | 요청 경로 | 실행자 | 영속 상태 | | 대상 | 요청 경로 | 실행자 | 영속 상태 |
@@ -87,7 +90,10 @@ server build는 기존 Turbo cache를 재사용할 수 있지만, 자원 보호
source와 공개 build-time 값이 다른 frontend artifact를 cache hit로 잘못 복원하지 source와 공개 build-time 값이 다른 frontend artifact를 cache hit로 잘못 복원하지
않습니다. Profile base path와 API/SSE URL은 공용 bundle 입력이 아니라 게시 시점의 않습니다. Profile base path와 API/SSE URL은 공용 bundle 입력이 아니라 게시 시점의
runtime JSON config이므로 profile 사이에서 의도적으로 같은 cache key를 사용합니다. 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 제한으로 `NODE_OPTIONS`와 `RAYON_NUM_THREADS`는 출력에는 영향을 주지 않는 resource 제한으로
build child에 전달됩니다. build child에 전달됩니다.
- `TURN_DAEMON_NODE_OPTIONS`가 설정되어 있으면 Gateway orchestrator는 그 값을 - `TURN_DAEMON_NODE_OPTIONS`가 설정되어 있으면 Gateway orchestrator는 그 값을
+1 -1
View File
@@ -43,7 +43,7 @@ typecheck·unit·Chromium 검증을 실행한 것으로 설명하지 않습니
## 새 worktree 준비 ## 새 worktree 준비
Playwright 설정의 `webServer`는 frontend package의 `dev` 또는 Playwright 설정의 `webServer`는 frontend package의 `dev` 또는
`vue-tsc && vite build`를 직접 실행합니다. 이 경로는 root Turbo task가 Vue 타입 검사와 `vite build`를 직접 실행합니다. 이 경로는 root Turbo task가
보장하는 Prisma 생성과 upstream package build를 거치지 않으므로, 새 worktree에서 보장하는 Prisma 생성과 upstream package build를 거치지 않으므로, 새 worktree에서
브라우저 test를 먼저 실행하면 제품 코드와 무관한 module/type 오류가 발생할 수 브라우저 test를 먼저 실행하면 제품 코드와 무관한 module/type 오류가 발생할 수
있습니다. 있습니다.
+2 -1
View File
@@ -56,6 +56,7 @@
"@types/node": "^26.2.0", "@types/node": "^26.2.0",
"@typescript-eslint/eslint-plugin": "^8.67.0", "@typescript-eslint/eslint-plugin": "^8.67.0",
"@typescript-eslint/parser": "^8.67.0", "@typescript-eslint/parser": "^8.67.0",
"@typescript/native": "npm:typescript@7.0.2",
"eslint": "^10.8.1", "eslint": "^10.8.1",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6", "eslint-plugin-prettier": "^5.5.6",
@@ -67,9 +68,9 @@
"prisma": "^7.10.0", "prisma": "^7.10.0",
"tsdown": "^0.23.0", "tsdown": "^0.23.0",
"turbo": "^2.11.3", "turbo": "^2.11.3",
"@typescript/native": "npm:typescript@7.0.2",
"typescript": "6.0.3", "typescript": "6.0.3",
"typescript-eslint": "^8.67.0", "typescript-eslint": "^8.67.0",
"typescript-native-bridge": "6.0.3-bridge.18.tsgo.7.0.2",
"vitepress": "1.6.4", "vitepress": "1.6.4",
"vue-eslint-parser": "^10.4.1" "vue-eslint-parser": "^10.4.1"
} }
+5 -1
View File
@@ -3,7 +3,11 @@
"compilerOptions": { "compilerOptions": {
"rootDir": "src", "rootDir": "src",
"outDir": "dist", "outDir": "dist",
"composite": false "composite": false,
"paths": {
"@sammo-ts/common": ["./src/index.ts"],
"@sammo-ts/common/*": ["./src/*"]
}
}, },
"include": ["src"] "include": ["src"]
} }
+3 -3
View File
@@ -1,4 +1,5 @@
import { defineConfig } from 'tsdown'; import { defineConfig } from 'tsdown';
import { emitDeclarations } from '../../tools/build-scripts/emit-declarations.mjs';
export default defineConfig({ export default defineConfig({
entry: { entry: {
@@ -20,9 +21,8 @@ export default defineConfig({
}, },
format: 'es', format: 'es',
outDir: 'dist', outDir: 'dist',
dts: { dts: false,
build: true, hooks: { 'build:done': emitDeclarations },
},
sourcemap: true, sourcemap: true,
target: 'node22', target: 'node22',
platform: 'node', platform: 'node',
+1
View File
@@ -18,6 +18,7 @@
"prisma:migrate:deploy:game": "prisma migrate deploy --schema prisma/game.prisma", "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: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", "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:account-icon": "sh scripts/verify-account-icon-migration.sh",
"verify:migration:kakao-talk": "sh scripts/verify-kakao-talk-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", "verify:migration:npc-selection": "sh scripts/verify-npc-selection-token-migration.sh",
@@ -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")
);
+41
View File
@@ -115,6 +115,8 @@ model AppUser {
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
lastLoginAt DateTime? @map("last_login_at") lastLoginAt DateTime? @map("last_login_at")
legacyData Json @default(dbgenerated("'{}'::jsonb")) @map("legacy_data") legacyData Json @default(dbgenerated("'{}'::jsonb")) @map("legacy_data")
openRequests GatewayOpenRequest[]
openEndorsements GatewayOpenEndorsement[]
icons UserIcon[] icons UserIcon[]
specialAccessGrants SpecialAccountAccessGrant[] specialAccessGrants SpecialAccountAccessGrant[]
webPushSubscriptions WebPushSubscription[] webPushSubscriptions WebPushSubscription[]
@@ -254,6 +256,7 @@ model GatewayProfile {
meta Json @default(dbgenerated("'{}'::jsonb")) meta Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
openRequests GatewayOpenRequest[]
operations GatewayOperation[] operations GatewayOperation[]
runtimeActions GatewayRuntimeAction[] runtimeActions GatewayRuntimeAction[]
@@ -407,6 +410,7 @@ model GatewayOperation {
bulkRelease GatewayBulkRelease? @relation(fields: [bulkReleaseId], references: [id], onDelete: SetNull) bulkRelease GatewayBulkRelease? @relation(fields: [bulkReleaseId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
openRequest GatewayOpenRequest?
logs GatewayOperationLog[] logs GatewayOperationLog[]
@@index([status, scheduledAt, createdAt]) @@index([status, scheduledAt, createdAt])
@@ -513,3 +517,40 @@ model SystemSetting {
@@map("system") @@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 });
}
+5 -1
View File
@@ -3,7 +3,11 @@
"compilerOptions": { "compilerOptions": {
"rootDir": "src", "rootDir": "src",
"outDir": "dist", "outDir": "dist",
"composite": false "composite": false,
"paths": {
"@sammo-ts/infra": ["./src/index.ts"],
"@sammo-ts/infra/*": ["./src/*"]
}
}, },
"include": ["src"] "include": ["src"]
} }
+6 -3
View File
@@ -16,8 +16,11 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"useUnknownInCatchVariables": true, "useUnknownInCatchVariables": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true "noUnusedParameters": true,
"paths": {
"@sammo-ts/logic": ["./src/index.ts"],
"@sammo-ts/logic/*": ["./src/*"]
}
}, },
"include": ["src"], "include": ["src"]
"references": [{ "path": "../common" }]
} }
+74
View File
@@ -241,6 +241,9 @@ importers:
typescript-eslint: typescript-eslint:
specifier: ^8.67.0 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) 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: vitepress:
specifier: 1.6.4 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) 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==} resolution: {integrity: sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 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': '@typescript/typescript-aix-ppc64@7.0.2':
resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==}
engines: {node: '>=16.20.0'} engines: {node: '>=16.20.0'}
@@ -5032,6 +5070,11 @@ packages:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: 6.0.3 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: typescript@6.0.3:
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
@@ -6917,6 +6960,27 @@ snapshots:
'@typescript-eslint/types': 8.67.0 '@typescript-eslint/types': 8.67.0
eslint-visitor-keys: 5.0.1 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': '@typescript/typescript-aix-ppc64@7.0.2':
optional: true optional: true
@@ -9225,6 +9289,16 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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@6.0.3: {}
typescript@7.0.2: typescript@7.0.2:
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"formatVersion": 1, "formatVersion": 1,
"controllerProtocol": 2, "controllerProtocol": 2,
"gatewaySchemaHead": "20260825000000_add_bulk_release_batches", "gatewaySchemaHead": "20261022000000_add_open_requests",
"gameSchemaHead": "20260916080000_index_play_audit_decision_month", "gameSchemaHead": "20260916080000_index_play_audit_decision_month",
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"] "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>;
+39
View File
@@ -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 });
}
});
+1 -1
View File
@@ -7,7 +7,7 @@
"build": "pnpm -w tsc7 -p tools/build-scripts/tsconfig.json", "build": "pnpm -w tsc7 -p tools/build-scripts/tsconfig.json",
"dev": "node -e \"console.log('dev not configured')\"", "dev": "node -e \"console.log('dev not configured')\"",
"lint": "node -e \"console.log('lint 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", "typecheck": "pnpm -w tsc7 -b tools/build-scripts/tsconfig.json",
"build:server": "node ./build-server.mjs" "build:server": "node ./build-server.mjs"
} }
+8 -1
View File
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url';
const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const expectedApiVersion = '6.0.3'; const expectedApiVersion = '6.0.3';
const expectedCliVersion = '7.0.2'; const expectedCliVersion = '7.0.2';
const expectedVueBridgeVersion = '6.0.3-bridge.18.tsgo.7.0.2';
const consumers = [ const consumers = [
['root', 'package.json'], ['root', 'package.json'],
@@ -35,4 +36,10 @@ if (cliVersion !== expectedCliVersion) {
throw new Error(`native tsc resolved ${cliVersion}; expected ${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_RUNTIME_ACTION_DATABASE_URL gateway_runtime
GATEWAY_OPERATION_DATABASE_URL gateway_runtime GATEWAY_OPERATION_DATABASE_URL gateway_runtime
GATEWAY_RELEASE_DATABASE_URL gateway_runtime GATEWAY_RELEASE_DATABASE_URL gateway_runtime
OPEN_REQUEST_DATABASE_URL gateway_runtime
GENERAL_LIFECYCLE_DATABASE_URL core GENERAL_LIFECYCLE_DATABASE_URL core
IMMEDIATE_ACTION_DATABASE_URL immediate_action IMMEDIATE_ACTION_DATABASE_URL immediate_action
INPUT_EVENT_DATABASE_URL core INPUT_EVENT_DATABASE_URL core
@@ -30,3 +31,4 @@ TURN_DIFFERENTIAL_DATABASE_URL core
TURN_FULL_LIFECYCLE_PERSISTENCE_DATABASE_URL reference_full_lifecycle TURN_FULL_LIFECYCLE_PERSISTENCE_DATABASE_URL reference_full_lifecycle
WEB_PUSH_GATEWAY_DATABASE_URL web_push_gateway WEB_PUSH_GATEWAY_DATABASE_URL web_push_gateway
TOURNAMENT_RECOVERY_DATABASE_URL external_fixture TOURNAMENT_RECOVERY_DATABASE_URL external_fixture
NPC_POLICY_DATABASE_URL external_fixture
1 # Environment variable Execution mode
5 GATEWAY_RUNTIME_ACTION_DATABASE_URL gateway_runtime
6 GATEWAY_OPERATION_DATABASE_URL gateway_runtime
7 GATEWAY_RELEASE_DATABASE_URL gateway_runtime
8 OPEN_REQUEST_DATABASE_URL gateway_runtime
9 GENERAL_LIFECYCLE_DATABASE_URL core
10 IMMEDIATE_ACTION_DATABASE_URL immediate_action
11 INPUT_EVENT_DATABASE_URL core
31 TURN_FULL_LIFECYCLE_PERSISTENCE_DATABASE_URL reference_full_lifecycle
32 WEB_PUSH_GATEWAY_DATABASE_URL web_push_gateway
33 TOURNAMENT_RECOVERY_DATABASE_URL external_fixture
34 NPC_POLICY_DATABASE_URL external_fixture
+1
View File
@@ -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_RUNTIME_ACTION_DATABASE_URL=$gateway_runtime_database_url
export GATEWAY_OPERATION_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_RELEASE_DATABASE_URL=$gateway_runtime_database_url
export GATEWAY_RUNTIME_INTEGRATION_SCHEMA=$gateway_runtime_schema export GATEWAY_RUNTIME_INTEGRATION_SCHEMA=$gateway_runtime_schema
run_marked_tests app/gateway-api \ run_marked_tests app/gateway-api \
+33
View File
@@ -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);
}
+9
View File
@@ -0,0 +1,9 @@
import { createRequire } from 'node:module';
import path from 'node:path';
// Scope the bridge to the Vue CLI. Vite, ESLint and other compiler API
// consumers keep resolving the workspace's stock TypeScript 6 installation.
const requireFromFrontend = createRequire(path.resolve('package.json'));
const requireFromToolchain = createRequire(import.meta.url);
const { run } = requireFromFrontend('vue-tsc');
run(requireFromToolchain.resolve('typescript-native-bridge/lib/tsc.js'));
+3 -3
View File
@@ -1,4 +1,5 @@
import { defineConfig } from 'tsdown'; import { defineConfig } from 'tsdown';
import { emitDeclarations } from './tools/build-scripts/emit-declarations.mjs';
export default defineConfig({ export default defineConfig({
workspace: { workspace: {
@@ -8,9 +9,8 @@ export default defineConfig({
entry: 'src/index.ts', entry: 'src/index.ts',
format: 'es', format: 'es',
outDir: 'dist', outDir: 'dist',
dts: { dts: false,
build: true hooks: { 'build:done': emitDeclarations },
},
sourcemap: true, sourcemap: true,
target: 'node22', target: 'node22',
platform: 'node', platform: 'node',
+29 -4
View File
@@ -6,7 +6,15 @@
"build": { "build": {
"dependsOn": ["^build", "typecheck"], "dependsOn": ["^build", "typecheck"],
"env": ["NODE_ENV"], "env": ["NODE_ENV"],
"inputs": ["$TURBO_DEFAULT$", ".env*"], "inputs": [
"$TURBO_DEFAULT$",
".env*",
"$TURBO_ROOT$/tsconfig*.json",
"$TURBO_ROOT$/tsdown.config.ts",
"$TURBO_ROOT$/tools/build-scripts/emit-declarations.mjs",
"$TURBO_ROOT$/tools/build-scripts/emit-declarations.d.mts",
"$TURBO_ROOT$/tools/vue-typecheck.mjs"
],
"outputs": ["dist/**"] "outputs": ["dist/**"]
}, },
"build:release": { "build:release": {
@@ -27,13 +35,22 @@
"$TURBO_ROOT$/packages/logic/src/**", "$TURBO_ROOT$/packages/logic/src/**",
"$TURBO_ROOT$/app/game-engine/src/**", "$TURBO_ROOT$/app/game-engine/src/**",
"$TURBO_ROOT$/app/game-api/src/**", "$TURBO_ROOT$/app/game-api/src/**",
"$TURBO_ROOT$/app/gateway-api/src/**" "$TURBO_ROOT$/app/gateway-api/src/**",
"$TURBO_ROOT$/tools/vue-typecheck.mjs"
], ],
"cache": true "cache": true,
"env": ["!VITE_*"]
}, },
"typecheck": { "typecheck": {
"dependsOn": ["^build", "prisma:generate"], "dependsOn": ["^build", "prisma:generate"],
"cache": true "cache": true,
"inputs": [
"$TURBO_DEFAULT$",
"$TURBO_ROOT$/tsconfig*.json",
"$TURBO_ROOT$/tools/build-scripts/emit-declarations.d.mts",
"$TURBO_ROOT$/tools/vue-typecheck.mjs"
],
"env": ["!VITE_*"]
}, },
"prisma:generate": { "prisma:generate": {
"inputs": ["$TURBO_DEFAULT$"], "inputs": ["$TURBO_DEFAULT$"],
@@ -53,6 +70,14 @@
"dev": { "dev": {
"cache": false, "cache": false,
"persistent": true "persistent": true
},
"@sammo-ts/game-frontend#build": {
"dependsOn": ["^build"],
"env": ["NODE_ENV", "VITE_*"]
},
"@sammo-ts/gateway-frontend#build": {
"dependsOn": ["^build"],
"env": ["NODE_ENV", "VITE_*"]
} }
} }
} }