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": {
+233 -166
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;
items: { switch (effect.type) {
...base.items, case 'general:patch':
...(patch.items ?? {}), if (
}, effect.targetId === undefined ||
}); effect.targetId === context.generalId
) {
const mergeTriggerState = <TriggerState extends GeneralTriggerState>( Object.assign(generalDraft, effect.patch);
base: TriggerState, if (effect.patch.stats) {
patch: Partial<TriggerState> generalDraft.stats = {
): TriggerState => ({ ...generalDraft.stats,
...base, ...effect.patch.stats,
...patch, };
flags: { ...base.flags, ...(patch.flags ?? {}) }, }
counters: { ...base.counters, ...(patch.counters ?? {}) }, if (effect.patch.role) {
modifiers: { ...base.modifiers, ...(patch.modifiers ?? {}) }, generalDraft.role = {
meta: { ...base.meta, ...(patch.meta ?? {}) }, ...generalDraft.role,
}); ...effect.patch.role,
items: {
const applyGeneralPatch = <TriggerState extends GeneralTriggerState>( ...generalDraft.role.items,
base: General<TriggerState>, ...(effect.patch.role.items ?? {}),
patch: Partial<General<TriggerState>> },
): General<TriggerState> => ({ };
...base, }
...patch, if (effect.patch.triggerState) {
stats: patch.stats ? mergeStats(base.stats, patch.stats) : base.stats, generalDraft.triggerState = {
role: patch.role ? mergeRole(base.role, patch.role) : base.role, ...generalDraft.triggerState,
triggerState: patch.triggerState ...effect.patch.triggerState,
? mergeTriggerState(base.triggerState, patch.triggerState) flags: {
: base.triggerState, ...generalDraft.triggerState.flags,
meta: patch.meta ? { ...base.meta, ...patch.meta } : base.meta, ...(effect.patch.triggerState.flags ?? {}),
}); },
counters: {
const applyCityPatch = (base: City, patch: Partial<City>): City => ({ ...generalDraft.triggerState.counters,
...base, ...(effect.patch.triggerState.counters ?? {}),
...patch, },
meta: patch.meta ? { ...base.meta, ...patch.meta } : base.meta, modifiers: {
}); ...generalDraft.triggerState.modifiers,
...(effect.patch.triggerState.modifiers ?? {}),
const applyNationPatch = (base: Nation, patch: Partial<Nation>): Nation => ({ },
...base, meta: {
...patch, ...generalDraft.triggerState.meta,
meta: patch.meta ? { ...base.meta, ...patch.meta } : base.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
@@ -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,124 +315,142 @@ export const resolveGeneralAction = <
cities: [], cities: [],
nations: [], 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']> = { const dirty: NonNullable<GeneralActionResolution['dirty']> = {
general: false, general: false,
city: false, city: false,
nation: false, nation: false,
generalId: context.general.id, generalId: context.general.id,
}; };
if (context.city) { if (context.city) dirty.cityId = context.city.id;
dirty.cityId = context.city.id; if (context.nation) dirty.nationId = context.nation.id;
}
if (context.nation) {
dirty.nationId = context.nation.id;
}
for (const effect of outcome.effects) { // worldPatches를 분석하여 dirty 설정
switch (effect.type) { for (const patch of worldPatches) {
case 'general:patch': if (patch.path[0] === 'general') dirty.general = true;
if ( if (patch.path[0] === 'city') dirty.city = true;
effect.targetId === undefined || if (patch.path[0] === 'nation') dirty.nation = true;
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;
}
} }
const nextTurnAt =
nextTurnAtOverride ??
getNextTurnAt(scheduleContext.now, scheduleContext.schedule);
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: {}