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 + ); + }); +});