feat: refactor utility functions for improved consistency and usability; consolidate parsing logic

This commit is contained in:
2026-01-17 12:36:58 +00:00
parent bc76b6e725
commit 07ea17dacf
25 changed files with 162 additions and 211 deletions
+1
View File
@@ -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.
+1 -7
View File
@@ -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<string, unknown> => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
return value as Record<string, unknown>;
};
const resolveNumber = (record: Record<string, unknown>, keys: string[], fallback: number): number => {
for (const key of keys) {
const value = record[key];
+10 -15
View File
@@ -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`,
};
+1 -5
View File
@@ -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<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
const asRecord = (value: unknown): Record<string, unknown> => (isRecord(value) ? value : {});
const resolveStartYear = (worldState: WorldStateRow): number => {
const meta = asRecord(worldState.meta);
const scenarioMeta = asRecord(meta.scenarioMeta);
+1 -9
View File
@@ -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<string, unknown> =>
value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
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);
+1 -5
View File
@@ -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<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
const asRecord = (value: unknown): Record<string, unknown> => (isRecord(value) ? value : {});
const asTriggerRecord = (value: unknown): Record<string, TriggerValue> =>
isRecord(value) ? (value as Record<string, TriggerValue>) : {};
+1 -3
View File
@@ -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<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
const normalizeAction = (action: string | null | undefined): string =>
action && action.length > 0 ? action : DEFAULT_TURN_ACTION;
+2 -4
View File
@@ -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<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
export const asRecord = (value: unknown): Record<string, unknown> => (isRecord(value) ? value : {});
export { asRecord, isRecord };
export const readNumber = (value: unknown, fallback = 0): number => {
if (typeof value === 'number' && Number.isFinite(value)) {
+8 -32
View File
@@ -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>): TurnRunBudget | undefined => {
const budgetOverride: Partial<TurnRunBudget> = {
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,
@@ -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<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const normalizeMeta = (value: unknown): Record<string, unknown> => (isRecord(value) ? value : {});
const normalizeStatus = (value: unknown): GatewayAdminActionStatus | null => {
@@ -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<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
const asRecord = (value: unknown): Record<string, unknown> => (isRecord(value) ? value : {});
const normalizeCode = (value: string | null | undefined): string | null => {
if (!value || value === 'None') {
return null;
@@ -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<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
const asRecord = (value: unknown): Record<string, unknown> => (isRecord(value) ? value : {});
const resolveConstraintEnv = (
world: TurnWorldState,
scenarioMeta: ScenarioMeta | undefined,
@@ -325,7 +320,7 @@ class WorldStateView implements StateView {
}
}
const extractArgsRecord = (value: unknown): Record<string, unknown> => (isRecord(value) ? value : {});
const extractArgsRecord = (value: unknown): Record<string, unknown> => asRecord(value);
const buildConstraintContext = (
general: TurnGeneral,
@@ -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<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
const normalizeAction = (action: string | null | undefined): string =>
action && action.length > 0 ? action : DEFAULT_TURN_ACTION;
+1 -5
View File
@@ -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<string, unknown>;
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<string, TriggerValue> =>
isRecord(value) ? (value as Record<string, TriggerValue>) : {};
+1 -11
View File
@@ -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 = <T>(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;
@@ -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<number>;
}
const parseJson = <T>(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;
+22 -38
View File
@@ -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'
@@ -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<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const normalizeMeta = (value: unknown): Record<string, unknown> => (isRecord(value) ? value : {});
const normalizeStatus = (value: unknown): GatewayAdminActionStatus | null => {
+1
View File
@@ -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';
+88
View File
@@ -0,0 +1,88 @@
export const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
export const asRecord = (value: unknown): Record<string, unknown> => (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 = <T>(raw: string | null): T | null => {
if (!raw) {
return null;
}
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
};
@@ -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<string, unknown> =>
value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
const resolveNumber = (record: Record<string, unknown>, keys: string[], fallback: number): number => {
for (const key of keys) {
const value = record[key];
@@ -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<string, unknown> =>
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 = <T extends string>(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;
}
+1 -14
View File
@@ -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());
@@ -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<string, unknown> => value !== null && typeof value === 'object';
export const parseWarDexAux = (aux: unknown): WarDexAux => {
if (!isRecord(aux)) {
return {};
+8 -20
View File
@@ -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<string, number> = {
하북: 1,
@@ -14,26 +22,6 @@ const DEFAULT_REGION_MAP: Record<string, number> = {
const DEFAULT_MAX_TECH_LEVEL = 12;
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
const asRecord = (value: unknown): Record<string, unknown> => (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<string, number> => {
if (!isRecord(value)) {
return {};