From 18247eeff4aee4d01eeb52fef0f2775d0ad7b2e3 Mon Sep 17 00:00:00 2001 From: Hide_D Date: Fri, 2 Jan 2026 09:38:05 +0000 Subject: [PATCH] feat: add immer dependency and integrate draft handling in engine actions --- packages/logic/package.json | 1 + packages/logic/src/actions/engine.ts | 399 ++++++++++++++++----------- pnpm-lock.yaml | 11 + 3 files changed, 245 insertions(+), 166 deletions(-) diff --git a/packages/logic/package.json b/packages/logic/package.json index 5f145e1..0d66a13 100644 --- a/packages/logic/package.json +++ b/packages/logic/package.json @@ -20,6 +20,7 @@ }, "dependencies": { "@sammo-ts/common": "workspace:*", + "immer": "^11.1.3", "zod": "^4.2.1" }, "devDependencies": { diff --git a/packages/logic/src/actions/engine.ts b/packages/logic/src/actions/engine.ts index 1860405..ed7e13a 100644 --- a/packages/logic/src/actions/engine.ts +++ b/packages/logic/src/actions/engine.ts @@ -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; + city?: City; + nation?: Nation | null; +} + export interface GeneralActionResolveContext< TriggerState extends GeneralTriggerState = GeneralTriggerState > extends GeneralActionContext { @@ -121,61 +130,105 @@ export interface GeneralActionResolution { }; } -const mergeStats = (base: StatBlock, patch: Partial): StatBlock => ({ - leadership: patch.leadership ?? base.leadership, - strength: patch.strength ?? base.strength, - intelligence: patch.intelligence ?? base.intelligence, -}); - -const mergeRole = ( - base: GeneralRole, - patch: Partial -): GeneralRole => ({ - ...base, - ...patch, - items: { - ...base.items, - ...(patch.items ?? {}), - }, -}); - -const mergeTriggerState = ( - base: TriggerState, - patch: Partial -): 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 = ( - base: General, - patch: Partial> -): General => ({ - ...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 => ({ - ...base, - ...patch, - meta: patch.meta ? { ...base.meta, ...patch.meta } : base.meta, -}); - -const applyNationPatch = (base: Nation, patch: Partial): 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>, + effect: GeneralActionEffect, + 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 = { @@ -266,124 +315,142 @@ export const resolveGeneralAction = < cities: [], nations: [], }; + + const [nextWorld, worldPatches] = produceWithPatches( + { + general: context.general, + city: context.city, + nation: context.nation, + } as WorldState, + (draft) => { + const outcome = resolver.resolve( + { + ...context, + general: castDraft(draft.general), + city: castDraft(draft.city), + nation: castDraft(draft.nation), + } as GeneralActionResolveContext, + 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, + }); + } 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 = { 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, - }); - } - 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; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ac05c8..3feb5ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {}