放置RPGの敗北・撤退・自動再挑戦を状態機械として実装する
currentStage・highestStageCleared・retryFarmClearsRemainingを分離し、live tickとoffline simulationで同じ敗北ループを成立させる実装。
進行状態をcurrentStageだけで表さない
敗北で一つ前のステージへ戻るゲームでは、currentStageと最高到達点を同じ値で管理すると状態が破綻します。Slime Mercenariesでは進行側にcurrentAreaId / currentStage、エリアごとにhighestStageClearedを持ち、現在の周回位置と恒久的な到達実績を分離しています。撤退ではcurrentStageだけを戻し、highestStageClearedは巻き戻しません。
戦闘側にはcurrentWaveIndex、waveWorkRemaining、retryFarmClearsRemaining、frontierDefeatTimeRemainingSecを持たせています。特にretryFarmClearsRemainingは「撤退後、何回farm stageをclearしたらfrontierへ戻るか」という状態そのもので、UIのタイマーや一時的なフラグではありません。
勝敗判定を描画から分離する
各StageDefinitionは必要に応じてrequiredPartyPowerを持ち、boss側も独立したrequiredPartyPowerを持てます。domainではpartyCombatPower(state)とrequiredPartyPowerを比較してfrontier敗北を決めます。キャラクターのアニメーション、projectile、HP表示はこの判定のauthorityにしません。
この分離により、画面を描画していないoffline simulationでも同じ勝敗判定を使えます。live battleだけ別ロジックにすると、アプリを閉じた瞬間に進行結果が変わるため、combat.ts側のanalytical modelを唯一の進行authorityにしています。
敗北そのものにも仮想時間を持たせる
戦力不足を検出した瞬間に即撤退させるのではなく、frontierDefeatTimeRemainingSecへ短い敗北時間を保持します。nextCombatBoundarySecも、この値がある場合は「敗北が確定する次の仮想時刻」を返します。これにより、live tickを細かく刻んだ場合とofflineで一気に時間を進めた場合の結果を一致させやすくなります。
さらに、敗北countdown中に強化されてpartyCombatPowerがrequiredPartyPower以上になった場合は、残っていたfrontierDefeatTimeRemainingSecとwaveWorkRemainingを破棄してencounterをクリーンに再開します。敗北用countdownを通常の戦闘workとして誤解釈しないための処理です。
offlineでは未突破frontierの初回clearを抑制する
advanceCombatToにはallowFrontierFirstClear policyを渡せるようにしています。offline resumeでは既存ステージのfarmingは進めても、未突破のmajor frontierを自動で初clearしない構成にできます。
これは演出上の都合だけではありません。offline処理は長い時間を一括適用するため、first clear、area unlock、reward unlockが一度に複数発生しやすくなります。初回突破だけactive sessionへ戻すことで、恒久progressionの境界を明示できます。
テストは状態遷移をevent列まで確認する
domain testでは通常stageのpower不足、boss frontierのpower不足、farm clearによるretryFarmClearsRemaining減算、再挑戦でも弱い場合の再撤退、farm中の強化で突破できる場合を個別に検証しています。simulator testではdefeat数とretreat数が一致することも確認しています。
presentation側もstageRetreatedと、farming=trueのstageClearedを分けています。domain上の撤退を単なるcurrentStage書き換えで終わらせずsemantic eventとして外へ出すことで、UIが「前進clear」と「撤退中の周回clear」を誤認しない構成にしています。