diff --git a/AGENTS.md b/AGENTS.md index 4d96dfd..ef34edb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,7 @@ ## Planned Monorepo Layout (TypeScript Rewrite) - `/packages/common`: shared utilities and type definitions. + - Frequently used utility functions should be registered here and utilized. - `/packages/infra`: Prisma/Redis connectors and other runtime infra. - `/packages/logic`: pure game logic with DI/interfaces for external dependencies. - `/app/gateway-frontend`: Gateway UI application. diff --git a/app/game-api/src/battleSim/environment.ts b/app/game-api/src/battleSim/environment.ts index 2ea648b..569cd81 100644 --- a/app/game-api/src/battleSim/environment.ts +++ b/app/game-api/src/battleSim/environment.ts @@ -2,6 +2,7 @@ import type { WorldStateRow } from '../context.js'; import type { BattleSimJobPayload, BattleSimRequestPayload } from './types.js'; import { loadUnitSetDefinitionByName } from './unitSetLoader.js'; import type { WarEngineConfig } from '@sammo-ts/logic'; +import { asRecord } from '@sammo-ts/common'; const DEFAULT_WAR_CONFIG = { armPerPhase: 500, @@ -11,13 +12,6 @@ const DEFAULT_WAR_CONFIG = { maxAtmosByWar: 150, }; -const asRecord = (value: unknown): Record => { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return {}; - } - return value as Record; -}; - const resolveNumber = (record: Record, keys: string[], fallback: number): number => { for (const key of keys) { const value = record[key]; diff --git a/app/game-api/src/config.ts b/app/game-api/src/config.ts index 4d8ba1e..27e6bf0 100644 --- a/app/game-api/src/config.ts +++ b/app/game-api/src/config.ts @@ -1,3 +1,5 @@ +import { parseNumberWithFallback } from '@sammo-ts/common'; + export interface GameApiConfig { host: string; port: number; @@ -13,17 +15,6 @@ export interface GameApiConfig { flushChannel: string; } -const parseNumber = (value: string | undefined, fallback: number, label: string): number => { - if (!value) { - return fallback; - } - const parsed = Number(value); - if (Number.isNaN(parsed)) { - throw new Error(`${label} must be a number.`); - } - return parsed; -}; - export const resolveGameApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.env): GameApiConfig => { const profile = env.PROFILE ?? env.SERVER_PROFILE ?? 'hwe'; const scenario = env.SCENARIO ?? 'default'; @@ -36,19 +27,23 @@ export const resolveGameApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.env return { host: env.GAME_API_HOST ?? '0.0.0.0', - port: parseNumber(env.GAME_API_PORT, 14000, 'GAME_API_PORT'), + port: parseNumberWithFallback(env.GAME_API_PORT, 14000, 'GAME_API_PORT'), trpcPath: env.TRPC_PATH ?? '/trpc', eventsPath: env.GAME_API_EVENTS_PATH ?? '/events', profile, scenario, profileName, - daemonRequestTimeoutMs: parseNumber(env.DAEMON_REQUEST_TIMEOUT_MS, 5000, 'DAEMON_REQUEST_TIMEOUT_MS'), - battleSimRequestTimeoutMs: parseNumber( + daemonRequestTimeoutMs: parseNumberWithFallback(env.DAEMON_REQUEST_TIMEOUT_MS, 5000, 'DAEMON_REQUEST_TIMEOUT_MS'), + battleSimRequestTimeoutMs: parseNumberWithFallback( env.BATTLE_SIM_REQUEST_TIMEOUT_MS, 8000, 'BATTLE_SIM_REQUEST_TIMEOUT_MS' ), - battleSimResultTtlSeconds: parseNumber(env.BATTLE_SIM_RESULT_TTL_SECONDS, 60, 'BATTLE_SIM_RESULT_TTL_SECONDS'), + battleSimResultTtlSeconds: parseNumberWithFallback( + env.BATTLE_SIM_RESULT_TTL_SECONDS, + 60, + 'BATTLE_SIM_RESULT_TTL_SECONDS' + ), gameTokenSecret: secret, flushChannel: `${gatewayPrefix}:flush`, }; diff --git a/app/game-api/src/maps/worldMap.ts b/app/game-api/src/maps/worldMap.ts index 39f4dd6..94c297b 100644 --- a/app/game-api/src/maps/worldMap.ts +++ b/app/game-api/src/maps/worldMap.ts @@ -1,4 +1,5 @@ import type { GameApiContext, WorldStateRow } from '../context.js'; +import { asRecord, isRecord } from '@sammo-ts/common'; export type MapCityCompact = [number, number, number, number, number, number]; export type MapNationCompact = [number, string, string, number]; @@ -45,11 +46,6 @@ const MAP_VERSION = 0 as const; const BASE_MAP_TTL_SECONDS = 30; const PUBLIC_MAP_TTL_SECONDS = 600; -const isRecord = (value: unknown): value is Record => - value !== null && typeof value === 'object' && !Array.isArray(value); - -const asRecord = (value: unknown): Record => (isRecord(value) ? value : {}); - const resolveStartYear = (worldState: WorldStateRow): number => { const meta = asRecord(worldState.meta); const scenarioMeta = asRecord(meta.scenarioMeta); diff --git a/app/game-api/src/router/join/index.ts b/app/game-api/src/router/join/index.ts index 55cfdcb..10dbe6a 100644 --- a/app/game-api/src/router/join/index.ts +++ b/app/game-api/src/router/join/index.ts @@ -4,6 +4,7 @@ import { randomBytes } from 'node:crypto'; import type { WorldStateRow } from '../../context.js'; import { authedProcedure, router } from '../../trpc.js'; +import { asNumber, asRecord, asStringArray } from '@sammo-ts/common'; import { isPersonalityTraitKey, isWarTraitKey, @@ -23,15 +24,6 @@ const DEFAULT_JOIN_STAT = { bonusMax: 5, }; -const asRecord = (value: unknown): Record => - value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {}; - -const asNumber = (value: unknown, fallback: number): number => - typeof value === 'number' && Number.isFinite(value) ? value : fallback; - -const asStringArray = (value: unknown): string[] => - Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; - const resolveJoinStat = (worldState: WorldStateRow) => { const config = asRecord(worldState.config); const stat = asRecord(config.stat); diff --git a/app/game-api/src/turns/commandTable.ts b/app/game-api/src/turns/commandTable.ts index 36ff0e0..b280513 100644 --- a/app/game-api/src/turns/commandTable.ts +++ b/app/game-api/src/turns/commandTable.ts @@ -13,6 +13,7 @@ import type { TriggerValue, } from '@sammo-ts/logic'; import { evaluateConstraints, loadGeneralTurnCommandSpecs, loadNationTurnCommandSpecs } from '@sammo-ts/logic'; +import { asRecord, isRecord } from '@sammo-ts/common'; import type { CityRow, GeneralRow, NationRow, WorldStateRow } from '../context.js'; import { loadTurnCommandProfile } from './turnCommandProfile.js'; @@ -73,11 +74,6 @@ const DEFAULT_GENERAL_GOLD = 1000; const DEFAULT_GENERAL_RICE = 1000; const DEFAULT_CREW_TYPE_ID = 1100; -const isRecord = (value: unknown): value is Record => - value !== null && typeof value === 'object' && !Array.isArray(value); - -const asRecord = (value: unknown): Record => (isRecord(value) ? value : {}); - const asTriggerRecord = (value: unknown): Record => isRecord(value) ? (value as Record) : {}; diff --git a/app/game-api/src/turns/reservedTurns.ts b/app/game-api/src/turns/reservedTurns.ts index e973c53..3ea6330 100644 --- a/app/game-api/src/turns/reservedTurns.ts +++ b/app/game-api/src/turns/reservedTurns.ts @@ -1,4 +1,5 @@ import type { DatabaseClient, GeneralTurnRow, NationTurnRow, InputJsonValue } from '../context.js'; +import { isRecord } from '@sammo-ts/common'; export const DEFAULT_TURN_ACTION = '휴식'; export const MAX_GENERAL_TURNS = 30; @@ -15,9 +16,6 @@ export interface ReservedTurnView { args: InputJsonValue; } -const isRecord = (value: unknown): value is Record => - value !== null && typeof value === 'object' && !Array.isArray(value); - const normalizeAction = (action: string | null | undefined): string => action && action.length > 0 ? action : DEFAULT_TURN_ACTION; diff --git a/app/game-engine/src/turn/ai/aiUtils.ts b/app/game-engine/src/turn/ai/aiUtils.ts index 7b0d474..7311bb1 100644 --- a/app/game-engine/src/turn/ai/aiUtils.ts +++ b/app/game-engine/src/turn/ai/aiUtils.ts @@ -1,9 +1,7 @@ import type { City, Nation } from '@sammo-ts/logic'; +import { asRecord, isRecord } from '@sammo-ts/common'; -export const isRecord = (value: unknown): value is Record => - value !== null && typeof value === 'object' && !Array.isArray(value); - -export const asRecord = (value: unknown): Record => (isRecord(value) ? value : {}); +export { asRecord, isRecord }; export const readNumber = (value: unknown, fallback = 0): number => { if (typeof value === 'number' && Number.isFinite(value)) { diff --git a/app/game-engine/src/turn/cli.ts b/app/game-engine/src/turn/cli.ts index a81996a..83695ef 100644 --- a/app/game-engine/src/turn/cli.ts +++ b/app/game-engine/src/turn/cli.ts @@ -1,4 +1,5 @@ import type { TurnSchedule } from '@sammo-ts/logic'; +import { parseOptionalBoolean, parseOptionalNumber } from '@sammo-ts/common'; import type { TurnRunBudget } from '../lifecycle/types.js'; import { resolveDatabaseUrl } from '../scenario/databaseUrl.js'; @@ -24,36 +25,11 @@ const DEFAULT_BUDGET: TurnRunBudget = { catchUpCap: 1, }; -const parseNumber = (value: string | undefined): number | undefined => { - if (!value) { - return undefined; - } - const parsed = Number(value); - if (!Number.isFinite(parsed)) { - return undefined; - } - return parsed; -}; - -const parseBoolean = (value: string | undefined): boolean | undefined => { - if (!value) { - return undefined; - } - const normalized = value.trim().toLowerCase(); - if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) { - return true; - } - if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) { - return false; - } - return undefined; -}; - const buildBudgetOverride = (env: NodeJS.ProcessEnv, override?: Partial): TurnRunBudget | undefined => { const budgetOverride: Partial = { - budgetMs: parseNumber(env.TURN_BUDGET_MS), - maxGenerals: parseNumber(env.TURN_MAX_GENERALS), - catchUpCap: parseNumber(env.TURN_CATCH_UP_CAP), + budgetMs: parseOptionalNumber(env.TURN_BUDGET_MS), + maxGenerals: parseOptionalNumber(env.TURN_MAX_GENERALS), + catchUpCap: parseOptionalNumber(env.TURN_CATCH_UP_CAP), ...override, }; @@ -78,10 +54,10 @@ export const runTurnDaemonCli = async (options: TurnDaemonCliOptions = {}): Prom schema: env.GATEWAY_DB_SCHEMA ?? 'public', })); const budget = buildBudgetOverride(env, options.budget); - const tickMinutes = options.tickMinutes ?? parseNumber(env.TURN_TICK_MINUTES); - const enableDatabaseFlush = options.enableDatabaseFlush ?? parseBoolean(env.TURN_FLUSH_DB) ?? true; - const pauseGateIntervalMs = parseNumber(env.TURN_PAUSE_GATE_MS); - const adminActionIntervalMs = options.adminActionIntervalMs ?? parseNumber(env.TURN_ADMIN_ACTION_MS); + const tickMinutes = options.tickMinutes ?? parseOptionalNumber(env.TURN_TICK_MINUTES); + const enableDatabaseFlush = options.enableDatabaseFlush ?? parseOptionalBoolean(env.TURN_FLUSH_DB) ?? true; + const pauseGateIntervalMs = parseOptionalNumber(env.TURN_PAUSE_GATE_MS); + const adminActionIntervalMs = options.adminActionIntervalMs ?? parseOptionalNumber(env.TURN_ADMIN_ACTION_MS); const runtime = await createTurnDaemonRuntime({ profile, diff --git a/app/game-engine/src/turn/gatewayAdminActions.ts b/app/game-engine/src/turn/gatewayAdminActions.ts index 6ade195..afd94c8 100644 --- a/app/game-engine/src/turn/gatewayAdminActions.ts +++ b/app/game-engine/src/turn/gatewayAdminActions.ts @@ -1,4 +1,5 @@ import { createGatewayPostgresConnector } from '@sammo-ts/infra'; +import { isRecord } from '@sammo-ts/common'; export type GatewayAdminActionStatus = 'REQUESTED' | 'APPLIED' | 'FAILED' | 'IGNORED'; @@ -53,9 +54,6 @@ export interface GatewayAdminActionConsumer { const DEFAULT_POLL_MS = 5000; -const isRecord = (value: unknown): value is Record => - Boolean(value) && typeof value === 'object' && !Array.isArray(value); - const normalizeMeta = (value: unknown): Record => (isRecord(value) ? value : {}); const normalizeStatus = (value: unknown): GatewayAdminActionStatus | null => { diff --git a/app/game-engine/src/turn/reservedTurnCommands.ts b/app/game-engine/src/turn/reservedTurnCommands.ts index a18e018..5ab8cff 100644 --- a/app/game-engine/src/turn/reservedTurnCommands.ts +++ b/app/game-engine/src/turn/reservedTurnCommands.ts @@ -15,16 +15,12 @@ import { ITEM_KEYS, loadItemModules, } from '@sammo-ts/logic'; +import { asRecord } from '@sammo-ts/common'; const DEFAULT_GENERAL_GOLD = 1000; const DEFAULT_GENERAL_RICE = 1000; const DEFAULT_CREW_TYPE_ID = 1100; -const isRecord = (value: unknown): value is Record => - value !== null && typeof value === 'object' && !Array.isArray(value); - -const asRecord = (value: unknown): Record => (isRecord(value) ? value : {}); - const normalizeCode = (value: string | null | undefined): string | null => { if (!value || value === 'None') { return null; diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index f0570a3..6e1f993 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -21,7 +21,7 @@ import { resolveGeneralAction, } from '@sammo-ts/logic'; import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic'; -import { LiteHashDRBG } from '@sammo-ts/common'; +import { asRecord, LiteHashDRBG } from '@sammo-ts/common'; import type { ConstraintContext, StateView } from '@sammo-ts/logic'; @@ -43,11 +43,6 @@ import type { AiReservedTurnProvider } from './ai/types.js'; const DEFAULT_ACTION = '휴식'; -const isRecord = (value: unknown): value is Record => - value !== null && typeof value === 'object' && !Array.isArray(value); - -const asRecord = (value: unknown): Record => (isRecord(value) ? value : {}); - const resolveConstraintEnv = ( world: TurnWorldState, scenarioMeta: ScenarioMeta | undefined, @@ -325,7 +320,7 @@ class WorldStateView implements StateView { } } -const extractArgsRecord = (value: unknown): Record => (isRecord(value) ? value : {}); +const extractArgsRecord = (value: unknown): Record => asRecord(value); const buildConstraintContext = ( general: TurnGeneral, diff --git a/app/game-engine/src/turn/reservedTurnStore.ts b/app/game-engine/src/turn/reservedTurnStore.ts index 47fc130..1d14a28 100644 --- a/app/game-engine/src/turn/reservedTurnStore.ts +++ b/app/game-engine/src/turn/reservedTurnStore.ts @@ -1,4 +1,5 @@ import { createGamePostgresConnector, type InputJsonValue, type TurnEngineDatabaseClient } from '@sammo-ts/infra'; +import { isRecord } from '@sammo-ts/common'; export interface ReservedTurnEntry { action: string; @@ -22,9 +23,6 @@ const DEFAULT_NATION_TURNS = 12; const asJson = (value: unknown): InputJsonValue => value as InputJsonValue; -const isRecord = (value: unknown): value is Record => - value !== null && typeof value === 'object' && !Array.isArray(value); - const normalizeAction = (action: string | null | undefined): string => action && action.length > 0 ? action : DEFAULT_TURN_ACTION; diff --git a/app/game-engine/src/turn/worldLoader.ts b/app/game-engine/src/turn/worldLoader.ts index ad16658..c414ea3 100644 --- a/app/game-engine/src/turn/worldLoader.ts +++ b/app/game-engine/src/turn/worldLoader.ts @@ -10,6 +10,7 @@ import { } from '@sammo-ts/infra'; import type { City, Nation, ScenarioConfig, ScenarioMeta, Troop, TriggerValue } from '@sammo-ts/logic'; import { z } from 'zod'; +import { asRecord, isRecord } from '@sammo-ts/common'; import { getNextTickTime } from '../lifecycle/getNextTickTime.js'; import type { MapLoaderOptions } from '../scenario/mapLoader.js'; @@ -27,11 +28,6 @@ interface TurnWorldLoaderOptions { type JsonRecord = Record; -const isRecord = (value: unknown): value is JsonRecord => - value !== null && typeof value === 'object' && !Array.isArray(value); - -const asRecord = (value: unknown): JsonRecord => (isRecord(value) ? value : {}); - const asTriggerRecord = (value: unknown): Record => isRecord(value) ? (value as Record) : {}; diff --git a/app/gateway-api/src/auth/oauthSessionStore.ts b/app/gateway-api/src/auth/oauthSessionStore.ts index 13aeba7..9bab3c7 100644 --- a/app/gateway-api/src/auth/oauthSessionStore.ts +++ b/app/gateway-api/src/auth/oauthSessionStore.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto'; +import { parseJson } from '@sammo-ts/common'; export type OAuthMode = 'login' | 'change_pw'; @@ -41,17 +42,6 @@ interface RedisClientLike { multi(): RedisPipeline; } -const parseJson = (raw: string | null): T | null => { - if (!raw) { - return null; - } - try { - return JSON.parse(raw) as T; - } catch { - return null; - } -}; - export class RedisOAuthSessionStore implements OAuthSessionStore { private readonly client: RedisClientLike; private readonly prefix: string; diff --git a/app/gateway-api/src/auth/redisSessionService.ts b/app/gateway-api/src/auth/redisSessionService.ts index 1d9d49b..8ca7257 100644 --- a/app/gateway-api/src/auth/redisSessionService.ts +++ b/app/gateway-api/src/auth/redisSessionService.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto'; +import { parseJson } from '@sammo-ts/common'; import { createGatewayRedisKeyBuilder } from './redisKeys.js'; import type { @@ -30,17 +31,6 @@ interface RedisClientLike { del(key: string): Promise; } -const parseJson = (raw: string | null): T | null => { - if (!raw) { - return null; - } - try { - return JSON.parse(raw) as T; - } catch { - return null; - } -}; - // Redis 세션 저장소는 게이트웨이와 게임 서버 간 SSO 토큰을 관리한다. export class RedisGatewaySessionService implements GatewaySessionService { private readonly client: RedisClientLike; diff --git a/app/gateway-api/src/config.ts b/app/gateway-api/src/config.ts index 24355df..7fbe376 100644 --- a/app/gateway-api/src/config.ts +++ b/app/gateway-api/src/config.ts @@ -1,4 +1,5 @@ import path from 'node:path'; +import { parseBooleanWithFallback, parseNumberWithFallback } from '@sammo-ts/common'; export interface GatewayApiConfig { host: string; @@ -36,31 +37,6 @@ export interface GatewayOrchestratorConfig { worktreeRoot: string; } -const parseNumber = (value: string | undefined, fallback: number, label: string): number => { - if (!value) { - return fallback; - } - const parsed = Number(value); - if (Number.isNaN(parsed)) { - throw new Error(`${label} must be a number.`); - } - return parsed; -}; - -const parseBoolean = (value: string | undefined, fallback: boolean): boolean => { - if (!value) { - return fallback; - } - const normalized = value.trim().toLowerCase(); - if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) { - return true; - } - if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) { - return false; - } - return fallback; -}; - const resolveSchemaName = (value: string | undefined): string => { if (!value) { return 'public'; @@ -83,36 +59,44 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process. const redisKeyPrefix = env.GATEWAY_REDIS_PREFIX ?? 'sammo:gateway'; return { host: env.GATEWAY_API_HOST ?? '0.0.0.0', - port: parseNumber(env.GATEWAY_API_PORT, 13000, 'GATEWAY_API_PORT'), + port: parseNumberWithFallback(env.GATEWAY_API_PORT, 13000, 'GATEWAY_API_PORT'), trpcPath: env.TRPC_PATH ?? '/trpc', dbSchema: resolveSchemaName(env.GATEWAY_DB_SCHEMA), redisKeyPrefix, flushChannel: `${redisKeyPrefix}:flush`, - sessionTtlSeconds: parseNumber(env.SESSION_TTL_SECONDS, 60 * 60 * 24 * 7, 'SESSION_TTL_SECONDS'), - gameSessionTtlSeconds: parseNumber(env.GAME_SESSION_TTL_SECONDS, 60 * 60 * 6, 'GAME_SESSION_TTL_SECONDS'), + sessionTtlSeconds: parseNumberWithFallback(env.SESSION_TTL_SECONDS, 60 * 60 * 24 * 7, 'SESSION_TTL_SECONDS'), + gameSessionTtlSeconds: parseNumberWithFallback( + env.GAME_SESSION_TTL_SECONDS, + 60 * 60 * 6, + 'GAME_SESSION_TTL_SECONDS' + ), gameTokenSecret: secret, - oauthSessionTtlSeconds: parseNumber(env.OAUTH_SESSION_TTL_SECONDS, 10 * 60, 'OAUTH_SESSION_TTL_SECONDS'), + oauthSessionTtlSeconds: parseNumberWithFallback( + env.OAUTH_SESSION_TTL_SECONDS, + 10 * 60, + 'OAUTH_SESSION_TTL_SECONDS' + ), kakaoRestKey, kakaoAdminKey: env.KAKAO_ADMIN_KEY, kakaoRedirectUri, publicBaseUrl, - orchestratorEnabled: parseBoolean(env.GATEWAY_ORCHESTRATOR_ENABLED, false), - orchestratorReconcileIntervalMs: parseNumber( + orchestratorEnabled: parseBooleanWithFallback(env.GATEWAY_ORCHESTRATOR_ENABLED, false), + orchestratorReconcileIntervalMs: parseNumberWithFallback( env.GATEWAY_ORCHESTRATOR_RECONCILE_MS, 15000, 'GATEWAY_ORCHESTRATOR_RECONCILE_MS' ), - orchestratorScheduleIntervalMs: parseNumber( + orchestratorScheduleIntervalMs: parseNumberWithFallback( env.GATEWAY_ORCHESTRATOR_SCHEDULE_MS, 5000, 'GATEWAY_ORCHESTRATOR_SCHEDULE_MS' ), - orchestratorBuildIntervalMs: parseNumber( + orchestratorBuildIntervalMs: parseNumberWithFallback( env.GATEWAY_ORCHESTRATOR_BUILD_MS, 10000, 'GATEWAY_ORCHESTRATOR_BUILD_MS' ), - orchestratorAdminIntervalMs: parseNumber( + orchestratorAdminIntervalMs: parseNumberWithFallback( env.GATEWAY_ORCHESTRATOR_ADMIN_MS, 5000, 'GATEWAY_ORCHESTRATOR_ADMIN_MS' @@ -135,22 +119,22 @@ export const resolveGatewayOrchestratorConfigFromEnv = ( dbSchema: resolveSchemaName(env.GATEWAY_DB_SCHEMA), redisKeyPrefix, gameTokenSecret: secret, - orchestratorReconcileIntervalMs: parseNumber( + orchestratorReconcileIntervalMs: parseNumberWithFallback( env.GATEWAY_ORCHESTRATOR_RECONCILE_MS, 15000, 'GATEWAY_ORCHESTRATOR_RECONCILE_MS' ), - orchestratorScheduleIntervalMs: parseNumber( + orchestratorScheduleIntervalMs: parseNumberWithFallback( env.GATEWAY_ORCHESTRATOR_SCHEDULE_MS, 5000, 'GATEWAY_ORCHESTRATOR_SCHEDULE_MS' ), - orchestratorBuildIntervalMs: parseNumber( + orchestratorBuildIntervalMs: parseNumberWithFallback( env.GATEWAY_ORCHESTRATOR_BUILD_MS, 10000, 'GATEWAY_ORCHESTRATOR_BUILD_MS' ), - orchestratorAdminIntervalMs: parseNumber( + orchestratorAdminIntervalMs: parseNumberWithFallback( env.GATEWAY_ORCHESTRATOR_ADMIN_MS, 5000, 'GATEWAY_ORCHESTRATOR_ADMIN_MS' diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index ba75fc9..d06ac4d 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -2,6 +2,7 @@ import path from 'node:path'; import { seedScenarioToDatabase, type ScenarioInstallOptions } from '@sammo-ts/game-engine'; import { createGamePostgresConnector, resolvePostgresConfigFromEnv } from '@sammo-ts/infra'; +import { isRecord } from '@sammo-ts/common'; import type { BuildRunner } from './buildRunner.js'; import type { ProcessManager } from './processManager.js'; @@ -103,9 +104,6 @@ interface GatewayAdminActionResult { detail?: string; } -const isRecord = (value: unknown): value is Record => - Boolean(value) && typeof value === 'object' && !Array.isArray(value); - const normalizeMeta = (value: unknown): Record => (isRecord(value) ? value : {}); const normalizeStatus = (value: unknown): GatewayAdminActionStatus | null => { diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 7093d8f..e816d0d 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -10,6 +10,7 @@ export * from './util/RNG.js'; export * from './util/RandUtil.js'; export * from './util/TestRNG.js'; export * from './util/sha512.js'; +export * from './util/parse.js'; export * from './turnDaemon/types.js'; export * from './realtime/keys.js'; export * from './realtime/types.js'; diff --git a/packages/common/src/util/parse.ts b/packages/common/src/util/parse.ts new file mode 100644 index 0000000..bdbc1b0 --- /dev/null +++ b/packages/common/src/util/parse.ts @@ -0,0 +1,88 @@ +export const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); + +export const asRecord = (value: unknown): Record => (isRecord(value) ? value : {}); + +export const asNumber = (value: unknown, fallback: number): number => + typeof value === 'number' && Number.isFinite(value) ? value : fallback; + +export const asString = (value: unknown, fallback: string): string => (typeof value === 'string' ? value : fallback); + +export const asNullableNumber = (value: unknown): number | null => + typeof value === 'number' && Number.isFinite(value) ? value : null; + +export const asNullableString = (value: unknown): string | null => (typeof value === 'string' ? value : null); + +export const asStringArray = (value: unknown): string[] => + Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; + +export const asNullableStringArray = (value: unknown): string[] | null => { + if (value === null || value === undefined) { + return null; + } + return asStringArray(value); +}; + +export const parseNumberWithFallback = (value: string | undefined, fallback: number, label?: string): number => { + if (!value) { + return fallback; + } + const parsed = Number(value); + if (Number.isNaN(parsed)) { + if (label) { + throw new Error(`${label} must be a number.`); + } + return fallback; + } + return parsed; +}; + +export const parseOptionalNumber = (value: string | undefined): number | undefined => { + if (!value) { + return undefined; + } + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return undefined; + } + return parsed; +}; + +export const parseBooleanWithFallback = (value: string | undefined, fallback: boolean): boolean => { + if (!value) { + return fallback; + } + const normalized = value.trim().toLowerCase(); + if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) { + return true; + } + if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) { + return false; + } + return fallback; +}; + +export const parseOptionalBoolean = (value: string | undefined): boolean | undefined => { + if (!value) { + return undefined; + } + const normalized = value.trim().toLowerCase(); + if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) { + return true; + } + if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) { + return false; + } + return undefined; +}; + +export const parseJson = (raw: string | null): T | null => { + if (!raw) { + return null; + } + try { + return JSON.parse(raw) as T; + } catch { + return null; + } +}; diff --git a/packages/logic/src/actions/turn/actionContextHelpers.ts b/packages/logic/src/actions/turn/actionContextHelpers.ts index ef532db..8635794 100644 --- a/packages/logic/src/actions/turn/actionContextHelpers.ts +++ b/packages/logic/src/actions/turn/actionContextHelpers.ts @@ -4,6 +4,7 @@ import type { ScenarioMeta } from '@sammo-ts/logic/world/types.js'; import type { WarAftermathConfig, WarEngineConfig, WarTimeContext } from '@sammo-ts/logic/war/types.js'; import type { UnitSetDefinition } from '@sammo-ts/logic/world/types.js'; import type { ActionContextWorldRef, ActionContextWorldState } from './actionContext.js'; +import { asRecord } from '@sammo-ts/common'; export interface WorldSummary { totalGeneralCount: number; @@ -112,9 +113,6 @@ const DEFAULT_AFTER_CONFIG = { defaultCityWall: 1000, }; -const asRecord = (value: unknown): Record => - value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {}; - const resolveNumber = (record: Record, keys: string[], fallback: number): number => { for (const key of keys) { const value = record[key]; diff --git a/packages/logic/src/actions/turn/commandProfile.ts b/packages/logic/src/actions/turn/commandProfile.ts index 3ce99fa..80f6811 100644 --- a/packages/logic/src/actions/turn/commandProfile.ts +++ b/packages/logic/src/actions/turn/commandProfile.ts @@ -1,19 +1,17 @@ import { GENERAL_TURN_COMMAND_KEYS, isGeneralTurnCommandKey, type GeneralTurnCommandKey } from './general/index.js'; import { NATION_TURN_COMMAND_KEYS, isNationTurnCommandKey, type NationTurnCommandKey } from './nation/index.js'; +import { asStringArray, isRecord } from '@sammo-ts/common'; export interface TurnCommandProfile { general: GeneralTurnCommandKey[]; nation: NationTurnCommandKey[]; } -const isRecord = (value: unknown): value is Record => - value !== null && typeof value === 'object' && !Array.isArray(value); - -const asStringArray = (value: unknown): string[] | null => { +const asStringArrayOrNull = (value: unknown): string[] | null => { if (!Array.isArray(value)) { return null; } - const list = value.filter((entry): entry is string => typeof entry === 'string'); + const list = asStringArray(value); return list.length > 0 ? list : null; }; @@ -23,7 +21,7 @@ const parseKeyList = (options: { isKey: (value: string) => value is T; label: string; }): T[] => { - const rawList = asStringArray(options.raw); + const rawList = asStringArrayOrNull(options.raw); if (!rawList) { return options.defaults; } diff --git a/packages/logic/src/scenario/parseScenario.ts b/packages/logic/src/scenario/parseScenario.ts index 0bc0c2b..7a33706 100644 --- a/packages/logic/src/scenario/parseScenario.ts +++ b/packages/logic/src/scenario/parseScenario.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { asNullableNumber, asNullableString, asNumber, asString, asStringArray, isRecord } from '@sammo-ts/common'; import type { ScenarioConfig, @@ -23,24 +24,10 @@ const FALLBACK_STAT: ScenarioStatBlock = { chiefMin: 0, }; -const isRecord = (value: unknown): value is UnknownRecord => - typeof value === 'object' && value !== null && !Array.isArray(value); - const toRecordOrUndefined = (value: unknown): UnknownRecord | undefined => (isRecord(value) ? value : undefined); const toArrayOrUndefined = (value: unknown): unknown[] | undefined => (Array.isArray(value) ? value : undefined); -const asNumber = (value: unknown, fallback: number): number => (typeof value === 'number' ? value : fallback); - -const asString = (value: unknown, fallback: string): string => (typeof value === 'string' ? value : fallback); - -const asNullableNumber = (value: unknown): number | null => (typeof value === 'number' ? value : null); - -const asNullableString = (value: unknown): string | null => (typeof value === 'string' ? value : null); - -const asStringArray = (value: unknown): string[] => - Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; - const zRecord = z.record(z.string(), z.unknown()); const zUnknownArray = z.array(z.unknown()); const zOptionalRecord = z.preprocess(toRecordOrUndefined, zRecord.optional()); diff --git a/packages/logic/src/triggers/special/war/aux.ts b/packages/logic/src/triggers/special/war/aux.ts index 96a8b3f..c625900 100644 --- a/packages/logic/src/triggers/special/war/aux.ts +++ b/packages/logic/src/triggers/special/war/aux.ts @@ -1,10 +1,10 @@ +import { isRecord } from '@sammo-ts/common'; + export interface WarDexAux { isAttacker?: boolean; opposeType?: { armType: number }; } -const isRecord = (value: unknown): value is Record => value !== null && typeof value === 'object'; - export const parseWarDexAux = (aux: unknown): WarDexAux => { if (!isRecord(aux)) { return {}; diff --git a/packages/logic/src/world/unitSet.ts b/packages/logic/src/world/unitSet.ts index e05cbd3..dd8f3d2 100644 --- a/packages/logic/src/world/unitSet.ts +++ b/packages/logic/src/world/unitSet.ts @@ -1,5 +1,13 @@ import type { City, General, Nation } from '@sammo-ts/logic/domain/entities.js'; import type { CrewTypeDefinition, CrewTypeRequirement, MapDefinition, UnitSetDefinition } from './types.js'; +import { + asNullableStringArray, + asNumber, + asRecord, + asString, + asStringArray, + isRecord, +} from '@sammo-ts/common'; const DEFAULT_REGION_MAP: Record = { 하북: 1, @@ -14,26 +22,6 @@ const DEFAULT_REGION_MAP: Record = { const DEFAULT_MAX_TECH_LEVEL = 12; -const isRecord = (value: unknown): value is Record => - value !== null && typeof value === 'object' && !Array.isArray(value); - -const asRecord = (value: unknown): Record => (isRecord(value) ? value : {}); - -const asNumber = (value: unknown, fallback: number): number => - typeof value === 'number' && Number.isFinite(value) ? value : fallback; - -const asString = (value: unknown, fallback: string): string => (typeof value === 'string' ? value : fallback); - -const asStringArray = (value: unknown): string[] => - Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; - -const asNullableStringArray = (value: unknown): string[] | null => { - if (value === null || value === undefined) { - return null; - } - return asStringArray(value); -}; - const normalizeCoef = (value: unknown): Record => { if (!isRecord(value)) { return {};