feat: add immer dependency and integrate draft handling in engine actions

This commit is contained in:
2026-01-02 09:38:05 +00:00
parent 56495c65e3
commit 18247eeff4
3 changed files with 245 additions and 166 deletions
+1
View File
@@ -20,6 +20,7 @@
},
"dependencies": {
"@sammo-ts/common": "workspace:*",
"immer": "^11.1.3",
"zod": "^4.2.1"
},
"devDependencies": {
+233 -166
View File
@@ -1,14 +1,13 @@
import type { RandomGenerator } from '@sammo-ts/common';
import { enablePatches, produceWithPatches, type Draft, castDraft } from 'immer';
import type {
City,
General,
GeneralRole,
GeneralTriggerState,
CityId,
GeneralId,
Nation,
NationId,
StatBlock,
} from '../domain/entities.js';
import type { GeneralActionContext } from '../triggers/general.js';
import { getNextTurnAt, type TurnSchedule } from '../turn/calendar.js';
@@ -19,6 +18,16 @@ import {
LogScope,
} from '../logging/types.js';
enablePatches();
export interface WorldState<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> {
general: General<TriggerState>;
city?: City;
nation?: Nation | null;
}
export interface GeneralActionResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> extends GeneralActionContext<TriggerState> {
@@ -121,61 +130,105 @@ export interface GeneralActionResolution {
};
}
const mergeStats = (base: StatBlock, patch: Partial<StatBlock>): StatBlock => ({
leadership: patch.leadership ?? base.leadership,
strength: patch.strength ?? base.strength,
intelligence: patch.intelligence ?? base.intelligence,
});
const mergeRole = (
base: GeneralRole,
patch: Partial<GeneralRole>
): GeneralRole => ({
...base,
...patch,
items: {
...base.items,
...(patch.items ?? {}),
},
});
const mergeTriggerState = <TriggerState extends GeneralTriggerState>(
base: TriggerState,
patch: Partial<TriggerState>
): TriggerState => ({
...base,
...patch,
flags: { ...base.flags, ...(patch.flags ?? {}) },
counters: { ...base.counters, ...(patch.counters ?? {}) },
modifiers: { ...base.modifiers, ...(patch.modifiers ?? {}) },
meta: { ...base.meta, ...(patch.meta ?? {}) },
});
const applyGeneralPatch = <TriggerState extends GeneralTriggerState>(
base: General<TriggerState>,
patch: Partial<General<TriggerState>>
): General<TriggerState> => ({
...base,
...patch,
stats: patch.stats ? mergeStats(base.stats, patch.stats) : base.stats,
role: patch.role ? mergeRole(base.role, patch.role) : base.role,
triggerState: patch.triggerState
? mergeTriggerState(base.triggerState, patch.triggerState)
: base.triggerState,
meta: patch.meta ? { ...base.meta, ...patch.meta } : base.meta,
});
const applyCityPatch = (base: City, patch: Partial<City>): City => ({
...base,
...patch,
meta: patch.meta ? { ...base.meta, ...patch.meta } : base.meta,
});
const applyNationPatch = (base: Nation, patch: Partial<Nation>): Nation => ({
...base,
...patch,
meta: patch.meta ? { ...base.meta, ...patch.meta } : base.meta,
});
/**
* 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 = <
TriggerState extends GeneralTriggerState = GeneralTriggerState
@@ -254,11 +307,7 @@ export const resolveGeneralAction = <
scheduleContext: TurnScheduleContext,
args: Args
): GeneralActionResolution => {
const outcome = resolver.resolve(context, args);
const logs: LogEntryDraft[] = [];
let nextGeneral = context.general;
let nextCity = context.city;
let nextNation = context.nation ?? null;
let nextTurnAtOverride: Date | null = null;
const createdGenerals: General[] = [];
const patches: NonNullable<GeneralActionResolution['patches']> = {
@@ -266,124 +315,142 @@ export const resolveGeneralAction = <
cities: [],
nations: [],
};
const [nextWorld, worldPatches] = produceWithPatches(
{
general: context.general,
city: context.city,
nation: context.nation,
} as WorldState<TriggerState>,
(draft) => {
const outcome = resolver.resolve(
{
...context,
general: castDraft(draft.general),
city: castDraft(draft.city),
nation: castDraft(draft.nation),
} as GeneralActionResolveContext<TriggerState>,
args
);
for (const effect of outcome.effects) {
switch (effect.type) {
case 'log':
// 로그 대상이 비어 있으면 현재 장수/국가 기준으로 보정한다.
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;
case 'schedule:override':
nextTurnAtOverride = effect.nextTurnAt;
break;
case 'general:add':
createdGenerals.push(effect.general as General);
break;
case 'general:patch':
case 'city:patch':
case 'nation:patch':
applyEffectToDraft(draft, effect, {
generalId: context.general.id,
...(context.city?.id !== undefined
? { cityId: context.city.id }
: {}),
...(context.nation?.id !== undefined
? { nationId: context.nation.id }
: {}),
});
// 타겟이 다른 경우 patches에 추가 (applyEffectToDraft에서 처리되지 않은 경우)
if (
effect.type === 'general:patch' &&
effect.targetId !== undefined &&
effect.targetId !== context.general.id
) {
patches.generals.push({
id: effect.targetId,
patch: effect.patch as Partial<General>,
});
} else if (
effect.type === 'city:patch' &&
effect.targetId !== undefined &&
effect.targetId !== context.city?.id
) {
patches.cities.push({
id: effect.targetId,
patch: effect.patch,
});
} else if (
effect.type === 'nation:patch' &&
effect.targetId !== undefined &&
effect.targetId !== context.nation?.id
) {
patches.nations.push({
id: effect.targetId,
patch: effect.patch,
});
}
break;
}
}
}
);
const nextTurnAt =
nextTurnAtOverride ??
getNextTurnAt(scheduleContext.now, scheduleContext.schedule);
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;
}
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':
if (
effect.targetId === undefined ||
effect.targetId === context.general.id
) {
nextGeneral = applyGeneralPatch(nextGeneral, effect.patch);
dirty.general = true;
} else {
patches.generals.push({
id: effect.targetId,
patch: effect.patch as Partial<General>,
});
}
break;
case 'general:add':
createdGenerals.push(effect.general as General);
break;
case 'city:patch':
if (
effect.targetId === undefined ||
effect.targetId === nextCity?.id
) {
if (nextCity) {
nextCity = applyCityPatch(nextCity, effect.patch);
dirty.city = true;
}
} else {
patches.cities.push({
id: effect.targetId,
patch: effect.patch,
});
}
break;
case 'nation:patch':
if (
effect.targetId === undefined ||
effect.targetId === nextNation?.id
) {
if (nextNation) {
nextNation = applyNationPatch(nextNation, effect.patch);
dirty.nation = true;
}
} else {
patches.nations.push({
id: effect.targetId,
patch: effect.patch,
});
}
break;
case 'log':
// 로그 대상이 비어 있으면 현재 장수/국가 기준으로 보정한다.
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;
case 'schedule:override':
nextTurnAtOverride = effect.nextTurnAt;
break;
default:
break;
}
// worldPatches를 분석하여 dirty 설정
for (const patch of worldPatches) {
if (patch.path[0] === 'general') dirty.general = true;
if (patch.path[0] === 'city') dirty.city = true;
if (patch.path[0] === 'nation') dirty.nation = true;
}
const nextTurnAt =
nextTurnAtOverride ??
getNextTurnAt(scheduleContext.now, scheduleContext.schedule);
const resolution: GeneralActionResolution = {
general: nextGeneral,
nation: nextNation,
general: nextWorld.general as General,
nation: nextWorld.nation as Nation | null,
nextTurnAt,
logs,
effects: outcome.effects,
effects: [], // 이제 effects는 직접 사용되지 않음 (이미 반영됨)
};
if (nextCity) {
resolution.city = nextCity;
if (nextWorld.city) {
resolution.city = nextWorld.city as City;
}
if (dirty.general || dirty.city || dirty.nation) {
resolution.dirty = dirty;
+11
View File
@@ -60,6 +60,9 @@ importers:
app/game-engine:
dependencies:
'@prisma/client':
specifier: ^7.2.0
version: 7.2.0(prisma@7.2.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3)
'@sammo-ts/common':
specifier: workspace:*
version: link:../../packages/common
@@ -176,6 +179,9 @@ importers:
'@sammo-ts/common':
specifier: workspace:*
version: link:../common
immer:
specifier: ^11.1.3
version: 11.1.3
zod:
specifier: ^4.2.1
version: 4.2.1
@@ -1278,6 +1284,9 @@ packages:
resolution: {integrity: sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==}
engines: {node: '>=0.10.0'}
immer@11.1.3:
resolution: {integrity: sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==}
import-without-cache@0.2.5:
resolution: {integrity: sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A==}
engines: {node: '>=20.19.0'}
@@ -3066,6 +3075,8 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
immer@11.1.3: {}
import-without-cache@0.2.5: {}
ini@1.3.8: {}