diff --git a/app/game-engine/src/turn/gatewayAdminActions.ts b/app/game-engine/src/turn/gatewayAdminActions.ts index afd94c8..32e206a 100644 --- a/app/game-engine/src/turn/gatewayAdminActions.ts +++ b/app/game-engine/src/turn/gatewayAdminActions.ts @@ -30,6 +30,7 @@ export interface GatewayAdminActionRecord { } | null; openAt?: string | null; preopenAt?: string | null; + gitRef?: string | null; }; } diff --git a/app/gateway-api/package.json b/app/gateway-api/package.json index 30cbf0e..ee196a3 100644 --- a/app/gateway-api/package.json +++ b/app/gateway-api/package.json @@ -31,6 +31,7 @@ "@sammo-ts/common": "workspace:*", "@sammo-ts/game-engine": "workspace:*", "@sammo-ts/infra": "workspace:*", + "@sammo-ts/logic": "workspace:*", "@trpc/server": "^11.8.1", "date-fns": "^4.1.0", "es-toolkit": "^1.43.0", diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index 5e8ef50..6aba393 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -4,7 +4,7 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { procedure, router } from './trpc.js'; -import { listScenarioPreviews } from './scenario/scenarioCatalog.js'; +import { listScenarioPreviews, resolveGitCommitSha } from './scenario/scenarioCatalog.js'; import type { UserSanctions, UserServerRestriction } from './auth/userRepository.js'; import type { AdminAuthContext } from './adminAuth.js'; import type { GatewayApiContext } from './context.js'; @@ -221,6 +221,7 @@ const zInstallOptions = z.object({ autorunUser: zInstallAutorun.nullable().optional(), openAt: z.string().datetime().optional(), preopenAt: z.string().datetime().optional(), + gitRef: z.string().min(1).max(128).optional(), }); type SanctionsPatch = z.infer; @@ -506,9 +507,18 @@ export const adminRouter = router({ }, })); }), - listScenarios: profileAdminProcedure.query(async () => { - return listScenarioPreviews(); - }), + listScenarios: profileAdminProcedure + .input( + z + .object({ + gitRef: z.string().min(1).max(128).optional(), + }) + .optional() + ) + .query(async ({ input }) => { + const gitRef = input?.gitRef?.trim(); + return listScenarioPreviews({ gitRef: gitRef || null }); + }), upsert: profileAdminProcedure .input( z.object({ @@ -669,6 +679,22 @@ export const adminRouter = router({ } } + let resolvedCommitSha: string | null = null; + const gitRef = input.install.gitRef?.trim(); + if (gitRef) { + try { + resolvedCommitSha = await resolveGitCommitSha(gitRef); + } catch (error) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'git ref is invalid or unavailable.', + }); + } + await ctx.profiles.updateBuildStatus(profile.profileName, profile.buildStatus, { + commitSha: resolvedCommitSha, + }); + } + const scheduledAt = openAt ? (preopenAt ?? openAt).toISOString() : null; const action = scheduledAt ? 'RESET_SCHEDULED' : 'RESET_NOW'; const meta = readMetaObject(profile.meta); @@ -685,6 +711,7 @@ export const adminRouter = router({ ...input.install, openAt: input.install.openAt ?? null, preopenAt: input.install.preopenAt ?? null, + gitRef: gitRef ?? null, autorunUser: autorunUser ? { limitMinutes: autorunUser.limitMinutes, diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index d06ac4d..341fd3e 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -96,6 +96,7 @@ interface GatewayAdminActionRecord { } | null; openAt?: string | null; preopenAt?: string | null; + gitRef?: string | null; }; } @@ -619,12 +620,17 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { }); return { status: 'FAILED', detail: 'build failed' }; } + const workspace = await this.workspaceManager.prepare(commitSha); + const resourceRoot = path.join(workspace.root, 'resources'); await seedScenarioToDatabase({ databaseUrl: seedInfo.databaseUrl, scenarioId: seedInfo.scenarioId, tickSeconds: seedInfo.tickSeconds, now: seedTime, installOptions: installOptions ?? undefined, + scenarioOptions: { scenarioRoot: path.join(resourceRoot, 'scenario') }, + mapOptions: { mapRoot: path.join(resourceRoot, 'map') }, + unitSetOptions: { unitSetRoot: path.join(resourceRoot, 'unitset') }, }); await this.repository.updateBuildStatus(profile.profileName, 'SUCCEEDED', { completedAt, diff --git a/app/gateway-api/src/scenario/scenarioCatalog.ts b/app/gateway-api/src/scenario/scenarioCatalog.ts index fb8507e..2e5da54 100644 --- a/app/gateway-api/src/scenario/scenarioCatalog.ts +++ b/app/gateway-api/src/scenario/scenarioCatalog.ts @@ -1,7 +1,10 @@ import fs from 'node:fs/promises'; import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; import { loadScenarioDefinitionById, resolveScenarioDefaultsPath } from '@sammo-ts/game-engine'; +import { parseScenarioDefaults, parseScenarioDefinition, type ScenarioDefaults } from '@sammo-ts/logic'; export interface ScenarioNationPreview { id: number; @@ -25,14 +28,89 @@ export interface ScenarioPreview { const SCENARIO_FILE_PATTERN = /^scenario_(\d+)\.json$/i; const CACHE_TTL_MS = 5 * 60 * 1000; +const DEFAULT_CACHE_KEY = 'local'; +const GIT_REF_PATTERN = /^[0-9A-Za-z._/-]+$/; +const SCENARIO_ROOT = path.join('resources', 'scenario'); -let cachedPreviews: { loadedAt: number; data: ScenarioPreview[] } | null = null; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const REPO_ROOT = path.resolve(__dirname, '..', '..', '..', '..'); + +const previewCache = new Map(); +const defaultsCache = new Map(); const resolveScenarioRoot = (): string => { const defaultsPath = resolveScenarioDefaultsPath(); return path.dirname(defaultsPath); }; +const runGit = (args: string[]): Promise<{ ok: boolean; output: string }> => + new Promise((resolve) => { + const child = spawn('git', args, { + cwd: REPO_ROOT, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let output = ''; + child.stdout.on('data', (chunk) => { + output += chunk.toString(); + }); + child.stderr.on('data', (chunk) => { + output += chunk.toString(); + }); + child.on('close', (code) => { + resolve({ ok: code === 0, output }); + }); + }); + +const normalizeGitRef = (value?: string | null): string | null => { + if (!value) { + return null; + } + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + if (trimmed.startsWith('-') || trimmed.includes('..') || !GIT_REF_PATTERN.test(trimmed)) { + return null; + } + return trimmed; +}; + +export const resolveGitCommitSha = async (gitRef: string): Promise => { + const normalized = normalizeGitRef(gitRef); + if (!normalized) { + throw new Error('git ref is invalid.'); + } + const resolveCommit = async (): Promise<{ ok: boolean; output: string }> => + runGit(['rev-parse', '--verify', `${normalized}^{commit}`]); + let result = await resolveCommit(); + if (!result.ok) { + await runGit(['fetch', '--all', '--tags']); + result = await resolveCommit(); + } + if (!result.ok) { + throw new Error('git ref not found.'); + } + const commit = result.output.trim().split('\n')[0]; + if (!commit) { + throw new Error('git ref did not resolve to a commit.'); + } + return commit; +}; + +const readGitFile = async (commitSha: string, relativePath: string): Promise => { + const result = await runGit(['show', `${commitSha}:${relativePath}`]); + if (!result.ok) { + throw new Error(`Failed to read ${relativePath} from ${commitSha}.`); + } + return result.output; +}; + +const readGitJson = async (commitSha: string, relativePath: string): Promise => { + const raw = await readGitFile(commitSha, relativePath); + return JSON.parse(raw) as unknown; +}; + const listScenarioIds = async (): Promise => { const root = resolveScenarioRoot(); const entries = await fs.readdir(root, { withFileTypes: true }); @@ -53,6 +131,30 @@ const listScenarioIds = async (): Promise => { return ids.sort((a, b) => a - b); }; +const listScenarioIdsFromGit = async (commitSha: string): Promise => { + const result = await runGit(['ls-tree', '-r', '--name-only', commitSha, SCENARIO_ROOT]); + if (!result.ok) { + throw new Error('Failed to list scenarios from git.'); + } + const ids: number[] = []; + result.output + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .forEach((entry) => { + const fileName = path.basename(entry); + const match = SCENARIO_FILE_PATTERN.exec(fileName); + if (!match) { + return; + } + 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) => { @@ -111,15 +213,80 @@ const buildScenarioPreview = async (scenarioId: number): Promise => { - if (cachedPreviews && Date.now() - cachedPreviews.loadedAt < CACHE_TTL_MS) { - return cachedPreviews.data; +const loadScenarioDefaultsFromGit = async (commitSha: string): Promise => { + const cached = defaultsCache.get(commitSha); + if (cached) { + return cached; } - const ids = await listScenarioIds(); - const previews = await Promise.all(ids.map((id) => buildScenarioPreview(id))); - cachedPreviews = { + const rawDefaults = await readGitJson(commitSha, path.join(SCENARIO_ROOT, 'default.json')); + const parsed = parseScenarioDefaults(rawDefaults); + defaultsCache.set(commitSha, parsed); + return parsed; +}; + +const buildScenarioPreviewFromGit = async (commitSha: string, scenarioId: number): Promise => { + const defaults = await loadScenarioDefaultsFromGit(commitSha); + const rawScenario = await readGitJson(commitSha, path.join(SCENARIO_ROOT, `scenario_${scenarioId}.json`)); + const scenario = parseScenarioDefinition(rawScenario, defaults); + 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 (options?: { gitRef?: string | null }): Promise => { + const rawGitRef = options?.gitRef ?? null; + const gitRef = normalizeGitRef(rawGitRef); + if (typeof rawGitRef === 'string' && rawGitRef.trim() && !gitRef) { + throw new Error('git ref is invalid.'); + } + if (!gitRef) { + const cached = previewCache.get(DEFAULT_CACHE_KEY); + if (cached && Date.now() - cached.loadedAt < CACHE_TTL_MS) { + return cached.data; + } + const ids = await listScenarioIds(); + const previews = await Promise.all(ids.map((id) => buildScenarioPreview(id))); + previewCache.set(DEFAULT_CACHE_KEY, { + loadedAt: Date.now(), + data: previews, + }); + return previews; + } + + const commitSha = await resolveGitCommitSha(gitRef); + const cacheKey = commitSha; + const cached = previewCache.get(cacheKey); + if (cached && Date.now() - cached.loadedAt < CACHE_TTL_MS) { + return cached.data; + } + const ids = await listScenarioIdsFromGit(commitSha); + const previews = await Promise.all(ids.map((id) => buildScenarioPreviewFromGit(commitSha, id))); + previewCache.set(cacheKey, { loadedAt: Date.now(), data: previews, - }; + }); return previews; }; diff --git a/app/gateway-api/test/scenarioCatalog.test.ts b/app/gateway-api/test/scenarioCatalog.test.ts new file mode 100644 index 0000000..0fbc865 --- /dev/null +++ b/app/gateway-api/test/scenarioCatalog.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; + +import { listScenarioPreviews, resolveGitCommitSha } from '../src/scenario/scenarioCatalog.js'; + +describe('scenarioCatalog git ref support', () => { + it('resolves HEAD to a commit hash', async () => { + const commitSha = await resolveGitCommitSha('HEAD'); + expect(commitSha).toMatch(/^[0-9a-f]{40}$/i); + }); + + it('loads scenario previews from a git ref', async () => { + const previews = await listScenarioPreviews({ gitRef: 'HEAD' }); + expect(previews.length).toBeGreaterThan(0); + const ids = previews.map((scenario) => scenario.id); + const sorted = [...ids].sort((a, b) => a - b); + expect(ids).toEqual(sorted); + }); +}); diff --git a/app/gateway-frontend/src/views/AdminView.vue b/app/gateway-frontend/src/views/AdminView.vue index 43f842b..7965c35 100644 --- a/app/gateway-frontend/src/views/AdminView.vue +++ b/app/gateway-frontend/src/views/AdminView.vue @@ -87,6 +87,12 @@ type ScenarioPreview = { nations: ScenarioNationPreview[]; }; +type ScenarioCatalogState = { + scenarios: ScenarioPreview[]; + loading: boolean; + status: string; +}; + type InstallFormState = { scenarioId: number; turnTermMinutes: number; @@ -102,6 +108,7 @@ type InstallFormState = { autorunUserOptions: Record; openAt: string; preopenAt: string; + gitRef: string; reason: string; }; @@ -169,7 +176,7 @@ type AdminClient = { query: () => Promise; }; listScenarios: { - query: () => Promise; + query: (input?: { gitRef?: string }) => Promise; }; updateMeta: { mutate: (input: { @@ -202,6 +209,7 @@ type AdminClient = { } | null; openAt?: string; preopenAt?: string; + gitRef?: string; }; reason?: string; }) => Promise<{ ok: boolean; action?: unknown }>; @@ -267,9 +275,7 @@ const profileActions = ref< > >({}); const profileActionStatus = ref>({}); -const scenarios = ref([]); -const scenariosLoading = ref(false); -const scenariosStatus = ref(''); +const scenarioCatalogs = ref>({}); const profileInstalls = ref>({}); const profileInstallStatus = ref>({}); @@ -397,6 +403,21 @@ const buildAutorunOptionMap = (options?: string[]): Record => { return map; }; +const normalizeGitRefInput = (value: string): string => value.trim(); + +const getScenarioCatalogKey = (gitRef: string): string => normalizeGitRefInput(gitRef); + +const getScenarioCatalogStateByRef = (gitRef: string): ScenarioCatalogState => { + const key = getScenarioCatalogKey(gitRef); + return ( + scenarioCatalogs.value[key] ?? { + scenarios: [], + loading: false, + status: '', + } + ); +}; + const ensureProfileInstallBuffers = (profile: AdminProfile) => { if (profileInstalls.value[profile.profileName]) { return; @@ -409,6 +430,9 @@ const ensureProfileInstallBuffers = (profile: AdminProfile) => { : undefined; const scenarioId = Number(profile.scenario); + const installGitRef = readString(install.gitRef, ''); + const buildCommitRef = typeof profile.buildCommitSha === 'string' ? profile.buildCommitSha : ''; + profileInstalls.value[profile.profileName] = { scenarioId: Number.isFinite(scenarioId) ? scenarioId : readNumber(install.scenarioId, 0), turnTermMinutes: readNumber(install.turnTermMinutes, 60), @@ -424,22 +448,23 @@ const ensureProfileInstallBuffers = (profile: AdminProfile) => { autorunUserOptions: buildAutorunOptionMap(autorunOptionsRaw), openAt: toLocalInputValue(install.openAt), preopenAt: toLocalInputValue(install.preopenAt), + gitRef: installGitRef || buildCommitRef, reason: '', }; }; -const scenarioMap = computed(() => { +const buildScenarioMap = (items: ScenarioPreview[]): Map => { const map = new Map(); - scenarios.value.forEach((scenario) => { + items.forEach((scenario) => { map.set(scenario.id, scenario); }); return map; -}); +}; -const scenarioGroups = computed(() => { +const buildScenarioGroups = (items: ScenarioPreview[]): Record => { const pattern = /【(.*?)[0-9\-_.a-zA-Z]*】/; const groups: Record = {}; - for (const scenario of scenarios.value) { + for (const scenario of items) { const match = pattern.exec(scenario.title); const category = match?.[1] ?? '기타'; if (!groups[category]) { @@ -448,14 +473,41 @@ const scenarioGroups = computed(() => { 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 catalog = getScenarioCatalogStateByRef(install.gitRef); + const map = buildScenarioMap(catalog.scenarios); + return map.get(install.scenarioId) ?? null; +}; + +const getScenarioGroups = (profileName: string): Record => { + const install = profileInstalls.value[profileName]; + if (!install) { + return {}; + } + const catalog = getScenarioCatalogStateByRef(install.gitRef); + return buildScenarioGroups(catalog.scenarios); +}; + +const getScenarioLoading = (profileName: string): boolean => { + const install = profileInstalls.value[profileName]; + if (!install) { + return false; + } + return getScenarioCatalogStateByRef(install.gitRef).loading; +}; + +const getScenarioStatus = (profileName: string): string => { + const install = profileInstalls.value[profileName]; + if (!install) { + return ''; + } + return getScenarioCatalogStateByRef(install.gitRef).status; }; const loadProfiles = async () => { @@ -467,6 +519,15 @@ const loadProfiles = async () => { ensureProfileInstallBuffers(profile); }); profiles.value = result; + const refs = new Set(); + refs.add(''); + result.forEach((profile) => { + const install = profileInstalls.value[profile.profileName]; + if (install?.gitRef) { + refs.add(normalizeGitRefInput(install.gitRef)); + } + }); + await Promise.all(Array.from(refs).map((gitRef) => loadScenarioCatalog(gitRef))); } catch (error) { profileActionStatus.value = { ...profileActionStatus.value, @@ -477,19 +538,47 @@ const loadProfiles = async () => { } }; -const loadScenarios = async () => { - scenariosLoading.value = true; - scenariosStatus.value = ''; +const loadScenarioCatalog = async (gitRef: string) => { + const key = getScenarioCatalogKey(gitRef); + const previous = scenarioCatalogs.value[key]; + scenarioCatalogs.value = { + ...scenarioCatalogs.value, + [key]: { + scenarios: previous?.scenarios ?? [], + loading: true, + status: '', + }, + }; try { - const result = await adminClient.profiles.listScenarios.query(); - scenarios.value = result; + const result = await adminClient.profiles.listScenarios.query(key ? { gitRef: key } : undefined); + scenarioCatalogs.value = { + ...scenarioCatalogs.value, + [key]: { + scenarios: result, + loading: false, + status: '', + }, + }; } catch (error) { - scenariosStatus.value = '시나리오 목록을 불러오지 못했습니다.'; - } finally { - scenariosLoading.value = false; + scenarioCatalogs.value = { + ...scenarioCatalogs.value, + [key]: { + scenarios: previous?.scenarios ?? [], + loading: false, + status: '시나리오 목록을 불러오지 못했습니다.', + }, + }; } }; +const loadScenariosForProfile = async (profileName: string) => { + const install = profileInstalls.value[profileName]; + if (!install) { + return; + } + await loadScenarioCatalog(install.gitRef); +}; + const updateProfileMeta = async (profileName: string) => { const edit = profileEdits.value[profileName]; if (!edit) { @@ -581,6 +670,7 @@ const requestInstall = async (profileName: string) => { return; } try { + const gitRef = normalizeGitRefInput(install.gitRef); await adminClient.profiles.install.mutate({ profileName, install: { @@ -597,6 +687,7 @@ const requestInstall = async (profileName: string) => { autorunUser, openAt: openAt ? openAt.toISOString() : undefined, preopenAt: preopenAt ? preopenAt.toISOString() : undefined, + gitRef: gitRef ? gitRef : undefined, }, reason: install.reason.trim() || undefined, }); @@ -820,7 +911,6 @@ const forceDeleteUser = async () => { onMounted(() => { void loadNotice(); void loadProfiles(); - void loadScenarios(); }); @@ -1259,15 +1349,35 @@ onMounted(() => {
+
+ +
+ + +
+
비워두면 현재 저장소 기준으로 불러옵니다.
+
+