MOE3: 玉突き衝突処理のリファクタリング

MoveOrder#resolve_pileup メソッド修正

やっぱり無限ループは怖いのでやっつけ修正。

失敗が確定した MoveOrder@orders.select{|o| o.move?} から外れるので、全ての MoveOrder の処理が完了するか、処理しきれない分が確定するまでループで回す。

class MainPhase < Phase
  #(略)

  # 玉突き衝突
  def resolve_pileup
    last_moves_size = 0
    loop do
      moves = @orders.select{|o| o.move?}
      break if moves.size == 0
      break if moves.size == last_moves_size
      last_moves_size = moves.size

      moves.collect{|m| m.dst}.uniq.each do |dst|
        hold = @orders.select{|o| o.hold? && o.province == dst}[0]
        next unless hold

        moves.select{|m| m.dst == dst}.each do |m1|
          m1.failure!
        end
      end
    end
  end

  #(略)
end

うーん、なんか気に入らない。もっと綺麗に書けるはず。

MainPhase#resolve_piliup メソッド再修正

再帰で書き直してみた。

class MainPhase < Phase
  #(略)

  # 玉突き衝突
  def resolve_pileup(last_moves_size = 0)
    moves = @orders.select{|o| o.move?}
    return if moves.size == 0
    return if moves.size == last_moves_size

    moves.each do |move|
      hold = @orders.select{|o| o.hold? && o.province == move.dst}[0]
      move.failure! if hold
    end
    resolve_pileup(moves.size)
  end

  #(略)
end

一般的に、ループの方が再帰よりもリソースや速度面で有利と言われている。

それでも最大 34 回程度のループなら誤差の範囲だろうし、ソースの読みやすさで言えばやはり再帰の方が上なのでこれで良しとする。

MOE3: 交換移動の解決

spec/models/main_phase_spec.rb

テスト。

マニュアルの Diagram6、German F Ber–PruGerman A Pru–Ber の交換移動禁止処理を実装する。

f:id:asagix:20130912114126p:plain:w200

describe MainPhase do
  describe "#resolve_orders" do
    let(:phase) { MainPhase.create }
    let(:resolved_orders) { phase.resolve_orders }

    #(略)

    shared_context "Diagram6 交換", diagram: 6 do
      let(:fleet_ber) { FactoryGirl.create(:fleet, :g, :ber, phase: phase) }
      let(:army_pru) { FactoryGirl.create(:army, :g, :pru, phase: phase) }

      let(:prov_ber) { fleet_ber.province }
      let(:prov_pru) { army_pru.province }

      let!(:move_ber_pru) { FactoryGirl.create(:move_order, unit: fleet_ber, destination: prov_pru) }
      let!(:move_pru_ber) { FactoryGirl.create(:move_order, unit: army_pru, destination: prov_ber) }
    end

    context "Diagram6", diagram: 6 do
      subject { resolved_orders }
      example { expect(subject.find(move_ber_pru).status).to eq OrderStatus::FAILURE }
      example { expect(subject.find(move_pru_ber).status).to eq OrderStatus::FAILURE }
    end
  end
end

MainPhase#resolve_exchange メソッド

まずは効率度外視で思いついたまま、テストを通過するためだけの処理を書いてみた。

class MainPhase < Phase
  #(略)

  # 交換
  def resolve_exchange
    moves = @orders.select{|o| o.move?}
    moves.collect{|m| m.dst}.uniq.each do |dst|
      move1 = moves.select{|m| m.dst == dst && m.unexecuted?}[0]
      next unless move1
      move2 = moves.select{|m| m.src == move1.dst && m.dst == move1.src}[0]
      next unless move2

      move1.failure!
      move2.failure!
    end
  end

  #(略)
end

ここでリファクタリングを実施しても良いが、少し思うところがあるので今はこのままにしておく。

MOE3: 玉突き衝突の解決

spec/models/main_phase_spec.rb

テストを書く。

マニュアルの Diagram5、German A Ber–PruGerman A Kie–Ber の移動失敗の連鎖処理を実装する。

f:id:asagix:20130912114125p:plain:w200

describe MainPhase do
  describe "#resolve_orders" do
    let(:phase) { MainPhase.create }
    let(:resolved_orders) { phase.resolve_orders }

    #(略)

    shared_context "Diagram5 玉突き衝突", diagram: 5 do
      let(:army_pru) { FactoryGirl.create(:army, :r, :pru, phase: phase) }
      let(:army_ber) { FactoryGirl.create(:army, :g, :ber, phase: phase) }
      let(:army_kie) { FactoryGirl.create(:army, :g, :kie, phase: phase) }
      let(:prov_pru) { army_pru.province }
      let(:prov_ber) { army_ber.province }

      let!(:hold_pru) { FactoryGirl.create(:hold_order, unit: army_pru) }
      let!(:move_ber_pru) { FactoryGirl.create(:move_order, unit: army_ber, destination: prov_pru) }
      let!(:move_kie_ber) { FactoryGirl.create(:move_order, unit: army_kie, destination: prov_ber) }
    end

    context "Diagram5", diagram: 5 do
      subject { resolved_orders }
      example { expect(subject.find(move_ber_pru).status).to eq OrderStatus::FAILURE }
      example { expect(subject.find(move_kie_ber).status).to eq OrderStatus::FAILURE }
    end
  end
end

MainPhase#resolve_pileup メソッド

メソッド名が適当になってきた。

MainPhase#resolve_pileup の仕様

  • 移動先が塞がってたら移動失敗。
  • 移動先に未解決の移動命令があったら処理保留。
  • 玉突き衝突解決のためにループ処理。

まず MoveOrder#hold? に手を入れる。

class MoveOrder < Order
  #(略)

  def hold?
    # 失敗した移動命令は維持と同様に扱う
    return true if failure?
    return true if standoff?
    false
  end

  #(略)
end

MainPhase#resolve_move もちょっと修正。

MainPhase#resolve_move の仕様

  • 移動先に維持命令を受けた軍があったら処理保留。
  • 移動先が競合する移動命令があったら処理保留。
  • 移動先に未解決の移動命令があったら処理保留。 ←New!
  • 残りは移動成功。

現時点では未処理命令に交換移動セット*1や循環移動セット*2があると、resolve_pileup で無限ループに突入してしまうので注意。

交換移動は Diagram6、循環移動は Diagram7 で実装する。

class MainPhase < Phase
  def resolve_orders
    setup
    resolve_move
    resolve_standoff
    resolve_pileup 
    cleanup
    orders(true)
  end

  #(略)

  # 障害のない移動
  def resolve_move
    moves = @orders.select{|o| o.move? && o.unexecuted?}
    moves.collect{|m| m.dst}.uniq.each do |dst|
      hold = @orders.select{|o| o.hold? && o.province == dst}[0]
      next if hold

      move = @orders.select{|o| o.move? && o.src == dst}[0]
      next if move

      move, *conflicts = moves.select{|m| m.dst == dst}
      next if conflicts.size != 0

      move.success!
    end
  end

  #(略)

  # 玉突き衝突
  def resolve_pileup 
    loop do
      moves = @orders.select{|o| o.move?}
      break if moves.size == 0

      moves.collect{|m| m.dst}.uniq.each do |dst|
        hold = @orders.select{|o| o.hold? && o.province == dst}[0]
        next unless hold

        moves.select{|m| m.dst == dst}.each do |m1|
          m1.failure!
        end
      end
    end
  end

  #(略)
end

ついでに Order#failure! も実装。

class Order <  ActiveRecord::Base
  #(略)

  def failure!
    self.status = OrderStatus::FAILURE
  end

  #(略)
end

メソッド名は Order#fail! の方が良さげかもしれない。後で考える。

*1:A→B、B→A みたいなやつ。

*2:A→B、B→C、C→A みたいなやつ。

MOE3: スタンドオフの解決

spec/models/main_phase_spec.rb

まずテストを書く。

マニュアルの Diagram4、German A Ber–SilRussian A War–Sil のスタンドオフ処理を実装する。

f:id:asagix:20130910224007p:plain:w200

ちなみに Diagram2 は 1 と同じ単純移動(ただし海軍)なので省略。

f:id:asagix:20130910224005p:plain:w200

Diagram3 は海軍にとっての非隣接地域への移動不可に関する事例ということで、MainPhase#resolve_orders の守備範囲外(不可能命令は登録する段階で弾く予定)なので省略。

f:id:asagix:20130910224006p:plain:w200

describe MainPhase do
  describe "#resolve_orders" do
    let(:phase) { MainPhase.create }
    let(:resolved_orders) { phase.resolve_orders }

    #(略)

    shared_context "Diagram4 スタンドオフ", diagram: 4 do
      let(:army_ber) { FactoryGirl.create(:army, :g, :ber, phase: phase) }
      let(:army_war) { FactoryGirl.create(:army, :r, :war, phase: phase) }
      let(:prov_sil) { Province.find_by(code: "sil") }

      let!(:move_ber_sil) { FactoryGirl.create(:move_order, unit: army_ber, destination: prov_sil) }
      let!(:move_war_sil) { FactoryGirl.create(:move_order, unit: army_war, destination: prov_sil) }
    end

    context "Diagram4", diagram: 4 do
      subject { resolved_orders }
      example { expect(subject.find(move_ber_sil).status).to eq OrderStatus::STANDOFF }
      example { expect(subject.find(move_war_sil).status).to eq OrderStatus::STANDOFF }
    end
  end
end

MainPhase#resolve_standoff メソッド

ここでも戦力評価の実装は後回し。

  • 未処理の MoveOrder オブジェクトを全て取得。
  • 移動先一つ一つについて競合する移動命令があるかチェック。
  • 移動先が競合した場合は問答無用でスタンドオフにする。

なんやかんやでこの形。

class MainPhase < Phase
  def resolve_orders
    setup
    resolve_move
    resolve_standoff
    cleanup
    orders(true)
  end

  #(略)

  # スタンドオフ
  def resolve_standoff
    moves = @orders.select{|o| o.move?}
    moves.collect{|m| m.dst}.uniq.each do |dst|
      conflicts = moves.select{|m| m.dst == dst}
      next if conflicts.size == 1

      conflicts.each do |m1|
        m1.standoff!
      end
    end
  end

  #(略)
end

MoveOrder#move? は成否を問わず処理済みなら false を返すようにしてみた。

class MoveOrder < Order
  #(略)

  def move?
    # 処理済の移動命令は移動命令として扱わない
    return true if unexecuted?
    false
  end

  def standoff!
    self.status = OrderStatus::STANDOFF
  end
end

MOE3: 単純移動命令の解決

MainPhase#resolve_move メソッド

先にテストを書く。

resolve_move 自体は private メソッドの位置付けで*1、実装が進むと名前や扱いが変わる可能性がある、というかその余地を縛りたくないので、テストはあくまで MainPhase#resolve_orders を対象としたものになる。

spec/main_phase_spec.rb

マニュアルの Diagram1 から French A Par-Bur を実装する。

f:id:asagix:20130910224004p:plain:w200

describe MainPhase do
  describe "#resolve_orders" do
    let(:phase) { MainPhase.create }
    let(:resolved_orders) { phase.resolve_orders }

    shared_context "単純移動 Diagram1", diagram: 1 do
      let(:army_par) { FactoryGirl.create(:army, :f, :par, phase: phase) }
      let(:prov_bur) { Province.where(code: "bur").first }

      let!(:move_par_bur) { FactoryGirl.create(:move_order, unit: army_par, destination: prov_bur) }
    end

    context "Diagram1", diagram: 1 do
      subject { resolved_orders }
      example { expect(subject.find(move_par_bur).status).to eq OrderStatus::SUCCESS }
    end
  end
end

MainPhase#resolve_orders 実行後、命令ステータスが OrderStatus::SUCCESS になれば良い。

MainPhase#resolve_orders

では実装しよう。

事前に実験したところ ActiveRecord の遅延評価とキャッシュが邪魔になりそうだったので*2、予めクエリ結果を配列として取得しておき、全てをオンメモリで処理した後に一括で save! することにした。

class MainPhase < Phase
  def resolve_orders
    setup
    resolve_move
    cleanup

    orders(true)
  end

  def setup
    @orders = orders.where(sample: false).to_a
  end

  def resolve_move
    moves = @orders.select{|o| o.move? && o.unexecuted?}
    moves.collect{|m| m.dst}.uniq.each do |dst|
      holds = @orders.select{|o| o.hold? && o.province == dst}
      return if holds.size != 0

      move, *conflicts = moves.select{|m| m.dst == dst}
      return if conflicts.size != 0

      move.success!
    end
  end

  def cleanup
    @orders.each do |order|
      order.save!
    end
  end
end

setup

事前準備として、orders のクエリに対して「標本命令以外」を指定して取得した結果を配列化している。

いずれ「仮想命令以外」も条件として追加するが、こちらはクエリ条件ではなく、配列化後に Array#select で絞り込むのが楽なはずだ。

そのためには、命令者の担当国とユニットの所属国が異なる場合に true を返してくれる Order#assumed? メソッドが欲しいので Player モデルの実装までお預け。

resolve_move

戦力の実装はもう少し先なので、ここでは戦力評価抜きで確定する移動命令を確定させてしまう。

MainPhase#resolve_move の仕様

  • 移動先に維持命令を受けた軍があったら処理保留。
  • 移動先が競合する移動命令があったら処理保留。
  • 残りは移動成功。

Order#hold?Order#move?Order#unexecuted?Order#success! は楽をするために導入。hold?move? は派生クラスで適宜オーバーライドする。

MoveOrder#hold? は基本的に false を返すが、移動失敗が確定していたら true を返すと便利になるような気がしないでもない。

cleanup

この時点ですべての命令に対する処理が完了しているので一つずつ DB に保存する。

*1:明示的に宣言するのは必要に迫られてからで良いのだ。

*2:頻繁なステータスの書き換えが発生する行軍解決処理とはすこぶる相性が悪かった。

MOE3: 行軍解決処理の基本デザイン

MainPhase#resolve_orders の基本形

setupresolve_movecleanup の順に処理し、最後の orders(true) では引数の true 指定でキャッシュを破棄したクエリを返す。

class MainPhase < Phase
  def resolve_orders
    setup
    resolve_move
    cleanup

    orders(true)
  end

  def setup
    @orders = orders.to_a
  end

  def reslove_move
    # これから実装
  end

  def cleanup
    @orders.each do |order|
      order.save!
    end
  end
end

とりあえず単純移動命令処理の resolve_move から実装し、スタンドオフ、玉突き衝突といった処理区分を resolve_move の前後に追加していく。

MOE3: 行軍解決処理

MainPhase#resolve_orders メソッド

いよいよ、肝心要の行軍解決処理、MainPhase#resolve_orders に着手する。

撤退フェイズの RetreatPhase#resolve_orders、調整フェイズの AdjustPhase#resolve_orders は当分後回しで。

行軍解決処理の仕様

  • フェイズに登録された命令の成否判定のみを行う
  • あり得ない命令(海軍の内陸侵入やワープなど)の整合性検証は行わない
  • ユニットの配置変更処理は行わない

このメソッドは今からそれなりに複雑になることが分かっているので、責任範囲をなるべく限定する。

現時点では地域隣接情報を作っていないので、いずれにせよ不正移動命令や遠隔地への異次元支援を検出できないのだが、ここではあくまで命令単体では正常であることを前提として処理する。

もちろん、支援や輸送の空振りは普通にあることなので考慮する。

has_many 設定

まず、Phase から Unit を経由して Order に直接アクセスできるように has_many を設定しておく。

class Unit < ActiveRecord::Base
  #(略)

  has_many :orders

  #(略)
end
class Phase < ActiveRecord::Base
  belongs_to :turn

  has_many :units
  has_many :orders, through: :units
end

これで MainPhase#resolve_orders 内から orders メソッドで関係する全ての命令を取得できる。