diff --git a/packages/logic/src/actions/turn/general/che_선동.ts b/packages/logic/src/actions/turn/general/che_선동.ts index 8933af4..c18b85c 100644 --- a/packages/logic/src/actions/turn/general/che_선동.ts +++ b/packages/logic/src/actions/turn/general/che_선동.ts @@ -25,6 +25,8 @@ import type { ActionContextBase, ActionContextOptions } from '@sammo-ts/logic/ac import type { GeneralTurnCommandSpec } from './index.js'; import { JosaUtil } from '@sammo-ts/common'; import { parseArgsWithSchema } from '../parseArgs.js'; +import { GeneralActionPipeline, type GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js'; +import { consumeSuccessfulStrategyItem } from './strategyItemConsumption.js'; export interface AgitateResolveContext< TriggerState extends GeneralTriggerState = GeneralTriggerState, @@ -44,6 +46,11 @@ export class ActionResolver< TriggerState extends GeneralTriggerState = GeneralTriggerState, > implements GeneralActionResolver { readonly key = ACTION_KEY; + private readonly pipeline: GeneralActionPipeline; + + constructor(modules: Array | null | undefined> = []) { + this.pipeline = new GeneralActionPipeline(modules); + } resolve(context: GeneralActionResolveContext, args: AgitateArgs): GeneralActionOutcome { const ctx = context as AgitateResolveContext; @@ -86,8 +93,8 @@ export class ActionResolver< 1 )}만큼 감소하고, 장수 ${injuryCount}명이 부상 당했습니다.`, { - category: LogCategory.ACTION, - format: LogFormat.PLAIN, + category: LogCategory.ACTION, + format: LogFormat.PLAIN, } ); @@ -107,6 +114,8 @@ export class ActionResolver< ) ); + consumeSuccessfulStrategyItem(this.pipeline, context); + // General Update (Cost + Exp + LeaderExp) effects.push( createGeneralPatchEffect( @@ -137,8 +146,10 @@ export class ActionDefinition< public readonly name = ACTION_NAME; private readonly resolver: ActionResolver; - constructor(_env: TurnCommandEnv) { - this.resolver = new ActionResolver(); + constructor(env: TurnCommandEnv) { + this.resolver = new ActionResolver( + (env.generalActionModules ?? []) as GeneralActionModule[] + ); } parseArgs(raw: unknown): AgitateArgs | null { diff --git a/packages/logic/src/actions/turn/general/che_탈취.ts b/packages/logic/src/actions/turn/general/che_탈취.ts index 19a7900..03fab42 100644 --- a/packages/logic/src/actions/turn/general/che_탈취.ts +++ b/packages/logic/src/actions/turn/general/che_탈취.ts @@ -29,6 +29,8 @@ import type { ActionContextBase, ActionContextOptions } from '@sammo-ts/logic/ac import type { GeneralTurnCommandSpec } from './index.js'; import { JosaUtil } from '@sammo-ts/common'; import { parseArgsWithSchema } from '../parseArgs.js'; +import { GeneralActionPipeline, type GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js'; +import { consumeSuccessfulStrategyItem } from './strategyItemConsumption.js'; export interface SeizeResolveContext< TriggerState extends GeneralTriggerState = GeneralTriggerState, @@ -50,6 +52,11 @@ export class ActionResolver< TriggerState extends GeneralTriggerState = GeneralTriggerState, > implements GeneralActionResolver { readonly key = ACTION_KEY; + private readonly pipeline: GeneralActionPipeline; + + constructor(modules: Array | null | undefined> = []) { + this.pipeline = new GeneralActionPipeline(modules); + } resolve(context: GeneralActionResolveContext, args: SeizeArgs): GeneralActionOutcome { const ctx = context as SeizeResolveContext; @@ -166,6 +173,8 @@ export class ActionResolver< format: LogFormat.PLAIN, }); + consumeSuccessfulStrategyItem(this.pipeline, context); + effects.push( createGeneralPatchEffect( { @@ -195,8 +204,10 @@ export class ActionDefinition< public readonly name = ACTION_NAME; private readonly resolver: ActionResolver; - constructor(_env: TurnCommandEnv) { - this.resolver = new ActionResolver(); + constructor(env: TurnCommandEnv) { + this.resolver = new ActionResolver( + (env.generalActionModules ?? []) as GeneralActionModule[] + ); } parseArgs(raw: unknown): SeizeArgs | null { diff --git a/packages/logic/src/actions/turn/general/che_파괴.ts b/packages/logic/src/actions/turn/general/che_파괴.ts index e6310ad..5bf5572 100644 --- a/packages/logic/src/actions/turn/general/che_파괴.ts +++ b/packages/logic/src/actions/turn/general/che_파괴.ts @@ -25,6 +25,8 @@ import type { ActionContextBase, ActionContextOptions } from '@sammo-ts/logic/ac import type { GeneralTurnCommandSpec } from './index.js'; import { JosaUtil } from '@sammo-ts/common'; import { parseArgsWithSchema } from '../parseArgs.js'; +import { GeneralActionPipeline, type GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js'; +import { consumeSuccessfulStrategyItem } from './strategyItemConsumption.js'; export interface DestroyResolveContext< TriggerState extends GeneralTriggerState = GeneralTriggerState, @@ -44,6 +46,11 @@ export class ActionResolver< TriggerState extends GeneralTriggerState = GeneralTriggerState, > implements GeneralActionResolver { readonly key = ACTION_KEY; + private readonly pipeline: GeneralActionPipeline; + + constructor(modules: Array | null | undefined> = []) { + this.pipeline = new GeneralActionPipeline(modules); + } resolve(context: GeneralActionResolveContext, args: DestroyArgs): GeneralActionOutcome { const ctx = context as DestroyResolveContext; @@ -102,6 +109,8 @@ export class ActionResolver< ) ); + consumeSuccessfulStrategyItem(this.pipeline, context); + // General Update (Cost + Exp) effects.push( createGeneralPatchEffect( @@ -132,8 +141,10 @@ export class ActionDefinition< public readonly name = ACTION_NAME; private readonly resolver: ActionResolver; - constructor(_env: TurnCommandEnv) { - this.resolver = new ActionResolver(); + constructor(env: TurnCommandEnv) { + this.resolver = new ActionResolver( + (env.generalActionModules ?? []) as GeneralActionModule[] + ); } parseArgs(raw: unknown): DestroyArgs | null { diff --git a/packages/logic/src/actions/turn/general/che_화계.ts b/packages/logic/src/actions/turn/general/che_화계.ts index 79aa2ff..5f6a096 100644 --- a/packages/logic/src/actions/turn/general/che_화계.ts +++ b/packages/logic/src/actions/turn/general/che_화계.ts @@ -36,6 +36,7 @@ import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/action import type { GeneralTurnCommandSpec } from './index.js'; import { clamp } from 'es-toolkit'; import { parseArgsWithSchema } from '../parseArgs.js'; +import { consumeSuccessfulStrategyItem } from './strategyItemConsumption.js'; export interface FireAttackEnvironment { develCost: number; @@ -251,9 +252,11 @@ export class ActionResolver< > implements GeneralActionResolver { readonly key = 'che_화계'; private readonly command: CommandResolver; + private readonly pipeline: GeneralActionPipeline; constructor(modules: Array | null | undefined>, env: FireAttackEnvironment) { this.command = new CommandResolver(modules, env); + this.pipeline = new GeneralActionPipeline(modules); } resolve( @@ -334,7 +337,7 @@ export class ActionResolver< context.addLog( `${context.destCity.name}에 ${commandName}${JosaUtil.pick(commandName, '이')} 성공했습니다.`, { - format: LogFormat.MONTH, + format: LogFormat.MONTH, } ); context.addLog( @@ -345,7 +348,9 @@ export class ActionResolver< ); const itemCode = general.role.items.item; - if (typeof itemCode === 'string' && itemCode.length > 0) { + const actionResult = consumeSuccessfulStrategyItem(this.pipeline, context); + const consumedItems = Array.isArray(actionResult?.['consumedItems']) ? actionResult['consumedItems'] : []; + if (typeof itemCode === 'string' && consumedItems.includes(itemCode)) { context.addLog(`${itemCode}${JosaUtil.pick(itemCode, '을')} 사용!`, { format: LogFormat.PLAIN, }); @@ -383,13 +388,7 @@ export class ActionDefinition< buildMinConstraints(_ctx: ConstraintContext, _args: FireAttackArgs): Constraint[] { const { gold, rice } = this.command.getCost(); - return [ - notBeNeutral(), - occupiedCity(), - suppliedCity(), - reqGeneralGold(() => gold), - reqGeneralRice(() => rice), - ]; + return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralGold(() => gold), reqGeneralRice(() => rice)]; } buildConstraints(_ctx: ConstraintContext, _args: FireAttackArgs): Constraint[] { diff --git a/packages/logic/src/actions/turn/general/strategyItemConsumption.ts b/packages/logic/src/actions/turn/general/strategyItemConsumption.ts new file mode 100644 index 0000000..b8cffba --- /dev/null +++ b/packages/logic/src/actions/turn/general/strategyItemConsumption.ts @@ -0,0 +1,12 @@ +import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; +import type { GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js'; +import type { GeneralActionPipeline } from '@sammo-ts/logic/triggers/general-action.js'; + +export const consumeSuccessfulStrategyItem = ( + pipeline: GeneralActionPipeline, + context: GeneralActionResolveContext +): Record | null => + pipeline.onArbitraryAction(context, 'GeneralCommand', null, { + command: '계략', + success: true, + }); diff --git a/packages/logic/src/items/che_계략_이추.ts b/packages/logic/src/items/che_계략_이추.ts index 39ce68b..8a4715d 100644 --- a/packages/logic/src/items/che_계략_이추.ts +++ b/packages/logic/src/items/che_계략_이추.ts @@ -13,6 +13,7 @@ export const itemModule: ItemModule = { consumable: true, reqSecu: 1000, unique: false, + consumeOn: [{ actionType: 'GeneralCommand', command: '계략', successOnly: true }], onCalcDomestic: (_context, turnType, varType, value) => { if (turnType === '계략' && varType === 'success') { return value + 0.2; diff --git a/packages/logic/src/items/che_계략_향낭.ts b/packages/logic/src/items/che_계략_향낭.ts index c60d5fb..cdccb98 100644 --- a/packages/logic/src/items/che_계략_향낭.ts +++ b/packages/logic/src/items/che_계략_향낭.ts @@ -13,6 +13,7 @@ export const itemModule: ItemModule = { consumable: true, reqSecu: 2000, unique: false, + consumeOn: [{ actionType: 'GeneralCommand', command: '계략', successOnly: true }], onCalcDomestic: (_context, turnType, varType, value) => { if (turnType === '계략' && varType === 'success') { return value + 0.5; diff --git a/packages/logic/src/items/che_명마_03_노새.ts b/packages/logic/src/items/che_명마_03_노새.ts index 39fa81e..cab7c08 100644 --- a/packages/logic/src/items/che_명마_03_노새.ts +++ b/packages/logic/src/items/che_명마_03_노새.ts @@ -7,7 +7,7 @@ export const itemModule: ItemModule = createStatItemModule({ slot: 'horse', statName: 'leadership', statValue: 3, - cost: 4500, + cost: 6000, buyable: true, - reqSecu: 2500, + reqSecu: 3000, }); diff --git a/packages/logic/src/items/che_명마_04_나귀.ts b/packages/logic/src/items/che_명마_04_나귀.ts index 4f8dd0b..229c0f2 100644 --- a/packages/logic/src/items/che_명마_04_나귀.ts +++ b/packages/logic/src/items/che_명마_04_나귀.ts @@ -7,7 +7,7 @@ export const itemModule: ItemModule = createStatItemModule({ slot: 'horse', statName: 'leadership', statValue: 4, - cost: 6000, + cost: 10000, buyable: true, - reqSecu: 3000, + reqSecu: 4000, }); diff --git a/packages/logic/src/items/che_명마_05_갈색마.ts b/packages/logic/src/items/che_명마_05_갈색마.ts index 922d8bd..1c2e916 100644 --- a/packages/logic/src/items/che_명마_05_갈색마.ts +++ b/packages/logic/src/items/che_명마_05_갈색마.ts @@ -7,7 +7,7 @@ export const itemModule: ItemModule = createStatItemModule({ slot: 'horse', statName: 'leadership', statValue: 5, - cost: 7500, + cost: 15000, buyable: true, - reqSecu: 3500, + reqSecu: 5000, }); diff --git a/packages/logic/src/items/che_무기_03_단극.ts b/packages/logic/src/items/che_무기_03_단극.ts index fafa34f..162acf9 100644 --- a/packages/logic/src/items/che_무기_03_단극.ts +++ b/packages/logic/src/items/che_무기_03_단극.ts @@ -7,7 +7,7 @@ export const itemModule: ItemModule = createStatItemModule({ slot: 'weapon', statName: 'strength', statValue: 3, - cost: 4500, + cost: 6000, buyable: true, - reqSecu: 2500, + reqSecu: 3000, }); diff --git a/packages/logic/src/items/che_무기_04_목검.ts b/packages/logic/src/items/che_무기_04_목검.ts index 1e7fdcc..e777217 100644 --- a/packages/logic/src/items/che_무기_04_목검.ts +++ b/packages/logic/src/items/che_무기_04_목검.ts @@ -7,7 +7,7 @@ export const itemModule: ItemModule = createStatItemModule({ slot: 'weapon', statName: 'strength', statValue: 4, - cost: 6000, + cost: 10000, buyable: true, - reqSecu: 3000, + reqSecu: 4000, }); diff --git a/packages/logic/src/items/che_무기_05_죽창.ts b/packages/logic/src/items/che_무기_05_죽창.ts index 5085335..7385098 100644 --- a/packages/logic/src/items/che_무기_05_죽창.ts +++ b/packages/logic/src/items/che_무기_05_죽창.ts @@ -7,7 +7,7 @@ export const itemModule: ItemModule = createStatItemModule({ slot: 'weapon', statName: 'strength', statValue: 5, - cost: 7500, + cost: 15000, buyable: true, - reqSecu: 3500, + reqSecu: 5000, }); diff --git a/packages/logic/src/items/che_무기_06_소부.ts b/packages/logic/src/items/che_무기_06_소부.ts index 103a571..d38cf6d 100644 --- a/packages/logic/src/items/che_무기_06_소부.ts +++ b/packages/logic/src/items/che_무기_06_소부.ts @@ -7,7 +7,7 @@ export const itemModule: ItemModule = createStatItemModule({ slot: 'weapon', statName: 'strength', statValue: 6, - cost: 9000, + cost: 21000, buyable: true, - reqSecu: 4000, + reqSecu: 6000, }); diff --git a/packages/logic/src/items/che_무기_07_맥궁.ts b/packages/logic/src/items/che_무기_07_맥궁.ts index 1167d6d..f5d4e59 100644 --- a/packages/logic/src/items/che_무기_07_맥궁.ts +++ b/packages/logic/src/items/che_무기_07_맥궁.ts @@ -1,7 +1,9 @@ import { createStatItemModule } from './base.js'; import type { ItemModule } from './types.js'; +import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js'; +import { che_저격발동, che_저격시도 } from '@sammo-ts/logic/war/triggers/che_저격.js'; -export const itemModule: ItemModule = createStatItemModule({ +const baseModule = createStatItemModule({ key: 'che_무기_07_맥궁', rawName: '맥궁', slot: 'weapon', @@ -11,4 +13,18 @@ export const itemModule: ItemModule = createStatItemModule({ buyable: false, reqSecu: 0, unique: true, + extraInfo: '[전투] 새로운 상대와 전투 시 20% 확률로 저격 발동, 성공 시 사기+20', }); + +const raiseType = BaseWarUnitTrigger.TYPE_ITEM + BaseWarUnitTrigger.TYPE_DEDUP_TYPE_BASE * 107; + +export const itemModule: ItemModule = { + ...baseModule, + getBattlePhaseTriggerList: (context) => + context.unit + ? new WarTriggerCaller( + new che_저격시도(context.unit, raiseType, 0.2, 20, 40), + new che_저격발동(context.unit, raiseType) + ) + : null, +}; diff --git a/packages/logic/src/items/che_무기_11_이광궁.ts b/packages/logic/src/items/che_무기_11_이광궁.ts index 7195cee..bee9936 100644 --- a/packages/logic/src/items/che_무기_11_이광궁.ts +++ b/packages/logic/src/items/che_무기_11_이광궁.ts @@ -1,7 +1,9 @@ import { createStatItemModule } from './base.js'; import type { ItemModule } from './types.js'; +import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js'; +import { che_저격발동, che_저격시도 } from '@sammo-ts/logic/war/triggers/che_저격.js'; -export const itemModule: ItemModule = createStatItemModule({ +const baseModule = createStatItemModule({ key: 'che_무기_11_이광궁', rawName: '이광궁', slot: 'weapon', @@ -11,4 +13,18 @@ export const itemModule: ItemModule = createStatItemModule({ buyable: false, reqSecu: 0, unique: true, + extraInfo: '[전투] 새로운 상대와 전투 시 10% 확률로 저격 발동, 성공 시 사기+10', }); + +const raiseType = BaseWarUnitTrigger.TYPE_ITEM + BaseWarUnitTrigger.TYPE_DEDUP_TYPE_BASE * 111; + +export const itemModule: ItemModule = { + ...baseModule, + getBattlePhaseTriggerList: (context) => + context.unit + ? new WarTriggerCaller( + new che_저격시도(context.unit, raiseType, 0.1, 20, 40, 10), + new che_저격발동(context.unit, raiseType) + ) + : null, +}; diff --git a/packages/logic/src/items/che_무기_13_양유기궁.ts b/packages/logic/src/items/che_무기_13_양유기궁.ts index ee1c805..5a3afaf 100644 --- a/packages/logic/src/items/che_무기_13_양유기궁.ts +++ b/packages/logic/src/items/che_무기_13_양유기궁.ts @@ -1,7 +1,9 @@ import { createStatItemModule } from './base.js'; import type { ItemModule } from './types.js'; +import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js'; +import { che_저격발동, che_저격시도 } from '@sammo-ts/logic/war/triggers/che_저격.js'; -export const itemModule: ItemModule = createStatItemModule({ +const baseModule = createStatItemModule({ key: 'che_무기_13_양유기궁', rawName: '양유기궁', slot: 'weapon', @@ -11,4 +13,18 @@ export const itemModule: ItemModule = createStatItemModule({ buyable: false, reqSecu: 0, unique: true, + extraInfo: '[전투] 새로운 상대와 전투 시 10% 확률로 저격 발동, 성공 시 사기+10', }); + +const raiseType = BaseWarUnitTrigger.TYPE_ITEM + BaseWarUnitTrigger.TYPE_DEDUP_TYPE_BASE * 113; + +export const itemModule: ItemModule = { + ...baseModule, + getBattlePhaseTriggerList: (context) => + context.unit + ? new WarTriggerCaller( + new che_저격시도(context.unit, raiseType, 0.1, 20, 40, 10), + new che_저격발동(context.unit, raiseType) + ) + : null, +}; diff --git a/packages/logic/src/items/che_불굴_상편.ts b/packages/logic/src/items/che_불굴_상편.ts index 4d78d22..338d85d 100644 --- a/packages/logic/src/items/che_불굴_상편.ts +++ b/packages/logic/src/items/che_불굴_상편.ts @@ -1,5 +1,7 @@ import { clamp } from '@sammo-ts/logic/war/utils.js'; import { WarUnitGeneral } from '@sammo-ts/logic/war/units.js'; +import { WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js'; +import { 전투력보정 } from '@sammo-ts/logic/war/triggers/전투력보정.js'; import type { ItemModule } from './types.js'; const ITEM_KEY = 'che_불굴_상편'; @@ -15,14 +17,15 @@ export const itemModule: ItemModule = { consumable: false, reqSecu: 0, unique: false, - getWarPowerMultiplier: (_context, unit, _oppose) => { + getBattlePhaseTriggerList: (context) => { + const unit = context.unit; if (!(unit instanceof WarUnitGeneral)) { - return [1, 1]; + return null; } const general = unit.getGeneral(); const leadership = general.stats.leadership; const crew = general.crew; const crewRatio = clamp(crew / (leadership * 100), 0, 1); - return [1 + 0.6 * (1 - crewRatio), 1]; + return new WarTriggerCaller(new 전투력보정(unit, 1 + 0.6 * (1 - crewRatio))); }, }; diff --git a/packages/logic/src/items/che_상성보정_과실주.ts b/packages/logic/src/items/che_상성보정_과실주.ts index 66a8ccb..40da35f 100644 --- a/packages/logic/src/items/che_상성보정_과실주.ts +++ b/packages/logic/src/items/che_상성보정_과실주.ts @@ -1,4 +1,6 @@ import type { ItemModule } from './types.js'; +import { WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js'; +import { 전투력보정 } from '@sammo-ts/logic/war/triggers/전투력보정.js'; const ITEM_KEY = 'che_상성보정_과실주'; @@ -13,11 +15,16 @@ export const itemModule: ItemModule = { consumable: false, reqSecu: 0, unique: false, - getWarPowerMultiplier: (_context, unit, oppose) => { + getBattlePhaseTriggerList: (context) => { + const unit = context.unit; + const oppose = unit?.getOppose(); + if (!unit || !oppose) { + return null; + } const attackCoef = unit.getCrewType().getAttackCoef(oppose.getCrewType()); if (attackCoef < 1) { - return [1, 1]; + return null; } - return [1.1, 0.9]; + return new WarTriggerCaller(new 전투력보정(unit, 1.1, 0.9)); }, }; diff --git a/packages/logic/src/items/che_서적_04_건상역주.ts b/packages/logic/src/items/che_서적_04_건상역주.ts index 85b519c..43e4995 100644 --- a/packages/logic/src/items/che_서적_04_건상역주.ts +++ b/packages/logic/src/items/che_서적_04_건상역주.ts @@ -12,9 +12,9 @@ const baseModule = createStatItemModule({ slot: 'book', statName: 'intelligence', statValue: STAT_VALUE, - cost: 6000, + cost: 10000, buyable: true, - reqSecu: 3000, + reqSecu: 4000, extraInfo: '[전투] 계략 시도 확률 +2%p', }); diff --git a/packages/logic/src/items/che_서적_05_여씨춘추.ts b/packages/logic/src/items/che_서적_05_여씨춘추.ts index 1cc8f21..42e786c 100644 --- a/packages/logic/src/items/che_서적_05_여씨춘추.ts +++ b/packages/logic/src/items/che_서적_05_여씨춘추.ts @@ -12,9 +12,9 @@ const baseModule = createStatItemModule({ slot: 'book', statName: 'intelligence', statValue: STAT_VALUE, - cost: 7500, + cost: 15000, buyable: true, - reqSecu: 3500, + reqSecu: 5000, extraInfo: '[전투] 계략 시도 확률 +3%p', }); diff --git a/packages/logic/src/items/che_서적_06_사민월령.ts b/packages/logic/src/items/che_서적_06_사민월령.ts index 59d9362..4247895 100644 --- a/packages/logic/src/items/che_서적_06_사민월령.ts +++ b/packages/logic/src/items/che_서적_06_사민월령.ts @@ -12,9 +12,9 @@ const baseModule = createStatItemModule({ slot: 'book', statName: 'intelligence', statValue: STAT_VALUE, - cost: 9000, + cost: 21000, buyable: true, - reqSecu: 4000, + reqSecu: 6000, extraInfo: '[전투] 계략 시도 확률 +3%p', }); diff --git a/packages/logic/src/items/che_서적_12_산해경.ts b/packages/logic/src/items/che_서적_12_산해경.ts index 1824c09..b41cb56 100644 --- a/packages/logic/src/items/che_서적_12_산해경.ts +++ b/packages/logic/src/items/che_서적_12_산해경.ts @@ -1,7 +1,9 @@ import { createStatItemModule } from './base.js'; import type { ItemModule } from './types.js'; +import { WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js'; +import { che_반계발동, che_반계시도 } from '@sammo-ts/logic/war/triggers/che_반계.js'; -export const itemModule: ItemModule = createStatItemModule({ +const baseModule = createStatItemModule({ key: 'che_서적_12_산해경', rawName: '산해경', slot: 'book', @@ -13,3 +15,9 @@ export const itemModule: ItemModule = createStatItemModule({ unique: true, extraInfo: '[전투] 상대의 계략을 10% 확률로 되돌림', }); + +export const itemModule: ItemModule = { + ...baseModule, + getBattlePhaseTriggerList: (context) => + context.unit ? new WarTriggerCaller(new che_반계시도(context.unit, 0.1), new che_반계발동(context.unit)) : null, +}; diff --git a/packages/logic/src/items/che_의술_상한잡병론.ts b/packages/logic/src/items/che_의술_상한잡병론.ts index 0c355e0..fd36db9 100644 --- a/packages/logic/src/items/che_의술_상한잡병론.ts +++ b/packages/logic/src/items/che_의술_상한잡병론.ts @@ -1,19 +1,3 @@ -import { GeneralTriggerCaller } from '@sammo-ts/logic/triggers/general.js'; -import { CheUisulCityHealTrigger } from '@sammo-ts/logic/triggers/generalTriggers/che_도시치료.js'; -import type { ItemModule } from './types.js'; +import { createMedicalItem } from './createMedicalItem.js'; -export const itemModule: ItemModule = { - key: 'che_의술_상한잡병론', - rawName: '상한잡병론', - name: '상한잡병론(의술)', - info: '[의술] 매달 도시의 부상병을 치료. 치료 효율 50% 향상', - slot: 'book', - cost: 500, - buyable: false, - consumable: false, - reqSecu: 0, - unique: false, - getPreTurnExecuteTriggerList: (context) => { - return new GeneralTriggerCaller(new CheUisulCityHealTrigger(context.general, 1.5)); - }, -}; +export const itemModule = createMedicalItem('che_의술_상한잡병론', '상한잡병론'); diff --git a/packages/logic/src/items/che_의술_정력견혈산.ts b/packages/logic/src/items/che_의술_정력견혈산.ts index b771b6c..c67638d 100644 --- a/packages/logic/src/items/che_의술_정력견혈산.ts +++ b/packages/logic/src/items/che_의술_정력견혈산.ts @@ -1,19 +1,3 @@ -import { GeneralTriggerCaller } from '@sammo-ts/logic/triggers/general.js'; -import { CheUisulCityHealTrigger } from '@sammo-ts/logic/triggers/generalTriggers/che_도시치료.js'; -import type { ItemModule } from './types.js'; +import { createMedicalItem } from './createMedicalItem.js'; -export const itemModule: ItemModule = { - key: 'che_의술_정력견혈산', - rawName: '정력견혈산', - name: '정력견혈산(의술)', - info: '[의술] 매달 도시의 부상병을 치료. 치료 효율 100% 향상', - slot: 'book', - cost: 500, - buyable: false, - consumable: false, - reqSecu: 0, - unique: false, - getPreTurnExecuteTriggerList: (context) => { - return new GeneralTriggerCaller(new CheUisulCityHealTrigger(context.general, 2)); - }, -}; +export const itemModule = createMedicalItem('che_의술_정력견혈산', '정력견혈산'); diff --git a/packages/logic/src/items/che_의술_청낭서.ts b/packages/logic/src/items/che_의술_청낭서.ts index 0497584..d955607 100644 --- a/packages/logic/src/items/che_의술_청낭서.ts +++ b/packages/logic/src/items/che_의술_청낭서.ts @@ -1,19 +1,3 @@ -import { GeneralTriggerCaller } from '@sammo-ts/logic/triggers/general.js'; -import { CheUisulCityHealTrigger } from '@sammo-ts/logic/triggers/generalTriggers/che_도시치료.js'; -import type { ItemModule } from './types.js'; +import { createMedicalItem } from './createMedicalItem.js'; -export const itemModule: ItemModule = { - key: 'che_의술_청낭서', - rawName: '청낭서', - name: '청낭서(의술)', - info: '[의술] 매달 도시의 부상병을 치료. 치료 효율 50% 향상', - slot: 'book', - cost: 500, - buyable: false, - consumable: false, - reqSecu: 0, - unique: false, - getPreTurnExecuteTriggerList: (context) => { - return new GeneralTriggerCaller(new CheUisulCityHealTrigger(context.general, 1.5)); - }, -}; +export const itemModule = createMedicalItem('che_의술_청낭서', '청낭서'); diff --git a/packages/logic/src/items/che_의술_태평청령.ts b/packages/logic/src/items/che_의술_태평청령.ts index 648fec3..ad9500e 100644 --- a/packages/logic/src/items/che_의술_태평청령.ts +++ b/packages/logic/src/items/che_의술_태평청령.ts @@ -1,19 +1,3 @@ -import { GeneralTriggerCaller } from '@sammo-ts/logic/triggers/general.js'; -import { CheUisulCityHealTrigger } from '@sammo-ts/logic/triggers/generalTriggers/che_도시치료.js'; -import type { ItemModule } from './types.js'; +import { createMedicalItem } from './createMedicalItem.js'; -export const itemModule: ItemModule = { - key: 'che_의술_태평청령', - rawName: '태평청령', - name: '태평청령(의술)', - info: '[의술] 매달 도시의 부상병을 치료. 치료 효율 50% 향상', - slot: 'book', - cost: 500, - buyable: false, - consumable: false, - reqSecu: 0, - unique: false, - getPreTurnExecuteTriggerList: (context) => { - return new GeneralTriggerCaller(new CheUisulCityHealTrigger(context.general, 1.5)); - }, -}; +export const itemModule = createMedicalItem('che_의술_태평청령', '태평청령'); diff --git a/packages/logic/src/items/che_저격_매화수전.ts b/packages/logic/src/items/che_저격_매화수전.ts index f1047ab..f4724fb 100644 --- a/packages/logic/src/items/che_저격_매화수전.ts +++ b/packages/logic/src/items/che_저격_매화수전.ts @@ -9,7 +9,7 @@ export const itemModule: ItemModule = { key: 'che_저격_매화수전', rawName: '매화수전', name: '매화수전(저격)', - info: '[전투] 계략 시도 단계에서 저격 확률 50%', + info: '[전투] 새로운 상대와 전투 시 50% 확률로 저격 발동, 성공 시 사기+20', slot: 'item', cost: 200, buyable: false, diff --git a/packages/logic/src/items/che_저격_비도.ts b/packages/logic/src/items/che_저격_비도.ts index 8c9d044..be610f1 100644 --- a/packages/logic/src/items/che_저격_비도.ts +++ b/packages/logic/src/items/che_저격_비도.ts @@ -9,7 +9,7 @@ export const itemModule: ItemModule = { key: 'che_저격_비도', rawName: '비도', name: '비도(저격)', - info: '[전투] 계략 시도 단계에서 저격 확률 50%', + info: '[전투] 새로운 상대와 전투 시 50% 확률로 저격 발동, 성공 시 사기+20', slot: 'item', cost: 200, buyable: false, diff --git a/packages/logic/src/items/che_척사_오악진형도.ts b/packages/logic/src/items/che_척사_오악진형도.ts index 275ae5b..d133ab3 100644 --- a/packages/logic/src/items/che_척사_오악진형도.ts +++ b/packages/logic/src/items/che_척사_오악진형도.ts @@ -15,12 +15,7 @@ export const itemModule: ItemModule = { unique: false, getWarPowerMultiplier: (_context, _unit, oppose) => { const opposeCrewType = oppose.getCrewType(); - // In Sammo, region/city units usually have reqCities or reqRegions in crewType. - // We'll check if those properties exist and have at least one element. - if ( - (opposeCrewType.reqCities && opposeCrewType.reqCities.length > 0) || - (opposeCrewType.reqRegions && opposeCrewType.reqRegions.length > 0) - ) { + if (opposeCrewType.reqCities() || opposeCrewType.reqRegions()) { return [1.15, 0.85]; } return [1, 1]; diff --git a/packages/logic/src/items/createMedicalItem.ts b/packages/logic/src/items/createMedicalItem.ts new file mode 100644 index 0000000..39d117b --- /dev/null +++ b/packages/logic/src/items/createMedicalItem.ts @@ -0,0 +1,23 @@ +import { GeneralTriggerCaller } from '@sammo-ts/logic/triggers/general.js'; +import { CheUisulCityHealTrigger } from '@sammo-ts/logic/triggers/generalTriggers/che_도시치료.js'; +import { triggerModule as medicalWarTriggerModule } from '@sammo-ts/logic/war/triggers/che_의술.js'; +import type { ItemModule } from './types.js'; + +const INFO = + '[군사] 매 턴마다 자신(100%)과 소속 도시 장수(적 포함 50%) 부상 회복
[전투] 페이즈마다 40% 확률로 치료 발동(아군 피해 30% 감소, 부상 회복)'; + +export const createMedicalItem = (key: string, rawName: string): ItemModule => ({ + key, + rawName, + name: `${rawName}(의술)`, + info: INFO, + slot: 'item', + cost: 200, + buyable: false, + consumable: false, + reqSecu: 0, + unique: true, + getPreTurnExecuteTriggerList: (context) => new GeneralTriggerCaller(new CheUisulCityHealTrigger(context.general)), + getBattlePhaseTriggerList: (context) => + context.unit ? medicalWarTriggerModule.createTriggerList(context.unit) : null, +}); diff --git a/packages/logic/src/items/eventBattleTrait.ts b/packages/logic/src/items/eventBattleTrait.ts new file mode 100644 index 0000000..f0627dc --- /dev/null +++ b/packages/logic/src/items/eventBattleTrait.ts @@ -0,0 +1,69 @@ +import type { TraitModule } from '@sammo-ts/logic/triggers/special/types.js'; +import type { ItemModule } from './types.js'; + +export const createEventBattleTraitItemModule = ( + key: string, + traitModule: TraitModule, + overrides: Partial< + Pick + > = {} +): ItemModule => { + const itemModule: ItemModule = { + key, + rawName: overrides.rawName ?? '비급', + name: overrides.name ?? `비급(${traitModule.name})`, + info: overrides.info ?? traitModule.info, + slot: 'item', + cost: overrides.cost ?? 100, + buyable: overrides.buyable ?? true, + consumable: overrides.consumable ?? false, + reqSecu: overrides.reqSecu ?? 3000, + unique: false, + }; + if (traitModule.getPreTurnExecuteTriggerList) { + itemModule.getPreTurnExecuteTriggerList = traitModule.getPreTurnExecuteTriggerList; + } + if (traitModule.onCalcDomestic) { + itemModule.onCalcDomestic = traitModule.onCalcDomestic; + } + if (traitModule.onCalcStat) { + itemModule.onCalcStat = traitModule.onCalcStat; + } + if (traitModule.onCalcOpposeStat) { + itemModule.onCalcOpposeStat = traitModule.onCalcOpposeStat; + } + if (traitModule.onCalcStrategic) { + itemModule.onCalcStrategic = traitModule.onCalcStrategic; + } + if (traitModule.onCalcNationalIncome) { + itemModule.onCalcNationalIncome = traitModule.onCalcNationalIncome; + } + if (traitModule.onArbitraryAction) { + itemModule.onArbitraryAction = traitModule.onArbitraryAction; + } + if (traitModule.getBattleInitTriggerList) { + itemModule.getBattleInitTriggerList = traitModule.getBattleInitTriggerList; + } + if (traitModule.getBattlePhaseTriggerList) { + itemModule.getBattlePhaseTriggerList = traitModule.getBattlePhaseTriggerList; + } + if (traitModule.getWarPowerMultiplier) { + itemModule.getWarPowerMultiplier = + traitModule.key === 'che_무쌍' + ? (context, unit, oppose) => { + const general = + 'getGeneral' in unit && typeof (unit as { getGeneral?: unknown }).getGeneral === 'function' + ? ( + unit as typeof unit & { + getGeneral: () => { role: { specialWar: string | null } }; + } + ).getGeneral() + : null; + return general?.role.specialWar === traitModule.key + ? [1, 1] + : traitModule.getWarPowerMultiplier!(context, unit, oppose); + } + : traitModule.getWarPowerMultiplier; + } + return itemModule; +}; diff --git a/packages/logic/src/items/event_충차.ts b/packages/logic/src/items/event_충차.ts new file mode 100644 index 0000000..e7274e5 --- /dev/null +++ b/packages/logic/src/items/event_충차.ts @@ -0,0 +1,53 @@ +import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js'; +import { consumeEquippedItemCharge, getEquippedItemInstance } from './inventory.js'; +import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js'; +import { WarUnitCity, WarUnitGeneral, type WarUnit } from '@sammo-ts/logic/war/units.js'; +import type { ItemModule } from './types.js'; + +const ITEM_KEY = 'event_충차'; + +class EventRamConsumptionTrigger extends BaseWarUnitTrigger { + constructor(unit: WarUnit) { + super(unit, TriggerPriority.Pre + 200, BaseWarUnitTrigger.TYPE_CONSUMABLE_ITEM); + } + + protected actionWar( + self: WarUnit, + oppose: WarUnit, + _selfEnv: Record, + _opposeEnv: Record + ): boolean { + if (!(self instanceof WarUnitGeneral) || !(oppose instanceof WarUnitCity)) { + return true; + } + if (self.hasActivatedSkillOnLog('충차공격') > 0) { + return true; + } + const general = self.getGeneral(); + if (getEquippedItemInstance(general, 'item')?.itemKey !== ITEM_KEY) { + return true; + } + + self.activateSkill('충차공격', '아이템사용'); + self.getLogger().pushGeneralBattleDetailLog('충차로 성벽을 공격합니다.'); + consumeEquippedItemCharge(general, 'item', ITEM_KEY, 2); + return true; + } +} + +export const itemModule: ItemModule = { + key: ITEM_KEY, + rawName: '충차', + name: '충차', + info: '[전투] 성벽 공격 시 대미지 +50%, 2회용', + slot: 'item', + cost: 2000, + buyable: true, + consumable: true, + initialCharges: 2, + reqSecu: 3000, + unique: false, + getWarPowerMultiplier: (_context, _unit, oppose) => (oppose instanceof WarUnitCity ? [1.5, 1] : [1, 1]), + getBattlePhaseTriggerList: (context) => + context.unit ? new WarTriggerCaller(new EventRamConsumptionTrigger(context.unit)) : null, +}; diff --git a/packages/logic/src/items/index.ts b/packages/logic/src/items/index.ts index dbd4fae..5383c5d 100644 --- a/packages/logic/src/items/index.ts +++ b/packages/logic/src/items/index.ts @@ -17,6 +17,7 @@ import { WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js'; import type { WarUnit } from '@sammo-ts/logic/war/units.js'; import type { ItemModule, ItemModuleExport } from './types.js'; import { listEquippedItemKeys } from './utils.js'; +import { removeEquippedItem } from './inventory.js'; export const ITEM_KEYS = [ 'che_간파_노군입산부', @@ -120,6 +121,7 @@ export const ITEM_KEYS = [ 'che_서적_15_손자병법', 'che_숙련_동작', 'che_약탈_옥벽', + 'che_위압_조목삭', 'che_의술_상한잡병론', 'che_의술_정력견혈산', 'che_의술_청낭서', @@ -142,6 +144,27 @@ export const ITEM_KEYS = [ 'che_훈련_단결도', 'che_훈련_철벽서', 'che_훈련_청주', + 'event_전투특기_격노', + 'event_전투특기_견고', + 'event_전투특기_공성', + 'event_전투특기_궁병', + 'event_전투특기_귀병', + 'event_전투특기_기병', + 'event_전투특기_돌격', + 'event_전투특기_무쌍', + 'event_전투특기_반계', + 'event_전투특기_보병', + 'event_전투특기_신산', + 'event_전투특기_신중', + 'event_전투특기_위압', + 'event_전투특기_의술', + 'event_전투특기_저격', + 'event_전투특기_집중', + 'event_전투특기_징병', + 'event_전투특기_척사', + 'event_전투특기_필살', + 'event_전투특기_환술', + 'event_충차', ] as const; export type ItemKey = (typeof ITEM_KEYS)[number]; @@ -250,6 +273,21 @@ const defaultImporters: Record = { che_서적_15_손자병법: async () => import('./che_서적_15_손자병법.js'), che_숙련_동작: async () => import('./che_숙련_동작.js'), che_약탈_옥벽: async () => import('./che_약탈_옥벽.js'), + che_위압_조목삭: async () => { + const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([ + import('../triggers/special/war/che_위압.js'), + import('./eventBattleTrait.js'), + ]); + return { + itemModule: createEventBattleTraitItemModule('che_위압_조목삭', traitModule, { + rawName: '조목삭', + name: '조목삭(위압)', + cost: 200, + buyable: false, + reqSecu: 0, + }), + }; + }, che_의술_상한잡병론: async () => import('./che_의술_상한잡병론.js'), che_의술_정력견혈산: async () => import('./che_의술_정력견혈산.js'), che_의술_청낭서: async () => import('./che_의술_청낭서.js'), @@ -272,6 +310,147 @@ const defaultImporters: Record = { che_훈련_단결도: async () => import('./che_훈련_단결도.js'), che_훈련_철벽서: async () => import('./che_훈련_철벽서.js'), che_훈련_청주: async () => import('./che_훈련_청주.js'), + event_전투특기_격노: async () => { + const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([ + import('../triggers/special/war/che_격노.js'), + import('./eventBattleTrait.js'), + ]); + return { itemModule: createEventBattleTraitItemModule('event_전투특기_격노', traitModule) }; + }, + event_전투특기_견고: async () => { + const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([ + import('../triggers/special/war/che_견고.js'), + import('./eventBattleTrait.js'), + ]); + return { itemModule: createEventBattleTraitItemModule('event_전투특기_견고', traitModule) }; + }, + event_전투특기_공성: async () => { + const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([ + import('../triggers/special/war/che_공성.js'), + import('./eventBattleTrait.js'), + ]); + return { itemModule: createEventBattleTraitItemModule('event_전투특기_공성', traitModule) }; + }, + event_전투특기_궁병: async () => { + const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([ + import('../triggers/special/war/che_궁병.js'), + import('./eventBattleTrait.js'), + ]); + return { itemModule: createEventBattleTraitItemModule('event_전투특기_궁병', traitModule) }; + }, + event_전투특기_귀병: async () => { + const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([ + import('../triggers/special/war/che_귀병.js'), + import('./eventBattleTrait.js'), + ]); + return { itemModule: createEventBattleTraitItemModule('event_전투특기_귀병', traitModule) }; + }, + event_전투특기_기병: async () => { + const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([ + import('../triggers/special/war/che_기병.js'), + import('./eventBattleTrait.js'), + ]); + return { itemModule: createEventBattleTraitItemModule('event_전투특기_기병', traitModule) }; + }, + event_전투특기_돌격: async () => { + const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([ + import('../triggers/special/war/che_돌격.js'), + import('./eventBattleTrait.js'), + ]); + return { itemModule: createEventBattleTraitItemModule('event_전투특기_돌격', traitModule) }; + }, + event_전투특기_무쌍: async () => { + const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([ + import('../triggers/special/war/che_무쌍.js'), + import('./eventBattleTrait.js'), + ]); + return { itemModule: createEventBattleTraitItemModule('event_전투특기_무쌍', traitModule) }; + }, + event_전투특기_반계: async () => { + const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([ + import('../triggers/special/war/che_반계.js'), + import('./eventBattleTrait.js'), + ]); + return { itemModule: createEventBattleTraitItemModule('event_전투특기_반계', traitModule) }; + }, + event_전투특기_보병: async () => { + const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([ + import('../triggers/special/war/che_보병.js'), + import('./eventBattleTrait.js'), + ]); + return { itemModule: createEventBattleTraitItemModule('event_전투특기_보병', traitModule) }; + }, + event_전투특기_신산: async () => { + const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([ + import('../triggers/special/war/che_신산.js'), + import('./eventBattleTrait.js'), + ]); + return { itemModule: createEventBattleTraitItemModule('event_전투특기_신산', traitModule) }; + }, + event_전투특기_신중: async () => { + const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([ + import('../triggers/special/war/che_신중.js'), + import('./eventBattleTrait.js'), + ]); + return { itemModule: createEventBattleTraitItemModule('event_전투특기_신중', traitModule) }; + }, + event_전투특기_위압: async () => { + const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([ + import('../triggers/special/war/che_위압.js'), + import('./eventBattleTrait.js'), + ]); + return { itemModule: createEventBattleTraitItemModule('event_전투특기_위압', traitModule) }; + }, + event_전투특기_의술: async () => { + const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([ + import('../triggers/special/war/che_의술.js'), + import('./eventBattleTrait.js'), + ]); + return { itemModule: createEventBattleTraitItemModule('event_전투특기_의술', traitModule) }; + }, + event_전투특기_저격: async () => { + const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([ + import('../triggers/special/war/che_저격.js'), + import('./eventBattleTrait.js'), + ]); + return { itemModule: createEventBattleTraitItemModule('event_전투특기_저격', traitModule) }; + }, + event_전투특기_집중: async () => { + const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([ + import('../triggers/special/war/che_집중.js'), + import('./eventBattleTrait.js'), + ]); + return { itemModule: createEventBattleTraitItemModule('event_전투특기_집중', traitModule) }; + }, + event_전투특기_징병: async () => { + const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([ + import('../triggers/special/war/che_징병.js'), + import('./eventBattleTrait.js'), + ]); + return { itemModule: createEventBattleTraitItemModule('event_전투특기_징병', traitModule) }; + }, + event_전투특기_척사: async () => { + const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([ + import('../triggers/special/war/che_척사.js'), + import('./eventBattleTrait.js'), + ]); + return { itemModule: createEventBattleTraitItemModule('event_전투특기_척사', traitModule) }; + }, + event_전투특기_필살: async () => { + const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([ + import('../triggers/special/war/che_필살.js'), + import('./eventBattleTrait.js'), + ]); + return { itemModule: createEventBattleTraitItemModule('event_전투특기_필살', traitModule) }; + }, + event_전투특기_환술: async () => { + const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([ + import('../triggers/special/war/che_환술.js'), + import('./eventBattleTrait.js'), + ]); + return { itemModule: createEventBattleTraitItemModule('event_전투특기_환술', traitModule) }; + }, + event_충차: async () => import('./event_충차.js'), }; export const isItemKey = (value: string): value is ItemKey => ITEM_KEYS.includes(value as ItemKey); @@ -294,7 +473,13 @@ export class ItemLoader { if (!('itemModule' in module)) { throw new Error(`Missing itemModule for item: ${key}`); } - const resolved = module.itemModule; + const resolved: ItemModule = { + ...module.itemModule, + // Legacy defines the finite unique pool as every non-buyable + // scenario item; keep that invariant authoritative instead of + // relying on duplicated per-module flags. + unique: !module.itemModule.buyable, + }; if (resolved.key !== key) { throw new Error(`Item key mismatch: expected ${key}, got ${resolved.key}`); } @@ -455,11 +640,30 @@ class ItemGeneralActionRouter< let current = aux ?? null; for (const module of this.resolveModules(context)) { if (!module.onArbitraryAction) { - continue; + // Declarative consumption rules are evaluated below even when + // the item has no custom arbitrary-action hook. + } else { + const result = module.onArbitraryAction(context, actionType, phase, current); + if (result !== undefined) { + current = result; + } } - const result = module.onArbitraryAction(context, actionType, phase, current); - if (result !== undefined) { - current = result; + + const shouldConsume = module.consumeOn?.some( + (rule) => + rule.actionType === actionType && + (rule.phase === undefined || rule.phase === phase) && + (rule.command === undefined || current?.['command'] === rule.command) && + (!rule.successOnly || current?.['success'] === true) + ); + if (shouldConsume) { + const removed = removeEquippedItem(context.general, module.slot); + if (removed?.itemKey === module.key) { + const consumedItems = Array.isArray(current?.['consumedItems']) + ? (current['consumedItems'] as unknown[]) + : []; + current = { ...(current ?? {}), consumedItems: [...consumedItems, module.key] }; + } } } return current; @@ -581,6 +785,7 @@ export { getEquippedItemInstance, parseItemInventory, projectItemSlots, + readItemInventory, readItemInventoryFromMeta, removeEquippedItem, serializeItemInventory, diff --git a/packages/logic/src/items/inventory.ts b/packages/logic/src/items/inventory.ts index aa5da94..7687b7d 100644 --- a/packages/logic/src/items/inventory.ts +++ b/packages/logic/src/items/inventory.ts @@ -154,6 +154,10 @@ export const ensureItemInventory = ( return general.itemInventory; }; +export const readItemInventory = ( + general: General +): GeneralItemInventory => general.itemInventory ?? createItemInventoryFromSlots(general.role.items); + export const projectItemSlots = (inventory: GeneralItemInventory): GeneralItemSlots => { const slots: GeneralItemSlots = { horse: null, weapon: null, book: null, item: null }; for (const slot of ITEM_SLOTS) { @@ -167,7 +171,7 @@ export const getEquippedItemInstance = , slot: GeneralItemSlot ): GeneralItemInstance | null => { - const inventory = ensureItemInventory(general); + const inventory = readItemInventory(general); const instanceId = inventory.equipped[slot]; return instanceId ? (inventory.instances[instanceId] ?? null) : null; }; @@ -220,7 +224,9 @@ export const consumeEquippedItemCharge = { - const instance = getEquippedItemInstance(general, slot); + const inventory = ensureItemInventory(general); + const instanceId = inventory.equipped[slot]; + const instance = instanceId ? inventory.instances[instanceId] : undefined; if (!instance || instance.itemKey !== itemKey) { return false; } diff --git a/packages/logic/src/items/types.ts b/packages/logic/src/items/types.ts index c3a1965..184fed6 100644 --- a/packages/logic/src/items/types.ts +++ b/packages/logic/src/items/types.ts @@ -17,6 +17,13 @@ import type { WarUnit } from '@sammo-ts/logic/war/units.js'; export type ItemSlot = 'horse' | 'weapon' | 'book' | 'item'; +export interface ItemActionConsumptionRule { + actionType: TriggerActionType; + phase?: TriggerActionPhase | null; + command?: string; + successOnly?: boolean; +} + export interface ItemModule { key: string; name: string; @@ -27,6 +34,7 @@ export interface ItemModule { if (typeof value === 'boolean') { @@ -27,7 +32,7 @@ export const isInventoryEnabled = (config: ScenarioConfig): boolean => { export const listEquippedItemKeys = ( general: General ): string[] => { - const inventory = ensureItemInventory(general); + const inventory = readItemInventory(general); const items = (['horse', 'weapon', 'book', 'item'] as const).map((slot) => { const instanceId = inventory.equipped[slot]; return instanceId ? (inventory.instances[instanceId]?.itemKey ?? null) : null; @@ -58,6 +63,7 @@ export const setItemRemain = ( itemKey: string, remain: number | null ): void => { + ensureItemInventory(general); const instance = getEquippedItemInstance(general, 'item'); if (!instance || instance.itemKey !== itemKey) { return; diff --git a/packages/logic/src/triggers/types.ts b/packages/logic/src/triggers/types.ts index 9ad03ab..6f322d1 100644 --- a/packages/logic/src/triggers/types.ts +++ b/packages/logic/src/triggers/types.ts @@ -1,4 +1,4 @@ -export type TriggerActionType = '장비매매'; +export type TriggerActionType = '장비매매' | 'GeneralCommand'; export type TriggerActionPhase = '판매' | '구매'; diff --git a/packages/logic/src/war/triggers.ts b/packages/logic/src/war/triggers.ts index cb4459f..28abd2c 100644 --- a/packages/logic/src/war/triggers.ts +++ b/packages/logic/src/war/triggers.ts @@ -1,7 +1,9 @@ import type { RandUtil } from '@sammo-ts/common'; +import type { General } from '@sammo-ts/logic/domain/entities.js'; import { TriggerCaller, type Trigger } from '@sammo-ts/logic/triggers/core.js'; import type { WarUnit } from './units.js'; +import { removeEquippedItem } from '@sammo-ts/logic/items/inventory.js'; export interface WarTriggerContext { rng: RandUtil; @@ -87,11 +89,28 @@ export abstract class BaseWarUnitTrigger implements WarTrigger { opposeEnv: Record ): boolean; - // 아이템 소모 트리거는 아직 미구현. 필요 시 raiseType을 활용해 확장 가능하다. public processConsumableItem(): boolean { if (!(this.raiseType & BaseWarUnitTrigger.TYPE_ITEM)) { return false; } - return false; + if (this.unit.hasActivatedSkill('아이템사용')) { + return false; + } + this.unit.activateSkill('아이템사용'); + if (this.raiseType !== BaseWarUnitTrigger.TYPE_CONSUMABLE_ITEM) { + return false; + } + if (this.unit.hasActivatedSkill('아이템소모')) { + return false; + } + const unit = this.unit as WarUnit & { + getGeneral?: () => General; + }; + const general = unit.getGeneral?.(); + if (!general) { + return false; + } + this.unit.activateSkill('아이템소모'); + return removeEquippedItem(general, 'item') !== null; } } diff --git a/packages/logic/src/war/triggers/전투력보정.ts b/packages/logic/src/war/triggers/전투력보정.ts new file mode 100644 index 0000000..d97847b --- /dev/null +++ b/packages/logic/src/war/triggers/전투력보정.ts @@ -0,0 +1,24 @@ +import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js'; +import { BaseWarUnitTrigger } from '@sammo-ts/logic/war/triggers.js'; +import type { WarUnit } from '@sammo-ts/logic/war/units.js'; + +export class 전투력보정 extends BaseWarUnitTrigger { + constructor( + unit: WarUnit, + private readonly attackMultiplier: number, + private readonly defenceMultiplier = 1 + ) { + super(unit, TriggerPriority.Begin + 20); + } + + protected actionWar( + self: WarUnit, + oppose: WarUnit, + _selfEnv: Record, + _opposeEnv: Record + ): boolean { + self.multiplyWarPowerMultiply(this.attackMultiplier); + oppose.multiplyWarPowerMultiply(this.defenceMultiplier); + return true; + } +} diff --git a/packages/logic/src/war/units/general.ts b/packages/logic/src/war/units/general.ts index 8244591..9c21017 100644 --- a/packages/logic/src/war/units/general.ts +++ b/packages/logic/src/war/units/general.ts @@ -15,14 +15,7 @@ import { getTechAbility, getTechCost } from '@sammo-ts/logic/world/unitSet.js'; import type { WarActionPipeline, WarActionContext } from '../actions.js'; import type { WarEngineConfig } from '../types.js'; import type { WarCrewType } from '../crewType.js'; -import { - clamp, - clampMin, - getMetaNumber, - getMetaString, - increaseMetaNumber, - round, -} from '../utils.js'; +import { clamp, clampMin, getMetaNumber, getMetaString, increaseMetaNumber, round } from '../utils.js'; import { WAR_CRITICAL_RANGE, WarUnit, resolveNationTech } from './base.js'; type CityUnit = WarUnit & { getCityId: () => number }; @@ -150,24 +143,47 @@ export class WarUnitGeneral< public getComputedStat( statName: keyof StatBlock, base: number, - options: { withInjury?: boolean; withStatAdjust?: boolean; withActions?: boolean } = {} + options: { + withInjury?: boolean; + withStatAdjust?: boolean; + withActions?: boolean; + truncate?: boolean; + } = {} ): number { const withInjury = options.withInjury ?? true; const withStatAdjust = options.withStatAdjust ?? true; const withActions = options.withActions ?? true; + const truncate = options.truncate ?? true; const injuryRatio = withInjury ? (100 - this.general.injury) / 100 : 1; let value = base * injuryRatio; if (withStatAdjust && statName === 'strength') { - value += round((this.general.stats.intelligence * injuryRatio) / 4); + value += round( + this.getComputedStat('intelligence', this.general.stats.intelligence, { + withInjury, + withStatAdjust: false, + withActions, + truncate: false, + }) / 4 + ); } else if (withStatAdjust && statName === 'intelligence') { - value += round((this.general.stats.strength * injuryRatio) / 4); + value += round( + this.getComputedStat('strength', this.general.stats.strength, { + withInjury, + withStatAdjust: false, + withActions, + truncate: false, + }) / 4 + ); } const maxGeneralStat = this.config.maxGeneralStat ?? 255; value = clamp(value, 0, maxGeneralStat); if (withActions) { value = this.actionPipeline.onCalcStat(this.getActionContext(), statName, value); } - return clamp(value, 0, maxGeneralStat); + // Legacy General::getStatValue() applies every action and then returns + // Util::toInt(), which truncates toward zero on every stat read. + const clamped = clamp(value, 0, maxGeneralStat); + return truncate ? Math.trunc(clamped) : clamped; } private resolveMainStat(armType: number, withInjury = true): number { @@ -345,7 +361,8 @@ export class WarUnitGeneral< : crewType.armType; const key = `${META_DEX_PREFIX}${armType}`; const base = getMetaNumber(this.general.meta, key); - this.general.meta[key] = round(base + exp); + const adjustedExp = this.actionPipeline.onCalcStat(this.getActionContext(), 'addDex', exp, { armType }); + this.general.meta[key] = base + adjustedExp; } public calcRiceConsumption(damage: number): number { diff --git a/packages/logic/test/itemInventory.test.ts b/packages/logic/test/itemInventory.test.ts index fefead0..0561b1c 100644 --- a/packages/logic/test/itemInventory.test.ts +++ b/packages/logic/test/itemInventory.test.ts @@ -10,6 +10,7 @@ import { projectItemSlots, serializeItemInventory, } from '../src/items/inventory.js'; +import { listEquippedItemKeys } from '../src/items/utils.js'; const makeGeneral = (): General => ({ id: 7, @@ -58,6 +59,16 @@ describe('GeneralItemInventory', () => { expect(Object.keys(inventory.instances)).toHaveLength(3); }); + it('reads legacy slots without mutating a frozen world snapshot', () => { + const general = makeGeneral(); + general.role.items.item = 'che_치료_환약'; + const frozen = Object.freeze(general); + + expect(listEquippedItemKeys(frozen)).toEqual(['che_치료_환약']); + expect(getEquippedItemInstance(frozen, 'item')?.itemKey).toBe('che_치료_환약'); + expect(frozen.itemInventory).toBeUndefined(); + }); + it('keeps consumable charges in the equipped item instance', () => { const general = makeGeneral(); equipNewItem(general, 'item', 'che_치료_환약', { charges: 3 }); diff --git a/packages/logic/test/scenarios/general_commands_new.test.ts b/packages/logic/test/scenarios/general_commands_new.test.ts index 396f921..60b88e2 100644 --- a/packages/logic/test/scenarios/general_commands_new.test.ts +++ b/packages/logic/test/scenarios/general_commands_new.test.ts @@ -14,7 +14,15 @@ import { commandSpec as spySpec } from '../../src/actions/turn/general/che_첩 import { commandSpec as destroySpec } from '../../src/actions/turn/general/che_파괴.js'; import { commandSpec as agitateSpec } from '../../src/actions/turn/general/che_선동.js'; import { commandSpec as seizeSpec } from '../../src/actions/turn/general/che_탈취.js'; +import { commandSpec as fireSpec } from '../../src/actions/turn/general/che_화계.js'; import type { TurnCommandEnv } from '../../src/actions/turn/commandEnv.js'; +import { + createItemActionModules, + createItemModuleRegistry, + equipNewItem, + getEquippedItemInstance, + loadItemModules, +} from '../../src/items/index.js'; describe('General Commands New Scenario', () => { // 1. Setup Environment @@ -357,6 +365,7 @@ describe('General Commands New Scenario', () => { triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, meta: {}, }); + equipNewItem(gen1, 'item', 'che_계략_이추'); const gen2: General = { id: 2, name: 'G2', nationId: 2, cityId: 2, troopId: 0, npcState: 0, ticket: 0 } as any; Object.assign(gen2, { @@ -392,6 +401,16 @@ describe('General Commands New Scenario', () => { const world = new InMemoryWorld(snapshot); const runner = new TestGameRunner(world, 200, 1); + const itemGeneralModules = createItemActionModules( + createItemModuleRegistry(await loadItemModules(['che_계략_이추', 'che_계략_향낭'])) + ).general; + const reEquipStrategyItem = (itemKey: 'che_계략_이추' | 'che_계략_향낭'): void => { + const nextGeneral = structuredClone(world.getGeneral(1)!); + equipNewItem(nextGeneral, 'item', itemKey); + world.snapshot.generals = world.snapshot.generals.map((general) => + general.id === nextGeneral.id ? nextGeneral : general + ); + }; // 1. Employ (G1 -> G2) const employDef = employSpec.createDefinition(systemEnv); @@ -424,7 +443,8 @@ describe('General Commands New Scenario', () => { expect(spyInfo['2']).toBe(3); // City 2 spied level 3 // 3. Destroy (G1 -> C2) - const destroyDef = destroySpec.createDefinition(systemEnv); + const strategyEnv = { ...systemEnv, generalActionModules: itemGeneralModules }; + const destroyDef = destroySpec.createDefinition(strategyEnv); await runner.runTurn([ { generalId: 1, @@ -438,9 +458,42 @@ describe('General Commands New Scenario', () => { const c2_after_destroy = world.getCity(2)!; expect(c2_after_destroy.defence).toBeLessThan(1000); expect(c2_after_destroy.state).toBe(32); + expect(getEquippedItemInstance(world.getGeneral(1)!, 'item')).toBeNull(); - // 4. Agitate (G1 -> C2) - const agitateDef = agitateSpec.createDefinition(systemEnv); + // 4. Fire attack consumes the successful one-use strategy item. + reEquipStrategyItem('che_계략_향낭'); + const fireDef = fireSpec.createDefinition(strategyEnv); + await runner.runTurn([ + { + generalId: 1, + commandKey: 'che_화계', + resolver: fireDef, + args: { destCityId: 2 }, + context: { + destCity: city2, + destNation: nation2, + destGenerals: [gen2], + env: systemEnv, + map: MINIMAL_MAP, + rng: { + real: () => 0, + int: (min: number, _max: number) => min, + nextInt: (min: number, _max: number) => min, + next: () => 0, + nextBool: () => true, + nextRange: (min: number, _max: number) => min, + nextRangeInt: (min: number, _max: number) => min, + nextFloat1: () => 0, + }, + }, + }, + ]); + expect(getEquippedItemInstance(world.getGeneral(1)!, 'item')).toBeNull(); + expect(world.getGeneral(1)!.role.items.item).toBeNull(); + + // 5. Agitate (G1 -> C2) + reEquipStrategyItem('che_계략_이추'); + const agitateDef = agitateSpec.createDefinition(strategyEnv); await runner.runTurn([ { generalId: 1, @@ -455,9 +508,12 @@ describe('General Commands New Scenario', () => { const trust = c2_after_agitate.meta.trust as number; expect(trust).toBeLessThan(100); expect(c2_after_agitate.security).toBeLessThan(1000); + expect(getEquippedItemInstance(world.getGeneral(1)!, 'item')).toBeNull(); - // 5. Seize (G1 -> C2) - const seizeDef = seizeSpec.createDefinition(systemEnv); + // 6. Seize (G1 -> C2) + reEquipStrategyItem('che_계략_향낭'); + const seizeDef = seizeSpec.createDefinition(strategyEnv); + const goldBeforeSeize = world.getGeneral(1)!.gold; // Ensure C2 is supplied (nation has gold/rice) await runner.runTurn([ { @@ -474,6 +530,7 @@ describe('General Commands New Scenario', () => { const g1_after_seize = world.getGeneral(1)!; - expect(g1_after_seize.gold).toBeGreaterThan(4000); // Cost deducted multiple times + expect(g1_after_seize.gold).toBeGreaterThan(goldBeforeSeize - systemEnv.develCost); + expect(getEquippedItemInstance(g1_after_seize, 'item')).toBeNull(); }); }); diff --git a/packages/logic/test/warEngine.test.ts b/packages/logic/test/warEngine.test.ts index 2365cad..552429b 100644 --- a/packages/logic/test/warEngine.test.ts +++ b/packages/logic/test/warEngine.test.ts @@ -11,6 +11,13 @@ import type { WarEngineConfig } from '../src/war/types.js'; import { WarCrewType } from '../src/war/crewType.js'; import { loadWarTriggerModules } from '../src/war/triggers/index.js'; import { WarUnitCity, WarUnitGeneral } from '../src/war/units.js'; +import { + createItemActionModules, + createItemModuleRegistry, + equipNewItem, + getEquippedItemInstance, + loadItemModules, +} from '../src/items/index.js'; const buildConfig = (): WarEngineConfig => ({ armPerPhase: 500, @@ -209,6 +216,71 @@ describe('war triggers', () => { }); describe('resolveWarBattle', () => { + it('persists multi-use battle item charges and removes the last charge', async () => { + const general = buildGeneral(100); + equipNewItem(general, 'item', 'event_충차', { charges: 2 }); + const itemModules = createItemActionModules( + createItemModuleRegistry(await loadItemModules(['event_충차'])) + ).war; + + for (const expectedCharges of [1, null] as const) { + general.crew = 5000; + general.rice = 10000; + const defenderCity = { ...buildCity(), wall: 3000, wallMax: 3000 }; + resolveWarBattle({ + rng: new RandUtil(new ConstantRNG(0)), + unitSet: buildUnitSet(), + config: buildConfig(), + time: { year: 200, month: 1, startYear: 180 }, + attacker: { + general, + city: buildCity(), + nation: buildNation(), + modules: itemModules, + }, + defenders: [], + defenderCity, + defenderNation: buildNation(), + }); + + const equipped = getEquippedItemInstance(general, 'item'); + if (expectedCharges === null) { + expect(equipped).toBeNull(); + expect(general.role.items.item).toBeNull(); + } else { + expect(equipped?.state.charges).toBe(expectedCharges); + expect(general.role.items.item).toBe('event_충차'); + } + } + }); + + it('removes a one-use battle item through the canonical inventory', async () => { + const general = buildGeneral(100); + equipNewItem(general, 'item', 'che_저격_수극'); + const itemModules = createItemActionModules( + createItemModuleRegistry(await loadItemModules(['che_저격_수극'])) + ).war; + + resolveWarBattle({ + rng: new RandUtil(new ConstantRNG(0)), + unitSet: buildUnitSet(), + config: buildConfig(), + time: { year: 200, month: 1, startYear: 180 }, + attacker: { + general, + city: buildCity(), + nation: buildNation(), + modules: itemModules, + }, + defenders: [], + defenderCity: buildCity(), + defenderNation: buildNation(), + }); + + expect(getEquippedItemInstance(general, 'item')).toBeNull(); + expect(general.role.items.item).toBeNull(); + }); + it('handles supply rout when defender nation has no rice', () => { const rng = new RandUtil(new ConstantRNG(0)); const config = buildConfig(); diff --git a/tools/integration-tests/test/battleDifferential.test.ts b/tools/integration-tests/test/battleDifferential.test.ts index cc76a16..cd80908 100644 --- a/tools/integration-tests/test/battleDifferential.test.ts +++ b/tools/integration-tests/test/battleDifferential.test.ts @@ -3,7 +3,13 @@ import fs from 'node:fs'; import path from 'node:path'; import { LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common'; -import type { UnitSetDefinition, WarBattleTraceEvent, WarEngineConfig } from '@sammo-ts/logic'; +import { + ITEM_KEYS, + loadItemModules, + type UnitSetDefinition, + type WarBattleTraceEvent, + type WarEngineConfig, +} from '@sammo-ts/logic'; import { describe, expect, it } from 'vitest'; import { processBattleSimJob } from '../../../app/game-api/src/battleSim/processor.js'; @@ -23,6 +29,16 @@ interface RandomCall { result: unknown; } +interface ReferenceItemMetadata { + rawName: string; + name: string; + info: string; + cost: number | null; + buyable: boolean; + consumable: boolean; + reqSecu: number; +} + class TracingRng implements RNG { public readonly calls: RandomCall[] = []; @@ -83,15 +99,7 @@ const readJson = (filePath: string): T => JSON.parse(fs.readFileSync(filePath const runReferenceTrace = (workspaceRoot: string, fixtureJson: string): ReferenceTrace => { const stdout = execFileSync( 'docker', - [ - 'compose', - 'exec', - '-T', - 'php', - 'php', - '/var/www/html/hwe/compare/battle_trace.php', - '-', - ], + ['compose', 'exec', '-T', 'php', 'php', '/var/www/html/hwe/compare/battle_trace.php', '-'], { cwd: path.join(workspaceRoot, 'docker_compose_files/reference'), input: fixtureJson, @@ -102,6 +110,20 @@ const runReferenceTrace = (workspaceRoot: string, fixtureJson: string): Referenc return JSON.parse(stdout) as ReferenceTrace; }; +const runReferenceItemCatalog = (workspaceRoot: string, itemKeys: string[]): Record => { + const stdout = execFileSync( + 'docker', + ['compose', 'exec', '-T', 'php', 'php', '/var/www/html/hwe/compare/item_catalog.php'], + { + cwd: path.join(workspaceRoot, 'docker_compose_files/reference'), + input: JSON.stringify(itemKeys), + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + } + ); + return JSON.parse(stdout) as Record; +}; + const expectNearlyEqual = (actual: unknown, expected: unknown, label: string): void => { expect(typeof actual, `${label}: actual type`).toBe('number'); expect(typeof expected, `${label}: reference type`).toBe('number'); @@ -118,7 +140,12 @@ const assertTraceParity = ( reference: ReferenceTrace, coreRng: TracingRng | null ): void => { - expect(coreEvents.map((event) => event.event)).toEqual(reference.events.map((event) => event.event)); + const coreEventNames = coreEvents.map((event) => event.event); + const referenceEventNames = reference.events.map((event) => event.event); + expect( + coreEventNames, + `event sequence\ncore=${JSON.stringify(coreEventNames)}\nref=${JSON.stringify(referenceEventNames)}` + ).toEqual(referenceEventNames); expect(coreRng?.calls.map(({ operation, result }) => ({ operation, result }))).toEqual( reference.rng.map(({ operation, result }) => ({ operation, result })) ); @@ -147,6 +174,139 @@ const workspaceRoot = findWorkspaceRoot(process.cwd()); const describeWithReference = workspaceRoot ? describe : describe.skip; describeWithReference('ref ↔ core2026 battle differential', () => { + it('loads every scenario item with the scenario slot', async () => { + const scenarioDir = path.resolve(process.cwd(), '../../resources/scenario'); + const itemSlots = new Map(); + for (const filename of fs.readdirSync(scenarioDir).filter((entry) => entry.endsWith('.json'))) { + const scenario = readJson<{ + const?: { + allItems?: Partial>>; + }; + }>(path.join(scenarioDir, filename)); + for (const slot of ['horse', 'weapon', 'book', 'item'] as const) { + for (const itemKey of Object.keys(scenario.const?.allItems?.[slot] ?? {})) { + itemSlots.set(itemKey, slot); + } + } + } + + const modules = await loadItemModules([...ITEM_KEYS]); + const reference = runReferenceItemCatalog(workspaceRoot!, [...ITEM_KEYS]); + expect(modules).toHaveLength(145); + const failures: string[] = []; + for (const module of modules) { + const actual = { + rawName: module.rawName, + name: module.name, + info: module.info, + cost: module.cost, + buyable: module.buyable, + consumable: module.consumable, + reqSecu: module.reqSecu, + }; + if ( + module.slot !== itemSlots.get(module.key) || + module.unique !== !reference[module.key]?.buyable || + JSON.stringify(actual) !== JSON.stringify(reference[module.key]) + ) { + failures.push( + `${module.key}: slot=${module.slot}/${String(itemSlots.get(module.key))} unique=${String(module.unique)}/${String(!reference[module.key]?.buyable)} core=${JSON.stringify(actual)} ref=${JSON.stringify(reference[module.key])}` + ); + } + } + expect(failures, failures.join('\n')).toEqual([]); + }); + + it('matches the legacy non-stacking rule for the 무쌍 trait item', () => { + const base = readJson( + path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json') + ); + base.seed = 'battle-differential-duplicate-musang'; + base.attackerGeneral.personal = 'None'; + base.attackerGeneral.special2 = 'che_무쌍'; + base.attackerGeneral.item = 'event_전투특기_무쌍'; + base.attackerGeneral.crew = 5000; + base.attackerGeneral.leadership = 90; + base.attackerGeneral.strength = 90; + base.attackerGeneral.intel = 90; + base.attackerGeneral.dex1 = 12000; + const unitSet = readJson( + path.resolve(process.cwd(), '../../resources/unitset/unitset_che.json') + ); + const config: WarEngineConfig = { + armPerPhase: 500, + maxTrainByCommand: 100, + maxAtmosByCommand: 100, + maxTrainByWar: 110, + maxAtmosByWar: 150, + castleCrewTypeId: 1000, + armTypes: { footman: 1, archer: 2, cavalry: 3, wizard: 4, siege: 5, misc: 6, castle: 0 }, + }; + const coreEvents: WarBattleTraceEvent[] = []; + let coreRng: TracingRng | null = null; + processBattleSimJob( + { + ...base, + unitSet, + config, + time: { year: base.year, month: base.month, startYear: base.startYear }, + }, + { + trace: (event) => coreEvents.push(event), + rngFactory: (seed) => { + coreRng = new TracingRng(LiteHashDRBG.build(seed)); + return new RandUtil(coreRng); + }, + } + ); + assertTraceParity(coreEvents, runReferenceTrace(workspaceRoot!, JSON.stringify(base)), coreRng); + }); + + it('matches 척사 items against region-restricted troops', () => { + const unitSet = readJson( + path.resolve(process.cwd(), '../../resources/unitset/unitset_che.json') + ); + const config: WarEngineConfig = { + armPerPhase: 500, + maxTrainByCommand: 100, + maxAtmosByCommand: 100, + maxTrainByWar: 110, + maxAtmosByWar: 150, + castleCrewTypeId: 1000, + armTypes: { footman: 1, archer: 2, cavalry: 3, wizard: 4, siege: 5, misc: 6, castle: 0 }, + }; + + for (const itemKey of ['che_척사_오악진형도', 'event_전투특기_척사']) { + const base = readJson( + path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json') + ); + base.seed = `battle-differential-region-item-${itemKey}`; + base.attackerGeneral.personal = 'None'; + base.attackerGeneral.special2 = 'None'; + base.attackerGeneral.item = itemKey; + base.attackerGeneral.crew = 5000; + base.defenderGenerals[0]!.crewtype = 1101; + const coreEvents: WarBattleTraceEvent[] = []; + let coreRng: TracingRng | null = null; + processBattleSimJob( + { + ...base, + unitSet, + config, + time: { year: base.year, month: base.month, startYear: base.startYear }, + }, + { + trace: (event) => coreEvents.push(event), + rngFactory: (seed) => { + coreRng = new TracingRng(LiteHashDRBG.build(seed)); + return new RandUtil(coreRng); + }, + } + ); + assertTraceParity(coreEvents, runReferenceTrace(workspaceRoot!, JSON.stringify(base)), coreRng); + } + }); + it('keeps the detailed event sequence and phase values within 1%', () => { const fixturePath = path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json'); const fixtureJson = fs.readFileSync(fixturePath, 'utf8'); @@ -429,11 +589,207 @@ describeWithReference('ref ↔ core2026 battle differential', () => { const reference = runReferenceTrace(workspaceRoot!, fixtureJson); expect(result.result).toBe(true); - expect(reference.events.map((event) => event.event)).toEqual([ - 'battle_start', - 'supply_retreat', - 'battle_end', - ]); + expect(reference.events.map((event) => event.event)).toEqual(['battle_start', 'supply_retreat', 'battle_end']); assertTraceParity(coreEvents, reference, coreRng); }); + + it('matches every scenario item in an attacker battle simulation', { timeout: 180_000 }, () => { + const scenarioDir = path.resolve(process.cwd(), '../../resources/scenario'); + const itemSlots = new Map(); + for (const filename of fs.readdirSync(scenarioDir).filter((entry) => entry.endsWith('.json'))) { + const scenario = readJson<{ + const?: { + allItems?: Partial>>; + }; + }>(path.join(scenarioDir, filename)); + for (const slot of ['horse', 'weapon', 'book', 'item'] as const) { + for (const itemKey of Object.keys(scenario.const?.allItems?.[slot] ?? {})) { + itemSlots.set(itemKey, slot); + } + } + } + expect(itemSlots.size).toBe(145); + expect([...ITEM_KEYS].sort()).toEqual([...itemSlots.keys()].sort()); + + const unitSet = readJson( + path.resolve(process.cwd(), '../../resources/unitset/unitset_che.json') + ); + const config: WarEngineConfig = { + armPerPhase: 500, + maxTrainByCommand: 100, + maxAtmosByCommand: 100, + maxTrainByWar: 110, + maxAtmosByWar: 150, + castleCrewTypeId: 1000, + armTypes: { + footman: 1, + archer: 2, + cavalry: 3, + wizard: 4, + siege: 5, + misc: 6, + castle: 0, + }, + }; + const failures: string[] = []; + + const itemFilter = process.env['ITEM_PARITY_FILTER']; + for (const [itemKey, slot] of [...itemSlots].sort(([lhs], [rhs]) => lhs.localeCompare(rhs))) { + if (itemFilter && itemKey !== itemFilter) { + continue; + } + const base = readJson( + path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json') + ); + base.seed = `battle-differential-item-${itemKey}`; + base.attackerGeneral.personal = 'None'; + base.attackerGeneral.special2 = 'None'; + base.attackerGeneral.crew = 5000; + base.attackerGeneral.leadership = 90; + base.attackerGeneral.strength = 90; + base.attackerGeneral.intel = 90; + base.attackerGeneral.dex1 = 12000; + base.attackerGeneral.dex2 = 12000; + base.attackerGeneral.dex3 = 12000; + base.attackerGeneral.dex4 = 12000; + base.attackerGeneral.dex5 = 12000; + base.attackerGeneral[slot] = itemKey; + const defender = base.defenderGenerals[0]!; + defender.personal = 'None'; + defender.special2 = 'None'; + defender.crew = 1500; + defender.leadership = 75; + defender.strength = 75; + defender.intel = 75; + base.defenderCity.wall = 3000; + base.defenderCity.wall_max = 3000; + + const payload: BattleSimJobPayload = { + ...base, + unitSet, + config, + time: { year: base.year, month: base.month, startYear: base.startYear }, + }; + const coreEvents: WarBattleTraceEvent[] = []; + let coreRng: TracingRng | null = null; + processBattleSimJob(payload, { + trace: (event) => coreEvents.push(event), + rngFactory: (seed) => { + coreRng = new TracingRng(LiteHashDRBG.build(seed)); + return new RandUtil(coreRng); + }, + }); + const reference = runReferenceTrace(workspaceRoot!, JSON.stringify(base)); + try { + assertTraceParity(coreEvents, reference, coreRng); + } catch (error) { + const debug = + process.env['ITEM_PARITY_DEBUG'] === '1' + ? `\ncore=${JSON.stringify( + coreEvents + .map((event, index) => ({ index, event })) + .filter(({ event }) => event.event === 'phase_damage') + )}\nref=${JSON.stringify( + reference.events + .map((event, index) => ({ index, event })) + .filter(({ event }) => event.event === 'phase_damage') + )}` + : ''; + failures.push(`${itemKey}: ${error instanceof Error ? error.message : String(error)}${debug}`); + } + } + expect(failures, failures.join('\n')).toEqual([]); + }); + + it('matches every scenario item in a defender battle simulation', { timeout: 180_000 }, () => { + const scenarioDir = path.resolve(process.cwd(), '../../resources/scenario'); + const itemSlots = new Map(); + for (const filename of fs.readdirSync(scenarioDir).filter((entry) => entry.endsWith('.json'))) { + const scenario = readJson<{ + const?: { + allItems?: Partial>>; + }; + }>(path.join(scenarioDir, filename)); + for (const slot of ['horse', 'weapon', 'book', 'item'] as const) { + for (const itemKey of Object.keys(scenario.const?.allItems?.[slot] ?? {})) { + itemSlots.set(itemKey, slot); + } + } + } + + const unitSet = readJson( + path.resolve(process.cwd(), '../../resources/unitset/unitset_che.json') + ); + const config: WarEngineConfig = { + armPerPhase: 500, + maxTrainByCommand: 100, + maxAtmosByCommand: 100, + maxTrainByWar: 110, + maxAtmosByWar: 150, + castleCrewTypeId: 1000, + armTypes: { + footman: 1, + archer: 2, + cavalry: 3, + wizard: 4, + siege: 5, + misc: 6, + castle: 0, + }, + }; + const failures: string[] = []; + + const itemFilter = process.env['ITEM_PARITY_FILTER']; + for (const [itemKey, slot] of [...itemSlots].sort(([lhs], [rhs]) => lhs.localeCompare(rhs))) { + if (itemFilter && itemKey !== itemFilter) { + continue; + } + const base = readJson( + path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json') + ); + base.seed = `battle-differential-defender-item-${itemKey}`; + base.attackerGeneral.personal = 'None'; + base.attackerGeneral.special2 = 'None'; + base.attackerGeneral.crew = 1500; + base.attackerGeneral.leadership = 75; + base.attackerGeneral.strength = 75; + base.attackerGeneral.intel = 75; + const defender = base.defenderGenerals[0]!; + defender.personal = 'None'; + defender.special2 = 'None'; + defender.crew = 5000; + defender.leadership = 90; + defender.strength = 90; + defender.intel = 90; + defender.dex1 = 12000; + defender.dex2 = 12000; + defender.dex3 = 12000; + defender.dex4 = 12000; + defender.dex5 = 12000; + defender[slot] = itemKey; + + const payload: BattleSimJobPayload = { + ...base, + unitSet, + config, + time: { year: base.year, month: base.month, startYear: base.startYear }, + }; + const coreEvents: WarBattleTraceEvent[] = []; + let coreRng: TracingRng | null = null; + processBattleSimJob(payload, { + trace: (event) => coreEvents.push(event), + rngFactory: (seed) => { + coreRng = new TracingRng(LiteHashDRBG.build(seed)); + return new RandUtil(coreRng); + }, + }); + const reference = runReferenceTrace(workspaceRoot!, JSON.stringify(base)); + try { + assertTraceParity(coreEvents, reference, coreRng); + } catch (error) { + failures.push(`${itemKey}: ${error instanceof Error ? error.message : String(error)}`); + } + } + expect(failures, failures.join('\n')).toEqual([]); + }); });