From 35d8824b95ed3faebf163176abdb0b091b6b2c11 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 15 Aug 2026 06:46:52 +0000 Subject: [PATCH] refactor: tighten backend and logic boundaries --- app/game-api/src/auction/scheduler.ts | 55 -- app/game-api/src/auth/tokenVerifier.ts | 45 - app/game-api/src/battleSim/schema.ts | 6 +- app/game-api/src/daemon/redisTransport.ts | 152 --- app/game-api/src/index.ts | 2 - app/game-api/src/router/join/index.ts | 6 +- app/game-api/src/router/lobby/index.ts | 2 +- app/game-api/src/router/nation/shared.ts | 4 +- app/game-api/src/services/inheritance.ts | 31 +- app/game-api/src/services/selectPool.ts | 12 - app/game-api/src/tournament/workerHelpers.ts | 114 ++- app/game-api/src/trpc.ts | 7 - app/game-api/src/turns/reservedTurns.ts | 20 +- app/game-api/test/selectPoolRng.test.ts | 32 +- app/game-engine/src/index.ts | 1 - app/game-engine/src/lifecycle/clock.ts | 1 - app/game-engine/src/scenario/mapLoader.ts | 4 +- .../src/scenario/scenarioLoader.ts | 6 +- app/game-engine/src/scenario/unitSetLoader.ts | 4 +- app/game-engine/src/turn/ai/aiUtils.ts | 34 +- .../src/turn/ai/generalAi/general/index.ts | 25 +- .../src/turn/ai/generalAi/nation/index.ts | 23 - .../src/turn/inheritancePointCalculation.ts | 2 +- app/game-engine/src/turn/turnDaemon.ts | 879 ++++++++++-------- .../test/inputEventAtomicity.test.ts | 3 +- .../test/monthlyNationLevelAction.test.ts | 6 +- ...eClockShiftPersistence.integration.test.ts | 2 +- .../test/turnDaemonLifecycle.test.ts | 2 +- app/gateway-api/src/account/router.ts | 7 - .../src/auth/inMemoryUserRepository.ts | 4 +- .../src/auth/kakaoAccountVerification.ts | 8 +- app/gateway-api/src/auth/passwordHasher.ts | 3 - .../src/auth/postgresUserRepository.ts | 8 +- app/gateway-api/src/auth/registrationInput.ts | 2 +- app/gateway-api/src/index.ts | 3 +- .../src/orchestrator/buildRunner.ts | 2 +- .../src/orchestrator/gatewayOrchestrator.ts | 4 +- .../orchestrator/gatewayReleaseRepository.ts | 5 +- .../src/orchestrator/profileRepository.ts | 11 +- app/gateway-api/src/profileOrder.ts | 2 +- .../src/releaseController.ts | 2 +- app/release-controller/src/selfUpgrade.ts | 2 +- docs/architecture/package-boundaries.md | 20 + packages/common/src/auth/sanctions.ts | 2 +- packages/common/src/realtime/types.ts | 46 +- packages/common/src/util/TournamentRNG.ts | 3 - packages/common/src/util/sha512.ts | 2 +- packages/infra/src/errorLogRepository.ts | 68 -- packages/infra/src/index.ts | 2 - packages/infra/src/logRepository.ts | 112 --- packages/logic/src/actions/admin/index.ts | 1 - packages/logic/src/actions/engine.ts | 291 +++--- packages/logic/src/actions/index.ts | 3 - .../src/actions/instant/general/index.ts | 1 - .../logic/src/actions/instant/nation/index.ts | 15 - .../src/actions/turn/general/che_등용수락.ts | 4 +- .../src/actions/turn/general/che_장비매매.ts | 6 +- .../actions/turn/general/cityDevelopment.ts | 102 -- .../logic/src/actions/turn/general/index.ts | 2 +- packages/logic/src/constraints/city.ts | 47 +- packages/logic/src/constraints/evaluate.ts | 10 +- packages/logic/src/constraints/general.ts | 98 -- packages/logic/src/constraints/misc.ts | 3 - packages/logic/src/constraints/nation.ts | 19 - packages/logic/src/crewType/registry.ts | 2 - packages/logic/src/diplomacy/index.ts | 1 - packages/logic/src/economy/nationIncome.ts | 10 +- packages/logic/src/index.ts | 1 - packages/logic/src/items/index.ts | 13 - packages/logic/src/items/utils.ts | 80 +- packages/logic/src/messages/message.ts | 6 +- packages/logic/src/resources/mapSchema.ts | 6 +- .../logic/src/resources/scenarioSchema.ts | 4 +- packages/logic/src/resources/unitSetSchema.ts | 4 +- packages/logic/src/rewards/uniqueLottery.ts | 5 +- packages/logic/src/scenario/scenarioEffect.ts | 2 +- packages/logic/src/triggers/general.ts | 2 +- packages/logic/src/turn/calendar.ts | 2 +- packages/logic/src/war/crewTypeTriggers.ts | 16 +- packages/logic/src/war/triggers/che_계략.ts | 22 +- packages/logic/src/war/triggers/che_회피.ts | 10 +- packages/logic/src/war/triggers/index.ts | 16 +- packages/logic/src/war/units.ts | 2 +- packages/logic/src/war/utils.ts | 24 - packages/logic/src/world/index.ts | 1 - packages/logic/src/world/loader.ts | 63 -- 86 files changed, 820 insertions(+), 1872 deletions(-) delete mode 100644 app/game-api/src/auth/tokenVerifier.ts delete mode 100644 app/game-api/src/daemon/redisTransport.ts delete mode 100644 app/game-api/src/services/selectPool.ts delete mode 100644 app/game-engine/src/lifecycle/clock.ts delete mode 100644 packages/infra/src/errorLogRepository.ts delete mode 100644 packages/infra/src/logRepository.ts delete mode 100644 packages/logic/src/actions/admin/index.ts delete mode 100644 packages/logic/src/actions/instant/general/index.ts delete mode 100644 packages/logic/src/actions/instant/nation/index.ts delete mode 100644 packages/logic/src/actions/turn/general/cityDevelopment.ts delete mode 100644 packages/logic/src/world/loader.ts diff --git a/app/game-api/src/auction/scheduler.ts b/app/game-api/src/auction/scheduler.ts index eb83924e..d0354491 100644 --- a/app/game-api/src/auction/scheduler.ts +++ b/app/game-api/src/auction/scheduler.ts @@ -7,14 +7,6 @@ import { loadCurrentGameTime, type CurrentGameTime } from '../services/gameClock interface RedisSortedSetClient { zAdd(key: string, values: Array<{ score: number; value: string }>): Promise; - zRem(key: string, values: string | string[]): Promise; -} - -export interface AuctionEventUpdate { - auctionId: number; - closeAt: Date; - eventId: string; - eventAt: Date; } export const resolveAuctionTimerScore = (time: CurrentGameTime, closeAt: Date, closeTick?: bigint | null): number => { @@ -58,50 +50,3 @@ export const seedAuctionTimers = async ( await redis.zAdd(keys.timerKey, payload); return payload.length; }; - -export const applyAuctionEvent = async ( - db: DatabaseClient, - redis: RedisSortedSetClient, - keys: AuctionTimerKeys, - event: AuctionEventUpdate -): Promise => { - const now = new Date(); - const gameTime = await loadCurrentGameTime(db, now); - const closeTick = gameTime.dateToTick(event.closeAt); - const updated = await db.$executeRaw( - GamePrisma.sql` - UPDATE auction - SET close_at = ${event.closeAt}, - close_tick = ${closeTick === null ? null : BigInt(closeTick)}, - latest_event_id = ${event.eventId}, - latest_event_at = ${event.eventAt}, - updated_at = ${now} - WHERE id = ${event.auctionId} - AND status = 'OPEN' - AND ( - latest_event_at < ${event.eventAt} - OR (latest_event_at = ${event.eventAt} AND latest_event_id < ${event.eventId}) - ) - ` - ); - - if (updated > 0) { - await redis.zAdd(keys.timerKey, [ - { - score: resolveAuctionTimerScore(gameTime, event.closeAt, closeTick === null ? null : BigInt(closeTick)), - value: String(event.auctionId), - }, - ]); - return true; - } - - return false; -}; - -export const removeAuctionTimer = async ( - redis: RedisSortedSetClient, - keys: AuctionTimerKeys, - auctionId: number -): Promise => { - await redis.zRem(keys.timerKey, String(auctionId)); -}; diff --git a/app/game-api/src/auth/tokenVerifier.ts b/app/game-api/src/auth/tokenVerifier.ts deleted file mode 100644 index 71426cbb..00000000 --- a/app/game-api/src/auth/tokenVerifier.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; -import { decryptGameSessionToken } from '@sammo-ts/common/auth/gameToken'; -import { isAfter, isValid, parseISO } from 'date-fns'; - -import type { FlushStore } from './flushStore.js'; - -export interface GameTokenVerifier { - verify(token: string): GameSessionTokenPayload | null; -} - -const parseDate = (value: string): Date | null => { - const parsed = parseISO(value); - return isValid(parsed) ? parsed : null; -}; - -export const createGameTokenVerifier = (options: { - secret: string; - profileName: string; - flushStore: FlushStore; -}): GameTokenVerifier => { - return { - verify: (token: string): GameSessionTokenPayload | null => { - const payload = decryptGameSessionToken(token, options.secret); - if (!payload) { - return null; - } - if (payload.profile !== options.profileName) { - return null; - } - const expiresAt = parseDate(payload.expiresAt); - const issuedAt = parseDate(payload.issuedAt); - if (!expiresAt || !issuedAt) { - return null; - } - if (isAfter(new Date(), expiresAt)) { - return null; - } - const flushedAt = options.flushStore.getFlushedAt(payload.user.id); - if (flushedAt && issuedAt <= flushedAt) { - return null; - } - return payload; - }, - }; -}; diff --git a/app/game-api/src/battleSim/schema.ts b/app/game-api/src/battleSim/schema.ts index 9ad999ba..09fb8d9b 100644 --- a/app/game-api/src/battleSim/schema.ts +++ b/app/game-api/src/battleSim/schema.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import type { BattleSimRequestPayload } from './types.js'; -export const zBattleSimGeneral = z.object({ +const zBattleSimGeneral = z.object({ no: z.number().int().positive(), name: z.string().min(1), nation: z.number().int().positive(), @@ -46,7 +46,7 @@ export const zBattleSimGeneral = z.object({ inheritBuff: z.union([z.record(z.string(), z.number()), z.array(z.number())]).optional(), }); -export const zBattleSimCity = z.object({ +const zBattleSimCity = z.object({ city: z.number().int().positive(), nation: z.number().int().min(0), supply: z.number().int().min(0), @@ -70,7 +70,7 @@ export const zBattleSimCity = z.object({ conflict: z.string(), }); -export const zBattleSimNation = z.object({ +const zBattleSimNation = z.object({ type: z.string().min(1), tech: z.number().min(0), level: z.number().int().min(0), diff --git a/app/game-api/src/daemon/redisTransport.ts b/app/game-api/src/daemon/redisTransport.ts deleted file mode 100644 index 65443c86..00000000 --- a/app/game-api/src/daemon/redisTransport.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { randomUUID } from 'node:crypto'; - -import type { TurnDaemonStreamKeys } from './streamKeys.js'; -import type { TurnDaemonTransport } from './transport.js'; -import type { - TurnDaemonCommand, - TurnDaemonCommandEnvelope, - TurnDaemonCommandResult, - TurnDaemonEventEnvelope, - TurnDaemonStatus, -} from './types.js'; - -interface RedisTurnDaemonTransportOptions { - keys: TurnDaemonStreamKeys; - requestTimeoutMs: number; -} - -interface RedisClientLike { - xAdd(stream: string, id: string, message: Record): Promise; - xRead(streams: { key: string; id: string }, options?: { BLOCK?: number; COUNT?: number }): Promise; -} - -type RedisStreamReadResponse = Array<{ - name: string; - messages: Array<{ id: string; message: Record }>; -}>; - -const buildCommandEnvelope = (command: TurnDaemonCommand): TurnDaemonCommandEnvelope => { - const requestId = command.requestId ?? randomUUID(); - return { - requestId, - sentAt: new Date().toISOString(), - command, - }; -}; - -const parseEventEnvelope = (raw: string): TurnDaemonEventEnvelope | null => { - try { - const parsed = JSON.parse(raw) as Partial; - if (!parsed || typeof parsed !== 'object') { - return null; - } - if (!parsed.event || typeof parsed.event !== 'object') { - return null; - } - if (typeof parsed.sentAt !== 'string') { - return null; - } - return parsed as TurnDaemonEventEnvelope; - } catch { - return null; - } -}; - -// 턴 데몬 제어 스트림을 Redis로 구현한 전송기. -export class RedisTurnDaemonTransport implements TurnDaemonTransport { - private readonly client: RedisClientLike; - private readonly keys: TurnDaemonStreamKeys; - private readonly requestTimeoutMs: number; - - constructor(client: RedisClientLike, options: RedisTurnDaemonTransportOptions) { - this.client = client; - this.keys = options.keys; - this.requestTimeoutMs = options.requestTimeoutMs; - } - - // Redis 스트림에 명령을 기록해서 턴 데몬에게 전달한다. - async sendCommand(command: TurnDaemonCommand): Promise { - const envelope = buildCommandEnvelope(command); - await this.client.xAdd(this.keys.commandStream, '*', { - payload: JSON.stringify(envelope), - }); - return envelope.requestId; - } - - async requestCommand(command: TurnDaemonCommand, timeoutMs?: number): Promise { - const requestId = await this.sendCommand(command); - - const deadline = Date.now() + (timeoutMs ?? this.requestTimeoutMs); - let lastId = '0-0'; - - while (Date.now() < deadline) { - const remaining = Math.max(1, deadline - Date.now()); - const response = (await this.client.xRead( - { key: this.keys.eventStream, id: lastId }, - { BLOCK: remaining, COUNT: 10 } - )) as RedisStreamReadResponse | null; - - if (!response) { - return null; - } - - for (const stream of response) { - for (const message of stream.messages) { - lastId = message.id; - const payload = message.message.payload; - if (!payload) { - continue; - } - const envelope = parseEventEnvelope(payload); - if (!envelope) { - continue; - } - if (envelope.event.type === 'commandResult' && envelope.requestId === requestId) { - return envelope.event.result; - } - } - } - } - - return null; - } - - async requestStatus(timeoutMs?: number): Promise { - const requestId = randomUUID(); - await this.sendCommand({ type: 'getStatus', requestId }); - - const deadline = Date.now() + (timeoutMs ?? this.requestTimeoutMs); - let lastId = '0-0'; - - while (Date.now() < deadline) { - const remaining = Math.max(1, deadline - Date.now()); - const response = (await this.client.xRead( - { key: this.keys.eventStream, id: lastId }, - { BLOCK: remaining, COUNT: 10 } - )) as RedisStreamReadResponse | null; - - if (!response) { - return null; - } - - for (const stream of response) { - for (const message of stream.messages) { - lastId = message.id; - const payload = message.message.payload; - if (!payload) { - continue; - } - const envelope = parseEventEnvelope(payload); - if (!envelope) { - continue; - } - if (envelope.event.type === 'status' && envelope.requestId === requestId) { - return envelope.event.status; - } - } - } - } - - return null; - } -} diff --git a/app/game-api/src/index.ts b/app/game-api/src/index.ts index 4f805590..9a9c65f1 100644 --- a/app/game-api/src/index.ts +++ b/app/game-api/src/index.ts @@ -14,9 +14,7 @@ export * from './daemon/transport.js'; export * from './daemon/databaseTransport.js'; export * from './daemon/idempotentTransport.js'; export * from './daemon/inMemoryTransport.js'; -export * from './daemon/redisTransport.js'; export * from './auth/flushStore.js'; -export * from './auth/tokenVerifier.js'; export * from './battleSim/types.js'; export * from './battleSim/transport.js'; export * from './battleSim/redisTransport.js'; diff --git a/app/game-api/src/router/join/index.ts b/app/game-api/src/router/join/index.ts index e9393caa..25fd88c3 100644 --- a/app/game-api/src/router/join/index.ts +++ b/app/game-api/src/router/join/index.ts @@ -16,7 +16,11 @@ import { import { readInheritancePoint, resolveInheritConstants } from '../../services/inheritance.js'; import { loadAuthoritativeAccountIcon } from '../../services/accountIconSync.js'; import { loadCurrentGameTime } from '../../services/gameClock.js'; -import { getSelectionPoolStatus, reserveSelectionPool, resolveSelectionMaxGeneral } from '../../services/selectPool.js'; +import { + getSelectionPoolStatus, + reserveSelectionPool, + resolveSelectionMaxGeneral, +} from '@sammo-ts/game-engine/turn/selectPoolService.js'; import { ConflictingTurnDaemonCommandError, RejectedNpcPossessionCommandError, diff --git a/app/game-api/src/router/lobby/index.ts b/app/game-api/src/router/lobby/index.ts index abd5ba36..a0817186 100644 --- a/app/game-api/src/router/lobby/index.ts +++ b/app/game-api/src/router/lobby/index.ts @@ -3,7 +3,7 @@ import { TRPCError } from '@trpc/server'; import { asRecord } from '@sammo-ts/common'; import { zWorldStateConfig, zWorldStateMeta } from '../../context.js'; -import { isSelectionPoolWorld, resolveSelectionMaxGeneral } from '../../services/selectPool.js'; +import { isSelectionPoolWorld, resolveSelectionMaxGeneral } from '@sammo-ts/game-engine/turn/selectPoolService.js'; import { procedure, router } from '../../trpc.js'; export const lobbyRouter = router({ diff --git a/app/game-api/src/router/nation/shared.ts b/app/game-api/src/router/nation/shared.ts index 21cd21c5..6f05ffc3 100644 --- a/app/game-api/src/router/nation/shared.ts +++ b/app/game-api/src/router/nation/shared.ts @@ -210,9 +210,9 @@ export const resolveOfficerCity = (meta: Record): number => { return readMetaNumber(meta, 'officer_city', 0); }; -export const resolveBelong = (meta: Record): number => readMetaNumber(meta, 'belong', 0); +const resolveBelong = (meta: Record): number => readMetaNumber(meta, 'belong', 0); -export const resolvePermission = (meta: Record): PermissionKind => { +const resolvePermission = (meta: Record): PermissionKind => { const value = meta.permission; if (value === 'ambassador' || value === 'auditor') { return value; diff --git a/app/game-api/src/services/inheritance.ts b/app/game-api/src/services/inheritance.ts index 6d08560a..e6b2bde9 100644 --- a/app/game-api/src/services/inheritance.ts +++ b/app/game-api/src/services/inheritance.ts @@ -70,14 +70,8 @@ export const resolveInheritConstants = (worldState: WorldStateRow): InheritConst configConst.inheritBornTurntimePoint, DEFAULT_INHERIT_CONST.inheritBornTurntimePoint ), - inheritBornCityPoint: asNumber( - configConst.inheritBornCityPoint, - DEFAULT_INHERIT_CONST.inheritBornCityPoint - ), - inheritBornStatPoint: asNumber( - configConst.inheritBornStatPoint, - DEFAULT_INHERIT_CONST.inheritBornStatPoint - ), + inheritBornCityPoint: asNumber(configConst.inheritBornCityPoint, DEFAULT_INHERIT_CONST.inheritBornCityPoint), + inheritBornStatPoint: asNumber(configConst.inheritBornStatPoint, DEFAULT_INHERIT_CONST.inheritBornStatPoint), inheritItemUniqueMinPoint: asNumber( configConst.inheritItemUniqueMinPoint, DEFAULT_INHERIT_CONST.inheritItemUniqueMinPoint @@ -152,18 +146,6 @@ export const setInheritancePoint = async ( }); }; -export const addInheritancePoint = async ( - db: DatabaseClient, - userId: string, - key: InheritPointKey, - delta: number -): Promise => { - const current = await readInheritancePoint(db, userId, key); - const next = current + delta; - await setInheritancePoint(db, userId, key, next); - return next; -}; - export const appendInheritanceLog = async ( db: DatabaseClient, userId: string, @@ -181,7 +163,7 @@ export const appendInheritanceLog = async ( }); }; -export const readUserMetaValue = (meta: Record, key: string): number => { +const readUserMetaValue = (meta: Record, key: string): number => { const value = meta[key]; if (typeof value !== 'number' || !Number.isFinite(value)) { return 0; @@ -189,7 +171,7 @@ export const readUserMetaValue = (meta: Record, key: string): n return value; }; -export const computeDexPoint = (meta: Record): number => { +const computeDexPoint = (meta: Record): number => { let total = 0; for (const [key, value] of Object.entries(meta)) { if (!key.startsWith('dex')) { @@ -251,7 +233,10 @@ export const computeInheritanceItems = async (options: { }; export const sumInheritanceItems = (items: Record): number => { - return Object.entries(items).reduce((acc, [key, value]) => (key === 'previous' ? acc : acc + value), items.previous); + return Object.entries(items).reduce( + (acc, [key, value]) => (key === 'previous' ? acc : acc + value), + items.previous + ); }; export const readUserStateMeta = async (db: DatabaseClient, userId: string): Promise> => { diff --git a/app/game-api/src/services/selectPool.ts b/app/game-api/src/services/selectPool.ts deleted file mode 100644 index 508d5670..00000000 --- a/app/game-api/src/services/selectPool.ts +++ /dev/null @@ -1,12 +0,0 @@ -export { - buildSelectPoolSeed, - claimWeightedSelectionCandidates, - getSelectionPoolStatus, - isSelectionPoolWorld, - reserveSelectionPool, - resolveSelectionMaxGeneral, - SelectPoolError, - type SelectPoolCandidateDto, - type SelectPoolCandidateInfo, - type SelectPoolReservationDto, -} from '@sammo-ts/game-engine/turn/selectPoolService.js'; diff --git a/app/game-api/src/tournament/workerHelpers.ts b/app/game-api/src/tournament/workerHelpers.ts index 70adf3d7..ecac5fa7 100644 --- a/app/game-api/src/tournament/workerHelpers.ts +++ b/app/game-api/src/tournament/workerHelpers.ts @@ -19,8 +19,6 @@ export type TournamentPrismaClient = { $transaction: (actions: Promise[]) => Promise; }; -export const sleepMs = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); - export const isBattleStage = (stage: number): boolean => stage >= 7 && stage <= 10; export const isPreBattleStage = (stage: number): boolean => stage >= 1 && stage <= 6; @@ -50,7 +48,7 @@ export const resolveBettingCloseAt = (state: TournamentState): string => { return new Date(resolveScheduledBaseMs(state) + Math.max(1000, bettingTermMs)).toISOString(); }; -export const resolveStatValue = ( +const resolveStatValue = ( type: TournamentType, entry: { leadership: number; strength: number; intel: number } ): number => { @@ -70,13 +68,34 @@ export const resolveStatValue = ( export const resolveGroupPair = (stage: number, phase: number): [number, number] | null => { if (stage === 2) { const pairMap: Array<[number, number]> = [ - [0, 1], [2, 3], [4, 5], [6, 7], - [0, 2], [1, 3], [4, 6], [5, 7], - [0, 3], [1, 6], [2, 5], [4, 7], - [0, 4], [1, 5], [2, 6], [3, 7], - [0, 5], [1, 4], [2, 7], [3, 6], - [0, 6], [1, 7], [2, 4], [3, 5], - [0, 7], [1, 2], [3, 4], [5, 6], + [0, 1], + [2, 3], + [4, 5], + [6, 7], + [0, 2], + [1, 3], + [4, 6], + [5, 7], + [0, 3], + [1, 6], + [2, 5], + [4, 7], + [0, 4], + [1, 5], + [2, 6], + [3, 7], + [0, 5], + [1, 4], + [2, 7], + [3, 6], + [0, 6], + [1, 7], + [2, 4], + [3, 5], + [0, 7], + [1, 2], + [3, 4], + [5, 6], ]; const basePair = pairMap[phase % 28]; if (!basePair) { @@ -87,9 +106,12 @@ export const resolveGroupPair = (stage: number, phase: number): [number, number] if (stage === 4) { const pairMap: Array<[number, number]> = [ - [0, 1], [2, 3], - [0, 2], [1, 3], - [0, 3], [1, 2], + [0, 1], + [2, 3], + [0, 2], + [1, 3], + [0, 3], + [1, 2], ]; return pairMap[phase % 6] ?? null; } @@ -133,10 +155,8 @@ export const assignGroupSlots = ( }); }; -export const selectWeighted = ( - rng: ReturnType, - pool: Array<{ item: T; weight: number }> -): T => rng.choiceUsingWeightPair(pool.map((entry) => [entry.item, entry.weight])); +const selectWeighted = (rng: ReturnType, pool: Array<{ item: T; weight: number }>): T => + rng.choiceUsingWeightPair(pool.map((entry) => [entry.item, entry.weight])); export const fillParticipants = async (options: { prisma: TournamentPrismaClient; @@ -203,7 +223,10 @@ export const fillParticipants = async (options: { while (result.length < limit && applicantPool.length > 0) { const picked = selectWeighted(applicantRng, applicantPool); - applicantPool.splice(applicantPool.findIndex((entry) => entry.item.id === picked.id), 1); + applicantPool.splice( + applicantPool.findIndex((entry) => entry.item.id === picked.id), + 1 + ); takenIds.add(picked.id); result.push(picked); } @@ -262,7 +285,10 @@ export const fillParticipants = async (options: { while (result.length < limit && npcPool.length > 0) { const picked = selectWeighted(npcRng, npcPool); - npcPool.splice(npcPool.findIndex((entry) => entry.item.id === picked.id), 1); + npcPool.splice( + npcPool.findIndex((entry) => entry.item.id === picked.id), + 1 + ); takenIds.add(picked.id); result.push(picked); } @@ -344,28 +370,28 @@ export const applyGroupMatch = ( return { participants: participants.map((entry) => { - if (entry.id !== attacker.id && entry.id !== defender.id) { - return entry; - } - const next = { - ...entry, - win: entry.win ?? 0, - draw: entry.draw ?? 0, - lose: entry.lose ?? 0, - gl: entry.gl ?? 0, - }; - if (result.draw) { - next.draw += 1; + if (entry.id !== attacker.id && entry.id !== defender.id) { + return entry; + } + const next = { + ...entry, + win: entry.win ?? 0, + draw: entry.draw ?? 0, + lose: entry.lose ?? 0, + gl: entry.gl ?? 0, + }; + if (result.draw) { + next.draw += 1; + return next; + } + if (result.winnerId === entry.id) { + next.win += 1; + next.gl += glDelta; + return next; + } + next.lose += 1; + next.gl -= glDelta; return next; - } - if (result.winnerId === entry.id) { - next.win += 1; - next.gl += glDelta; - return next; - } - next.lose += 1; - next.gl -= glDelta; - return next; }), outcome, }; @@ -573,7 +599,7 @@ export const buildTournamentRewardPayload = ( }; }; -export const resolveNumber = (source: Record, keys: string[], fallback: number): number => { +const resolveNumber = (source: Record, keys: string[], fallback: number): number => { for (const key of keys) { const value = source[key]; if (typeof value === 'number' && Number.isFinite(value)) { @@ -598,11 +624,7 @@ export const seedNpcBets = async (options: { const matches = await store.getMatches(); const candidateIds = Array.from( - new Set( - matches - .filter((match) => match.stage === 7) - .flatMap((match) => [match.attackerId, match.defenderId]) - ) + new Set(matches.filter((match) => match.stage === 7).flatMap((match) => [match.attackerId, match.defenderId])) ); if (candidateIds.length === 0) { return; diff --git a/app/game-api/src/trpc.ts b/app/game-api/src/trpc.ts index 1210aed3..4471a22c 100644 --- a/app/game-api/src/trpc.ts +++ b/app/game-api/src/trpc.ts @@ -95,9 +95,6 @@ export const authedProcedure: typeof procedure = procedure.use(requireAuthMiddle // Ref의 increaseRefresh()는 로그인/제재 확인 뒤, 업무 validation과 mutation // transaction보다 먼저 별도 저장된다. access middleware를 input-event보다 // 앞에 두어 실패하거나 재시도되는 업무 transaction과 접속 기록을 분리한다. -export const accessProcedure: typeof procedure = t.procedure - .use(generalAccessEndpointMiddleware) - .use(inputEventMiddleware); export const accessAuthedProcedure: typeof procedure = t.procedure .use(requireAuthMiddleware) .use(generalAccessEndpointMiddleware) @@ -119,10 +116,6 @@ export const sessionActivityProcedure = t.procedure; // 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과 // 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다. export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware); -export const accessReadOnlyAuthedProcedure: typeof procedure = t.procedure - .use(requireAuthMiddleware) - .use(generalAccessEndpointMiddleware); - // 입력이 있는 Ref handler는 request parsing을 마친 뒤 increaseRefresh()를 // 호출한다. 이 factory들은 parser를 access/input-event middleware 앞에 둔다. export const accessInputProcedure: typeof procedure.input = (input) => diff --git a/app/game-api/src/turns/reservedTurns.ts b/app/game-api/src/turns/reservedTurns.ts index 4487743e..43ec023c 100644 --- a/app/game-api/src/turns/reservedTurns.ts +++ b/app/game-api/src/turns/reservedTurns.ts @@ -1,7 +1,7 @@ import type { DatabaseClient, GeneralTurnRow, NationTurnRow, InputJsonValue } from '../context.js'; import { isRecord } from '@sammo-ts/common'; -export const DEFAULT_TURN_ACTION = '휴식'; +const DEFAULT_TURN_ACTION = '휴식'; export const MAX_GENERAL_TURNS = 30; export const MAX_NATION_TURNS = 12; @@ -157,7 +157,7 @@ const persistNationTurns = async ( }); }; -export const loadGeneralTurns = async (db: DatabaseClient, generalId: number): Promise => { +const loadGeneralTurns = async (db: DatabaseClient, generalId: number): Promise => { const rows = await db.generalTurn.findMany({ where: { generalId }, orderBy: [{ turnIdx: 'asc' }], @@ -165,11 +165,6 @@ export const loadGeneralTurns = async (db: DatabaseClient, generalId: number): P return buildTurnListFromRows(rows, MAX_GENERAL_TURNS); }; -export const listGeneralTurns = async (db: DatabaseClient, generalId: number): Promise => { - const turns = await loadGeneralTurns(db, generalId); - return serializeTurnList(turns); -}; - export const getGeneralTurnSnapshot = async (db: DatabaseClient, generalId: number): Promise => { const [turns, revisionRow, general] = await Promise.all([ loadGeneralTurns(db, generalId), @@ -187,7 +182,7 @@ export const getGeneralTurnSnapshot = async (db: DatabaseClient, generalId: numb }; }; -export const loadNationTurns = async ( +const loadNationTurns = async ( db: DatabaseClient, nationId: number, officerLevel: number @@ -199,15 +194,6 @@ export const loadNationTurns = async ( return buildTurnListFromRows(rows, MAX_NATION_TURNS); }; -export const listNationTurns = async ( - db: DatabaseClient, - nationId: number, - officerLevel: number -): Promise => { - const turns = await loadNationTurns(db, nationId, officerLevel); - return serializeTurnList(turns); -}; - export const getNationTurnSnapshot = async ( db: DatabaseClient, nationId: number, diff --git a/app/game-api/test/selectPoolRng.test.ts b/app/game-api/test/selectPoolRng.test.ts index 9f66dccb..9aa242de 100644 --- a/app/game-api/test/selectPoolRng.test.ts +++ b/app/game-api/test/selectPoolRng.test.ts @@ -5,41 +5,19 @@ import { describe, expect, it } from 'vitest'; import { LiteHashDRBG, RandUtil } from '@sammo-ts/common'; -import { - buildSelectPoolSeed, - claimWeightedSelectionCandidates, -} from '../src/services/selectPool.js'; +import { buildSelectPoolSeed, claimWeightedSelectionCandidates } from '@sammo-ts/game-engine/turn/selectPoolService.js'; interface PoolResource { - data: Array< - [ - string, - number, - number, - number, - string, - [number, number, number, number, number], - 0 | 1, - string, - ] - >; + data: Array<[string, number, number, number, string, [number, number, number, number, number], 0 | 1, string]>; } const loadWeightedRows = async (): Promise> => { - const filePath = path.resolve( - import.meta.dirname, - '../../../resources/general-pool/SPoolUnderU30.json' - ); + const filePath = path.resolve(import.meta.dirname, '../../../resources/general-pool/SPoolUnderU30.json'); const resource = JSON.parse(await fs.readFile(filePath, 'utf8')) as PoolResource; - return resource.data.map((row, index) => [ - { id: index + 1 }, - row[5].reduce((sum, value) => sum + value, 0), - ]); + return resource.data.map((row, index) => [{ id: index + 1 }, row[5].reduce((sum, value) => sum + value, 0)]); }; -const drawVector = async ( - hiddenSeed: string -): Promise<{ selected: number[]; draws: number[] }> => { +const drawVector = async (hiddenSeed: string): Promise<{ selected: number[]; draws: number[] }> => { const weighted = await loadWeightedRows(); const now = new Date('2026-07-30T03:34:56.000Z'); const draws: number[] = []; diff --git a/app/game-engine/src/index.ts b/app/game-engine/src/index.ts index eab7f925..5276b11a 100644 --- a/app/game-engine/src/index.ts +++ b/app/game-engine/src/index.ts @@ -1,7 +1,6 @@ import { runTurnDaemonCli } from './turn/cli.js'; export * from './lifecycle/types.js'; -export * from './lifecycle/clock.js'; export * from './lifecycle/databaseCommandQueue.js'; export * from './lifecycle/databaseTurnDaemonLease.js'; export * from './lifecycle/inMemoryControlQueue.js'; diff --git a/app/game-engine/src/lifecycle/clock.ts b/app/game-engine/src/lifecycle/clock.ts deleted file mode 100644 index e29f2594..00000000 --- a/app/game-engine/src/lifecycle/clock.ts +++ /dev/null @@ -1 +0,0 @@ -export { ManualClock, StepClock, SystemClock } from '@sammo-ts/common'; diff --git a/app/game-engine/src/scenario/mapLoader.ts b/app/game-engine/src/scenario/mapLoader.ts index 7d37325d..dd2d71b6 100644 --- a/app/game-engine/src/scenario/mapLoader.ts +++ b/app/game-engine/src/scenario/mapLoader.ts @@ -25,12 +25,12 @@ const readJsonFile = async (filePath: string): Promise => { const resolveMapRoot = (options?: MapLoaderOptions): string => options?.mapRoot ?? DEFAULT_MAP_ROOT; -export const resolveMapDefinitionPath = (mapName: string, options?: MapLoaderOptions): string => { +const resolveMapDefinitionPath = (mapName: string, options?: MapLoaderOptions): string => { const prefix = options?.filePrefix ?? 'map_'; return path.resolve(resolveMapRoot(options), `${prefix}${mapName}.json`); }; -export const loadMapDefinition = async (mapPath: string): Promise => { +const loadMapDefinition = async (mapPath: string): Promise => { const raw = await readJsonFile(mapPath); return MapDefinitionSchema.parse(raw); }; diff --git a/app/game-engine/src/scenario/scenarioLoader.ts b/app/game-engine/src/scenario/scenarioLoader.ts index b6b27f0b..1c521c5e 100644 --- a/app/game-engine/src/scenario/scenarioLoader.ts +++ b/app/game-engine/src/scenario/scenarioLoader.ts @@ -29,16 +29,16 @@ const resolveScenarioRoot = (options?: ScenarioLoaderOptions): string => options export const resolveScenarioDefaultsPath = (options?: ScenarioLoaderOptions): string => path.resolve(resolveScenarioRoot(options), options?.defaultsFileName ?? 'default.json'); -export const resolveScenarioPath = (options: ScenarioLoaderOptions | undefined, scenarioId: number): string => +const resolveScenarioPath = (options: ScenarioLoaderOptions | undefined, scenarioId: number): string => path.resolve(resolveScenarioRoot(options), `scenario_${scenarioId}.json`); -export const loadScenarioDefaults = async (defaultsPath: string): Promise => { +const loadScenarioDefaults = async (defaultsPath: string): Promise => { // 기본 시나리오 파일을 읽고 정규화한다. const raw = await readJsonFile(defaultsPath); return parseScenarioDefaults(raw); }; -export const loadScenarioDefinition = async ( +const loadScenarioDefinition = async ( scenarioPath: string, defaults: ScenarioDefaults ): Promise => { diff --git a/app/game-engine/src/scenario/unitSetLoader.ts b/app/game-engine/src/scenario/unitSetLoader.ts index b7e2706f..54a9a20b 100644 --- a/app/game-engine/src/scenario/unitSetLoader.ts +++ b/app/game-engine/src/scenario/unitSetLoader.ts @@ -20,12 +20,12 @@ const readJsonFile = async (filePath: string): Promise => { const resolveUnitSetRoot = (options?: UnitSetLoaderOptions): string => options?.unitSetRoot ?? DEFAULT_UNIT_SET_ROOT; -export const resolveUnitSetDefinitionPath = (unitSetName: string, options?: UnitSetLoaderOptions): string => { +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 => { +const loadUnitSetDefinition = async (unitSetPath: string): Promise => { const raw = await readJsonFile(unitSetPath); return parseUnitSetDefinition(raw); }; diff --git a/app/game-engine/src/turn/ai/aiUtils.ts b/app/game-engine/src/turn/ai/aiUtils.ts index cd80b35c..3937fc6f 100644 --- a/app/game-engine/src/turn/ai/aiUtils.ts +++ b/app/game-engine/src/turn/ai/aiUtils.ts @@ -1,7 +1,7 @@ -import type { City, Nation } from '@sammo-ts/logic'; -import { asRecord, isRecord } from '@sammo-ts/common'; +import type { City } from '@sammo-ts/logic'; +import { asRecord } from '@sammo-ts/common'; -export { asRecord, isRecord }; +export { asRecord }; export const readNumber = (value: unknown, fallback = 0): number => { if (typeof value === 'number' && Number.isFinite(value)) { @@ -16,21 +16,6 @@ export const readNumber = (value: unknown, fallback = 0): number => { return fallback; }; -export const readBoolean = (value: unknown, fallback = false): boolean => { - if (typeof value === 'boolean') { - return value; - } - if (typeof value === 'number') { - return value !== 0; - } - if (typeof value === 'string') { - return value === 'true' || value === '1'; - } - return fallback; -}; - -export const readString = (value: unknown, fallback = ''): string => (typeof value === 'string' ? value : fallback); - export const readMetaNumber = (meta: Record, key: string, fallback = 0): number => readNumber(meta[key], fallback); @@ -49,12 +34,6 @@ export const readRequiredMetaNumber = (meta: Record, key: strin throw new Error(`meta.${key} is required${suffix}.`); }; -export const readMetaString = (meta: Record, key: string, fallback = ''): string => - readString(meta[key], fallback); - -export const readMetaBoolean = (meta: Record, key: string, fallback = false): boolean => - readBoolean(meta[key], fallback); - export const valueFit = (value: number, min?: number | null, max?: number | null): number => { let next = value; if (min !== null && min !== undefined && next < min) { @@ -108,10 +87,3 @@ export const calcCityDevRatio = (city: City): number => { } return total / max; }; - -export const readNationTech = (nation: Nation | null | undefined): number => { - if (!nation) { - return 0; - } - return readMetaNumber(asRecord(nation.meta), 'tech', 0); -}; diff --git a/app/game-engine/src/turn/ai/generalAi/general/index.ts b/app/game-engine/src/turn/ai/generalAi/general/index.ts index 648ca47d..55775514 100644 --- a/app/game-engine/src/turn/ai/generalAi/general/index.ts +++ b/app/game-engine/src/turn/ai/generalAi/general/index.ts @@ -7,30 +7,7 @@ import { do후방워프, do전방워프, do내정워프, do귀환, do집합 } fr import { doNPC헌납, doNPC사망대비 } from './npcActions.js'; import { do국가선택, do중립, do거병, do건국, do해산, do선양, do방랑군이동 } from './politicsActions.js'; -export { - do일반내정, - do긴급내정, - do전쟁내정, - do금쌀구매, - do징병, - do전투준비, - do소집해제, - do출병, - do후방워프, - do전방워프, - do내정워프, - do귀환, - do집합, - doNPC헌납, - doNPC사망대비, - do국가선택, - do중립, - do거병, - do건국, - do해산, - do선양, - do방랑군이동, -}; +export { do징병 }; export const generalActionHandlers: Record< string, diff --git a/app/game-engine/src/turn/ai/generalAi/nation/index.ts b/app/game-engine/src/turn/ai/generalAi/nation/index.ts index 29450794..46291233 100644 --- a/app/game-engine/src/turn/ai/generalAi/nation/index.ts +++ b/app/game-engine/src/turn/ai/generalAi/nation/index.ts @@ -12,29 +12,6 @@ import { do유저장긴급포상, do유저장포상, doNPC긴급포상, doNPC포 import { do불가침제의, do선전포고 } from './diplomacy.js'; import { do천도 } from './capital.js'; -export { - do부대전방발령, - do부대후방발령, - do부대구출발령, - do부대유저장후방발령, - do유저장후방발령, - do유저장구출발령, - do유저장전방발령, - do유저장내정발령, - doNPC후방발령, - doNPC구출발령, - doNPC전방발령, - doNPC내정발령, - do유저장긴급포상, - do유저장포상, - doNPC긴급포상, - doNPC포상, - doNPC몰수, - do불가침제의, - do선전포고, - do천도, -}; - export const nationActionHandlers: Record< string, (ai: GeneralAI) => ReturnType | null diff --git a/app/game-engine/src/turn/inheritancePointCalculation.ts b/app/game-engine/src/turn/inheritancePointCalculation.ts index ca2dbaac..48cad4bc 100644 --- a/app/game-engine/src/turn/inheritancePointCalculation.ts +++ b/app/game-engine/src/turn/inheritancePointCalculation.ts @@ -2,7 +2,7 @@ import type { TurnGeneral } from './types.js'; const DEX_LIMIT = 1_275_975; -export const STORED_INHERITANCE_KEYS = [ +const STORED_INHERITANCE_KEYS = [ 'lived_month', 'max_domestic_critical', 'active_action', diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 9968b538..ac1bd723 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -6,6 +6,7 @@ import { createEmptyRealtimeReadModelChanges, GameClock, hasRealtimeReadModelChanges, + SystemClock, type GameClockMode, type RealtimeEvent, type RealtimeReadModelChanges, @@ -13,7 +14,6 @@ import { import { createGamePostgresConnector, createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra'; import { NATION_TRAIT_KEYS, NationTraitLoader, loadNationTraitModules } from '@sammo-ts/logic'; -import { SystemClock } from '../lifecycle/clock.js'; import { getNextTickTime } from '../lifecycle/getNextTickTime.js'; import { InMemoryControlQueue } from '../lifecycle/inMemoryControlQueue.js'; import type { Clock, TurnDaemonControlQueue, TurnDaemonHooks, TurnRunBudget } from '../lifecycle/types.js'; @@ -193,21 +193,18 @@ const resolveRedisConfig = (redisUrl?: string, env: NodeJS.ProcessEnv = process. return resolveRedisConfigFromEnv(env); }; -const createTurnDaemonRuntimeWithLease = async ( - options: TurnDaemonRuntimeOptions, - databaseFlushEnabled: boolean, - turnDaemonLease: DatabaseTurnDaemonLease | null -): Promise => { - if (options.exclusiveFastForward && options.profileName) { - throw new Error('exclusiveFastForward cannot be used with a gateway-managed profile.'); - } - // DB에서 월드를 읽고 턴 데몬을 구동할 런타임을 만든다. - const { state, snapshot } = await loadTurnWorldFromDatabase({ - databaseUrl: options.databaseUrl, - mapOptions: options.mapOptions, - }); - const clock = options.clock ?? new SystemClock(); +type LoadedTurnWorld = Awaited>; +type MonthlyActionModuleBundle = Awaited>; +type NationTraitModuleMap = Map>[number]>; +type ReservedTurnStoreHandle = Awaited>; +type RedisConnector = ReturnType; +type GamePostgresConnector = ReturnType; +const resolveRuntimeState = ( + state: LoadedTurnWorld['state'], + options: Pick, + clock: Clock +) => { const tickMinutes = resolveTickMinutes(state.tickSeconds, options.tickMinutes); const nextTickSeconds = tickMinutes * 60; const tickSecondsChanged = options.tickMinutes !== undefined && nextTickSeconds !== state.tickSeconds; @@ -231,22 +228,420 @@ const createTurnDaemonRuntimeWithLease = async ( ...(options.gameClockMode ? { clockMode: options.gameClockMode } : {}), ...(modeChanged ? { clockWallAnchor: new Date(clock.nowMs()) } : {}), }; - const schedule = options.schedule ?? buildFixedSchedule(tickMinutes); - const hasEventAction = (name: string): boolean => - snapshot.events.some( - (event) => - Array.isArray(event.action) && - event.action.some((action) => Array.isArray(action) && action[0] === name) + return { tickMinutes, resolvedState }; +}; + +const hasMonthlyEventAction = (events: LoadedTurnWorld['snapshot']['events'], name: string): boolean => + events.some( + (event) => + Array.isArray(event.action) && event.action.some((action) => Array.isArray(action) && action[0] === name) + ); + +const requiresReservedTurnStore = (events: LoadedTurnWorld['snapshot']['events']): boolean => + [ + 'UpdateNationLevel', + 'CreateManyNPC', + 'RegNPC', + 'RegNeutralNPC', + 'RaiseNPCNation', + 'RaiseInvader', + 'AutoDeleteInvader', + 'ProvideNPCTroopLeader', + ].some((name) => hasMonthlyEventAction(events, name)); + +const createMonthlyEventActions = (options: { + databaseUrl: string; + snapshot: LoadedTurnWorld['snapshot']; + getWorld: () => InMemoryTurnWorld | null; + reservedTurnStoreHandle: ReservedTurnStoreHandle | null; + commandEnv: ReturnType; + actionModules: MonthlyActionModuleBundle; + nationTraits: NationTraitModuleMap; + incomeHandler: ReturnType; +}): Map => { + const eventActions = new Map(); + eventActions.set( + 'RandomizeCityTradeRate', + createRandomizeCityTradeRateHandler({ + getWorld: options.getWorld, + }) + ); + eventActions.set( + 'RaiseDisaster', + createRaiseDisasterHandler({ + getWorld: options.getWorld, + generalActionModules: options.actionModules.general, + }) + ); + eventActions.set( + 'UpdateCitySupply', + createUpdateCitySupplyHandler({ + getWorld: options.getWorld, + map: options.snapshot.map, + }) + ); + eventActions.set( + 'ProcessSemiAnnual', + createProcessSemiAnnualHandler({ + getWorld: options.getWorld, + nationTraits: options.nationTraits, + }) + ); + eventActions.set( + 'ProcessWarIncome', + createProcessWarIncomeHandler({ + getWorld: options.getWorld, + nationTraits: options.nationTraits, + }) + ); + eventActions.set('CreateAdminNPC', createCreateAdminNpcHandler()); + if (options.reservedTurnStoreHandle) { + const reservedTurns = options.reservedTurnStoreHandle.store; + eventActions.set( + 'CreateManyNPC', + createCreateManyNpcHandler({ + getWorld: options.getWorld, + reservedTurns, + env: options.commandEnv, + }) ); - const eventRequiresReservedTurns = - hasEventAction('UpdateNationLevel') || - hasEventAction('CreateManyNPC') || - hasEventAction('RegNPC') || - hasEventAction('RegNeutralNPC') || - hasEventAction('RaiseNPCNation') || - hasEventAction('RaiseInvader') || - hasEventAction('AutoDeleteInvader') || - hasEventAction('ProvideNPCTroopLeader'); + for (const actionName of ['RegNPC', 'RegNeutralNPC'] as const) { + eventActions.set( + actionName, + createRegisterNpcHandler({ + actionName, + getWorld: options.getWorld, + reservedTurns, + env: options.commandEnv, + worldConfig: options.snapshot.worldConfig, + scenarioFiction: options.snapshot.scenarioMeta?.fiction, + }) + ); + } + eventActions.set( + 'RaiseNPCNation', + createRaiseNpcNationHandler({ + getWorld: options.getWorld, + reservedTurns, + env: options.commandEnv, + map: options.snapshot.map, + loadArchivedNationMaxId: (serverId) => loadArchivedNationMaxId(options.databaseUrl, serverId), + }) + ); + eventActions.set( + 'RaiseInvader', + createRaiseInvaderHandler({ + getWorld: options.getWorld, + reservedTurns, + env: options.commandEnv, + loadArchivedNationMaxId: (serverId) => loadArchivedNationMaxId(options.databaseUrl, serverId), + }) + ); + eventActions.set( + 'AutoDeleteInvader', + createAutoDeleteInvaderHandler({ + getWorld: options.getWorld, + reservedTurns, + }) + ); + eventActions.set( + 'ProvideNPCTroopLeader', + createProvideNpcTroopLeaderHandler({ + getWorld: options.getWorld, + reservedTurns, + env: options.commandEnv, + }) + ); + eventActions.set( + 'UpdateNationLevel', + createUpdateNationLevelHandler({ + getWorld: options.getWorld, + reservedTurns, + itemModules: options.actionModules.itemModules, + loadAdditionalOccupiedUniqueCounts: () => loadOccupiedAuctionUniqueCounts(options.databaseUrl), + }) + ); + } + eventActions.set('InvaderEnding', createInvaderEndingHandler({ getWorld: options.getWorld })); + eventActions.set('ChangeCity', createChangeCityHandler({ getWorld: options.getWorld })); + eventActions.set('OpenNationBetting', createOpenNationBettingHandler({ getWorld: options.getWorld })); + eventActions.set('FinishNationBetting', createFinishNationBettingHandler({ getWorld: options.getWorld })); + for (const actionName of ['BlockScoutAction', 'UnblockScoutAction'] as const) { + eventActions.set( + actionName, + createScoutBlockHandler({ + actionName, + getWorld: options.getWorld, + }) + ); + } + eventActions.set('AssignGeneralSpeciality', createAssignGeneralSpecialityHandler({ getWorld: options.getWorld })); + eventActions.set('AddGlobalBetray', createAddGlobalBetrayHandler({ getWorld: options.getWorld })); + eventActions.set( + 'LostUniqueItem', + createLostUniqueItemHandler({ + getWorld: options.getWorld, + itemModules: options.actionModules.itemModules, + }) + ); + eventActions.set('MergeInheritPointRank', createMergeInheritPointRankHandler({ getWorld: options.getWorld })); + eventActions.set('ProcessIncome', createProcessIncomeActionHandler(options.incomeHandler)); + eventActions.set('NoticeToHistoryLog', createNoticeToHistoryLogHandler({ getWorld: options.getWorld })); + eventActions.set('NewYear', createNewYearHandler({ getWorld: options.getWorld })); + eventActions.set('ResetOfficerLock', createResetOfficerLockHandler({ getWorld: options.getWorld })); + return eventActions; +}; + +interface MonthlyRuntimeCache { + nationPowerRollCount: number; + tournamentRollConsumed: boolean; +} + +const createMonthlyCalendarRuntime = async (options: { + databaseUrl: string; + profileName: string; + databaseFlushEnabled: boolean; + snapshot: LoadedTurnWorld['snapshot']; + currentYear: number; + commandEnv: ReturnType; + incomeHandler: ReturnType; + monthlyEventHandler: ReturnType; + hasEventAction: (name: string) => boolean; + calendarHandlerOverride?: TurnCalendarHandler; + getWorld: () => InMemoryTurnWorld | null; + getRedisClient: () => ReturnType['client'] | undefined; + clock: Clock; +}) => { + const cache: MonthlyRuntimeCache = { + nationPowerRollCount: options.snapshot.nations.length, + tournamentRollConsumed: false, + }; + const unification = options.calendarHandlerOverride + ? null + : createUnificationHandler({ + profileName: options.profileName, + getWorld: options.getWorld, + loadPendingUniqueAuctions: options.databaseFlushEnabled + ? () => loadPendingUnificationAuctionCancellations(options.databaseUrl) + : undefined, + dispatchUnitedEvents: (context) => options.monthlyEventHandler.dispatchTarget('united', context), + }); + const monthlyBoundaryPreHandler = createMonthlyBoundaryPreHandler({ + getWorld: options.getWorld, + startYear: options.snapshot.scenarioMeta?.startYear ?? options.currentYear, + commandEnv: options.commandEnv, + }); + const monthlyNationStatsHandler = createMonthlyNationStatsHandler({ + getWorld: options.getWorld, + onNationPowerRollCount: (count) => { + cache.nationPowerRollCount = count; + }, + }); + const neutralAuctionRegistrar = await createNeutralAuctionRegistrar({ + databaseUrl: options.databaseUrl, + profileName: options.profileName, + getWorld: options.getWorld, + getRedisClient: options.getRedisClient, + getWorldConfig: () => options.snapshot.worldConfig ?? null, + getNationPowerRollCount: () => cache.nationPowerRollCount, + getTournamentRollConsumed: () => cache.tournamentRollConsumed, + now: () => options.getWorld()?.getGameNow(new Date(options.clock.nowMs())) ?? new Date(options.clock.nowMs()), + }); + const tournamentAutoStartHandler = createTournamentAutoStartHandler({ + profileName: options.profileName, + getWorld: options.getWorld, + getRedisClient: options.getRedisClient, + getWorldConfig: () => options.snapshot.worldConfig ?? null, + getNationPowerRollCount: () => cache.nationPowerRollCount, + onTournamentRollConsumed: (consumed) => { + cache.tournamentRollConsumed = consumed; + }, + // Deterministic/manual runtimes must schedule the tournament from the + // same clock that advances the game world. Production still falls + // back to the system clock. + now: () => options.getWorld()?.getGameNow(new Date(options.clock.nowMs())) ?? new Date(options.clock.nowMs()), + }); + const calendarHandler = composeCalendarHandlers( + options.monthlyEventHandler, + options.hasEventAction('ProcessIncome') ? null : options.incomeHandler, + createYearbookHandler({ profileName: options.profileName, getWorld: options.getWorld }).handler, + monthlyBoundaryPreHandler, + createNationTurnMonthlyHandler({ getWorld: options.getWorld }), + monthlyNationStatsHandler, + createMonthlyDiplomacyHandler({ getWorld: options.getWorld }), + createMonthlyWarSettingHandler({ getWorld: options.getWorld }), + createMonthlyWanderHandler({ + getWorld: options.getWorld, + startYear: options.snapshot.scenarioMeta?.startYear ?? options.currentYear, + commandEnv: options.commandEnv, + }), + createMonthlyNationCountHandler({ getWorld: options.getWorld }), + options.calendarHandlerOverride ?? unification?.handler, + tournamentAutoStartHandler, + neutralAuctionRegistrar.handler, + createFrontStateHandler({ getWorld: options.getWorld, map: options.snapshot.map ?? null }) + ); + return { calendarHandler, neutralAuctionRegistrar, cache }; +}; + +const createRealtimeRuntime = async (options: { + redisUrl?: string; + profileName: string; + hooks?: TurnDaemonHooks; + takeCommittedReadModelChanges: (() => RealtimeReadModelChanges | null) | null; +}): Promise<{ redisConnector: RedisConnector | null; hooks?: TurnDaemonHooks }> => { + const redisConfig = resolveRedisConfig(options.redisUrl); + if (!redisConfig) { + return { redisConnector: null, hooks: options.hooks }; + } + + const redisConnector = createRedisConnector(redisConfig); + await redisConnector.connect(); + const redisClient = redisConnector.client; + const realtimeChannel = buildGameEventChannel(options.profileName); + const revisionKey = buildGameReadModelRevisionKey(options.profileName); + const domainRevisionKey = buildGameReadModelDomainRevisionKey(options.profileName); + const publishRealtimeEvent = async (event: RealtimeEvent): Promise => { + await redisClient.publish(realtimeChannel, JSON.stringify(event)); + }; + const publishReadModelChanges = async (changes: RealtimeReadModelChanges): Promise => { + if ( + changes.worldChanged || + (changes.mapCityIds ?? changes.cityIds).length > 0 || + (changes.mapNationIds ?? changes.nationIds).length > 0 + ) { + await redisClient.hIncrBy(domainRevisionKey, 'world', 1); + } + return redisClient.incr(revisionKey); + }; + const publishCommittedChanges = async (changes: RealtimeReadModelChanges): Promise => { + if (!hasRealtimeReadModelChanges(changes)) { + return undefined; + } + return publishReadModelChanges(changes); + }; + const basePublishEvents = options.hooks?.publishEvents; + const basePublishCommandEvents = options.hooks?.publishCommandEvents; + const hooks: TurnDaemonHooks = { + ...options.hooks, + publishEvents: async (result) => { + try { + const changes = options.takeCommittedReadModelChanges?.() ?? createEmptyRealtimeReadModelChanges(); + if (result.processedTurns > 0) { + changes.worldChanged = true; + } + const revision = await publishCommittedChanges(changes); + await publishRealtimeEvent({ + type: 'turnCompleted', + at: new Date().toISOString(), + lastTurnTime: result.lastTurnTime, + changes, + revision, + }); + } catch { + // 실시간 이벤트 전송 실패는 턴 처리 결과에 영향을 주지 않는다. + } + await basePublishEvents?.(result); + }, + publishCommandEvents: async (result) => { + try { + const changes = options.takeCommittedReadModelChanges?.(); + if (changes && result.type === 'shiftSchedule' && result.ok) { + changes.lobbyChanged = true; + } + if (changes && hasRealtimeReadModelChanges(changes)) { + const revision = await publishCommittedChanges(changes); + if (revision !== undefined) { + await publishRealtimeEvent({ + type: 'readModelChanged', + at: new Date().toISOString(), + changes, + revision, + }); + } + } + } catch { + // 명령은 이미 commit되었으므로 이벤트 실패로 되돌리지 않는다. + } + await basePublishCommandEvents?.(result); + }, + }; + return { redisConnector, hooks }; +}; + +const createStartedAdminActionConsumer = async (options: { + runtimeOptions: TurnDaemonRuntimeOptions; + turnDaemonLease: DatabaseTurnDaemonLease | null; + commandConnector: GamePostgresConnector | null; + redisConnector: RedisConnector | null; + controlQueue: TurnDaemonControlQueue; +}) => { + const profileName = options.runtimeOptions.profileName; + if (!profileName) { + return null; + } + const consumer = await createGatewayAdminActionConsumer({ + databaseUrl: options.runtimeOptions.databaseUrl, + gatewayDatabaseUrl: options.runtimeOptions.gatewayDatabaseUrl, + profileName, + pollIntervalMs: options.runtimeOptions.adminActionIntervalMs, + handler: async (action) => { + const reason = action.reason ?? `admin:${action.action ?? 'action'}`; + if (options.turnDaemonLease?.isLost()) { + return { status: 'REQUESTED', detail: 'turn-daemon lease 재획득을 기다리는 중입니다.' }; + } + if (action.action === 'RESET_NOW' || action.action === 'RESET_SCHEDULED') { + return { status: 'REQUESTED', detail: 'waiting for orchestrator reset' }; + } + if (action.action === 'ACCELERATE' || action.action === 'DELAY') { + if (!options.commandConnector) { + return { status: 'FAILED', detail: '게임 command database 연결이 없습니다.' }; + } + return applyRuntimeClockShift({ + action, + profileName, + db: options.commandConnector.prisma, + redis: options.redisConnector?.client, + }); + } + switch (action.action) { + case 'RESUME': + options.controlQueue.enqueue({ type: 'resume', reason }); + return { status: 'APPLIED', detail: 'resume queued' }; + case 'PAUSE': + options.controlQueue.enqueue({ type: 'pause', reason }); + return { status: 'APPLIED', detail: 'pause queued' }; + case 'STOP': + case 'SHUTDOWN': + options.controlQueue.enqueue({ type: 'shutdown', reason }); + return { status: 'APPLIED', detail: 'shutdown queued' }; + default: + return { status: 'IGNORED', detail: 'not implemented' }; + } + }, + }); + consumer.start(); + return consumer; +}; + +const createTurnDaemonRuntimeWithLease = async ( + options: TurnDaemonRuntimeOptions, + databaseFlushEnabled: boolean, + turnDaemonLease: DatabaseTurnDaemonLease | null +): Promise => { + if (options.exclusiveFastForward && options.profileName) { + throw new Error('exclusiveFastForward cannot be used with a gateway-managed profile.'); + } + // DB에서 월드를 읽고 턴 데몬을 구동할 런타임을 만든다. + const { state, snapshot } = await loadTurnWorldFromDatabase({ + databaseUrl: options.databaseUrl, + mapOptions: options.mapOptions, + }); + const clock = options.clock ?? new SystemClock(); + const { tickMinutes, resolvedState } = resolveRuntimeState(state, options, clock); + const schedule = options.schedule ?? buildFixedSchedule(tickMinutes); + const hasEventAction = (name: string): boolean => hasMonthlyEventAction(snapshot.events, name); + const eventRequiresReservedTurns = requiresReservedTurnStore(snapshot.events); const reservedTurnStoreHandle = options.generalTurnHandler && !eventRequiresReservedTurns ? null @@ -263,7 +658,7 @@ const createTurnDaemonRuntimeWithLease = async ( }) : await loadTurnCommandProfile()); let worldRef: InMemoryTurnWorld | null = null; - let redisConnector: ReturnType | null = null; + let redisConnector: RedisConnector | null = null; const nationTraits = await loadNationTraitModules([...NATION_TRAIT_KEYS], new NationTraitLoader()); const nationTraitMap = new Map(nationTraits.map((module) => [module.key, module])); const monthlyActionModules = await loadActionModuleBundle( @@ -276,263 +671,40 @@ const createTurnDaemonRuntimeWithLease = async ( scenarioConfig: snapshot.scenarioConfig, nationTraits: nationTraitMap, }); - const eventActions = new Map(); - eventActions.set( - 'RandomizeCityTradeRate', - createRandomizeCityTradeRateHandler({ - getWorld: () => worldRef, - }) - ); - eventActions.set( - 'RaiseDisaster', - createRaiseDisasterHandler({ - getWorld: () => worldRef, - generalActionModules: monthlyActionModules.general, - }) - ); - eventActions.set( - 'UpdateCitySupply', - createUpdateCitySupplyHandler({ - getWorld: () => worldRef, - map: snapshot.map, - }) - ); - eventActions.set( - 'ProcessSemiAnnual', - createProcessSemiAnnualHandler({ - getWorld: () => worldRef, - nationTraits: nationTraitMap, - }) - ); - eventActions.set( - 'ProcessWarIncome', - createProcessWarIncomeHandler({ - getWorld: () => worldRef, - nationTraits: nationTraitMap, - }) - ); - eventActions.set('CreateAdminNPC', createCreateAdminNpcHandler()); - if (reservedTurnStoreHandle) { - eventActions.set( - 'CreateManyNPC', - createCreateManyNpcHandler({ - getWorld: () => worldRef, - reservedTurns: reservedTurnStoreHandle.store, - env: monthlyCommandEnv, - }) - ); - for (const actionName of ['RegNPC', 'RegNeutralNPC'] as const) { - eventActions.set( - actionName, - createRegisterNpcHandler({ - actionName, - getWorld: () => worldRef, - reservedTurns: reservedTurnStoreHandle.store, - env: monthlyCommandEnv, - worldConfig: snapshot.worldConfig, - scenarioFiction: snapshot.scenarioMeta?.fiction, - }) - ); - } - eventActions.set( - 'RaiseNPCNation', - createRaiseNpcNationHandler({ - getWorld: () => worldRef, - reservedTurns: reservedTurnStoreHandle.store, - env: monthlyCommandEnv, - map: snapshot.map, - loadArchivedNationMaxId: (serverId) => loadArchivedNationMaxId(options.databaseUrl, serverId), - }) - ); - eventActions.set( - 'RaiseInvader', - createRaiseInvaderHandler({ - getWorld: () => worldRef, - reservedTurns: reservedTurnStoreHandle.store, - env: monthlyCommandEnv, - loadArchivedNationMaxId: (serverId) => loadArchivedNationMaxId(options.databaseUrl, serverId), - }) - ); - eventActions.set( - 'AutoDeleteInvader', - createAutoDeleteInvaderHandler({ - getWorld: () => worldRef, - reservedTurns: reservedTurnStoreHandle.store, - }) - ); - eventActions.set( - 'ProvideNPCTroopLeader', - createProvideNpcTroopLeaderHandler({ - getWorld: () => worldRef, - reservedTurns: reservedTurnStoreHandle.store, - env: monthlyCommandEnv, - }) - ); - eventActions.set( - 'UpdateNationLevel', - createUpdateNationLevelHandler({ - getWorld: () => worldRef, - reservedTurns: reservedTurnStoreHandle.store, - itemModules: monthlyActionModules.itemModules, - loadAdditionalOccupiedUniqueCounts: () => loadOccupiedAuctionUniqueCounts(options.databaseUrl), - }) - ); - } - eventActions.set( - 'InvaderEnding', - createInvaderEndingHandler({ - getWorld: () => worldRef, - }) - ); - eventActions.set( - 'ChangeCity', - createChangeCityHandler({ - getWorld: () => worldRef, - }) - ); - eventActions.set( - 'OpenNationBetting', - createOpenNationBettingHandler({ - getWorld: () => worldRef, - }) - ); - eventActions.set( - 'FinishNationBetting', - createFinishNationBettingHandler({ - getWorld: () => worldRef, - }) - ); - for (const actionName of ['BlockScoutAction', 'UnblockScoutAction'] as const) { - eventActions.set( - actionName, - createScoutBlockHandler({ - actionName, - getWorld: () => worldRef, - }) - ); - } - eventActions.set( - 'AssignGeneralSpeciality', - createAssignGeneralSpecialityHandler({ - getWorld: () => worldRef, - }) - ); - eventActions.set( - 'AddGlobalBetray', - createAddGlobalBetrayHandler({ - getWorld: () => worldRef, - }) - ); - eventActions.set( - 'LostUniqueItem', - createLostUniqueItemHandler({ - getWorld: () => worldRef, - itemModules: monthlyActionModules.itemModules, - }) - ); - eventActions.set( - 'MergeInheritPointRank', - createMergeInheritPointRankHandler({ - getWorld: () => worldRef, - }) - ); - eventActions.set('ProcessIncome', createProcessIncomeActionHandler(incomeHandler)); - eventActions.set('NoticeToHistoryLog', createNoticeToHistoryLogHandler({ getWorld: () => worldRef })); - eventActions.set('NewYear', createNewYearHandler({ getWorld: () => worldRef })); - eventActions.set('ResetOfficerLock', createResetOfficerLockHandler({ getWorld: () => worldRef })); + const eventActions = createMonthlyEventActions({ + databaseUrl: options.databaseUrl, + snapshot, + getWorld: () => worldRef, + reservedTurnStoreHandle, + commandEnv: monthlyCommandEnv, + actionModules: monthlyActionModules, + nationTraits: nationTraitMap, + incomeHandler, + }); const monthlyEventHandler = createMonthlyEventHandler({ getWorld: () => worldRef, startYear: snapshot.scenarioMeta?.startYear ?? state.currentYear, actions: eventActions, }); - const unification = options.calendarHandler - ? null - : createUnificationHandler({ - profileName: options.profileName ?? options.profile, - getWorld: () => worldRef, - loadPendingUniqueAuctions: databaseFlushEnabled - ? () => loadPendingUnificationAuctionCancellations(options.databaseUrl) - : undefined, - dispatchUnitedEvents: (context) => monthlyEventHandler.dispatchTarget('united', context), - }); - const nationTurnMonthlyHandler = createNationTurnMonthlyHandler({ - getWorld: () => worldRef, - }); - const monthlyBoundaryPreHandler = createMonthlyBoundaryPreHandler({ - getWorld: () => worldRef, - startYear: snapshot.scenarioMeta?.startYear ?? state.currentYear, - commandEnv: monthlyCommandEnv, - }); - let monthlyNationPowerRollCount = snapshot.nations.length; - let monthlyTournamentRollConsumed = false; - const monthlyNationStatsHandler = createMonthlyNationStatsHandler({ - getWorld: () => worldRef, - onNationPowerRollCount: (count) => { - monthlyNationPowerRollCount = count; - }, - }); - const monthlyDiplomacyHandler = createMonthlyDiplomacyHandler({ - getWorld: () => worldRef, - }); - const monthlyNationCountHandler = createMonthlyNationCountHandler({ - getWorld: () => worldRef, - }); - const monthlyWarSettingHandler = createMonthlyWarSettingHandler({ - getWorld: () => worldRef, - }); - const monthlyWanderHandler = createMonthlyWanderHandler({ - getWorld: () => worldRef, - startYear: snapshot.scenarioMeta?.startYear ?? state.currentYear, - commandEnv: monthlyCommandEnv, - }); - const frontStateHandler = createFrontStateHandler({ - getWorld: () => worldRef, - map: snapshot.map ?? null, - }); - const neutralAuctionRegistrar = await createNeutralAuctionRegistrar({ + const { + calendarHandler, + neutralAuctionRegistrar, + cache: monthlyRuntimeCache, + } = await createMonthlyCalendarRuntime({ databaseUrl: options.databaseUrl, profileName: options.profileName ?? options.profile, - getWorld: () => worldRef, - getRedisClient: () => redisConnector?.client, - getWorldConfig: () => snapshot.worldConfig ?? null, - getNationPowerRollCount: () => monthlyNationPowerRollCount, - getTournamentRollConsumed: () => monthlyTournamentRollConsumed, - now: () => worldRef?.getGameNow(new Date(clock.nowMs())) ?? new Date(clock.nowMs()), - }); - const tournamentAutoStartHandler = createTournamentAutoStartHandler({ - profileName: options.profileName ?? options.profile, - getWorld: () => worldRef, - getRedisClient: () => redisConnector?.client, - getWorldConfig: () => snapshot.worldConfig ?? null, - getNationPowerRollCount: () => monthlyNationPowerRollCount, - onTournamentRollConsumed: (consumed) => { - monthlyTournamentRollConsumed = consumed; - }, - // Deterministic/manual runtimes must schedule the tournament from the - // same clock that advances the game world. Production still falls - // back to the system clock. - now: () => worldRef?.getGameNow(new Date(clock.nowMs())) ?? new Date(clock.nowMs()), - }); - const yearbookHandler = createYearbookHandler({ - profileName: options.profileName ?? options.profile, - getWorld: () => worldRef, - }); - const calendarHandler = composeCalendarHandlers( + databaseFlushEnabled, + snapshot, + currentYear: state.currentYear, + commandEnv: monthlyCommandEnv, + incomeHandler, monthlyEventHandler, - hasEventAction('ProcessIncome') ? null : incomeHandler, - yearbookHandler.handler, - monthlyBoundaryPreHandler, - nationTurnMonthlyHandler, - monthlyNationStatsHandler, - monthlyDiplomacyHandler, - monthlyWarSettingHandler, - monthlyWanderHandler, - monthlyNationCountHandler, - options.calendarHandler ?? unification?.handler, - tournamentAutoStartHandler, - neutralAuctionRegistrar.handler, - frontStateHandler - ); + hasEventAction, + calendarHandlerOverride: options.calendarHandler, + getWorld: () => worldRef, + getRedisClient: () => redisConnector?.client, + clock, + }); let occupiedAuctionUniqueItemKeys: string[] = []; let refreshOccupiedAuctionUniqueItemKeys = async (): Promise => {}; const prefetchedNationTurns = new Set(); @@ -580,14 +752,14 @@ const createTurnDaemonRuntimeWithLease = async ( } stateManager.register('runtimeCaches', { capture: () => ({ - monthlyNationPowerRollCount, - monthlyTournamentRollConsumed, + monthlyNationPowerRollCount: monthlyRuntimeCache.nationPowerRollCount, + monthlyTournamentRollConsumed: monthlyRuntimeCache.tournamentRollConsumed, occupiedAuctionUniqueItemKeys: [...occupiedAuctionUniqueItemKeys], prefetchedNationTurns: Array.from(prefetchedNationTurns), }), restore: (captured) => { - monthlyNationPowerRollCount = captured.monthlyNationPowerRollCount; - monthlyTournamentRollConsumed = captured.monthlyTournamentRollConsumed; + monthlyRuntimeCache.nationPowerRollCount = captured.monthlyNationPowerRollCount; + monthlyRuntimeCache.tournamentRollConsumed = captured.monthlyTournamentRollConsumed; occupiedAuctionUniqueItemKeys = [...captured.occupiedAuctionUniqueItemKeys]; prefetchedNationTurns.clear(); for (const key of captured.prefetchedNationTurns) { @@ -642,8 +814,6 @@ const createTurnDaemonRuntimeWithLease = async ( const controlQueue = options.controlQueue ?? new InMemoryControlQueue(); let hooks: TurnDaemonHooks | undefined; - let publishRealtimeEvent: ((event: RealtimeEvent) => Promise) | null = null; - let publishReadModelChanges: ((changes: RealtimeReadModelChanges) => Promise) | null = null; let takeCommittedReadModelChanges: (() => RealtimeReadModelChanges | null) | null = null; let close = async () => {}; let auctionFinalizer: Awaited> | null = null; @@ -734,84 +904,14 @@ const createTurnDaemonRuntimeWithLease = async ( }; } - const redisConfig = resolveRedisConfig(options.redisUrl); - if (redisConfig) { - redisConnector = createRedisConnector(redisConfig); - await redisConnector.connect(); - const redisClient = redisConnector.client; - const realtimeChannel = buildGameEventChannel(options.profileName ?? options.profile); - publishRealtimeEvent = async (event: RealtimeEvent) => { - await redisClient.publish(realtimeChannel, JSON.stringify(event)); - }; - const revisionKey = buildGameReadModelRevisionKey(options.profileName ?? options.profile); - const domainRevisionKey = buildGameReadModelDomainRevisionKey(options.profileName ?? options.profile); - publishReadModelChanges = async (changes) => { - if ( - changes.worldChanged || - (changes.mapCityIds ?? changes.cityIds).length > 0 || - (changes.mapNationIds ?? changes.nationIds).length > 0 - ) { - await redisClient.hIncrBy(domainRevisionKey, 'world', 1); - } - return redisClient.incr(revisionKey); - }; - } - - if (publishRealtimeEvent) { - const basePublishEvents = hooks?.publishEvents; - const basePublishCommandEvents = hooks?.publishCommandEvents; - const publishCommittedChanges = async (changes: RealtimeReadModelChanges): Promise => { - if (!hasRealtimeReadModelChanges(changes)) { - return undefined; - } - return publishReadModelChanges?.(changes); - }; - // Durable mutation summaries invalidate only the affected read models. - hooks = { - ...hooks, - publishEvents: async (result) => { - try { - const changes = takeCommittedReadModelChanges?.() ?? createEmptyRealtimeReadModelChanges(); - if (result.processedTurns > 0) { - changes.worldChanged = true; - } - const revision = await publishCommittedChanges(changes); - await publishRealtimeEvent({ - type: 'turnCompleted', - at: new Date().toISOString(), - lastTurnTime: result.lastTurnTime, - changes, - revision, - }); - } catch { - // 실시간 이벤트 전송 실패는 턴 처리 결과에 영향을 주지 않는다. - } - await basePublishEvents?.(result); - }, - publishCommandEvents: async (result) => { - try { - const changes = takeCommittedReadModelChanges?.(); - if (changes && result.type === 'shiftSchedule' && result.ok) { - changes.lobbyChanged = true; - } - if (changes && hasRealtimeReadModelChanges(changes)) { - const revision = await publishCommittedChanges(changes); - if (revision !== undefined) { - await publishRealtimeEvent({ - type: 'readModelChanged', - at: new Date().toISOString(), - changes, - revision, - }); - } - } - } catch { - // 명령은 이미 commit되었으므로 이벤트 실패로 되돌리지 않는다. - } - await basePublishCommandEvents?.(result); - }, - }; - } + const realtimeRuntime = await createRealtimeRuntime({ + redisUrl: options.redisUrl, + profileName: options.profileName ?? options.profile, + hooks, + takeCommittedReadModelChanges, + }); + redisConnector = realtimeRuntime.redisConnector; + hooks = realtimeRuntime.hooks; const commandConnector = hooks ? createGamePostgresConnector({ url: options.databaseUrl }) : null; const databaseCommandQueue = commandConnector ? new DatabaseTurnDaemonCommandQueue(commandConnector.prisma) : null; @@ -880,50 +980,13 @@ const createTurnDaemonRuntimeWithLease = async ( { profile: options.profile, defaultBudget } ); - if (options.profileName) { - adminActionConsumer = await createGatewayAdminActionConsumer({ - databaseUrl: options.databaseUrl, - gatewayDatabaseUrl: options.gatewayDatabaseUrl, - profileName: options.profileName, - pollIntervalMs: options.adminActionIntervalMs, - handler: async (action) => { - const reason = action.reason ?? `admin:${action.action ?? 'action'}`; - if (turnDaemonLease?.isLost()) { - return { status: 'REQUESTED', detail: 'turn-daemon lease 재획득을 기다리는 중입니다.' }; - } - if (action.action === 'RESET_NOW' || action.action === 'RESET_SCHEDULED') { - // 리셋은 오케스트레이터에서 빌드+재기동으로 처리한다. - return { status: 'REQUESTED', detail: 'waiting for orchestrator reset' }; - } - if (action.action === 'ACCELERATE' || action.action === 'DELAY') { - if (!commandConnector) { - return { status: 'FAILED', detail: '게임 command database 연결이 없습니다.' }; - } - return applyRuntimeClockShift({ - action, - profileName: options.profileName!, - db: commandConnector.prisma, - redis: redisConnector?.client, - }); - } - switch (action.action) { - case 'RESUME': - resolvedControlQueue.enqueue({ type: 'resume', reason }); - return { status: 'APPLIED', detail: 'resume queued' }; - case 'PAUSE': - resolvedControlQueue.enqueue({ type: 'pause', reason }); - return { status: 'APPLIED', detail: 'pause queued' }; - case 'STOP': - case 'SHUTDOWN': - resolvedControlQueue.enqueue({ type: 'shutdown', reason }); - return { status: 'APPLIED', detail: 'shutdown queued' }; - default: - return { status: 'IGNORED', detail: 'not implemented' }; - } - }, - }); - adminActionConsumer.start(); - } + adminActionConsumer = await createStartedAdminActionConsumer({ + runtimeOptions: options, + turnDaemonLease, + commandConnector, + redisConnector, + controlQueue: resolvedControlQueue, + }); return { lifecycle, diff --git a/app/game-engine/test/inputEventAtomicity.test.ts b/app/game-engine/test/inputEventAtomicity.test.ts index f28ab2e0..cfddbf24 100644 --- a/app/game-engine/test/inputEventAtomicity.test.ts +++ b/app/game-engine/test/inputEventAtomicity.test.ts @@ -1,10 +1,9 @@ import { describe, expect, it, vi } from 'vitest'; -import { asRecord } from '@sammo-ts/common'; +import { asRecord, ManualClock } from '@sammo-ts/common'; import { InMemoryControlQueue, EngineStateManager, - ManualClock, TurnDaemonLifecycle, type TurnDaemonCommandResult, type TurnProcessor, diff --git a/app/game-engine/test/monthlyNationLevelAction.test.ts b/app/game-engine/test/monthlyNationLevelAction.test.ts index 8208cbfa..8c5b7360 100644 --- a/app/game-engine/test/monthlyNationLevelAction.test.ts +++ b/app/game-engine/test/monthlyNationLevelAction.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; import { - createItemInventoryFromSlots, ITEM_KEYS, LogCategory, LogScope, @@ -9,6 +8,7 @@ import { type ItemModule, type Nation, } from '@sammo-ts/logic'; +import { createItemInventoryFromSlots } from '@sammo-ts/logic/items/inventory.js'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; import { createUpdateNationLevelHandler } from '../src/turn/monthlyNationLevelAction.js'; @@ -148,9 +148,7 @@ const buildHarness = async ( generals: [buildGeneral(1), buildGeneral(2, { meta: { killturn: 850, belong: 30 } })], cities: [ ...Array.from({ length: cityCount }, (_, index) => buildCity(index + 1)), - ...Array.from({ length: options.neutralCityCount ?? 0 }, (_, index) => - buildCity(cityCount + index + 1, 0) - ), + ...Array.from({ length: options.neutralCityCount ?? 0 }, (_, index) => buildCity(cityCount + index + 1, 0)), ], nations: [ ...(options.neutralCityCount diff --git a/app/game-engine/test/runtimeClockShiftPersistence.integration.test.ts b/app/game-engine/test/runtimeClockShiftPersistence.integration.test.ts index 488682ad..bb5f0f1b 100644 --- a/app/game-engine/test/runtimeClockShiftPersistence.integration.test.ts +++ b/app/game-engine/test/runtimeClockShiftPersistence.integration.test.ts @@ -2,7 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; import { GAME_TICKS_PER_TURN } from '@sammo-ts/common'; -import { SystemClock } from '../src/lifecycle/clock.js'; +import { SystemClock } from '@sammo-ts/common'; import { DatabaseTurnDaemonCommandQueue } from '../src/lifecycle/databaseCommandQueue.js'; import { getNextTickTime } from '../src/lifecycle/getNextTickTime.js'; import { TurnDaemonLifecycle } from '../src/lifecycle/turnDaemonLifecycle.js'; diff --git a/app/game-engine/test/turnDaemonLifecycle.test.ts b/app/game-engine/test/turnDaemonLifecycle.test.ts index 594e1975..afce9318 100644 --- a/app/game-engine/test/turnDaemonLifecycle.test.ts +++ b/app/game-engine/test/turnDaemonLifecycle.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it, vi } from 'vitest'; +import { ManualClock } from '@sammo-ts/common'; import { InMemoryControlQueue, EngineStateManager, - ManualClock, TurnDaemonLifecycle, getNextTickTime, type TurnProcessor, diff --git a/app/gateway-api/src/account/router.ts b/app/gateway-api/src/account/router.ts index f0cbe79c..dd3e7bcd 100644 --- a/app/gateway-api/src/account/router.ts +++ b/app/gateway-api/src/account/router.ts @@ -46,13 +46,6 @@ const decodeImage = (input: string): Buffer => { return buffer; }; -const SEOUL_OFFSET_MS = 9 * 60 * 60 * 1000; - -export const kstDayStart = (value: Date): Date => { - const shifted = new Date(value.getTime() + SEOUL_OFFSET_MS); - return new Date(Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate()) - SEOUL_OFFSET_MS); -}; - const assertIconChangeAvailable = (user: UserRecord, now: Date): void => { if ( user.picture !== 'default.jpg' && diff --git a/app/gateway-api/src/auth/inMemoryUserRepository.ts b/app/gateway-api/src/auth/inMemoryUserRepository.ts index 30abd617..87adb3ea 100644 --- a/app/gateway-api/src/auth/inMemoryUserRepository.ts +++ b/app/gateway-api/src/auth/inMemoryUserRepository.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto'; -import { createSimplePasswordHasher, type PasswordHasher } from './passwordHasher.js'; +import { createPasswordHasher, type PasswordHasher } from './passwordHasher.js'; import type { AdminUserListItem, CreateUserInput, @@ -24,7 +24,7 @@ const toAdminUserListItem = (user: UserRecord): AdminUserListItem => ({ }); // 유저 데이터 저장소를 메모리로 대체한 임시 구현. -export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimplePasswordHasher()): UserRepository => { +export const createInMemoryUserRepository = (hasher: PasswordHasher = createPasswordHasher()): UserRepository => { const usersByName = new Map(); const usersByOauthId = new Map(); const usersByEmail = new Map(); diff --git a/app/gateway-api/src/auth/kakaoAccountVerification.ts b/app/gateway-api/src/auth/kakaoAccountVerification.ts index ba3de5fc..89eb4bdb 100644 --- a/app/gateway-api/src/auth/kakaoAccountVerification.ts +++ b/app/gateway-api/src/auth/kakaoAccountVerification.ts @@ -6,10 +6,10 @@ import type { KakaoOAuthClient, KakaoOAuthToken, KakaoUserInfo } from './kakaoCl import type { OAuthSessionStore } from './oauthSessionStore.js'; import type { UserOAuthInfo, UserRecord, UserRepository } from './userRepository.js'; -export const KAKAO_LOGIN_SCOPES = ['account_email', 'talk_message'] as const; -export const KAKAO_OTP_TTL_SECONDS = 180; -export const KAKAO_OTP_ATTEMPTS = 3; -export const KAKAO_TALK_VERIFICATION_DAYS = 10; +const KAKAO_LOGIN_SCOPES = ['account_email', 'talk_message'] as const; +const KAKAO_OTP_TTL_SECONDS = 180; +const KAKAO_OTP_ATTEMPTS = 3; +const KAKAO_TALK_VERIFICATION_DAYS = 10; export type KakaoVerificationErrorCode = | 'EMAIL_REQUIRED' diff --git a/app/gateway-api/src/auth/passwordHasher.ts b/app/gateway-api/src/auth/passwordHasher.ts index 43c7a318..0332df5d 100644 --- a/app/gateway-api/src/auth/passwordHasher.ts +++ b/app/gateway-api/src/auth/passwordHasher.ts @@ -97,6 +97,3 @@ export const createPasswordHasher = (options: { legacyGlobalSalt?: string } = {} return { ok: false, needsUpgrade: false }; }, }); - -// 기존 import 지점을 깨지 않되 새 계정은 Argon2id를 사용한다. -export const createSimplePasswordHasher = createPasswordHasher; diff --git a/app/gateway-api/src/auth/postgresUserRepository.ts b/app/gateway-api/src/auth/postgresUserRepository.ts index aa7574eb..606b9741 100644 --- a/app/gateway-api/src/auth/postgresUserRepository.ts +++ b/app/gateway-api/src/auth/postgresUserRepository.ts @@ -1,6 +1,6 @@ import { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra'; -import { createSimplePasswordHasher, type PasswordHasher } from './passwordHasher.js'; +import { createPasswordHasher, type PasswordHasher } from './passwordHasher.js'; import type { AdminUserListItem, CreateUserInput, @@ -158,7 +158,7 @@ const mapSpecialAccessGrant = (row: { export const createPostgresUserRepository = ( prisma: GatewayPrismaClient, - hasher: PasswordHasher = createSimplePasswordHasher() + hasher: PasswordHasher = createPasswordHasher() ): UserRepository => { return { async findById(id: string): Promise { @@ -406,7 +406,9 @@ export const createPostgresUserRepository = ( if (result.count !== 1) { return null; } - return mapSpecialAccessGrant(await prisma.specialAccountAccessGrant.findUniqueOrThrow({ where: { id: grantId } })); + return mapSpecialAccessGrant( + await prisma.specialAccountAccessGrant.findUniqueOrThrow({ where: { id: grantId } }) + ); }, async updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise { await prisma.appUser.update({ diff --git a/app/gateway-api/src/auth/registrationInput.ts b/app/gateway-api/src/auth/registrationInput.ts index 84b764e7..220b9291 100644 --- a/app/gateway-api/src/auth/registrationInput.ts +++ b/app/gateway-api/src/auth/registrationInput.ts @@ -21,7 +21,7 @@ const isWideCodePoint = (codePoint: number): boolean => (codePoint >= 0x1f900 && codePoint <= 0x1f9ff) || (codePoint >= 0x20000 && codePoint <= 0x3fffd)); -export const legacyStringWidth = (value: string): number => +const legacyStringWidth = (value: string): number => Array.from(value).reduce((width, character) => { const codePoint = character.codePointAt(0) ?? 0; return width + (isWideCodePoint(codePoint) ? 2 : 1); diff --git a/app/gateway-api/src/index.ts b/app/gateway-api/src/index.ts index b425004f..78c82ad0 100644 --- a/app/gateway-api/src/index.ts +++ b/app/gateway-api/src/index.ts @@ -6,8 +6,7 @@ export * from './config.js'; export * from './context.js'; export * from './router.js'; export * from './server.js'; -import { GatewayPrisma } from '@sammo-ts/infra'; -export { GatewayPrisma }; +import type { GatewayPrisma } from '@sammo-ts/infra'; export type JsonObject = GatewayPrisma.JsonObject; export type JsonArray = GatewayPrisma.JsonArray; export * from './orchestrator/profileRepository.js'; diff --git a/app/gateway-api/src/orchestrator/buildRunner.ts b/app/gateway-api/src/orchestrator/buildRunner.ts index 43159c5b..6f93338e 100644 --- a/app/gateway-api/src/orchestrator/buildRunner.ts +++ b/app/gateway-api/src/orchestrator/buildRunner.ts @@ -26,7 +26,7 @@ export interface BuildRunner { } export const MAX_BUILD_OUTPUT_CHARS = 64 * 1024; -export const DEFAULT_RELEASE_TURBO_CONCURRENCY = 1; +const DEFAULT_RELEASE_TURBO_CONCURRENCY = 1; export const resolveReleaseTurboConcurrency = (env?: Record): number => { const configured = env?.RELEASE_TURBO_CONCURRENCY?.trim(); diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index a0a72a6e..fc468ce1 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -469,10 +469,10 @@ export const buildProcessDefinitions = ( const sanitizeArtifactName = (value: string): string => value.replace(/[^0-9A-Za-z._-]+/g, '_'); -export const buildProfileFrontendOutDir = (workspaceRoot: string, profileName: string): string => +const buildProfileFrontendOutDir = (workspaceRoot: string, profileName: string): string => path.join(workspaceRoot, '.release-dist', sanitizeArtifactName(profileName), 'game-frontend'); -export const buildProfileFrontendCommands = ( +const buildProfileFrontendCommands = ( workspaceRoot: string, profile: Pick, env?: Record diff --git a/app/gateway-api/src/orchestrator/gatewayReleaseRepository.ts b/app/gateway-api/src/orchestrator/gatewayReleaseRepository.ts index f1cd3e32..a7421222 100644 --- a/app/gateway-api/src/orchestrator/gatewayReleaseRepository.ts +++ b/app/gateway-api/src/orchestrator/gatewayReleaseRepository.ts @@ -2,8 +2,7 @@ import type { GatewayPrisma, GatewayPrismaClient } from '@sammo-ts/infra'; import type { GatewayOperationStatus, GatewaySourceMode } from './profileRepository.js'; -export const GATEWAY_RELEASE_OPERATION_TYPES = ['DEPLOY', 'ROLLBACK'] as const; -export type GatewayReleaseOperationType = (typeof GATEWAY_RELEASE_OPERATION_TYPES)[number]; +export type GatewayReleaseOperationType = 'DEPLOY' | 'ROLLBACK'; export interface GatewayReleaseStateRecord { id: string; @@ -46,7 +45,7 @@ export interface GatewayReleaseOperationCreateInput { requestedBy: string; } -export const GATEWAY_RELEASE_LOG_LEVELS = ['INFO', 'OUTPUT', 'ERROR'] as const; +const GATEWAY_RELEASE_LOG_LEVELS = ['INFO', 'OUTPUT', 'ERROR'] as const; export type GatewayReleaseLogLevel = (typeof GATEWAY_RELEASE_LOG_LEVELS)[number]; export interface GatewayReleaseLogRecord { diff --git a/app/gateway-api/src/orchestrator/profileRepository.ts b/app/gateway-api/src/orchestrator/profileRepository.ts index 9f75392d..e5f6179f 100644 --- a/app/gateway-api/src/orchestrator/profileRepository.ts +++ b/app/gateway-api/src/orchestrator/profileRepository.ts @@ -14,14 +14,11 @@ export type GatewayProfileStatus = (typeof GATEWAY_PROFILE_STATUSES)[number]; export const GATEWAY_BUILD_STATUSES = ['IDLE', 'QUEUED', 'RUNNING', 'FAILED', 'SUCCEEDED'] as const; export type GatewayBuildStatus = (typeof GATEWAY_BUILD_STATUSES)[number]; -export const GATEWAY_OPERATION_TYPES = ['RESET', 'DEPLOY', 'START', 'STOP'] as const; -export type GatewayOperationType = (typeof GATEWAY_OPERATION_TYPES)[number]; +export type GatewayOperationType = 'RESET' | 'DEPLOY' | 'START' | 'STOP'; -export const GATEWAY_OPERATION_STATUSES = ['QUEUED', 'RUNNING', 'SUCCEEDED', 'FAILED', 'CANCELLED'] as const; -export type GatewayOperationStatus = (typeof GATEWAY_OPERATION_STATUSES)[number]; +export type GatewayOperationStatus = 'QUEUED' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELLED'; -export const GATEWAY_SOURCE_MODES = ['BRANCH', 'COMMIT'] as const; -export type GatewaySourceMode = (typeof GATEWAY_SOURCE_MODES)[number]; +export type GatewaySourceMode = 'BRANCH' | 'COMMIT'; export interface GatewayOperationRecord { id: string; @@ -57,7 +54,7 @@ export interface GatewayOperationCreateInput { scheduledAt?: string; } -export const GATEWAY_OPERATION_LOG_LEVELS = ['INFO', 'OUTPUT', 'ERROR'] as const; +const GATEWAY_OPERATION_LOG_LEVELS = ['INFO', 'OUTPUT', 'ERROR'] as const; export type GatewayOperationLogLevel = (typeof GATEWAY_OPERATION_LOG_LEVELS)[number]; export interface GatewayOperationLogRecord { diff --git a/app/gateway-api/src/profileOrder.ts b/app/gateway-api/src/profileOrder.ts index 442eb023..0a86a710 100644 --- a/app/gateway-api/src/profileOrder.ts +++ b/app/gateway-api/src/profileOrder.ts @@ -20,7 +20,7 @@ export const resolveGatewayProfileKoreanName = (profile: string, configuredName? return gatewayProfileKoreanNames.get(profile) ?? profile; }; -export const compareGatewayProfiles = ( +const compareGatewayProfiles = ( left: { profile: string; instanceKey: string }, right: { profile: string; instanceKey: string } ): number => { diff --git a/app/release-controller/src/releaseController.ts b/app/release-controller/src/releaseController.ts index 5461da2c..fe540f27 100644 --- a/app/release-controller/src/releaseController.ts +++ b/app/release-controller/src/releaseController.ts @@ -25,7 +25,7 @@ const HEARTBEAT_INTERVAL_MS = 60_000; const PROCESS_NAMES = ['sammo:gateway-api', 'sammo:gateway-frontend', 'sammo:gateway-orchestrator'] as const; const SENSITIVE_ENV_NAME = /(SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE_KEY|CLIENT_SECRET|DATABASE_URL|REDIS_URL)/iu; -export const buildGatewayReleaseCommands = ( +const buildGatewayReleaseCommands = ( workspaceRoot: string, needsInstall: boolean, config: ReleaseControllerConfig diff --git a/app/release-controller/src/selfUpgrade.ts b/app/release-controller/src/selfUpgrade.ts index 8fd87c2b..bc09ff81 100644 --- a/app/release-controller/src/selfUpgrade.ts +++ b/app/release-controller/src/selfUpgrade.ts @@ -17,7 +17,7 @@ import { buildGatewayMigrationCommand } from './releaseController.js'; const CONTROLLER_PROCESS_NAME = 'sammo:release-controller'; -export const buildReleaseControllerCommands = ( +const buildReleaseControllerCommands = ( workspaceRoot: string, needsInstall: boolean, config: ReleaseControllerConfig diff --git a/docs/architecture/package-boundaries.md b/docs/architecture/package-boundaries.md index 57d16482..a40d0ba5 100644 --- a/docs/architecture/package-boundaries.md +++ b/docs/architecture/package-boundaries.md @@ -50,6 +50,26 @@ Frontend가 tRPC router shape를 참조할 때는 `import type`만 사용하고 package를 `devDependencies`에 둡니다. 브라우저에서 실제 실행하는 공유 값만 `common` 또는 `logic`의 browser-safe export에서 가져옵니다. +## 공개 export와 façade 기준 + +공개 export는 현재 package 밖의 호출자가 사용하는 값, 동적 module loader가 +정해진 이름으로 읽는 값, 또는 `package.json`의 명시적 subpath 계약에 필요한 +값만 둡니다. 같은 저장소 내부에서만 쓰는 helper와 schema 조각은 선언 파일에 +남기지 않고 module 내부 값으로 둡니다. 정적 분석이 동적 import의 +`ActionDefinition`, `commandSpec`, `actionContextBuilder`를 미사용으로 표시해도 +이 이름들은 turn command protocol이므로 제거하지 않습니다. + +소유 package의 안정적인 subpath를 그대로 전달하기만 하는 app-local 파일은 +별도 정책이나 변환을 추가하지 않는 한 만들지 않습니다. 호출자는 실제 소유 +subpath를 직접 import하고, 인증·validation·오류 변환 또는 런타임 주입처럼 +경계 자체가 의미를 가질 때만 façade를 유지합니다. 루트 barrel의 `export *`와 +동일 symbol을 다시 나열하는 중복 export도 두지 않습니다. + +함수 분리는 line 수만을 기준으로 하지 않습니다. 계산·effect 적용·dirty-state +판정, 월간 handler 구성, 실시간 발행처럼 입력과 결과가 독립적인 단계는 별도 +함수로 분리합니다. 반대로 daemon의 resource 획득과 역순 종료처럼 한 수명주기 +계약을 이루는 orchestration은 호출 순서를 한곳에서 검토할 수 있게 유지합니다. + ## 자동 검사 ```sh diff --git a/packages/common/src/auth/sanctions.ts b/packages/common/src/auth/sanctions.ts index 5bfeeaae..62c3aaa0 100644 --- a/packages/common/src/auth/sanctions.ts +++ b/packages/common/src/auth/sanctions.ts @@ -8,7 +8,7 @@ const FEATURE_ALIASES: Record> = { messages: new Set(['*', 'message', 'messages']), }; -export const isFutureSanctionDate = (value: string | undefined, now = new Date()): boolean => { +const isFutureSanctionDate = (value: string | undefined, now = new Date()): boolean => { if (!value) { return false; } diff --git a/packages/common/src/realtime/types.ts b/packages/common/src/realtime/types.ts index 19d72fc9..c925fdae 100644 --- a/packages/common/src/realtime/types.ts +++ b/packages/common/src/realtime/types.ts @@ -108,10 +108,7 @@ export const resolveRealtimeReadModelInvalidation = ( const ownGeneralChanged = contains(changes.generalIds, identity.generalId); const ownCityChanged = contains(changes.cityIds, identity.cityId); const ownNationChanged = contains(changes.nationIds, identity.nationId); - const ownFrontStatusNationChanged = contains( - changes.frontStatusNationIds ?? changes.nationIds, - identity.nationId - ); + const ownFrontStatusNationChanged = contains(changes.frontStatusNationIds ?? changes.nationIds, identity.nationId); const ownGeneralMapChanged = contains(changes.mapGeneralIds ?? changes.generalIds, identity.generalId); const frontStatusGeneralChanged = changes.frontStatusGeneralIds !== undefined @@ -122,8 +119,7 @@ export const resolveRealtimeReadModelInvalidation = ( const lobbyChanged = changes.lobbyChanged ?? changes.contactsChanged; const entityContextChanged = ownGeneralChanged || ownCityChanged || ownNationChanged; const mapEntitiesChanged = - (changes.mapCityIds ?? changes.cityIds).length > 0 || - (changes.mapNationIds ?? changes.nationIds).length > 0; + (changes.mapCityIds ?? changes.cityIds).length > 0 || (changes.mapNationIds ?? changes.nationIds).length > 0; const commandEntitiesChanged = changes.cityIds.length > 0 || changes.nationIds.length > 0; return { @@ -167,39 +163,6 @@ export const createEmptyRealtimeReadModelChanges = (): RealtimeReadModelChanges lobbyChanged: false, }); -const mergeIds = (left: readonly number[], right: readonly number[]): number[] => - [...new Set([...left, ...right])].sort((a, b) => a - b); - -export const mergeRealtimeReadModelChanges = ( - left: RealtimeReadModelChanges, - right: RealtimeReadModelChanges -): RealtimeReadModelChanges => ({ - generalIds: mergeIds(left.generalIds, right.generalIds), - cityIds: mergeIds(left.cityIds, right.cityIds), - nationIds: mergeIds(left.nationIds, right.nationIds), - mapGeneralIds: mergeIds(left.mapGeneralIds ?? left.generalIds, right.mapGeneralIds ?? right.generalIds), - mapCityIds: mergeIds(left.mapCityIds ?? left.cityIds, right.mapCityIds ?? right.cityIds), - mapNationIds: mergeIds(left.mapNationIds ?? left.nationIds, right.mapNationIds ?? right.nationIds), - frontStatusGeneralIds: mergeIds( - left.frontStatusGeneralIds ?? (left.contactsChanged ? left.generalIds : []), - right.frontStatusGeneralIds ?? (right.contactsChanged ? right.generalIds : []) - ), - frontStatusNationIds: mergeIds( - left.frontStatusNationIds ?? left.nationIds, - right.frontStatusNationIds ?? right.nationIds - ), - frontStatusActorIds: mergeIds(left.frontStatusActorIds ?? [], right.frontStatusActorIds ?? []), - lobbyGeneralIds: mergeIds(left.lobbyGeneralIds ?? left.generalIds, right.lobbyGeneralIds ?? right.generalIds), - reservedGeneralIds: mergeIds(left.reservedGeneralIds, right.reservedGeneralIds), - recordGeneralIds: mergeIds(left.recordGeneralIds, right.recordGeneralIds), - worldChanged: left.worldChanged || right.worldChanged, - globalRecordsChanged: left.globalRecordsChanged || right.globalRecordsChanged, - worldHistoryChanged: left.worldHistoryChanged || right.worldHistoryChanged, - contactsChanged: left.contactsChanged || right.contactsChanged, - frontStatusChanged: Boolean(left.frontStatusChanged) || Boolean(right.frontStatusChanged), - lobbyChanged: (left.lobbyChanged ?? left.contactsChanged) || (right.lobbyChanged ?? right.contactsChanged), -}); - export const hasRealtimeReadModelChanges = (changes: RealtimeReadModelChanges): boolean => changes.generalIds.length > 0 || changes.cityIds.length > 0 || @@ -251,7 +214,4 @@ export interface MessagesInvalidatedEvent { /** Events safe to expose to an authenticated browser over SSE. */ export type PublicRealtimeEvent = ReadModelInvalidatedEvent | MessagesInvalidatedEvent; -export type RealtimeEvent = - | TurnCompletedEvent - | ReadModelChangedEvent - | MessageCreatedEvent; +export type RealtimeEvent = TurnCompletedEvent | ReadModelChangedEvent | MessageCreatedEvent; diff --git a/packages/common/src/util/TournamentRNG.ts b/packages/common/src/util/TournamentRNG.ts index 6aec88cf..6be94590 100644 --- a/packages/common/src/util/TournamentRNG.ts +++ b/packages/common/src/util/TournamentRNG.ts @@ -32,6 +32,3 @@ const buildTournamentSeedKey = (baseSeed: string, context: TournamentRngContext) export const createTournamentRng = (baseSeed: string, context: TournamentRngContext): RandUtil => new RandUtil(LiteHashDRBG.build(buildTournamentSeedKey(baseSeed, context))); - -export const createTournamentSeedKey = (baseSeed: string, context: TournamentRngContext): string => - buildTournamentSeedKey(baseSeed, context); diff --git a/packages/common/src/util/sha512.ts b/packages/common/src/util/sha512.ts index bc2bbe3f..522fd121 100644 --- a/packages/common/src/util/sha512.ts +++ b/packages/common/src/util/sha512.ts @@ -26,7 +26,7 @@ function normalizeUint8Array(bytes: Uint8Array): Uint8Array { return out as Uint8Array; } -export function sha512Bytes(data: BytesLike): Uint8Array { +function sha512Bytes(data: BytesLike): Uint8Array { const input = convertBytesLikeToUint8Array(data); if (nodeCreateHash) { const digest = nodeCreateHash('sha512').update(input).digest(); diff --git a/packages/infra/src/errorLogRepository.ts b/packages/infra/src/errorLogRepository.ts deleted file mode 100644 index e6847b7a..00000000 --- a/packages/infra/src/errorLogRepository.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { GamePrisma, GamePrismaClient } from './gamePrisma.js'; - -export interface ErrorLogQueryOptions { - limit?: number; - beforeId?: number; - category?: string; -} - -export interface ErrorLogCreateInput { - category: string; - message: string; - source?: string; - trace?: string; - context?: GamePrisma.InputJsonValue; -} - -export interface ErrorLogView { - id: number; - category: string; - source: string | null; - message: string; - trace: string | null; - context: GamePrisma.JsonValue; - createdAt: Date; -} - -const buildPaginationWhere = ( - base: GamePrisma.ErrorLogWhereInput, - options: ErrorLogQueryOptions -): GamePrisma.ErrorLogWhereInput => { - if (options.beforeId) { - return { - ...base, - id: { lt: options.beforeId }, - }; - } - return base; -}; - -const buildFindArgs = ( - where: GamePrisma.ErrorLogWhereInput, - options: ErrorLogQueryOptions -): GamePrisma.ErrorLogFindManyArgs => ({ - where: buildPaginationWhere(where, options), - orderBy: { id: 'desc' }, - take: options.limit ?? 50, -}); - -export class ErrorLogRepository { - constructor(private readonly prisma: GamePrismaClient) {} - - async createErrorLog(input: ErrorLogCreateInput): Promise { - return this.prisma.errorLog.create({ - data: { - category: input.category, - source: input.source ?? null, - message: input.message, - trace: input.trace ?? null, - context: input.context ?? {}, - }, - }); - } - - async listErrorLogs(options: ErrorLogQueryOptions = {}): Promise { - const base: GamePrisma.ErrorLogWhereInput = options.category ? { category: options.category } : {}; - return this.prisma.errorLog.findMany(buildFindArgs(base, options)); - } -} diff --git a/packages/infra/src/index.ts b/packages/infra/src/index.ts index 6dfab63b..5e24780f 100644 --- a/packages/infra/src/index.ts +++ b/packages/infra/src/index.ts @@ -4,7 +4,5 @@ export type { GamePrismaClient } from './gamePrisma.js'; export { createGatewayPostgresConnector, GatewayPrisma } from './gatewayPrisma.js'; export type { GatewayPrismaClient } from './gatewayPrisma.js'; export * from './db.js'; -export * from './errorLogRepository.js'; -export * from './logRepository.js'; export * from './redis.js'; export * from './turnEngineDb.js'; diff --git a/packages/infra/src/logRepository.ts b/packages/infra/src/logRepository.ts deleted file mode 100644 index b71a4bd4..00000000 --- a/packages/infra/src/logRepository.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { LogCategory, LogScope } from '@sammo-ts/logic'; - -import type { GamePrisma, GamePrismaClient } from './gamePrisma.js'; - -export interface LogQueryOptions { - limit?: number; - beforeId?: number; -} - -export interface LogEntryView { - id: number; - scope: LogScope; - category: LogCategory; - subType: string | null; - text: string; - year: number; - month: number; - createdAt: Date; - generalId: number | null; - nationId: number | null; - userId: number | null; -} - -const buildPaginationWhere = ( - base: GamePrisma.LogEntryWhereInput, - options: LogQueryOptions -): GamePrisma.LogEntryWhereInput => { - if (options.beforeId) { - return { - ...base, - id: { lt: options.beforeId }, - }; - } - return base; -}; - -const buildFindArgs = ( - where: GamePrisma.LogEntryWhereInput, - options: LogQueryOptions -): GamePrisma.LogEntryFindManyArgs => ({ - where: buildPaginationWhere(where, options), - orderBy: { id: 'desc' }, - take: options.limit ?? 50, -}); - -export class LogRepository { - constructor(private readonly prisma: GamePrismaClient) {} - - // 전역(시스템) 로그 조회 - async listSystemLogs(category: LogCategory, options: LogQueryOptions = {}): Promise { - return this.prisma.logEntry.findMany( - buildFindArgs( - { - scope: LogScope.SYSTEM, - category, - }, - options - ) - ); - } - - // 국가 로그 조회 - async listNationLogs( - nationId: number, - category: LogCategory, - options: LogQueryOptions = {} - ): Promise { - return this.prisma.logEntry.findMany( - buildFindArgs( - { - scope: LogScope.NATION, - category, - nationId, - }, - options - ) - ); - } - - // 장수 로그 조회 - async listGeneralLogs( - generalId: number, - category: LogCategory, - options: LogQueryOptions = {} - ): Promise { - return this.prisma.logEntry.findMany( - buildFindArgs( - { - scope: LogScope.GENERAL, - category, - generalId, - }, - options - ) - ); - } - - // 유저 로그 조회 - async listUserLogs(userId: number, options: LogQueryOptions & { subType?: string } = {}): Promise { - return this.prisma.logEntry.findMany( - buildFindArgs( - { - scope: LogScope.USER, - category: LogCategory.USER, - userId, - subType: options.subType, - }, - options - ) - ); - } -} diff --git a/packages/logic/src/actions/admin/index.ts b/packages/logic/src/actions/admin/index.ts deleted file mode 100644 index cb0ff5c3..00000000 --- a/packages/logic/src/actions/admin/index.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/packages/logic/src/actions/engine.ts b/packages/logic/src/actions/engine.ts index ae8997c8..dde02334 100644 --- a/packages/logic/src/actions/engine.ts +++ b/packages/logic/src/actions/engine.ts @@ -1,5 +1,5 @@ import type { RandomGenerator } from '@sammo-ts/common'; -import { enablePatches, produceWithPatches, castDraft } from 'immer'; +import { enablePatches, produceWithPatches, castDraft, type Draft, type Patch } from 'immer'; import type { City, General, @@ -82,11 +82,6 @@ export interface LogEffect { entry: LogEntryDraft; } -export interface NextTurnOverrideEffect { - type: 'schedule:override'; - nextTurnAt: Date; -} - export interface MessageAddEffect { type: 'message:add'; draft: MessageDraft; @@ -100,8 +95,7 @@ export type GeneralActionEffect { effects: GeneralActionEffect[]; @@ -229,10 +223,129 @@ export const createMessageEffect = (draft: MessageDraft): MessageAddEffect => ({ draft, }); -export const createNextTurnOverrideEffect = (nextTurnAt: Date): NextTurnOverrideEffect => ({ - type: 'schedule:override', - nextTurnAt, -}); +const createActionLogSink = ( + context: GeneralActionResolveInputContext, + logs: LogEntryDraft[] +): GeneralActionResolveContext['addLog'] => { + return (message, options = {}) => { + const entry: LogEntryDraft = { + scope: options.scope ?? LogScope.GENERAL, + category: options.category ?? LogCategory.ACTION, + text: message, + format: options.format ?? LogFormat.MONTH, + ...options, + }; + + switch (entry.scope) { + case LogScope.GENERAL: + logs.push({ + ...entry, + generalId: entry.generalId ?? context.general.id, + }); + break; + case LogScope.NATION: + if (entry.nationId !== undefined) { + logs.push(entry); + break; + } + if (context.nation?.id !== undefined) { + logs.push({ + ...entry, + nationId: context.nation.id, + }); + } + break; + case LogScope.USER: + if (entry.userId) { + logs.push(entry); + } + break; + case LogScope.SYSTEM: + default: + logs.push(entry); + break; + } + }; +}; + +interface ActionResolutionAccumulator { + createdGenerals: General[]; + createdNations: Nation[]; + patches: NonNullable; + pendingEffects: GeneralActionEffect[]; +} + +const applyGeneralActionEffects = (options: { + effects: GeneralActionEffect[]; + draft: Draft>; + context: GeneralActionResolveInputContext; + addLog: GeneralActionResolveContext['addLog']; + accumulator: ActionResolutionAccumulator; +}): void => { + const { effects, draft, context, addLog, accumulator } = options; + for (const effect of effects) { + switch (effect.type) { + case 'log': + addLog(effect.entry.text, effect.entry); + break; + case 'general:add': + accumulator.createdGenerals.push(effect.general as General); + break; + case 'nation:add': + accumulator.createdNations.push(effect.nation as Nation); + break; + case 'diplomacy:patch': + case 'message:add': + accumulator.pendingEffects.push(effect); + break; + case 'general:patch': + if (effect.targetId !== undefined && effect.targetId !== context.general.id) { + accumulator.patches.generals.push({ + id: effect.targetId, + patch: effect.patch as Partial, + }); + } else { + Object.assign(draft.general, effect.patch); + } + break; + case 'city:patch': + if (effect.targetId !== undefined && effect.targetId !== context.city?.id) { + accumulator.patches.cities.push({ id: effect.targetId, patch: effect.patch }); + } else if (draft.city) { + Object.assign(draft.city, effect.patch); + } + break; + case 'nation:patch': + if (effect.targetId !== undefined && effect.targetId !== context.nation?.id) { + accumulator.patches.nations.push({ id: effect.targetId, patch: effect.patch }); + } else if (draft.nation) { + Object.assign(draft.nation, effect.patch); + } + break; + } + } +}; + +const resolveDirtyState = ( + context: GeneralActionResolveInputContext, + worldPatches: readonly Patch[] +): NonNullable => { + const dirty: NonNullable = { + general: false, + city: false, + nation: false, + generalId: context.general.id, + }; + if (context.city) dirty.cityId = context.city.id; + if (context.nation) dirty.nationId = context.nation.id; + + for (const patch of worldPatches) { + if (patch.path[0] === 'general') dirty.general = true; + if (patch.path[0] === 'city') dirty.city = true; + if (patch.path[0] === 'nation') dirty.nation = true; + } + return dirty; +}; // 행동 결과를 Effect로 모아 상태/턴 계산을 수행한다. export const resolveGeneralAction = ( @@ -242,16 +355,12 @@ export const resolveGeneralAction = { const logs: LogEntryDraft[] = []; - let nextTurnAtOverride: Date | null = null; - const createdGenerals: General[] = []; - const createdNations: Nation[] = []; - const patches: NonNullable = { - generals: [], - cities: [], - nations: [], + const accumulator: ActionResolutionAccumulator = { + createdGenerals: [], + createdNations: [], + patches: { generals: [], cities: [], nations: [] }, + pendingEffects: [], }; - - const pendingEffects: GeneralActionEffect[] = []; let outcome: GeneralActionOutcome | undefined; const [nextWorld, worldPatches] = produceWithPatches( { @@ -260,45 +369,7 @@ export const resolveGeneralAction = , (draft) => { - const addLog = (message: string, options: Partial> = {}) => { - const entry: LogEntryDraft = { - scope: options.scope ?? LogScope.GENERAL, - category: options.category ?? LogCategory.ACTION, - text: message, - format: options.format ?? LogFormat.MONTH, - ...options, - }; - - switch (entry.scope) { - case LogScope.GENERAL: - logs.push({ - ...entry, - generalId: entry.generalId ?? context.general.id, - }); - break; - case LogScope.NATION: - if (entry.nationId !== undefined) { - logs.push(entry); - break; - } - if (context.nation?.id !== undefined) { - logs.push({ - ...entry, - nationId: context.nation.id, - }); - } - break; - case LogScope.USER: - if (entry.userId) { - logs.push(entry); - } - break; - case LogScope.SYSTEM: - default: - logs.push(entry); - break; - } - }; + const addLog = createActionLogSink(context, logs); outcome = resolver.resolve( { @@ -312,85 +383,19 @@ export const resolveGeneralAction = , - }); - } else if (effect.type === 'general:patch') { - Object.assign(draft.general, effect.patch); - } else if ( - effect.type === 'city:patch' && - effect.targetId !== undefined && - effect.targetId !== context.city?.id - ) { - patches.cities.push({ - id: effect.targetId, - patch: effect.patch, - }); - } else if (effect.type === 'city:patch' && draft.city) { - Object.assign(draft.city, effect.patch); - } else if ( - effect.type === 'nation:patch' && - effect.targetId !== undefined && - effect.targetId !== context.nation?.id - ) { - patches.nations.push({ - id: effect.targetId, - patch: effect.patch, - }); - } else if (effect.type === 'nation:patch' && draft.nation) { - Object.assign(draft.nation, effect.patch); - } - break; - } - } + applyGeneralActionEffects({ + effects: outcome.effects, + draft, + context, + addLog, + accumulator, + }); } ); - const nextTurnAt = nextTurnAtOverride ?? getNextTurnAt(scheduleContext.now, scheduleContext.schedule); + const nextTurnAt = getNextTurnAt(scheduleContext.now, scheduleContext.schedule); - const dirty: NonNullable = { - general: false, - city: false, - nation: false, - generalId: context.general.id, - }; - if (context.city) dirty.cityId = context.city.id; - if (context.nation) dirty.nationId = context.nation.id; - - // worldPatches를 분석하여 dirty 설정 - for (const patch of worldPatches) { - if (patch.path[0] === 'general') dirty.general = true; - if (patch.path[0] === 'city') dirty.city = true; - if (patch.path[0] === 'nation') dirty.nation = true; - } + const dirty = resolveDirtyState(context, worldPatches); const resolution: GeneralActionResolution = { general: nextWorld.general as General, @@ -398,7 +403,7 @@ export const resolveGeneralAction = 0 || patches.cities.length > 0 || patches.nations.length > 0) { - resolution.patches = patches; + if ( + accumulator.patches.generals.length > 0 || + accumulator.patches.cities.length > 0 || + accumulator.patches.nations.length > 0 + ) { + resolution.patches = accumulator.patches; } - if (createdGenerals.length > 0 || createdNations.length > 0) { + if (accumulator.createdGenerals.length > 0 || accumulator.createdNations.length > 0) { resolution.created = { - generals: createdGenerals, - ...(createdNations.length > 0 ? { nations: createdNations } : {}), + generals: accumulator.createdGenerals, + ...(accumulator.createdNations.length > 0 ? { nations: accumulator.createdNations } : {}), }; } diff --git a/packages/logic/src/actions/index.ts b/packages/logic/src/actions/index.ts index 5fe49daf..62ff2d7a 100644 --- a/packages/logic/src/actions/index.ts +++ b/packages/logic/src/actions/index.ts @@ -6,6 +6,3 @@ export * from './turn/commandModule.js'; export * from './turn/commandProfile.js'; export * from './turn/general/index.js'; export * from './turn/nation/index.js'; -export * from './instant/general/index.js'; -export * from './instant/nation/index.js'; -export * from './admin/index.js'; diff --git a/packages/logic/src/actions/instant/general/index.ts b/packages/logic/src/actions/instant/general/index.ts deleted file mode 100644 index cb0ff5c3..00000000 --- a/packages/logic/src/actions/instant/general/index.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/packages/logic/src/actions/instant/nation/index.ts b/packages/logic/src/actions/instant/nation/index.ts deleted file mode 100644 index ebf4e1b6..00000000 --- a/packages/logic/src/actions/instant/nation/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -export { - ActionDefinition as NonAggressionAcceptActionDefinition, - type NonAggressionAcceptArgs, - type NonAggressionAcceptContext, -} from './che_불가침수락.js'; -export { - ActionDefinition as NonAggressionCancelAcceptActionDefinition, - type NonAggressionCancelAcceptArgs, - type NonAggressionCancelAcceptContext, -} from './che_불가침파기수락.js'; -export { - ActionDefinition as StopWarAcceptActionDefinition, - type StopWarAcceptArgs, - type StopWarAcceptContext, -} from './che_종전수락.js'; diff --git a/packages/logic/src/actions/turn/general/che_등용수락.ts b/packages/logic/src/actions/turn/general/che_등용수락.ts index d3dce61b..78b1acc5 100644 --- a/packages/logic/src/actions/turn/general/che_등용수락.ts +++ b/packages/logic/src/actions/turn/general/che_등용수락.ts @@ -9,7 +9,7 @@ import { reqGeneralValue, reqEnvValue, readMetaNumberFromUnknown, - alwaysFail, + denyWithReason, } from '@sammo-ts/logic/constraints/presets.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { @@ -318,7 +318,7 @@ export class ActionDefinition< } buildPermissionConstraints(_ctx: ConstraintContext, _args: AcceptScoutArgs): Constraint[] { - return [alwaysFail('예약 불가능 커맨드')]; + return [denyWithReason('예약 불가능 커맨드')]; } buildConstraints(_ctx: ConstraintContext, _args: AcceptScoutArgs): Constraint[] { diff --git a/packages/logic/src/actions/turn/general/che_장비매매.ts b/packages/logic/src/actions/turn/general/che_장비매매.ts index 60df674d..cea56f2c 100644 --- a/packages/logic/src/actions/turn/general/che_장비매매.ts +++ b/packages/logic/src/actions/turn/general/che_장비매매.ts @@ -1,7 +1,7 @@ import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; import { - alwaysFail, + denyWithReason, reqCityCapacity, reqCityTrader, reqGeneralGold, @@ -131,13 +131,13 @@ export class ActionDefinition< } const currentItemCode = general.role.items[args.itemType]; if (currentItemCode === args.itemCode) { - return alwaysFail('이미 가지고 있습니다.').test(ctx, view); + return denyWithReason('이미 가지고 있습니다.').test(ctx, view); } if (currentItemCode) { const currentItem = readItem(this.env.itemCatalog, currentItemCode); if (currentItem && !currentItem.buyable) { - return alwaysFail('이미 진귀한 것을 가지고 있습니다.').test(ctx, view); + return denyWithReason('이미 진귀한 것을 가지고 있습니다.').test(ctx, view); } } return { kind: 'allow' }; diff --git a/packages/logic/src/actions/turn/general/cityDevelopment.ts b/packages/logic/src/actions/turn/general/cityDevelopment.ts deleted file mode 100644 index 3c8245ec..00000000 --- a/packages/logic/src/actions/turn/general/cityDevelopment.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { City, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; -import type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/constraints/types.js'; -import { - notBeNeutral, - notWanderingNation, - occupiedCity, - remainCityCapacity, - reqGeneralGold, - reqGeneralRice, - suppliedCity, -} from '@sammo-ts/logic/constraints/presets.js'; -import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; -import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js'; -import { clamp } from 'es-toolkit'; - -export interface CityDevelopmentArgs {} - -export interface CityDevelopmentEnvironment { - develCost?: number; - amount?: number; -} - -type NumberKeys = { [K in keyof T]-?: T[K] extends number ? K : never }[keyof T]; - -export interface CityDevelopmentConfig { - key: string; - name: string; - statKey: NumberKeys; - maxKey: NumberKeys; - label: string; - baseAmount: number; -} - -const readNumber = (value: unknown): number | null => - typeof value === 'number' && Number.isFinite(value) ? value : null; - -export class CityDevelopmentActionDefinition< - TriggerState extends GeneralTriggerState = GeneralTriggerState, -> implements GeneralActionDefinition { - public readonly key: string; - public readonly name: string; - private readonly config: CityDevelopmentConfig; - private readonly env: CityDevelopmentEnvironment; - - constructor(config: CityDevelopmentConfig, env: CityDevelopmentEnvironment) { - this.key = config.key; - this.name = config.name; - this.config = config; - this.env = env; - } - - parseArgs(_raw: unknown): CityDevelopmentArgs | null { - void _raw; - return {}; - } - - buildConstraints(_ctx: ConstraintContext, _args: CityDevelopmentArgs): Constraint[] { - const getRequiredGold = (_context: ConstraintContext, _view: StateView): number => this.env.develCost ?? 0; - - return [ - notBeNeutral(), - notWanderingNation(), - occupiedCity(), - suppliedCity(), - remainCityCapacity(this.config.statKey, this.config.label), - reqGeneralGold(getRequiredGold), - reqGeneralRice(() => 0), - ]; - } - - resolve( - context: GeneralActionResolveContext, - _args: CityDevelopmentArgs - ): GeneralActionOutcome { - const general = context.general; - const city = context.city; - if (!city) { - context.addLog('도시 정보를 찾지 못했습니다.'); - return { effects: [] }; - } - - const baseAmount = this.env.amount ?? this.config.baseAmount; - const current = readNumber(city[this.config.statKey]); - const max = readNumber(city[this.config.maxKey]); - if (current === null || max === null) { - context.addLog('도시 정보를 찾지 못했습니다.'); - return { effects: [] }; - } - - const nextValue = clamp(current + baseAmount, 0, max); - const costGold = this.env.develCost ?? 0; - - // 직접 수정 (Immer Draft) - city[this.config.statKey] = nextValue; - general.gold = Math.max(0, general.gold - costGold); - - const logMessage = `${this.config.label}이 ${nextValue - current} 증가했습니다.`; - context.addLog(logMessage); - - return { effects: [] }; - } -} diff --git a/packages/logic/src/actions/turn/general/index.ts b/packages/logic/src/actions/turn/general/index.ts index 1adbb0dd..ebd40290 100644 --- a/packages/logic/src/actions/turn/general/index.ts +++ b/packages/logic/src/actions/turn/general/index.ts @@ -169,4 +169,4 @@ export const loadGeneralTurnCommandSpecs = async ( return specs; }; -export { readLegacyCityTrust, storeLegacyCityTrust } from './legacyCityTrust.js'; +export { readLegacyCityTrust } from './legacyCityTrust.js'; diff --git a/packages/logic/src/constraints/city.ts b/packages/logic/src/constraints/city.ts index dc2ec73f..8d08e104 100644 --- a/packages/logic/src/constraints/city.ts +++ b/packages/logic/src/constraints/city.ts @@ -236,30 +236,6 @@ export const remainCityCapacity = (key: keyof City, label: string): Constraint = }, }); -export const remainCityCapacityByMax = (key: keyof City, maxKey: keyof City, label: string): Constraint => ({ - name: 'remainCityCapacityByMax', - requires: (ctx) => (ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : []), - test: (ctx, view) => { - const city = readCity(view, ctx.cityId); - if (!city) { - if (ctx.cityId === undefined) { - return unknownOrDeny(ctx, [], '도시 정보가 없습니다.'); - } - const req: RequirementKey = { kind: 'city', id: ctx.cityId }; - return unknownOrDeny(ctx, [req], '도시 정보가 없습니다.'); - } - const current = city[key]; - const max = city[maxKey]; - if (typeof current !== 'number' || typeof max !== 'number') { - return unknownOrDeny(ctx, [], '도시 정보가 없습니다.'); - } - if (current < max) { - return allow(); - } - return { kind: 'deny', reason: `${label}이 충분합니다.` }; - }, -}); - export const reqCityCapacity = (key: keyof City, label: string, required: number | string): Constraint => ({ name: 'reqCityCapacity', requires: (ctx) => (ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : []), @@ -506,7 +482,7 @@ export const hasRouteWithEnemy = (): Constraint => ({ }, }); -export const beNeutralCity = (): Constraint => ({ +export const neutralCity = (): Constraint => ({ name: 'beNeutralCity', requires: (ctx) => (ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : []), test: (ctx, view) => { @@ -534,8 +510,6 @@ export const beNeutralCity = (): Constraint => ({ }, }); -export const neutralCity = (): Constraint => beNeutralCity(); - export const constructableCity = (): Constraint => ({ name: 'constructableCity', requires: (ctx) => (ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : []), @@ -558,25 +532,6 @@ export const constructableCity = (): Constraint => ({ }, }); -export const reqCityLevel = (levels: number[]): Constraint => ({ - name: 'reqCityLevel', - requires: (ctx) => (ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : []), - test: (ctx, view) => { - const city = readCity(view, ctx.cityId); - if (!city) { - if (ctx.cityId === undefined) { - return unknownOrDeny(ctx, [], '도시 정보가 없습니다.'); - } - const req: RequirementKey = { kind: 'city', id: ctx.cityId }; - return unknownOrDeny(ctx, [req], '도시 정보가 없습니다.'); - } - if (levels.includes(city.level)) { - return allow(); - } - return { kind: 'deny', reason: '규모가 맞지 않습니다.' }; - }, -}); - export const nearCity = (maxDistance: number): Constraint => ({ name: 'nearCity', requires: (ctx) => { diff --git a/packages/logic/src/constraints/evaluate.ts b/packages/logic/src/constraints/evaluate.ts index eea57387..bd2a7c4a 100644 --- a/packages/logic/src/constraints/evaluate.ts +++ b/packages/logic/src/constraints/evaluate.ts @@ -1,4 +1,4 @@ -import type { Constraint, ConstraintContext, ConstraintResult, RequirementKey, StateView } from './types.js'; +import type { Constraint, ConstraintContext, ConstraintResult, StateView } from './types.js'; export const evaluateConstraints = ( constraints: Constraint[], @@ -24,14 +24,6 @@ export const evaluateConstraints = ( return { kind: 'allow' }; }; -export const collectRequirements = (constraints: Constraint[], ctx: ConstraintContext): RequirementKey[] => { - const keys: RequirementKey[] = []; - for (const constraint of constraints) { - keys.push(...constraint.requires(ctx)); - } - return keys; -}; - export interface ActionWithConstraints { buildConstraints(ctx: ConstraintContext, args: unknown): Constraint[]; } diff --git a/packages/logic/src/constraints/general.ts b/packages/logic/src/constraints/general.ts index a6302edf..be9eb46a 100644 --- a/packages/logic/src/constraints/general.ts +++ b/packages/logic/src/constraints/general.ts @@ -89,25 +89,6 @@ export const beChief = (): Constraint => ({ }, }); -export const beMonarch = (): Constraint => ({ - name: 'beMonarch', - requires: (ctx) => [{ kind: 'general', id: ctx.actorId }], - test: (ctx, view) => { - const req: RequirementKey = { kind: 'general', id: ctx.actorId }; - if (!view.has(req)) { - return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.'); - } - const general = view.get(req) as General | null; - if (!general) { - return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.'); - } - if (general.officerLevel === 12) { - return allow(); - } - return { kind: 'deny', reason: '군주가 아닙니다.' }; - }, -}); - export const beLord = (): Constraint => ({ name: 'beLord', requires: (ctx) => [{ kind: 'general', id: ctx.actorId }], @@ -508,47 +489,6 @@ export const existsDestGeneral = (): Constraint => ({ }, }); -export const destGeneralInDestNation = (): Constraint => ({ - name: 'destGeneralInDestNation', - requires: (ctx) => { - const reqs: RequirementKey[] = []; - const destGeneralId = resolveDestGeneralId(ctx); - if (destGeneralId !== undefined) { - reqs.push({ kind: 'destGeneral', id: destGeneralId }); - } - const destNationId = resolveDestNationId(ctx); - if (destNationId !== undefined) { - reqs.push({ kind: 'destNation', id: destNationId }); - } - return reqs; - }, - test: (ctx, view) => { - const destGeneral = readDestGeneral(ctx, view); - if (!destGeneral) { - const destGeneralId = resolveDestGeneralId(ctx); - if (destGeneralId === undefined) { - return unknownOrDeny(ctx, [], '장수 정보가 없습니다.'); - } - const req: RequirementKey = { - kind: 'destGeneral', - id: destGeneralId, - }; - return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.'); - } - const destNationId = resolveDestNationId(ctx); - if (destNationId === undefined) { - return unknownOrDeny(ctx, [], '국가 정보가 없습니다.'); - } - if (destGeneral.nationId !== destNationId) { - return { - kind: 'deny', - reason: '제의 장수가 국가 소속이 아닙니다.', - }; - } - return allow(); - }, -}); - export const friendlyDestGeneral = (): Constraint => ({ name: 'friendlyDestGeneral', requires: (ctx) => { @@ -603,29 +543,6 @@ export const mustBeNPC = (): Constraint => ({ }, }); -export const notSameDestNation = (): Constraint => ({ - name: 'notSameDestNation', - requires: (ctx) => { - const reqs: RequirementKey[] = []; - const destNationId = resolveDestNationId(ctx); - if (destNationId !== undefined) { - reqs.push({ kind: 'destNation', id: destNationId }); - } - return reqs; - }, - test: (ctx, _view) => { - const destNationId = resolveDestNationId(ctx); - if (destNationId === undefined) { - return unknownOrDeny(ctx, [], '목표 국가가 없습니다.'); - } - - if (ctx.nationId === destNationId) { - return { kind: 'deny', reason: '이미 소속된 국가입니다.' }; - } - return allow(); - }, -}); - export const notLord = (): Constraint => ({ name: 'notLord', requires: (ctx) => [{ kind: 'general', id: ctx.actorId }], @@ -640,18 +557,3 @@ export const notLord = (): Constraint => ({ return { kind: 'deny', reason: '군주는 불가능합니다.' }; }, }); - -export const notChief = (): Constraint => ({ - name: 'notChief', - requires: (ctx) => [{ kind: 'general', id: ctx.actorId }], - test: (ctx, view) => { - const req: RequirementKey = { kind: 'general', id: ctx.actorId }; - const general = view.get(req) as General | null; - if (!general) return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.'); - - if (general.officerLevel <= 4) { - return allow(); - } - return { kind: 'deny', reason: '수뇌입니다.' }; - }, -}); diff --git a/packages/logic/src/constraints/misc.ts b/packages/logic/src/constraints/misc.ts index f65b9100..cfada777 100644 --- a/packages/logic/src/constraints/misc.ts +++ b/packages/logic/src/constraints/misc.ts @@ -7,9 +7,6 @@ export const denyWithReason = (reason: string): Constraint => ({ test: () => ({ kind: 'deny', reason }), }); -// TODO: 점진 이전을 위해 유지. 신규 코드에서는 denyWithReason을 사용한다. -export const alwaysFail = denyWithReason; - export const notOpeningPart = (relYear: number, openingPartYear: number): Constraint => ({ name: 'notOpeningPart', requires: () => [], diff --git a/packages/logic/src/constraints/nation.ts b/packages/logic/src/constraints/nation.ts index 417b5b96..16dccb99 100644 --- a/packages/logic/src/constraints/nation.ts +++ b/packages/logic/src/constraints/nation.ts @@ -48,25 +48,6 @@ export const notWanderingNation = (): Constraint => ({ }, }); -export const beWanderingNation = (): Constraint => ({ - name: 'beWanderingNation', - requires: (ctx) => (ctx.nationId !== undefined ? [{ kind: 'nation', id: ctx.nationId }] : []), - test: (ctx, view) => { - const nation = readNation(view, ctx.nationId); - if (!nation) { - if (ctx.nationId === undefined) { - return unknownOrDeny(ctx, [], '국가 정보가 없습니다.'); - } - const req: RequirementKey = { kind: 'nation', id: ctx.nationId }; - return unknownOrDeny(ctx, [req], '국가 정보가 없습니다.'); - } - if (nation.level === 0) { - return allow(); - } - return { kind: 'deny', reason: '방랑군이 아닙니다.' }; - }, -}); - export const wanderingNation = (): Constraint => ({ name: 'wanderingNation', requires: (ctx) => (ctx.nationId !== undefined ? [{ kind: 'nation', id: ctx.nationId }] : []), diff --git a/packages/logic/src/crewType/registry.ts b/packages/logic/src/crewType/registry.ts index f2645dda..cc0d9921 100644 --- a/packages/logic/src/crewType/registry.ts +++ b/packages/logic/src/crewType/registry.ts @@ -1,8 +1,6 @@ import { actionModule as castleFirst } from './actions/che_성벽선제.js'; import type { CrewTypeActionModule, CrewTypeActionRegistry } from './types.js'; -export const CREW_TYPE_ACTION_KEYS = ['che_성벽선제'] as const; - export const createCrewTypeActionRegistry = ( modules: readonly CrewTypeActionModule[] = [castleFirst] ): CrewTypeActionRegistry => new Map(modules.map((module) => [module.key, module])); diff --git a/packages/logic/src/diplomacy/index.ts b/packages/logic/src/diplomacy/index.ts index 098af0c4..e46adecb 100644 --- a/packages/logic/src/diplomacy/index.ts +++ b/packages/logic/src/diplomacy/index.ts @@ -4,7 +4,6 @@ import { DIPLOMACY_STATE } from './constants.js'; export { DIPLOMACY_STATE } from './constants.js'; -export const DEFAULT_DECLARE_WAR_TERM = 24; export const DEFAULT_WAR_TERM = 6; const MAX_WAR_TERM = 13; diff --git a/packages/logic/src/economy/nationIncome.ts b/packages/logic/src/economy/nationIncome.ts index 624943e5..e64b59da 100644 --- a/packages/logic/src/economy/nationIncome.ts +++ b/packages/logic/src/economy/nationIncome.ts @@ -87,7 +87,7 @@ export const createIncomeActionContext = (nation: Nation): GeneralActionContext return { general, nation }; }; -export const calcCityGoldIncomeBase = ( +const calcCityGoldIncomeBase = ( context: NationIncomeContext, city: CityIncomeSource, officerCnt: number, @@ -112,7 +112,7 @@ export const calcCityGoldIncomeBase = ( return Math.round(adjusted); }; -export const calcCityRiceIncomeBase = ( +const calcCityRiceIncomeBase = ( context: NationIncomeContext, city: CityIncomeSource, officerCnt: number, @@ -137,7 +137,7 @@ export const calcCityRiceIncomeBase = ( return Math.round(adjusted); }; -export const calcCityWallIncomeBase = ( +const calcCityWallIncomeBase = ( context: NationIncomeContext, city: CityIncomeSource, officerCnt: number, @@ -161,7 +161,7 @@ export const calcCityWallIncomeBase = ( return Math.round(adjusted); }; -export const calcCityWarGoldIncome = (context: NationIncomeContext, city: CityIncomeSource): number => { +const calcCityWarGoldIncome = (context: NationIncomeContext, city: CityIncomeSource): number => { if (city.supplyState === 0) { return 0; } @@ -257,7 +257,7 @@ export const getWarGoldIncome = (context: NationIncomeContext, cities: CityIncom return total; }; -export const resolveDedLevel = (dedication: number): number => { +const resolveDedLevel = (dedication: number): number => { const level = Math.ceil(Math.sqrt(Math.max(0, dedication)) / 10); return Math.max(0, Math.min(MAX_DED_LEVEL, level)); }; diff --git a/packages/logic/src/index.ts b/packages/logic/src/index.ts index 735b10c3..d8de2068 100644 --- a/packages/logic/src/index.ts +++ b/packages/logic/src/index.ts @@ -11,7 +11,6 @@ export * from './economy/index.js'; export * from './logging/index.js'; export * from './messages/index.js'; export * from './items/index.js'; -export { ITEM_KEYS, createItemActionModules, createItemModuleRegistry, loadItemModules } from './items/index.js'; export * from './rewards/uniqueLottery.js'; export * from './inheritance/inheritBuff.js'; export * from './resources/index.js'; diff --git a/packages/logic/src/items/index.ts b/packages/logic/src/items/index.ts index 2af8e057..587ec53c 100644 --- a/packages/logic/src/items/index.ts +++ b/packages/logic/src/items/index.ts @@ -754,26 +754,13 @@ export const createItemActionModules = { - if (typeof value === 'boolean') { - return value; - } - if (typeof value === 'number') { - return value > 0; - } - if (typeof value === 'string') { - const normalized = value.trim().toLowerCase(); - return normalized === 'true' || normalized === 'yes' || normalized === '1'; - } - return false; -}; - -export const isInventoryEnabled = (config: ScenarioConfig): boolean => { - const constConfig = config.const ?? {}; - return toBoolean( - constConfig['allowInventory'] ?? constConfig['inventoryEnabled'] ?? constConfig['enableInventory'] - ); -}; +import { consumeEquippedItemCharge, readItemInventory } from './inventory.js'; export const listEquippedItemKeys = ( general: General @@ -49,32 +21,6 @@ export const listEquippedItemKeys = ( return result; }; -export const getItemRemain = ( - general: General, - itemKey: string -): number | null => { - const instance = getEquippedItemInstance(general, 'item'); - const value = instance?.itemKey === itemKey ? instance.state.charges : undefined; - return typeof value === 'number' && value > 0 ? value : null; -}; - -export const setItemRemain = ( - general: General, - itemKey: string, - remain: number | null -): void => { - ensureItemInventory(general); - const instance = getEquippedItemInstance(general, 'item'); - if (!instance || instance.itemKey !== itemKey) { - return; - } - if (remain === null || remain <= 0) { - delete instance.state.charges; - return; - } - instance.state.charges = remain; -}; - export const consumeItemRemain = ( general: General, itemKey: string, @@ -82,27 +28,3 @@ export const consumeItemRemain = ( ): boolean => { return consumeEquippedItemCharge(general, 'item', itemKey, fallbackRemain); }; - -export const canAcquireItem = (options: { - general: General; - item: ItemModule; - config: ScenarioConfig; - registry: Map; -}): boolean => { - const { general, item, config, registry } = options; - if (!item.unique) { - return true; - } - if (isInventoryEnabled(config)) { - return true; - } - const slotItemKey = general.role.items[item.slot]; - if (!slotItemKey) { - return true; - } - const slotItem = registry.get(slotItemKey); - if (!slotItem) { - return true; - } - return !slotItem.unique; -}; diff --git a/packages/logic/src/messages/message.ts b/packages/logic/src/messages/message.ts index b4ee8676..d858c3f4 100644 --- a/packages/logic/src/messages/message.ts +++ b/packages/logic/src/messages/message.ts @@ -45,9 +45,9 @@ export interface MessageStore { insertMessage(draft: MessageRecordDraft): Promise; } -export const isValidMailbox = (mailbox: number): boolean => mailbox > 0 && mailbox <= MESSAGE_MAILBOX_PUBLIC; +const isValidMailbox = (mailbox: number): boolean => mailbox > 0 && mailbox <= MESSAGE_MAILBOX_PUBLIC; -export const resolveReceiverMailbox = (draft: MessageDraft): number => { +const resolveReceiverMailbox = (draft: MessageDraft): number => { switch (draft.msgType) { case 'public': return MESSAGE_MAILBOX_PUBLIC; @@ -59,7 +59,7 @@ export const resolveReceiverMailbox = (draft: MessageDraft): number => { } }; -export const resolveSenderMailbox = (draft: MessageDraft): number | null => { +const resolveSenderMailbox = (draft: MessageDraft): number | null => { switch (draft.msgType) { case 'public': return null; diff --git a/packages/logic/src/resources/mapSchema.ts b/packages/logic/src/resources/mapSchema.ts index 339d434b..a191b13b 100644 --- a/packages/logic/src/resources/mapSchema.ts +++ b/packages/logic/src/resources/mapSchema.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -export const MapCityStatsSchema = z.object({ +const MapCityStatsSchema = z.object({ population: z.number(), agriculture: z.number(), commerce: z.number(), @@ -9,7 +9,7 @@ export const MapCityStatsSchema = z.object({ wall: z.number(), }); -export const MapCityDefinitionSchema = z.object({ +const MapCityDefinitionSchema = z.object({ id: z.number(), name: z.string(), level: z.number(), @@ -24,7 +24,7 @@ export const MapCityDefinitionSchema = z.object({ meta: z.record(z.string(), z.unknown()).optional(), }); -export const MapDefaultsSchema = z +const MapDefaultsSchema = z .object({ trust: z.number(), trade: z.number(), diff --git a/packages/logic/src/resources/scenarioSchema.ts b/packages/logic/src/resources/scenarioSchema.ts index f99c9446..5a4d26f2 100644 --- a/packages/logic/src/resources/scenarioSchema.ts +++ b/packages/logic/src/resources/scenarioSchema.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { SCENARIO_EFFECT_KEYS } from '../scenario/scenarioEffect.js'; -export const ScenarioStatBlockSchema = z +const ScenarioStatBlockSchema = z .object({ total: z.number(), min: z.number(), @@ -18,7 +18,7 @@ export const ScenarioDefaultsInputSchema = z.object({ iconPath: z.string().optional(), }); -export const ScenarioExtendsInputSchema = z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]); +const ScenarioExtendsInputSchema = z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]); const ScenarioConstInputSchema = z .object({ diff --git a/packages/logic/src/resources/unitSetSchema.ts b/packages/logic/src/resources/unitSetSchema.ts index 3359f8a0..55976288 100644 --- a/packages/logic/src/resources/unitSetSchema.ts +++ b/packages/logic/src/resources/unitSetSchema.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; const numericRecordSchema = z.record(z.string(), z.number()); const numericArraySchema = z.array(z.number()); -export const CrewTypeRequirementSchema = z.union([ +const CrewTypeRequirementSchema = z.union([ z.object({ type: z.literal('ReqTech'), tech: z.number() }), z.object({ type: z.literal('ReqRegions'), regions: z.array(z.string()) }), z.object({ type: z.literal('ReqCities'), cities: z.array(z.string()) }), @@ -22,7 +22,7 @@ export const CrewTypeRequirementSchema = z.union([ z.object({ type: z.string() }).passthrough(), ]); -export const CrewTypeDefinitionInputSchema = z.object({ +const CrewTypeDefinitionInputSchema = z.object({ id: z.number(), armType: z.number(), name: z.string(), diff --git a/packages/logic/src/rewards/uniqueLottery.ts b/packages/logic/src/rewards/uniqueLottery.ts index 99882367..61301a40 100644 --- a/packages/logic/src/rewards/uniqueLottery.ts +++ b/packages/logic/src/rewards/uniqueLottery.ts @@ -7,8 +7,7 @@ import { equipNewItem } from '../items/inventory.js'; export type UniqueItemPool = Record>; -export const UNIQUE_ACQUIRE_TYPES = ['아이템', '설문조사', '랜덤 임관', '건국'] as const; -export type UniqueAcquireType = (typeof UNIQUE_ACQUIRE_TYPES)[number]; +export type UniqueAcquireType = '아이템' | '설문조사' | '랜덤 임관' | '건국'; export type UniqueLotteryRequest = { acquireType: UniqueAcquireType; @@ -322,7 +321,7 @@ export const rollUniqueLottery = (input: UniqueLotteryInput): string | null => { return rng.choiceUsingWeightPair(availableUnique); }; -export const applyUniqueItemGain = ( +const applyUniqueItemGain = ( context: GeneralActionResolveContext, itemModule: ItemModule, acquireType: UniqueAcquireType, diff --git a/packages/logic/src/scenario/scenarioEffect.ts b/packages/logic/src/scenario/scenarioEffect.ts index 4a8541ee..3d71feb8 100644 --- a/packages/logic/src/scenario/scenarioEffect.ts +++ b/packages/logic/src/scenario/scenarioEffect.ts @@ -12,7 +12,7 @@ const DEFENCE_TRAIN_PENALTY_WAIVER_EFFECTS = new Set([ 'event_MoreEffect', ]); -export const isScenarioEffectKey = (value: string): value is ScenarioEffectKey => +const isScenarioEffectKey = (value: string): value is ScenarioEffectKey => SCENARIO_EFFECT_KEYS.includes(value as ScenarioEffectKey); export const normalizeScenarioEffect = (value: unknown): ScenarioEffectKey | null => { diff --git a/packages/logic/src/triggers/general.ts b/packages/logic/src/triggers/general.ts index 9f011ad3..06a78c61 100644 --- a/packages/logic/src/triggers/general.ts +++ b/packages/logic/src/triggers/general.ts @@ -18,7 +18,7 @@ export interface GeneralSkillActivation { activate(...keys: string[]): void; } -export const createGeneralSkillActivation = ( +const createGeneralSkillActivation = ( general: General ): GeneralSkillActivation => ({ has: (key: string) => Boolean(general.triggerState.flags[key]), diff --git a/packages/logic/src/turn/calendar.ts b/packages/logic/src/turn/calendar.ts index 257a5b31..451d0342 100644 --- a/packages/logic/src/turn/calendar.ts +++ b/packages/logic/src/turn/calendar.ts @@ -37,7 +37,7 @@ const findCurrentEntryIndex = (minuteOfDay: number, entries: TurnScheduleEntries const getEntryAt = (entries: TurnScheduleEntries, index: number): TurnScheduleEntry => entries[Math.max(0, Math.min(entries.length - 1, index))] ?? entries[0]; -export const getTickMinutesAt = (date: Date, schedule: TurnSchedule): number => { +const getTickMinutesAt = (date: Date, schedule: TurnSchedule): number => { const entries = normalizeEntries(schedule.entries); const minuteOfDay = toMinuteOfDay(date); const index = findCurrentEntryIndex(minuteOfDay, entries); diff --git a/packages/logic/src/war/crewTypeTriggers.ts b/packages/logic/src/war/crewTypeTriggers.ts index 5875c4c9..cbedb377 100644 --- a/packages/logic/src/war/crewTypeTriggers.ts +++ b/packages/logic/src/war/crewTypeTriggers.ts @@ -8,19 +8,6 @@ import { che_필살발동, che_필살시도 } from './triggers/che_필살.js'; import { che_회피발동, che_회피시도 } from './triggers/che_회피.js'; import { che_계략발동, che_계략실패, che_계략시도 } from './triggers/che_계략.js'; -export const CREW_TYPE_WAR_TRIGGER_KEYS = [ - 'che_성벽부상무효', - 'che_기병병종전투', - 'che_방어력증가5p', - 'che_선제사격시도', - 'che_선제사격발동', - 'che_저지시도', - 'che_저지발동', - 'che_필살', - 'che_회피', - 'che_계략', -] as const; - export const createCrewTypeWarTriggerRegistry = (): WarTriggerRegistry => ({ che_성벽부상무효: (unit) => new che_성벽부상무효(unit), che_기병병종전투: (unit) => new che_기병병종전투(unit), @@ -31,6 +18,5 @@ export const createCrewTypeWarTriggerRegistry = (): WarTriggerRegistry => ({ che_저지발동: (unit) => new che_저지(unit), che_필살: (unit) => new WarTriggerCaller(new che_필살시도(unit), new che_필살발동(unit)), che_회피: (unit) => new WarTriggerCaller(new che_회피시도(unit), new che_회피발동(unit)), - che_계략: (unit) => - new WarTriggerCaller(new che_계략시도(unit), new che_계략발동(unit), new che_계략실패(unit)), + che_계략: (unit) => new WarTriggerCaller(new che_계략시도(unit), new che_계략발동(unit), new che_계략실패(unit)), }); diff --git a/packages/logic/src/war/triggers/che_계략.ts b/packages/logic/src/war/triggers/che_계략.ts index d62bd026..2da3f604 100644 --- a/packages/logic/src/war/triggers/che_계략.ts +++ b/packages/logic/src/war/triggers/che_계략.ts @@ -2,9 +2,8 @@ import { JosaUtil } from '@sammo-ts/common'; import { LogFormat } from '@sammo-ts/logic/logging/types.js'; import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js'; -import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js'; +import { BaseWarUnitTrigger } from '@sammo-ts/logic/war/triggers.js'; import { WarUnitCity, WarUnitGeneral, type WarUnit } from '@sammo-ts/logic/war/units.js'; -import type { WarTriggerModule } from './types.js'; const MAGIC_TO_GENERAL = { 위보: [1.2, 1.1], @@ -83,13 +82,7 @@ export class che_계략시도 extends BaseWarUnitTrigger { const table = oppose instanceof WarUnitCity ? MAGIC_TO_CITY : MAGIC_TO_GENERAL; const magic = self.rng.choice(Object.keys(table)); const [rawSuccessDamage, failDamage] = table[magic as keyof typeof table]; - const successDamage = applyMagicDamageModifiers( - self, - oppose, - 'warMagicSuccessDamage', - rawSuccessDamage, - magic - ); + const successDamage = applyMagicDamageModifiers(self, oppose, 'warMagicSuccessDamage', rawSuccessDamage, magic); self.activateSkill('계략시도', magic); if (self.rng.nextBool(successProbability)) { @@ -145,7 +138,8 @@ export class che_계략실패 extends BaseWarUnitTrigger { selfEnv: Record, _opposeEnv: Record ): boolean { - if (!(self instanceof WarUnitGeneral) || !self.hasActivatedSkill('계략실패') || selfEnv['계략실패']) return true; + if (!(self instanceof WarUnitGeneral) || !self.hasActivatedSkill('계략실패') || selfEnv['계략실패']) + return true; const magicState = readMagic(selfEnv); if (!magicState) return true; selfEnv['계략실패'] = true; @@ -159,11 +153,3 @@ export class che_계략실패 extends BaseWarUnitTrigger { return true; } } - -export const triggerModule: WarTriggerModule = { - key: 'che_계략', - name: '계략', - info: '[전투] 귀병의 계략 시도/성공/실패', - createTriggerList: (unit) => - new WarTriggerCaller(new che_계략시도(unit), new che_계략발동(unit), new che_계략실패(unit)), -}; diff --git a/packages/logic/src/war/triggers/che_회피.ts b/packages/logic/src/war/triggers/che_회피.ts index 0699cbba..90139beb 100644 --- a/packages/logic/src/war/triggers/che_회피.ts +++ b/packages/logic/src/war/triggers/che_회피.ts @@ -1,8 +1,7 @@ import { LogFormat } from '@sammo-ts/logic/logging/types.js'; import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js'; -import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js'; +import { BaseWarUnitTrigger } from '@sammo-ts/logic/war/triggers.js'; import { WarUnitGeneral, type WarUnit } from '@sammo-ts/logic/war/units.js'; -import type { WarTriggerModule } from './types.js'; export class che_회피시도 extends BaseWarUnitTrigger { constructor(unit: WarUnit) { @@ -41,10 +40,3 @@ export class che_회피발동 extends BaseWarUnitTrigger { return true; } } - -export const triggerModule: WarTriggerModule = { - key: 'che_회피', - name: '회피', - info: '[전투] 페이즈마다 확률로 회피 발동', - createTriggerList: (unit) => new WarTriggerCaller(new che_회피시도(unit), new che_회피발동(unit)), -}; diff --git a/packages/logic/src/war/triggers/index.ts b/packages/logic/src/war/triggers/index.ts index 7fd924d3..72f423f0 100644 --- a/packages/logic/src/war/triggers/index.ts +++ b/packages/logic/src/war/triggers/index.ts @@ -1,9 +1,6 @@ import type { WarTriggerModule, WarTriggerModuleExport } from './types.js'; -import type { WarTriggerRegistry } from '@sammo-ts/logic/war/triggers.js'; -export const WAR_TRIGGER_KEYS = ['che_필살', 'che_의술'] as const; - -export type WarTriggerKey = (typeof WAR_TRIGGER_KEYS)[number]; +export type WarTriggerKey = 'che_필살' | 'che_의술'; export type WarTriggerImporter = () => Promise; @@ -12,9 +9,6 @@ const defaultImporters: Record = { che_의술: async () => import('./che_의술.js'), }; -export const isWarTriggerKey = (value: string): value is WarTriggerKey => - WAR_TRIGGER_KEYS.includes(value as WarTriggerKey); - export class WarTriggerLoader { private readonly cache = new Map>(); @@ -60,12 +54,4 @@ export const loadWarTriggerModules = async ( return modules; }; -export const createWarTriggerRegistry = (modules: WarTriggerModule[]): WarTriggerRegistry => { - const registry: WarTriggerRegistry = {}; - for (const module of modules) { - registry[module.key] = (unit) => module.createTriggerList(unit); - } - return registry; -}; - export type { WarTriggerModule, WarTriggerModuleExport } from './types.js'; diff --git a/packages/logic/src/war/units.ts b/packages/logic/src/war/units.ts index e91619c3..e655c227 100644 --- a/packages/logic/src/war/units.ts +++ b/packages/logic/src/war/units.ts @@ -1,3 +1,3 @@ -export { WarUnit, WAR_CRITICAL_RANGE, resolveNationTech } from './units/base.js'; +export { WarUnit } from './units/base.js'; export { WarUnitGeneral } from './units/general.js'; export { WarUnitCity } from './units/city.js'; diff --git a/packages/logic/src/war/utils.ts b/packages/logic/src/war/utils.ts index 5666af88..d5d97a3c 100644 --- a/packages/logic/src/war/utils.ts +++ b/packages/logic/src/war/utils.ts @@ -9,8 +9,6 @@ export const clamp = (value: number, min: number, max: number): number => Math.m export const clampMin = (value: number, min: number): number => (value < min ? min : value); -export const clampMax = (value: number, max: number): number => (value > max ? max : value); - // REF-COMPAT:BEGIN ref-php-half-rounding // PHP's round() compensates for small binary floating-point drift around a // half boundary and rounds halves away from zero. War state is persisted to @@ -36,11 +34,6 @@ export const getMetaNumber = (meta: Record, key: string, f return typeof value === 'number' && Number.isFinite(value) ? value : fallback; }; -export const getMetaString = (meta: Record, key: string): string | null => { - const value = meta[key]; - return typeof value === 'string' ? value : null; -}; - export const setMetaNumber = (meta: Record, key: string, value: number): void => { meta[key] = round(value); }; @@ -120,23 +113,6 @@ export const sortConflictEntries = ( .map(([key, value]) => [key, value] as const); }; -export const stringifyConflict = (conflict: Record | null): string => { - if (!conflict) { - return '{}'; - } - const sorted = Object.entries(conflict) - .map(([key, value]) => [Number(key), value] as const) - .filter(([key, value]) => Number.isFinite(key) && typeof value === 'number') - .sort(([, lhs], [, rhs]) => rhs - lhs); - - const ordered: Record = {}; - for (const [key, value] of sorted) { - ordered[String(key)] = value; - } - - return JSON.stringify(ordered); -}; - export const sortConflict = ( conflict: Record, preferredOrder: number[] = [] diff --git a/packages/logic/src/world/index.ts b/packages/logic/src/world/index.ts index 2abbc019..5f0a8eea 100644 --- a/packages/logic/src/world/index.ts +++ b/packages/logic/src/world/index.ts @@ -1,5 +1,4 @@ export * from './types.js'; export * from './bootstrap.js'; -export * from './loader.js'; export * from './unitSet.js'; export * from './distance.js'; diff --git a/packages/logic/src/world/loader.ts b/packages/logic/src/world/loader.ts deleted file mode 100644 index 880fc76b..00000000 --- a/packages/logic/src/world/loader.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { City, General, Nation, Troop } from '@sammo-ts/logic/domain/entities.js'; -import type { ScenarioConfig, ScenarioDiplomacy } from '@sammo-ts/logic/scenario/types.js'; -import type { ScenarioConfigSource, WorldStateSnapshotSource } from '@sammo-ts/logic/ports/worldSnapshot.js'; -import type { MapDefinition, ScenarioMeta, UnitSetDefinition, WorldSnapshot } from './types.js'; - -export interface WorldSnapshotLoadInput< - GeneralType extends General = General, - CityType extends City = City, - NationType extends Nation = Nation, - TroopType extends Troop = Troop, -> { - worldSource: WorldStateSnapshotSource; - scenarioConfig?: ScenarioConfig; - scenarioMeta?: ScenarioMeta; - scenarioSource?: ScenarioConfigSource; - map: MapDefinition; - unitSet?: UnitSetDefinition; - diplomacy?: ScenarioDiplomacy[]; - events?: unknown[]; - initialEvents?: unknown[]; -} - -// DB 기반 월드 로더: 세계 상태와 시나리오 설정을 합쳐 스냅샷을 만든다. -export const loadWorldSnapshot = async < - GeneralType extends General, - CityType extends City, - NationType extends Nation, - TroopType extends Troop, ->( - input: WorldSnapshotLoadInput -): Promise => { - const { worldSource, scenarioSource } = input; - - const scenarioConfig = - input.scenarioConfig ?? (scenarioSource ? await scenarioSource.loadScenarioConfig() : undefined); - if (!scenarioConfig) { - throw new Error('Scenario config is required to load world snapshot.'); - } - - const scenarioMeta = - input.scenarioMeta ?? (scenarioSource?.loadScenarioMeta ? await scenarioSource.loadScenarioMeta() : undefined); - - const [generals, cities, nations, troops] = await Promise.all([ - worldSource.listGenerals(), - worldSource.listCities(), - worldSource.listNations(), - worldSource.listTroops(), - ]); - - return { - scenarioConfig, - ...(scenarioMeta ? { scenarioMeta } : {}), - map: input.map, - ...(input.unitSet ? { unitSet: input.unitSet } : {}), - nations, - cities, - generals, - troops, - diplomacy: input.diplomacy ?? [], - events: input.events ?? [], - initialEvents: input.initialEvents ?? [], - }; -};