重複キャラを自動マージしない個体管理と合成トランザクション
同種個体を別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定義を参照できます。表示では材料が足りているのに実行時だけ不足する、あるいは一部だけ消費されて失敗する、といった不整合を減らせます。