MOE3: 単純移動命令の解決
MainPhase#resolve_move メソッド
先にテストを書く。
resolve_move
自体は private メソッドの位置付けで*1、実装が進むと名前や扱いが変わる可能性がある、というかその余地を縛りたくないので、テストはあくまで MainPhase#resolve_orders
を対象としたものになる。
spec/main_phase_spec.rb
マニュアルの Diagram1 から French A Par-Bur
を実装する。
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 に保存する。
MOE3: 行軍解決処理の基本デザイン
MainPhase#resolve_orders の基本形
setup
、resolve_move
、cleanup
の順に処理し、最後の 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 と派生モデル
例によって単一テーブル継承。
派生クラスまで見ても属性は Turn
への参照だけのはず。当面は。
Phase モデルの属性
- turn_id: 「どの卓の何ターン目か」を特定するための参照。
- type: 単一テーブル継承用の属性。
Phase(フェイズ)モデル
全てのフェイズの基底クラスとなり、Phase インスタンスが生成されることはない。
class Phase < ActiveRecord::Base belongs_to :turn end
MainPhase(メインフェイズ)モデル
本来のルールでの外交フェイズ、命令記入フェイズ、命令解決フェイズを指す。
MOE ではチャット形式で外交をしながら同時に命令記入を行い、命令解決は全自動で処理されるので、システム的にはこれらをメインフェイズとしてまとめてしまう。
現時点の構想では、行軍解決処理はここに実装する。
class MainPhase < Phase end
RetreatPhase(撤退フェイズ)モデル
メインフェイズで撃退された軍を処理するフェイズ。
撤退命令と解隊命令の処理をここに実装する。
しかし考えてみたら、ここで撤退なり解隊を指示する軍が存在しなかった。メインフェイズで敗退した時点で盤面からは一時的に除去されており、撤退命令が成功しない限り戻っては来ないからだ。
Unit
が存在しないということは Phase
と Order
の間に関連が成立しないということだから、このままでは 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
が好きになれず、専ら where
と first
の組み合わせを使用していたので早速導入。
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 に追加しなければ。
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
に任意の固定値を期待するのは正しくないように思えるのでやらない。
Order
の before_save
コールバックで設定するにしても*1、同じ理由から id
は使えないので代替のユニークな固定値が必要になる。それが code
というわけだ。
db/seeds.rb ですべてのステータスを登録しておき、OrderStatus
クラスで UNEXECUTED = OrderStatus.where(code:0).first
のように定数定義しておけば、OrderStatus::定数
で任意の命令ステータスオブジェクトが取得できる。万歳。
*1:after_initialize などオブジェクト生成時に初期化しようとすると、クエリ実行時にヒットした全レコードのインスタンスを生成してしまう find 系メソッドとの相性がよろしくないらしい。