From bc76b6e725429cbce419001d2725987dbbf9ca0b Mon Sep 17 00:00:00 2001 From: Hide_D Date: Sat, 17 Jan 2026 12:24:57 +0000 Subject: [PATCH] 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. --- app/game-api/src/context.ts | 17 + app/game-api/src/router/join/index.ts | 48 +- .../src/scenario/scenarioSeeder.ts | 132 +++- .../src/turn/gatewayAdminActions.ts | 18 + app/game-engine/test/scenarioSeeder.test.ts | 63 ++ app/gateway-api/src/adminRouter.ts | 170 +++++ .../src/orchestrator/gatewayOrchestrator.ts | 157 ++++- .../src/orchestrator/profileRepository.ts | 10 + .../src/scenario/scenarioCatalog.ts | 125 ++++ app/gateway-api/test/authFlow.test.ts | 1 + app/gateway-frontend/src/views/AdminView.vue | 648 +++++++++++++++++- docs/architecture/todo.md | 1 + 12 files changed, 1362 insertions(+), 28 deletions(-) create mode 100644 app/gateway-api/src/scenario/scenarioCatalog.ts diff --git a/app/game-api/src/context.ts b/app/game-api/src/context.ts index f874446..c3c2864 100644 --- a/app/game-api/src/context.ts +++ b/app/game-api/src/context.ts @@ -16,15 +16,32 @@ export interface GameProfile { export const zWorldStateConfig = z.object({ maxUserCnt: z.number().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; export const zWorldStateMeta = z.object({ starttime: z.string().optional(), opentime: z.string().optional(), + preopenAt: z.string().optional(), turntime: z.string().optional(), otherTextInfo: z.string().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; diff --git a/app/game-api/src/router/join/index.ts b/app/game-api/src/router/join/index.ts index 046a90e..55cfdcb 100644 --- a/app/game-api/src/router/join/index.ts +++ b/app/game-api/src/router/join/index.ts @@ -1,5 +1,6 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; +import { randomBytes } from 'node:crypto'; import type { WorldStateRow } from '../../context.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 => { let hash = 0; 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 statTotal = input.leadership + input.strength + input.intel; @@ -195,12 +215,30 @@ export const joinRouter = router({ const personalityOptions = await loadPersonalityOptions(); const personalityKeys = personalityOptions.map((trait) => trait.key); + const resolveGeneralName = async (): Promise => { + 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 = input.character === 'Random' - ? pickFromList(personalityKeys, `${userId}:${input.name}`) ?? 'None' + ? pickFromList(personalityKeys, `${userId}:${generalName}`) ?? 'None' : isPersonalityTraitKey(input.character) - ? input.character - : 'None'; + ? input.character + : 'None'; return ctx.db.$transaction(async (db) => { const existing = await db.general.findFirst({ where: { userId } }); @@ -210,7 +248,7 @@ export const joinRouter = router({ message: '이미 장수가 생성되어 있습니다.', }); } - const nameExists = await db.general.findFirst({ where: { name: input.name } }); + const nameExists = await db.general.findFirst({ where: { name: generalName } }); if (nameExists) { throw new TRPCError({ code: 'CONFLICT', @@ -234,7 +272,7 @@ export const joinRouter = router({ data: { id: nextId, userId, - name: input.name, + name: generalName, nationId: 0, cityId, troopId: 0, diff --git a/app/game-engine/src/scenario/scenarioSeeder.ts b/app/game-engine/src/scenario/scenarioSeeder.ts index 4c8f12b..cb50f5b 100644 --- a/app/game-engine/src/scenario/scenarioSeeder.ts +++ b/app/game-engine/src/scenario/scenarioSeeder.ts @@ -12,6 +12,27 @@ const DEFAULT_TICK_SECONDS = 120 * 60; const DEFAULT_GENERAL_GOLD = 1000; const DEFAULT_GENERAL_RICE = 1000; +const MINUTES_TO_MS = 60_000; + +export interface ScenarioAutorunOptions { + limitMinutes: number; + options: Record; +} + +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 { scenarioId: number; databaseUrl: string; @@ -21,6 +42,7 @@ export interface ScenarioSeedOptions { resetTables?: boolean; now?: Date; tickSeconds?: number; + installOptions?: ScenarioInstallOptions; includeNeutralNationInSeed?: boolean; defaultGeneralGold?: number; defaultGeneralRice?: number; @@ -33,6 +55,61 @@ export interface ScenarioSeedResult { 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 => { if (startYear === null || birthYear <= 0) { return 20; @@ -77,12 +154,15 @@ const buildEventRows = (rows: unknown[], targetOverride?: string): TurnEngineEve // 시나리오 초기 데이터를 로드해 DB에 저장한다. export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Promise => { + const install = options.installOptions; 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 unitSet = await loadUnitSetDefinitionByName(scenario.config.environment.unitSet, options.unitSetOptions); const { seed, warnings } = buildScenarioBootstrap({ - scenario, + scenario: scenarioDefinition, map, unitSet, options: { @@ -92,10 +172,47 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom const connector = createGamePostgresConnector({ url: options.databaseUrl }); 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 generalRice = options.defaultGeneralRice ?? DEFAULT_GENERAL_RICE; + const worldConfig: Record = { + 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 = { + 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(); try { const prisma = connector.prisma; @@ -113,14 +230,11 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom await prisma.worldState.create({ data: { scenarioCode: String(options.scenarioId), - currentYear: scenario.startYear ?? 0, - currentMonth: 1, + currentYear: startState.currentYear, + currentMonth: startState.currentMonth, tickSeconds, - config: asJson(seed.scenarioConfig), - meta: asJson({ - scenarioId: options.scenarioId, - scenarioMeta: seed.scenarioMeta, - }), + config: asJson({ ...seed.scenarioConfig, ...worldConfig }), + meta: asJson(worldMeta), }, }); diff --git a/app/game-engine/src/turn/gatewayAdminActions.ts b/app/game-engine/src/turn/gatewayAdminActions.ts index 4860ebb..6ade195 100644 --- a/app/game-engine/src/turn/gatewayAdminActions.ts +++ b/app/game-engine/src/turn/gatewayAdminActions.ts @@ -12,6 +12,24 @@ export interface GatewayAdminActionRecord { handledAt?: string | null; handler?: 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 { diff --git a/app/game-engine/test/scenarioSeeder.test.ts b/app/game-engine/test/scenarioSeeder.test.ts index 23c9d7b..0c959f0 100644 --- a/app/game-engine/test/scenarioSeeder.test.ts +++ b/app/game-engine/test/scenarioSeeder.test.ts @@ -1,6 +1,7 @@ import { createGamePostgresConnector } from '@sammo-ts/infra'; import { describe, expect, test } from 'vitest'; import { resolveDatabaseUrl } from '../src/scenario/databaseUrl.js'; +import { loadScenarioDefinitionById } from '../src/scenario/scenarioLoader.js'; import { seedScenarioToDatabase } from '../src/scenario/scenarioSeeder.js'; const scenarioId = 1010; @@ -25,6 +26,15 @@ type ScenarioSeederPrismaClient = { where: { srcNationId: number; destNationId: number }; }): 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 => { @@ -97,4 +107,57 @@ describeDb('scenario database seed', () => { 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; + expect(config.extendedGeneral).toBe(false); + expect(config.joinMode).toBe('full'); + + const meta = (worldState.meta ?? {}) as Record; + const autorun = (meta.autorun_user ?? {}) as Record; + const autorunOptions = (autorun.options ?? {}) as Record; + expect(autorunOptions.develop).toBe(true); + } finally { + await connector.disconnect(); + } + }); }); diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index d7a1a85..5e8ef50 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -4,6 +4,7 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { procedure, router } from './trpc.js'; +import { listScenarioPreviews } from './scenario/scenarioCatalog.js'; import type { UserSanctions, UserServerRestriction } from './auth/userRepository.js'; import type { AdminAuthContext } from './adminAuth.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 zBuildStatus = z.enum(GATEWAY_BUILD_STATUSES); const zUserRoleMode = z.enum(['set', 'grant', 'revoke']); +const zJoinMode = z.enum(['full', 'onlyRandom']); const zServerAction = z.enum([ 'RESUME', 'PAUSE', @@ -24,6 +26,17 @@ const zServerAction = z.enum([ '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_SUPERUSER = 'admin.superuser'; const ROLE_SUPERUSER = 'superuser'; @@ -185,6 +198,31 @@ const zSanctionsPatch = z.object({ 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; // 제재 패치 입력을 현재 제재 상태에 병합한다. @@ -468,6 +506,9 @@ export const adminRouter = router({ }, })); }), + listScenarios: profileAdminProcedure.query(async () => { + return listScenarioPreviews(); + }), upsert: profileAdminProcedure .input( z.object({ @@ -549,6 +590,135 @@ export const adminRouter = router({ const nextMeta = applyMetaPatch(meta, input.patch); 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 .input( z.object({ diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index ff8e079..ba75fc9 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -1,6 +1,6 @@ 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 type { BuildRunner } from './buildRunner.js'; @@ -78,6 +78,24 @@ interface GatewayAdminActionRecord { handledAt?: string | null; handler?: 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 { @@ -113,6 +131,95 @@ const parseScenarioId = (value: string | number | null | undefined): number | nu 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>((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 => `sammo:${profileName}:${role === 'api' ? 'game-api' : 'turn-daemon'}`; @@ -476,15 +583,29 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { this.buildInFlight = true; this.resetInFlight.add(profile.profileName); 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) { return { status: 'FAILED', detail: 'scenarioId is missing' }; } 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(); + 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.stopProfile(profile); + await this.stopProfile(activeProfile); await this.repository.updateBuildStatus(profile.profileName, 'RUNNING', { requestedAt: startedAt, startedAt, @@ -505,13 +626,20 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { scenarioId: seedInfo.scenarioId, tickSeconds: seedInfo.tickSeconds, now: seedTime, + installOptions: installOptions ?? undefined, }); await this.repository.updateBuildStatus(profile.profileName, 'SUCCEEDED', { completedAt, error: null, }); - await this.repository.updateStatus(profile.profileName, 'RUNNING'); - await this.startProfile(profile); + const now = this.now(); + 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' }; } catch (error) { const detail = error instanceof Error ? error.message : String(error); @@ -527,14 +655,15 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { } private async resolveResetSeedInfo( - profile: GatewayProfileRecord + profile: GatewayProfileRecord, + overrides?: { scenarioId?: number | null; tickSeconds?: number } ): Promise<{ databaseUrl: string; scenarioId: number | null; tickSeconds?: number }> { const databaseUrl = resolvePostgresConfigFromEnv({ env: this.processConfig.baseEnv ?? process.env, schema: profile.profile, }).url; - let scenarioId = parseScenarioId(profile.scenario); - let tickSeconds: number | undefined; + let scenarioId = overrides?.scenarioId ?? parseScenarioId(profile.scenario); + let tickSeconds: number | undefined = overrides?.tickSeconds; const connector = createGamePostgresConnector({ url: databaseUrl }); await connector.connect(); try { @@ -542,11 +671,13 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { select: { scenarioCode: true, tickSeconds: true }, }); if (row) { - const resolvedScenario = parseScenarioId(row.scenarioCode); - if (resolvedScenario !== null) { - scenarioId = resolvedScenario; + if (scenarioId === null) { + const resolvedScenario = parseScenarioId(row.scenarioCode); + 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; } } diff --git a/app/gateway-api/src/orchestrator/profileRepository.ts b/app/gateway-api/src/orchestrator/profileRepository.ts index 8a36062..95fbb3b 100644 --- a/app/gateway-api/src/orchestrator/profileRepository.ts +++ b/app/gateway-api/src/orchestrator/profileRepository.ts @@ -53,6 +53,7 @@ export interface GatewayProfileRepository { listProfiles(): Promise; getProfile(profileName: string): Promise; upsertProfile(input: GatewayProfileUpsertInput): Promise; + updateScenario(profileName: string, scenario: string): Promise; updateStatus( profileName: string, status: GatewayProfileStatus, @@ -178,6 +179,15 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat }); return mapProfile(row); }, + async updateScenario(profileName: string, scenario: string): Promise { + const row = await prisma.gatewayProfile.update({ + where: { profileName }, + data: { + scenario, + }, + }); + return row ? mapProfile(row) : null; + }, async updateStatus( profileName: string, status: GatewayProfileStatus, diff --git a/app/gateway-api/src/scenario/scenarioCatalog.ts b/app/gateway-api/src/scenario/scenarioCatalog.ts new file mode 100644 index 0000000..fb8507e --- /dev/null +++ b/app/gateway-api/src/scenario/scenarioCatalog.ts @@ -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 => { + 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 => { + const counts = new Map(); + 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 => { + 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 => { + 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; +}; diff --git a/app/gateway-api/test/authFlow.test.ts b/app/gateway-api/test/authFlow.test.ts index 1ca1754..5bdb5cd 100644 --- a/app/gateway-api/test/authFlow.test.ts +++ b/app/gateway-api/test/authFlow.test.ts @@ -49,6 +49,7 @@ const buildCaller = () => { upsertProfile: async () => { throw new Error('not used'); }, + updateScenario: async () => null, updateStatus: async () => null, updateBuildStatus: async () => null, updateMeta: async () => null, diff --git a/app/gateway-frontend/src/views/AdminView.vue b/app/gateway-frontend/src/views/AdminView.vue index 39f3eca..43f842b 100644 --- a/app/gateway-frontend/src/views/AdminView.vue +++ b/app/gateway-frontend/src/views/AdminView.vue @@ -67,6 +67,44 @@ type AdminProfile = { meta: Record; }; +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; + openAt: string; + preopenAt: string; + reason: string; +}; + type AdminAction = | 'RESUME' | 'PAUSE' @@ -130,6 +168,9 @@ type AdminClient = { list: { query: () => Promise; }; + listScenarios: { + query: () => Promise; + }; updateMeta: { mutate: (input: { profileName: string; @@ -141,6 +182,30 @@ type AdminClient = { }; }) => Promise; }; + 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: { mutate: (input: { profileName: string; @@ -202,6 +267,23 @@ const profileActions = ref< > >({}); const profileActionStatus = ref>({}); +const scenarios = ref([]); +const scenariosLoading = ref(false); +const scenariosStatus = ref(''); +const profileInstalls = ref>({}); +const profileInstallStatus = ref>({}); + +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 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 => { + const map: Record = {}; + 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; + const install = (meta.install ?? {}) as Record; + const autorunUser = (install.autorunUser ?? {}) as Record; + 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(); + scenarios.value.forEach((scenario) => { + map.set(scenario.id, scenario); + }); + return map; +}); + +const scenarioGroups = computed(() => { + const pattern = /【(.*?)[0-9\-_.a-zA-Z]*】/; + const groups: Record = {}; + 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 () => { profilesLoading.value = true; try { const result = await adminClient.profiles.list.query(); - result.forEach(ensureProfileBuffers); + result.forEach((profile) => { + ensureProfileBuffers(profile); + ensureProfileInstallBuffers(profile); + }); profiles.value = result; } catch (error) { 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 edit = profileEdits.value[profileName]; 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 () => { userLoading.value = true; userError.value = ''; @@ -563,6 +820,7 @@ const forceDeleteUser = async () => { onMounted(() => { void loadNotice(); void loadProfiles(); + void loadScenarios(); }); @@ -990,6 +1248,394 @@ onMounted(() => { + +
+
+

설치/리셋

+ {{ profileInstallStatus[profile.profileName] }} +
+
+
+
+ + +
+ {{ scenariosStatus }} +
+
+ +
+
+ + +
+
+ +
+ + +
+
+
+ +
+ +
+ + +
+
+ +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ +
+ + + +
+
+ +
+ +
+ + + +
+
+
+ +
+
+ +
+ + + + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + + +
+
+ {{ getScenarioPreview(profile.profileName)?.title }} +
+
시작 연도: {{ getScenarioPreview(profile.profileName)?.year ?? '-' }}년
+
+ NPC: {{ getScenarioPreview(profile.profileName)?.npcCount }}명 + +{{ getScenarioPreview(profile.profileName)?.npcExCount }}명 + + / 중립 {{ getScenarioPreview(profile.profileName)?.npcNeutralCount }}명 + +
+
+
국가
+
+
+ {{ nation.name }} + {{ nation.generals }}명 + (+{{ nation.generalsEx }}) + · {{ nation.cities.join(', ') }} +
+
+
+
+
+
+
{{ profileActionStatus.global }} diff --git a/docs/architecture/todo.md b/docs/architecture/todo.md index ddc4035..5387c3d 100644 --- a/docs/architecture/todo.md +++ b/docs/architecture/todo.md @@ -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] 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] 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