feat: Enhance scenario installation options and UI integration
- Added new installation options to GatewayAdminActionRecord and implemented parsing logic in gatewayOrchestrator.ts. - Updated resolveResetSeedInfo to accommodate new scenario installation parameters. - Introduced a new scenario catalog module to manage scenario previews and details. - Enhanced AdminView.vue to include a comprehensive installation form with various configuration options. - Implemented scenario listing and selection functionality in the frontend. - Updated profile repository to support scenario updates. - Added tests to cover new functionalities and ensure stability.
This commit is contained in:
@@ -16,15 +16,32 @@ export interface GameProfile {
|
|||||||
export const zWorldStateConfig = z.object({
|
export const zWorldStateConfig = z.object({
|
||||||
maxUserCnt: z.number().optional(),
|
maxUserCnt: z.number().optional(),
|
||||||
fictionMode: z.string().optional(),
|
fictionMode: z.string().optional(),
|
||||||
|
fiction: z.number().optional(),
|
||||||
|
joinMode: z.string().optional(),
|
||||||
|
blockGeneralCreate: z.number().optional(),
|
||||||
|
npcMode: z.number().optional(),
|
||||||
|
showImgLevel: z.number().optional(),
|
||||||
|
tournamentTrig: z.boolean().optional(),
|
||||||
|
extendedGeneral: z.boolean().optional(),
|
||||||
|
turnTermMinutes: z.number().optional(),
|
||||||
|
syncTurnTime: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
export type WorldStateConfig = z.infer<typeof zWorldStateConfig>;
|
export type WorldStateConfig = z.infer<typeof zWorldStateConfig>;
|
||||||
|
|
||||||
export const zWorldStateMeta = z.object({
|
export const zWorldStateMeta = z.object({
|
||||||
starttime: z.string().optional(),
|
starttime: z.string().optional(),
|
||||||
opentime: z.string().optional(),
|
opentime: z.string().optional(),
|
||||||
|
preopenAt: z.string().optional(),
|
||||||
turntime: z.string().optional(),
|
turntime: z.string().optional(),
|
||||||
otherTextInfo: z.string().optional(),
|
otherTextInfo: z.string().optional(),
|
||||||
isUnited: z.number().optional(),
|
isUnited: z.number().optional(),
|
||||||
|
autorun_user: z
|
||||||
|
.object({
|
||||||
|
limit_minutes: z.number().optional(),
|
||||||
|
options: z.record(z.string(), z.boolean()).optional(),
|
||||||
|
})
|
||||||
|
.nullable()
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
export type WorldStateMeta = z.infer<typeof zWorldStateMeta>;
|
export type WorldStateMeta = z.infer<typeof zWorldStateMeta>;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
import type { WorldStateRow } from '../../context.js';
|
import type { WorldStateRow } from '../../context.js';
|
||||||
import { authedProcedure, router } from '../../trpc.js';
|
import { authedProcedure, router } from '../../trpc.js';
|
||||||
@@ -43,6 +44,17 @@ const resolveJoinStat = (worldState: WorldStateRow) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolveJoinPolicy = (worldState: WorldStateRow) => {
|
||||||
|
const config = asRecord(worldState.config);
|
||||||
|
const joinMode = typeof config.joinMode === 'string' ? config.joinMode : 'full';
|
||||||
|
const blockGeneralCreate =
|
||||||
|
typeof config.blockGeneralCreate === 'number' && Number.isFinite(config.blockGeneralCreate)
|
||||||
|
? Math.floor(config.blockGeneralCreate)
|
||||||
|
: 0;
|
||||||
|
return { joinMode, blockGeneralCreate };
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
const hashString = (value: string): number => {
|
const hashString = (value: string): number => {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (let i = 0; i < value.length; i += 1) {
|
for (let i = 0; i < value.length; i += 1) {
|
||||||
@@ -169,6 +181,14 @@ export const joinRouter = router({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const joinPolicy = resolveJoinPolicy(worldState);
|
||||||
|
if (joinPolicy.blockGeneralCreate === 1) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: '장수 생성이 제한된 서버입니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const statRule = resolveJoinStat(worldState);
|
const statRule = resolveJoinStat(worldState);
|
||||||
const statTotal = input.leadership + input.strength + input.intel;
|
const statTotal = input.leadership + input.strength + input.intel;
|
||||||
|
|
||||||
@@ -195,12 +215,30 @@ export const joinRouter = router({
|
|||||||
|
|
||||||
const personalityOptions = await loadPersonalityOptions();
|
const personalityOptions = await loadPersonalityOptions();
|
||||||
const personalityKeys = personalityOptions.map((trait) => trait.key);
|
const personalityKeys = personalityOptions.map((trait) => trait.key);
|
||||||
|
const resolveGeneralName = async (): Promise<string> => {
|
||||||
|
if (joinPolicy.blockGeneralCreate !== 2) {
|
||||||
|
return input.name;
|
||||||
|
}
|
||||||
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
|
const candidate = randomBytes(5).toString('hex');
|
||||||
|
const exists = await ctx.db.general.findFirst({ where: { name: candidate } });
|
||||||
|
if (!exists) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'INTERNAL_SERVER_ERROR',
|
||||||
|
message: '랜덤 장수명 생성에 실패했습니다.',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const generalName = await resolveGeneralName();
|
||||||
const chosenPersonality =
|
const chosenPersonality =
|
||||||
input.character === 'Random'
|
input.character === 'Random'
|
||||||
? pickFromList(personalityKeys, `${userId}:${input.name}`) ?? 'None'
|
? pickFromList(personalityKeys, `${userId}:${generalName}`) ?? 'None'
|
||||||
: isPersonalityTraitKey(input.character)
|
: isPersonalityTraitKey(input.character)
|
||||||
? input.character
|
? input.character
|
||||||
: 'None';
|
: 'None';
|
||||||
|
|
||||||
return ctx.db.$transaction(async (db) => {
|
return ctx.db.$transaction(async (db) => {
|
||||||
const existing = await db.general.findFirst({ where: { userId } });
|
const existing = await db.general.findFirst({ where: { userId } });
|
||||||
@@ -210,7 +248,7 @@ export const joinRouter = router({
|
|||||||
message: '이미 장수가 생성되어 있습니다.',
|
message: '이미 장수가 생성되어 있습니다.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const nameExists = await db.general.findFirst({ where: { name: input.name } });
|
const nameExists = await db.general.findFirst({ where: { name: generalName } });
|
||||||
if (nameExists) {
|
if (nameExists) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'CONFLICT',
|
code: 'CONFLICT',
|
||||||
@@ -234,7 +272,7 @@ export const joinRouter = router({
|
|||||||
data: {
|
data: {
|
||||||
id: nextId,
|
id: nextId,
|
||||||
userId,
|
userId,
|
||||||
name: input.name,
|
name: generalName,
|
||||||
nationId: 0,
|
nationId: 0,
|
||||||
cityId,
|
cityId,
|
||||||
troopId: 0,
|
troopId: 0,
|
||||||
|
|||||||
@@ -12,6 +12,27 @@ const DEFAULT_TICK_SECONDS = 120 * 60;
|
|||||||
const DEFAULT_GENERAL_GOLD = 1000;
|
const DEFAULT_GENERAL_GOLD = 1000;
|
||||||
const DEFAULT_GENERAL_RICE = 1000;
|
const DEFAULT_GENERAL_RICE = 1000;
|
||||||
|
|
||||||
|
const MINUTES_TO_MS = 60_000;
|
||||||
|
|
||||||
|
export interface ScenarioAutorunOptions {
|
||||||
|
limitMinutes: number;
|
||||||
|
options: Record<string, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScenarioInstallOptions {
|
||||||
|
turnTermMinutes?: number;
|
||||||
|
sync?: boolean;
|
||||||
|
fiction?: number;
|
||||||
|
extend?: boolean;
|
||||||
|
blockGeneralCreate?: number;
|
||||||
|
npcMode?: number;
|
||||||
|
showImgLevel?: number;
|
||||||
|
tournamentTrig?: boolean;
|
||||||
|
joinMode?: 'full' | 'onlyRandom';
|
||||||
|
autorunUser?: ScenarioAutorunOptions | null;
|
||||||
|
preopenAt?: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ScenarioSeedOptions {
|
export interface ScenarioSeedOptions {
|
||||||
scenarioId: number;
|
scenarioId: number;
|
||||||
databaseUrl: string;
|
databaseUrl: string;
|
||||||
@@ -21,6 +42,7 @@ export interface ScenarioSeedOptions {
|
|||||||
resetTables?: boolean;
|
resetTables?: boolean;
|
||||||
now?: Date;
|
now?: Date;
|
||||||
tickSeconds?: number;
|
tickSeconds?: number;
|
||||||
|
installOptions?: ScenarioInstallOptions;
|
||||||
includeNeutralNationInSeed?: boolean;
|
includeNeutralNationInSeed?: boolean;
|
||||||
defaultGeneralGold?: number;
|
defaultGeneralGold?: number;
|
||||||
defaultGeneralRice?: number;
|
defaultGeneralRice?: number;
|
||||||
@@ -33,6 +55,61 @@ export interface ScenarioSeedResult {
|
|||||||
|
|
||||||
const asJson = (value: unknown): InputJsonValue => value as InputJsonValue;
|
const asJson = (value: unknown): InputJsonValue => value as InputJsonValue;
|
||||||
|
|
||||||
|
const formatDateTime = (date: Date): string => {
|
||||||
|
const pad = (value: number): string => String(value).padStart(2, '0');
|
||||||
|
return [
|
||||||
|
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`,
|
||||||
|
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`,
|
||||||
|
].join(' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const cutTurn = (date: Date, turnTermMinutes: number): Date => {
|
||||||
|
const base = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 1, 0, 0, 0);
|
||||||
|
base.setDate(base.getDate() - 1);
|
||||||
|
const diffMinutes = Math.floor((date.getTime() - base.getTime()) / MINUTES_TO_MS);
|
||||||
|
const alignedMinutes = diffMinutes - (diffMinutes % turnTermMinutes);
|
||||||
|
return new Date(base.getTime() + alignedMinutes * MINUTES_TO_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cutDay = (date: Date, turnTermMinutes: number): { startTime: Date; month: number; yearPulled: boolean } => {
|
||||||
|
const base = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 1, 0, 0, 0);
|
||||||
|
base.setDate(base.getDate() - 1);
|
||||||
|
const baseGap = 12 * turnTermMinutes;
|
||||||
|
const diffMinutes = Math.floor((date.getTime() - base.getTime()) / MINUTES_TO_MS);
|
||||||
|
const timeAdjust = diffMinutes % baseGap;
|
||||||
|
const month = Math.floor(timeAdjust / turnTermMinutes) + 1;
|
||||||
|
const yearPulled = month > 3;
|
||||||
|
const alignedMinutes = diffMinutes - timeAdjust + (yearPulled ? baseGap : 0);
|
||||||
|
return {
|
||||||
|
startTime: new Date(base.getTime() + alignedMinutes * MINUTES_TO_MS),
|
||||||
|
month,
|
||||||
|
yearPulled,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveStartState = (
|
||||||
|
scenarioStartYear: number | null,
|
||||||
|
now: Date,
|
||||||
|
turnTermMinutes: number,
|
||||||
|
sync: boolean
|
||||||
|
): { startTime: Date; currentYear: number; currentMonth: number } => {
|
||||||
|
const startYear = scenarioStartYear ?? 0;
|
||||||
|
if (!sync) {
|
||||||
|
return {
|
||||||
|
startTime: cutTurn(now, turnTermMinutes),
|
||||||
|
currentYear: startYear,
|
||||||
|
currentMonth: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { startTime, month, yearPulled } = cutDay(now, turnTermMinutes);
|
||||||
|
return {
|
||||||
|
startTime,
|
||||||
|
currentYear: startYear - (yearPulled ? 1 : 0),
|
||||||
|
currentMonth: month,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const resolveGeneralAge = (startYear: number | null, birthYear: number): number => {
|
const resolveGeneralAge = (startYear: number | null, birthYear: number): number => {
|
||||||
if (startYear === null || birthYear <= 0) {
|
if (startYear === null || birthYear <= 0) {
|
||||||
return 20;
|
return 20;
|
||||||
@@ -77,12 +154,15 @@ const buildEventRows = (rows: unknown[], targetOverride?: string): TurnEngineEve
|
|||||||
|
|
||||||
// 시나리오 초기 데이터를 로드해 DB에 저장한다.
|
// 시나리오 초기 데이터를 로드해 DB에 저장한다.
|
||||||
export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Promise<ScenarioSeedResult> => {
|
export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Promise<ScenarioSeedResult> => {
|
||||||
|
const install = options.installOptions;
|
||||||
const scenario = await loadScenarioDefinitionById(options.scenarioId, options.scenarioOptions);
|
const scenario = await loadScenarioDefinitionById(options.scenarioId, options.scenarioOptions);
|
||||||
|
const includeExtendedGeneral = install?.extend ?? true;
|
||||||
|
const scenarioDefinition = includeExtendedGeneral ? scenario : { ...scenario, generalsEx: [] };
|
||||||
const map = await loadMapDefinitionByName(scenario.config.environment.mapName, options.mapOptions);
|
const map = await loadMapDefinitionByName(scenario.config.environment.mapName, options.mapOptions);
|
||||||
const unitSet = await loadUnitSetDefinitionByName(scenario.config.environment.unitSet, options.unitSetOptions);
|
const unitSet = await loadUnitSetDefinitionByName(scenario.config.environment.unitSet, options.unitSetOptions);
|
||||||
|
|
||||||
const { seed, warnings } = buildScenarioBootstrap({
|
const { seed, warnings } = buildScenarioBootstrap({
|
||||||
scenario,
|
scenario: scenarioDefinition,
|
||||||
map,
|
map,
|
||||||
unitSet,
|
unitSet,
|
||||||
options: {
|
options: {
|
||||||
@@ -92,10 +172,47 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
|||||||
|
|
||||||
const connector = createGamePostgresConnector({ url: options.databaseUrl });
|
const connector = createGamePostgresConnector({ url: options.databaseUrl });
|
||||||
const now = options.now ?? new Date();
|
const now = options.now ?? new Date();
|
||||||
const tickSeconds = options.tickSeconds ?? DEFAULT_TICK_SECONDS;
|
const tickSeconds =
|
||||||
|
install?.turnTermMinutes !== undefined ? install.turnTermMinutes * 60 : options.tickSeconds ?? DEFAULT_TICK_SECONDS;
|
||||||
|
const turnTermMinutes = Math.max(1, Math.round(tickSeconds / 60));
|
||||||
|
const sync = install?.sync ?? false;
|
||||||
|
const startState = resolveStartState(scenario.startYear ?? null, now, turnTermMinutes, sync);
|
||||||
const generalGold = options.defaultGeneralGold ?? DEFAULT_GENERAL_GOLD;
|
const generalGold = options.defaultGeneralGold ?? DEFAULT_GENERAL_GOLD;
|
||||||
const generalRice = options.defaultGeneralRice ?? DEFAULT_GENERAL_RICE;
|
const generalRice = options.defaultGeneralRice ?? DEFAULT_GENERAL_RICE;
|
||||||
|
|
||||||
|
const worldConfig: Record<string, unknown> = {
|
||||||
|
fiction: install?.fiction,
|
||||||
|
fictionMode: install?.fiction === 0 ? '연의' : install?.fiction === 1 ? '가상' : undefined,
|
||||||
|
joinMode: install?.joinMode,
|
||||||
|
blockGeneralCreate: install?.blockGeneralCreate,
|
||||||
|
npcMode: install?.npcMode,
|
||||||
|
showImgLevel: install?.showImgLevel,
|
||||||
|
tournamentTrig: install?.tournamentTrig,
|
||||||
|
extendedGeneral: includeExtendedGeneral,
|
||||||
|
turnTermMinutes: install?.turnTermMinutes,
|
||||||
|
syncTurnTime: install?.sync,
|
||||||
|
};
|
||||||
|
|
||||||
|
const worldMeta: Record<string, unknown> = {
|
||||||
|
scenarioId: options.scenarioId,
|
||||||
|
scenarioMeta: seed.scenarioMeta,
|
||||||
|
starttime: formatDateTime(startState.startTime),
|
||||||
|
turntime: formatDateTime(now),
|
||||||
|
opentime: formatDateTime(now),
|
||||||
|
lastTurnTime: formatDateTime(now),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (install?.preopenAt) {
|
||||||
|
worldMeta.preopenAt = formatDateTime(install.preopenAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (install?.autorunUser) {
|
||||||
|
worldMeta.autorun_user = {
|
||||||
|
limit_minutes: install.autorunUser.limitMinutes,
|
||||||
|
options: install.autorunUser.options,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
await connector.connect();
|
await connector.connect();
|
||||||
try {
|
try {
|
||||||
const prisma = connector.prisma;
|
const prisma = connector.prisma;
|
||||||
@@ -113,14 +230,11 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
|||||||
await prisma.worldState.create({
|
await prisma.worldState.create({
|
||||||
data: {
|
data: {
|
||||||
scenarioCode: String(options.scenarioId),
|
scenarioCode: String(options.scenarioId),
|
||||||
currentYear: scenario.startYear ?? 0,
|
currentYear: startState.currentYear,
|
||||||
currentMonth: 1,
|
currentMonth: startState.currentMonth,
|
||||||
tickSeconds,
|
tickSeconds,
|
||||||
config: asJson(seed.scenarioConfig),
|
config: asJson({ ...seed.scenarioConfig, ...worldConfig }),
|
||||||
meta: asJson({
|
meta: asJson(worldMeta),
|
||||||
scenarioId: options.scenarioId,
|
|
||||||
scenarioMeta: seed.scenarioMeta,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,24 @@ export interface GatewayAdminActionRecord {
|
|||||||
handledAt?: string | null;
|
handledAt?: string | null;
|
||||||
handler?: string | null;
|
handler?: string | null;
|
||||||
detail?: string | null;
|
detail?: string | null;
|
||||||
|
install?: {
|
||||||
|
scenarioId?: number;
|
||||||
|
turnTermMinutes?: number;
|
||||||
|
sync?: boolean;
|
||||||
|
fiction?: number;
|
||||||
|
extend?: boolean;
|
||||||
|
blockGeneralCreate?: number;
|
||||||
|
npcMode?: number;
|
||||||
|
showImgLevel?: number;
|
||||||
|
tournamentTrig?: boolean;
|
||||||
|
joinMode?: string;
|
||||||
|
autorunUser?: {
|
||||||
|
limitMinutes?: number;
|
||||||
|
options?: string[];
|
||||||
|
} | null;
|
||||||
|
openAt?: string | null;
|
||||||
|
preopenAt?: string | null;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GatewayAdminActionResult {
|
export interface GatewayAdminActionResult {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createGamePostgresConnector } from '@sammo-ts/infra';
|
import { createGamePostgresConnector } from '@sammo-ts/infra';
|
||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
import { resolveDatabaseUrl } from '../src/scenario/databaseUrl.js';
|
import { resolveDatabaseUrl } from '../src/scenario/databaseUrl.js';
|
||||||
|
import { loadScenarioDefinitionById } from '../src/scenario/scenarioLoader.js';
|
||||||
import { seedScenarioToDatabase } from '../src/scenario/scenarioSeeder.js';
|
import { seedScenarioToDatabase } from '../src/scenario/scenarioSeeder.js';
|
||||||
|
|
||||||
const scenarioId = 1010;
|
const scenarioId = 1010;
|
||||||
@@ -25,6 +26,15 @@ type ScenarioSeederPrismaClient = {
|
|||||||
where: { srcNationId: number; destNationId: number };
|
where: { srcNationId: number; destNationId: number };
|
||||||
}): Promise<{ stateCode: number; term: number } | null>;
|
}): Promise<{ stateCode: number; term: number } | null>;
|
||||||
};
|
};
|
||||||
|
worldState: {
|
||||||
|
findFirst(): Promise<{
|
||||||
|
config: unknown;
|
||||||
|
meta: unknown;
|
||||||
|
tickSeconds: number;
|
||||||
|
currentYear: number;
|
||||||
|
currentMonth: number;
|
||||||
|
} | null>;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const canConnectToDatabase = async (url: string): Promise<boolean> => {
|
const canConnectToDatabase = async (url: string): Promise<boolean> => {
|
||||||
@@ -97,4 +107,57 @@ describeDb('scenario database seed', () => {
|
|||||||
await connector.disconnect();
|
await connector.disconnect();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('applies install options to world state', async () => {
|
||||||
|
const scenario = await loadScenarioDefinitionById(scenarioId);
|
||||||
|
const { seed } = await seedScenarioToDatabase({
|
||||||
|
scenarioId,
|
||||||
|
databaseUrl,
|
||||||
|
now: new Date('2030-01-01T00:00:00Z'),
|
||||||
|
installOptions: {
|
||||||
|
turnTermMinutes: 60,
|
||||||
|
sync: false,
|
||||||
|
fiction: 1,
|
||||||
|
extend: false,
|
||||||
|
blockGeneralCreate: 2,
|
||||||
|
npcMode: 0,
|
||||||
|
showImgLevel: 3,
|
||||||
|
tournamentTrig: true,
|
||||||
|
joinMode: 'full',
|
||||||
|
autorunUser: {
|
||||||
|
limitMinutes: 60,
|
||||||
|
options: {
|
||||||
|
develop: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const expectedGenerals = scenario.generals.length + scenario.generalsNeutral.length;
|
||||||
|
expect(seed.generals.length).toBe(expectedGenerals);
|
||||||
|
|
||||||
|
const connector = createGamePostgresConnector({ url: databaseUrl });
|
||||||
|
await connector.connect();
|
||||||
|
try {
|
||||||
|
const prisma = connector.prisma as unknown as ScenarioSeederPrismaClient;
|
||||||
|
const worldState = await prisma.worldState.findFirst();
|
||||||
|
expect(worldState).not.toBeNull();
|
||||||
|
if (!worldState) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(worldState.tickSeconds).toBe(3600);
|
||||||
|
expect(worldState.currentMonth).toBe(1);
|
||||||
|
|
||||||
|
const config = (worldState.config ?? {}) as Record<string, unknown>;
|
||||||
|
expect(config.extendedGeneral).toBe(false);
|
||||||
|
expect(config.joinMode).toBe('full');
|
||||||
|
|
||||||
|
const meta = (worldState.meta ?? {}) as Record<string, unknown>;
|
||||||
|
const autorun = (meta.autorun_user ?? {}) as Record<string, unknown>;
|
||||||
|
const autorunOptions = (autorun.options ?? {}) as Record<string, unknown>;
|
||||||
|
expect(autorunOptions.develop).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await connector.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { TRPCError } from '@trpc/server';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { procedure, router } from './trpc.js';
|
import { procedure, router } from './trpc.js';
|
||||||
|
import { listScenarioPreviews } from './scenario/scenarioCatalog.js';
|
||||||
import type { UserSanctions, UserServerRestriction } from './auth/userRepository.js';
|
import type { UserSanctions, UserServerRestriction } from './auth/userRepository.js';
|
||||||
import type { AdminAuthContext } from './adminAuth.js';
|
import type { AdminAuthContext } from './adminAuth.js';
|
||||||
import type { GatewayApiContext } from './context.js';
|
import type { GatewayApiContext } from './context.js';
|
||||||
@@ -12,6 +13,7 @@ import { GATEWAY_BUILD_STATUSES, GATEWAY_PROFILE_STATUSES } from './orchestrator
|
|||||||
const zProfileStatus = z.enum(GATEWAY_PROFILE_STATUSES);
|
const zProfileStatus = z.enum(GATEWAY_PROFILE_STATUSES);
|
||||||
const zBuildStatus = z.enum(GATEWAY_BUILD_STATUSES);
|
const zBuildStatus = z.enum(GATEWAY_BUILD_STATUSES);
|
||||||
const zUserRoleMode = z.enum(['set', 'grant', 'revoke']);
|
const zUserRoleMode = z.enum(['set', 'grant', 'revoke']);
|
||||||
|
const zJoinMode = z.enum(['full', 'onlyRandom']);
|
||||||
const zServerAction = z.enum([
|
const zServerAction = z.enum([
|
||||||
'RESUME',
|
'RESUME',
|
||||||
'PAUSE',
|
'PAUSE',
|
||||||
@@ -24,6 +26,17 @@ const zServerAction = z.enum([
|
|||||||
'SHUTDOWN',
|
'SHUTDOWN',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const TURN_TERM_MINUTES = [1, 2, 5, 10, 20, 30, 60, 120] as const;
|
||||||
|
const AUTORUN_USER_OPTIONS = [
|
||||||
|
'develop',
|
||||||
|
'warp',
|
||||||
|
'recruit',
|
||||||
|
'recruit_high',
|
||||||
|
'train',
|
||||||
|
'battle',
|
||||||
|
'chief',
|
||||||
|
] as const;
|
||||||
|
|
||||||
const ADMIN_ROLE_PREFIX = 'admin.';
|
const ADMIN_ROLE_PREFIX = 'admin.';
|
||||||
const ADMIN_ROLE_SUPERUSER = 'admin.superuser';
|
const ADMIN_ROLE_SUPERUSER = 'admin.superuser';
|
||||||
const ROLE_SUPERUSER = 'superuser';
|
const ROLE_SUPERUSER = 'superuser';
|
||||||
@@ -185,6 +198,31 @@ const zSanctionsPatch = z.object({
|
|||||||
serverRestrictions: z.record(z.string(), zServerRestriction.nullable()).nullable().optional(),
|
serverRestrictions: z.record(z.string(), zServerRestriction.nullable()).nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const zInstallAutorun = z.object({
|
||||||
|
limitMinutes: z.number().int().min(0).max(43200),
|
||||||
|
options: z.array(z.enum(AUTORUN_USER_OPTIONS)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const isAllowedTurnTerm = (value: number): boolean => TURN_TERM_MINUTES.some((term) => term === value);
|
||||||
|
|
||||||
|
const zInstallOptions = z.object({
|
||||||
|
scenarioId: z.number().int().min(0),
|
||||||
|
turnTermMinutes: z.number().int().refine((value) => isAllowedTurnTerm(value), {
|
||||||
|
message: 'turnTermMinutes must divide 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(),
|
||||||
|
});
|
||||||
|
|
||||||
type SanctionsPatch = z.infer<typeof zSanctionsPatch>;
|
type SanctionsPatch = z.infer<typeof zSanctionsPatch>;
|
||||||
|
|
||||||
// 제재 패치 입력을 현재 제재 상태에 병합한다.
|
// 제재 패치 입력을 현재 제재 상태에 병합한다.
|
||||||
@@ -468,6 +506,9 @@ export const adminRouter = router({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}),
|
}),
|
||||||
|
listScenarios: profileAdminProcedure.query(async () => {
|
||||||
|
return listScenarioPreviews();
|
||||||
|
}),
|
||||||
upsert: profileAdminProcedure
|
upsert: profileAdminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -549,6 +590,135 @@ export const adminRouter = router({
|
|||||||
const nextMeta = applyMetaPatch(meta, input.patch);
|
const nextMeta = applyMetaPatch(meta, input.patch);
|
||||||
return ctx.profiles.updateMeta(input.profileName, nextMeta);
|
return ctx.profiles.updateMeta(input.profileName, nextMeta);
|
||||||
}),
|
}),
|
||||||
|
install: profileAdminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
profileName: z.string().min(1),
|
||||||
|
install: zInstallOptions,
|
||||||
|
reason: z.string().max(200).optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const profile = await ctx.profiles.getProfile(input.profileName);
|
||||||
|
if (!profile) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: 'Profile not found.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const openAt = input.install.openAt ? new Date(input.install.openAt) : null;
|
||||||
|
const preopenAt = input.install.preopenAt ? new Date(input.install.preopenAt) : null;
|
||||||
|
if (openAt && Number.isNaN(openAt.getTime())) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'openAt is invalid.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (preopenAt && Number.isNaN(preopenAt.getTime())) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'preopenAt is invalid.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (openAt && openAt.getTime() < now.getTime()) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'openAt must be in the future.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (preopenAt && !openAt) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'openAt is required when preopenAt is set.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (preopenAt && openAt && preopenAt.getTime() >= openAt.getTime()) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'preopenAt must be earlier than openAt.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const autorunUser = input.install.autorunUser ?? null;
|
||||||
|
if (autorunUser) {
|
||||||
|
if (autorunUser.limitMinutes <= 0 && autorunUser.options.length > 0) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'autorunUser limitMinutes must be positive when options are provided.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (autorunUser.limitMinutes > 0 && autorunUser.options.length === 0) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'autorunUser options must be provided when limitMinutes is set.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scenarioValue = String(input.install.scenarioId);
|
||||||
|
if (profile.scenario !== scenarioValue) {
|
||||||
|
try {
|
||||||
|
await ctx.profiles.updateScenario(profile.profileName, scenarioValue);
|
||||||
|
} catch (error) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'CONFLICT',
|
||||||
|
message: 'Scenario update failed due to duplication.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduledAt = openAt ? (preopenAt ?? openAt).toISOString() : null;
|
||||||
|
const action = scheduledAt ? 'RESET_SCHEDULED' : 'RESET_NOW';
|
||||||
|
const meta = readMetaObject(profile.meta);
|
||||||
|
const actionLog = Array.isArray(meta.adminActions)
|
||||||
|
? meta.adminActions.filter((entry) => entry && typeof entry === 'object')
|
||||||
|
: [];
|
||||||
|
const actionRecord = {
|
||||||
|
action,
|
||||||
|
requestedAt: now.toISOString(),
|
||||||
|
scheduledAt,
|
||||||
|
reason: input.reason ?? null,
|
||||||
|
status: 'REQUESTED',
|
||||||
|
install: {
|
||||||
|
...input.install,
|
||||||
|
openAt: input.install.openAt ?? null,
|
||||||
|
preopenAt: input.install.preopenAt ?? null,
|
||||||
|
autorunUser: autorunUser
|
||||||
|
? {
|
||||||
|
limitMinutes: autorunUser.limitMinutes,
|
||||||
|
options: autorunUser.options,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextMeta = {
|
||||||
|
...meta,
|
||||||
|
adminActions: [...actionLog, actionRecord],
|
||||||
|
install: actionRecord.install,
|
||||||
|
installUpdatedAt: now.toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await ctx.profiles.updateMeta(input.profileName, nextMeta);
|
||||||
|
|
||||||
|
if (openAt) {
|
||||||
|
await ctx.profiles.updateStatus(profile.profileName, profile.status, {
|
||||||
|
preopenAt: preopenAt ? preopenAt.toISOString() : openAt.toISOString(),
|
||||||
|
openAt: openAt.toISOString(),
|
||||||
|
scheduledStartAt: scheduledAt,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await ctx.profiles.updateStatus(profile.profileName, profile.status, {
|
||||||
|
preopenAt: null,
|
||||||
|
openAt: null,
|
||||||
|
scheduledStartAt: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, action: actionRecord };
|
||||||
|
}),
|
||||||
requestAction: adminProcedure
|
requestAction: adminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { seedScenarioToDatabase } from '@sammo-ts/game-engine';
|
import { seedScenarioToDatabase, type ScenarioInstallOptions } from '@sammo-ts/game-engine';
|
||||||
import { createGamePostgresConnector, resolvePostgresConfigFromEnv } from '@sammo-ts/infra';
|
import { createGamePostgresConnector, resolvePostgresConfigFromEnv } from '@sammo-ts/infra';
|
||||||
|
|
||||||
import type { BuildRunner } from './buildRunner.js';
|
import type { BuildRunner } from './buildRunner.js';
|
||||||
@@ -78,6 +78,24 @@ interface GatewayAdminActionRecord {
|
|||||||
handledAt?: string | null;
|
handledAt?: string | null;
|
||||||
handler?: string | null;
|
handler?: string | null;
|
||||||
detail?: string | null;
|
detail?: string | null;
|
||||||
|
install?: {
|
||||||
|
scenarioId?: number;
|
||||||
|
turnTermMinutes?: number;
|
||||||
|
sync?: boolean;
|
||||||
|
fiction?: number;
|
||||||
|
extend?: boolean;
|
||||||
|
blockGeneralCreate?: number;
|
||||||
|
npcMode?: number;
|
||||||
|
showImgLevel?: number;
|
||||||
|
tournamentTrig?: boolean;
|
||||||
|
joinMode?: string;
|
||||||
|
autorunUser?: {
|
||||||
|
limitMinutes?: number;
|
||||||
|
options?: string[];
|
||||||
|
} | null;
|
||||||
|
openAt?: string | null;
|
||||||
|
preopenAt?: string | null;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GatewayAdminActionResult {
|
interface GatewayAdminActionResult {
|
||||||
@@ -113,6 +131,95 @@ const parseScenarioId = (value: string | number | null | undefined): number | nu
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const parseDateTime = (value: unknown): Date | null => {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseInstallOptions = (
|
||||||
|
action: GatewayAdminActionRecord
|
||||||
|
): {
|
||||||
|
installOptions: ScenarioInstallOptions | null;
|
||||||
|
scenarioId: number | null;
|
||||||
|
openAt: Date | null;
|
||||||
|
preopenAt: Date | null;
|
||||||
|
} => {
|
||||||
|
if (!isRecord(action.install)) {
|
||||||
|
return { installOptions: null, scenarioId: null, openAt: null, preopenAt: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const install = action.install;
|
||||||
|
const scenarioId = parseScenarioId(install.scenarioId ?? null);
|
||||||
|
const turnTermMinutes =
|
||||||
|
typeof install.turnTermMinutes === 'number' && Number.isFinite(install.turnTermMinutes)
|
||||||
|
? Math.floor(install.turnTermMinutes)
|
||||||
|
: undefined;
|
||||||
|
const sync = typeof install.sync === 'boolean' ? install.sync : undefined;
|
||||||
|
const fiction =
|
||||||
|
typeof install.fiction === 'number' && Number.isFinite(install.fiction) ? Math.floor(install.fiction) : undefined;
|
||||||
|
const extend = typeof install.extend === 'boolean' ? install.extend : undefined;
|
||||||
|
const blockGeneralCreate =
|
||||||
|
typeof install.blockGeneralCreate === 'number' && Number.isFinite(install.blockGeneralCreate)
|
||||||
|
? Math.floor(install.blockGeneralCreate)
|
||||||
|
: undefined;
|
||||||
|
const npcMode =
|
||||||
|
typeof install.npcMode === 'number' && Number.isFinite(install.npcMode) ? Math.floor(install.npcMode) : undefined;
|
||||||
|
const showImgLevel =
|
||||||
|
typeof install.showImgLevel === 'number' && Number.isFinite(install.showImgLevel)
|
||||||
|
? Math.floor(install.showImgLevel)
|
||||||
|
: undefined;
|
||||||
|
const tournamentTrig = typeof install.tournamentTrig === 'boolean' ? install.tournamentTrig : undefined;
|
||||||
|
const joinMode = typeof install.joinMode === 'string' ? install.joinMode : undefined;
|
||||||
|
|
||||||
|
let autorunUser: ScenarioInstallOptions['autorunUser'];
|
||||||
|
if (isRecord(install.autorunUser)) {
|
||||||
|
const limitMinutes =
|
||||||
|
typeof install.autorunUser.limitMinutes === 'number' && Number.isFinite(install.autorunUser.limitMinutes)
|
||||||
|
? Math.floor(install.autorunUser.limitMinutes)
|
||||||
|
: 0;
|
||||||
|
const optionsRaw = Array.isArray(install.autorunUser.options)
|
||||||
|
? install.autorunUser.options.filter((option) => typeof option === 'string')
|
||||||
|
: [];
|
||||||
|
const options = optionsRaw.reduce<Record<string, boolean>>((acc, option) => {
|
||||||
|
acc[option] = true;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
if (limitMinutes > 0 && Object.keys(options).length > 0) {
|
||||||
|
autorunUser = { limitMinutes, options };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAt = parseDateTime(install.openAt ?? null);
|
||||||
|
const preopenAt = parseDateTime(install.preopenAt ?? null);
|
||||||
|
|
||||||
|
const installOptions: ScenarioInstallOptions = {
|
||||||
|
turnTermMinutes,
|
||||||
|
sync,
|
||||||
|
fiction,
|
||||||
|
extend,
|
||||||
|
blockGeneralCreate,
|
||||||
|
npcMode,
|
||||||
|
showImgLevel,
|
||||||
|
tournamentTrig,
|
||||||
|
joinMode: joinMode === 'full' || joinMode === 'onlyRandom' ? joinMode : undefined,
|
||||||
|
autorunUser: autorunUser ?? null,
|
||||||
|
preopenAt: preopenAt ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
installOptions,
|
||||||
|
scenarioId,
|
||||||
|
openAt,
|
||||||
|
preopenAt,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const buildProcessName = (profileName: string, role: 'api' | 'daemon'): string =>
|
const buildProcessName = (profileName: string, role: 'api' | 'daemon'): string =>
|
||||||
`sammo:${profileName}:${role === 'api' ? 'game-api' : 'turn-daemon'}`;
|
`sammo:${profileName}:${role === 'api' ? 'game-api' : 'turn-daemon'}`;
|
||||||
|
|
||||||
@@ -476,15 +583,29 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
this.buildInFlight = true;
|
this.buildInFlight = true;
|
||||||
this.resetInFlight.add(profile.profileName);
|
this.resetInFlight.add(profile.profileName);
|
||||||
try {
|
try {
|
||||||
const seedInfo = await this.resolveResetSeedInfo(profile);
|
const { installOptions, scenarioId: installScenarioId, openAt, preopenAt } = parseInstallOptions(action);
|
||||||
|
const tickOverride =
|
||||||
|
installOptions?.turnTermMinutes !== undefined ? installOptions.turnTermMinutes * 60 : undefined;
|
||||||
|
const seedInfo = await this.resolveResetSeedInfo(profile, {
|
||||||
|
scenarioId: installScenarioId,
|
||||||
|
tickSeconds: tickOverride,
|
||||||
|
});
|
||||||
if (!seedInfo.scenarioId) {
|
if (!seedInfo.scenarioId) {
|
||||||
return { status: 'FAILED', detail: 'scenarioId is missing' };
|
return { status: 'FAILED', detail: 'scenarioId is missing' };
|
||||||
}
|
}
|
||||||
const seedTime =
|
const seedTime =
|
||||||
action.scheduledAt && action.action === 'RESET_SCHEDULED' ? new Date(action.scheduledAt) : this.now();
|
openAt ??
|
||||||
|
(action.scheduledAt && action.action === 'RESET_SCHEDULED' ? new Date(action.scheduledAt) : this.now());
|
||||||
const startedAt = this.now().toISOString();
|
const startedAt = this.now().toISOString();
|
||||||
|
let activeProfile = profile;
|
||||||
|
if (installScenarioId !== null && String(installScenarioId) !== profile.scenario) {
|
||||||
|
const updated = await this.repository.updateScenario(profile.profileName, String(installScenarioId));
|
||||||
|
if (updated) {
|
||||||
|
activeProfile = updated;
|
||||||
|
}
|
||||||
|
}
|
||||||
await this.repository.updateStatus(profile.profileName, 'STOPPED');
|
await this.repository.updateStatus(profile.profileName, 'STOPPED');
|
||||||
await this.stopProfile(profile);
|
await this.stopProfile(activeProfile);
|
||||||
await this.repository.updateBuildStatus(profile.profileName, 'RUNNING', {
|
await this.repository.updateBuildStatus(profile.profileName, 'RUNNING', {
|
||||||
requestedAt: startedAt,
|
requestedAt: startedAt,
|
||||||
startedAt,
|
startedAt,
|
||||||
@@ -505,13 +626,20 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
scenarioId: seedInfo.scenarioId,
|
scenarioId: seedInfo.scenarioId,
|
||||||
tickSeconds: seedInfo.tickSeconds,
|
tickSeconds: seedInfo.tickSeconds,
|
||||||
now: seedTime,
|
now: seedTime,
|
||||||
|
installOptions: installOptions ?? undefined,
|
||||||
});
|
});
|
||||||
await this.repository.updateBuildStatus(profile.profileName, 'SUCCEEDED', {
|
await this.repository.updateBuildStatus(profile.profileName, 'SUCCEEDED', {
|
||||||
completedAt,
|
completedAt,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
await this.repository.updateStatus(profile.profileName, 'RUNNING');
|
const now = this.now();
|
||||||
await this.startProfile(profile);
|
const shouldPreopen = openAt ? openAt.getTime() > now.getTime() : false;
|
||||||
|
await this.repository.updateStatus(profile.profileName, shouldPreopen ? 'PREOPEN' : 'RUNNING', {
|
||||||
|
preopenAt: preopenAt ? preopenAt.toISOString() : openAt ? openAt.toISOString() : null,
|
||||||
|
openAt: openAt ? openAt.toISOString() : null,
|
||||||
|
scheduledStartAt: action.scheduledAt ?? null,
|
||||||
|
});
|
||||||
|
await this.startProfile(activeProfile);
|
||||||
return { status: 'APPLIED', detail: 'reset completed via rebuild' };
|
return { status: 'APPLIED', detail: 'reset completed via rebuild' };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const detail = error instanceof Error ? error.message : String(error);
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
@@ -527,14 +655,15 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async resolveResetSeedInfo(
|
private async resolveResetSeedInfo(
|
||||||
profile: GatewayProfileRecord
|
profile: GatewayProfileRecord,
|
||||||
|
overrides?: { scenarioId?: number | null; tickSeconds?: number }
|
||||||
): Promise<{ databaseUrl: string; scenarioId: number | null; tickSeconds?: number }> {
|
): Promise<{ databaseUrl: string; scenarioId: number | null; tickSeconds?: number }> {
|
||||||
const databaseUrl = resolvePostgresConfigFromEnv({
|
const databaseUrl = resolvePostgresConfigFromEnv({
|
||||||
env: this.processConfig.baseEnv ?? process.env,
|
env: this.processConfig.baseEnv ?? process.env,
|
||||||
schema: profile.profile,
|
schema: profile.profile,
|
||||||
}).url;
|
}).url;
|
||||||
let scenarioId = parseScenarioId(profile.scenario);
|
let scenarioId = overrides?.scenarioId ?? parseScenarioId(profile.scenario);
|
||||||
let tickSeconds: number | undefined;
|
let tickSeconds: number | undefined = overrides?.tickSeconds;
|
||||||
const connector = createGamePostgresConnector({ url: databaseUrl });
|
const connector = createGamePostgresConnector({ url: databaseUrl });
|
||||||
await connector.connect();
|
await connector.connect();
|
||||||
try {
|
try {
|
||||||
@@ -542,11 +671,13 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
select: { scenarioCode: true, tickSeconds: true },
|
select: { scenarioCode: true, tickSeconds: true },
|
||||||
});
|
});
|
||||||
if (row) {
|
if (row) {
|
||||||
const resolvedScenario = parseScenarioId(row.scenarioCode);
|
if (scenarioId === null) {
|
||||||
if (resolvedScenario !== null) {
|
const resolvedScenario = parseScenarioId(row.scenarioCode);
|
||||||
scenarioId = resolvedScenario;
|
if (resolvedScenario !== null) {
|
||||||
|
scenarioId = resolvedScenario;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (typeof row.tickSeconds === 'number' && Number.isFinite(row.tickSeconds)) {
|
if (tickSeconds === undefined && typeof row.tickSeconds === 'number' && Number.isFinite(row.tickSeconds)) {
|
||||||
tickSeconds = row.tickSeconds;
|
tickSeconds = row.tickSeconds;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export interface GatewayProfileRepository {
|
|||||||
listProfiles(): Promise<GatewayProfileRecord[]>;
|
listProfiles(): Promise<GatewayProfileRecord[]>;
|
||||||
getProfile(profileName: string): Promise<GatewayProfileRecord | null>;
|
getProfile(profileName: string): Promise<GatewayProfileRecord | null>;
|
||||||
upsertProfile(input: GatewayProfileUpsertInput): Promise<GatewayProfileRecord>;
|
upsertProfile(input: GatewayProfileUpsertInput): Promise<GatewayProfileRecord>;
|
||||||
|
updateScenario(profileName: string, scenario: string): Promise<GatewayProfileRecord | null>;
|
||||||
updateStatus(
|
updateStatus(
|
||||||
profileName: string,
|
profileName: string,
|
||||||
status: GatewayProfileStatus,
|
status: GatewayProfileStatus,
|
||||||
@@ -178,6 +179,15 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
|
|||||||
});
|
});
|
||||||
return mapProfile(row);
|
return mapProfile(row);
|
||||||
},
|
},
|
||||||
|
async updateScenario(profileName: string, scenario: string): Promise<GatewayProfileRecord | null> {
|
||||||
|
const row = await prisma.gatewayProfile.update({
|
||||||
|
where: { profileName },
|
||||||
|
data: {
|
||||||
|
scenario,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return row ? mapProfile(row) : null;
|
||||||
|
},
|
||||||
async updateStatus(
|
async updateStatus(
|
||||||
profileName: string,
|
profileName: string,
|
||||||
status: GatewayProfileStatus,
|
status: GatewayProfileStatus,
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { loadScenarioDefinitionById, resolveScenarioDefaultsPath } from '@sammo-ts/game-engine';
|
||||||
|
|
||||||
|
export interface ScenarioNationPreview {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
cities: string[];
|
||||||
|
generals: number;
|
||||||
|
generalsEx: number;
|
||||||
|
generalsNeutral: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScenarioPreview {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
year: number | null;
|
||||||
|
npcCount: number;
|
||||||
|
npcExCount: number;
|
||||||
|
npcNeutralCount: number;
|
||||||
|
nations: ScenarioNationPreview[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCENARIO_FILE_PATTERN = /^scenario_(\d+)\.json$/i;
|
||||||
|
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
let cachedPreviews: { loadedAt: number; data: ScenarioPreview[] } | null = null;
|
||||||
|
|
||||||
|
const resolveScenarioRoot = (): string => {
|
||||||
|
const defaultsPath = resolveScenarioDefaultsPath();
|
||||||
|
return path.dirname(defaultsPath);
|
||||||
|
};
|
||||||
|
|
||||||
|
const listScenarioIds = async (): Promise<number[]> => {
|
||||||
|
const root = resolveScenarioRoot();
|
||||||
|
const entries = await fs.readdir(root, { withFileTypes: true });
|
||||||
|
const ids: number[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isFile()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const match = SCENARIO_FILE_PATTERN.exec(entry.name);
|
||||||
|
if (!match) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const id = Number(match[1]);
|
||||||
|
if (Number.isFinite(id)) {
|
||||||
|
ids.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids.sort((a, b) => a - b);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildNationIdResolver = (nations: Array<{ id: number; name: string }>): ((value: number | string | null) => number | null) => {
|
||||||
|
const byName = new Map(nations.map((nation) => [nation.name, nation.id]));
|
||||||
|
return (value) => {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return Math.floor(value);
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return byName.get(value) ?? null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const countGeneralsByNation = (
|
||||||
|
rows: Array<{ nation: number | string | null }>,
|
||||||
|
resolveNationId: (value: number | string | null) => number | null
|
||||||
|
): Map<number, number> => {
|
||||||
|
const counts = new Map<number, number>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const nationId = resolveNationId(row.nation);
|
||||||
|
if (nationId === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
counts.set(nationId, (counts.get(nationId) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildScenarioPreview = async (scenarioId: number): Promise<ScenarioPreview> => {
|
||||||
|
const scenario = await loadScenarioDefinitionById(scenarioId);
|
||||||
|
const resolveNationId = buildNationIdResolver(scenario.nations);
|
||||||
|
|
||||||
|
const baseCounts = new Map(scenario.nations.map((nation) => [nation.id, 0]));
|
||||||
|
const generalCounts = countGeneralsByNation(scenario.generals, resolveNationId);
|
||||||
|
const generalExCounts = countGeneralsByNation(scenario.generalsEx, resolveNationId);
|
||||||
|
const generalNeutralCounts = countGeneralsByNation(scenario.generalsNeutral, resolveNationId);
|
||||||
|
|
||||||
|
const nations = scenario.nations.map((nation) => ({
|
||||||
|
id: nation.id,
|
||||||
|
name: nation.name,
|
||||||
|
color: nation.color,
|
||||||
|
cities: nation.cities,
|
||||||
|
generals: generalCounts.get(nation.id) ?? baseCounts.get(nation.id) ?? 0,
|
||||||
|
generalsEx: generalExCounts.get(nation.id) ?? baseCounts.get(nation.id) ?? 0,
|
||||||
|
generalsNeutral: generalNeutralCounts.get(nation.id) ?? baseCounts.get(nation.id) ?? 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: scenarioId,
|
||||||
|
title: scenario.title,
|
||||||
|
year: scenario.startYear ?? null,
|
||||||
|
npcCount: scenario.generals.length,
|
||||||
|
npcExCount: scenario.generalsEx.length,
|
||||||
|
npcNeutralCount: scenario.generalsNeutral.length,
|
||||||
|
nations,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listScenarioPreviews = async (): Promise<ScenarioPreview[]> => {
|
||||||
|
if (cachedPreviews && Date.now() - cachedPreviews.loadedAt < CACHE_TTL_MS) {
|
||||||
|
return cachedPreviews.data;
|
||||||
|
}
|
||||||
|
const ids = await listScenarioIds();
|
||||||
|
const previews = await Promise.all(ids.map((id) => buildScenarioPreview(id)));
|
||||||
|
cachedPreviews = {
|
||||||
|
loadedAt: Date.now(),
|
||||||
|
data: previews,
|
||||||
|
};
|
||||||
|
return previews;
|
||||||
|
};
|
||||||
@@ -49,6 +49,7 @@ const buildCaller = () => {
|
|||||||
upsertProfile: async () => {
|
upsertProfile: async () => {
|
||||||
throw new Error('not used');
|
throw new Error('not used');
|
||||||
},
|
},
|
||||||
|
updateScenario: async () => null,
|
||||||
updateStatus: async () => null,
|
updateStatus: async () => null,
|
||||||
updateBuildStatus: async () => null,
|
updateBuildStatus: async () => null,
|
||||||
updateMeta: async () => null,
|
updateMeta: async () => null,
|
||||||
|
|||||||
@@ -67,6 +67,44 @@ type AdminProfile = {
|
|||||||
meta: Record<string, unknown>;
|
meta: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ScenarioNationPreview = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
cities: string[];
|
||||||
|
generals: number;
|
||||||
|
generalsEx: number;
|
||||||
|
generalsNeutral: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ScenarioPreview = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
year: number | null;
|
||||||
|
npcCount: number;
|
||||||
|
npcExCount: number;
|
||||||
|
npcNeutralCount: number;
|
||||||
|
nations: ScenarioNationPreview[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type InstallFormState = {
|
||||||
|
scenarioId: number;
|
||||||
|
turnTermMinutes: number;
|
||||||
|
sync: boolean;
|
||||||
|
fiction: number;
|
||||||
|
extend: boolean;
|
||||||
|
blockGeneralCreate: number;
|
||||||
|
npcMode: number;
|
||||||
|
showImgLevel: number;
|
||||||
|
tournamentTrig: boolean;
|
||||||
|
joinMode: 'full' | 'onlyRandom';
|
||||||
|
autorunUserMinutes: number;
|
||||||
|
autorunUserOptions: Record<string, boolean>;
|
||||||
|
openAt: string;
|
||||||
|
preopenAt: string;
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
type AdminAction =
|
type AdminAction =
|
||||||
| 'RESUME'
|
| 'RESUME'
|
||||||
| 'PAUSE'
|
| 'PAUSE'
|
||||||
@@ -130,6 +168,9 @@ type AdminClient = {
|
|||||||
list: {
|
list: {
|
||||||
query: () => Promise<AdminProfile[]>;
|
query: () => Promise<AdminProfile[]>;
|
||||||
};
|
};
|
||||||
|
listScenarios: {
|
||||||
|
query: () => Promise<ScenarioPreview[]>;
|
||||||
|
};
|
||||||
updateMeta: {
|
updateMeta: {
|
||||||
mutate: (input: {
|
mutate: (input: {
|
||||||
profileName: string;
|
profileName: string;
|
||||||
@@ -141,6 +182,30 @@ type AdminClient = {
|
|||||||
};
|
};
|
||||||
}) => Promise<AdminProfile | null>;
|
}) => Promise<AdminProfile | null>;
|
||||||
};
|
};
|
||||||
|
install: {
|
||||||
|
mutate: (input: {
|
||||||
|
profileName: string;
|
||||||
|
install: {
|
||||||
|
scenarioId: number;
|
||||||
|
turnTermMinutes: number;
|
||||||
|
sync: boolean;
|
||||||
|
fiction: number;
|
||||||
|
extend: boolean;
|
||||||
|
blockGeneralCreate: number;
|
||||||
|
npcMode: number;
|
||||||
|
showImgLevel: number;
|
||||||
|
tournamentTrig: boolean;
|
||||||
|
joinMode: 'full' | 'onlyRandom';
|
||||||
|
autorunUser?: {
|
||||||
|
limitMinutes: number;
|
||||||
|
options: string[];
|
||||||
|
} | null;
|
||||||
|
openAt?: string;
|
||||||
|
preopenAt?: string;
|
||||||
|
};
|
||||||
|
reason?: string;
|
||||||
|
}) => Promise<{ ok: boolean; action?: unknown }>;
|
||||||
|
};
|
||||||
requestAction: {
|
requestAction: {
|
||||||
mutate: (input: {
|
mutate: (input: {
|
||||||
profileName: string;
|
profileName: string;
|
||||||
@@ -202,6 +267,23 @@ const profileActions = ref<
|
|||||||
>
|
>
|
||||||
>({});
|
>({});
|
||||||
const profileActionStatus = ref<Record<string, string>>({});
|
const profileActionStatus = ref<Record<string, string>>({});
|
||||||
|
const scenarios = ref<ScenarioPreview[]>([]);
|
||||||
|
const scenariosLoading = ref(false);
|
||||||
|
const scenariosStatus = ref('');
|
||||||
|
const profileInstalls = ref<Record<string, InstallFormState>>({});
|
||||||
|
const profileInstallStatus = ref<Record<string, string>>({});
|
||||||
|
|
||||||
|
const autorunOptionLabels = [
|
||||||
|
{ key: 'develop', label: '내정' },
|
||||||
|
{ key: 'warp', label: '순간이동' },
|
||||||
|
{ key: 'recruit', label: '징병' },
|
||||||
|
{ key: 'recruit_high', label: '모병' },
|
||||||
|
{ key: 'train', label: '훈사' },
|
||||||
|
{ key: 'battle', label: '출병' },
|
||||||
|
{ key: 'chief', label: '기본 사령턴' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const turnTermOptions = [120, 60, 30, 20, 10, 5, 2, 1] as const;
|
||||||
|
|
||||||
const userLookupMode = ref<'username' | 'id' | 'email'>('username');
|
const userLookupMode = ref<'username' | 'id' | 'email'>('username');
|
||||||
const userLookupValue = ref('');
|
const userLookupValue = ref('');
|
||||||
@@ -281,11 +363,109 @@ const ensureProfileBuffers = (profile: AdminProfile) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const pad2 = (value: number): string => String(value).padStart(2, '0');
|
||||||
|
|
||||||
|
const formatLocalInput = (date: Date): string =>
|
||||||
|
`${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}T${pad2(date.getHours())}:${pad2(
|
||||||
|
date.getMinutes()
|
||||||
|
)}`;
|
||||||
|
|
||||||
|
const toLocalInputValue = (value: unknown): string => {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return formatLocalInput(parsed);
|
||||||
|
};
|
||||||
|
|
||||||
|
const readNumber = (value: unknown, fallback: number): number =>
|
||||||
|
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||||
|
|
||||||
|
const readBoolean = (value: unknown, fallback: boolean): boolean =>
|
||||||
|
typeof value === 'boolean' ? value : fallback;
|
||||||
|
|
||||||
|
const readString = (value: unknown, fallback: string): string => (typeof value === 'string' ? value : fallback);
|
||||||
|
|
||||||
|
const buildAutorunOptionMap = (options?: string[]): Record<string, boolean> => {
|
||||||
|
const map: Record<string, boolean> = {};
|
||||||
|
autorunOptionLabels.forEach(({ key }) => {
|
||||||
|
map[key] = options ? options.includes(key) : true;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureProfileInstallBuffers = (profile: AdminProfile) => {
|
||||||
|
if (profileInstalls.value[profile.profileName]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const meta = (profile.meta ?? {}) as Record<string, unknown>;
|
||||||
|
const install = (meta.install ?? {}) as Record<string, unknown>;
|
||||||
|
const autorunUser = (install.autorunUser ?? {}) as Record<string, unknown>;
|
||||||
|
const autorunOptionsRaw = Array.isArray(autorunUser.options)
|
||||||
|
? autorunUser.options.filter((option): option is string => typeof option === 'string')
|
||||||
|
: undefined;
|
||||||
|
const scenarioId = Number(profile.scenario);
|
||||||
|
|
||||||
|
profileInstalls.value[profile.profileName] = {
|
||||||
|
scenarioId: Number.isFinite(scenarioId) ? scenarioId : readNumber(install.scenarioId, 0),
|
||||||
|
turnTermMinutes: readNumber(install.turnTermMinutes, 60),
|
||||||
|
sync: readBoolean(install.sync, true),
|
||||||
|
fiction: readNumber(install.fiction, 1),
|
||||||
|
extend: readBoolean(install.extend, true),
|
||||||
|
blockGeneralCreate: readNumber(install.blockGeneralCreate, 0),
|
||||||
|
npcMode: readNumber(install.npcMode, 0),
|
||||||
|
showImgLevel: readNumber(install.showImgLevel, 3),
|
||||||
|
tournamentTrig: readBoolean(install.tournamentTrig, true),
|
||||||
|
joinMode: readString(install.joinMode, 'full') === 'onlyRandom' ? 'onlyRandom' : 'full',
|
||||||
|
autorunUserMinutes: readNumber(autorunUser.limitMinutes, 1440),
|
||||||
|
autorunUserOptions: buildAutorunOptionMap(autorunOptionsRaw),
|
||||||
|
openAt: toLocalInputValue(install.openAt),
|
||||||
|
preopenAt: toLocalInputValue(install.preopenAt),
|
||||||
|
reason: '',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const scenarioMap = computed(() => {
|
||||||
|
const map = new Map<number, ScenarioPreview>();
|
||||||
|
scenarios.value.forEach((scenario) => {
|
||||||
|
map.set(scenario.id, scenario);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
const scenarioGroups = computed(() => {
|
||||||
|
const pattern = /【(.*?)[0-9\-_.a-zA-Z]*】/;
|
||||||
|
const groups: Record<string, ScenarioPreview[]> = {};
|
||||||
|
for (const scenario of scenarios.value) {
|
||||||
|
const match = pattern.exec(scenario.title);
|
||||||
|
const category = match?.[1] ?? '기타';
|
||||||
|
if (!groups[category]) {
|
||||||
|
groups[category] = [];
|
||||||
|
}
|
||||||
|
groups[category].push(scenario);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
});
|
||||||
|
|
||||||
|
const getScenarioPreview = (profileName: string): ScenarioPreview | null => {
|
||||||
|
const install = profileInstalls.value[profileName];
|
||||||
|
if (!install) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return scenarioMap.value.get(install.scenarioId) ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
const loadProfiles = async () => {
|
const loadProfiles = async () => {
|
||||||
profilesLoading.value = true;
|
profilesLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const result = await adminClient.profiles.list.query();
|
const result = await adminClient.profiles.list.query();
|
||||||
result.forEach(ensureProfileBuffers);
|
result.forEach((profile) => {
|
||||||
|
ensureProfileBuffers(profile);
|
||||||
|
ensureProfileInstallBuffers(profile);
|
||||||
|
});
|
||||||
profiles.value = result;
|
profiles.value = result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
profileActionStatus.value = {
|
profileActionStatus.value = {
|
||||||
@@ -297,6 +477,19 @@ const loadProfiles = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadScenarios = async () => {
|
||||||
|
scenariosLoading.value = true;
|
||||||
|
scenariosStatus.value = '';
|
||||||
|
try {
|
||||||
|
const result = await adminClient.profiles.listScenarios.query();
|
||||||
|
scenarios.value = result;
|
||||||
|
} catch (error) {
|
||||||
|
scenariosStatus.value = '시나리오 목록을 불러오지 못했습니다.';
|
||||||
|
} finally {
|
||||||
|
scenariosLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const updateProfileMeta = async (profileName: string) => {
|
const updateProfileMeta = async (profileName: string) => {
|
||||||
const edit = profileEdits.value[profileName];
|
const edit = profileEdits.value[profileName];
|
||||||
if (!edit) {
|
if (!edit) {
|
||||||
@@ -356,6 +549,70 @@ const requestProfileAction = async (profileName: string, action: AdminAction) =>
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const requestInstall = async (profileName: string) => {
|
||||||
|
const install = profileInstalls.value[profileName];
|
||||||
|
if (!install) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const options = Object.entries(install.autorunUserOptions)
|
||||||
|
.filter(([, enabled]) => enabled)
|
||||||
|
.map(([key]) => key);
|
||||||
|
const autorunUser =
|
||||||
|
install.autorunUserMinutes > 0 && options.length
|
||||||
|
? {
|
||||||
|
limitMinutes: install.autorunUserMinutes,
|
||||||
|
options,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
const openAt = install.openAt ? new Date(install.openAt) : null;
|
||||||
|
if (openAt && Number.isNaN(openAt.getTime())) {
|
||||||
|
profileInstallStatus.value = {
|
||||||
|
...profileInstallStatus.value,
|
||||||
|
[profileName]: '오픈 시간이 올바르지 않습니다.',
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const preopenAt = install.preopenAt ? new Date(install.preopenAt) : null;
|
||||||
|
if (preopenAt && Number.isNaN(preopenAt.getTime())) {
|
||||||
|
profileInstallStatus.value = {
|
||||||
|
...profileInstallStatus.value,
|
||||||
|
[profileName]: '가오픈 시간이 올바르지 않습니다.',
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await adminClient.profiles.install.mutate({
|
||||||
|
profileName,
|
||||||
|
install: {
|
||||||
|
scenarioId: install.scenarioId,
|
||||||
|
turnTermMinutes: install.turnTermMinutes,
|
||||||
|
sync: install.sync,
|
||||||
|
fiction: install.fiction,
|
||||||
|
extend: install.extend,
|
||||||
|
blockGeneralCreate: install.blockGeneralCreate,
|
||||||
|
npcMode: install.npcMode,
|
||||||
|
showImgLevel: install.showImgLevel,
|
||||||
|
tournamentTrig: install.tournamentTrig,
|
||||||
|
joinMode: install.joinMode,
|
||||||
|
autorunUser,
|
||||||
|
openAt: openAt ? openAt.toISOString() : undefined,
|
||||||
|
preopenAt: preopenAt ? preopenAt.toISOString() : undefined,
|
||||||
|
},
|
||||||
|
reason: install.reason.trim() || undefined,
|
||||||
|
});
|
||||||
|
profileInstallStatus.value = {
|
||||||
|
...profileInstallStatus.value,
|
||||||
|
[profileName]: openAt ? '설치 예약 완료' : '설치 요청 완료',
|
||||||
|
};
|
||||||
|
await loadProfiles();
|
||||||
|
} catch (error) {
|
||||||
|
profileInstallStatus.value = {
|
||||||
|
...profileInstallStatus.value,
|
||||||
|
[profileName]: '설치 요청 실패',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const lookupUser = async () => {
|
const lookupUser = async () => {
|
||||||
userLoading.value = true;
|
userLoading.value = true;
|
||||||
userError.value = '';
|
userError.value = '';
|
||||||
@@ -563,6 +820,7 @@ const forceDeleteUser = async () => {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void loadNotice();
|
void loadNotice();
|
||||||
void loadProfiles();
|
void loadProfiles();
|
||||||
|
void loadScenarios();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -990,6 +1248,394 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="profileInstalls[profile.profileName]"
|
||||||
|
class="border-t border-zinc-800 pt-4 space-y-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h4 class="text-sm font-semibold">설치/리셋</h4>
|
||||||
|
<span class="text-xs text-zinc-500">{{ profileInstallStatus[profile.profileName] }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid lg:grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="text-xs text-zinc-400">시나리오 선택</label>
|
||||||
|
<select
|
||||||
|
v-model.number="profileInstalls[profile.profileName].scenarioId"
|
||||||
|
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||||
|
:disabled="scenariosLoading"
|
||||||
|
>
|
||||||
|
<option v-if="scenariosLoading" disabled>불러오는 중...</option>
|
||||||
|
<template v-for="(items, group) in scenarioGroups" :key="group">
|
||||||
|
<optgroup :label="group">
|
||||||
|
<option v-for="scenario in items" :key="scenario.id" :value="scenario.id">
|
||||||
|
{{ scenario.title }}
|
||||||
|
</option>
|
||||||
|
</optgroup>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
<div v-if="scenariosStatus" class="text-xs text-red-400">
|
||||||
|
{{ scenariosStatus }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="text-xs text-zinc-400">턴 시간(분)</label>
|
||||||
|
<select
|
||||||
|
v-model.number="profileInstalls[profile.profileName].turnTermMinutes"
|
||||||
|
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||||
|
>
|
||||||
|
<option v-for="term in turnTermOptions" :key="term" :value="term">
|
||||||
|
{{ term }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="text-xs text-zinc-400">시간 동기화</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
||||||
|
<input
|
||||||
|
v-model="profileInstalls[profile.profileName].sync"
|
||||||
|
class="accent-yellow-500"
|
||||||
|
type="radio"
|
||||||
|
:value="true"
|
||||||
|
/>
|
||||||
|
Y
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
||||||
|
<input
|
||||||
|
v-model="profileInstalls[profile.profileName].sync"
|
||||||
|
class="accent-yellow-500"
|
||||||
|
type="radio"
|
||||||
|
:value="false"
|
||||||
|
/>
|
||||||
|
N
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="text-xs text-zinc-400">NPC 상성</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
||||||
|
<input
|
||||||
|
v-model.number="profileInstalls[profile.profileName].fiction"
|
||||||
|
class="accent-yellow-500"
|
||||||
|
type="radio"
|
||||||
|
:value="0"
|
||||||
|
/>
|
||||||
|
연의
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
||||||
|
<input
|
||||||
|
v-model.number="profileInstalls[profile.profileName].fiction"
|
||||||
|
class="accent-yellow-500"
|
||||||
|
type="radio"
|
||||||
|
:value="1"
|
||||||
|
/>
|
||||||
|
가상
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="text-xs text-zinc-400">확장 NPC</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
||||||
|
<input
|
||||||
|
v-model="profileInstalls[profile.profileName].extend"
|
||||||
|
class="accent-yellow-500"
|
||||||
|
type="radio"
|
||||||
|
:value="true"
|
||||||
|
/>
|
||||||
|
포함
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
||||||
|
<input
|
||||||
|
v-model="profileInstalls[profile.profileName].extend"
|
||||||
|
class="accent-yellow-500"
|
||||||
|
type="radio"
|
||||||
|
:value="false"
|
||||||
|
/>
|
||||||
|
미포함
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="text-xs text-zinc-400">임관 모드</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
||||||
|
<input
|
||||||
|
v-model="profileInstalls[profile.profileName].joinMode"
|
||||||
|
class="accent-yellow-500"
|
||||||
|
type="radio"
|
||||||
|
value="full"
|
||||||
|
/>
|
||||||
|
일반
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
||||||
|
<input
|
||||||
|
v-model="profileInstalls[profile.profileName].joinMode"
|
||||||
|
class="accent-yellow-500"
|
||||||
|
type="radio"
|
||||||
|
value="onlyRandom"
|
||||||
|
/>
|
||||||
|
랜덤 임관
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="text-xs text-zinc-400">장수 임의 생성</label>
|
||||||
|
<div class="flex gap-2 flex-wrap">
|
||||||
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
||||||
|
<input
|
||||||
|
v-model.number="profileInstalls[profile.profileName].blockGeneralCreate"
|
||||||
|
class="accent-yellow-500"
|
||||||
|
type="radio"
|
||||||
|
:value="0"
|
||||||
|
/>
|
||||||
|
가능
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
||||||
|
<input
|
||||||
|
v-model.number="profileInstalls[profile.profileName].blockGeneralCreate"
|
||||||
|
class="accent-yellow-500"
|
||||||
|
type="radio"
|
||||||
|
:value="2"
|
||||||
|
/>
|
||||||
|
장수명 무작위
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
||||||
|
<input
|
||||||
|
v-model.number="profileInstalls[profile.profileName].blockGeneralCreate"
|
||||||
|
class="accent-yellow-500"
|
||||||
|
type="radio"
|
||||||
|
:value="1"
|
||||||
|
/>
|
||||||
|
불가
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="text-xs text-zinc-400">NPC 빙의</label>
|
||||||
|
<div class="flex gap-2 flex-wrap">
|
||||||
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
||||||
|
<input
|
||||||
|
v-model.number="profileInstalls[profile.profileName].npcMode"
|
||||||
|
class="accent-yellow-500"
|
||||||
|
type="radio"
|
||||||
|
:value="1"
|
||||||
|
/>
|
||||||
|
가능
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
||||||
|
<input
|
||||||
|
v-model.number="profileInstalls[profile.profileName].npcMode"
|
||||||
|
class="accent-yellow-500"
|
||||||
|
type="radio"
|
||||||
|
:value="0"
|
||||||
|
/>
|
||||||
|
불가
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
||||||
|
<input
|
||||||
|
v-model.number="profileInstalls[profile.profileName].npcMode"
|
||||||
|
class="accent-yellow-500"
|
||||||
|
type="radio"
|
||||||
|
:value="2"
|
||||||
|
/>
|
||||||
|
선택 생성 가능
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="text-xs text-zinc-400">이미지 표기</label>
|
||||||
|
<div class="flex gap-2 flex-wrap">
|
||||||
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
||||||
|
<input
|
||||||
|
v-model.number="profileInstalls[profile.profileName].showImgLevel"
|
||||||
|
class="accent-yellow-500"
|
||||||
|
type="radio"
|
||||||
|
:value="0"
|
||||||
|
/>
|
||||||
|
안함
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
||||||
|
<input
|
||||||
|
v-model.number="profileInstalls[profile.profileName].showImgLevel"
|
||||||
|
class="accent-yellow-500"
|
||||||
|
type="radio"
|
||||||
|
:value="1"
|
||||||
|
/>
|
||||||
|
전콘
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
||||||
|
<input
|
||||||
|
v-model.number="profileInstalls[profile.profileName].showImgLevel"
|
||||||
|
class="accent-yellow-500"
|
||||||
|
type="radio"
|
||||||
|
:value="2"
|
||||||
|
/>
|
||||||
|
전콘, 병종
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
||||||
|
<input
|
||||||
|
v-model.number="profileInstalls[profile.profileName].showImgLevel"
|
||||||
|
class="accent-yellow-500"
|
||||||
|
type="radio"
|
||||||
|
:value="3"
|
||||||
|
/>
|
||||||
|
전콘, 병종, NPC
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="text-xs text-zinc-400">토너먼트 자동 시작</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
||||||
|
<input
|
||||||
|
v-model="profileInstalls[profile.profileName].tournamentTrig"
|
||||||
|
class="accent-yellow-500"
|
||||||
|
type="radio"
|
||||||
|
:value="false"
|
||||||
|
/>
|
||||||
|
수동
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
||||||
|
<input
|
||||||
|
v-model="profileInstalls[profile.profileName].tournamentTrig"
|
||||||
|
class="accent-yellow-500"
|
||||||
|
type="radio"
|
||||||
|
:value="true"
|
||||||
|
/>
|
||||||
|
자동
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="text-xs text-zinc-400">휴식 턴 자동 행동</label>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<label
|
||||||
|
v-for="option in autorunOptionLabels"
|
||||||
|
:key="option.key"
|
||||||
|
class="flex items-center gap-1 text-xs text-zinc-300"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="profileInstalls[profile.profileName].autorunUserOptions[option.key]"
|
||||||
|
class="accent-yellow-500"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
{{ option.label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="text-xs text-zinc-400">유효 시간(분)</label>
|
||||||
|
<select
|
||||||
|
v-model.number="profileInstalls[profile.profileName].autorunUserMinutes"
|
||||||
|
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||||
|
>
|
||||||
|
<option :value="0">꺼짐</option>
|
||||||
|
<option :value="43200">항상</option>
|
||||||
|
<option :value="10">10분</option>
|
||||||
|
<option :value="20">20분</option>
|
||||||
|
<option :value="30">30분</option>
|
||||||
|
<option :value="60">1시간</option>
|
||||||
|
<option :value="120">2시간</option>
|
||||||
|
<option :value="180">3시간</option>
|
||||||
|
<option :value="240">4시간</option>
|
||||||
|
<option :value="360">6시간</option>
|
||||||
|
<option :value="480">8시간</option>
|
||||||
|
<option :value="600">10시간</option>
|
||||||
|
<option :value="720">12시간</option>
|
||||||
|
<option :value="1440">24시간</option>
|
||||||
|
<option :value="2160">36시간</option>
|
||||||
|
<option :value="2880">48시간</option>
|
||||||
|
<option :value="3600">60시간</option>
|
||||||
|
<option :value="4320">72시간</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="text-xs text-zinc-400">오픈 예약</label>
|
||||||
|
<input
|
||||||
|
v-model="profileInstalls[profile.profileName].openAt"
|
||||||
|
type="datetime-local"
|
||||||
|
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="text-xs text-zinc-400">가오픈 예약</label>
|
||||||
|
<input
|
||||||
|
v-model="profileInstalls[profile.profileName].preopenAt"
|
||||||
|
type="datetime-local"
|
||||||
|
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="text-xs text-zinc-400">설치 메모</label>
|
||||||
|
<input
|
||||||
|
v-model="profileInstalls[profile.profileName].reason"
|
||||||
|
type="text"
|
||||||
|
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||||
|
placeholder="사유/메모"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="bg-emerald-600 hover:bg-emerald-500 text-black font-semibold px-4 py-2 rounded w-full"
|
||||||
|
@click="requestInstall(profile.profileName)"
|
||||||
|
>
|
||||||
|
설치 적용
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="getScenarioPreview(profile.profileName)" class="bg-zinc-950 border border-zinc-800 rounded p-3 text-xs text-zinc-300 space-y-2">
|
||||||
|
<div class="font-semibold text-zinc-200">
|
||||||
|
{{ getScenarioPreview(profile.profileName)?.title }}
|
||||||
|
</div>
|
||||||
|
<div>시작 연도: {{ getScenarioPreview(profile.profileName)?.year ?? '-' }}년</div>
|
||||||
|
<div>
|
||||||
|
NPC: {{ getScenarioPreview(profile.profileName)?.npcCount }}명
|
||||||
|
<span v-if="getScenarioPreview(profile.profileName)?.npcExCount">+{{ getScenarioPreview(profile.profileName)?.npcExCount }}명</span>
|
||||||
|
<span v-if="getScenarioPreview(profile.profileName)?.npcNeutralCount">
|
||||||
|
/ 중립 {{ getScenarioPreview(profile.profileName)?.npcNeutralCount }}명
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-zinc-400">국가</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div
|
||||||
|
v-for="nation in getScenarioPreview(profile.profileName)?.nations ?? []"
|
||||||
|
:key="nation.id"
|
||||||
|
class="text-[11px]"
|
||||||
|
>
|
||||||
|
<span :style="{ color: nation.color }">{{ nation.name }}</span>
|
||||||
|
{{ nation.generals }}명
|
||||||
|
<span v-if="nation.generalsEx">(+{{ nation.generalsEx }})</span>
|
||||||
|
<span class="text-zinc-500">· {{ nation.cities.join(', ') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="profileActionStatus.global" class="text-xs text-red-400">
|
<div v-if="profileActionStatus.global" class="text-xs text-red-400">
|
||||||
{{ profileActionStatus.global }}
|
{{ profileActionStatus.global }}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ Move items into the main docs once they are finalized.
|
|||||||
- [AI suggestion] Implement diplomacy/state transitions and monthly/command-based updates beyond read-only maps.
|
- [AI suggestion] Implement diplomacy/state transitions and monthly/command-based updates beyond read-only maps.
|
||||||
- [AI suggestion] Integrate war/battle pipeline into turn processing (troop movement/war resolution hooks, not just isolated sim jobs).
|
- [AI suggestion] Integrate war/battle pipeline into turn processing (troop movement/war resolution hooks, not just isolated sim jobs).
|
||||||
- [AI suggestion] Expand turn command catalog beyond the current subset (general/nation commands).
|
- [AI suggestion] Expand turn command catalog beyond the current subset (general/nation commands).
|
||||||
|
- [AI suggestion] Apply install settings (`join_mode`, `npcmode`, `show_img_level`, `tournament_trig`) to runtime rules/command constraints and UI behavior, beyond just storing them in world state.
|
||||||
|
|
||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user