feat: eslint 적용 및 관련 코드 일괄 수정
This commit is contained in:
@@ -8,7 +8,8 @@
|
||||
"dev": "tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-api --watch",
|
||||
"worker": "GAME_API_ROLE=battle-sim-worker node dist/index.js",
|
||||
"worker:dev": "GAME_API_ROLE=battle-sim-worker tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-api --watch",
|
||||
"lint": "node -e \"console.log('lint not configured')\"",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "vitest run --config vitest.config.ts",
|
||||
"typecheck": "tsc -b"
|
||||
},
|
||||
|
||||
@@ -18,11 +18,7 @@ const asRecord = (value: unknown): Record<string, unknown> => {
|
||||
return value as Record<string, unknown>;
|
||||
};
|
||||
|
||||
const resolveNumber = (
|
||||
record: Record<string, unknown>,
|
||||
keys: string[],
|
||||
fallback: number
|
||||
): number => {
|
||||
const resolveNumber = (record: Record<string, unknown>, keys: string[], fallback: number): number => {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
@@ -32,10 +28,7 @@ const resolveNumber = (
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const resolveUnitSetName = (
|
||||
worldState: WorldStateRow,
|
||||
fallback: string
|
||||
): string => {
|
||||
const resolveUnitSetName = (worldState: WorldStateRow, fallback: string): string => {
|
||||
const config = asRecord(worldState.config);
|
||||
const environment = asRecord(config.environment ?? config.map);
|
||||
const unitSet = environment.unitSet;
|
||||
@@ -76,9 +69,12 @@ const resolveCastleCrewTypeId = (unitSet: {
|
||||
return crewTypes[0]?.id ?? 0;
|
||||
};
|
||||
|
||||
const resolveCastleArmType = (unitSet: {
|
||||
crewTypes?: Array<{ id: number; armType: number }>;
|
||||
}, castleCrewTypeId: number): number => {
|
||||
const resolveCastleArmType = (
|
||||
unitSet: {
|
||||
crewTypes?: Array<{ id: number; armType: number }>;
|
||||
},
|
||||
castleCrewTypeId: number
|
||||
): number => {
|
||||
const crewTypes = unitSet.crewTypes ?? [];
|
||||
return crewTypes.find((crewType) => crewType.id === castleCrewTypeId)?.armType ?? 0;
|
||||
};
|
||||
@@ -93,39 +89,15 @@ export const buildBattleSimJobPayload = async (
|
||||
|
||||
const configRecord = asRecord(worldState.config);
|
||||
const constValues = asRecord(configRecord.const ?? configRecord.consts);
|
||||
const castleCrewTypeId = resolveNumber(
|
||||
constValues,
|
||||
['castleCrewTypeId'],
|
||||
resolveCastleCrewTypeId(unitSet)
|
||||
);
|
||||
const castleCrewTypeId = resolveNumber(constValues, ['castleCrewTypeId'], resolveCastleCrewTypeId(unitSet));
|
||||
const castleArmType = resolveCastleArmType(unitSet, castleCrewTypeId);
|
||||
|
||||
const config: WarEngineConfig = {
|
||||
armPerPhase: resolveNumber(
|
||||
constValues,
|
||||
['armPerPhase', 'armperphase'],
|
||||
DEFAULT_WAR_CONFIG.armPerPhase
|
||||
),
|
||||
maxTrainByCommand: resolveNumber(
|
||||
constValues,
|
||||
['maxTrainByCommand'],
|
||||
DEFAULT_WAR_CONFIG.maxTrainByCommand
|
||||
),
|
||||
maxAtmosByCommand: resolveNumber(
|
||||
constValues,
|
||||
['maxAtmosByCommand'],
|
||||
DEFAULT_WAR_CONFIG.maxAtmosByCommand
|
||||
),
|
||||
maxTrainByWar: resolveNumber(
|
||||
constValues,
|
||||
['maxTrainByWar'],
|
||||
DEFAULT_WAR_CONFIG.maxTrainByWar
|
||||
),
|
||||
maxAtmosByWar: resolveNumber(
|
||||
constValues,
|
||||
['maxAtmosByWar'],
|
||||
DEFAULT_WAR_CONFIG.maxAtmosByWar
|
||||
),
|
||||
armPerPhase: resolveNumber(constValues, ['armPerPhase', 'armperphase'], DEFAULT_WAR_CONFIG.armPerPhase),
|
||||
maxTrainByCommand: resolveNumber(constValues, ['maxTrainByCommand'], DEFAULT_WAR_CONFIG.maxTrainByCommand),
|
||||
maxAtmosByCommand: resolveNumber(constValues, ['maxAtmosByCommand'], DEFAULT_WAR_CONFIG.maxAtmosByCommand),
|
||||
maxTrainByWar: resolveNumber(constValues, ['maxTrainByWar'], DEFAULT_WAR_CONFIG.maxTrainByWar),
|
||||
maxAtmosByWar: resolveNumber(constValues, ['maxAtmosByWar'], DEFAULT_WAR_CONFIG.maxAtmosByWar),
|
||||
castleCrewTypeId,
|
||||
armTypes: {
|
||||
footman: 1,
|
||||
|
||||
@@ -4,9 +4,7 @@ export interface BattleSimQueueKeys {
|
||||
notifyKeyPrefix: string;
|
||||
}
|
||||
|
||||
export const buildBattleSimQueueKeys = (
|
||||
profileName: string
|
||||
): BattleSimQueueKeys => ({
|
||||
export const buildBattleSimQueueKeys = (profileName: string): BattleSimQueueKeys => ({
|
||||
queueKey: `sammo:${profileName}:battle-sim:queue`,
|
||||
resultKeyPrefix: `sammo:${profileName}:battle-sim:result:`,
|
||||
notifyKeyPrefix: `sammo:${profileName}:battle-sim:notify:`,
|
||||
|
||||
@@ -20,11 +20,7 @@ import {
|
||||
type WarActionModule,
|
||||
} from '@sammo-ts/logic';
|
||||
|
||||
import {
|
||||
type BattleSimJobPayload,
|
||||
type BattleSimLogBuckets,
|
||||
type BattleSimResultPayload,
|
||||
} from './types.js';
|
||||
import { type BattleSimJobPayload, type BattleSimLogBuckets, type BattleSimResultPayload } from './types.js';
|
||||
import { convertLog } from './logFormatter.js';
|
||||
|
||||
const DEFAULT_GENERAL_AGE = 20;
|
||||
@@ -33,8 +29,7 @@ const itemWarModules: WarActionModule[] = createItemActionModules(
|
||||
createItemModuleRegistry(await loadItemModules([...ITEM_KEYS]))
|
||||
).war;
|
||||
|
||||
const normalizeItemCode = (value: string | null): string | null =>
|
||||
value === 'None' ? null : value;
|
||||
const normalizeItemCode = (value: string | null): string | null => (value === 'None' ? null : value);
|
||||
|
||||
const mapNationPayload = (payload: BattleSimJobPayload['attackerNation']): Nation => ({
|
||||
id: payload.nation,
|
||||
@@ -81,9 +76,7 @@ const mapCityPayload = (payload: BattleSimJobPayload['attackerCity']): City => (
|
||||
},
|
||||
});
|
||||
|
||||
const mapGeneralPayload = (
|
||||
payload: BattleSimJobPayload['attackerGeneral']
|
||||
): General => ({
|
||||
const mapGeneralPayload = (payload: BattleSimJobPayload['attackerGeneral']): General => ({
|
||||
id: payload.no,
|
||||
name: payload.name,
|
||||
nationId: payload.nation,
|
||||
@@ -121,9 +114,7 @@ const mapGeneralPayload = (
|
||||
flags: {},
|
||||
counters: {},
|
||||
modifiers: {},
|
||||
meta: payload.inheritBuff
|
||||
? { inheritBuff: JSON.stringify(payload.inheritBuff) }
|
||||
: {},
|
||||
meta: payload.inheritBuff ? { inheritBuff: JSON.stringify(payload.inheritBuff) } : {},
|
||||
},
|
||||
meta: {
|
||||
explevel: payload.explevel,
|
||||
@@ -226,9 +217,7 @@ const resolveCityRiceConsumption = (options: {
|
||||
year: number;
|
||||
startYear: number;
|
||||
}): number => {
|
||||
const cityReport = options.battle.reports.find(
|
||||
(report) => report.type === 'city'
|
||||
);
|
||||
const cityReport = options.battle.reports.find((report) => report.type === 'city');
|
||||
if (!cityReport) {
|
||||
return 0;
|
||||
}
|
||||
@@ -236,9 +225,7 @@ const resolveCityRiceConsumption = (options: {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const crewType = options.unitSet.crewTypes?.find(
|
||||
(item) => item.id === options.castleCrewTypeId
|
||||
);
|
||||
const crewType = options.unitSet.crewTypes?.find((item) => item.id === options.castleCrewTypeId);
|
||||
const riceCoef = crewType?.rice ?? 1;
|
||||
const tech = Number(options.defenderNation.meta.tech ?? 0);
|
||||
const trainAtmos = resolveCityTrainAtmos(options.year, options.startYear);
|
||||
@@ -346,9 +333,7 @@ export const processBattleSimJob = (payload: BattleSimJobPayload): BattleSimResu
|
||||
});
|
||||
|
||||
lastBattle = outcome;
|
||||
const attackerReport = outcome.reports.find(
|
||||
(report) => report.type === 'general' && report.isAttacker
|
||||
);
|
||||
const attackerReport = outcome.reports.find((report) => report.type === 'general' && report.isAttacker);
|
||||
const killed = attackerReport?.killed ?? 0;
|
||||
const dead = attackerReport?.dead ?? 0;
|
||||
|
||||
@@ -386,8 +371,7 @@ export const processBattleSimJob = (payload: BattleSimJobPayload): BattleSimResu
|
||||
|
||||
const attackerActivated = outcome.metrics?.attackerActivatedSkills ?? {};
|
||||
for (const [skillName, value] of Object.entries(attackerActivated)) {
|
||||
attackerSkills[skillName] =
|
||||
(attackerSkills[skillName] ?? 0) + value * weight;
|
||||
attackerSkills[skillName] = (attackerSkills[skillName] ?? 0) + value * weight;
|
||||
}
|
||||
|
||||
const defenderActivated = outcome.metrics?.defenderActivatedSkills ?? [];
|
||||
|
||||
@@ -3,10 +3,7 @@ import crypto from 'node:crypto';
|
||||
import type { BattleSimJobPayload, BattleSimResultPayload, BattleSimTransportResponse } from './types.js';
|
||||
import type { BattleSimQueueKeys } from './keys.js';
|
||||
|
||||
type RedisBlPopResult =
|
||||
| { key: string; element: string }
|
||||
| [string, string]
|
||||
| null;
|
||||
type RedisBlPopResult = { key: string; element: string } | [string, string] | null;
|
||||
|
||||
interface RedisClientLike {
|
||||
rPush(key: string, value: string): Promise<number>;
|
||||
@@ -22,8 +19,7 @@ export interface RedisBattleSimTransportOptions {
|
||||
resultTtlSeconds: number;
|
||||
}
|
||||
|
||||
const toTimeoutSeconds = (timeoutMs: number): number =>
|
||||
Math.max(1, Math.ceil(timeoutMs / 1000));
|
||||
const toTimeoutSeconds = (timeoutMs: number): number => Math.max(1, Math.ceil(timeoutMs / 1000));
|
||||
|
||||
const parseBlPopValue = (result: RedisBlPopResult): string | null => {
|
||||
if (!result) {
|
||||
|
||||
@@ -6,15 +6,7 @@ import { parseUnitSetDefinition, type UnitSetDefinition } from '@sammo-ts/logic'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const DEFAULT_UNIT_SET_ROOT = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'game-engine',
|
||||
'resources',
|
||||
'unitset'
|
||||
);
|
||||
const DEFAULT_UNIT_SET_ROOT = path.resolve(__dirname, '..', '..', '..', 'game-engine', 'resources', 'unitset');
|
||||
|
||||
export interface UnitSetLoaderOptions {
|
||||
unitSetRoot?: string;
|
||||
@@ -26,20 +18,14 @@ const readJsonFile = async (filePath: string): Promise<unknown> => {
|
||||
return JSON.parse(raw) as unknown;
|
||||
};
|
||||
|
||||
const resolveUnitSetRoot = (options?: UnitSetLoaderOptions): string =>
|
||||
options?.unitSetRoot ?? DEFAULT_UNIT_SET_ROOT;
|
||||
const resolveUnitSetRoot = (options?: UnitSetLoaderOptions): string => options?.unitSetRoot ?? DEFAULT_UNIT_SET_ROOT;
|
||||
|
||||
export const resolveUnitSetDefinitionPath = (
|
||||
unitSetName: string,
|
||||
options?: UnitSetLoaderOptions
|
||||
): string => {
|
||||
export const resolveUnitSetDefinitionPath = (unitSetName: string, options?: UnitSetLoaderOptions): string => {
|
||||
const prefix = options?.filePrefix ?? 'unitset_';
|
||||
return path.resolve(resolveUnitSetRoot(options), `${prefix}${unitSetName}.json`);
|
||||
};
|
||||
|
||||
export const loadUnitSetDefinition = async (
|
||||
unitSetPath: string
|
||||
): Promise<UnitSetDefinition> => {
|
||||
export const loadUnitSetDefinition = async (unitSetPath: string): Promise<UnitSetDefinition> => {
|
||||
const raw = await readJsonFile(unitSetPath);
|
||||
return parseUnitSetDefinition(raw);
|
||||
};
|
||||
|
||||
@@ -6,10 +6,7 @@ import { processBattleSimJob } from './processor.js';
|
||||
import { RedisBattleSimTransport } from './redisTransport.js';
|
||||
import type { BattleSimJob } from './types.js';
|
||||
|
||||
type RedisBlPopResult =
|
||||
| { key: string; element: string }
|
||||
| [string, string]
|
||||
| null;
|
||||
type RedisBlPopResult = { key: string; element: string } | [string, string] | null;
|
||||
|
||||
const parseBlPopValue = (result: RedisBlPopResult): string | null => {
|
||||
if (!result) {
|
||||
@@ -57,8 +54,7 @@ export const runBattleSimWorker = async (): Promise<void> => {
|
||||
const result = processBattleSimJob(job.payload);
|
||||
await transport.pushResult(job.jobId, result);
|
||||
} catch (error) {
|
||||
const reason =
|
||||
error instanceof Error ? error.message : '전투 시뮬레이션 오류';
|
||||
const reason = error instanceof Error ? error.message : '전투 시뮬레이션 오류';
|
||||
await transport.pushResult(job.jobId, {
|
||||
result: false,
|
||||
reason,
|
||||
|
||||
@@ -23,9 +23,7 @@ const parseNumber = (value: string | undefined, fallback: number, label: string)
|
||||
return parsed;
|
||||
};
|
||||
|
||||
export const resolveGameApiConfigFromEnv = (
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): GameApiConfig => {
|
||||
export const resolveGameApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.env): GameApiConfig => {
|
||||
const profile = env.PROFILE ?? env.SERVER_PROFILE ?? 'hwe';
|
||||
const scenario = env.SCENARIO ?? 'default';
|
||||
const profileName = `${profile}:${scenario}`;
|
||||
@@ -42,21 +40,13 @@ export const resolveGameApiConfigFromEnv = (
|
||||
profile,
|
||||
scenario,
|
||||
profileName,
|
||||
daemonRequestTimeoutMs: parseNumber(
|
||||
env.DAEMON_REQUEST_TIMEOUT_MS,
|
||||
5000,
|
||||
'DAEMON_REQUEST_TIMEOUT_MS'
|
||||
),
|
||||
daemonRequestTimeoutMs: parseNumber(env.DAEMON_REQUEST_TIMEOUT_MS, 5000, 'DAEMON_REQUEST_TIMEOUT_MS'),
|
||||
battleSimRequestTimeoutMs: parseNumber(
|
||||
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: parseNumber(env.BATTLE_SIM_RESULT_TTL_SECONDS, 60, 'BATTLE_SIM_RESULT_TTL_SECONDS'),
|
||||
gameTokenSecret: secret,
|
||||
flushChannel: `${gatewayPrefix}:flush`,
|
||||
};
|
||||
|
||||
@@ -26,13 +26,13 @@ export const zWorldStateMeta = z.object({
|
||||
});
|
||||
export type WorldStateMeta = z.infer<typeof zWorldStateMeta>;
|
||||
|
||||
export type WorldStateRow = GamePrisma.WorldStateGetPayload<{}>;
|
||||
export type GeneralRow = GamePrisma.GeneralGetPayload<{}>;
|
||||
export type GeneralTurnRow = GamePrisma.GeneralTurnGetPayload<{}>;
|
||||
export type NationTurnRow = GamePrisma.NationTurnGetPayload<{}>;
|
||||
export type CityRow = GamePrisma.CityGetPayload<{}>;
|
||||
export type NationRow = GamePrisma.NationGetPayload<{}>;
|
||||
export type TroopRow = GamePrisma.TroopGetPayload<{}>;
|
||||
export type WorldStateRow = GamePrisma.WorldStateGetPayload<Record<string, never>>;
|
||||
export type GeneralRow = GamePrisma.GeneralGetPayload<Record<string, never>>;
|
||||
export type GeneralTurnRow = GamePrisma.GeneralTurnGetPayload<Record<string, never>>;
|
||||
export type NationTurnRow = GamePrisma.NationTurnGetPayload<Record<string, never>>;
|
||||
export type CityRow = GamePrisma.CityGetPayload<Record<string, never>>;
|
||||
export type NationRow = GamePrisma.NationGetPayload<Record<string, never>>;
|
||||
export type TroopRow = GamePrisma.TroopGetPayload<Record<string, never>>;
|
||||
|
||||
export type JsonValue = GamePrisma.JsonValue;
|
||||
export type InputJsonValue = GamePrisma.InputJsonValue;
|
||||
|
||||
@@ -27,10 +27,7 @@ export class InMemoryTurnDaemonTransport implements TurnDaemonTransport {
|
||||
|
||||
// 테스트용: 메모리 큐에 명령을 저장하고 requestId를 반환한다.
|
||||
async sendCommand(command: TurnDaemonCommand): Promise<string> {
|
||||
const requestId =
|
||||
command.type === 'getStatus' && command.requestId
|
||||
? command.requestId
|
||||
: randomUUID();
|
||||
const requestId = command.type === 'getStatus' && command.requestId ? command.requestId : randomUUID();
|
||||
this.commands.push({
|
||||
requestId,
|
||||
sentAt: new Date().toISOString(),
|
||||
@@ -39,10 +36,7 @@ export class InMemoryTurnDaemonTransport implements TurnDaemonTransport {
|
||||
return requestId;
|
||||
}
|
||||
|
||||
async requestCommand(
|
||||
command: TurnDaemonCommand,
|
||||
_timeoutMs?: number
|
||||
): Promise<TurnDaemonCommandResult | null> {
|
||||
async requestCommand(command: TurnDaemonCommand, _timeoutMs?: number): Promise<TurnDaemonCommandResult | null> {
|
||||
const requestId = await this.sendCommand(command);
|
||||
return this.results.get(requestId) ?? null;
|
||||
}
|
||||
|
||||
@@ -17,10 +17,7 @@ interface RedisTurnDaemonTransportOptions {
|
||||
|
||||
interface RedisClientLike {
|
||||
xAdd(stream: string, id: string, message: Record<string, string>): Promise<string>;
|
||||
xRead(
|
||||
streams: { key: string; id: string },
|
||||
options?: { BLOCK?: number; COUNT?: number }
|
||||
): Promise<unknown>;
|
||||
xRead(streams: { key: string; id: string }, options?: { BLOCK?: number; COUNT?: number }): Promise<unknown>;
|
||||
}
|
||||
|
||||
type RedisStreamReadResponse = Array<{
|
||||
@@ -29,10 +26,7 @@ type RedisStreamReadResponse = Array<{
|
||||
}>;
|
||||
|
||||
const buildCommandEnvelope = (command: TurnDaemonCommand): TurnDaemonCommandEnvelope => {
|
||||
const requestId =
|
||||
command.type === 'getStatus' && command.requestId
|
||||
? command.requestId
|
||||
: randomUUID();
|
||||
const requestId = command.type === 'getStatus' && command.requestId ? command.requestId : randomUUID();
|
||||
return {
|
||||
requestId,
|
||||
sentAt: new Date().toISOString(),
|
||||
@@ -79,10 +73,7 @@ export class RedisTurnDaemonTransport implements TurnDaemonTransport {
|
||||
return envelope.requestId;
|
||||
}
|
||||
|
||||
async requestCommand(
|
||||
command: TurnDaemonCommand,
|
||||
timeoutMs?: number
|
||||
): Promise<TurnDaemonCommandResult | null> {
|
||||
async requestCommand(command: TurnDaemonCommand, timeoutMs?: number): Promise<TurnDaemonCommandResult | null> {
|
||||
const requestId = await this.sendCommand(command);
|
||||
|
||||
const deadline = Date.now() + (timeoutMs ?? this.requestTimeoutMs);
|
||||
@@ -110,10 +101,7 @@ export class RedisTurnDaemonTransport implements TurnDaemonTransport {
|
||||
if (!envelope) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
envelope.event.type === 'commandResult' &&
|
||||
envelope.requestId === requestId
|
||||
) {
|
||||
if (envelope.event.type === 'commandResult' && envelope.requestId === requestId) {
|
||||
return envelope.event.result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import type {
|
||||
TurnDaemonCommand,
|
||||
TurnDaemonCommandResult,
|
||||
TurnDaemonStatus,
|
||||
} from './types.js';
|
||||
import type { TurnDaemonCommand, TurnDaemonCommandResult, TurnDaemonStatus } from './types.js';
|
||||
|
||||
export interface TurnDaemonTransport {
|
||||
sendCommand(command: TurnDaemonCommand): Promise<string>;
|
||||
requestCommand(
|
||||
command: TurnDaemonCommand,
|
||||
timeoutMs?: number
|
||||
): Promise<TurnDaemonCommandResult | null>;
|
||||
requestCommand(command: TurnDaemonCommand, timeoutMs?: number): Promise<TurnDaemonCommandResult | null>;
|
||||
requestStatus(timeoutMs?: number): Promise<TurnDaemonStatus | null>;
|
||||
}
|
||||
|
||||
@@ -31,8 +31,7 @@ const isMain = (): boolean => {
|
||||
|
||||
if (isMain()) {
|
||||
const role = process.env.GAME_API_ROLE ?? 'server';
|
||||
const run =
|
||||
role === 'battle-sim-worker' ? runBattleSimWorker : runGameApiServer;
|
||||
const run = role === 'battle-sim-worker' ? runBattleSimWorker : runGameApiServer;
|
||||
run().catch((error) => {
|
||||
console.error('[game-api] failed to start', error);
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -47,8 +47,7 @@ const BASE_MAP_TTL_SECONDS = 30;
|
||||
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 asRecord = (value: unknown): Record<string, unknown> => (isRecord(value) ? value : {});
|
||||
|
||||
const resolveStartYear = (worldState: WorldStateRow): number => {
|
||||
const meta = asRecord(worldState.meta);
|
||||
@@ -98,10 +97,7 @@ const resolveSpyList = (meta: Record<string, unknown>): Record<number, number> =
|
||||
const buildBaseMapCacheKey = (ctx: GameApiContext): string =>
|
||||
`sammo:map:base:${ctx.profile.id}:${ctx.profile.scenario}`;
|
||||
|
||||
const loadBaseMap = async (
|
||||
ctx: GameApiContext,
|
||||
useCache: boolean
|
||||
): Promise<BaseMapResult | null> => {
|
||||
const loadBaseMap = async (ctx: GameApiContext, useCache: boolean): Promise<BaseMapResult | null> => {
|
||||
const cacheKey = buildBaseMapCacheKey(ctx);
|
||||
if (useCache) {
|
||||
const cached = await ctx.redis.get(cacheKey);
|
||||
@@ -143,14 +139,7 @@ const loadBaseMap = async (
|
||||
const meta = asRecord(row.meta);
|
||||
const state = readState(meta);
|
||||
const supplyFlag = row.supplyState > 0 ? 1 : 0;
|
||||
return [
|
||||
row.id,
|
||||
row.level,
|
||||
state,
|
||||
row.nationId,
|
||||
row.region,
|
||||
supplyFlag,
|
||||
];
|
||||
return [row.id, row.level, state, row.nationId, row.region, supplyFlag];
|
||||
});
|
||||
|
||||
const nationList: MapNationCompact[] = nationRows.map((row) => [
|
||||
@@ -225,9 +214,7 @@ export const loadWorldMap = async (
|
||||
FROM general
|
||||
WHERE nation_id = ${myNation}
|
||||
`;
|
||||
shownByGeneralList = generalCities
|
||||
.map((row) => row.cityId)
|
||||
.filter((id) => Number.isFinite(id));
|
||||
shownByGeneralList = generalCities.map((row) => row.cityId).filter((id) => Number.isFinite(id));
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -50,10 +50,7 @@ const toMessageView = (row: MessageRow): MessageView => {
|
||||
};
|
||||
};
|
||||
|
||||
export const insertMessage = async (
|
||||
db: DatabaseClient,
|
||||
draft: MessageRecordDraft
|
||||
): Promise<number> => {
|
||||
export const insertMessage = async (db: DatabaseClient, draft: MessageRecordDraft): Promise<number> => {
|
||||
const rows = await db.$queryRaw<Array<{ id: number }>>`
|
||||
INSERT INTO message (mailbox, type, src, dest, time, valid_until, message)
|
||||
VALUES (
|
||||
|
||||
@@ -21,10 +21,7 @@ export const resolveNationInfo = async (
|
||||
return { name: nation.name, color: nation.color };
|
||||
};
|
||||
|
||||
export const buildTargetFromGeneral = async (
|
||||
db: DatabaseClient,
|
||||
general: GeneralRow
|
||||
): Promise<MessageTarget> => {
|
||||
export const buildTargetFromGeneral = async (db: DatabaseClient, general: GeneralRow): Promise<MessageTarget> => {
|
||||
const nation = await resolveNationInfo(db, general.nationId);
|
||||
return {
|
||||
generalId: general.id,
|
||||
@@ -36,11 +33,7 @@ export const buildTargetFromGeneral = async (
|
||||
};
|
||||
};
|
||||
|
||||
export const buildNationTarget = (
|
||||
nationId: number,
|
||||
nationName: string,
|
||||
color: string
|
||||
): MessageTarget => ({
|
||||
export const buildNationTarget = (nationId: number, nationName: string, color: string): MessageTarget => ({
|
||||
generalId: 0,
|
||||
generalName: '',
|
||||
nationId,
|
||||
|
||||
+123
-154
@@ -48,9 +48,9 @@ const zTurnRunBudget = z.object({
|
||||
catchUpCap: z.number().int().positive(),
|
||||
});
|
||||
|
||||
|
||||
const buildShiftAmountSchema = (maxTurns: number) =>
|
||||
z.number()
|
||||
z
|
||||
.number()
|
||||
.int()
|
||||
.min(-(maxTurns - 1))
|
||||
.max(maxTurns - 1)
|
||||
@@ -114,7 +114,7 @@ export const appRouter = router({
|
||||
if (ctx.auth?.user.id) {
|
||||
const general = await ctx.db.general.findFirst({
|
||||
where: { userId: ctx.auth.user.id },
|
||||
select: { name: true, picture: true }
|
||||
select: { name: true, picture: true },
|
||||
});
|
||||
if (general) {
|
||||
myGeneral = {
|
||||
@@ -143,33 +143,25 @@ export const appRouter = router({
|
||||
}),
|
||||
}),
|
||||
battle: router({
|
||||
simulate: procedure
|
||||
.input(zBattleSimRequest)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const worldState = await ctx.db.worldState.findFirst();
|
||||
if (!worldState) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'World state is not initialized.',
|
||||
});
|
||||
}
|
||||
simulate: procedure.input(zBattleSimRequest).mutation(async ({ ctx, input }) => {
|
||||
const worldState = await ctx.db.worldState.findFirst();
|
||||
if (!worldState) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'World state is not initialized.',
|
||||
});
|
||||
}
|
||||
|
||||
const payload = await buildBattleSimJobPayload(
|
||||
worldState,
|
||||
input,
|
||||
ctx.profile.id
|
||||
);
|
||||
return ctx.battleSim.simulate(payload);
|
||||
}),
|
||||
getSimulation: procedure
|
||||
.input(zBattleSimJobId)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const result = await ctx.battleSim.getSimulationResult(input.jobId);
|
||||
if (!result) {
|
||||
return { status: 'queued', jobId: input.jobId };
|
||||
}
|
||||
return { status: 'completed', jobId: input.jobId, payload: result };
|
||||
}),
|
||||
const payload = await buildBattleSimJobPayload(worldState, input, ctx.profile.id);
|
||||
return ctx.battleSim.simulate(payload);
|
||||
}),
|
||||
getSimulation: procedure.input(zBattleSimJobId).query(async ({ ctx, input }) => {
|
||||
const result = await ctx.battleSim.getSimulationResult(input.jobId);
|
||||
if (!result) {
|
||||
return { status: 'queued', jobId: input.jobId };
|
||||
}
|
||||
return { status: 'completed', jobId: input.jobId, payload: result };
|
||||
}),
|
||||
}),
|
||||
world: router({
|
||||
getState: procedure.query(async ({ ctx }) => {
|
||||
@@ -254,7 +246,8 @@ export const appRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
generalId: z.number().int().positive(),
|
||||
turnIndex: z.number()
|
||||
turnIndex: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(MAX_GENERAL_TURNS - 1),
|
||||
@@ -300,18 +293,15 @@ export const appRouter = router({
|
||||
});
|
||||
}
|
||||
|
||||
const turns = await shiftGeneralTurns(
|
||||
ctx.db,
|
||||
input.generalId,
|
||||
input.amount
|
||||
);
|
||||
const turns = await shiftGeneralTurns(ctx.db, input.generalId, input.amount);
|
||||
return { ok: true, turns };
|
||||
}),
|
||||
setNation: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
generalId: z.number().int().positive(),
|
||||
turnIndex: z.number()
|
||||
turnIndex: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(MAX_NATION_TURNS - 1),
|
||||
@@ -382,12 +372,7 @@ export const appRouter = router({
|
||||
});
|
||||
}
|
||||
|
||||
const turns = await shiftNationTurns(
|
||||
ctx.db,
|
||||
general.nationId,
|
||||
general.officerLevel,
|
||||
input.amount
|
||||
);
|
||||
const turns = await shiftNationTurns(ctx.db, general.nationId, general.officerLevel, input.amount);
|
||||
return { ok: true, turns };
|
||||
}),
|
||||
}),
|
||||
@@ -420,37 +405,36 @@ export const appRouter = router({
|
||||
diplomacy: MESSAGE_MAILBOX_NATIONAL_BASE + nationId,
|
||||
} satisfies Record<MessageType, number>;
|
||||
|
||||
const [privateMessages, publicMessages, nationalMessages, diplomacyMessages] =
|
||||
await Promise.all([
|
||||
fetchMessagesFromMailbox({
|
||||
db: ctx.db,
|
||||
mailbox: mailboxes.private,
|
||||
msgType: 'private',
|
||||
limit: 15,
|
||||
fromSeq: sequence,
|
||||
}),
|
||||
fetchMessagesFromMailbox({
|
||||
db: ctx.db,
|
||||
mailbox: mailboxes.public,
|
||||
msgType: 'public',
|
||||
limit: 15,
|
||||
fromSeq: sequence,
|
||||
}),
|
||||
fetchMessagesFromMailbox({
|
||||
db: ctx.db,
|
||||
mailbox: mailboxes.national,
|
||||
msgType: 'national',
|
||||
limit: 15,
|
||||
fromSeq: sequence,
|
||||
}),
|
||||
fetchMessagesFromMailbox({
|
||||
db: ctx.db,
|
||||
mailbox: mailboxes.diplomacy,
|
||||
msgType: 'diplomacy',
|
||||
limit: 15,
|
||||
fromSeq: sequence,
|
||||
}),
|
||||
]);
|
||||
const [privateMessages, publicMessages, nationalMessages, diplomacyMessages] = await Promise.all([
|
||||
fetchMessagesFromMailbox({
|
||||
db: ctx.db,
|
||||
mailbox: mailboxes.private,
|
||||
msgType: 'private',
|
||||
limit: 15,
|
||||
fromSeq: sequence,
|
||||
}),
|
||||
fetchMessagesFromMailbox({
|
||||
db: ctx.db,
|
||||
mailbox: mailboxes.public,
|
||||
msgType: 'public',
|
||||
limit: 15,
|
||||
fromSeq: sequence,
|
||||
}),
|
||||
fetchMessagesFromMailbox({
|
||||
db: ctx.db,
|
||||
mailbox: mailboxes.national,
|
||||
msgType: 'national',
|
||||
limit: 15,
|
||||
fromSeq: sequence,
|
||||
}),
|
||||
fetchMessagesFromMailbox({
|
||||
db: ctx.db,
|
||||
mailbox: mailboxes.diplomacy,
|
||||
msgType: 'diplomacy',
|
||||
limit: 15,
|
||||
fromSeq: sequence,
|
||||
}),
|
||||
]);
|
||||
|
||||
const messageBuckets: Record<MessageType, MessageView[]> = {
|
||||
private: privateMessages,
|
||||
@@ -481,20 +465,11 @@ export const appRouter = router({
|
||||
|
||||
if (lastType === 'private' && messageBuckets.private.length > 0) {
|
||||
messageBuckets.private.pop();
|
||||
} else if (
|
||||
lastType === 'public' &&
|
||||
messageBuckets.public.length > 0
|
||||
) {
|
||||
} else if (lastType === 'public' && messageBuckets.public.length > 0) {
|
||||
messageBuckets.public.pop();
|
||||
} else if (
|
||||
lastType === 'national' &&
|
||||
messageBuckets.national.length > 0
|
||||
) {
|
||||
} else if (lastType === 'national' && messageBuckets.national.length > 0) {
|
||||
messageBuckets.national.pop();
|
||||
} else if (
|
||||
lastType === 'diplomacy' &&
|
||||
messageBuckets.diplomacy.length > 0
|
||||
) {
|
||||
} else if (lastType === 'diplomacy' && messageBuckets.diplomacy.length > 0) {
|
||||
messageBuckets.diplomacy.pop();
|
||||
}
|
||||
|
||||
@@ -591,27 +566,16 @@ export const appRouter = router({
|
||||
if (input.mailbox === MESSAGE_MAILBOX_PUBLIC) {
|
||||
msgType = 'public';
|
||||
} else if (input.mailbox >= MESSAGE_MAILBOX_NATIONAL_BASE) {
|
||||
const destNationId =
|
||||
input.mailbox - MESSAGE_MAILBOX_NATIONAL_BASE;
|
||||
const destNationId = input.mailbox - MESSAGE_MAILBOX_NATIONAL_BASE;
|
||||
if (destNationId <= 0) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Invalid nation mailbox.',
|
||||
});
|
||||
}
|
||||
const nationInfo = await resolveNationInfo(
|
||||
ctx.db,
|
||||
destNationId
|
||||
);
|
||||
dest = buildNationTarget(
|
||||
destNationId,
|
||||
nationInfo.name,
|
||||
nationInfo.color
|
||||
);
|
||||
msgType =
|
||||
destNationId === general.nationId
|
||||
? 'national'
|
||||
: 'diplomacy';
|
||||
const nationInfo = await resolveNationInfo(ctx.db, destNationId);
|
||||
dest = buildNationTarget(destNationId, nationInfo.name, nationInfo.color);
|
||||
msgType = destNationId === general.nationId ? 'national' : 'diplomacy';
|
||||
} else if (input.mailbox > 0) {
|
||||
const destGeneral = await ctx.db.general.findUnique({
|
||||
where: { id: input.mailbox },
|
||||
@@ -643,8 +607,7 @@ export const appRouter = router({
|
||||
|
||||
const result = await sendMessage(
|
||||
{
|
||||
insertMessage: (draft: MessageRecordDraft) =>
|
||||
insertMessage(ctx.db, draft),
|
||||
insertMessage: (draft: MessageRecordDraft) => insertMessage(ctx.db, draft),
|
||||
},
|
||||
draft
|
||||
);
|
||||
@@ -777,47 +740,45 @@ export const appRouter = router({
|
||||
}
|
||||
return { ok: true };
|
||||
}),
|
||||
setMySetting: authedProcedure
|
||||
.input(zGeneralSettings)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'setMySetting',
|
||||
generalId: general.id,
|
||||
settings: input,
|
||||
});
|
||||
if (!result || result.type !== 'setMySetting') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
if (!result.ok) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
|
||||
}
|
||||
return { ok: true };
|
||||
}),
|
||||
dropItem: authedProcedure
|
||||
.input(z.object({ itemType: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'dropItem',
|
||||
generalId: general.id,
|
||||
itemType: input.itemType,
|
||||
});
|
||||
if (!result || result.type !== 'dropItem') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
if (!result.ok) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
|
||||
}
|
||||
return { ok: true };
|
||||
}),
|
||||
setMySetting: authedProcedure.input(zGeneralSettings).mutation(async ({ ctx, input }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'setMySetting',
|
||||
generalId: general.id,
|
||||
settings: input,
|
||||
});
|
||||
if (!result || result.type !== 'setMySetting') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
if (!result.ok) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
|
||||
}
|
||||
return { ok: true };
|
||||
}),
|
||||
dropItem: authedProcedure.input(z.object({ itemType: z.string() })).mutation(async ({ ctx, input }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'dropItem',
|
||||
generalId: general.id,
|
||||
itemType: input.itemType,
|
||||
});
|
||||
if (!result || result.type !== 'dropItem') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
if (!result.ok) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
|
||||
}
|
||||
return { ok: true };
|
||||
}),
|
||||
}),
|
||||
nation: router({
|
||||
changePermission: authedProcedure
|
||||
.input(z.object({
|
||||
isAmbassador: z.boolean(),
|
||||
targetGeneralIds: z.array(z.number().int().positive()),
|
||||
}))
|
||||
.input(
|
||||
z.object({
|
||||
isAmbassador: z.boolean(),
|
||||
targetGeneralIds: z.array(z.number().int().positive()),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
@@ -852,11 +813,13 @@ export const appRouter = router({
|
||||
return { ok: true };
|
||||
}),
|
||||
appoint: authedProcedure
|
||||
.input(z.object({
|
||||
destGeneralId: z.number().int().nonnegative(),
|
||||
destCityId: z.number().int().nonnegative(),
|
||||
officerLevel: z.number().int().nonnegative(),
|
||||
}))
|
||||
.input(
|
||||
z.object({
|
||||
destGeneralId: z.number().int().nonnegative(),
|
||||
destCityId: z.number().int().nonnegative(),
|
||||
officerLevel: z.number().int().nonnegative(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
@@ -895,9 +858,11 @@ export const appRouter = router({
|
||||
}),
|
||||
pause: procedure
|
||||
.input(
|
||||
z.object({
|
||||
reason: z.string().min(1).optional(),
|
||||
}).optional()
|
||||
z
|
||||
.object({
|
||||
reason: z.string().min(1).optional(),
|
||||
})
|
||||
.optional()
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const requestId = await ctx.turnDaemon.sendCommand({
|
||||
@@ -908,9 +873,11 @@ export const appRouter = router({
|
||||
}),
|
||||
resume: procedure
|
||||
.input(
|
||||
z.object({
|
||||
reason: z.string().min(1).optional(),
|
||||
}).optional()
|
||||
z
|
||||
.object({
|
||||
reason: z.string().min(1).optional(),
|
||||
})
|
||||
.optional()
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const requestId = await ctx.turnDaemon.sendCommand({
|
||||
@@ -921,9 +888,11 @@ export const appRouter = router({
|
||||
}),
|
||||
status: procedure
|
||||
.input(
|
||||
z.object({
|
||||
timeoutMs: z.number().int().positive().optional(),
|
||||
}).optional()
|
||||
z
|
||||
.object({
|
||||
timeoutMs: z.number().int().positive().optional(),
|
||||
})
|
||||
.optional()
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.turnDaemon.requestStatus(input?.timeoutMs);
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from '@sammo-ts/infra';
|
||||
|
||||
import { resolveGameApiConfigFromEnv } from './config.js';
|
||||
import { createGameApiContext, type DatabaseClient } from './context.js';
|
||||
import { createGameApiContext, type DatabaseClient as _DatabaseClient } from './context.js';
|
||||
import { buildTurnDaemonStreamKeys } from './daemon/streamKeys.js';
|
||||
import { RedisTurnDaemonTransport } from './daemon/redisTransport.js';
|
||||
import { InMemoryFlushStore, RedisGatewayFlushSubscriber } from './auth/flushStore.js';
|
||||
@@ -35,9 +35,7 @@ const extractBearerToken = (value: string | string[] | undefined): string | null
|
||||
|
||||
export const createGameApiServer = async () => {
|
||||
const config = resolveGameApiConfigFromEnv();
|
||||
const postgres = createGamePostgresConnector(
|
||||
resolvePostgresConfigFromEnv({ schema: config.profile })
|
||||
);
|
||||
const postgres = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: config.profile }));
|
||||
const redis = createRedisConnector(resolveRedisConfigFromEnv());
|
||||
|
||||
await postgres.connect();
|
||||
@@ -55,11 +53,7 @@ export const createGameApiServer = async () => {
|
||||
const flushStore = new InMemoryFlushStore();
|
||||
const flushSubscriberClient = redis.client.duplicate();
|
||||
await flushSubscriberClient.connect();
|
||||
const flushSubscriber = new RedisGatewayFlushSubscriber(
|
||||
flushSubscriberClient,
|
||||
config.flushChannel,
|
||||
flushStore
|
||||
);
|
||||
const flushSubscriber = new RedisGatewayFlushSubscriber(flushSubscriberClient, config.flushChannel, flushStore);
|
||||
await flushSubscriber.start();
|
||||
const tokenVerifier = createGameTokenVerifier({
|
||||
secret: config.gameTokenSecret,
|
||||
@@ -104,7 +98,6 @@ export const createGameApiServer = async () => {
|
||||
profile: config.profileName,
|
||||
}));
|
||||
|
||||
|
||||
app.addHook('onClose', async () => {
|
||||
await flushSubscriber.stop();
|
||||
await flushSubscriberClient.quit();
|
||||
|
||||
@@ -12,18 +12,9 @@ import type {
|
||||
TurnCommandEnv,
|
||||
TriggerValue,
|
||||
} from '@sammo-ts/logic';
|
||||
import {
|
||||
evaluateConstraints,
|
||||
loadGeneralTurnCommandSpecs,
|
||||
loadNationTurnCommandSpecs,
|
||||
} from '@sammo-ts/logic';
|
||||
import { evaluateConstraints, loadGeneralTurnCommandSpecs, loadNationTurnCommandSpecs } from '@sammo-ts/logic';
|
||||
|
||||
import type {
|
||||
CityRow,
|
||||
GeneralRow,
|
||||
NationRow,
|
||||
WorldStateRow,
|
||||
} from '../context.js';
|
||||
import type { CityRow, GeneralRow, NationRow, WorldStateRow } from '../context.js';
|
||||
import { loadTurnCommandProfile } from './turnCommandProfile.js';
|
||||
|
||||
type AvailabilityStatus = 'available' | 'blocked' | 'needsInput' | 'unknown';
|
||||
@@ -85,8 +76,7 @@ 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 asRecord = (value: unknown): Record<string, unknown> => (isRecord(value) ? value : {});
|
||||
|
||||
const asTriggerRecord = (value: unknown): Record<string, TriggerValue> =>
|
||||
isRecord(value) ? (value as Record<string, TriggerValue>) : {};
|
||||
@@ -143,11 +133,7 @@ class MemoryStateView implements StateView {
|
||||
}
|
||||
}
|
||||
|
||||
const resolveNumber = (
|
||||
source: Record<string, unknown>,
|
||||
keys: string[],
|
||||
fallback: number
|
||||
): number => {
|
||||
const resolveNumber = (source: Record<string, unknown>, keys: string[], fallback: number): number => {
|
||||
for (const key of keys) {
|
||||
const value = source[key];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
@@ -157,10 +143,7 @@ const resolveNumber = (
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const resolveOptionalString = (
|
||||
source: Record<string, unknown>,
|
||||
keys: string[]
|
||||
): string | null => {
|
||||
const resolveOptionalString = (source: Record<string, unknown>, keys: string[]): string | null => {
|
||||
for (const key of keys) {
|
||||
const value = source[key];
|
||||
if (typeof value === 'string') {
|
||||
@@ -175,106 +158,36 @@ const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => {
|
||||
const constValues = asRecord(config.const);
|
||||
|
||||
return {
|
||||
develCost: resolveNumber(
|
||||
constValues,
|
||||
['develCost', 'develcost', 'develrate'],
|
||||
0
|
||||
),
|
||||
develCost: resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0),
|
||||
trainDelta: resolveNumber(constValues, ['trainDelta'], 0),
|
||||
atmosDelta: resolveNumber(constValues, ['atmosDelta'], 0),
|
||||
maxTrainByCommand: resolveNumber(constValues, ['maxTrainByCommand'], 0),
|
||||
maxAtmosByCommand: resolveNumber(
|
||||
constValues,
|
||||
['maxAtmosByCommand'],
|
||||
0
|
||||
),
|
||||
sabotageDefaultProb: resolveNumber(
|
||||
constValues,
|
||||
['sabotageDefaultProb'],
|
||||
0
|
||||
),
|
||||
sabotageProbCoefByStat: resolveNumber(
|
||||
constValues,
|
||||
['sabotageProbCoefByStat'],
|
||||
0
|
||||
),
|
||||
sabotageDefenceCoefByGeneralCount: resolveNumber(
|
||||
constValues,
|
||||
['sabotageDefenceCoefByGeneralCount'],
|
||||
0
|
||||
),
|
||||
sabotageDamageMin: resolveNumber(
|
||||
constValues,
|
||||
['sabotageDamageMin'],
|
||||
0
|
||||
),
|
||||
sabotageDamageMax: resolveNumber(
|
||||
constValues,
|
||||
['sabotageDamageMax'],
|
||||
0
|
||||
),
|
||||
openingPartYear: resolveNumber(
|
||||
constValues,
|
||||
['openingPartYear'],
|
||||
0
|
||||
),
|
||||
maxGeneral: resolveNumber(
|
||||
constValues,
|
||||
['defaultMaxGeneral', 'maxGeneral'],
|
||||
0
|
||||
),
|
||||
defaultNpcGold: resolveNumber(
|
||||
constValues,
|
||||
['defaultNpcGold', 'defaultGold'],
|
||||
DEFAULT_GENERAL_GOLD
|
||||
),
|
||||
defaultNpcRice: resolveNumber(
|
||||
constValues,
|
||||
['defaultNpcRice', 'defaultRice'],
|
||||
DEFAULT_GENERAL_RICE
|
||||
),
|
||||
defaultCrewTypeId: resolveNumber(
|
||||
constValues,
|
||||
['defaultCrewTypeId'],
|
||||
DEFAULT_CREW_TYPE_ID
|
||||
),
|
||||
defaultSpecialDomestic: resolveOptionalString(
|
||||
constValues,
|
||||
['defaultSpecialDomestic']
|
||||
),
|
||||
defaultSpecialWar: resolveOptionalString(
|
||||
constValues,
|
||||
['defaultSpecialWar']
|
||||
),
|
||||
initialNationGenLimit: resolveNumber(
|
||||
constValues,
|
||||
['initialNationGenLimit'],
|
||||
0
|
||||
),
|
||||
maxAtmosByCommand: resolveNumber(constValues, ['maxAtmosByCommand'], 0),
|
||||
sabotageDefaultProb: resolveNumber(constValues, ['sabotageDefaultProb'], 0),
|
||||
sabotageProbCoefByStat: resolveNumber(constValues, ['sabotageProbCoefByStat'], 0),
|
||||
sabotageDefenceCoefByGeneralCount: resolveNumber(constValues, ['sabotageDefenceCoefByGeneralCount'], 0),
|
||||
sabotageDamageMin: resolveNumber(constValues, ['sabotageDamageMin'], 0),
|
||||
sabotageDamageMax: resolveNumber(constValues, ['sabotageDamageMax'], 0),
|
||||
openingPartYear: resolveNumber(constValues, ['openingPartYear'], 0),
|
||||
maxGeneral: resolveNumber(constValues, ['defaultMaxGeneral', 'maxGeneral'], 0),
|
||||
defaultNpcGold: resolveNumber(constValues, ['defaultNpcGold', 'defaultGold'], DEFAULT_GENERAL_GOLD),
|
||||
defaultNpcRice: resolveNumber(constValues, ['defaultNpcRice', 'defaultRice'], DEFAULT_GENERAL_RICE),
|
||||
defaultCrewTypeId: resolveNumber(constValues, ['defaultCrewTypeId'], DEFAULT_CREW_TYPE_ID),
|
||||
defaultSpecialDomestic: resolveOptionalString(constValues, ['defaultSpecialDomestic']),
|
||||
defaultSpecialWar: resolveOptionalString(constValues, ['defaultSpecialWar']),
|
||||
initialNationGenLimit: resolveNumber(constValues, ['initialNationGenLimit'], 0),
|
||||
maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], 0),
|
||||
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], 0),
|
||||
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], 0),
|
||||
maxResourceActionAmount: resolveNumber(
|
||||
constValues,
|
||||
['maxResourceActionAmount'],
|
||||
0
|
||||
),
|
||||
maxResourceActionAmount: resolveNumber(constValues, ['maxResourceActionAmount'], 0),
|
||||
};
|
||||
};
|
||||
|
||||
const buildConstraintEnv = (
|
||||
worldState: WorldStateRow
|
||||
): Record<string, unknown> => {
|
||||
const buildConstraintEnv = (worldState: WorldStateRow): Record<string, unknown> => {
|
||||
const meta = asRecord(worldState.meta);
|
||||
const scenarioMeta = asRecord(meta.scenarioMeta);
|
||||
const startYear =
|
||||
typeof scenarioMeta.startYear === 'number'
|
||||
? scenarioMeta.startYear
|
||||
: undefined;
|
||||
const relYear =
|
||||
typeof startYear === 'number'
|
||||
? worldState.currentYear - startYear
|
||||
: undefined;
|
||||
const startYear = typeof scenarioMeta.startYear === 'number' ? scenarioMeta.startYear : undefined;
|
||||
const relYear = typeof startYear === 'number' ? worldState.currentYear - startYear : undefined;
|
||||
|
||||
return {
|
||||
currentYear: worldState.currentYear,
|
||||
@@ -331,10 +244,7 @@ const mapGeneralRow = (row: GeneralRow): General => ({
|
||||
|
||||
const mapCityRow = (row: CityRow): City => {
|
||||
const meta = asTriggerRecord(row.meta);
|
||||
const state =
|
||||
typeof meta.state === 'number' && Number.isFinite(meta.state)
|
||||
? Math.floor(meta.state)
|
||||
: 0;
|
||||
const state = typeof meta.state === 'number' && Number.isFinite(meta.state) ? Math.floor(meta.state) : 0;
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
@@ -420,11 +330,7 @@ const evaluateAvailability = (
|
||||
}
|
||||
const missingKinds = new Set(result.missing.map((req) => req.kind));
|
||||
const inputOnlyMissing =
|
||||
missingKinds.size === 0
|
||||
? reqArg
|
||||
: Array.from(missingKinds).every((kind) =>
|
||||
INPUT_REQUIREMENT_KINDS.has(kind)
|
||||
);
|
||||
missingKinds.size === 0 ? reqArg : Array.from(missingKinds).every((kind) => INPUT_REQUIREMENT_KINDS.has(kind));
|
||||
if (inputOnlyMissing) {
|
||||
return {
|
||||
possible: true,
|
||||
@@ -450,20 +356,12 @@ const evaluateDefinition = (
|
||||
return evaluateAvailability(constraints, ctx, view, reqArg);
|
||||
};
|
||||
|
||||
const pickAvailability = (
|
||||
lhs: AvailabilityCore,
|
||||
rhs: AvailabilityCore
|
||||
): AvailabilityCore =>
|
||||
AVAILABILITY_PRIORITY[lhs.status] >= AVAILABILITY_PRIORITY[rhs.status]
|
||||
? lhs
|
||||
: rhs;
|
||||
const pickAvailability = (lhs: AvailabilityCore, rhs: AvailabilityCore): AvailabilityCore =>
|
||||
AVAILABILITY_PRIORITY[lhs.status] >= AVAILABILITY_PRIORITY[rhs.status] ? lhs : rhs;
|
||||
|
||||
type TurnCommandSpec = GeneralTurnCommandSpec | NationTurnCommandSpec;
|
||||
|
||||
const buildEntries = (
|
||||
env: CommandEnv,
|
||||
specs: TurnCommandSpec[]
|
||||
): CommandEntry[] => {
|
||||
const buildEntries = (env: CommandEnv, specs: TurnCommandSpec[]): CommandEntry[] => {
|
||||
const entries: CommandEntry[] = [];
|
||||
|
||||
for (const spec of specs) {
|
||||
@@ -477,20 +375,16 @@ const buildEntries = (
|
||||
|
||||
if (spec.key === 'che_포상') {
|
||||
entry.evaluate = (ctx, view) => {
|
||||
const gold = evaluateDefinition(
|
||||
definition,
|
||||
ctx,
|
||||
view,
|
||||
true,
|
||||
{ isGold: true, amount: 1, destGeneralId: 0 }
|
||||
);
|
||||
const rice = evaluateDefinition(
|
||||
definition,
|
||||
ctx,
|
||||
view,
|
||||
true,
|
||||
{ isGold: false, amount: 1, destGeneralId: 0 }
|
||||
);
|
||||
const gold = evaluateDefinition(definition, ctx, view, true, {
|
||||
isGold: true,
|
||||
amount: 1,
|
||||
destGeneralId: 0,
|
||||
});
|
||||
const rice = evaluateDefinition(definition, ctx, view, true, {
|
||||
isGold: false,
|
||||
amount: 1,
|
||||
destGeneralId: 0,
|
||||
});
|
||||
return pickAvailability(gold, rice);
|
||||
};
|
||||
}
|
||||
@@ -501,23 +395,13 @@ const buildEntries = (
|
||||
return entries;
|
||||
};
|
||||
|
||||
const buildGroups = (
|
||||
entries: CommandEntry[],
|
||||
ctx: ConstraintContext,
|
||||
view: StateView
|
||||
): TurnCommandGroup[] => {
|
||||
const buildGroups = (entries: CommandEntry[], ctx: ConstraintContext, view: StateView): TurnCommandGroup[] => {
|
||||
const groups = new Map<string, TurnCommandAvailability[]>();
|
||||
|
||||
for (const entry of entries) {
|
||||
const availability = entry.evaluate
|
||||
? entry.evaluate(ctx, view)
|
||||
: evaluateDefinition(
|
||||
entry.definition,
|
||||
ctx,
|
||||
view,
|
||||
entry.reqArg,
|
||||
entry.args
|
||||
);
|
||||
: evaluateDefinition(entry.definition, ctx, view, entry.reqArg, entry.args);
|
||||
const value: TurnCommandAvailability = {
|
||||
key: entry.definition.key,
|
||||
name: entry.definition.name,
|
||||
@@ -550,9 +434,7 @@ export const buildTurnCommandTable = async (options: {
|
||||
const general = mapGeneralRow(options.general);
|
||||
const city = options.city ? mapCityRow(options.city) : null;
|
||||
const nation = options.nation ? mapNationRow(options.nation) : null;
|
||||
const generalList = options.nationGenerals
|
||||
? options.nationGenerals.map(mapGeneralRow)
|
||||
: null;
|
||||
const generalList = options.nationGenerals ? options.nationGenerals.map(mapGeneralRow) : null;
|
||||
const view = buildStateView(general, city, nation, generalList);
|
||||
|
||||
const ctx: ConstraintContext = {
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import type {
|
||||
DatabaseClient,
|
||||
GeneralTurnRow,
|
||||
NationTurnRow,
|
||||
InputJsonValue,
|
||||
} from '../context.js';
|
||||
import type { DatabaseClient, GeneralTurnRow, NationTurnRow, InputJsonValue } from '../context.js';
|
||||
|
||||
export const DEFAULT_TURN_ACTION = '휴식';
|
||||
export const MAX_GENERAL_TURNS = 30;
|
||||
@@ -26,21 +21,16 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
const normalizeAction = (action: string | null | undefined): string =>
|
||||
action && action.length > 0 ? action : DEFAULT_TURN_ACTION;
|
||||
|
||||
const normalizeArgs = (args: unknown): InputJsonValue =>
|
||||
isRecord(args) ? (args as InputJsonValue) : {};
|
||||
const normalizeArgs = (args: unknown): InputJsonValue => (isRecord(args) ? (args as InputJsonValue) : {});
|
||||
|
||||
const createDefaultEntry = (): ReservedTurnEntry => ({
|
||||
action: DEFAULT_TURN_ACTION,
|
||||
args: {},
|
||||
});
|
||||
|
||||
const buildDefaultTurns = (length: number): ReservedTurnEntry[] =>
|
||||
Array.from({ length }, () => createDefaultEntry());
|
||||
const buildDefaultTurns = (length: number): ReservedTurnEntry[] => Array.from({ length }, () => createDefaultEntry());
|
||||
|
||||
const applyShift = (
|
||||
turns: ReservedTurnEntry[],
|
||||
amount: number
|
||||
): ReservedTurnEntry[] => {
|
||||
const applyShift = (turns: ReservedTurnEntry[], amount: number): ReservedTurnEntry[] => {
|
||||
if (amount === 0) {
|
||||
return turns.slice();
|
||||
}
|
||||
@@ -55,10 +45,7 @@ const applyShift = (
|
||||
return sliced.concat(padding);
|
||||
};
|
||||
|
||||
const buildTurnListFromRows = (
|
||||
rows: Array<GeneralTurnRow | NationTurnRow>,
|
||||
maxTurns: number
|
||||
): ReservedTurnEntry[] => {
|
||||
const buildTurnListFromRows = (rows: Array<GeneralTurnRow | NationTurnRow>, maxTurns: number): ReservedTurnEntry[] => {
|
||||
const result = buildDefaultTurns(maxTurns);
|
||||
for (const row of rows) {
|
||||
if (row.turnIdx < 0 || row.turnIdx >= maxTurns) {
|
||||
@@ -113,10 +100,7 @@ const persistNationTurns = async (
|
||||
});
|
||||
};
|
||||
|
||||
export const loadGeneralTurns = async (
|
||||
db: DatabaseClient,
|
||||
generalId: number
|
||||
): Promise<ReservedTurnEntry[]> => {
|
||||
export const loadGeneralTurns = async (db: DatabaseClient, generalId: number): Promise<ReservedTurnEntry[]> => {
|
||||
const rows = await db.generalTurn.findMany({
|
||||
where: { generalId },
|
||||
orderBy: [{ turnIdx: 'asc' }],
|
||||
|
||||
@@ -2,21 +2,12 @@ import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
DEFAULT_TURN_COMMAND_PROFILE,
|
||||
parseTurnCommandProfile,
|
||||
type TurnCommandProfile,
|
||||
} from '@sammo-ts/logic';
|
||||
import { DEFAULT_TURN_COMMAND_PROFILE, parseTurnCommandProfile, type TurnCommandProfile } from '@sammo-ts/logic';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..', '..');
|
||||
const DEFAULT_PROFILE_PATH = path.resolve(
|
||||
REPO_ROOT,
|
||||
'resources',
|
||||
'turn-commands',
|
||||
'default.json'
|
||||
);
|
||||
const DEFAULT_PROFILE_PATH = path.resolve(REPO_ROOT, 'resources', 'turn-commands', 'default.json');
|
||||
|
||||
export interface TurnCommandProfileOptions {
|
||||
filePath?: string;
|
||||
@@ -27,11 +18,8 @@ const readCommandProfile = async (filePath: string): Promise<TurnCommandProfile>
|
||||
return parseTurnCommandProfile(JSON.parse(raw) as unknown);
|
||||
};
|
||||
|
||||
export const loadTurnCommandProfile = async (
|
||||
options?: TurnCommandProfileOptions
|
||||
): Promise<TurnCommandProfile> => {
|
||||
const filePath =
|
||||
options?.filePath ?? process.env.TURN_COMMANDS_PATH ?? DEFAULT_PROFILE_PATH;
|
||||
export const loadTurnCommandProfile = async (options?: TurnCommandProfileOptions): Promise<TurnCommandProfile> => {
|
||||
const filePath = options?.filePath ?? process.env.TURN_COMMANDS_PATH ?? DEFAULT_PROFILE_PATH;
|
||||
try {
|
||||
return await readCommandProfile(filePath);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type {
|
||||
DatabaseClient,
|
||||
GeneralTurnRow,
|
||||
NationTurnRow,
|
||||
} from '../src/context.js';
|
||||
import type { DatabaseClient, GeneralTurnRow, NationTurnRow } from '../src/context.js';
|
||||
import {
|
||||
MAX_GENERAL_TURNS,
|
||||
MAX_NATION_TURNS,
|
||||
@@ -53,8 +49,7 @@ const buildDb = () => {
|
||||
},
|
||||
},
|
||||
nationTurn: {
|
||||
findMany: async ({ where }: any) =>
|
||||
nationTurns.get(`${where.nationId}:${where.officerLevel}`) ?? [],
|
||||
findMany: async ({ where }: any) => nationTurns.get(`${where.nationId}:${where.officerLevel}`) ?? [],
|
||||
deleteMany: async ({ where }: any) => {
|
||||
nationTurns.delete(`${where.nationId}:${where.officerLevel}`);
|
||||
return {};
|
||||
@@ -85,13 +80,7 @@ describe('reservedTurns', () => {
|
||||
it('sets and shifts general turns', async () => {
|
||||
const { db } = buildDb();
|
||||
|
||||
const initial = await setGeneralTurn(
|
||||
db,
|
||||
1,
|
||||
0,
|
||||
'che_화계',
|
||||
{ destCityId: 10 }
|
||||
);
|
||||
const initial = await setGeneralTurn(db, 1, 0, 'che_화계', { destCityId: 10 });
|
||||
|
||||
expect(initial).toHaveLength(MAX_GENERAL_TURNS);
|
||||
expect(initial[0]?.action).toBe('che_화계');
|
||||
@@ -108,14 +97,7 @@ describe('reservedTurns', () => {
|
||||
it('sets and shifts nation turns', async () => {
|
||||
const { db } = buildDb();
|
||||
|
||||
const initial = await setNationTurn(
|
||||
db,
|
||||
2,
|
||||
5,
|
||||
0,
|
||||
'che_포상',
|
||||
{ isGold: true, amount: 200, destGeneralId: 7 }
|
||||
);
|
||||
const initial = await setNationTurn(db, 2, 5, 0, 'che_포상', { isGold: true, amount: 200, destGeneralId: 7 });
|
||||
|
||||
expect(initial).toHaveLength(MAX_NATION_TURNS);
|
||||
expect(initial[0]?.action).toBe('che_포상');
|
||||
|
||||
@@ -43,11 +43,12 @@ const buildContext = (options?: {
|
||||
},
|
||||
};
|
||||
return {
|
||||
db,
|
||||
db: db as any,
|
||||
turnDaemon: transport,
|
||||
battleSim,
|
||||
profile,
|
||||
auth: null,
|
||||
redis: {} as any,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -65,6 +66,7 @@ describe('appRouter', () => {
|
||||
|
||||
it('returns world state snapshots', async () => {
|
||||
const state: WorldStateRow = {
|
||||
id: 1,
|
||||
scenarioCode: 'default',
|
||||
currentYear: 1,
|
||||
currentMonth: 2,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"extends": "../../tsconfig.paths.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"composite": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
@@ -14,7 +13,7 @@
|
||||
"@sammo-ts/logic/*": ["../../packages/logic/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"include": ["src", "test", "*.ts"],
|
||||
"references": [
|
||||
{ "path": "../../packages/common" },
|
||||
{ "path": "../../packages/infra" },
|
||||
|
||||
Reference in New Issue
Block a user