From fe438c5195b051de2dae4106ab6dfa30e665fafb Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 21 Aug 2026 02:59:15 +0000 Subject: [PATCH] =?UTF-8?q?fix(logic):=20=EC=A0=84=ED=88=AC=20=ED=8A=B9?= =?UTF-8?q?=EA=B8=B0=EC=9D=98=20=EB=B9=84=EC=A0=84=ED=88=AC=20=EC=BB=A4?= =?UTF-8?q?=EB=A7=A8=EB=93=9C=20=ED=9A=A8=EA=B3=BC=EB=A5=BC=20=EB=B3=B5?= =?UTF-8?q?=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 모병에서도 실제 행동명을 특기 계산에 전달해 징병 특기의 훈련과 사기를 레거시 값으로 맞춘다. 의술의 환자별 기록 소유권과 PLAIN 형식, 다인 치료 요약 대상도 복구하고 전투 특기 비전투 hook 회귀를 추가한다. --- .../src/turn/reservedTurnHandler.ts | 5 +- .../src/actions/turn/general/che_징병.ts | 18 +- packages/logic/src/triggers/general.ts | 4 +- .../triggers/generalTriggers/che_도시치료.ts | 24 +- packages/logic/test/disbandAction.test.ts | 33 +++ packages/logic/test/specialActions.test.ts | 69 ++++++ .../logic/test/warTraitCommandEffects.test.ts | 209 ++++++++++++++++++ 7 files changed, 348 insertions(+), 14 deletions(-) create mode 100644 packages/logic/test/warTraitCommandEffects.test.ts diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 79a67641..270f0fdf 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -1591,7 +1591,10 @@ export const createReservedTurnHandler = async (options: { }, rng: preprocessRng, log: { - push: (message) => logs.push(createGeneralActionLog(currentGeneral.id, message)), + push: (message, logOptions) => + logs.push(createGeneralActionLog(currentGeneral.id, message, logOptions)), + pushForGeneral: (generalId, message, logOptions) => + logs.push(createGeneralActionLog(generalId, message, logOptions)), }, }); preTurnPipeline.getPreTurnExecuteTriggerList(preTurnContext).fire(preTurnContext, baseConstraintEnv); diff --git a/packages/logic/src/actions/turn/general/che_징병.ts b/packages/logic/src/actions/turn/general/che_징병.ts index e9af5e64..636190f9 100644 --- a/packages/logic/src/actions/turn/general/che_징병.ts +++ b/packages/logic/src/actions/turn/general/che_징병.ts @@ -39,7 +39,7 @@ export interface RecruitEnvironment { defaultAtmos?: number; minAvailableRecruitPop?: number; defaultTrust?: number; - actionName?: string; + actionName?: '징병' | '모병'; } export interface RecruitResolveContext< @@ -229,6 +229,10 @@ export class CommandResolver): number { const general = context.general; const base = applyLegacyInjury(general.stats.leadership, general.injury); @@ -245,10 +249,10 @@ export class CommandResolver { @@ -10,7 +11,8 @@ export interface GeneralWorldView 0) { general.injury = 0; context.skill.activate('pre.부상경감', 'pre.치료'); - logger?.push('의술을 펼쳐 스스로 치료합니다!'); + logger?.push('의술을 펼쳐 스스로 치료합니다!', { format: LogFormat.PLAIN }); } const candidates = resolveCityGenerals(general, context).filter((candidate) => { @@ -64,19 +65,32 @@ export class CheUisulCityHealTrigger< for (const patient of healed) { patient.injury = 0; + const generalName = general.name; + logger?.pushForGeneral?.( + patient.id, + `${generalName}${JosaUtil.pick(generalName, '이')} 의술로써 치료해줍니다!`, + { format: LogFormat.PLAIN } + ); } if (healed.length === 0) { return env; } - const firstName = healed[0]?.name ?? '장수'; + // Ref overwrites `$curedPatientName` for each successful patient and + // therefore names the last general in the ordered draw list. + const curedPatientName = healed.at(-1)?.name ?? '장수'; if (healed.length === 1) { - const josa = JosaUtil.pick(firstName, '을'); - logger?.push(`의술을 펼쳐 도시의 장수 ${firstName}${josa} 치료합니다!`); + const josa = JosaUtil.pick(curedPatientName, '을'); + logger?.push(`의술을 펼쳐 도시의 장수 ${curedPatientName}${josa} 치료합니다!`, { + format: LogFormat.PLAIN, + }); } else { const otherCount = healed.length - 1; - logger?.push(`의술을 펼쳐 도시의 장수들 ${firstName} 외 ${otherCount}명을 치료합니다!`); + logger?.push( + `의술을 펼쳐 도시의 장수들 ${curedPatientName} 외 ${otherCount}명을 치료합니다!`, + { format: LogFormat.PLAIN } + ); } return env; diff --git a/packages/logic/test/disbandAction.test.ts b/packages/logic/test/disbandAction.test.ts index 3fb59301..56d579ea 100644 --- a/packages/logic/test/disbandAction.test.ts +++ b/packages/logic/test/disbandAction.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { createRefOrderedActionStack } from '../src/actionModules/bundle.js'; import type { GeneralActionModule } from '../src/actionModules/general.js'; import { ActionDefinition } from '../src/actions/turn/general/che_소집해제.js'; +import { traitModule as recruitTrait } from '../src/actionModules/traits/war/che_징병.js'; describe('che_소집해제', () => { it('applies legacy experience and dedication stat hooks', () => { @@ -43,4 +44,36 @@ describe('che_소집해제', () => { expect(general.experience).toBe(1_077); expect(general.dedication).toBe(2_090); }); + + it('does not return population when the general has the 징병 trait', () => { + const definition = new ActionDefinition({ + generalActionModules: [recruitTrait], + } as never); + const general = { + crew: 500, + experience: 1_000, + dedication: 2_000, + stats: { leadership: 80, strength: 70, intelligence: 60 }, + role: { + personality: null, + specialDomestic: null, + specialWar: 'che_징병', + items: { horse: null, weapon: null, book: null, item: null }, + }, + meta: {}, + }; + const city = { population: 10_000 }; + + definition.resolve( + { + general, + city, + addLog: () => undefined, + } as never, + {} + ); + + expect(general.crew).toBe(0); + expect(city.population).toBe(10_000); + }); }); diff --git a/packages/logic/test/specialActions.test.ts b/packages/logic/test/specialActions.test.ts index 635e47c8..1aadbf52 100644 --- a/packages/logic/test/specialActions.test.ts +++ b/packages/logic/test/specialActions.test.ts @@ -13,8 +13,10 @@ import { loadEventDomesticTraitModules, loadDomesticTraitModules, loadWarTraitModules, + WAR_TRAIT_KEYS, } from '../src/actionModules/traits/index.js'; import { ActionLogger } from '../src/logging/actionLogger.js'; +import { LogFormat } from '../src/logging/types.js'; import { WarActionPipeline } from '../src/war/actions.js'; import { WarCrewType } from '../src/war/crewType.js'; import { createWarTriggerEnv } from '../src/war/triggers.js'; @@ -164,6 +166,23 @@ const buildUnitSet = (): UnitSetDefinition => ({ }); describe('trait modules', () => { + it('keeps the Ref inventory of battle traits with non-battle hooks', async () => { + const war = await loadWarTraitModules([...WAR_TRAIT_KEYS]); + + expect(war.filter((module) => module.onCalcDomestic).map((module) => module.key)).toEqual([ + 'che_귀병', + 'che_신산', + 'che_보병', + 'che_궁병', + 'che_기병', + 'che_공성', + 'che_징병', + ]); + expect(war.filter((module) => module.getPreTurnExecuteTriggerList).map((module) => module.key)).toEqual([ + 'che_의술', + ]); + }); + it('loads trait modules by key', async () => { const domestic = await loadDomesticTraitModules(['che_인덕', 'che_발명']); const war = await loadWarTraitModules(['che_의술', 'che_징병']); @@ -286,6 +305,56 @@ describe('trait modules', () => { expect(higherIdPatient.injury).toBe(30); }); + it('writes Ref-compatible patient and healer logs for 의술 city healing', async () => { + const war = await loadWarTraitModules(['che_의술']); + const registry = createTraitCatalog({ war }); + const pipeline = new GeneralActionPipeline(createTraitModules(registry).general); + const healer = buildGeneral({ + name: '의사', + role: { + personality: null, + specialDomestic: null, + specialWar: 'che_의술', + items: { horse: null, weapon: null, book: null, item: null }, + }, + }); + const firstPatient = buildGeneral({ id: 2, name: '환자갑', injury: 20 }); + const lastPatient = buildGeneral({ id: 3, name: '환자을', injury: 20 }); + const worldView = { + listGeneralsByCity: () => [lastPatient, healer, firstPatient], + listGenerals: () => [lastPatient, healer, firstPatient], + }; + const rng: RandomGenerator = { + nextFloat1: () => 0, + nextBool: () => true, + nextInt: (minInclusive: number) => minInclusive, + }; + const healerLogs: string[] = []; + const patientLogs: Array<{ generalId: number; message: string; format: LogFormat | undefined }> = []; + const healerFormats: Array = []; + const context = createGeneralTriggerContext({ + general: healer, + rng, + worldView, + log: { + push: (message, options) => { + healerLogs.push(message); + healerFormats.push(options?.format); + }, + pushForGeneral: (generalId, message, options) => + patientLogs.push({ generalId, message, format: options?.format }), + }, + }); + + pipeline.getPreTurnExecuteTriggerList(context).fire(context, {}); + + expect(patientLogs.map((log) => log.generalId)).toEqual([2, 3]); + expect(patientLogs.every((log) => log.message.includes('의사'))).toBe(true); + expect(patientLogs.every((log) => log.format === LogFormat.PLAIN)).toBe(true); + expect(healerLogs.at(-1)).toContain('환자을 외 1명'); + expect(healerFormats.every((format) => format === LogFormat.PLAIN)).toBe(true); + }); + it('activates 의술 battle trigger and reduces damage', async () => { const domestic = await loadDomesticTraitModules(['che_인덕', 'che_발명']); const war = await loadWarTraitModules(['che_의술', 'che_징병']); diff --git a/packages/logic/test/warTraitCommandEffects.test.ts b/packages/logic/test/warTraitCommandEffects.test.ts new file mode 100644 index 00000000..e8ca8676 --- /dev/null +++ b/packages/logic/test/warTraitCommandEffects.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from 'vitest'; + +import type { City, General, Nation } from '../src/domain/entities.js'; +import { + ActionDefinition as DraftActionDefinition, + CommandResolver as RecruitmentCommandResolver, +} from '../src/actions/turn/general/che_징병.js'; +import { ActionDefinition as MercenaryActionDefinition } from '../src/actions/turn/general/che_모병.js'; +import { StrategyCommandResolver, type StrategyActionConfig } from '../src/actions/turn/general/strategyCommand.js'; +import { traitModule as recruitTrait } from '../src/actionModules/traits/war/che_징병.js'; +import { traitModule as footmanTrait } from '../src/actionModules/traits/war/che_보병.js'; +import { traitModule as archerTrait } from '../src/actionModules/traits/war/che_궁병.js'; +import { traitModule as cavalryTrait } from '../src/actionModules/traits/war/che_기병.js'; +import { traitModule as wizardTrait } from '../src/actionModules/traits/war/che_귀병.js'; +import { traitModule as siegeTrait } from '../src/actionModules/traits/war/che_공성.js'; +import { traitModule as strategistTrait } from '../src/actionModules/traits/war/che_신산.js'; + +const buildGeneral = (overrides: Partial = {}): General => ({ + id: 1, + name: '특기 감사 장수', + nationId: 1, + cityId: 1, + troopId: 0, + npcState: 0, + experience: 0, + dedication: 0, + officerLevel: 1, + gold: 100_000, + rice: 100_000, + crew: 0, + crewTypeId: 1, + train: 0, + atmos: 0, + injury: 0, + age: 30, + stats: { leadership: 80, strength: 70, intelligence: 60 }, + role: { + personality: null, + specialDomestic: null, + specialWar: 'che_징병', + items: { horse: null, weapon: null, book: null, item: null }, + }, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24 }, + ...overrides, +}); + +const buildNation = (): Nation => ({ + id: 1, + name: '특기 감사국', + color: '#000000', + capitalCityId: 1, + chiefGeneralId: 1, + gold: 100_000, + rice: 100_000, + power: 0, + level: 1, + typeCode: 'che_중립', + meta: { tech: 0 }, +}); + +const buildCity = (): City => ({ + id: 2, + name: '특기 감사성', + nationId: 2, + level: 1, + state: 0, + population: 10_000, + populationMax: 20_000, + agriculture: 1_000, + agricultureMax: 2_000, + commerce: 1_000, + commerceMax: 2_000, + security: 1_000, + securityMax: 2_000, + defence: 500, + defenceMax: 1_000, + wall: 500, + wallMax: 1_000, + supplyState: 1, + frontState: 0, + meta: { trust: 50 }, +}); + +describe('전투 특기의 비전투 커맨드 효과', () => { + it('징병 특기가 징병과 모병의 훈련·사기를 각각 70과 84로 설정한다', () => { + const context = { general: buildGeneral(), nation: buildNation() }; + const draft = new RecruitmentCommandResolver([recruitTrait], { + actionName: '징병', + defaultTrain: 40, + defaultAtmos: 40, + }); + const mercenary = new RecruitmentCommandResolver([recruitTrait], { + actionName: '모병', + costOffset: 2, + defaultTrain: 70, + defaultAtmos: 70, + }); + + expect(draft.getTrain(context)).toBe(70); + expect(draft.getAtmos(context)).toBe(70); + expect(mercenary.getTrain(context)).toBe(84); + expect(mercenary.getAtmos(context)).toBe(84); + }); + + it('실제 징병·모병 action이 특기 훈사와 인구 보존을 저장 상태에 반영한다', () => { + const unitSet = { + id: 'war-trait-command-audit', + name: '전투특기 커맨드 감사', + crewTypes: [{ id: 101, name: '감사병', armType: 1, cost: 10, rice: 1, requirements: [] }], + }; + const map = { id: 'war-trait-command-audit', name: '전투특기 커맨드 감사', cities: [] }; + + for (const [definition, expectedReadiness] of [ + [new DraftActionDefinition([recruitTrait], {}), 70], + [new MercenaryActionDefinition([recruitTrait]), 84], + ] as const) { + const general = buildGeneral(); + const city = { ...buildCity(), id: 1, nationId: 1, population: 100_000 }; + definition.resolve( + { + general, + city, + nation: buildNation(), + map, + unitSet, + cities: [city], + addLog: () => undefined, + } as never, + { crewType: 101, amount: 1_000 } + ); + + expect(general.crew).toBe(1_000); + expect(general.train).toBe(expectedReadiness); + expect(general.atmos).toBe(expectedReadiness); + expect(city.population).toBe(100_000); + } + }); + + it('징병 특기가 통솔 상한을 25% 높이고 징병 인구를 소모하지 않는다', () => { + const context = { general: buildGeneral(), nation: buildNation() }; + const command = new RecruitmentCommandResolver([recruitTrait], { actionName: '징병' }); + + expect(command.resolveLeadership(context)).toBe(100); + expect(command.resolveCrewPlan(context, 2, 20_000)).toEqual({ requested: 20_000, applied: 10_000 }); + expect(command.getRecruitPopulation(context, 10_000)).toBe(0); + }); + + it.each([ + ['che_보병', footmanTrait, 1], + ['che_궁병', archerTrait, 2], + ['che_기병', cavalryTrait, 3], + ['che_귀병', wizardTrait, 4], + ['che_공성', siegeTrait, 5], + ] as const)('%s 특기가 해당 계통의 징병·모병 비용만 10% 낮춘다', (_key, trait, armType) => { + const context = { general: buildGeneral(), nation: buildNation() }; + const crewTypeId = 100 + armType; + const command = new RecruitmentCommandResolver([trait], { + actionName: '모병', + costOffset: 2, + defaultTrain: 70, + defaultAtmos: 70, + }); + + expect(command.getCost(context, crewTypeId, 1_000, { armType, cost: 10 }).gold).toBe(180); + expect(command.getCost(context, crewTypeId, 1_000, { armType: 9, cost: 10 }).gold).toBe(200); + }); + + it.each([ + ['che_화계', '화계', 'intelligence', 'fire', true], + ['che_선동', '선동', 'leadership', 'agitate', true], + ['che_파괴', '파괴', 'strength', 'destroy', true], + ['che_탈취', '탈취', 'strength', 'seize', false], + ] as const)('신산 특기가 %s 성공 공격값을 10%p 높인다', (key, name, statKey, damageMode, injuryGeneral) => { + const config: StrategyActionConfig = { + key, + name, + statKey, + statExpKey: + statKey === 'intelligence' ? 'intel_exp' : statKey === 'leadership' ? 'leadership_exp' : 'strength_exp', + damageMode, + injuryGeneral, + }; + const env = { + develCost: 100, + sabotageDefaultProb: 0.5, + sabotageProbCoefByStat: 300, + sabotageDefenceCoefByGeneralCount: 0.1, + sabotageDamageMin: 10, + sabotageDamageMax: 20, + }; + const general = buildGeneral(); + const context = { + general, + city: { ...buildCity(), id: 1, nationId: 1 }, + nation: buildNation(), + destCity: buildCity(), + destNation: { ...buildNation(), id: 2 }, + destGenerals: [], + distance: 1, + }; + const base = general.stats[statKey] / env.sabotageProbCoefByStat; + + expect(new StrategyCommandResolver([strategistTrait], env, config).getProbability(context).attack).toBeCloseTo( + base + 0.1, + 12 + ); + }); +});