diff --git a/packages/logic/src/actions/turn/general/che_징병.ts b/packages/logic/src/actions/turn/general/che_징병.ts index 636190f9..5e419091 100644 --- a/packages/logic/src/actions/turn/general/che_징병.ts +++ b/packages/logic/src/actions/turn/general/che_징병.ts @@ -597,7 +597,20 @@ export class ActionDefinition< reqCityTrust(20), reqGeneralGold(getCost, requirements), reqGeneralRice(getRice, requirements), - reqGeneralCrewMargin((context) => resolveCrewTypeId(context.args), requirements), + reqGeneralCrewMargin( + (context) => resolveCrewTypeId(context.args), + requirements, + (context, view) => { + const calcContext = buildCalcContext(context, view); + if (!calcContext) { + return null; + } + // Ref ReqGeneralCrewMargin reconstructs General and calls + // getLeadership(), so the precondition must use the same + // injured, trait, and item-adjusted capacity as getCost(). + return this.command.resolveLeadership(calcContext) * 100; + } + ), ]; if (ctx.mode === 'full') { diff --git a/packages/logic/src/constraints/general.ts b/packages/logic/src/constraints/general.ts index be9eb46a..aa716dcd 100644 --- a/packages/logic/src/constraints/general.ts +++ b/packages/logic/src/constraints/general.ts @@ -432,7 +432,8 @@ export const allowJoinDestNation = (relYear: number): Constraint => ({ export const reqGeneralCrewMargin = ( getCrewTypeId: (ctx: ConstraintContext, view: StateView) => number | null, - requirements: RequirementKey[] = [] + requirements: RequirementKey[] = [], + getMaxCrew?: (ctx: ConstraintContext, view: StateView) => number | null ): Constraint => ({ name: 'reqGeneralCrewMargin', requires: (ctx) => [{ kind: 'general', id: ctx.actorId }, ...requirements], @@ -453,7 +454,10 @@ export const reqGeneralCrewMargin = ( if (crewTypeId !== general.crewTypeId) { return allow(); } - const maxCrew = general.stats.leadership * 100; + const maxCrew = getMaxCrew ? getMaxCrew(ctx, view) : general.stats.leadership * 100; + if (maxCrew === null) { + return unknownOrDeny(ctx, requirements, '장수 정보가 없습니다.'); + } if (maxCrew > general.crew) { return allow(); } diff --git a/packages/logic/test/scenarios/recruitmentBonusLeadership.test.ts b/packages/logic/test/scenarios/recruitmentBonusLeadership.test.ts new file mode 100644 index 00000000..81942b1e --- /dev/null +++ b/packages/logic/test/scenarios/recruitmentBonusLeadership.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'vitest'; +import type { City, General, Nation } from '../../src/domain/entities.js'; +import { loadActionModuleBundle } from '../../src/actionModules/bundle.js'; +import { commandSpec as conscriptSpec } from '../../src/actions/turn/general/che_징병.js'; +import { commandSpec as recruitSpec } from '../../src/actions/turn/general/che_모병.js'; +import type { TurnCommandEnv } from '../../src/actions/turn/commandEnv.js'; +import type { WorldSnapshot } from '../../src/world/types.js'; +import { MINIMAL_MAP } from '../fixtures/minimalMap.js'; +import { InMemoryWorld, TestGameRunner } from '../testEnv.js'; + +const NATION_ID = 1; +const CITY_ID = 1; +const GENERAL_ID = 1; +const CREW_TYPE_ID = 1; +const UNIT_SET = { + id: 'default', + name: 'default', + crewTypes: [ + { + id: CREW_TYPE_ID, + name: '보병', + armType: 1, + attack: 100, + defence: 100, + speed: 5, + avoid: 0, + magicCoef: 0, + cost: 10, + rice: 1, + requirements: [], + attackCoef: {}, + defenceCoef: {}, + info: [], + initSkillTrigger: null, + phaseSkillTrigger: null, + iActionList: null, + }, + ], +}; + +const makeWorld = (): InMemoryWorld => { + const nation: Nation = { + id: NATION_ID, + name: '추가 통솔 테스트국', + color: '#ffffff', + capitalCityId: CITY_ID, + chiefGeneralId: GENERAL_ID, + gold: 100_000, + rice: 100_000, + power: 0, + level: 5, + typeCode: 'test', + meta: { tech: 0 }, + }; + const cityDefinition = MINIMAL_MAP.cities[0]; + if (!cityDefinition) { + throw new Error('minimal map city is missing'); + } + const city: City = { + id: CITY_ID, + name: cityDefinition.name, + nationId: NATION_ID, + level: 1, + state: 0, + population: 100_000, + populationMax: 100_000, + agriculture: 1_000, + agricultureMax: 1_000, + commerce: 1_000, + commerceMax: 1_000, + security: 1_000, + securityMax: 1_000, + defence: 1_000, + defenceMax: 1_000, + wall: 1_000, + wallMax: 1_000, + supplyState: 1, + frontState: 0, + meta: { trust: 100 }, + }; + const general: General = { + id: GENERAL_ID, + name: '추가 통솔 장수', + nationId: NATION_ID, + cityId: CITY_ID, + troopId: 0, + npcState: 0, + experience: 0, + dedication: 0, + officerLevel: 5, + gold: 100_000, + rice: 100_000, + crew: 10_000, + crewTypeId: CREW_TYPE_ID, + train: 100, + atmos: 100, + injury: 0, + age: 30, + stats: { leadership: 100, strength: 70, intelligence: 70 }, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { horse: 'che_명마_10_옥추마', weapon: null, book: null, item: null }, + }, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24 }, + }; + const snapshot: WorldSnapshot = { + scenarioConfig: { environment: { mapName: 'minimal_map', unitSet: 'default' }, options: {} } as never, + scenarioMeta: { + title: '추가 통솔 테스트', + startYear: 200, + life: 0, + fiction: 0, + history: [], + ignoreDefaultEvents: false, + }, + map: MINIMAL_MAP, + unitSet: UNIT_SET, + nations: [nation], + cities: [city], + generals: [general], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + }; + return new InMemoryWorld(snapshot); +}; + +const makeEnv = async (): Promise => { + const actionModules = await loadActionModuleBundle(); + return { + unitSet: UNIT_SET, + develCost: 50, + trainDelta: 35, + atmosDelta: 35, + maxTrainByCommand: 100, + maxAtmosByCommand: 100, + sabotageDefaultProb: 0.5, + sabotageProbCoefByStat: 0.1, + sabotageDefenceCoefByGeneralCount: 0.1, + sabotageDamageMin: 10, + sabotageDamageMax: 20, + openingPartYear: 200, + maxGeneral: 10, + defaultNpcGold: 1_000, + defaultNpcRice: 1_000, + defaultCrewTypeId: CREW_TYPE_ID, + defaultSpecialDomestic: null, + defaultSpecialWar: null, + initialNationGenLimit: 10, + maxTechLevel: 10, + baseGold: 1_000, + baseRice: 1_000, + maxResourceActionAmount: 1_000, + generalActionModules: actionModules.general, + }; +}; + +describe.each([ + ['징병', 'che_징병', conscriptSpec], + ['모병', 'che_모병', recruitSpec], +] as const)('%s 추가 통솔 병력 여유', (_name, commandKey, commandSpec) => { + it('원시 통솔 한도에 도달해도 보정 통솔만큼 같은 병종을 추가 모집한다', async () => { + const world = makeWorld(); + const runner = new TestGameRunner(world, 200, 1); + const definition = commandSpec.createDefinition(await makeEnv()); + + await runner.runTurn([ + { + generalId: GENERAL_ID, + commandKey, + resolver: definition, + args: { crewType: CREW_TYPE_ID, amount: 100 }, + }, + ]); + + expect(world.getGeneral(GENERAL_ID)?.crew).toBe(10_100); + }); +});