開発Tips

協力育成の競合・サイクル跨ぎで日数が壊れたバグを直す

「ぽんっ!」(旧称:でいりんぐ)の育成キャラは、こどもがスタンプを貯めると育ちます。ここに 「連携しているおとなも 1 日 1 回えいようをあげられる協力育成」 を足したところ、育成日数の計算が壊れるバグが出ました。並行性と日付計算が絡む、いかにも壊れやすい箇所だったので残します。

背景(当時の状況)

「おとなも 1 日 1 回えいようをあげられる協力育成」を足した直後、リリース前の QA 監査(2026-07-11〜12)で日数計算が壊れる実バグを見つけました。攻撃者視点・ベテラン視点・がむしゃら QA という複数の観点で、あえて過去日やサイクル境界を突いたところ、狙いどおり弱いところが出てきた形です。

症状: 日数が負になる・卵の基準日が後退する

  • 過去日のレコードが絡むと、育成にかかった日数(daysTaken)が負の値になることがある。
  • サイクルを跨ぐと、卵の基準日(いつ産まれたか)が後退してしまう。
  • おとなの給餌とこどもの完了処理が近いタイミングで走ると、二重適用が起きる。

原因①: 基準日の取り方が甘かった

日数計算の基準に「今日」だけを使っていると、過去日のレコードやサイクル跨ぎで基準が前後します。完成日を求めるときに、複数の候補のうち最新の日付を採るように変えました。

// 基準日は「今日・サイクル開始・最終給餌・おとなの最終給餌」の最大を採る
// 日付はエポック日(int)で保持し、最大値を選ぶ
final effectiveDay = [today, cycleStart, lastFed, lastAdultFed]
    .reduce((a, b) => a > b ? a : b);

こうすると、過去日のレコードが混ざっても基準が後退せず、日数が負になりません。

原因②: 更新系が並行に走って二重適用される

こどもの「全部できた」処理(onAllTasksDone)と、おとなの給餌反映(applyAdultFeeds)が並行に走ると、同じ状態を二重に更新してしまいます。ここは更新系を Future チェーンで直列化し、順番に適用されることを保証しました。

  • 変更系の処理を逐次実行にして、同時に走らないようにする。
  • あわせて、破棄後の状態更新を防ぐ ref.mounted ガードと、給餌ボタンの連打ガードも追加。

教訓: 「日付」と「並行更新」は最初から疑う

  • 日付は「今日」だけを基準にしない。 過去レコード・サイクル・複数の給餌者が絡むと、基準日は簡単に前後する。候補の max を採るのが堅牢でした。
  • 複数の主体が同じ状態を更新する機能(こども+おとなの協力育成)は、更新系を直列化しないと二重適用が起きる。設計時点で「同時に走ったらどうなるか」を必ず考える。
  • こうした競合は低頻度でしか再現しないため、リリース前の攻撃者視点・QA 視点のレビューであえて過去日やサイクル境界を突くと見つかりやすいです。

まとめ

協力育成のように複数の人が同じ対象を更新する機能は、日付基準の取り方と更新の直列化を最初から設計に織り込むべきでした。「今日を基準にすれば十分」という素朴な実装が、過去日やサイクル跨ぎで崩れることを、リリース前監査で潰せたのが幸いでした。