diff --git a/app/game-api/src/router/lobby/index.ts b/app/game-api/src/router/lobby/index.ts index a2fe2dde..93052114 100644 --- a/app/game-api/src/router/lobby/index.ts +++ b/app/game-api/src/router/lobby/index.ts @@ -5,6 +5,7 @@ import { asNumber, asRecord } from '@sammo-ts/common'; import { zWorldStateConfig, zWorldStateMeta } from '../../context.js'; import { isSelectionPoolWorld, resolveSelectionMaxGeneral } from '@sammo-ts/game-engine/turn/selectPoolService.js'; import { loadCurrentGameTime } from '../../services/gameClock.js'; +import { loadTurnEngineRunning } from '../../services/turnEngineStatus.js'; import { procedure, router } from '../../trpc.js'; export const lobbyRouter = router({ @@ -35,6 +36,7 @@ export const lobbyRouter = router({ .map(([option]) => option) : []; const gameTime = await loadCurrentGameTime(ctx.db); + const turnEngineRunning = await loadTurnEngineRunning(ctx.profileStatusSource, ctx.db, ctx.profile.name); let myGeneral = null; if (ctx.auth?.user.id) { @@ -72,6 +74,7 @@ export const lobbyRouter = router({ clockMode: gameTime.mode ?? 'realtime', clockRunning: gameTime.running, clockStartsAt: gameTime.startsAt?.toISOString() ?? null, + turnEngineRunning, otherTextInfo: worldState.meta.otherTextInfo ?? '', npcMode: worldState.config.npcMode ?? 0, defaultStatTotal: asNumber(asRecord(rawConfig.stat).total, 165), diff --git a/app/game-api/src/router/turns/index.ts b/app/game-api/src/router/turns/index.ts index 93ba0ae5..659d9b67 100644 --- a/app/game-api/src/router/turns/index.ts +++ b/app/game-api/src/router/turns/index.ts @@ -14,9 +14,8 @@ import { } from '../../turns/commandTable.js'; import { loadMapDefinitionByName } from '../../maps/mapDefinition.js'; import { - assertReservedTurnActionAvailable, buildEquipmentTradeItemOptions, - parseRegisteredTurnArgs, + parseReservedTurnArgs, TURN_COMMAND_NATION_COLORS, type TurnCommandInputOptions, } from '../../turns/commandInput.js'; @@ -66,9 +65,17 @@ const buildBulkEntrySchema = (turnList: z.ZodType) => args: z.unknown().optional(), }); -const parseCommandArgs = async (scope: 'general' | 'nation', action: string, args: unknown) => { +const parseCommandArgs = async ( + scope: 'general' | 'nation', + action: string, + args: unknown, + worldState: WorldStateRow +) => { try { - return await parseRegisteredTurnArgs(scope, action, args); + // 사용자 입력은 action별 argument schema보다 먼저 현재 scenario의 + // 선택 가능 profile을 통과해야 한다. 내부 전용 명령의 parser를 외부 + // 요청이 직접 호출하지 못하게 하는 첫 경계다. + return await parseReservedTurnArgs(scope, action, args, asRecord(worldState.config).const); } catch (error) { throw new TRPCError({ code: 'BAD_REQUEST', @@ -78,22 +85,6 @@ const parseCommandArgs = async (scope: 'general' | 'nation', action: string, arg } }; -const assertScenarioCommandAvailable = async ( - scope: 'general' | 'nation', - action: string, - worldState: WorldStateRow -): Promise => { - try { - await assertReservedTurnActionAvailable(scope, action, asRecord(worldState.config).const); - } catch (error) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: error instanceof Error ? error.message : 'Unavailable turn command.', - cause: error, - }); - } -}; - const mutateReservedTurns = async (mutation: () => Promise): Promise => { try { return await mutation(); @@ -492,9 +483,8 @@ export const turnsRouter = router({ ) .mutation(async ({ ctx, input }) => { const general = await getOwnedGeneral(ctx, input.generalId); - const args = await parseCommandArgs('general', input.action, input.args); const worldState = await getReservationWorldState(ctx); - await assertScenarioCommandAvailable('general', input.action, worldState); + const args = await parseCommandArgs('general', input.action, input.args, worldState); await assertReservedTurnPermission(worldState, general, 'general', input.action, args); const snapshot = await mutateReservedTurns(() => @@ -549,16 +539,15 @@ export const turnsRouter = router({ ) .mutation(async ({ ctx, input }) => { const general = await getOwnedGeneral(ctx, input.generalId); + const worldState = await getReservationWorldState(ctx); const updates = await Promise.all( input.entries.map(async (entry) => ({ turnIndices: expandGeneralTurnIndices(entry.turnList), action: entry.action, - args: await parseCommandArgs('general', entry.action, entry.args), + args: await parseCommandArgs('general', entry.action, entry.args, worldState), })) ); - const worldState = await getReservationWorldState(ctx); for (const update of updates) { - await assertScenarioCommandAvailable('general', update.action, worldState); await assertReservedTurnPermission(worldState, general, 'general', update.action, update.args); } const snapshot = await mutateReservedTurns(() => @@ -596,9 +585,8 @@ export const turnsRouter = router({ message: 'General is not an officer.', }); } - const args = await parseCommandArgs('nation', input.action, input.args); const worldState = await getReservationWorldState(ctx); - await assertScenarioCommandAvailable('nation', input.action, worldState); + const args = await parseCommandArgs('nation', input.action, input.args, worldState); assertNationTurnInputAllowed(general); await assertReservedTurnPermission(worldState, general, 'nation', input.action, args); @@ -714,9 +702,8 @@ export const turnsRouter = router({ const update = { turnIndices: entry.turnList, action: entry.action, - args: await parseCommandArgs('nation', entry.action, entry.args), + args: await parseCommandArgs('nation', entry.action, entry.args, worldState), }; - await assertScenarioCommandAvailable('nation', update.action, worldState); assertNationTurnInputAllowed(general); await assertReservedTurnPermission(worldState, general, 'nation', update.action, update.args); updates.push(update); diff --git a/app/game-api/src/server.ts b/app/game-api/src/server.ts index 11c14a1e..5cb33387 100644 --- a/app/game-api/src/server.ts +++ b/app/game-api/src/server.ts @@ -40,6 +40,7 @@ import { } from './realtime/publicEvent.js'; import { GatewayHttpAccountIconSource } from './auth/accountIconSource.js'; import { GatewayHttpProfileStatusSource } from './auth/profileStatusSource.js'; +import { CachedTurnEngineStatus } from './services/turnEngineStatus.js'; import { createAdminProfileIconResetFlushHandler } from './services/accountIconSync.js'; import { AccountIconResetReconciler } from './services/accountIconResetReconciler.js'; import { createBestEffortResourceCloser } from './services/bestEffortResourceCloser.js'; @@ -115,6 +116,7 @@ export const createGameApiServer = async () => { config.gatewayInternalApiUrl, config.gameTokenSecret ); + const turnEngineStatus = new CachedTurnEngineStatus(profileStatusSource, postgres.prisma, config.profileName); const turnDaemon = new DatabaseTurnDaemonTransport(postgres.prisma, config.daemonRequestTimeoutMs); const accountIconResetReconciler = new AccountIconResetReconciler( @@ -383,13 +385,24 @@ export const createGameApiServer = async () => { }); }); + let heartbeatPending = false; const heartbeat = setInterval(() => { - sendFrame( - formatSseFrame({ - event: 'ping', - data: '{}', + if (heartbeatPending) return; + heartbeatPending = true; + void turnEngineStatus + .get() + .then((turnEngineRunning) => { + if (closed) return; + sendFrame( + formatSseFrame({ + event: 'ping', + data: JSON.stringify({ turnEngineRunning }), + }) + ); }) - ); + .finally(() => { + heartbeatPending = false; + }); }, 15000); const close = () => { diff --git a/app/game-api/src/services/turnEngineStatus.ts b/app/game-api/src/services/turnEngineStatus.ts new file mode 100644 index 00000000..7b4dfde4 --- /dev/null +++ b/app/game-api/src/services/turnEngineStatus.ts @@ -0,0 +1,63 @@ +import { gatewayProfileCapabilities } from '@sammo-ts/common'; + +import type { ProfileStatusSource } from '../auth/profileStatusSource.js'; + +interface TurnDaemonLeaseSource { + turnDaemonLease: { + findUnique(input: { + where: { profile: string }; + select: { leaseUntil: true }; + }): Promise<{ leaseUntil: Date } | null>; + }; +} + +export const loadTurnEngineRunning = async ( + source: ProfileStatusSource | undefined, + db: TurnDaemonLeaseSource, + profileName: string, + now = new Date() +): Promise => { + if (!source) return null; + try { + const status = await source.get(profileName); + if (status === null) return null; + if (!gatewayProfileCapabilities(status).turnsRunning) return false; + const lease = await db.turnDaemonLease.findUnique({ + where: { profile: profileName }, + select: { leaseUntil: true }, + }); + return lease !== null && lease.leaseUntil.getTime() > now.getTime(); + } catch { + return null; + } +}; + +export class CachedTurnEngineStatus { + private cachedAt = Number.NEGATIVE_INFINITY; + private cachedValue: boolean | null = null; + private pending: Promise | null = null; + + constructor( + private readonly source: ProfileStatusSource, + private readonly db: TurnDaemonLeaseSource, + private readonly profileName: string, + private readonly cacheMs = 2_000, + private readonly now = () => Date.now() + ) {} + + get(): Promise { + if (this.now() - this.cachedAt < this.cacheMs) { + return Promise.resolve(this.cachedValue); + } + if (this.pending) return this.pending; + + this.pending = loadTurnEngineRunning(this.source, this.db, this.profileName).then((value) => { + this.cachedValue = value; + this.cachedAt = this.now(); + return value; + }); + return this.pending.finally(() => { + this.pending = null; + }); + } +} diff --git a/app/game-api/src/turns/commandInput.ts b/app/game-api/src/turns/commandInput.ts index 87ed7a98..fa12257e 100644 --- a/app/game-api/src/turns/commandInput.ts +++ b/app/game-api/src/turns/commandInput.ts @@ -362,7 +362,7 @@ export const loadTurnCommandSpecs = async (scenarioConst?: unknown) => { }; }; -export const parseRegisteredTurnArgs = async ( +const parseRegisteredTurnArgs = async ( scope: 'general' | 'nation', action: string, rawArgs: unknown diff --git a/app/game-api/test/commandInput.test.ts b/app/game-api/test/commandInput.test.ts index f01f3d49..a6b143d2 100644 --- a/app/game-api/test/commandInput.test.ts +++ b/app/game-api/test/commandInput.test.ts @@ -99,6 +99,31 @@ describe('turn command argument input', () => { await expect(parseReservedTurnArgs('general', 'che_포상', {})).rejects.toThrow('Unknown general turn command'); }); + it('rejects internal general commands before parsing their arguments or scenario overrides', async () => { + await expect(parseReservedTurnArgs('general', 'che_NPC능동', {})).rejects.toThrow( + 'Unknown general turn command: che_NPC능동' + ); + await expect(parseReservedTurnArgs('general', 'che_방랑', {})).rejects.toThrow( + 'Unknown general turn command: che_방랑' + ); + await expect( + parseReservedTurnArgs('general', 'che_등용수락', { destNationId: 1, destGeneralId: 2 }) + ).rejects.toThrow('Unknown general turn command: che_등용수락'); + + await expect( + parseReservedTurnArgs( + 'general', + 'che_NPC능동', + { optionText: '순간이동', destCityId: 1 }, + { + availableGeneralCommand: { + 내부: ['휴식', 'che_NPC능동'], + }, + } + ) + ).rejects.toThrow('Unknown scenario general command key: che_NPC능동'); + }); + it('accepts and rejects reserved commands from the real 904/905/910/912 world config', async () => { const scenarioConsts = Object.fromEntries( await Promise.all( diff --git a/app/game-api/test/lobbyRouter.test.ts b/app/game-api/test/lobbyRouter.test.ts index c9c09ec8..f1932c0c 100644 --- a/app/game-api/test/lobbyRouter.test.ts +++ b/app/game-api/test/lobbyRouter.test.ts @@ -39,8 +39,12 @@ const buildContext = ( nation: { count: vi.fn(async () => 0), }, + turnDaemonLease: { + findUnique: vi.fn(async () => ({ leaseUntil: new Date('2099-01-01T00:00:00.000Z') })), + }, } as unknown as DatabaseClient, - }) as GameApiContext; + profileStatusSource: { get: vi.fn(async () => 'RUNNING' as const) }, + }) as unknown as GameApiContext; describe('lobby season state', () => { it.each([0, 1, 2, 3])('returns legacy isunited state %i', async (isunited) => { @@ -70,9 +74,28 @@ describe('lobby season state', () => { expect(result.clockMode).toBe('manual'); expect(result.clockRunning).toBe(false); expect(result.clockStartsAt).toBeNull(); + expect(result.turnEngineRunning).toBe(true); expect(new Date(result.serverWallTime).getTime()).not.toBeNaN(); }); + it('projects the explicit Gateway turn-running capability independently of the game clock mode', async () => { + const context = buildContext( + {}, + { + baseTime: new Date('2026-08-15T00:00:00.000Z'), + tick: 72_000_000n, + mode: 'realtime', + wallAnchor: new Date('2026-08-15T00:00:00.000Z'), + } + ); + context.profileStatusSource = { get: vi.fn(async () => 'PAUSED' as const) }; + + const result = await appRouter.createCaller(context).lobby.info(); + + expect(result.clockRunning).toBe(true); + expect(result.turnEngineRunning).toBe(false); + }); + it('exposes the future realtime wall anchor without advancing the preopen clock', async () => { const wallAnchor = new Date('2099-08-21T11:00:00.000Z'); const result = await appRouter diff --git a/app/game-api/test/router.test.ts b/app/game-api/test/router.test.ts index 7634ddc2..bbe9523f 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -1440,7 +1440,12 @@ describe('appRouter', () => { const generalWrites: unknown[] = []; const nationWrites: unknown[] = []; const caller = appRouter.createCaller( - buildContext({ general, generalTurnWrites: generalWrites, nationTurnWrites: nationWrites }) + buildContext({ + state: buildWorldState(), + general, + generalTurnWrites: generalWrites, + nationTurnWrites: nationWrites, + }) ); await expect( @@ -1461,6 +1466,18 @@ describe('appRouter', () => { expectedRevision: 0, }) ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + await expect( + caller.turns.reserved.setGeneral({ + generalId: 14, + turnIndex: 0, + action: 'che_NPC능동', + args: {}, + expectedRevision: 0, + }) + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: 'Unknown general turn command: che_NPC능동', + }); expect(generalWrites).toHaveLength(0); expect(nationWrites).toHaveLength(0); diff --git a/app/game-api/test/turnEngineStatus.test.ts b/app/game-api/test/turnEngineStatus.test.ts new file mode 100644 index 00000000..8d6e1e26 --- /dev/null +++ b/app/game-api/test/turnEngineStatus.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { CachedTurnEngineStatus, loadTurnEngineRunning } from '../src/services/turnEngineStatus.js'; + +describe('turn engine status projection', () => { + it('maps Gateway profile capabilities and keeps unavailable status unknown', async () => { + const activeLease = { + turnDaemonLease: { + findUnique: vi.fn(async () => ({ leaseUntil: new Date('2026-08-24T00:01:00.000Z') })), + }, + }; + const now = new Date('2026-08-24T00:00:00.000Z'); + await expect(loadTurnEngineRunning({ get: async () => 'RUNNING' }, activeLease, 'che:default', now)).resolves.toBe( + true + ); + await expect(loadTurnEngineRunning({ get: async () => 'PREOPEN' }, activeLease, 'che:default', now)).resolves.toBe( + false + ); + await expect(loadTurnEngineRunning({ get: async () => 'PAUSED' }, activeLease, 'che:default', now)).resolves.toBe( + false + ); + await expect(loadTurnEngineRunning({ get: async () => null }, activeLease, 'che:default', now)).resolves.toBeNull(); + await expect( + loadTurnEngineRunning( + { get: async () => Promise.reject(new Error('gateway unavailable')) }, + activeLease, + 'che:default', + now + ) + ).resolves.toBeNull(); + }); + + it('marks a RUNNING profile stopped when its daemon lease is missing or expired', async () => { + const source = { get: async () => 'RUNNING' as const }; + const now = new Date('2026-08-24T00:00:00.000Z'); + await expect( + loadTurnEngineRunning( + source, + { turnDaemonLease: { findUnique: async () => null } }, + 'che:default', + now + ) + ).resolves.toBe(false); + await expect( + loadTurnEngineRunning( + source, + { + turnDaemonLease: { + findUnique: async () => ({ leaseUntil: new Date('2026-08-23T23:59:59.999Z') }), + }, + }, + 'che:default', + now + ) + ).resolves.toBe(false); + }); + + it('coalesces concurrent heartbeat reads and refreshes after the bounded cache window', async () => { + let now = 1_000; + const get = vi.fn(async () => 'RUNNING' as const); + const findUnique = vi.fn(async () => ({ leaseUntil: new Date('2099-01-01T00:00:00.000Z') })); + const cache = new CachedTurnEngineStatus( + { get }, + { turnDaemonLease: { findUnique } }, + 'che:default', + 2_000, + () => now + ); + + await expect(Promise.all([cache.get(), cache.get()])).resolves.toEqual([true, true]); + expect(get).toHaveBeenCalledTimes(1); + now += 1_999; + await expect(cache.get()).resolves.toBe(true); + expect(get).toHaveBeenCalledTimes(1); + now += 1; + await expect(cache.get()).resolves.toBe(true); + expect(get).toHaveBeenCalledTimes(2); + expect(findUnique).toHaveBeenCalledTimes(2); + }); +}); diff --git a/app/game-engine/src/turn/reservedTurnCommands.ts b/app/game-engine/src/turn/reservedTurnCommands.ts index a4317d08..4639bf8d 100644 --- a/app/game-engine/src/turn/reservedTurnCommands.ts +++ b/app/game-engine/src/turn/reservedTurnCommands.ts @@ -8,6 +8,7 @@ import type { UnitSetDefinition, } from '@sammo-ts/logic'; import { + INTERNAL_GENERAL_TURN_COMMAND_KEYS, LEGACY_RANDOM_GENERAL_FIRST_NAMES, LEGACY_RANDOM_GENERAL_LAST_NAMES, LEGACY_DEFAULT_MAX_LEVEL, @@ -173,6 +174,7 @@ export const buildReservedTurnDefinitions = async (options: { env: TurnCommandEnv; commandProfile: TurnCommandProfile; defaultActionKey: GeneralTurnCommandKey & NationTurnCommandKey; + internalGeneralCommandKeys?: readonly GeneralTurnCommandKey[]; }): Promise<{ general: Map; nation: Map; @@ -198,7 +200,10 @@ export const buildReservedTurnDefinitions = async (options: { options.env.warActionModules ??= moduleBundle.war; options.env.nationTraitModules = moduleBundle.nationTraitModules; - const generalSpecs = await loadGeneralTurnCommandSpecs(options.commandProfile.general); + const generalSpecs = await loadGeneralTurnCommandSpecs([ + ...options.commandProfile.general, + ...(options.internalGeneralCommandKeys ?? INTERNAL_GENERAL_TURN_COMMAND_KEYS), + ]); const nationSpecs = await loadNationTurnCommandSpecs(options.commandProfile.nation); const general = new Map(generalSpecs.map((spec) => [spec.key, spec.createDefinition(options.env)])); diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 57be4490..c787e0c4 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -10,13 +10,13 @@ import type { ScenarioConfig, ScenarioMeta, Troop, - GeneralTurnCommandKey, TurnCommandProfile, TurnCommandEnv, UnitSetDefinition, } from '@sammo-ts/logic'; import { DEFAULT_TURN_COMMAND_PROFILE, + INTERNAL_GENERAL_TURN_COMMAND_KEYS, GeneralTurnCommandLoader, GeneralActionPipeline, NationTurnCommandLoader, @@ -71,8 +71,6 @@ import { } from './scenarioStaticEvents.js'; const DEFAULT_ACTION = '휴식'; -const AI_INTERNAL_GENERAL_ACTION_KEYS = ['che_NPC능동'] as const satisfies readonly GeneralTurnCommandKey[]; - const LEGACY_STAT_CHANGE_GENERAL_ACTIONS = new Set([ 'che_소집해제', 'che_랜덤임관', @@ -964,10 +962,9 @@ export const createReservedTurnHandler = async (options: { }; const generalModuleLoader = new GeneralTurnCommandLoader(); const nationModuleLoader = new NationTurnCommandLoader(); - // NPC AI emits a few engine-internal commands that are intentionally not - // exposed by the scenario's player command profile. Keep their definitions - // available to AI resolution without adding them to the public profile. - for (const key of AI_INTERNAL_GENERAL_ACTION_KEYS) { + // AI·월간 이벤트·서신이 생성하는 내부 명령은 사용자 선택 profile에는 + // 노출하지 않지만, 내부 실행 경로에서는 항상 정의와 context를 찾을 수 있어야 한다. + for (const key of INTERNAL_GENERAL_TURN_COMMAND_KEYS) { const module = await generalModuleLoader.load(key); if (!generalDefinitions.has(key)) { generalDefinitions.set(key, module.commandSpec.createDefinition(env)); @@ -2460,18 +2457,13 @@ export const createImmediateGeneralActionExecutor = async (options: { const env = buildCommandEnv(options.world.getScenarioConfig(), options.world.getUnitSet()); const commandProfile = options.commandProfile ?? DEFAULT_TURN_COMMAND_PROFILE; // 등용수락은 예약 화면에 노출되는 명령이 아니라 등용 서신의 응답이 - // 직접 실행하는 내부 명령이다. 선택 가능 명령 프로필에 없더라도 등용 - // 서신을 수락할 수 있도록 즉시 행동 정의에는 항상 포함한다. - const immediateCommandProfile: TurnCommandProfile = commandProfile.general.includes('che_등용수락') - ? commandProfile - : { - ...commandProfile, - general: [...commandProfile.general, 'che_등용수락'], - }; + // 직접 실행하는 내부 명령이다. 공통 내부 집합 전체 대신 이 실행기에 필요한 + // 정의만 명시해 loader 경계를 재사용한다. const { general: definitions } = await buildReservedTurnDefinitions({ env, - commandProfile: immediateCommandProfile, + commandProfile, defaultActionKey: DEFAULT_ACTION, + internalGeneralCommandKeys: ['che_등용수락'], }); const generalModuleLoader = new GeneralTurnCommandLoader(); const contextBuilders = new Map(); diff --git a/app/game-engine/test/inheritanceActiveActionInventory.test.ts b/app/game-engine/test/inheritanceActiveActionInventory.test.ts index 102aa023..20811619 100644 --- a/app/game-engine/test/inheritanceActiveActionInventory.test.ts +++ b/app/game-engine/test/inheritanceActiveActionInventory.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { DEFAULT_TURN_COMMAND_PROFILE, type ScenarioConfig } from '@sammo-ts/logic'; +import { + DEFAULT_TURN_COMMAND_PROFILE, + INTERNAL_GENERAL_TURN_COMMAND_KEYS, + type ScenarioConfig, +} from '@sammo-ts/logic'; import { buildCommandEnv, buildReservedTurnDefinitions } from '../src/turn/reservedTurnCommands.js'; @@ -54,6 +58,7 @@ describe('Ref active-action inheritance inventory', () => { env: buildCommandEnv(scenarioConfig), commandProfile: DEFAULT_TURN_COMMAND_PROFILE, defaultActionKey: '휴식', + internalGeneralCommandKeys: INTERNAL_GENERAL_TURN_COMMAND_KEYS, }); const generalWithFixedOrContextAmount = [...general.entries()] @@ -71,4 +76,14 @@ describe('Ref active-action inheritance inventory', () => { .sort(); expect(nationWithPoint).toEqual([...nationCommands].sort()); }); + + it('loads every internal command without adding it to the selectable profile', async () => { + const { general } = await buildReservedTurnDefinitions({ + env: buildCommandEnv(scenarioConfig), + commandProfile: { general: ['휴식'], nation: ['휴식'] }, + defaultActionKey: '휴식', + }); + + expect([...general.keys()].sort()).toEqual(['che_NPC능동', 'che_등용수락', 'che_방랑', '휴식'].sort()); + }); }); diff --git a/app/game-engine/test/monthlyInvaderAction.test.ts b/app/game-engine/test/monthlyInvaderAction.test.ts index 3d0c29c8..751ff92e 100644 --- a/app/game-engine/test/monthlyInvaderAction.test.ts +++ b/app/game-engine/test/monthlyInvaderAction.test.ts @@ -10,6 +10,7 @@ import { import { createMonthlyEventHandler } from '../src/turn/monthlyEventHandler.js'; import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js'; import { buildCommandEnv } from '../src/turn/reservedTurnCommands.js'; +import { createReservedTurnHandler } from '../src/turn/reservedTurnHandler.js'; import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; const buildCity = (id: number, nationId: number, level: number): City => ({ @@ -313,8 +314,24 @@ describe('invader monthly actions', () => { getWorld: () => world, reservedTurns: harness.reservedTurns, }); + const reservedTurnHandler = await createReservedTurnHandler({ + reservedTurns: harness.reservedTurns, + scenarioConfig, + scenarioMeta: { + title: '내부 방랑 실행 fixture', + startYear: 190, + life: null, + fiction: null, + history: [], + ignoreDefaultEvents: false, + }, + map: harness.snapshot.map, + getWorld: () => world, + commandProfile: { general: ['휴식'], nation: ['휴식'] }, + }); world = new InMemoryTurnWorld(state, harness.snapshot, { schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + generalTurnHandler: reservedTurnHandler, calendarHandler: createMonthlyEventHandler({ getWorld: () => world, startYear: 190, @@ -342,6 +359,17 @@ describe('invader monthly actions', () => { Array.from({ length: 30 }, () => ({ action: 'che_방랑', args: {} })) ); expect(harness.reservedTurns.peekDirtyState().generalIds).toEqual([2]); + + const ruler = world.getGeneralById(2); + expect(ruler).not.toBeNull(); + expect(() => world.executeGeneralTurn(ruler!)).not.toThrow(); + expect(world.getNationById(2)).toMatchObject({ + name: 'ⓞ도시2대왕', + level: 0, + capitalCityId: 0, + typeCode: 'None', + }); + expect(world.getCityById(2)?.nationId).toBe(0); }); it('finishes with the legacy user-win logs and refresh multiplier', async () => { diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 772269e1..edbe8e46 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -42,6 +42,7 @@ type NavigationFixture = { clockMode?: 'realtime' | 'manual'; clockRunning?: boolean; clockStartsAt?: string | null; + turnEngineRunning?: boolean | null; cityDefence?: number; cityState?: number; nationRate?: number; @@ -598,6 +599,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => { clockMode: state.clockMode ?? 'realtime', clockRunning: state.clockRunning ?? true, clockStartsAt: state.clockStartsAt ?? null, + turnEngineRunning: state.turnEngineRunning === undefined ? true : state.turnEngineRunning, scenarioTitle: state.scenarioTitle ?? '', }); } @@ -2037,7 +2039,7 @@ test('main general card uses local turn time and command clock tracks corrected expect(state.operations).toHaveLength(operationsBeforePreopenBoundary); }); -test('main header clock follows minute boundaries only while game-server contact is recent', async ({ +test('main header clock follows minute boundaries only while the turn engine is running', async ({ page, }, testInfo) => { const state: NavigationFixture = { @@ -2052,6 +2054,7 @@ test('main header clock follows minute boundaries only while game-server contact serverWallTime: '2026-08-13T00:00:00.000Z', clockMode: 'realtime', clockRunning: true, + turnEngineRunning: true, }; await installRealtimeHarness(page); await installFixture(page, state); @@ -2063,37 +2066,42 @@ test('main header clock follows minute boundaries only while game-server contact const clock = page.locator('.execution-status'); const initialRequestCount = state.trpcRequests?.length ?? 0; await expect(clock).toHaveText('현재 시각: 08-13 09:00'); - await expect(clock).not.toHaveClass(/execution-status--stale/u); + await expect(clock).not.toHaveClass(/execution-status--stopped/u); await page.clock.runFor(25_000); await expect(clock).toHaveText('현재 시각: 08-13 09:01'); - await page.clock.runFor(21_000); - await expect(clock).toHaveClass(/execution-status--stale/u); - await expect(clock).toHaveAttribute('title', '최근 45초 동안 서버 통신이 없어 시각 갱신을 멈췄습니다.'); + await page.evaluate(() => { + (window as unknown as { __emitMainRealtime: (type: string, value: unknown) => void }).__emitMainRealtime( + 'ping', + { turnEngineRunning: false } + ); + }); + await expect(clock).toHaveClass(/execution-status--stopped/u); + await expect(clock).toHaveAttribute('title', '턴 엔진이 정지하여 현재 시각 보정을 멈췄습니다.'); await expect.poll(() => clock.evaluate((element) => getComputedStyle(element).color)).toBe('rgb(255, 0, 255)'); - const staleDesktopGeometry = await clock.evaluate((element) => ({ + const stoppedDesktopGeometry = await clock.evaluate((element) => ({ rect: element.getBoundingClientRect().toJSON(), overflow: element.scrollWidth - element.clientWidth, color: getComputedStyle(element).color, fontSize: getComputedStyle(element).fontSize, lineHeight: getComputedStyle(element).lineHeight, })); - expect(staleDesktopGeometry.rect.width).toBeCloseTo(333.33, 0); - expect(staleDesktopGeometry.rect.height).toBeGreaterThanOrEqual(36); - expect(staleDesktopGeometry.overflow).toBeLessThanOrEqual(0); - await clock.screenshot({ path: testInfo.outputPath('main-header-clock-stale-desktop-1200.png') }); - await page.clock.runFor(60_000); + expect(stoppedDesktopGeometry.rect.width).toBeCloseTo(333.33, 0); + expect(stoppedDesktopGeometry.rect.height).toBeGreaterThanOrEqual(36); + expect(stoppedDesktopGeometry.overflow).toBeLessThanOrEqual(0); + await clock.screenshot({ path: testInfo.outputPath('main-header-clock-stopped-desktop-1200.png') }); + await page.clock.runFor(81_000); await expect(clock).toHaveText('현재 시각: 08-13 09:01'); await page.evaluate(() => { (window as unknown as { __emitMainRealtime: (type: string, value: unknown) => void }).__emitMainRealtime( 'ping', - {} + { turnEngineRunning: true } ); }); await expect(clock).toHaveText('현재 시각: 08-13 09:02'); - await expect(clock).not.toHaveClass(/execution-status--stale/u); + await expect(clock).not.toHaveClass(/execution-status--stopped/u); await page.clock.runFor(39_000); await expect(clock).toHaveText('현재 시각: 08-13 09:03'); await expect.poll(() => clock.evaluate((element) => getComputedStyle(element).color)).toBe('rgb(0, 255, 255)'); @@ -2114,7 +2122,7 @@ test('main header clock follows minute boundaries only while game-server contact clock.screenshot({ path: testInfo.outputPath('main-header-clock-fresh-mobile-500.png') }), writeFile( testInfo.outputPath('main-header-clock-geometry.json'), - `${JSON.stringify({ staleDesktopGeometry, freshMobileGeometry }, null, 2)}\n` + `${JSON.stringify({ stoppedDesktopGeometry, freshMobileGeometry }, null, 2)}\n` ), ]); expect(state.trpcRequests?.length ?? 0).toBe(initialRequestCount); diff --git a/app/game-frontend/src/components/main/MainFrontStatus.vue b/app/game-frontend/src/components/main/MainFrontStatus.vue index b8e5d1d2..037bbb14 100644 --- a/app/game-frontend/src/components/main/MainFrontStatus.vue +++ b/app/game-frontend/src/components/main/MainFrontStatus.vue @@ -2,11 +2,6 @@ import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime'; import { computed, onUnmounted, ref, watch } from 'vue'; import { resolveTournamentStageName } from '../../utils/tournamentStatus'; -import { - GAME_SERVER_ACTIVITY_FRESHNESS_MS, - gameServerActivity, - isRecentGameServerActivity, -} from '../../utils/gameServerActivity'; import { millisecondsUntilNextMinute, projectServerClock, @@ -21,6 +16,7 @@ const props = defineProps<{ clockMode?: 'realtime' | 'manual'; clockRunning?: boolean; clockStartsAt?: string | null; + turnEngineRunning?: boolean | null; status: { onlineUserCount: number; onlineNations: string; @@ -38,10 +34,12 @@ const props = defineProps<{ const tournamentStatus = computed(() => resolveTournamentStageName(props.tournamentStage)); const currentServerTime = ref('기록 없음'); const hasServerClock = ref(false); -const serverClockFresh = ref(false); +const turnEngineStopped = computed(() => props.turnEngineRunning === false); +const turnEngineStatusUnknown = computed(() => typeof props.turnEngineRunning !== 'boolean'); const serverClockTitle = computed(() => { if (!hasServerClock.value) return '서버 시각을 아직 받지 못했습니다.'; - if (!serverClockFresh.value) return '최근 45초 동안 서버 통신이 없어 시각 갱신을 멈췄습니다.'; + if (turnEngineStopped.value) return '턴 엔진이 정지하여 현재 시각 보정을 멈췄습니다.'; + if (turnEngineStatusUnknown.value) return '턴 엔진 진행 상태를 확인하지 못했습니다.'; return undefined; }); @@ -54,7 +52,6 @@ const updateServerClock = () => { if (serverClockSample === null) { currentServerTime.value = '기록 없음'; hasServerClock.value = false; - serverClockFresh.value = false; return; } @@ -65,16 +62,14 @@ const updateServerClock = () => { fallback: '기록 없음', }); hasServerClock.value = true; + if (props.turnEngineRunning !== true) return; - const lastContactAt = gameServerActivity.lastContactAt.value; - serverClockFresh.value = isRecentGameServerActivity(lastContactAt, now); - if (!serverClockFresh.value || lastContactAt === null) return; - - const nextDelays = [lastContactAt + GAME_SERVER_ACTIVITY_FRESHNESS_MS - now + 1]; + const nextDelays: number[] = []; if (serverClockSample.clockMode !== 'manual' && serverClockSample.startDelayMs !== null) { const untilStartMs = serverClockSample.startDelayMs - projection.clientElapsedMs; nextDelays.push(untilStartMs > 0 ? untilStartMs : millisecondsUntilNextMinute(projection.time)); } + if (nextDelays.length === 0) return; serverClockTimer = setTimeout(updateServerClock, Math.max(1, Math.min(...nextDelays))); }; @@ -86,7 +81,7 @@ watch( }, { immediate: true } ); -watch(() => gameServerActivity.lastContactAt.value, updateServerClock); +watch(() => props.turnEngineRunning, updateServerClock); onUnmounted(() => { if (serverClockTimer !== undefined) clearTimeout(serverClockTimer); @@ -100,7 +95,8 @@ onUnmounted(() => { class="status-row execution-status" :class="{ 'execution-status--empty': !hasServerClock, - 'execution-status--stale': hasServerClock && !serverClockFresh, + 'execution-status--stopped': hasServerClock && turnEngineStopped, + 'execution-status--unknown': hasServerClock && turnEngineStatusUnknown, }" :title="serverClockTitle" > @@ -195,10 +191,14 @@ onUnmounted(() => { color: magenta; } -.execution-status--stale { +.execution-status--stopped { color: magenta; } +.execution-status--unknown { + color: #aaa; +} + .vote-label { color: cyan; } diff --git a/app/game-frontend/src/stores/mainDashboard.ts b/app/game-frontend/src/stores/mainDashboard.ts index ad44ed3f..ed15d8ca 100644 --- a/app/game-frontend/src/stores/mainDashboard.ts +++ b/app/game-frontend/src/stores/mainDashboard.ts @@ -81,7 +81,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { tournamentType?: TournamentType | null; }; type DashboardTabMessage = - { kind: 'patch'; patch: DashboardReadModelPatch } | { kind: 'status'; status: 'idle' | 'connected' }; + | { kind: 'patch'; patch: DashboardReadModelPatch } + | { + kind: 'status'; + status: 'idle' | 'connected'; + turnEngineRunning?: boolean | null; + }; const loading = ref(false); const refreshing = ref(false); @@ -467,6 +472,14 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { } }; + const applyTurnEngineRunning = (turnEngineRunning: boolean | null | undefined) => { + if (turnEngineRunning === undefined || !lobbyInfo.value) return; + lobbyInfo.value = structurallyShare(lobbyInfo.value, { + ...lobbyInfo.value, + turnEngineRunning, + }); + }; + const currentDashboardPatch = (): DashboardReadModelPatch => { const patch: DashboardReadModelPatch = {}; patch.contextSnapshot = toRaw(contextSnapshot); @@ -1115,6 +1128,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { return; } realtimeStatus.value = message.status; + applyTurnEngineRunning(message.turnEngineRunning); if (message.status === 'connected') markGameServerContact(); }, }); @@ -1209,11 +1223,27 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { markGameServerContact(); void refreshMessages(); }); - source.addEventListener('ping', () => { + source.addEventListener('ping', (event) => { + let turnEngineRunning: boolean | null | undefined; + if (event instanceof MessageEvent && typeof event.data === 'string') { + try { + const payload = JSON.parse(event.data) as { turnEngineRunning?: unknown }; + if (typeof payload.turnEngineRunning === 'boolean' || payload.turnEngineRunning === null) { + turnEngineRunning = payload.turnEngineRunning; + } + } catch { + // Older APIs send an empty heartbeat. Keep the last explicit engine state. + } + } + applyTurnEngineRunning(turnEngineRunning); markGameServerContact(); if (realtimeEnabled.value) { realtimeStatus.value = 'connected'; - realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'connected' }); + realtimeCoordinator?.postFromLeader({ + kind: 'status', + status: 'connected', + ...(turnEngineRunning === undefined ? {} : { turnEngineRunning }), + }); } }); }; diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index d5de7c4b..1a782515 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -253,6 +253,7 @@ watch( :clock-mode="lobbyInfo?.clockMode" :clock-running="lobbyInfo?.clockRunning" :clock-starts-at="lobbyInfo?.clockStartsAt" + :turn-engine-running="lobbyInfo?.turnEngineRunning" /> diff --git a/app/gateway-frontend/e2e/legacy-log-html.spec.ts b/app/gateway-frontend/e2e/legacy-log-html.spec.ts index 3773e1df..e525ec94 100644 --- a/app/gateway-frontend/e2e/legacy-log-html.spec.ts +++ b/app/gateway-frontend/e2e/legacy-log-html.spec.ts @@ -115,6 +115,13 @@ for (const viewport of [ await expect(lines.nth(1)).toContainText(' getComputedStyle(element).fontFamily); + const historyFontFamily = await page + .locator('.status-history') + .evaluate((element) => getComputedStyle(element).fontFamily); + expect(historyFontFamily).toBe(pageFontFamily); + expect(historyFontFamily).toContain('Pretendard'); + const geometry = await lines.evaluateAll((elements) => elements.map((element) => { const rect = element.getBoundingClientRect(); @@ -144,7 +151,7 @@ for (const viewport of [ const name = `legacy-log-gateway-${viewport.name}`; await writeFile( resolve(artifactRoot, `${name}.json`), - `${JSON.stringify({ viewport, geometry }, null, 2)}\n`, + `${JSON.stringify({ viewport, pageFontFamily, historyFontFamily, geometry }, null, 2)}\n`, 'utf8' ); await page.screenshot({ path: resolve(artifactRoot, `${name}.png`), fullPage: true }); diff --git a/app/gateway-frontend/src/views/HomeView.vue b/app/gateway-frontend/src/views/HomeView.vue index 03361fd6..932f5ddf 100644 --- a/app/gateway-frontend/src/views/HomeView.vue +++ b/app/gateway-frontend/src/views/HomeView.vue @@ -362,7 +362,7 @@ const handlePasswordReset = async (): Promise => { border-top: 1px solid #444; padding: 8px 12px; color: #ddd; - font-family: 'Times New Roman', serif; + font-family: inherit; font-size: 14px; line-height: 1.35; } diff --git a/app/gateway-frontend/test/fontContract.test.mjs b/app/gateway-frontend/test/fontContract.test.mjs new file mode 100644 index 00000000..d8ca7d12 --- /dev/null +++ b/app/gateway-frontend/test/fontContract.test.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import { readFile, readdir } from 'node:fs/promises'; +import path from 'node:path'; +import { describe, it } from 'node:test'; + +const sourceRoot = path.resolve(import.meta.dirname, '../src'); +const fontFamilyDeclaration = /font-family\s*:\s*([^;]+);/g; +const fontShorthandDeclaration = /(?:^|[\s{])font\s*:\s*([^;]+);/gm; +const forcedSerifFamily = /Times New Roman|(? { + const entries = await readdir(sourceRoot, { recursive: true, withFileTypes: true }); + + return entries + .filter((entry) => entry.isFile() && (entry.name.endsWith('.css') || entry.name.endsWith('.vue'))) + .map((entry) => path.join(entry.parentPath, entry.name)) + .sort(); +}; + +describe('gateway content font contract', () => { + it('does not force Times New Roman or a generic serif family', async () => { + const violations = []; + + for (const file of await listStyleSources()) { + const source = await readFile(file, 'utf8'); + for (const match of source.matchAll(fontFamilyDeclaration)) { + const value = match[1]?.trim() ?? ''; + if (forcedSerifFamily.test(value)) { + violations.push(`${path.relative(sourceRoot, file)}: font-family: ${value}`); + } + } + + for (const match of source.matchAll(fontShorthandDeclaration)) { + const value = match[1]?.trim() ?? ''; + if (forcedSerifFamily.test(value)) { + violations.push(`${path.relative(sourceRoot, file)}: font: ${value}`); + } + } + } + + assert.deepEqual(violations, []); + }); +}); diff --git a/packages/logic/src/actions/turn/commandProfile.ts b/packages/logic/src/actions/turn/commandProfile.ts index 8d52917f..f40a574f 100644 --- a/packages/logic/src/actions/turn/commandProfile.ts +++ b/packages/logic/src/actions/turn/commandProfile.ts @@ -1,10 +1,14 @@ -import { GENERAL_TURN_COMMAND_KEYS, isGeneralTurnCommandKey, type GeneralTurnCommandKey } from './general/index.js'; +import { + SELECTABLE_GENERAL_TURN_COMMAND_KEYS, + isSelectableGeneralTurnCommandKey, + type SelectableGeneralTurnCommandKey, +} from './general/index.js'; import { NATION_TURN_COMMAND_KEYS, isNationTurnCommandKey, type NationTurnCommandKey } from './nation/index.js'; import { asStringArray, isRecord } from '@sammo-ts/common'; import { TurnCommandProfileInputSchema } from '../../resources/turnCommandSchema.js'; export interface TurnCommandProfile { - general: GeneralTurnCommandKey[]; + general: SelectableGeneralTurnCommandKey[]; nation: NationTurnCommandKey[]; } @@ -15,7 +19,7 @@ export interface TurnCommandGroup { export interface ScenarioTurnCommandProfileResolution { profile: TurnCommandProfile; - generalGroups: Array> | null; + generalGroups: Array> | null; nationGroups: Array> | null; } @@ -49,7 +53,7 @@ const parseKeyList = (options: { }; export const DEFAULT_TURN_COMMAND_PROFILE: TurnCommandProfile = { - general: [...GENERAL_TURN_COMMAND_KEYS], + general: [...SELECTABLE_GENERAL_TURN_COMMAND_KEYS], nation: [...NATION_TURN_COMMAND_KEYS], }; @@ -62,7 +66,7 @@ export const parseTurnCommandProfile = (raw: unknown): TurnCommandProfile => { return { general: parseKeyList({ raw: data.general, - isKey: isGeneralTurnCommandKey, + isKey: isSelectableGeneralTurnCommandKey, label: 'general', }), nation: parseKeyList({ @@ -133,7 +137,7 @@ export const resolveScenarioTurnCommandProfile = ( const config = isRecord(scenarioConst) ? scenarioConst : {}; const generalGroups = parseScenarioCommandGroups({ raw: config.availableGeneralCommand, - isKey: isGeneralTurnCommandKey, + isKey: isSelectableGeneralTurnCommandKey, label: 'general', }); const nationGroups = parseScenarioCommandGroups({ diff --git a/packages/logic/src/actions/turn/general/index.ts b/packages/logic/src/actions/turn/general/index.ts index ebd40290..b6a8f541 100644 --- a/packages/logic/src/actions/turn/general/index.ts +++ b/packages/logic/src/actions/turn/general/index.ts @@ -60,6 +60,21 @@ export const GENERAL_TURN_COMMAND_KEYS = [ export type GeneralTurnCommandKey = (typeof GENERAL_TURN_COMMAND_KEYS)[number]; +/** + * 엔진·서신·월간 이벤트만 생성할 수 있고 예약 API의 사용자 입력으로는 + * 노출하지 않는 명령입니다. 저장된 예약 턴은 문자열 action을 유지하므로, + * 입력 경계와 실행 경계를 서로 다른 집합으로 관리합니다. + */ +export const INTERNAL_GENERAL_TURN_COMMAND_KEYS = [ + 'che_등용수락', + 'che_방랑', + 'che_NPC능동', +] as const satisfies readonly GeneralTurnCommandKey[]; + +export type InternalGeneralTurnCommandKey = (typeof INTERNAL_GENERAL_TURN_COMMAND_KEYS)[number]; + +export type SelectableGeneralTurnCommandKey = Exclude; + export type GeneralTurnCommandSpec = TurnCommandSpecBase; export type GeneralTurnCommandModule = TurnCommandModule; @@ -127,6 +142,20 @@ const defaultImporters: Record GENERAL_TURN_COMMAND_KEYS.includes(value as GeneralTurnCommandKey); +const internalGeneralTurnCommandKeySet: ReadonlySet = new Set( + INTERNAL_GENERAL_TURN_COMMAND_KEYS +); + +export const isInternalGeneralTurnCommandKey = (value: string): value is InternalGeneralTurnCommandKey => + isGeneralTurnCommandKey(value) && internalGeneralTurnCommandKeySet.has(value); + +export const isSelectableGeneralTurnCommandKey = (value: string): value is SelectableGeneralTurnCommandKey => + isGeneralTurnCommandKey(value) && !internalGeneralTurnCommandKeySet.has(value); + +export const SELECTABLE_GENERAL_TURN_COMMAND_KEYS = GENERAL_TURN_COMMAND_KEYS.filter( + (key): key is SelectableGeneralTurnCommandKey => !internalGeneralTurnCommandKeySet.has(key) +); + export class GeneralTurnCommandLoader { private readonly cache = new Map>(); diff --git a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts index 73f2fa00..3b4ea3e8 100644 --- a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts +++ b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts @@ -2,6 +2,7 @@ import { GameClock, LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common'; import { readLegacyStoredFloat } from '@sammo-ts/logic/compat/legacyFloat.js'; import { GENERAL_TURN_COMMAND_KEYS, + isSelectableGeneralTurnCommandKey, LogFormat, NATION_TURN_COMMAND_KEYS, normalizeScenarioEffect, @@ -12,6 +13,7 @@ import { type MessageRecordDraft, type Nation, type TurnCommandProfile, + type SelectableGeneralTurnCommandKey, type UnitSetDefinition, } from '@sammo-ts/logic'; import { InMemoryTurnWorld } from '@sammo-ts/game-engine/turn/inMemoryWorld.js'; @@ -239,14 +241,14 @@ export const createCoreTurnCommandProfile = (request: TurnCommandFixtureRequest) if (!GENERAL_TURN_COMMAND_KEYS.includes(request.action as (typeof GENERAL_TURN_COMMAND_KEYS)[number])) { throw new Error(`Unknown general command: ${request.action}`); } - const generalActions = [ - request.action, - ...configuredGeneralActions, + const generalActions: SelectableGeneralTurnCommandKey[] = [ + ...(isSelectableGeneralTurnCommandKey(request.action) ? [request.action] : []), + ...configuredGeneralActions.filter(isSelectableGeneralTurnCommandKey), '휴식', 'che_인재탐색', 'che_해산', 'che_이동', - ] as Array<(typeof GENERAL_TURN_COMMAND_KEYS)[number]>; + ]; return { general: [...new Set(generalActions)], nation: [...new Set(['휴식', ...configuredNationActions])] as Array< @@ -257,10 +259,12 @@ export const createCoreTurnCommandProfile = (request: TurnCommandFixtureRequest) if (!NATION_TURN_COMMAND_KEYS.includes(request.action as (typeof NATION_TURN_COMMAND_KEYS)[number])) { throw new Error(`Unknown nation command: ${request.action}`); } + const generalActions: SelectableGeneralTurnCommandKey[] = [ + '휴식', + ...configuredGeneralActions.filter(isSelectableGeneralTurnCommandKey), + ]; return { - general: [...new Set(['휴식', ...configuredGeneralActions])] as Array< - (typeof GENERAL_TURN_COMMAND_KEYS)[number] - >, + general: [...new Set(generalActions)], nation: [ ...new Set([ request.action as (typeof NATION_TURN_COMMAND_KEYS)[number],