123 lines
5.8 KiB
TypeScript
123 lines
5.8 KiB
TypeScript
import { ConstantRNG, RandUtil } from '@sammo-ts/common';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
import { createScenarioEffectActionModules } from '../src/actionModules/scenarioEffect.js';
|
|
import type { General } from '../src/domain/entities.js';
|
|
import { ActionLogger } from '../src/logging/actionLogger.js';
|
|
import { createWarTriggerEnv } from '../src/war/triggers.js';
|
|
import type { WarUnit } from '../src/war/units.js';
|
|
import { WarUnitCity } from '../src/war/units.js';
|
|
|
|
const generalContext = { general: {} as General };
|
|
|
|
const buildGeneralUnit = (isAttacker = true): WarUnit =>
|
|
({
|
|
isAttacker: () => isAttacker,
|
|
}) as unknown as WarUnit;
|
|
|
|
const buildCityUnit = (isAttacker = false): WarUnit => {
|
|
const unit = Object.create(WarUnitCity.prototype) as WarUnit & {
|
|
isAttacker: () => boolean;
|
|
};
|
|
unit.isAttacker = () => isAttacker;
|
|
return unit;
|
|
};
|
|
|
|
describe('scenario effect action modules', () => {
|
|
it.each(['event_UnlimitedDefenceThresholdChange', 'event_StrongAttacker', 'event_MoreEffect'])(
|
|
'%s removes only the defence-setting train/atmos penalty',
|
|
(key) => {
|
|
const module = createScenarioEffectActionModules(key).general;
|
|
expect(module?.onCalcDomestic?.(generalContext, 'changeDefenceTrain', 'train', -3)).toBe(0);
|
|
expect(module?.onCalcDomestic?.(generalContext, 'changeDefenceTrain', 'atmos', -6)).toBe(0);
|
|
}
|
|
);
|
|
|
|
it('doubles only the eight MoreEffect domestic score actions', () => {
|
|
const module = createScenarioEffectActionModules('event_MoreEffect').general!;
|
|
for (const action of ['상업', '농업', '치안', '기술', '성벽', '수비', '인구', '민심'] as const) {
|
|
expect(module.onCalcDomestic?.(generalContext, action, 'score', 12.5)).toBe(25);
|
|
expect(module.onCalcDomestic?.(generalContext, action, 'cost', 12.5)).toBe(12.5);
|
|
}
|
|
expect(module.onCalcDomestic?.(generalContext, '징병', 'score', 12.5)).toBe(12.5);
|
|
});
|
|
|
|
it('retains the dormant MoreEffect income hook contract without wiring it to monthly income', () => {
|
|
const module = createScenarioEffectActionModules('event_MoreEffect').general!;
|
|
expect(module.onCalcNationalIncome?.(generalContext, 'gold', 10)).toBe(20);
|
|
expect(module.onCalcNationalIncome?.(generalContext, 'rice', 10)).toBe(20);
|
|
expect(module.onCalcNationalIncome?.(generalContext, 'pop', 10)).toBe(20);
|
|
expect(module.onCalcNationalIncome?.(generalContext, 'pop', -10)).toBe(-10);
|
|
});
|
|
|
|
it('preserves StrongAttacker city exclusions and the exact 0.7143 literal', () => {
|
|
const strong = createScenarioEffectActionModules('event_StrongAttacker').war!;
|
|
const more = createScenarioEffectActionModules('event_MoreEffect').war!;
|
|
const attacker = buildGeneralUnit(true);
|
|
const defender = buildGeneralUnit(false);
|
|
const city = buildCityUnit(false);
|
|
|
|
expect(strong.getWarPowerMultiplier?.(generalContext, attacker, defender)).toEqual([1.4, 0.7143]);
|
|
expect(strong.getWarPowerMultiplier?.(generalContext, defender, attacker)).toEqual([1, 1]);
|
|
expect(strong.getWarPowerMultiplier?.(generalContext, attacker, city)).toEqual([1, 1]);
|
|
expect(strong.getWarPowerMultiplier?.(generalContext, city, attacker)).toEqual([1, 1]);
|
|
expect(more.getWarPowerMultiplier?.(generalContext, attacker, city)).toEqual([1.4, 0.7143]);
|
|
});
|
|
|
|
it('adds one phase and the exact two logs only for a progressed unit facing a fresh opponent', () => {
|
|
const selfLogger = new ActionLogger({ generalId: 1 });
|
|
const opposeLogger = new ActionLogger({ generalId: 2 });
|
|
let bonusPhase = 0;
|
|
const self = {
|
|
getUnitId: () => 'general:1',
|
|
isAttacker: () => true,
|
|
getPhase: () => 1,
|
|
addBonusPhase: (count: number) => {
|
|
bonusPhase += count;
|
|
},
|
|
getLogger: () => selfLogger,
|
|
} as unknown as WarUnit;
|
|
const oppose = {
|
|
getUnitId: () => 'general:2',
|
|
isAttacker: () => false,
|
|
getPhase: () => 0,
|
|
addBonusPhase: () => undefined,
|
|
getLogger: () => opposeLogger,
|
|
} as unknown as WarUnit;
|
|
|
|
const module = createScenarioEffectActionModules('event_StrongAttacker').war!;
|
|
const caller = module.getBattlePhaseTriggerList?.({ general: {} as General, unit: self });
|
|
let rngCalls = 0;
|
|
const source = new Proxy(new ConstantRNG(0), {
|
|
get(target, property, receiver) {
|
|
const value = Reflect.get(target, property, receiver);
|
|
if (typeof value !== 'function' || !String(property).startsWith('next')) {
|
|
return value;
|
|
}
|
|
return (...args: unknown[]) => {
|
|
rngCalls += 1;
|
|
return Reflect.apply(value, target, args);
|
|
};
|
|
},
|
|
});
|
|
caller?.fire({ rng: new RandUtil(source), attacker: self, defender: oppose }, createWarTriggerEnv());
|
|
|
|
expect(bonusPhase).toBe(1);
|
|
expect(rngCalls).toBe(0);
|
|
expect(selfLogger.flush()).toContainEqual(
|
|
expect.objectContaining({ text: '적군의 전멸에 <C>진격</>이 이어집니다!' })
|
|
);
|
|
expect(opposeLogger.flush()).toContainEqual(
|
|
expect.objectContaining({ text: '아군의 전멸에 상대의 <R>진격</>이 이어집니다!' })
|
|
);
|
|
});
|
|
|
|
it('returns no module for None/null and fails fast for an unknown effect', () => {
|
|
expect(createScenarioEffectActionModules(null)).toEqual({ general: null, war: null });
|
|
expect(createScenarioEffectActionModules('None')).toEqual({ general: null, war: null });
|
|
expect(() => createScenarioEffectActionModules('event_Missing')).toThrow(
|
|
'Unknown scenario effect: event_Missing'
|
|
);
|
|
});
|
|
});
|