feat: port scenario action effects
This commit is contained in:
@@ -37,7 +37,7 @@ const makeGeneral = (): General => ({
|
||||
describe('typed general action events', () => {
|
||||
it('folds synchronous event handlers in the supplied ref ownership order', () => {
|
||||
const trace: string[] = [];
|
||||
const names = ['nation', 'officer', 'domestic', 'war', 'personality', 'crew', 'inherit', 'item'];
|
||||
const names = ['nation', 'officer', 'domestic', 'war', 'personality', 'crew', 'inherit', 'scenario', 'item'];
|
||||
const modules: GeneralActionModule[] = names.map((name) => ({
|
||||
eventHandlers: {
|
||||
'strategy.succeeded': (_context, event) => {
|
||||
@@ -56,8 +56,8 @@ describe('typed general action events', () => {
|
||||
personality: modules[4]!,
|
||||
crewType: modules[5]!,
|
||||
inheritance: modules[6]!,
|
||||
scenario: null,
|
||||
items: [modules[7]!],
|
||||
scenario: modules[7]!,
|
||||
items: [modules[8]!],
|
||||
});
|
||||
const pipeline = new GeneralActionPipeline(stack);
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -76,4 +76,33 @@ describe('scenario parser', () => {
|
||||
picture: '장수/아회남.jpg',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the complete seven-scenario effect inventory typed and executable', async () => {
|
||||
const defaults = parseScenarioDefaults(await readJson(path.join(scenarioRoot, 'default.json')));
|
||||
const expected = new Map([
|
||||
[906, 'event_StrongAttacker'],
|
||||
[911, 'event_UnlimitedDefenceThresholdChange'],
|
||||
[913, 'event_MoreEffect'],
|
||||
[2703, 'event_StrongAttacker'],
|
||||
[2704, 'event_StrongAttacker'],
|
||||
[2903, 'event_StrongAttacker'],
|
||||
[2904, 'event_StrongAttacker'],
|
||||
]);
|
||||
|
||||
for (const [scenarioId, scenarioEffect] of expected) {
|
||||
const raw = await readJson(path.join(scenarioRoot, `scenario_${scenarioId}.json`));
|
||||
expect(parseScenarioDefinition(raw, defaults).config.environment.scenarioEffect).toBe(scenarioEffect);
|
||||
}
|
||||
});
|
||||
|
||||
it('fails before seeding when a scenario references an unknown effect', async () => {
|
||||
const defaults = parseScenarioDefaults(await readJson(path.join(scenarioRoot, 'default.json')));
|
||||
const raw = (await readJson(path.join(scenarioRoot, 'scenario_0.json'))) as Record<string, unknown>;
|
||||
raw.const = {
|
||||
...((raw.const as Record<string, unknown> | undefined) ?? {}),
|
||||
scenarioEffect: 'event_Missing',
|
||||
};
|
||||
|
||||
expect(() => parseScenarioDefinition(raw, defaults)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { City, General, Nation } from '../../src/domain/entities.js';
|
||||
import type { WorldSnapshot } from '../../src/world/types.js';
|
||||
import { commandSpec as developAgricultureSpec } from '../../src/actions/turn/general/che_농지개간.js';
|
||||
import type { TurnCommandEnv } from '../../src/actions/turn/commandEnv.js';
|
||||
import { loadActionModuleBundle } from '../../src/actionModules/bundle.js';
|
||||
|
||||
describe('Domestic Affairs Scenario', () => {
|
||||
it('should increase agriculture when executing "Farming" command', async () => {
|
||||
@@ -97,6 +98,8 @@ describe('Domestic Affairs Scenario', () => {
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
};
|
||||
const moduleBaselineSnapshot = structuredClone(snapshot);
|
||||
const moreEffectSnapshot = structuredClone(snapshot);
|
||||
|
||||
const world = new InMemoryWorld(snapshot);
|
||||
const runner = new TestGameRunner(world, 200, 1);
|
||||
@@ -143,6 +146,39 @@ describe('Domestic Affairs Scenario', () => {
|
||||
const updatedCity = world.getCity(1)!;
|
||||
// 레거시 che_농지개간: 능력치·경험등급·0.8~1.2 난수·성공 배율을 모두 반영한다.
|
||||
expect(updatedCity.agriculture).toBe(664);
|
||||
|
||||
const baselineModuleWorld = new InMemoryWorld(moduleBaselineSnapshot);
|
||||
const baselineModuleRunner = new TestGameRunner(baselineModuleWorld, 200, 1);
|
||||
const baselineBundle = await loadActionModuleBundle();
|
||||
await baselineModuleRunner.runTurn([
|
||||
{
|
||||
generalId: 1,
|
||||
commandKey: 'che_농지개간',
|
||||
resolver: developAgricultureSpec.createDefinition({
|
||||
...systemEnv,
|
||||
generalActionModules: baselineBundle.general,
|
||||
}),
|
||||
args: {},
|
||||
},
|
||||
]);
|
||||
|
||||
const moreEffectWorld = new InMemoryWorld(moreEffectSnapshot);
|
||||
const moreEffectRunner = new TestGameRunner(moreEffectWorld, 200, 1);
|
||||
const moduleBundle = await loadActionModuleBundle(undefined, 'event_MoreEffect');
|
||||
const moreEffectDefinition = developAgricultureSpec.createDefinition({
|
||||
...systemEnv,
|
||||
generalActionModules: moduleBundle.general,
|
||||
});
|
||||
await moreEffectRunner.runTurn([
|
||||
{
|
||||
generalId: 1,
|
||||
commandKey: 'che_농지개간',
|
||||
resolver: moreEffectDefinition,
|
||||
args: {},
|
||||
},
|
||||
]);
|
||||
expect(baselineModuleWorld.getCity(1)?.agriculture).toBe(672);
|
||||
expect(moreEffectWorld.getCity(1)?.agriculture).toBe(844);
|
||||
});
|
||||
|
||||
it('should not increase agriculture when city is already maxed', async () => {
|
||||
|
||||
Reference in New Issue
Block a user