반계 아이템 발동 누락과 특기 중첩 처리 수정
This commit is contained in:
@@ -1,7 +1,9 @@
|
|||||||
import { createStatItemModule } from './base.js';
|
import { createStatItemModule } from './base.js';
|
||||||
import type { ItemModule } from './types.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_사마법',
|
key: 'che_서적_07_사마법',
|
||||||
rawName: '사마법',
|
rawName: '사마법',
|
||||||
slot: 'book',
|
slot: 'book',
|
||||||
@@ -13,3 +15,18 @@ export const itemModule: ItemModule = createStatItemModule({
|
|||||||
unique: true,
|
unique: true,
|
||||||
extraInfo: '[전투] 상대의 계략을 10% 확률로 되돌림',
|
extraInfo: '[전투] 상대의 계략을 10% 확률로 되돌림',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const itemModule: ItemModule = {
|
||||||
|
...baseModule,
|
||||||
|
getBattlePhaseTriggerList: (context) =>
|
||||||
|
context.unit
|
||||||
|
? new WarTriggerCaller(
|
||||||
|
new che_반계시도(
|
||||||
|
context.unit,
|
||||||
|
BaseWarUnitTrigger.TYPE_ITEM + BaseWarUnitTrigger.TYPE_DEDUP_TYPE_BASE * 207,
|
||||||
|
0.1
|
||||||
|
),
|
||||||
|
new che_반계발동(context.unit)
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createStatItemModule } from './base.js';
|
import { createStatItemModule } from './base.js';
|
||||||
import type { ItemModule } from './types.js';
|
import type { ItemModule } from './types.js';
|
||||||
import { WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
|
import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
|
||||||
import { che_반계발동, che_반계시도 } from '@sammo-ts/logic/war/triggers/che_반계.js';
|
import { che_반계발동, che_반계시도 } from '@sammo-ts/logic/war/triggers/che_반계.js';
|
||||||
|
|
||||||
const baseModule = createStatItemModule({
|
const baseModule = createStatItemModule({
|
||||||
@@ -19,5 +19,10 @@ const baseModule = createStatItemModule({
|
|||||||
export const itemModule: ItemModule = {
|
export const itemModule: ItemModule = {
|
||||||
...baseModule,
|
...baseModule,
|
||||||
getBattlePhaseTriggerList: (context) =>
|
getBattlePhaseTriggerList: (context) =>
|
||||||
context.unit ? new WarTriggerCaller(new che_반계시도(context.unit, 0.1), new che_반계발동(context.unit)) : null,
|
context.unit
|
||||||
|
? new WarTriggerCaller(
|
||||||
|
new che_반계시도(context.unit, BaseWarUnitTrigger.TYPE_ITEM, 0.1),
|
||||||
|
new che_반계발동(context.unit)
|
||||||
|
)
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js'
|
|||||||
import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
|
import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
|
||||||
import { che_의술발동, che_의술시도 } from '@sammo-ts/logic/war/triggers/che_의술.js';
|
import { che_의술발동, che_의술시도 } from '@sammo-ts/logic/war/triggers/che_의술.js';
|
||||||
import { che_저격발동, che_저격시도 } from '@sammo-ts/logic/war/triggers/che_저격.js';
|
import { che_저격발동, che_저격시도 } from '@sammo-ts/logic/war/triggers/che_저격.js';
|
||||||
|
import { che_반계발동, che_반계시도 } from '@sammo-ts/logic/war/triggers/che_반계.js';
|
||||||
import type { ItemModule } from './types.js';
|
import type { ItemModule } from './types.js';
|
||||||
|
|
||||||
export const createEventBattleTraitItemModule = (
|
export const createEventBattleTraitItemModule = (
|
||||||
@@ -56,6 +57,14 @@ export const createEventBattleTraitItemModule = (
|
|||||||
new che_저격발동(context.unit, BaseWarUnitTrigger.TYPE_ITEM)
|
new che_저격발동(context.unit, BaseWarUnitTrigger.TYPE_ITEM)
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
} else if (traitModule.key === 'che_반계') {
|
||||||
|
itemModule.getBattlePhaseTriggerList = (context) =>
|
||||||
|
context.unit
|
||||||
|
? new WarTriggerCaller(
|
||||||
|
new che_반계시도(context.unit, BaseWarUnitTrigger.TYPE_ITEM),
|
||||||
|
new che_반계발동(context.unit)
|
||||||
|
)
|
||||||
|
: null;
|
||||||
} else if (traitModule.key === 'che_의술') {
|
} else if (traitModule.key === 'che_의술') {
|
||||||
itemModule.getBattlePhaseTriggerList = (context) =>
|
itemModule.getBattlePhaseTriggerList = (context) =>
|
||||||
context.unit
|
context.unit
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import type { WarUnit } from '@sammo-ts/logic/war/units.js';
|
|||||||
export class che_반계시도 extends BaseWarUnitTrigger {
|
export class che_반계시도 extends BaseWarUnitTrigger {
|
||||||
private readonly prob: number;
|
private readonly prob: number;
|
||||||
|
|
||||||
constructor(unit: WarUnit, prob = 0.4) {
|
constructor(unit: WarUnit, raiseType = BaseWarUnitTrigger.TYPE_NONE, prob = 0.4) {
|
||||||
super(unit, TriggerPriority.Body + 300);
|
// Ref는 특기와 아이템의 시도를 raiseType으로 구분하고 발동은 한 번만 합친다.
|
||||||
|
super(unit, TriggerPriority.Body + 300, raiseType);
|
||||||
this.prob = prob;
|
this.prob = prob;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { ConstantRNG, RandUtil } from '@sammo-ts/common';
|
import { ConstantRNG, RandUtil } from '@sammo-ts/common';
|
||||||
|
|
||||||
import type { City, General, Nation } from '../src/domain/entities.js';
|
import type { City, General, Nation } from '../src/domain/entities.js';
|
||||||
import type { UnitSetDefinition } from '../src/world/types.js';
|
import type { UnitSetDefinition } from '../src/world/types.js';
|
||||||
import { ActionLogger } from '../src/logging/actionLogger.js';
|
import { ActionLogger } from '../src/logging/actionLogger.js';
|
||||||
|
import { traitModule as counterTrait } from '../src/actionModules/traits/war/che_반계.js';
|
||||||
import { WarActionPipeline } from '../src/war/actions.js';
|
import { WarActionPipeline } from '../src/war/actions.js';
|
||||||
import { resolveWarBattle } from '../src/war/engine.js';
|
import { resolveWarBattle } from '../src/war/engine.js';
|
||||||
import { LegacyWarLogFlushSequence } from '../src/war/legacyFlushSequence.js';
|
import { LegacyWarLogFlushSequence } from '../src/war/legacyFlushSequence.js';
|
||||||
@@ -167,6 +168,74 @@ const buildGeneral = (strength: number): General => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('war triggers', () => {
|
describe('war triggers', () => {
|
||||||
|
it.each([
|
||||||
|
{ book: 'che_서적_07_사마법', item: null, trait: false, probabilities: [0.1] },
|
||||||
|
{ book: 'che_서적_12_산해경', item: null, trait: false, probabilities: [0.1] },
|
||||||
|
{ book: null, item: 'event_전투특기_반계', trait: false, probabilities: [0.4] },
|
||||||
|
{ book: 'che_서적_07_사마법', item: null, trait: true, probabilities: [0.4, 0.1] },
|
||||||
|
{ book: 'che_서적_12_산해경', item: null, trait: true, probabilities: [0.4, 0.1] },
|
||||||
|
{ book: null, item: 'event_전투특기_반계', trait: true, probabilities: [0.4, 0.4] },
|
||||||
|
{ book: 'che_서적_07_사마법', item: 'event_전투특기_반계', trait: true, probabilities: [0.4, 0.1, 0.4] },
|
||||||
|
// 산해경과 비급은 Ref에서 같은 ITEM raiseType: 뒤의 비급 시도 하나만 남는다.
|
||||||
|
{ book: 'che_서적_12_산해경', item: 'event_전투특기_반계', trait: true, probabilities: [0.4, 0.4] },
|
||||||
|
] as const)(
|
||||||
|
'preserves counter-stratagem attempts for $book / $item / trait=$trait',
|
||||||
|
async ({ book, item, trait, probabilities }) => {
|
||||||
|
const general = buildGeneral(80);
|
||||||
|
general.role.items.book = book;
|
||||||
|
general.role.items.item = item;
|
||||||
|
const keys = [book, item].filter((key) => key !== null);
|
||||||
|
const modules = createItemActionModules(createItemModuleRegistry(await loadItemModules(keys))).war;
|
||||||
|
const pipeline = new WarActionPipeline([...(trait ? [counterTrait] : []), ...modules]);
|
||||||
|
const rng = new RandUtil(new ConstantRNG(0));
|
||||||
|
const attempt = vi.spyOn(rng, 'nextBool').mockReturnValue(false);
|
||||||
|
const makeUnit = (attacker: boolean) =>
|
||||||
|
new WarUnitGeneral(
|
||||||
|
rng,
|
||||||
|
buildConfig(),
|
||||||
|
{ ...general, id: attacker ? 1 : 2 },
|
||||||
|
buildCity(),
|
||||||
|
buildNation(),
|
||||||
|
attacker,
|
||||||
|
new WarCrewType(buildUnitSet().crewTypes![0]!),
|
||||||
|
new ActionLogger({ generalId: attacker ? 1 : 2 }),
|
||||||
|
pipeline
|
||||||
|
);
|
||||||
|
const self = makeUnit(true);
|
||||||
|
const oppose = makeUnit(false);
|
||||||
|
const caller = pipeline.getBattlePhaseTriggerList(self.getActionContext());
|
||||||
|
const fire = () =>
|
||||||
|
caller.fire(
|
||||||
|
{ rng, attacker: self, defender: oppose },
|
||||||
|
{ e_attacker: {}, e_defender: { magic: ['화계', 1.5] } }
|
||||||
|
);
|
||||||
|
oppose.activateSkill('계략');
|
||||||
|
fire();
|
||||||
|
expect(attempt.mock.calls.map(([probability]) => probability)).toEqual(probabilities);
|
||||||
|
expect(self.hasActivatedSkill('반계')).toBe(false);
|
||||||
|
expect(oppose.hasActivatedSkill('계략')).toBe(true);
|
||||||
|
|
||||||
|
attempt.mockClear().mockReturnValue(true);
|
||||||
|
const multiply = vi.spyOn(self, 'multiplyWarPowerMultiply');
|
||||||
|
fire();
|
||||||
|
expect(attempt).toHaveBeenCalledTimes(1);
|
||||||
|
expect(self.hasActivatedSkill('반계')).toBe(true);
|
||||||
|
expect(oppose.hasActivatedSkill('계략')).toBe(false);
|
||||||
|
expect(multiply).toHaveBeenCalledExactlyOnceWith(1.5);
|
||||||
|
|
||||||
|
self.deactivateSkill('반계');
|
||||||
|
attempt.mockClear();
|
||||||
|
fire();
|
||||||
|
expect(attempt).not.toHaveBeenCalled();
|
||||||
|
oppose.activateSkill('계략');
|
||||||
|
self.activateSkill('반계불가');
|
||||||
|
fire();
|
||||||
|
expect(attempt).not.toHaveBeenCalled();
|
||||||
|
expect(self.hasActivatedSkill('반계')).toBe(false);
|
||||||
|
expect(oppose.hasActivatedSkill('계략')).toBe(true);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
it('applies the legacy talisman immunity to self and snipe restriction to the opponent', async () => {
|
it('applies the legacy talisman immunity to self and snipe restriction to the opponent', async () => {
|
||||||
const attacker = buildGeneral(80);
|
const attacker = buildGeneral(80);
|
||||||
attacker.role.items.item = 'che_부적_태현청생부';
|
attacker.role.items.item = 'che_부적_태현청생부';
|
||||||
|
|||||||
@@ -2151,6 +2151,127 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it('matches counter-stratagem items and stacked traits against wizards', { timeout: 180_000 }, () => {
|
||||||
|
const unitSet = readJson<UnitSetDefinition>(
|
||||||
|
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 },
|
||||||
|
};
|
||||||
|
// Ref의 raiseType별 시도 중복 제거와 단일 반사 발동을 실제 귀병 전투로 검증한다.
|
||||||
|
const variants = [
|
||||||
|
{ name: 'sima', book: 'che_서적_07_사마법' },
|
||||||
|
{ name: 'shanhai', book: 'che_서적_12_산해경' },
|
||||||
|
{ name: 'manual', item: 'event_전투특기_반계' },
|
||||||
|
{ name: 'trait', special2: 'che_반계' },
|
||||||
|
{ name: 'domestic', special: 'che_event_반계' },
|
||||||
|
{ name: 'sima-trait', book: 'che_서적_07_사마법', special2: 'che_반계' },
|
||||||
|
{ name: 'shanhai-trait', book: 'che_서적_12_산해경', special2: 'che_반계' },
|
||||||
|
{ name: 'manual-trait', item: 'event_전투특기_반계', special2: 'che_반계' },
|
||||||
|
{ name: 'sima-manual', book: 'che_서적_07_사마법', item: 'event_전투특기_반계' },
|
||||||
|
{ name: 'shanhai-manual', book: 'che_서적_12_산해경', item: 'event_전투특기_반계' },
|
||||||
|
{
|
||||||
|
name: 'all',
|
||||||
|
book: 'che_서적_07_사마법',
|
||||||
|
item: 'event_전투특기_반계',
|
||||||
|
special2: 'che_반계',
|
||||||
|
special: 'che_event_반계',
|
||||||
|
},
|
||||||
|
{ name: 'suppressed', book: 'che_서적_07_사마법', item: 'event_전투특기_반계', special2: 'che_반계' },
|
||||||
|
{ name: 'no-magic', book: 'che_서적_07_사마법', item: 'event_전투특기_반계', special2: 'che_반계' },
|
||||||
|
];
|
||||||
|
const cases = [];
|
||||||
|
for (const variant of variants) {
|
||||||
|
for (const side of ['attacker', 'defender'] as const) {
|
||||||
|
for (let seed = 0; seed < 8; seed += 1) {
|
||||||
|
const base = readJson<BattleSimRequestPayload & { startYear: number }>(
|
||||||
|
path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json')
|
||||||
|
);
|
||||||
|
base.seed = `counter-stratagem-${seed}`;
|
||||||
|
for (const general of [base.attackerGeneral, ...base.defenderGenerals]) {
|
||||||
|
Object.assign(general, {
|
||||||
|
personal: 'None',
|
||||||
|
special: 'None',
|
||||||
|
special2: 'None',
|
||||||
|
crew: 10000,
|
||||||
|
leadership: 100,
|
||||||
|
strength: 70,
|
||||||
|
intel: 100,
|
||||||
|
dex1: 12000,
|
||||||
|
dex4: 12000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const self = side === 'attacker' ? base.attackerGeneral : base.defenderGenerals[0]!;
|
||||||
|
const oppose = side === 'attacker' ? base.defenderGenerals[0]! : base.attackerGeneral;
|
||||||
|
const { name, ...equipment } = variant;
|
||||||
|
Object.assign(self, equipment);
|
||||||
|
oppose.crewtype = name === 'no-magic' ? 1100 : 1400;
|
||||||
|
if (name === 'suppressed') oppose.item = 'che_진압_박혁론';
|
||||||
|
const events: WarBattleTraceEvent[] = [];
|
||||||
|
const logs = createCoreLogCapture();
|
||||||
|
let rng: TracingRng | null = null;
|
||||||
|
let outcome: WarBattleOutcome | null = null;
|
||||||
|
processBattleSimJob(
|
||||||
|
{
|
||||||
|
...base,
|
||||||
|
unitSet,
|
||||||
|
config,
|
||||||
|
time: { year: base.year, month: base.month, startYear: base.startYear },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
trace: (event) => events.push(event),
|
||||||
|
loggerFactory: logs.loggerFactory,
|
||||||
|
onBattleResolved: (value) => {
|
||||||
|
outcome = value;
|
||||||
|
},
|
||||||
|
rngFactory: (value) => {
|
||||||
|
rng = new TracingRng(LiteHashDRBG.build(value));
|
||||||
|
return rng.createRandUtil();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
cases.push({ name, side, base, events, logs, rng, outcome });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const references = runReferenceTraceBatch(
|
||||||
|
workspaceRoot!,
|
||||||
|
cases.map(({ base }) => JSON.stringify(base))
|
||||||
|
);
|
||||||
|
const failures: string[] = [];
|
||||||
|
const activated = new Set<string>();
|
||||||
|
cases.forEach((entry, index) => {
|
||||||
|
const reference = references[index]!;
|
||||||
|
const label = `${entry.name}/${entry.side}/${entry.base.seed}`;
|
||||||
|
try {
|
||||||
|
assertTraceParity(entry.events, reference, entry.rng, entry.outcome);
|
||||||
|
assertAllLogBucketsParity(entry.logs, reference, entry.base, label);
|
||||||
|
const reflected = reference.events.some((event) => event[entry.side]?.activatedSkills['반계']);
|
||||||
|
if (entry.name === 'suppressed' || entry.name === 'no-magic') {
|
||||||
|
expect(reflected, label).toBe(false);
|
||||||
|
} else if (reflected) {
|
||||||
|
activated.add(`${entry.name}/${entry.side}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
failures.push(`${label}: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(failures, failures.join('\n')).toEqual([]);
|
||||||
|
for (const variant of variants.filter(({ name }) => !['suppressed', 'no-magic'].includes(name))) {
|
||||||
|
for (const side of ['attacker', 'defender']) {
|
||||||
|
expect(activated.has(`${variant.name}/${side}`), `${variant.name}/${side} actually reflects`).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('matches wizard strategy attempts, outcomes, and RNG consumption', () => {
|
it('matches wizard strategy attempts, outcomes, and RNG consumption', () => {
|
||||||
const base = readJson<BattleSimRequestPayload & { startYear: number }>(
|
const base = readJson<BattleSimRequestPayload & { startYear: number }>(
|
||||||
path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json')
|
path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json')
|
||||||
|
|||||||
Reference in New Issue
Block a user