feat: 상업 투자 관련 로직 및 효과 추가, 엔진에서 더러운 상태 추적 기능 개선

This commit is contained in:
2025-12-28 18:21:47 +00:00
parent b927a30e97
commit 7124483001
2 changed files with 159 additions and 0 deletions
@@ -10,6 +10,17 @@ import {
GeneralActionPipeline,
type GeneralActionModule,
} from '../../triggers/general-action.js';
import type {
GeneralActionOutcome,
GeneralActionResolver,
GeneralActionResolveContext,
GeneralActionEffect,
} from '../engine.js';
import {
createCityPatchEffect,
createGeneralPatchEffect,
createLogEffect,
} from '../engine.js';
export type DomesticCriticalPick = 'fail' | 'normal' | 'success';
@@ -51,6 +62,9 @@ export interface CommerceInvestmentResult {
const DEFAULT_TRUST = 50;
const DEFAULT_FRONT_DEBUFF = 0.5;
const DEFAULT_FRONT_STATES = [1, 3];
const ACTION_NAME = '상업 투자';
const CITY_KEY = 'commerce';
const STAT_EXP_KEY = 'intel_exp';
const getMetaNumber = (
meta: Record<string, unknown>,
@@ -85,6 +99,15 @@ const pickByWeight = (
return 'normal';
};
const addMetaNumber = (
meta: Record<string, unknown>,
key: string,
delta: number
): Record<string, unknown> => {
const current = getMetaNumber(meta, key) ?? 0;
return { ...meta, [key]: current + delta };
};
// 상업 투자 결과치를 계산하는 경로를 제공한다.
export class CommandResolver<
TriggerState extends GeneralTriggerState = GeneralTriggerState
@@ -226,3 +249,77 @@ export class CommandResolver<
};
}
}
export class ActionResolver<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> implements GeneralActionResolver<TriggerState> {
readonly key = 'che_상업투자';
private readonly command: CommandResolver<TriggerState>;
constructor(
modules: Array<GeneralActionModule<TriggerState> | null | undefined>,
env: InvestmentEnvironment
) {
this.command = new CommandResolver(modules, env);
}
resolve(
context: GeneralActionResolveContext<TriggerState>
): GeneralActionOutcome<TriggerState> {
const general = context.general;
const city = context.city;
if (!city) {
throw new Error('Commerce investment requires a city context.');
}
const result = this.command.resolve(
{
...context,
city,
nation: context.nation ?? null,
},
context.rng
);
const updatedCommerce = clamp(
city.commerce + result.score,
0,
city.commerceMax
);
const nextGold = Math.max(0, general.gold - result.costGold);
const nextRice = Math.max(0, general.rice - result.costRice);
const nextExperience = general.experience + result.exp;
const nextDedication = general.dedication + result.dedication;
const metaWithStatExp = addMetaNumber(general.meta, STAT_EXP_KEY, 1);
const metaUpdated =
result.pick === 'success'
? { ...metaWithStatExp, max_domestic_critical: result.score }
: { ...metaWithStatExp, max_domestic_critical: 0 };
const effects: Array<GeneralActionEffect<TriggerState>> = [
createCityPatchEffect({
[CITY_KEY]: updatedCommerce,
} as Partial<City>),
createGeneralPatchEffect({
gold: nextGold,
rice: nextRice,
experience: nextExperience,
dedication: nextDedication,
meta: metaUpdated,
}),
];
const pickLabel =
result.pick === 'success'
? '성공'
: result.pick === 'fail'
? '실패'
: '완료';
const logMessage = `${ACTION_NAME} ${pickLabel}: +${Math.round(result.score)}`;
effects.push(createLogEffect(logMessage));
return { effects };
}
}
+62
View File
@@ -4,7 +4,10 @@ import type {
General,
GeneralRole,
GeneralTriggerState,
CityId,
GeneralId,
Nation,
NationId,
StatBlock,
} from '../domain/entities.js';
import type { GeneralActionContext } from '../triggers/general.js';
@@ -81,6 +84,14 @@ export interface GeneralActionResolution {
nextTurnAt: Date;
logs: string[];
effects: GeneralActionEffect[];
dirty?: {
general: boolean;
city: boolean;
nation: boolean;
generalId?: GeneralId;
cityId?: CityId;
nationId?: NationId;
};
}
const mergeStats = (base: StatBlock, patch: Partial<StatBlock>): StatBlock => ({
@@ -139,6 +150,39 @@ const applyNationPatch = (base: Nation, patch: Partial<Nation>): Nation => ({
meta: patch.meta ? { ...base.meta, ...patch.meta } : base.meta,
});
export const createGeneralPatchEffect = <
TriggerState extends GeneralTriggerState = GeneralTriggerState
>(
patch: Partial<General<TriggerState>>
): GeneralPatchEffect<TriggerState> => ({
type: 'general:patch',
patch,
});
export const createCityPatchEffect = (patch: Partial<City>): CityPatchEffect => ({
type: 'city:patch',
patch,
});
export const createNationPatchEffect = (
patch: Partial<Nation>
): NationPatchEffect => ({
type: 'nation:patch',
patch,
});
export const createLogEffect = (message: string): LogEffect => ({
type: 'log',
message,
});
export const createNextTurnOverrideEffect = (
nextTurnAt: Date
): NextTurnOverrideEffect => ({
type: 'schedule:override',
nextTurnAt,
});
// 행동 결과를 Effect로 모아 상태/턴 계산을 수행한다.
export const resolveGeneralAction = <
TriggerState extends GeneralTriggerState = GeneralTriggerState
@@ -153,20 +197,35 @@ export const resolveGeneralAction = <
let nextCity = context.city;
let nextNation = context.nation ?? null;
let nextTurnAtOverride: Date | null = null;
const dirty: NonNullable<GeneralActionResolution['dirty']> = {
general: false,
city: false,
nation: false,
generalId: context.general.id,
};
if (context.city) {
dirty.cityId = context.city.id;
}
if (context.nation) {
dirty.nationId = context.nation.id;
}
for (const effect of outcome.effects) {
switch (effect.type) {
case 'general:patch':
nextGeneral = applyGeneralPatch(nextGeneral, effect.patch);
dirty.general = true;
break;
case 'city:patch':
if (nextCity) {
nextCity = applyCityPatch(nextCity, effect.patch);
dirty.city = true;
}
break;
case 'nation:patch':
if (nextNation) {
nextNation = applyNationPatch(nextNation, effect.patch);
dirty.nation = true;
}
break;
case 'log':
@@ -194,6 +253,9 @@ export const resolveGeneralAction = <
if (nextCity) {
resolution.city = nextCity;
}
if (dirty.general || dirty.city || dirty.nation) {
resolution.dirty = dirty;
}
return resolution;
};