feat: add git reference support for scenario previews; implement git ref handling in admin router and frontend
This commit is contained in:
@@ -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<typeof zSanctionsPatch>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, { loadedAt: number; data: ScenarioPreview[] }>();
|
||||
const defaultsCache = new Map<string, ScenarioDefaults>();
|
||||
|
||||
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<string> => {
|
||||
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<string> => {
|
||||
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<unknown> => {
|
||||
const raw = await readGitFile(commitSha, relativePath);
|
||||
return JSON.parse(raw) as unknown;
|
||||
};
|
||||
|
||||
const listScenarioIds = async (): Promise<number[]> => {
|
||||
const root = resolveScenarioRoot();
|
||||
const entries = await fs.readdir(root, { withFileTypes: true });
|
||||
@@ -53,6 +131,30 @@ const listScenarioIds = async (): Promise<number[]> => {
|
||||
return ids.sort((a, b) => a - b);
|
||||
};
|
||||
|
||||
const listScenarioIdsFromGit = async (commitSha: string): Promise<number[]> => {
|
||||
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<ScenarioPreview
|
||||
};
|
||||
};
|
||||
|
||||
export const listScenarioPreviews = async (): Promise<ScenarioPreview[]> => {
|
||||
if (cachedPreviews && Date.now() - cachedPreviews.loadedAt < CACHE_TTL_MS) {
|
||||
return cachedPreviews.data;
|
||||
const loadScenarioDefaultsFromGit = async (commitSha: string): Promise<ScenarioDefaults> => {
|
||||
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<ScenarioPreview> => {
|
||||
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<ScenarioPreview[]> => {
|
||||
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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user