Fix recruitment and seizure command parity

This commit is contained in:
2026-07-26 00:31:38 +00:00
parent 597bb80a6e
commit c1c8401b8c
5 changed files with 47 additions and 24 deletions
@@ -56,9 +56,10 @@ normalized to the core `*Id` spelling before command identity comparison.
canonical projection. canonical projection.
- `trace.ts`: before/execute/after capture boundary. - `trace.ts`: before/execute/after capture boundary.
- `compare.ts`: exact snapshot and delta comparison. - `compare.ts`: exact snapshot and delta comparison.
- `turnCommandGeneralMatrix.integration.test.ts`: 29 successful general - `turnCommandGeneralMatrix.integration.test.ts`: 37 successful general
command paths, including the four-call `전투태세` completion path and command paths, including the four-call `전투태세` completion path and
movement/return/gift/spy/sabotage commands. movement/return/gift/spy/sabotage, recruitment, proficiency transfer,
trait reset and equipment-trade commands.
- `turnCommandNationMatrix.integration.test.ts`: 8 successful nation - `turnCommandNationMatrix.integration.test.ts`: 8 successful nation
command paths. command paths.
- `turnCommandCoreReference.integration.test.ts`: declaration and live - `turnCommandCoreReference.integration.test.ts`: declaration and live
@@ -163,9 +164,9 @@ Compatibility is established per case only when:
4. live sortie also passes the battle trace comparison; 4. live sortie also passes the battle trace comparison;
5. any ignored path is documented in the case evidence. 5. any ignored path is documented in the case evidence.
As of 2026-07-25, 29 general cases, 8 nation cases, declaration and live sortie As of 2026-07-25, 37 general cases, 8 nation cases, declaration and live sortie
pass this boundary. Live sortie covers battle entry, conquest, defeated-general pass this boundary. Live sortie covers battle entry, conquest, defeated-general
neutralization and last-city nation collapse. This is 39 executable comparison neutralization and last-city nation collapse. This is 47 executable comparison
cases, not a claim that all 55 general and 38 nation command classes have been cases, not a claim that all 55 general and 38 nation command classes have been
dynamically compared. dynamically compared.
@@ -1,7 +1,10 @@
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import { ARGS_SCHEMA, ActionDefinition as RecruitActionDefinition } from './che_징병.js'; import {
ARGS_SCHEMA,
ActionDefinition as RecruitActionDefinition,
actionContextBuilder as recruitActionContextBuilder,
} from './che_징병.js';
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import type { GeneralTurnCommandSpec } from './index.js'; import type { GeneralTurnCommandSpec } from './index.js';
import type { GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js'; import type { GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js';
@@ -13,6 +16,7 @@ export class ActionDefinition<
constructor(modules: GeneralActionModule<TriggerState>[]) { constructor(modules: GeneralActionModule<TriggerState>[]) {
super(modules, { super(modules, {
actionName: '모병',
costOffset: 2, costOffset: 2,
defaultTrain: 70, // GameConst::$defaultTrainHigh defaultTrain: 70, // GameConst::$defaultTrainHigh
defaultAtmos: 70, // GameConst::$defaultAtmosHigh defaultAtmos: 70, // GameConst::$defaultAtmosHigh
@@ -20,7 +24,8 @@ export class ActionDefinition<
} }
} }
export const actionContextBuilder = defaultActionContextBuilder; // 모병도 징병과 동일하게 병종/지도/연도 컨텍스트가 필요하다.
export const actionContextBuilder = recruitActionContextBuilder;
export const commandSpec: GeneralTurnCommandSpec = { export const commandSpec: GeneralTurnCommandSpec = {
key: 'che_모병', key: 'che_모병',
@@ -418,7 +418,7 @@ export class ActionResolver<
general.meta = addMetaNumber(general.meta, dexGain.key, dexGain.amount); general.meta = addMetaNumber(general.meta, dexGain.key, dexGain.amount);
} }
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME }); tryApplyUniqueLottery(context, { acquireType: '아이템', reason: actionName });
return { effects: [] }; return { effects: [] };
} }
@@ -101,8 +101,11 @@ export class ActionResolver<
const rawGold = result.agriDamage * destCity.level * yearCoef * (0.25 + commRatio / 4); const rawGold = result.agriDamage * destCity.level * yearCoef * (0.25 + commRatio / 4);
const rawRice = result.commDamage * destCity.level * yearCoef * (0.25 + agriRatio / 4); const rawRice = result.commDamage * destCity.level * yearCoef * (0.25 + agriRatio / 4);
let stolenGold = Math.floor(rawGold); // 레거시는 탈취량을 부동소수점으로 유지한 채 국가/장수 DB 정수 필드에
let stolenRice = Math.floor(rawRice); // 기록할 때 반올림한다. 여기서 미리 내림하면 국고와 본국 몫이 1씩
// 달라질 수 있다.
let stolenGold = rawGold;
let stolenRice = rawRice;
const isSupplied = destCity.supplyState === 1; const isSupplied = destCity.supplyState === 1;
@@ -119,9 +122,8 @@ export class ActionResolver<
effects.push( effects.push(
createNationPatchEffect( createNationPatchEffect(
{ {
...destNation, gold: Math.round(destNation.gold - stolenGold),
gold: destNation.gold - stolenGold, rice: Math.round(destNation.rice - stolenRice),
rice: destNation.rice - stolenRice,
}, },
destNation.id destNation.id
) )
@@ -130,23 +132,23 @@ export class ActionResolver<
effects.push( effects.push(
createCityPatchEffect( createCityPatchEffect(
{ {
...destCity, // 레거시는 같은 명령 안에서 잠시 34로 쓴 뒤 최종 32로
state: 34, // 덮어쓴다. 관찰 가능한 최종 상태는 32다.
state: 32,
}, },
args.destCityId args.destCityId
) )
); );
} else { } else {
const commDmg = Math.floor(stolenGold / 12); const commDmg = stolenGold / 12;
const agriDmg = Math.floor(stolenRice / 12); const agriDmg = stolenRice / 12;
effects.push( effects.push(
createCityPatchEffect( createCityPatchEffect(
{ {
...destCity, commerce: Math.round(Math.max(0, destCity.commerce - commDmg)),
commerce: Math.max(0, destCity.commerce - commDmg), agriculture: Math.round(Math.max(0, destCity.agriculture - agriDmg)),
agriculture: Math.max(0, destCity.agriculture - agriDmg), state: 32,
state: 34,
}, },
args.destCityId args.destCityId
) )
@@ -165,7 +167,6 @@ export class ActionResolver<
effects.push( effects.push(
createNationPatchEffect( createNationPatchEffect(
{ {
...nation,
gold: nation.gold + nationShareGold, gold: nation.gold + nationShareGold,
rice: nation.rice + nationShareRice, rice: nation.rice + nationShareRice,
}, },
@@ -186,8 +187,8 @@ export class ActionResolver<
}); });
consumeSuccessfulStrategyItem(this.pipeline, context); consumeSuccessfulStrategyItem(this.pipeline, context);
general.gold += myShareGold; general.gold = Math.round(general.gold + myShareGold);
general.rice += myShareRice; general.rice = Math.round(general.rice + myShareRice);
return { effects }; return { effects };
} }
@@ -190,6 +190,22 @@ const cases: Array<[string, Record<string, unknown> | undefined, Record<string,
['che_첩보', { destCityID: 70 }, undefined], ['che_첩보', { destCityID: 70 }, undefined],
['che_화계', { destCityID: 70 }, undefined], ['che_화계', { destCityID: 70 }, undefined],
['che_파괴', { destCityID: 70 }, undefined], ['che_파괴', { destCityID: 70 }, undefined],
['che_선동', { destCityID: 70 }, undefined],
['che_탈취', { destCityID: 70 }, undefined],
['che_모병', { crewType: 1100, amount: 100 }, undefined],
['che_징병', { crewType: 1100, amount: 100 }, undefined],
['che_숙련전환', { srcArmType: 1, destArmType: 2 }, { dex1: 100 }],
[
'che_내정특기초기화',
undefined,
{ specialDomestic: 'che_인덕', lastTurn: { command: '내정 특기 초기화', term: 1 } },
],
[
'che_전투특기초기화',
undefined,
{ specialWar: 'che_귀병', lastTurn: { command: '전투 특기 초기화', term: 1 } },
],
['che_장비매매', { itemType: 'weapon', itemCode: 'che_무기_01_단도' }, undefined],
]; ];
integration('general command success matrix', () => { integration('general command success matrix', () => {