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:
@@ -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<typeof zSanctionsPatch>;
|
||||
|
||||
// 제재 패치 입력을 현재 제재 상태에 병합한다.
|
||||
@@ -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({
|
||||
|
||||
@@ -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<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 =>
|
||||
`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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface GatewayProfileRepository {
|
||||
listProfiles(): Promise<GatewayProfileRecord[]>;
|
||||
getProfile(profileName: string): Promise<GatewayProfileRecord | null>;
|
||||
upsertProfile(input: GatewayProfileUpsertInput): Promise<GatewayProfileRecord>;
|
||||
updateScenario(profileName: string, scenario: string): Promise<GatewayProfileRecord | null>;
|
||||
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<GatewayProfileRecord | null> {
|
||||
const row = await prisma.gatewayProfile.update({
|
||||
where: { profileName },
|
||||
data: {
|
||||
scenario,
|
||||
},
|
||||
});
|
||||
return row ? mapProfile(row) : null;
|
||||
},
|
||||
async updateStatus(
|
||||
profileName: string,
|
||||
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;
|
||||
};
|
||||
Reference in New Issue
Block a user