kikuta@dev:~$cat ./notes/explicit-fusion-over-auto-merge.md
·9 min·kikuta

重複キャラを自動マージしない個体管理と合成トランザクション

同種個体を別instanceとして保持し、battle・reserve・dispatchの排他状態とFusion Core変換をdomain commandで保証する設計。

typeとinstanceを分ける

Slime Mercenariesでは、同じSword Slimeを複数入手しても一つのtype countへ畳みません。SlimeProgressはid、serial、typeId、level、jobTier、fusionRank、fusionFormId、mutationId、assignmentを持ち、roster.slimesはSlimeInstanceIdをkeyにしたRecordとして保持します。

同種個体をinstanceとして残す理由は、levelやfusion状態だけでなくassignmentが個体ごとに異なるからです。一匹をbattle、別の一匹をdispatchへ送る場合、type単位の所持数だけでは整合性を表現できません。

assignmentをbattle / reserve / dispatchの3値に固定する

SlimeAssignmentはbattle、reserve、dispatchの3値です。formationSlotsに入っている個体はassignmentもbattleでなければならず、dispatch contractに紐づく個体はdispatchでなければなりません。state-invariants.tsでこの対応を検査しています。

startDispatchはreserve以外をnot-reserveでrejectします。戦闘中の個体を同時に派遣へ出すような二重所属をUIのdisable状態だけに任せず、domain command側で拒否します。formationから外した個体はreserveへ戻し、dispatch完了後もreserveへ戻します。

重複個体の素材化は明示commandにする

Fusion用Coreは重複入手時に自動生成しません。convertDuplicateToFusionCore(state, slimeId)へ変換対象のinstance IDを明示的に渡します。commandはnot-owned、not-reserve、last-of-typeをfailure reasonとして持ち、battle/dispatch中の個体や最後の一匹を素材化できないようにしています。

変換成功時だけ対象instanceをrosterから外し、そのtypeが持つfusionCoreTokenIdへtokenを1個grantします。「個体を削除する」と「Coreを増やす」を一つのdomain transitionとして扱うため、途中状態をUIへ露出しません。

Fusionは新instance生成ではなく既存instance更新

fuseSlimeは対象slimeIdを維持したままfusionRank、fusionFormId、jobTierを更新します。levelやassignmentなど、Fusion対象外の属性はspreadで保持されます。これにより、成長済み個体を別entityへ置き換えるためのID引き継ぎ処理が不要になります。

実行前にはpreviewSlimeFusionsで候補を列挙し、unlock area、minimum level、recipe tokenの不足数からcanFuseを算出します。branchが複数あるrankではfusionStepIdを省略するとfusion-choice-requiredでrejectし、暗黙のbranch選択をしません。

recipe消費も事前検証してから一括適用する

Fusion recipeはTokenRequirementの配列です。fuseSlimeでは先にpreviewで全requirementを検証し、canFuseがtrueになった後でspendRequirementsを呼びます。spendRequirements側で不足が発生した場合はcommand bugとして例外にし、通常のcontrol flowとして部分消費を許しません。

この形にすると、UI previewと実際のcommandが同じrequirement定義を参照できます。表示では材料が足りているのに実行時だけ不足する、あるいは一部だけ消費されて失敗する、といった不整合を減らせます。