Refactor action effects to use direct state mutations and logging

- Removed the use of createGeneralPatchEffect and createLogEffect in various action definitions.
- Replaced effects with direct mutations of the state for general and city entities.
- Updated logging to use context.addLog for better clarity and consistency.
- Ensured that all actions now return an empty effects array after processing.
This commit is contained in:
2026-01-02 10:25:33 +00:00
parent 18247eeff4
commit f137c5be65
17 changed files with 281 additions and 562 deletions
@@ -369,7 +369,7 @@ export const createReservedTurnHandler = async (options: {
const resolution = resolveGeneralAction( const resolution = resolveGeneralAction(
definition, definition,
actionContext as GeneralActionResolveContext, actionContext,
{ {
now: currentGeneral.turnTime, now: currentGeneral.turnTime,
schedule: context.schedule, schedule: context.schedule,
+56 -144
View File
@@ -1,5 +1,5 @@
import type { RandomGenerator } from '@sammo-ts/common'; import type { RandomGenerator } from '@sammo-ts/common';
import { enablePatches, produceWithPatches, type Draft, castDraft } from 'immer'; import { enablePatches, produceWithPatches, castDraft } from 'immer';
import type { import type {
City, City,
General, General,
@@ -34,8 +34,16 @@ export interface GeneralActionResolveContext<
rng: RandomGenerator; rng: RandomGenerator;
city?: City; city?: City;
nation?: Nation | null; nation?: Nation | null;
addLog(
message: string,
options?: Partial<Omit<LogEntryDraft, 'text'>>
): void;
} }
export type GeneralActionResolveInputContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> = Omit<GeneralActionResolveContext<TriggerState>, 'addLog'>;
export interface TurnScheduleContext { export interface TurnScheduleContext {
now: Date; now: Date;
schedule: TurnSchedule; schedule: TurnSchedule;
@@ -130,106 +138,6 @@ export interface GeneralActionResolution {
}; };
} }
/**
* Immer Draft에 Effect를 적용한다.
* 기존 Effect 기반 코드를 유지하면서 Draft에 즉시 반영하기 위함.
*/
export const applyEffectToDraft = <
TriggerState extends GeneralTriggerState = GeneralTriggerState
>(
draft: Draft<WorldState<TriggerState>>,
effect: GeneralActionEffect<TriggerState>,
context: { generalId: GeneralId; cityId?: CityId; nationId?: NationId }
): void => {
const generalDraft = draft.general as any;
switch (effect.type) {
case 'general:patch':
if (
effect.targetId === undefined ||
effect.targetId === context.generalId
) {
Object.assign(generalDraft, effect.patch);
if (effect.patch.stats) {
generalDraft.stats = {
...generalDraft.stats,
...effect.patch.stats,
};
}
if (effect.patch.role) {
generalDraft.role = {
...generalDraft.role,
...effect.patch.role,
items: {
...generalDraft.role.items,
...(effect.patch.role.items ?? {}),
},
};
}
if (effect.patch.triggerState) {
generalDraft.triggerState = {
...generalDraft.triggerState,
...effect.patch.triggerState,
flags: {
...generalDraft.triggerState.flags,
...(effect.patch.triggerState.flags ?? {}),
},
counters: {
...generalDraft.triggerState.counters,
...(effect.patch.triggerState.counters ?? {}),
},
modifiers: {
...generalDraft.triggerState.modifiers,
...(effect.patch.triggerState.modifiers ?? {}),
},
meta: {
...generalDraft.triggerState.meta,
...(effect.patch.triggerState.meta ?? {}),
},
};
}
if (effect.patch.meta) {
generalDraft.meta = {
...generalDraft.meta,
...effect.patch.meta,
};
}
}
break;
case 'city:patch':
if (
draft.city &&
(effect.targetId === undefined ||
effect.targetId === context.cityId)
) {
Object.assign(draft.city, effect.patch);
if (effect.patch.meta) {
draft.city.meta = {
...draft.city.meta,
...effect.patch.meta,
};
}
}
break;
case 'nation:patch':
if (
draft.nation &&
(effect.targetId === undefined ||
effect.targetId === context.nationId)
) {
Object.assign(draft.nation, effect.patch);
if (effect.patch.meta) {
draft.nation.meta = {
...draft.nation.meta,
...effect.patch.meta,
};
}
}
break;
default:
break;
}
};
export const createGeneralPatchEffect = < export const createGeneralPatchEffect = <
TriggerState extends GeneralTriggerState = GeneralTriggerState TriggerState extends GeneralTriggerState = GeneralTriggerState
>( >(
@@ -303,7 +211,7 @@ export const resolveGeneralAction = <
Args = unknown Args = unknown
>( >(
resolver: GeneralActionResolver<TriggerState, Args>, resolver: GeneralActionResolver<TriggerState, Args>,
context: GeneralActionResolveContext<TriggerState>, context: GeneralActionResolveInputContext<TriggerState>,
scheduleContext: TurnScheduleContext, scheduleContext: TurnScheduleContext,
args: Args args: Args
): GeneralActionResolution => { ): GeneralActionResolution => {
@@ -323,12 +231,56 @@ export const resolveGeneralAction = <
nation: context.nation, nation: context.nation,
} as WorldState<TriggerState>, } as WorldState<TriggerState>,
(draft) => { (draft) => {
const addLog = (
message: string,
options: Partial<Omit<LogEntryDraft, 'text'>> = {}
) => {
const entry: LogEntryDraft = {
scope: options.scope ?? LogScope.GENERAL,
category: options.category ?? LogCategory.ACTION,
text: message,
format: options.format ?? LogFormat.MONTH,
...options,
};
switch (entry.scope) {
case LogScope.GENERAL:
logs.push({
...entry,
generalId: entry.generalId ?? context.general.id,
});
break;
case LogScope.NATION:
if (entry.nationId !== undefined) {
logs.push(entry);
break;
}
if (context.nation?.id !== undefined) {
logs.push({
...entry,
nationId: context.nation.id,
});
}
break;
case LogScope.USER:
if (entry.userId) {
logs.push(entry);
}
break;
case LogScope.SYSTEM:
default:
logs.push(entry);
break;
}
};
const outcome = resolver.resolve( const outcome = resolver.resolve(
{ {
...context, ...context,
general: castDraft(draft.general), general: castDraft(draft.general),
city: castDraft(draft.city), city: castDraft(draft.city),
nation: castDraft(draft.nation), nation: castDraft(draft.nation),
addLog,
} as GeneralActionResolveContext<TriggerState>, } as GeneralActionResolveContext<TriggerState>,
args args
); );
@@ -336,38 +288,7 @@ export const resolveGeneralAction = <
for (const effect of outcome.effects) { for (const effect of outcome.effects) {
switch (effect.type) { switch (effect.type) {
case 'log': case 'log':
// 로그 대상이 비어 있으면 현재 장수/국가 기준으로 보정한다. addLog(effect.entry.text, effect.entry);
switch (effect.entry.scope) {
case LogScope.GENERAL:
logs.push({
...effect.entry,
generalId:
effect.entry.generalId ??
context.general.id,
});
break;
case LogScope.NATION:
if (effect.entry.nationId !== undefined) {
logs.push(effect.entry);
break;
}
if (context.nation?.id !== undefined) {
logs.push({
...effect.entry,
nationId: context.nation.id,
});
}
break;
case LogScope.USER:
if (effect.entry.userId) {
logs.push(effect.entry);
}
break;
case LogScope.SYSTEM:
default:
logs.push(effect.entry);
break;
}
break; break;
case 'schedule:override': case 'schedule:override':
nextTurnAtOverride = effect.nextTurnAt; nextTurnAtOverride = effect.nextTurnAt;
@@ -378,16 +299,7 @@ export const resolveGeneralAction = <
case 'general:patch': case 'general:patch':
case 'city:patch': case 'city:patch':
case 'nation:patch': case 'nation:patch':
applyEffectToDraft(draft, effect, { // 타겟이 다른 경우 patches에 추가
generalId: context.general.id,
...(context.city?.id !== undefined
? { cityId: context.city.id }
: {}),
...(context.nation?.id !== undefined
? { nationId: context.nation.id }
: {}),
});
// 타겟이 다른 경우 patches에 추가 (applyEffectToDraft에서 처리되지 않은 경우)
if ( if (
effect.type === 'general:patch' && effect.type === 'general:patch' &&
effect.targetId !== undefined && effect.targetId !== undefined &&
@@ -6,8 +6,7 @@ import type {
GeneralActionOutcome, GeneralActionOutcome,
GeneralActionResolveContext, GeneralActionResolveContext,
} from '../../engine.js'; } from '../../engine.js';
import { createGeneralPatchEffect, createLogEffect } from '../../engine.js'; import { LogCategory, LogFormat } from '../../../logging/types.js';
import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js';
import type { TurnCommandEnv } from '../commandEnv.js'; import type { TurnCommandEnv } from '../commandEnv.js';
import type { GeneralTurnCommandSpec } from './index.js'; import type { GeneralTurnCommandSpec } from './index.js';
@@ -35,24 +34,19 @@ export class ActionDefinition<
_args: UprisingArgs _args: UprisingArgs
): GeneralActionOutcome<TriggerState> { ): GeneralActionOutcome<TriggerState> {
const general = context.general; const general = context.general;
const meta = {
...general.meta, // 직접 수정 (Immer Draft)
general.meta = {
...general.meta as object,
uprising: true as TriggerValue, uprising: true as TriggerValue,
}; };
return { context.addLog(`${ACTION_NAME}을 준비했습니다.`, {
effects: [ category: LogCategory.ACTION,
createGeneralPatchEffect<TriggerState>( format: LogFormat.MONTH,
{ meta } as Partial<typeof general>, });
general.id
), return { effects: [] };
createLogEffect(`${ACTION_NAME}을 준비했습니다.`, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
}),
],
};
} }
} }
@@ -6,8 +6,7 @@ import type {
GeneralActionOutcome, GeneralActionOutcome,
GeneralActionResolveContext, GeneralActionResolveContext,
} from '../../engine.js'; } from '../../engine.js';
import { createGeneralPatchEffect, createLogEffect } from '../../engine.js'; import { LogCategory, LogFormat } from '../../../logging/types.js';
import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js';
import type { TurnCommandEnv } from '../commandEnv.js'; import type { TurnCommandEnv } from '../commandEnv.js';
import type { GeneralTurnCommandSpec } from './index.js'; import type { GeneralTurnCommandSpec } from './index.js';
@@ -35,24 +34,19 @@ export class ActionDefinition<
_args: FoundingArgs _args: FoundingArgs
): GeneralActionOutcome<TriggerState> { ): GeneralActionOutcome<TriggerState> {
const general = context.general; const general = context.general;
const meta = {
...general.meta, // 직접 수정 (Immer Draft)
general.meta = {
...general.meta as object,
founding: true as TriggerValue, founding: true as TriggerValue,
}; };
return { context.addLog(`${ACTION_NAME}을 준비했습니다.`, {
effects: [ category: LogCategory.ACTION,
createGeneralPatchEffect<TriggerState>( format: LogFormat.MONTH,
{ meta } as Partial<typeof general>, });
general.id
), return { effects: [] };
createLogEffect(`${ACTION_NAME}을 준비했습니다.`, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
}),
],
};
} }
} }
@@ -1,5 +1,4 @@
import type { import type {
General,
GeneralTriggerState, GeneralTriggerState,
Nation, Nation,
} from '../../../domain/entities.js'; } from '../../../domain/entities.js';
@@ -20,12 +19,6 @@ import type {
GeneralActionOutcome, GeneralActionOutcome,
GeneralActionResolveContext, GeneralActionResolveContext,
} from '../../engine.js'; } from '../../engine.js';
import {
createGeneralPatchEffect,
createLogEffect,
createNationPatchEffect,
} from '../../engine.js';
import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js';
import type { TurnCommandEnv } from '../commandEnv.js'; import type { TurnCommandEnv } from '../commandEnv.js';
import type { GeneralTurnCommandSpec } from './index.js'; import type { GeneralTurnCommandSpec } from './index.js';
@@ -84,17 +77,10 @@ export class ActionDefinition<
_args: TechResearchArgs _args: TechResearchArgs
): GeneralActionOutcome<TriggerState> { ): GeneralActionOutcome<TriggerState> {
const general = context.general; const general = context.general;
const nation = context.nation ?? null; const nation = context.nation;
if (!nation) { if (!nation) {
return { context.addLog('국가 정보를 찾지 못했습니다.');
effects: [ return { effects: [] };
createLogEffect('국가 정보를 찾지 못했습니다.', {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
}),
],
};
} }
const delta = this.env.techDelta ?? DEFAULT_TECH_DELTA; const delta = this.env.techDelta ?? DEFAULT_TECH_DELTA;
@@ -107,26 +93,14 @@ export class ActionDefinition<
const nextTech = Math.min(currentTech + delta, maxTech); const nextTech = Math.min(currentTech + delta, maxTech);
const applied = nextTech - currentTech; const applied = nextTech - currentTech;
const costGold = this.env.costGold ?? 0; const costGold = this.env.costGold ?? 0;
const generalPatch: Partial<General<TriggerState>> = {
gold: Math.max(0, general.gold - costGold),
};
return { // 직접 수정 (Immer Draft)
effects: [ nation.meta = { ...nation.meta, tech: nextTech };
createNationPatchEffect( general.gold = Math.max(0, general.gold - costGold);
{
meta: { ...nation.meta, tech: nextTech }, context.addLog(`${ACTION_NAME}로 기술이 ${applied} 상승했습니다.`);
} as Partial<Nation>,
nation.id return { effects: [] };
),
createGeneralPatchEffect<TriggerState>(generalPatch, general.id),
createLogEffect(`${ACTION_NAME}로 기술이 ${applied} 상승했습니다.`, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
}),
],
};
} }
} }
@@ -1,5 +1,4 @@
import type { import type {
General,
GeneralTriggerState, GeneralTriggerState,
} from '../../../domain/entities.js'; } from '../../../domain/entities.js';
import type { import type {
@@ -13,8 +12,6 @@ import type {
GeneralActionOutcome, GeneralActionOutcome,
GeneralActionResolveContext, GeneralActionResolveContext,
} from '../../engine.js'; } from '../../engine.js';
import { createGeneralPatchEffect, createLogEffect } from '../../engine.js';
import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js';
import type { TurnCommandEnv } from '../commandEnv.js'; import type { TurnCommandEnv } from '../commandEnv.js';
import type { GeneralTurnCommandSpec } from './index.js'; import type { GeneralTurnCommandSpec } from './index.js';
@@ -74,21 +71,14 @@ export class ActionDefinition<
const nextAtmos = clamp(general.atmos + delta, 0, maxAtmos); const nextAtmos = clamp(general.atmos + delta, 0, maxAtmos);
const applied = nextAtmos - general.atmos; const applied = nextAtmos - general.atmos;
const costGold = this.env.costGold ?? 0; const costGold = this.env.costGold ?? 0;
const patch: Partial<General<TriggerState>> = {
atmos: nextAtmos,
gold: Math.max(0, general.gold - costGold),
};
return { // 직접 수정 (Immer Draft)
effects: [ general.atmos = nextAtmos;
createGeneralPatchEffect<TriggerState>(patch, general.id), general.gold = Math.max(0, general.gold - costGold);
createLogEffect(`${ACTION_NAME}로 사기가 ${applied} 증가했습니다.`, {
scope: LogScope.GENERAL, context.addLog(`${ACTION_NAME}로 사기가 ${applied} 증가했습니다.`);
category: LogCategory.ACTION,
format: LogFormat.MONTH, return { effects: [] };
}),
],
};
} }
} }
@@ -30,12 +30,6 @@ import type {
GeneralActionOutcome, GeneralActionOutcome,
GeneralActionResolver, GeneralActionResolver,
GeneralActionResolveContext, GeneralActionResolveContext,
GeneralActionEffect,
} from '../../engine.js';
import {
createCityPatchEffect,
createGeneralPatchEffect,
createLogEffect,
} from '../../engine.js'; } from '../../engine.js';
import type { TurnCommandEnv } from '../commandEnv.js'; import type { TurnCommandEnv } from '../commandEnv.js';
import type { GeneralTurnCommandSpec } from './index.js'; import type { GeneralTurnCommandSpec } from './index.js';
@@ -335,36 +329,24 @@ export class ActionResolver<
context.rng context.rng
); );
const updatedCommerce = clamp( // 직접 수정 (Immer Draft)
city.commerce = clamp(
city.commerce + result.score, city.commerce + result.score,
0, 0,
city.commerceMax city.commerceMax
); );
const nextGold = Math.max(0, general.gold - result.costGold); general.gold = Math.max(0, general.gold - result.costGold);
const nextRice = Math.max(0, general.rice - result.costRice); general.rice = Math.max(0, general.rice - result.costRice);
const nextExperience = general.experience + result.exp; general.experience += result.exp;
const nextDedication = general.dedication + result.dedication; general.dedication += result.dedication;
const metaWithStatExp = addMetaNumber(general.meta, STAT_EXP_KEY, 1); const metaWithStatExp = addMetaNumber(general.meta, STAT_EXP_KEY, 1);
const metaUpdated = general.meta =
result.pick === 'success' result.pick === 'success'
? { ...metaWithStatExp, max_domestic_critical: result.score } ? { ...metaWithStatExp, max_domestic_critical: result.score }
: { ...metaWithStatExp, max_domestic_critical: 0 }; : { ...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 = const pickLabel =
result.pick === 'success' result.pick === 'success'
? '성공' ? '성공'
@@ -372,9 +354,9 @@ export class ActionResolver<
? '실패' ? '실패'
: '완료'; : '완료';
const logMessage = `${ACTION_NAME} ${pickLabel}: +${Math.round(result.score)}`; const logMessage = `${ACTION_NAME} ${pickLabel}: +${Math.round(result.score)}`;
effects.push(createLogEffect(logMessage)); context.addLog(logMessage);
return { effects }; return { effects: [] };
} }
} }
@@ -1,5 +1,4 @@
import type { import type {
General,
GeneralTriggerState, GeneralTriggerState,
} from '../../../domain/entities.js'; } from '../../../domain/entities.js';
import type { import type {
@@ -13,8 +12,6 @@ import type {
GeneralActionOutcome, GeneralActionOutcome,
GeneralActionResolveContext, GeneralActionResolveContext,
} from '../../engine.js'; } from '../../engine.js';
import { createGeneralPatchEffect, createLogEffect } from '../../engine.js';
import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js';
import type { TurnCommandEnv } from '../commandEnv.js'; import type { TurnCommandEnv } from '../commandEnv.js';
import type { GeneralTurnCommandSpec } from './index.js'; import type { GeneralTurnCommandSpec } from './index.js';
@@ -62,21 +59,14 @@ export class ActionDefinition<
const nextInjury = Math.max(0, general.injury - delta); const nextInjury = Math.max(0, general.injury - delta);
const applied = general.injury - nextInjury; const applied = general.injury - nextInjury;
const costGold = this.env.costGold ?? 0; const costGold = this.env.costGold ?? 0;
const patch: Partial<General<TriggerState>> = {
injury: nextInjury,
gold: Math.max(0, general.gold - costGold),
};
return { // 직접 수정 (Immer Draft)
effects: [ general.injury = nextInjury;
createGeneralPatchEffect<TriggerState>(patch, general.id), general.gold = Math.max(0, general.gold - costGold);
createLogEffect(`${ACTION_NAME}으로 부상이 ${applied} 회복되었습니다.`, {
scope: LogScope.GENERAL, context.addLog(`${ACTION_NAME}으로 부상이 ${applied} 회복되었습니다.`);
category: LogCategory.ACTION,
format: LogFormat.MONTH, return { effects: [] };
}),
],
};
} }
} }
@@ -27,9 +27,6 @@ import type {
} from '../../engine.js'; } from '../../engine.js';
import { import {
createGeneralAddEffect, createGeneralAddEffect,
createGeneralPatchEffect,
createLogEffect,
createNationPatchEffect,
} from '../../engine.js'; } from '../../engine.js';
import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js'; import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js';
import { buildRecruitmentGeneral } from './recruitment.js'; import { buildRecruitmentGeneral } from './recruitment.js';
@@ -250,47 +247,34 @@ export class ActionResolver<
_args: VolunteerRecruitArgs _args: VolunteerRecruitArgs
): GeneralActionOutcome<TriggerState> { ): GeneralActionOutcome<TriggerState> {
void _args; void _args;
const effects: Array<GeneralActionEffect<TriggerState>> = []; const general = context.general;
const nation = context.nation ?? null; const nation = context.nation;
const expGain = 5 * (DEFAULT_PRE_TURN + 1); const expGain = 5 * (DEFAULT_PRE_TURN + 1);
const dedGain = 5 * (DEFAULT_PRE_TURN + 1); const dedGain = 5 * (DEFAULT_PRE_TURN + 1);
effects.push(
createGeneralPatchEffect({
experience: context.general.experience + expGain,
dedication: context.general.dedication + dedGain,
})
);
effects.push( // 직접 수정 (Immer Draft)
createLogEffect(`${ACTION_NAME} 발동!`, { general.experience += expGain;
scope: LogScope.GENERAL, general.dedication += dedGain;
category: LogCategory.ACTION,
format: LogFormat.MONTH,
})
);
effects.push(
createLogEffect(`${ACTION_NAME} 발동`, {
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
})
);
if (nation?.id) { context.addLog(`${ACTION_NAME} 발동!`);
const generalName = context.general.name; context.addLog(`${ACTION_NAME} 발동`, {
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
});
if (nation) {
const generalName = general.name;
const generalJosa = JosaUtil.pick(generalName, '이'); const generalJosa = JosaUtil.pick(generalName, '이');
const actionJosa = JosaUtil.pick(ACTION_NAME, '을'); const actionJosa = JosaUtil.pick(ACTION_NAME, '을');
effects.push( context.addLog(
createLogEffect( `<Y>${generalName}</>${generalJosa} <M>${ACTION_NAME}</>${actionJosa} 발동했습니다.`,
`<Y>${generalName}</>${generalJosa} <M>${ACTION_NAME}</>${actionJosa} 발동했습니다.`, {
{ scope: LogScope.NATION,
scope: LogScope.NATION, category: LogCategory.HISTORY,
category: LogCategory.HISTORY, nationId: nation.id,
nationId: nation.id, format: LogFormat.YEAR_MONTH,
format: LogFormat.YEAR_MONTH, }
}
)
); );
} }
@@ -310,16 +294,15 @@ export class ActionResolver<
const globalDelay = this.command.getGlobalDelay(context); const globalDelay = this.command.getGlobalDelay(context);
if (nation) { if (nation) {
effects.push( nation.meta = {
createNationPatchEffect({ ...nation.meta as object,
meta: { gennum: nextGennum,
gennum: nextGennum, strategic_cmd_limit: globalDelay,
strategic_cmd_limit: globalDelay, };
},
}, nation.id)
);
} }
const effects: Array<GeneralActionEffect<TriggerState>> = [];
const baseAge = this.env.npcAge ?? DEFAULT_NPC_AGE; const baseAge = this.env.npcAge ?? DEFAULT_NPC_AGE;
const deathYears = this.env.npcDeathYears ?? DEFAULT_NPC_DEATH_YEARS; const deathYears = this.env.npcDeathYears ?? DEFAULT_NPC_DEATH_YEARS;
const killTurnMin = this.env.killTurnMin ?? DEFAULT_KILLTURN_MIN; const killTurnMin = this.env.killTurnMin ?? DEFAULT_KILLTURN_MIN;
@@ -20,14 +20,11 @@ import {
} from '../../../triggers/general-action.js'; } from '../../../triggers/general-action.js';
import type { GeneralActionDefinition } from '../../definition.js'; import type { GeneralActionDefinition } from '../../definition.js';
import type { import type {
GeneralActionEffect,
GeneralActionOutcome, GeneralActionOutcome,
GeneralActionResolveContext, GeneralActionResolveContext,
} from '../../engine.js'; } from '../../engine.js';
import { import {
createGeneralAddEffect, createGeneralAddEffect,
createGeneralPatchEffect,
createLogEffect,
} from '../../engine.js'; } from '../../engine.js';
import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js'; import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js';
import { buildRecruitmentGeneral } from './recruitment.js'; import { buildRecruitmentGeneral } from './recruitment.js';
@@ -301,40 +298,35 @@ export class ActionResolver<
_args: TalentScoutArgs _args: TalentScoutArgs
): GeneralActionOutcome<TriggerState> { ): GeneralActionOutcome<TriggerState> {
void _args; void _args;
const general = context.general;
const { gold: reqGold, rice: reqRice } = this.command.getCost(); const { gold: reqGold, rice: reqRice } = this.command.getCost();
const prop = this.command.calcFoundProp(context); const prop = this.command.calcFoundProp(context);
const found = context.rng.nextBool(prop); const found = context.rng.nextBool(prop);
const statKey = pickStatExpKey(context.rng, context.general); const statKey = pickStatExpKey(context.rng, general);
const metaAfter = const metaAfter =
found found
? addMetaNumber(context.general.meta, statKey, 3) ? addMetaNumber(general.meta, statKey, 3)
: addMetaNumber(context.general.meta, statKey, 1); : addMetaNumber(general.meta, statKey, 1);
const nextGold = Math.max(0, context.general.gold - reqGold); const nextGold = Math.max(0, general.gold - reqGold);
const nextRice = Math.max(0, context.general.rice - reqRice); const nextRice = Math.max(0, general.rice - reqRice);
const expGain = found ? 200 : 100; const expGain = found ? 200 : 100;
const dedGain = found ? 300 : 70; const dedGain = found ? 300 : 70;
const effects: Array<GeneralActionEffect<TriggerState>> = [ // 직접 수정 (Immer Draft)
createGeneralPatchEffect({ general.gold = nextGold;
gold: nextGold, general.rice = nextRice;
rice: nextRice, general.experience += expGain;
experience: context.general.experience + expGain, general.dedication += dedGain;
dedication: context.general.dedication + dedGain, general.meta = metaAfter;
meta: metaAfter,
}),
];
if (!found) { if (!found) {
effects.push( context.addLog('인재를 찾을 수 없었습니다.', {
createLogEffect('인재를 찾을 수 없었습니다.', { category: LogCategory.ACTION,
scope: LogScope.GENERAL, format: LogFormat.MONTH,
category: LogCategory.ACTION, });
format: LogFormat.MONTH, return { effects: [] };
})
);
return { effects };
} }
const candidate = resolveCandidate(context, context.rng, this.env); const candidate = resolveCandidate(context, context.rng, this.env);
@@ -396,32 +388,25 @@ export class ActionResolver<
meta, meta,
}); });
effects.push(createGeneralAddEffect(newGeneral));
const nameObjJosa = JosaUtil.pick(name, '을'); const nameObjJosa = JosaUtil.pick(name, '을');
const nameSubjJosa = JosaUtil.pick(name, '이'); const nameSubjJosa = JosaUtil.pick(name, '이');
effects.push( context.addLog(`인재 <Y>${name}</>${nameObjJosa} 발견했습니다.`, {
createLogEffect(`인재 <Y>${name}</>${nameObjJosa} 발견했습니다.`, { category: LogCategory.ACTION,
scope: LogScope.GENERAL, format: LogFormat.MONTH,
category: LogCategory.ACTION, });
format: LogFormat.MONTH, context.addLog(`인재 <Y>${name}</>${nameSubjJosa} 등장했습니다.`, {
}) scope: LogScope.SYSTEM,
); category: LogCategory.SUMMARY,
effects.push( format: LogFormat.MONTH,
createLogEffect(`인재 <Y>${name}</>${nameSubjJosa} 등장했습니다.`, { });
scope: LogScope.SYSTEM, context.addLog(`인재 <Y>${name}</>${nameObjJosa} 발견했습니다.`, {
category: LogCategory.SUMMARY, category: LogCategory.HISTORY,
format: LogFormat.MONTH, format: LogFormat.YEAR_MONTH,
}) });
);
effects.push(
createLogEffect(`인재 <Y>${name}</>${nameObjJosa} 발견했습니다.`, {
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
})
);
return { effects }; return {
effects: [createGeneralAddEffect(newGeneral)],
};
} }
} }
@@ -9,8 +9,7 @@ import type {
GeneralActionOutcome, GeneralActionOutcome,
GeneralActionResolveContext, GeneralActionResolveContext,
} from '../../engine.js'; } from '../../engine.js';
import { createLogEffect } from '../../engine.js'; import { LogCategory, LogFormat } from '../../../logging/types.js';
import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js';
import type { TurnCommandEnv } from '../commandEnv.js'; import type { TurnCommandEnv } from '../commandEnv.js';
import type { GeneralTurnCommandSpec } from './index.js'; import type { GeneralTurnCommandSpec } from './index.js';
@@ -47,22 +46,17 @@ export class ActionDefinition<
} }
resolve( resolve(
_context: GeneralActionResolveContext<TriggerState>, context: GeneralActionResolveContext<TriggerState>,
args: AppointmentArgs args: AppointmentArgs
): GeneralActionOutcome<TriggerState> { ): GeneralActionOutcome<TriggerState> {
void _context; context.addLog(
return { `${ACTION_NAME}을 신청했습니다. (국가 ${args.destNationId})`,
effects: [ {
createLogEffect( category: LogCategory.ACTION,
`${ACTION_NAME}을 신청했습니다. (국가 ${args.destNationId})`, format: LogFormat.MONTH,
{ }
scope: LogScope.GENERAL, );
category: LogCategory.ACTION, return { effects: [] };
format: LogFormat.MONTH,
}
),
],
};
} }
} }
@@ -26,15 +26,9 @@ import {
} from '../../../triggers/general-action.js'; } from '../../../triggers/general-action.js';
import type { GeneralActionDefinition } from '../../definition.js'; import type { GeneralActionDefinition } from '../../definition.js';
import type { import type {
GeneralActionEffect,
GeneralActionOutcome, GeneralActionOutcome,
GeneralActionResolveContext, GeneralActionResolveContext,
} from '../../engine.js'; } from '../../engine.js';
import {
createCityPatchEffect,
createGeneralPatchEffect,
createLogEffect,
} from '../../engine.js';
import type { MapDefinition, UnitSetDefinition } from '../../../world/types.js'; import type { MapDefinition, UnitSetDefinition } from '../../../world/types.js';
import type { TurnCommandEnv } from '../commandEnv.js'; import type { TurnCommandEnv } from '../commandEnv.js';
import type { GeneralTurnCommandSpec } from './index.js'; import type { GeneralTurnCommandSpec } from './index.js';
@@ -351,16 +345,14 @@ export class ActionResolver<
): GeneralActionOutcome<TriggerState> { ): GeneralActionOutcome<TriggerState> {
const { general, city } = context; const { general, city } = context;
if (!city) { if (!city) {
return { context.addLog('도시 정보가 없습니다.');
effects: [createLogEffect('도시 정보가 없습니다.')], return { effects: [] };
};
} }
const crewType = findCrewTypeById(context.unitSet, args.crewType); const crewType = findCrewTypeById(context.unitSet, args.crewType);
if (!crewType) { if (!crewType) {
return { context.addLog('병종 정보가 없습니다.');
effects: [createLogEffect('병종 정보가 없습니다.')], return { effects: [] };
};
} }
const availabilityContext: CrewTypeAvailabilityContext = { const availabilityContext: CrewTypeAvailabilityContext = {
@@ -376,9 +368,8 @@ export class ActionResolver<
availabilityContext.startYear = context.startYear; availabilityContext.startYear = context.startYear;
} }
if (!isCrewTypeAvailable(context.unitSet, crewType.id, availabilityContext)) { if (!isCrewTypeAvailable(context.unitSet, crewType.id, availabilityContext)) {
return { context.addLog('현재 선택할 수 없는 병종입니다.');
effects: [createLogEffect('현재 선택할 수 없는 병종입니다.')], return { effects: [] };
};
} }
const plan = this.command.getCost( const plan = this.command.getCost(
@@ -435,30 +426,26 @@ export class ActionResolver<
const expGain = Math.round(appliedCrew / 100); const expGain = Math.round(appliedCrew / 100);
const dedGain = Math.round(appliedCrew / 100); const dedGain = Math.round(appliedCrew / 100);
const metaUpdated = addMetaNumber(general.meta, 'leadership_exp', 1); // 직접 수정 (Immer Draft)
city.population = nextPopulation;
city.meta = {
...city.meta as object,
trust: nextTrust,
};
const effects: Array<GeneralActionEffect<TriggerState>> = [ general.crewTypeId = nextCrewTypeId;
createCityPatchEffect({ general.crew = nextCrew;
population: nextPopulation, general.train = nextTrain;
meta: { general.atmos = nextAtmos;
trust: nextTrust, general.gold = nextGold;
}, general.rice = nextRice;
}), general.experience += expGain;
createGeneralPatchEffect({ general.dedication += dedGain;
crewTypeId: nextCrewTypeId, general.meta = addMetaNumber(general.meta, 'leadership_exp', 1);
crew: nextCrew,
train: nextTrain,
atmos: nextAtmos,
gold: nextGold,
rice: nextRice,
experience: general.experience + expGain,
dedication: general.dedication + dedGain,
meta: metaUpdated,
}),
createLogEffect(logMessage),
];
return { effects }; context.addLog(logMessage);
return { effects: [] };
} }
} }
@@ -12,8 +12,7 @@ import type {
GeneralActionOutcome, GeneralActionOutcome,
GeneralActionResolveContext, GeneralActionResolveContext,
} from '../../engine.js'; } from '../../engine.js';
import { createLogEffect } from '../../engine.js'; import { LogCategory, LogFormat } from '../../../logging/types.js';
import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js';
import type { TurnCommandEnv } from '../commandEnv.js'; import type { TurnCommandEnv } from '../commandEnv.js';
import type { GeneralTurnCommandSpec } from './index.js'; import type { GeneralTurnCommandSpec } from './index.js';
@@ -56,19 +55,14 @@ export class ActionDefinition<
} }
resolve( resolve(
_context: GeneralActionResolveContext<TriggerState>, context: GeneralActionResolveContext<TriggerState>,
args: DispatchArgs args: DispatchArgs
): GeneralActionOutcome<TriggerState> { ): GeneralActionOutcome<TriggerState> {
void _context; context.addLog(`${ACTION_NAME}을 준비했습니다. (목표 도시 ${args.destCityId})`, {
return { category: LogCategory.ACTION,
effects: [ format: LogFormat.MONTH,
createLogEffect(`${ACTION_NAME}을 준비했습니다. (목표 도시 ${args.destCityId})`, { });
scope: LogScope.GENERAL, return { effects: [] };
category: LogCategory.ACTION,
format: LogFormat.MONTH,
}),
],
};
} }
} }
@@ -33,7 +33,6 @@ import type {
import { import {
createCityPatchEffect, createCityPatchEffect,
createGeneralPatchEffect, createGeneralPatchEffect,
createLogEffect,
} from '../../engine.js'; } from '../../engine.js';
import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js'; import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js';
import type { TurnCommandEnv } from '../commandEnv.js'; import type { TurnCommandEnv } from '../commandEnv.js';
@@ -332,6 +331,7 @@ export class ActionResolver<
_args: FireAttackArgs _args: FireAttackArgs
): GeneralActionOutcome<TriggerState> { ): GeneralActionOutcome<TriggerState> {
void _args; void _args;
const general = context.general;
const city = context.city; const city = context.city;
if (!city) { if (!city) {
throw new Error('Fire attack requires a city context.'); throw new Error('Fire attack requires a city context.');
@@ -351,13 +351,13 @@ export class ActionResolver<
const effects: Array<GeneralActionEffect<TriggerState>> = []; const effects: Array<GeneralActionEffect<TriggerState>> = [];
const nextGold = Math.max(0, context.general.gold - result.costGold); const nextGold = Math.max(0, general.gold - result.costGold);
const nextRice = Math.max(0, context.general.rice - result.costRice); const nextRice = Math.max(0, general.rice - result.costRice);
const nextExperience = context.general.experience + result.exp; const nextExperience = general.experience + result.exp;
const nextDedication = context.general.dedication + result.dedication; const nextDedication = general.dedication + result.dedication;
const metaWithStatExp = addMetaNumber( const metaWithStatExp = addMetaNumber(
context.general.meta, general.meta,
STAT_EXP_KEY, STAT_EXP_KEY,
1 1
); );
@@ -365,26 +365,21 @@ export class ActionResolver<
? addMetaNumber(metaWithStatExp, 'firenum', 1) ? addMetaNumber(metaWithStatExp, 'firenum', 1)
: metaWithStatExp; : metaWithStatExp;
effects.push( // 직접 수정 (Immer Draft)
createGeneralPatchEffect({ general.gold = nextGold;
gold: nextGold, general.rice = nextRice;
rice: nextRice, general.experience = nextExperience;
experience: nextExperience, general.dedication = nextDedication;
dedication: nextDedication, general.meta = metaUpdated;
meta: metaUpdated,
})
);
if (!result.success) { if (!result.success) {
effects.push( context.addLog(
createLogEffect( `<G><b>${context.destCity.name}</b></>에 ${ACTION_NAME} 실패했습니다.`,
`<G><b>${context.destCity.name}</b></>에 ${ACTION_NAME} 실패했습니다.`, {
{ format: LogFormat.MONTH,
format: LogFormat.MONTH, }
}
)
); );
return { effects }; return { effects: [] };
} }
const updatedCityMeta: Record<string, TriggerValue> = { const updatedCityMeta: Record<string, TriggerValue> = {
@@ -392,6 +387,7 @@ export class ActionResolver<
state: CITY_STATE_BURNING, state: CITY_STATE_BURNING,
}; };
// 타겟 도시는 Draft가 아니므로 Effect 반환
effects.push( effects.push(
createCityPatchEffect( createCityPatchEffect(
{ {
@@ -403,45 +399,38 @@ export class ActionResolver<
) )
); );
effects.push( context.addLog(
createLogEffect( `<G><b>${context.destCity.name}</b></>이 불타고 있습니다.`,
`<G><b>${context.destCity.name}</b></>이 불타고 있습니다.`, {
{ scope: LogScope.SYSTEM,
scope: LogScope.SYSTEM, category: LogCategory.SUMMARY,
category: LogCategory.SUMMARY, format: LogFormat.MONTH,
format: LogFormat.MONTH, }
}
)
); );
effects.push( context.addLog(
createLogEffect( `<G><b>${context.destCity.name}</b></>에 ${ACTION_NAME} 성공했습니다.`,
`<G><b>${context.destCity.name}</b></>에 ${ACTION_NAME} 성공했습니다.`, {
{ format: LogFormat.MONTH,
format: LogFormat.MONTH, }
}
)
); );
effects.push( context.addLog(
createLogEffect( `도시의 농업이 <C>${result.agriDamage}</>, 상업이 <C>${result.commDamage}</>만큼 감소하고, 장수 <C>${result.injuryCount}</>명이 부상 당했습니다.`,
`도시의 농업이 <C>${result.agriDamage}</>, 상업이 <C>${result.commDamage}</>만큼 감소하고, 장수 <C>${result.injuryCount}</>명이 부상 당했습니다.`, {
{ format: LogFormat.PLAIN,
format: LogFormat.PLAIN, }
}
)
); );
for (const injured of result.injuredGenerals) { for (const injured of result.injuredGenerals) {
// 타겟 장수는 Draft가 아니므로 Effect 반환
effects.push( effects.push(
createGeneralPatchEffect(injured.patch, injured.id) createGeneralPatchEffect(injured.patch, injured.id)
); );
effects.push( context.addLog(
createLogEffect( `<M>${ACTION_KEY}</>로 인해 <R>부상</>을 당했습니다.`,
`<M>${ACTION_KEY}</>로 인해 <R>부상</>을 당했습니다.`, {
{ generalId: injured.id,
generalId: injured.id, format: LogFormat.MONTH,
format: LogFormat.MONTH, }
}
)
); );
} }
@@ -1,5 +1,4 @@
import type { import type {
General,
GeneralTriggerState, GeneralTriggerState,
} from '../../../domain/entities.js'; } from '../../../domain/entities.js';
import type { import type {
@@ -13,8 +12,6 @@ import type {
GeneralActionOutcome, GeneralActionOutcome,
GeneralActionResolveContext, GeneralActionResolveContext,
} from '../../engine.js'; } from '../../engine.js';
import { createGeneralPatchEffect, createLogEffect } from '../../engine.js';
import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js';
import type { TurnCommandEnv } from '../commandEnv.js'; import type { TurnCommandEnv } from '../commandEnv.js';
import type { GeneralTurnCommandSpec } from './index.js'; import type { GeneralTurnCommandSpec } from './index.js';
@@ -74,21 +71,14 @@ export class ActionDefinition<
const nextTrain = clamp(general.train + delta, 0, maxTrain); const nextTrain = clamp(general.train + delta, 0, maxTrain);
const applied = nextTrain - general.train; const applied = nextTrain - general.train;
const costGold = this.env.costGold ?? 0; const costGold = this.env.costGold ?? 0;
const patch: Partial<General<TriggerState>> = {
train: nextTrain,
gold: Math.max(0, general.gold - costGold),
};
return { // 직접 수정 (Immer Draft)
effects: [ general.train = nextTrain;
createGeneralPatchEffect<TriggerState>(patch, general.id), general.gold = Math.max(0, general.gold - costGold);
createLogEffect(`${ACTION_NAME}을 통해 훈련도가 ${applied} 증가했습니다.`, {
scope: LogScope.GENERAL, context.addLog(`${ACTION_NAME}을 통해 훈련도가 ${applied} 증가했습니다.`);
category: LogCategory.ACTION,
format: LogFormat.MONTH, return { effects: [] };
}),
],
};
} }
} }
@@ -1,6 +1,5 @@
import type { import type {
City, City,
General,
GeneralTriggerState, GeneralTriggerState,
} from '../../../domain/entities.js'; } from '../../../domain/entities.js';
import type { import type {
@@ -21,12 +20,6 @@ import type {
GeneralActionOutcome, GeneralActionOutcome,
GeneralActionResolveContext, GeneralActionResolveContext,
} from '../../engine.js'; } from '../../engine.js';
import {
createCityPatchEffect,
createGeneralPatchEffect,
createLogEffect,
} from '../../engine.js';
import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js';
export interface CityDevelopmentArgs {} export interface CityDevelopmentArgs {}
@@ -98,53 +91,28 @@ export class CityDevelopmentActionDefinition<
const general = context.general; const general = context.general;
const city = context.city; const city = context.city;
if (!city) { if (!city) {
return { context.addLog('도시 정보를 찾지 못했습니다.');
effects: [ return { effects: [] };
createLogEffect('도시 정보를 찾지 못했습니다.', {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
}),
],
};
} }
const baseAmount = this.env.amount ?? this.config.baseAmount; const baseAmount = this.env.amount ?? this.config.baseAmount;
const current = readNumber(city[this.config.statKey]); const current = readNumber(city[this.config.statKey]);
const max = readNumber(city[this.config.maxKey]); const max = readNumber(city[this.config.maxKey]);
if (current === null || max === null) { if (current === null || max === null) {
return { context.addLog('도시 정보를 찾지 못했습니다.');
effects: [ return { effects: [] };
createLogEffect('도시 정보를 찾지 못했습니다.', {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
}),
],
};
} }
const nextValue = clamp(current + baseAmount, 0, max); const nextValue = clamp(current + baseAmount, 0, max);
const costGold = this.env.develCost ?? 0; const costGold = this.env.develCost ?? 0;
const generalPatch: Partial<General<TriggerState>> = {
gold: Math.max(0, general.gold - costGold), // 직접 수정 (Immer Draft)
}; (city as any)[this.config.statKey] = nextValue;
general.gold = Math.max(0, general.gold - costGold);
const logMessage = `${this.config.label}${nextValue - current} 증가했습니다.`; const logMessage = `${this.config.label}${nextValue - current} 증가했습니다.`;
context.addLog(logMessage);
return { return { effects: [] };
effects: [
createCityPatchEffect(
{ [this.config.statKey]: nextValue } as Partial<City>,
city.id
),
createGeneralPatchEffect<TriggerState>(generalPatch, general.id),
createLogEffect(logMessage, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
}),
],
};
} }
} }
@@ -10,8 +10,7 @@ import type {
GeneralActionOutcome, GeneralActionOutcome,
GeneralActionResolveContext, GeneralActionResolveContext,
} from '../../engine.js'; } from '../../engine.js';
import { createLogEffect } from '../../engine.js'; import { LogCategory, LogFormat } from '../../../logging/types.js';
import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js';
import type { TurnCommandEnv } from '../commandEnv.js'; import type { TurnCommandEnv } from '../commandEnv.js';
import type { GeneralTurnCommandSpec } from './index.js'; import type { GeneralTurnCommandSpec } from './index.js';
@@ -23,20 +22,14 @@ export class ActionResolver<
TriggerState extends GeneralTriggerState = GeneralTriggerState TriggerState extends GeneralTriggerState = GeneralTriggerState
> { > {
resolve( resolve(
_context: GeneralActionResolveContext<TriggerState>, context: GeneralActionResolveContext<TriggerState>,
_args: RestArgs _args: RestArgs
): GeneralActionOutcome<TriggerState> { ): GeneralActionOutcome<TriggerState> {
void _context; context.addLog('아무것도 실행하지 않았습니다.', {
void _args; category: LogCategory.ACTION,
return { format: LogFormat.MONTH,
effects: [ });
createLogEffect('아무것도 실행하지 않았습니다.', { return { effects: [] };
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
}),
],
};
} }
} }