diff --git a/app/game-api/src/router/turns/index.ts b/app/game-api/src/router/turns/index.ts index 7622c507..ad740fa7 100644 --- a/app/game-api/src/router/turns/index.ts +++ b/app/game-api/src/router/turns/index.ts @@ -13,9 +13,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'; @@ -64,9 +63,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', @@ -76,22 +83,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(); @@ -428,9 +419,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(() => @@ -485,16 +475,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(() => @@ -532,9 +521,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); await assertReservedTurnPermission(worldState, general, 'nation', input.action, args); const snapshot = await mutateReservedTurns(() => @@ -639,16 +627,15 @@ export const turnsRouter = router({ message: 'General is not an officer.', }); } + const worldState = await getReservationWorldState(ctx); const updates = await Promise.all( input.entries.map(async (entry) => ({ turnIndices: entry.turnList, action: entry.action, - args: await parseCommandArgs('nation', entry.action, entry.args), + args: await parseCommandArgs('nation', entry.action, entry.args, worldState), })) ); - const worldState = await getReservationWorldState(ctx); for (const update of updates) { - await assertScenarioCommandAvailable('nation', update.action, worldState); await assertReservedTurnPermission(worldState, general, 'nation', update.action, update.args); } const snapshot = await mutateReservedTurns(() => 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/router.test.ts b/app/game-api/test/router.test.ts index 687c02dc..7834bdfc 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -1227,7 +1227,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( @@ -1248,6 +1253,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-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/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],