랜덤 임관 기수의 등용 실행을 차단

This commit is contained in:
2026-09-01 23:39:14 +00:00
parent 89a33a8349
commit 77299ffe41
4 changed files with 101 additions and 12 deletions
+10 -8
View File
@@ -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 () => {
+14
View File
@@ -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 () => {
@@ -268,12 +268,13 @@ export const applyLegacyGeneralProgression = (
const resolveConstraintEnv = (
world: TurnWorldState,
scenarioMeta: ScenarioMeta | undefined,
env: TurnCommandEnv
env: TurnCommandEnv,
worldConfig: Record<string, unknown>
): Record<string, unknown> => {
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(),
@@ -161,6 +161,7 @@ const buildImmediateActionWorld = (options: {
availableInstantRetreat?: boolean;
lastTurnTime?: Date;
scenarioConst?: Record<string, unknown>;
worldConfig?: Record<string, unknown>;
}) => {
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 } };