diff --git a/app/game-api/test/commandTable.test.ts b/app/game-api/test/commandTable.test.ts index edb2a399..3e0af34c 100644 --- a/app/game-api/test/commandTable.test.ts +++ b/app/game-api/test/commandTable.test.ts @@ -595,15 +595,17 @@ describe('buildTurnCommandTable', () => { nationGenerals: null, }); - const appointment = table.general - .flatMap((group) => group.values) - .find((command) => command.key === 'che_임관'); + for (const commandKey of ['che_임관', 'che_등용']) { + const command = table.general + .flatMap((group) => group.values) + .find((candidate) => candidate.key === commandKey); - expect(appointment).toMatchObject({ - possible: false, - status: 'blocked', - reason: '랜덤 임관만 가능합니다', - }); + expect(command).toMatchObject({ + possible: false, + status: 'blocked', + reason: '랜덤 임관만 가능합니다', + }); + } }); it('hides founding at the Ref maxnation boundary without counting Core id=0', async () => { diff --git a/app/game-api/test/router.test.ts b/app/game-api/test/router.test.ts index 93f9761d..f2425ade 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -1418,6 +1418,20 @@ describe('appRouter', () => { message: expect.stringContaining('랜덤 임관만 가능합니다'), }); expect(randomOnlyWrites).toHaveLength(0); + + await expect( + randomOnlyCaller.turns.reserved.setGeneral({ + generalId: general.id, + turnIndex: 0, + action: 'che_등용', + args: { destGeneralId: 2 }, + expectedRevision: 0, + }) + ).rejects.toMatchObject({ + code: 'PRECONDITION_FAILED', + message: expect.stringContaining('랜덤 임관만 가능합니다'), + }); + expect(randomOnlyWrites).toHaveLength(0); }); it('checks nation treaty-term reservation permission but not full diplomacy state', async () => { diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index bdb1afce..1c7b2795 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -268,12 +268,13 @@ export const applyLegacyGeneralProgression = ( const resolveConstraintEnv = ( world: TurnWorldState, scenarioMeta: ScenarioMeta | undefined, - env: TurnCommandEnv + env: TurnCommandEnv, + worldConfig: Record ): Record => { const worldMeta = asRecord(world.meta); const startYear = typeof scenarioMeta?.startYear === 'number' ? scenarioMeta.startYear : undefined; const relYear = typeof startYear === 'number' ? world.currentYear - startYear : undefined; - const joinModeRaw = worldMeta.join_mode ?? worldMeta.joinMode; + const joinModeRaw = worldConfig.join_mode ?? worldConfig.joinMode ?? worldMeta.join_mode ?? worldMeta.joinMode; const joinMode = joinModeRaw === 'onlyRandom' ? 'onlyRandom' : 'full'; const killturnRaw = worldMeta.killturn; const killturn = @@ -1037,7 +1038,12 @@ export const createReservedTurnHandler = async (options: { const worldOverlay = worldRef ? createWorldOverlay(worldRef) : null; const worldView = worldOverlay?.view ?? worldRef; const baseConstraintEnv = { - ...resolveConstraintEnv(context.world, options.scenarioMeta, env), + ...resolveConstraintEnv( + context.world, + options.scenarioMeta, + env, + options.getWorld()?.getWorldConfig() ?? asRecord(options.scenarioConfig) + ), ...(options.map ? { map: options.map } : {}), ...(options.unitSet ? { unitSet: options.unitSet } : {}), }; @@ -2522,7 +2528,7 @@ export const createImmediateGeneralActionExecutor = async (options: { const state = options.world.getState(); const constraintEnv = { - ...resolveConstraintEnv(state, options.scenarioMeta, env), + ...resolveConstraintEnv(state, options.scenarioMeta, env, options.world.getWorldConfig()), ...(options.map ? { map: options.map } : {}), ...(options.world.getUnitSet() ? { unitSet: options.world.getUnitSet() } : {}), cities: options.world.listCities(), diff --git a/app/game-engine/test/myInformationCommands.test.ts b/app/game-engine/test/myInformationCommands.test.ts index b8d888c8..7fa43849 100644 --- a/app/game-engine/test/myInformationCommands.test.ts +++ b/app/game-engine/test/myInformationCommands.test.ts @@ -161,6 +161,7 @@ const buildImmediateActionWorld = (options: { availableInstantRetreat?: boolean; lastTurnTime?: Date; scenarioConst?: Record; + worldConfig?: Record; }) => { const state: TurnWorldState = { id: 1, @@ -209,6 +210,7 @@ const buildImmediateActionWorld = (options: { }, }, scenarioMeta, + worldConfig: options.worldConfig, map: options.map, }; const world = new InMemoryTurnWorld(state, snapshot, { schedule }); @@ -694,6 +696,71 @@ describe('my information world commands', () => { ]); }); + it('rejects recruitment-letter acceptance in a random-appointment-only world', async () => { + const recipient = buildGeneral({ + id: 8, + userId: 'user-8', + name: '재야장수', + nationId: 0, + cityId: 1, + officerLevel: 0, + }); + const recruiter = buildGeneral({ + id: 9, + userId: 'user-9', + name: '등용장수', + nationId: 2, + cityId: 2, + }); + const map = { + id: 'test', + name: 'test', + cities: [buildMapCity(1, [2]), buildMapCity(2, [1])], + }; + const fixture = buildImmediateActionWorld({ + general: recipient, + additionalGenerals: [recruiter], + cities: [ + { id: 1, name: '낙양', nationId: 0, supplyState: 1, meta: {} }, + { id: 2, name: '장안', nationId: 2, supplyState: 1, meta: {} }, + ] as TurnWorldSnapshot['cities'], + nations: [ + { + id: 2, + name: '등용국', + color: '#222222', + typeCode: 'che_중립', + level: 1, + capitalCityId: 2, + chiefGeneralId: recruiter.id, + gold: 0, + rice: 0, + power: 0, + meta: { gennum: 1 }, + }, + ] as TurnWorldSnapshot['nations'], + map, + worldConfig: { joinMode: 'onlyRandom' }, + }); + const executor = await createImmediateGeneralActionExecutor({ + world: fixture.world, + reservedTurns: fixture.reservedTurns, + scenarioMeta: fixture.scenarioMeta, + map, + }); + + await expect( + executor.execute({ + actionKey: 'che_등용수락', + generalId: recipient.id, + rng: new RandUtil(new LiteHashDRBG('reject-random-only-recruitment-letter')), + args: { destNationId: 2, destGeneralId: recruiter.id }, + }) + ).resolves.toEqual({ ok: false, reason: '랜덤 임관만 가능합니다 등용수락 실패.' }); + expect(fixture.world.getGeneralById(recipient.id)?.nationId).toBe(0); + expect(fixture.world.peekDirtyState().logs).toHaveLength(0); + }); + it('accepts a recruitment letter after the recruiter was deleted and preserves the reserved command', async () => { const deletedRecruiterId = 99; const originalLastTurn = { command: '내정 특기 초기화', arg: { phase: 2 } };