feat: port scenario action effects

This commit is contained in:
2026-07-30 19:00:04 +00:00
parent 7f31459385
commit c68d992046
39 changed files with 1131 additions and 95 deletions
+7 -5
View File
@@ -27,6 +27,8 @@ import {
WAR_TRAIT_KEYS,
} from './traits/index.js';
import type { NationTraitModule } from './traits/nation/index.js';
import { createScenarioEffectActionModules } from './scenarioEffect.js';
import type { ScenarioEffectKey } from '@sammo-ts/logic/scenario/scenarioEffect.js';
export interface ActionModuleBundle<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
general: RefOrderedActionStack<GeneralActionModule<TriggerState>>;
@@ -82,7 +84,8 @@ export const createRefOrderedActionStack = <Module>(slots: RefActionSlots<Module
// General::getActionList와 같은 소유권 순서로 실제 턴과 시뮬레이터의 모듈을 조립한다.
export const loadActionModuleBundle = async <TriggerState extends GeneralTriggerState = GeneralTriggerState>(
unitSet?: UnitSetDefinition
unitSet?: UnitSetDefinition,
scenarioEffect?: ScenarioEffectKey | null
): Promise<ActionModuleBundle<TriggerState>> => {
const [domestic, war, personality, nation, itemModules] = await Promise.all([
loadDomesticTraitModules([...DOMESTIC_TRAIT_KEYS]),
@@ -95,6 +98,7 @@ export const loadActionModuleBundle = async <TriggerState extends GeneralTrigger
const officer = createOfficerLevelActionModules<TriggerState>();
const items = createItemActionModules(createItemModuleRegistry(itemModules));
const inherit = createInheritBuffModules();
const scenario = createScenarioEffectActionModules<TriggerState>(scenarioEffect);
const crewTypeCatalog = unitSet?.crewTypes?.length
? compileCrewTypeCatalog(unitSet, createCrewTypeWarTriggerRegistry())
: null;
@@ -110,8 +114,7 @@ export const loadActionModuleBundle = async <TriggerState extends GeneralTrigger
? (crewTypeCatalog.generalActionModule as GeneralActionModule<TriggerState>)
: null,
inheritance: inherit.general as GeneralActionModule<TriggerState>,
// scenarioEffect는 현재 core runtime module이 없어 명시적으로 빈 slot입니다.
scenario: null,
scenario: scenario.general,
items: items.general,
}),
war: createRefOrderedActionStack<WarActionModule<TriggerState>>({
@@ -122,8 +125,7 @@ export const loadActionModuleBundle = async <TriggerState extends GeneralTrigger
personality: new TraitWarActionRouter('personality', traitCatalog),
crewType: crewTypeCatalog ? (crewTypeCatalog.warActionModule as WarActionModule<TriggerState>) : null,
inheritance: inherit.war as WarActionModule<TriggerState>,
// ref의 scenarioEffect 위치를 보존하되 미이식 module은 별도 gap으로 남깁니다.
scenario: null,
scenario: scenario.war,
items: items.war,
}),
itemModules,
@@ -3,4 +3,5 @@ export * from './general.js';
export * from './types.js';
export * from './officerLevel.js';
export * from './bundle.js';
export * from './scenarioEffect.js';
export * from './traits/index.js';
@@ -0,0 +1,84 @@
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { GeneralActionModule } from '@sammo-ts/logic/actionModules/general.js';
import type { WarActionModule } from '@sammo-ts/logic/war/actions.js';
import { WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
import { che_전멸시페이즈증가 } from '@sammo-ts/logic/war/triggers/che_전멸시페이즈증가.js';
import { WarUnitCity } from '@sammo-ts/logic/war/units.js';
import { normalizeScenarioEffect } from '@sammo-ts/logic/scenario/scenarioEffect.js';
export type { ScenarioEffectKey } from '@sammo-ts/logic/scenario/scenarioEffect.js';
export interface ScenarioEffectActionModules<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
general: GeneralActionModule<TriggerState> | null;
war: WarActionModule<TriggerState> | null;
}
const MORE_EFFECT_DOMESTIC_ACTIONS = new Set(['상업', '농업', '치안', '기술', '성벽', '수비', '인구', '민심']);
const createDefenceThresholdGeneralModule = <
TriggerState extends GeneralTriggerState,
>(): GeneralActionModule<TriggerState> => ({
onCalcDomestic: (_context, turnType, _varType, value) => (turnType === 'changeDefenceTrain' ? 0 : value),
});
const createAdvanceTriggerWarModule = <TriggerState extends GeneralTriggerState>(
includeCityWarPower: boolean
): WarActionModule<TriggerState> => ({
getWarPowerMultiplier: (_context, unit, oppose) => {
if (!includeCityWarPower && (unit instanceof WarUnitCity || oppose instanceof WarUnitCity)) {
return [1, 1];
}
return unit.isAttacker() ? [1.4, 0.7143] : [1, 1];
},
getBattlePhaseTriggerList: (context) => {
const unit = context.unit;
return unit ? new WarTriggerCaller(new che_전멸시페이즈증가(unit)) : null;
},
});
const createMoreEffectGeneralModule = <
TriggerState extends GeneralTriggerState,
>(): GeneralActionModule<TriggerState> => ({
onCalcDomestic: (_context, turnType, varType, value) => {
if (turnType === 'changeDefenceTrain') {
return 0;
}
return varType === 'score' && MORE_EFFECT_DOMESTIC_ACTIONS.has(turnType) ? value * 2 : value;
},
// ref에도 정의되어 있지만 실제 월간 수입 경로는 General이 아니라
// nation type module만 호출합니다. protocol 보존용이며 월간 경로에는
// 이 general hook을 연결하지 않습니다.
onCalcNationalIncome: (_context, type, amount) => {
if (type === 'gold' || type === 'rice' || (type === 'pop' && amount > 0)) {
return amount * 2;
}
return amount;
},
});
export const createScenarioEffectActionModules = <TriggerState extends GeneralTriggerState = GeneralTriggerState>(
scenarioEffect?: string | null
): ScenarioEffectActionModules<TriggerState> => {
const normalizedEffect = normalizeScenarioEffect(scenarioEffect);
if (!normalizedEffect) {
return { general: null, war: null };
}
switch (normalizedEffect) {
case 'event_UnlimitedDefenceThresholdChange':
return {
general: createDefenceThresholdGeneralModule<TriggerState>(),
war: null,
};
case 'event_StrongAttacker':
return {
general: createDefenceThresholdGeneralModule<TriggerState>(),
war: createAdvanceTriggerWarModule<TriggerState>(false),
};
case 'event_MoreEffect':
return {
general: createMoreEffectGeneralModule<TriggerState>(),
war: createAdvanceTriggerWarModule<TriggerState>(true),
};
}
};
+2 -1
View File
@@ -13,7 +13,8 @@ export type TriggerDomesticActionType =
| '기술'
| '모병'
| '단련'
| '조달';
| '조달'
| 'changeDefenceTrain';
export type TriggerDomesticVarType = 'cost' | 'score' | 'success' | 'fail' | 'train' | 'atmos' | 'rice' | 'probability';
@@ -3,6 +3,7 @@ import type { WarActionModule } from '@sammo-ts/logic/war/actions.js';
import type { UnitSetDefinition } from '@sammo-ts/logic/world/types.js';
import type { NationTraitModule } from '@sammo-ts/logic/actionModules/traits/nation/index.js';
import type { RefOrderedActionStack } from '@sammo-ts/logic/actionModules/bundle.js';
import type { ScenarioEffectKey } from '@sammo-ts/logic/scenario/scenarioEffect.js';
export interface TurnCommandItemCatalogEntry {
slot: 'horse' | 'weapon' | 'book' | 'item';
@@ -17,6 +18,7 @@ export interface TurnCommandItemCatalogEntry {
export interface TurnCommandEnv {
unitSet?: UnitSetDefinition;
scenarioEffect?: ScenarioEffectKey | null;
develCost: number;
minAvailableRecruitPop?: number;
trainDelta: number;
@@ -1,4 +1,5 @@
import { z } from 'zod';
import { SCENARIO_EFFECT_KEYS } from '../scenario/scenarioEffect.js';
export const ScenarioStatBlockSchema = z
.object({
@@ -19,6 +20,12 @@ export const ScenarioDefaultsInputSchema = z.object({
export const ScenarioExtendsInputSchema = z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]);
const ScenarioConstInputSchema = z
.object({
scenarioEffect: z.union([z.enum(SCENARIO_EFFECT_KEYS), z.literal(''), z.literal('None'), z.null()]).optional(),
})
.catchall(z.unknown());
const ScenarioBodyInputSchema = z
.object({
extends: ScenarioExtendsInputSchema.optional(),
@@ -29,7 +36,7 @@ const ScenarioBodyInputSchema = z
iconPath: z.string().optional(),
stat: ScenarioStatBlockSchema.optional(),
map: z.record(z.string(), z.unknown()).optional(),
const: z.record(z.string(), z.unknown()).optional(),
const: ScenarioConstInputSchema.optional(),
nation: z.array(z.unknown()).optional(),
diplomacy: z.array(z.unknown()).optional(),
general: z.array(z.unknown()).optional(),
+1
View File
@@ -1,2 +1,3 @@
export * from './types.js';
export * from './parseScenario.js';
export * from './scenarioEffect.js';
+3 -5
View File
@@ -13,6 +13,7 @@ import type {
ScenarioNation,
ScenarioStatBlock,
} from './types.js';
import { normalizeScenarioEffect } from './scenarioEffect.js';
type UnknownRecord = Record<string, unknown>;
@@ -45,12 +46,9 @@ const parseScenarioEnvironment = (mapConfig: UnknownRecord, constConfig: Unknown
const merged = { ...mapConfig, ...constConfig };
const mapName = asString(merged.mapName, 'che');
const unitSet = asString(merged.unitSet, 'che');
const scenarioEffect =
typeof merged.scenarioEffect === 'string' || merged.scenarioEffect === null ? merged.scenarioEffect : undefined;
const result: ScenarioEnvironment = { mapName, unitSet };
if (scenarioEffect !== undefined) {
result.scenarioEffect = scenarioEffect;
if (Object.hasOwn(merged, 'scenarioEffect')) {
result.scenarioEffect = normalizeScenarioEffect(merged.scenarioEffect);
}
return result;
};
@@ -0,0 +1,31 @@
export const SCENARIO_EFFECT_KEYS = [
'event_UnlimitedDefenceThresholdChange',
'event_StrongAttacker',
'event_MoreEffect',
] as const;
export type ScenarioEffectKey = (typeof SCENARIO_EFFECT_KEYS)[number];
const DEFENCE_TRAIN_PENALTY_WAIVER_EFFECTS = new Set<ScenarioEffectKey>([
'event_UnlimitedDefenceThresholdChange',
'event_StrongAttacker',
'event_MoreEffect',
]);
export const isScenarioEffectKey = (value: string): value is ScenarioEffectKey =>
SCENARIO_EFFECT_KEYS.includes(value as ScenarioEffectKey);
export const normalizeScenarioEffect = (value: unknown): ScenarioEffectKey | null => {
if (value === undefined || value === null || value === '' || value === 'None') {
return null;
}
if (typeof value === 'string' && isScenarioEffectKey(value)) {
return value;
}
throw new Error(`Unknown scenario effect: ${String(value)}`);
};
export const isDefenceTrainPenaltyWaivedByScenarioEffect = (value: string | null | undefined): boolean => {
const effect = normalizeScenarioEffect(value);
return effect !== null && DEFENCE_TRAIN_PENALTY_WAIVER_EFFECTS.has(effect);
};
+2 -1
View File
@@ -16,7 +16,7 @@ export interface ScenarioDefaults {
export interface ScenarioEnvironment {
mapName: string;
unitSet: string;
scenarioEffect?: string | null;
scenarioEffect?: ScenarioEffectKey | null;
}
export interface ScenarioConfig {
@@ -86,3 +86,4 @@ export interface ScenarioDefinition {
initialEvents: unknown[];
ignoreDefaultEvents: boolean;
}
import type { ScenarioEffectKey } from './scenarioEffect.js';
@@ -0,0 +1,32 @@
import { LogFormat } from '@sammo-ts/logic/logging/types.js';
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';
/**
* ref che_전멸시페이즈증가.
*
* 이전 수비자를 격파한 공격자가 phase를 소비한 상태로 새 수비자
* (phase 0)와 맞붙을 때 다음 phase 하나를 보너스로 얻습니다.
*/
export class che_전멸시페이즈증가 extends BaseWarUnitTrigger {
constructor(unit: WarUnit) {
super(unit, TriggerPriority.Post + 800);
}
protected actionWar(
self: WarUnit,
oppose: WarUnit,
_selfEnv: Record<string, unknown>,
_opposeEnv: Record<string, unknown>
): boolean {
if (self.getPhase() === 0 || oppose.getPhase() !== 0) {
return true;
}
self.addBonusPhase(1);
self.getLogger().pushGeneralBattleDetailLog('적군의 전멸에 <C>진격</>이 이어집니다!', LogFormat.PLAIN);
oppose.getLogger().pushGeneralBattleDetailLog('아군의 전멸에 상대의 <R>진격</>이 이어집니다!', LogFormat.PLAIN);
return true;
}
}
@@ -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);
+122
View File
@@ -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 () => {