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 メソッドで関係する全ての命令を取得できる。

MOE3: Phase モデル検討

Phase と派生モデル

f:id:asagix:20130830122728p:plain:w200

例によって単一テーブル継承。

派生クラスまで見ても属性は Turn への参照だけのはず。当面は。

Phase モデルの属性

  • turn_id: 「どの卓の何ターン目か」を特定するための参照。
  • type: 単一テーブル継承用の属性。

Phase(フェイズ)モデル

全てのフェイズの基底クラスとなり、Phase インスタンスが生成されることはない。

class Phase < ActiveRecord::Base
  belongs_to :turn
end

MainPhase(メインフェイズ)モデル

本来のルールでの外交フェイズ、命令記入フェイズ、命令解決フェイズを指す。

MOE ではチャット形式で外交をしながら同時に命令記入を行い、命令解決は全自動で処理されるので、システム的にはこれらをメインフェイズとしてまとめてしまう。

現時点の構想では、行軍解決処理はここに実装する。

class MainPhase < Phase
end

RetreatPhase(撤退フェイズ)モデル

メインフェイズで撃退された軍を処理するフェイズ。

撤退命令と解隊命令の処理をここに実装する。

しかし考えてみたら、ここで撤退なり解隊を指示する軍が存在しなかった。メインフェイズで敗退した時点で盤面からは一時的に除去されており、撤退命令が成功しない限り戻っては来ないからだ。

Unit が存在しないということは PhaseOrder の間に関連が成立しないということだから、このままでは RetreatPhase に関連付く撤退命令と解隊命令を Order の派生クラスとして実装するには無理がある。

どうしよう。

class MainPhase < Phase
end

AdjustPhase(調整フェイズ)モデル

前フェイズ終了時点で軍が位置する地域の所有権を更新し、その後保有補給都市数に合わせて各国が所持する軍の数を増減調整するフェイズ。秋のターンにのみ存在する。

増設命令と削減命令の処理をここに実装する。

こちらも削減命令はともかく、増設命令については命令が実行された結果として調整フェイズの終了時に軍が生成されるので、やはり調整フェイズの時点では命令の対象が存在しない。

ほんとにどうしよう。

class MainPhase < Phase
end

撤退フェイズと調整フェイズの命令

撤退、解体、増設を Order の派生クラスにしない場合、Phase に参照属性を持たせる方向で専用の命令モデルを追加する流れになると思うが、フェイズによって「フェイズ」「軍」「命令」の関係性が変わるのは直観性を著しく阻害する上にせっかく綺麗にまとまっている参照関係が無駄に複雑化して管理が面倒くさくなるから面白くない。

そもそも「どこの地域にいる軍をどうする」という機能においては Order の派生クラスであるべきなのだ。「その地域にはその時点で対象となる軍が存在しない」という極めて些細な理由だけでは、全く別系統の命令モデルをわざわざ起こす動機としては弱い。

つまるところ撤退、解体、増設を Order の派生クラスにするなら「存在しない軍」の問題を解決しなければならない。

そこで。

「存在しない軍」を用意しよう。

Unit に追加する属性

  • undefined: 撤退・調整フェイズで配置が確定していない軍は true

これで全ての命令は Order の派生クラスとして扱えるはずだ。

MOE3: Rails4 の find_by

find_by メソッド

小ネタ。

今さら知ったが、Rails4 では find_by_xxx 系のメソッドが非推奨となり、代わりにfind_by メソッドが導入されていた。

元々 method_missing で泥臭いことをやっていたっぽい find_by_xxx が好きになれず、専ら wherefirst の組み合わせを使用していたので早速導入。

app/models/order_status.rb

Before。

class OrderStatus < ActiveRecord::Base
  UNEXECUTED = OrderStatus.where(code:   0).first
  INVALID    = OrderStatus.where(code: 100).first
  SUCCESS    = OrderStatus.where(code: 200).first
  SATISFIED  = OrderStatus.where(code: 201).first
  FAILURE    = OrderStatus.where(code: 300).first
  CUT        = OrderStatus.where(code: 301).first
  STANDOFF   = OrderStatus.where(code: 302).first
  DISLODGED  = OrderStatus.where(code: 303).first
  CONFLICT   = OrderStatus.where(code: 400).first
  DISBANDED  = OrderStatus.where(code: 401).first
end

After。

class OrderStatus < ActiveRecord::Base
  UNEXECUTED = OrderStatus.find_by(code:   0)
  INVALID    = OrderStatus.find_by(code: 100)
  SUCCESS    = OrderStatus.find_by(code: 200)
  SATISFIED  = OrderStatus.find_by(code: 201)
  FAILURE    = OrderStatus.find_by(code: 300)
  CUT        = OrderStatus.find_by(code: 301)
  STANDOFF   = OrderStatus.find_by(code: 302)
  DISLODGED  = OrderStatus.find_by(code: 303)
  CONFLICT   = OrderStatus.find_by(code: 400)
  DISBANDED  = OrderStatus.find_by(code: 401)
end

わずかな違いだがすっきり。

MOE3: 定数定義の罠

OrderStatus の定数

テスト環境でちょっと嵌まった。

OrderStatus のクラス定義はこんな感じ。

class OrderStatus < ActiveRecord::Base
  UNEXECUTED = OrderStatus.where(code:  0).first
  INVALID    = OrderStatus.where(code:100).first
  SUCCESS    = OrderStatus.where(code:200).first
  SATISFIED  = OrderStatus.where(code:201).first
  FAILURE    = OrderStatus.where(code:300).first
  CUT        = OrderStatus.where(code:301).first
  STANDOFF   = OrderStatus.where(code:302).first
  DISLODGED  = OrderStatus.where(code:303).first
  CONFLICT   = OrderStatus.where(code:400).first
  DISBANDED  = OrderStatus.where(code:401).first
end

OrderStatus::SUCCESS のように使用する。code の値そのものには大して意味がないので気にしなくても良い。

重要なのは、OrderStatus クラスのロード時に db/seeds.rb で設定されたレコードが読み込まれて定数が定義されることだ。

落とし穴

開発環境では問題なく動く。

しかしテスト環境ではこれがうまくいかない。全てのステータス定数が nil になってしまうのだ。

ActiveRecord の遅延評価か、あるいはキャッシュが悪さしているのかとしばらく悩んで、ようやく原因に思い当たった。

テスト環境と db/seeds.rb

本来 Rails のテスト環境では db/seeds.rb は読み込まれないのだが、今回は OrderStatus のようにテスト環境でも使いたいマスタデータがあるので、spec/spec_helper.rb の冒頭で require している。

テスト環境では spec の example 実行の度に DB が初期化され、db/seeds.rb の再読み込みが行われてマスタデータが設定される。

# This file is copied to spec/ when you run 'rails generate rspec:install'
ENV["RAILS_ENV"] ||= 'test'
require File.expand_path("../../config/environment", __FILE__)
require 'rspec/rails'
require 'rspec/autorun'
require Rails.root.join("db", "seeds")

# Requires supporting ruby files with custom matchers and macros, etc,
# in spec/support/ and its subdirectories.
Dir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f }

#(略)

要するにタイミングの問題である。

db/seeds.rb によってマスタデータが生成される前に OrderStatus クラスがロードされているので、定数定義のためのクエリ実行時は order_status テーブルは空っぽなのだ。

定数定義の右辺、OrderStatus のクエリが常に nil を返すのは当然だった。

対策

本来のタイミングで定数が正しく定義されないのなら、テスト実行タイミングで定義しなおせばよい。

Ruby では定数の上書きは可能なのだが、それをやるといちいち警告が出て目障りなので spec/support/order_status.rb を下記の通り作成した。

このファイルは spec/spec_helper.rb によって自動的に読み込まれる。

# OrderStatus クラス再定義
Object.class_eval { remove_const :OrderStatus }
load Rails.root.join("app/models", "order_status.rb")

定数の再定義ではなく、OrderStatus そのものを再定義してしまうわけだ。

一応補足すると、require では app/models/order_status.rb の二度読みができないので load を使っている。

これでテスト環境でも OrderStatus が期待通りに機能するようになった。

MOE3: OrderStatus モデル検討

OrderStatus(命令状態)モデル

またまた忘れてた。

行軍解決に伴う、成功したのか失敗したのか、支援や輸送が成立したのか、撃退されたか等々の状態を表す属性が抜けていた。

OrderStatus への参照として Order に追加しなければ。

f:id:asagix:20130828103345p:plain:w200

Order モデルの属性

  • player_id: 「誰が指示した命令か」を特定するための参照。
  • unit_id: 「どの卓の何ターン目のどの軍への命令か」を特定するための参照。
  • destination_id: 「どこへ移動するか」を示す Province への参照。
  • viaconvoy: 海路利用を宣言する場合は true
  • target_id: 「どの命令を」支援あるいは輸送するかを示す Order への参照。
  • sample: 支援または輸送命令の target に設定される複製命令なら true
  • status_id: 命令の処理状態を示す StatusOrder への参照。←New!
  • type: 単一テーブル継承用の属性。

「撃退された」は命令の状態ではないが、命令された軍が撃退されれば命令は失敗するので、「撃退されたので失敗した」という状態として扱うのが都合が良い。行軍解決処理の実装段階で再検討することにはなるが、ひとまずは前例を踏襲する。

ステータスの洗い出し

MOE2 で使用しているステータスを流用する。

  • UNEXECUTED: 未処理
  • SUCCESS: 成功
  • FAILURE: 失敗
  • STANDOFF: スタンドオフ
  • CUT: 支援のカット
  • DISLODGED: 敗退
  • INVALID: 支援または輸送の命令不成立

今回必要になるかは不明だが、内部処理で使用している中間状態もある。

  • RESERVED: 一時保留

撤退フェイズの撤退命令を Order の派生クラスにするならこれらも必要になりそう。

  • DISBANDED: 解隊
  • CONFLICT: 撤退先の競合(解隊)

どうでもいいところでは、DISBANDED の日本語の呼称を解隊にするか解体にするかで未だに悩んでいる。

成否の評価基準

「維持支援は成功した(成立し、カットされなかった)けど、支援対象の軍が撃退された」とか「移動支援は成功したけど、結果として支援対象の軍が移動に失敗した」とか「輸送命令は成立したけど、別の海軍が撃退されて輸送経路が途中で寸断された」みたいな場合に、支援命令と輸送命令のステータスは成功とするべきか失敗とするべきかも悩む。

内部的にステータスを区別しておいて先送りしてしまうのもありかもしれない。その場合は「ビューでどのように表示するか」の問題になるので、モデルとしては悩みから解放される。ある程度先読みしてステータスを定義しておく必要はあるけど。

  • SATISFIED: 支援または輸送の命令成立

SUCCESS の前段階、INVALID とは対になる状態。これを追加すれば足りるだろうか。

ややこしいステータス

支援命令と輸送命令にステータスとして SATISFIED(成立)が導入される場合、支援対象あるいは輸送対象が維持または移動に成功して初めて SUCCESS(成功)となる。

この時「支援命令への支援」は「支援命令への維持支援」なので支援対象がカットされるかどうかは関係ないということでよろしいか。

「輸送命令を受けてて撃退された海軍だけど、輸送対象は別ルートで移動に成功した」?

知るか。お前は DISLODGED(敗退)だ。

「他国の陸軍を輸送するつもりだったが、輸送対象は陸路で移動した」?

輸送される側が海路移動宣言必須の MOE2 では無縁だった問題だな。とりあえず海路が複数ある場合にどこを通ったかは問題ではないのと同じで SUCCESS でいいんじゃね?

ていうか、支援と輸送は SATISFIED までで十分な気がしてきた。

OrderStatus(命令状態)モデル

モデルの構造自体は単純で良いだろう。

OrderStatus の属性

  • code: ユニークなステータスコード。
  • status: ステータスを示す文字列。

素直に考えれば Order#status 属性の初期値として UNEXECUTED(未処理)への参照が設定されるべきだが、これは DB のデフォルト値では指定できない。

全ての ActiveRecord のインスタンスには固有の id があるのでやってできないことはないが、id に任意の固定値を期待するのは正しくないように思えるのでやらない。

Orderbefore_save コールバックで設定するにしても*1、同じ理由から id は使えないので代替のユニークな固定値が必要になる。それが code というわけだ。

db/seeds.rb ですべてのステータスを登録しておき、OrderStatus クラスで UNEXECUTED = OrderStatus.where(code:0).first のように定数定義しておけば、OrderStatus::定数 で任意の命令ステータスオブジェクトが取得できる。万歳。

*1:after_initialize などオブジェクト生成時に初期化しようとすると、クエリ実行時にヒットした全レコードのインスタンスを生成してしまう find 系メソッドとの相性がよろしくないらしい。