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": { "dependencies": {
"@sammo-ts/common": "workspace:*", "@sammo-ts/common": "workspace:*",
"immer": "^11.1.3",
"zod": "^4.2.1" "zod": "^4.2.1"
}, },
"devDependencies": { "devDependencies": {
+194 -127
View File
@@ -1,14 +1,13 @@
import type { RandomGenerator } from '@sammo-ts/common'; import type { RandomGenerator } from '@sammo-ts/common';
import { enablePatches, produceWithPatches, type Draft, castDraft } from 'immer';
import type { import type {
City, City,
General, General,
GeneralRole,
GeneralTriggerState, GeneralTriggerState,
CityId, CityId,
GeneralId, GeneralId,
Nation, Nation,
NationId, NationId,
StatBlock,
} from '../domain/entities.js'; } from '../domain/entities.js';
import type { GeneralActionContext } from '../triggers/general.js'; import type { GeneralActionContext } from '../triggers/general.js';
import { getNextTurnAt, type TurnSchedule } from '../turn/calendar.js'; import { getNextTurnAt, type TurnSchedule } from '../turn/calendar.js';
@@ -19,6 +18,16 @@ import {
LogScope, LogScope,
} from '../logging/types.js'; } from '../logging/types.js';
enablePatches();
export interface WorldState<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> {
general: General<TriggerState>;
city?: City;
nation?: Nation | null;
}
export interface GeneralActionResolveContext< export interface GeneralActionResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState TriggerState extends GeneralTriggerState = GeneralTriggerState
> extends GeneralActionContext<TriggerState> { > extends GeneralActionContext<TriggerState> {
@@ -121,61 +130,105 @@ export interface GeneralActionResolution {
}; };
} }
const mergeStats = (base: StatBlock, patch: Partial<StatBlock>): StatBlock => ({ /**
leadership: patch.leadership ?? base.leadership, * Immer Draft에 Effect를 적용한다.
strength: patch.strength ?? base.strength, * 기존 Effect 기반 코드를 유지하면서 Draft에 즉시 반영하기 위함.
intelligence: patch.intelligence ?? base.intelligence, */
}); export const applyEffectToDraft = <
TriggerState extends GeneralTriggerState = GeneralTriggerState
const mergeRole = ( >(
base: GeneralRole, draft: Draft<WorldState<TriggerState>>,
patch: Partial<GeneralRole> effect: GeneralActionEffect<TriggerState>,
): GeneralRole => ({ context: { generalId: GeneralId; cityId?: CityId; nationId?: NationId }
...base, ): void => {
...patch, 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: { items: {
...base.items, ...generalDraft.role.items,
...(patch.items ?? {}), ...(effect.patch.role.items ?? {}),
}, },
}); };
}
const mergeTriggerState = <TriggerState extends GeneralTriggerState>( if (effect.patch.triggerState) {
base: TriggerState, generalDraft.triggerState = {
patch: Partial<TriggerState> ...generalDraft.triggerState,
): TriggerState => ({ ...effect.patch.triggerState,
...base, flags: {
...patch, ...generalDraft.triggerState.flags,
flags: { ...base.flags, ...(patch.flags ?? {}) }, ...(effect.patch.triggerState.flags ?? {}),
counters: { ...base.counters, ...(patch.counters ?? {}) }, },
modifiers: { ...base.modifiers, ...(patch.modifiers ?? {}) }, counters: {
meta: { ...base.meta, ...(patch.meta ?? {}) }, ...generalDraft.triggerState.counters,
}); ...(effect.patch.triggerState.counters ?? {}),
},
const applyGeneralPatch = <TriggerState extends GeneralTriggerState>( modifiers: {
base: General<TriggerState>, ...generalDraft.triggerState.modifiers,
patch: Partial<General<TriggerState>> ...(effect.patch.triggerState.modifiers ?? {}),
): General<TriggerState> => ({ },
...base, meta: {
...patch, ...generalDraft.triggerState.meta,
stats: patch.stats ? mergeStats(base.stats, patch.stats) : base.stats, ...(effect.patch.triggerState.meta ?? {}),
role: patch.role ? mergeRole(base.role, patch.role) : base.role, },
triggerState: patch.triggerState };
? mergeTriggerState(base.triggerState, patch.triggerState) }
: base.triggerState, if (effect.patch.meta) {
meta: patch.meta ? { ...base.meta, ...patch.meta } : base.meta, generalDraft.meta = {
}); ...generalDraft.meta,
...effect.patch.meta,
const applyCityPatch = (base: City, patch: Partial<City>): City => ({ };
...base, }
...patch, }
meta: patch.meta ? { ...base.meta, ...patch.meta } : base.meta, break;
}); case 'city:patch':
if (
const applyNationPatch = (base: Nation, patch: Partial<Nation>): Nation => ({ draft.city &&
...base, (effect.targetId === undefined ||
...patch, effect.targetId === context.cityId)
meta: patch.meta ? { ...base.meta, ...patch.meta } : base.meta, ) {
}); 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
@@ -254,11 +307,7 @@ export const resolveGeneralAction = <
scheduleContext: TurnScheduleContext, scheduleContext: TurnScheduleContext,
args: Args args: Args
): GeneralActionResolution => { ): GeneralActionResolution => {
const outcome = resolver.resolve(context, args);
const logs: LogEntryDraft[] = []; const logs: LogEntryDraft[] = [];
let nextGeneral = context.general;
let nextCity = context.city;
let nextNation = context.nation ?? null;
let nextTurnAtOverride: Date | null = null; let nextTurnAtOverride: Date | null = null;
const createdGenerals: General[] = []; const createdGenerals: General[] = [];
const patches: NonNullable<GeneralActionResolution['patches']> = { const patches: NonNullable<GeneralActionResolution['patches']> = {
@@ -266,70 +315,26 @@ export const resolveGeneralAction = <
cities: [], cities: [],
nations: [], nations: [],
}; };
const dirty: NonNullable<GeneralActionResolution['dirty']> = {
general: false, const [nextWorld, worldPatches] = produceWithPatches(
city: false, {
nation: false, general: context.general,
generalId: context.general.id, city: context.city,
}; nation: context.nation,
if (context.city) { } as WorldState<TriggerState>,
dirty.cityId = context.city.id; (draft) => {
} const outcome = resolver.resolve(
if (context.nation) { {
dirty.nationId = context.nation.id; ...context,
} general: castDraft(draft.general),
city: castDraft(draft.city),
nation: castDraft(draft.nation),
} as GeneralActionResolveContext<TriggerState>,
args
);
for (const effect of outcome.effects) { for (const effect of outcome.effects) {
switch (effect.type) { 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': case 'log':
// 로그 대상이 비어 있으면 현재 장수/국가 기준으로 보정한다. // 로그 대상이 비어 있으면 현재 장수/국가 기준으로 보정한다.
switch (effect.entry.scope) { switch (effect.entry.scope) {
@@ -337,7 +342,8 @@ export const resolveGeneralAction = <
logs.push({ logs.push({
...effect.entry, ...effect.entry,
generalId: generalId:
effect.entry.generalId ?? context.general.id, effect.entry.generalId ??
context.general.id,
}); });
break; break;
case LogScope.NATION: case LogScope.NATION:
@@ -366,24 +372,85 @@ export const resolveGeneralAction = <
case 'schedule:override': case 'schedule:override':
nextTurnAtOverride = effect.nextTurnAt; nextTurnAtOverride = effect.nextTurnAt;
break; break;
default: 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; break;
} }
} }
}
);
const nextTurnAt = const nextTurnAt =
nextTurnAtOverride ?? nextTurnAtOverride ??
getNextTurnAt(scheduleContext.now, scheduleContext.schedule); 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;
// 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 resolution: GeneralActionResolution = { const resolution: GeneralActionResolution = {
general: nextGeneral, general: nextWorld.general as General,
nation: nextNation, nation: nextWorld.nation as Nation | null,
nextTurnAt, nextTurnAt,
logs, logs,
effects: outcome.effects, effects: [], // 이제 effects는 직접 사용되지 않음 (이미 반영됨)
}; };
if (nextCity) { if (nextWorld.city) {
resolution.city = nextCity; resolution.city = nextWorld.city as City;
} }
if (dirty.general || dirty.city || dirty.nation) { if (dirty.general || dirty.city || dirty.nation) {
resolution.dirty = dirty; resolution.dirty = dirty;
+11
View File
@@ -60,6 +60,9 @@ importers:
app/game-engine: app/game-engine:
dependencies: 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': '@sammo-ts/common':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/common version: link:../../packages/common
@@ -176,6 +179,9 @@ importers:
'@sammo-ts/common': '@sammo-ts/common':
specifier: workspace:* specifier: workspace:*
version: link:../common version: link:../common
immer:
specifier: ^11.1.3
version: 11.1.3
zod: zod:
specifier: ^4.2.1 specifier: ^4.2.1
version: 4.2.1 version: 4.2.1
@@ -1278,6 +1284,9 @@ packages:
resolution: {integrity: sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==} resolution: {integrity: sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
immer@11.1.3:
resolution: {integrity: sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==}
import-without-cache@0.2.5: import-without-cache@0.2.5:
resolution: {integrity: sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A==} resolution: {integrity: sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A==}
engines: {node: '>=20.19.0'} engines: {node: '>=20.19.0'}
@@ -3066,6 +3075,8 @@ snapshots:
dependencies: dependencies:
safer-buffer: 2.1.2 safer-buffer: 2.1.2
immer@11.1.3: {}
import-without-cache@0.2.5: {} import-without-cache@0.2.5: {}
ini@1.3.8: {} ini@1.3.8: {}