MOE3: SupportOrder のテスト

Order#target と Order#target=

理想は TDD なんだけど、実際に処理を書いてみないと挙動のイメージがつかめないことが多いので、どうしてもある程度コードが形になってからテストを用意するスタイルになってしまう。

安定してきたらテスト先行に移れると思うけど当面はやむなしか。

実装は Order だけど、実際に使用されるのは SupportOrderConvoyOrder なので、SupportOrder のテストとして書いた。ConvoyOrder のテストは書くとしても差分が必要な場合だけになる予定。

spec/models/support_order_spec.rb

初めて shared_contextlet を活用してみたので、いろいろ実験した結果長くなった。試しに全文貼ってみる。

正直 SupportOrder#new で生成した場合の挙動や target 属性未指定で生成した場合の挙動はテストする必要があったのか疑問だけど、「どうなるか分からない」で放置するのも怖かったので書くだけ書いておいた。

行軍解決処理のテストはこの何倍も長くなるんだろうなあ。

# -*- coding: utf-8 -*-
require 'spec_helper'

describe SupportOrder do
  let(:hold_order) { FactoryGirl.create :hold_order }
  let(:move_order) { FactoryGirl.create :move_order }

  shared_context "SupportOrder#new で生成", support_order_gen: :new do
    let(:support_order) { FactoryGirl.build :support_order, target: target }
  end

  shared_context "SupportOrder#create で生成", support_order_gen: :create do
    let(:support_order) { FactoryGirl.create :support_order, target: target }
  end

  shared_context "target が nil の場合", target: :nil do
    let(:target) { nil }
  end

  shared_context "target が HoldOrder の場合", target: :hold do
    let(:target) { hold_order }
  end

  shared_context "target が MoveOrder の場合", target: :move do
    let(:target) { move_order }
  end

  describe "#new", support_order_gen: :new do
    context "target が nil の場合", target: :nil do
      subject { support_order.target }
      example { expect(subject).to be_nil }
    end

    context "target が HoldOrder の場合", target: :hold do
      subject { support_order.target }
      example { expect(subject).not_to be_nil }
    end

    context "target が MoveOrder の場合", target: :move do
      subject { support_order.target }
      example { expect(subject).not_to be_nil }
    end
  end

  describe "#create", support_order_gen: :create do
    context "target が nil の場合", target: :nil do
      subject { support_order.target }
      example { expect(subject).to be_nil }
    end

    context "target が HoldOrder の場合", target: :hold do
      subject { support_order.target }
      example { expect(subject).not_to be_nil }
    end

    context "target が MoveOrder の場合", target: :move do
      subject { support_order.target }
      example { expect(subject).not_to be_nil }
    end
  end

  describe "#target", support_order_gen: :new do
    context "target が HoldOrder の場合", target: :hold do
      subject { support_order.target }
      example { expect(subject).not_to eq hold_order }
      example { expect(subject.id).to be_nil }
      example { expect(subject.sample).to be_true }
      example { expect(subject.unit).to eq hold_order.unit }
      example { expect(subject.province).to eq hold_order.province }
      example { expect(subject.persisted?).to be_false }
    end

    context "target が MoveOrder の場合", target: :move do
      subject { support_order.target }
      example { expect(subject).not_to eq move_order }
      example { expect(subject.id).to be_nil }
      example { expect(subject.sample).to be_true }
      example { expect(subject.unit).to eq move_order.unit }
      example { expect(subject.src).to eq move_order.src }
      example { expect(subject.dst).to eq move_order.dst }
      example { expect(subject.persisted?).to be_false }
    end
  end

  describe "#target", support_order_gen: :create do
    context "target が HoldOrder の場合", target: :hold do
      subject { support_order.target }
      example { expect(subject).not_to eq hold_order }
      example { expect(subject.id).not_to be_nil }
      example { expect(subject.sample).to be_true }
      example { expect(subject.unit).to eq hold_order.unit }
      example { expect(subject.province).to eq hold_order.province }
      example { expect(subject.persisted?).to be_true }
    end

    context "target が MoveOrder の場合", target: :move do
      subject { support_order.target }
      example { expect(subject).not_to eq move_order }
      example { expect(subject.id).not_to be_nil }
      example { expect(subject.sample).to be_true }
      example { expect(subject.unit).to eq move_order.unit }
      example { expect(subject.src).to eq move_order.src }
      example { expect(subject.dst).to eq move_order.dst }
      example { expect(subject.persisted?).to be_true }
    end
  end

  describe "#target=", support_order_gen: :new do
    context "後から設定", target: :nil do
      before do
        support_order.target = hold_order
      end

      subject { support_order.target }
      example { expect(subject).not_to be_nil }
      example { expect(subject).not_to eq hold_order }
      example { expect(subject.unit).to eq hold_order.unit }
      example { expect(subject.province).to eq hold_order.province }
      example { expect(subject.persisted?).to be_false }
    end

    context "書き換え", target: :hold do
      before do
        @lt = support_order.target
        support_order.target = move_order
      end

      subject { support_order.target }
      example { expect(subject).not_to be_nil }
      example { expect(subject).not_to eq move_order }
      example { expect(subject.unit).to eq move_order.unit }
      example { expect(subject.src).to eq move_order.src }
      example { expect(subject.dst).to eq move_order.dst }
      example { expect(subject.persisted?).to be_false }
      example { expect(@lt.persisted?).to be_false }
    end
  end

  describe "#target=", support_order_gen: :create do
    context "後から設定", target: :nil do
      before do
        support_order.target = hold_order
      end

      subject { support_order.target }
      example { expect(subject).not_to be_nil }
      example { expect(subject).not_to eq hold_order }
      example { expect(subject.unit).to eq hold_order.unit }
      example { expect(subject.province).to eq hold_order.province }
      example { expect(subject.persisted?).to be_true }
    end

    context "書き換え", target: :hold do
      before do
        @lt = support_order.target
        support_order.target = move_order
      end

      subject { support_order.target }
      example { expect(subject).not_to be_nil }
      example { expect(subject).not_to eq move_order }
      example { expect(subject.unit).to eq move_order.unit }
      example { expect(subject.src).to eq move_order.src }
      example { expect(subject.dst).to eq move_order.dst }
      example { expect(subject.persisted?).to be_true }
      example { expect(@lt.persisted?).to be_false }
    end
  end
end

Order#target= リファクタリング

少しだけ綺麗になった。

class Order < ActiveRecord::Base
  #(略)

  # target に渡された Order は自動的に複製される
  alias_method :association_target=, :target=
  def target=(target)
    if target
      sample = target.dup
      sample.sample = true
    else
      sample = nil
    end
    self.target.destroy if self.target
    self.association_target = sample

    save! if persisted?
  end

  #(略)
end

belongs_to 宣言によって設定された Order#target= メソッドは Order.create(および Order.new)の実行時にも呼ばれる。

引数として nilOrder の派生クラスのオブジェクト以外が渡された場合の動きは考慮していないが、その場合は sample.sample = trueNoMethodError 例外が飛ぶ。

万が一 sample= メソッドを持つオブジェクトでも self.assosiation_target = sampleActiveRecord::AssociationTypeMismatch 例外になるので検出漏れはない。

例外が飛ぶ時点で呼び出し側のバグは明白なので、Order#target= としてはこれ以上の配慮は不要だろう。

MOE3: Order モデル実装

スキーマ

viaconvoysample のデフォルト値は false

  create_table "orders", force: true do |t|
    t.integer  "player_id"
    t.integer  "unit_id"
    t.integer  "destination_id"
    t.boolean  "viaconvoy",      default: false
    t.integer  "target_id"
    t.boolean  "sample",         default: false
    t.string   "type"
    t.datetime "created_at"
    t.datetime "updated_at"
  end

Order クラス

デリゲーター Order#province を定義。

tareget 関連の小細工は勢いだけで書いたので、ちゃんとテストしないと心配。

もうちょっと綺麗に書けないものか。

class Order < ActiveRecord::Base
  extend Forwardable

  belongs_to :player
  belongs_to :unit
  belongs_to :destination, class_name: "Province"
  belongs_to :target, class_name: "Order", dependent: :destroy

  # Order#province を定義
  def_delegator :unit, :province

  # target に渡された Order は自動的に複製される
  alias_method :association_target=, :target=
  def target=(target)
    return unless target
    self.target.destroy if self.target
    sample = target.dup
    sample.sample = true
    self.association_target = sample
    save! if persisted?
  end
end

もちろん targettarget=SupportOrderConvoyOrder でしか使わない。後々問題になるようなら HoldOrderMoveOrder のは塞ぐけど、実害がなければこのまま放置。

ところで、Order#target= の戻り値を複製された sample: true のオブジェクトにしたかったのだが、どうも Ruby の Setter メソッドの戻り値は変更できないようだ。特に必要はないので別に構わないんだけど。

HoldOrder クラス

今は書くことなし。

class HoldOrder < Order
end

MoveOrder クラス

MoveOrder#srcMoveOrder#dst も定義。

一見すると似たようなメソッドなのに、どちらも通常のメソッド定義じゃないところが面白い。

class MoveOrder < Order
  # MoveOrder#src を定義
  def_delegator :unit, :province, :src

  # MoveOrder#dst を定義
  alias_method :dst, :destination
end

SupportOrder クラスと ConvoyOrder クラス

今は書くことなし。

class SupportOrder < Order
end
class ConvoyOrder < Order
end

MOE3: 輸送命令

MOE2 の輸送命令

MOE2 で輸送命令を出す場合、輸送対象の陸軍が明示的に海路移動を宣言している必要がある。

輸送命令を指定する際の操作と行軍解決処理の簡易化と「おせっかい輸送」回避のためだが、実はこの仕様だと本来のルール上は可能なはずの「自動海路選択」が適用されない。

おせっかい輸送

例えば、Hol に独陸軍、Bel に仏海軍がいるとしよう。

独は「Bel の仏海軍が Pic に後退するなら」Hol の陸軍を Bel に進めたいと考えて A Hol-Bel を指定し、仏も同じ考えで F Bel-Hol を指定していたとする。お互いに「進軍できれば儲けもの」程度の思惑で、実際には 1 vs 1 で状況は動かないはずだった。

ところが、何のつもりか、ここで英が Nth の海軍に対して、誰が頼んだわけでもない F Nth C German A Hol-Bel を指示していた場合、仏独両軍にとっては想定外の入れ替えが発生してしまうことになる。

……本当に?

もちろん、それはルールで封じられている。

輸送についてのレアケース

陸軍が移動する際に陸路と海路両方の解釈が可能な場合、下記の通り処理する。

  1. 輸送海軍のどれか一つでも陸軍と所属国が同じなら海路。
  2. 輸送海軍の全てが対象の陸軍と所属国が異なるなら陸路。
  3. 陸軍が海路を宣言していれば海路。

というわけで「おせっかい輸送」は 2 で阻止される。

自動海路選択

むしろ「自動海路選択」、上記 1 のケースだが、MOE で輸送を利用する場合にはシステム上 3 を強制されるのでこのルールの出番がない。

正確には、海路宣言の強制によってレアケースルールの実装をサボったわけだ。

今のところ MOE の輸送仕様について特に問題視する声は聞こえてこないが、本来はルール上可能ならばシステム上も可能であるべきとは思う。

MOE3 での仕様

陸軍が海路利用を宣言していない場合でも、同じ所属国の海軍による輸送が成立していれば海路を選択したものと見なす。

どう実装すれば良いのか見通しは立っていないが、なるようになるだろう。

MoveOrder(移動命令)の海路宣言

というわけで、すっかり忘れてた。

MoveOrder モデルで必要な属性

  • destination_id: 「どこへ移動するか」を示す Province への参照。
  • viaconvoy: 海路利用を宣言する場合は true← New!

Order モデルの属性

改めて。

Order モデルの属性

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

今度こそ Order および派生モデルの実装だ。

MOE3: 仮想命令

従来の仮想命令

ディプロマシーでは他の軍を支援または輸送することができる。これは対象となる軍の所属国を問わない。

自国の軍を支援または輸送する場合、MOE ではまず対象となる軍の命令を登録し、支援または輸送命令登録の際に、先に登録してある命令の中から対象として選択可能な命令を指定する段取りになっている。

他国の軍については、行軍が解決されて公表されるまでその国が実際に出した命令を知ることはできないため、支援や輸送の対象として「この国のこの軍はこう動くはず」という自分専用の決め打ちの命令を登録できるようになっている。

その決め打ち命令を仮想命令と呼ぶ。

あくまで UI の都合で導入された概念のため、仮想命令は実際の行軍解決処理では全て無視され、命令履歴にも残らない。

もうひとつの仮想命令

上記仮想命令とは別に、SupportOrder および ConvoyOrder の「対象として参照される命令」も通常命令とは区別する必要がある。

例えば、支援命令 F Nrg S A Yor-Nwy は SupporOrder F Nrg Starget 属性に MoveOrder A Yor-Nwy への参照が設定されたものだが、この時 A Yor-Nwy は通常命令や仮想命令への直接の参照であってはならない。

通常命令も仮想命令も、支援あるいは輸送命令が設定された後で変更される可能性があるからだ。

命令の変更は「変更後命令の新規登録」と「変更前命令の削除」によって実現されるため、target に直接の参照を設定すると、その命令が変更された場合に支援あるいは輸送命令の構成要素が欠落する不具合が生じ得る。

標本命令

その解決策として、SupportOrder#target あるいは ConvoyOrder#target を設定する際には、対象として指定した通常命令または仮想命令の複製オブジェクトを設定する。

単純に複製しただけでは複製元の命令と区別がつかないので、Order モデルの属性に boolean 型の sample 属性を追加する。

SupportOrder モデルと ConvoyOrder モデルで必要な属性

  • target_id: 「どの命令を」支援あるいは輸送するかを示す Order への参照。
  • sample: 支援または輸送命令の target に設定される複製命令なら true←New!

今後 Order#sample == true のものを内部的に標本命令と呼び、通常命令および仮想命令と区別する。

Order モデルの属性

あらかた条件は出揃った。

Order モデルの属性

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

これでようやく Order および派生モデルの実装に入れる。

MOE3: Order モデル検討

Order と派生モデル

f:id:asagix:20130825115713p:plain:w200

単一テーブル継承を使用するので、Order モデルだけではなく派生クラスで必要になる属性も先に考えておかなければならない。

必要になってから属性を追加するのがアジャイル流かもしれないが、明らかに必要になると分かっているものを先送りにするのはストレスなので。

ひとつひとつ見ていこう。

Order(命令)モデル

全ての命令の基底クラスとなり、Order インスタンスが生成されることはない。

Order モデルの属性

  • player_id: 「誰が指示した命令か」を特定するための参照。
  • unit_id: 「どの卓の何ターン目のどの軍への命令か」を特定するための参照。
  • type: 単一テーブル継承用の属性。

HoldOrder(維持命令)モデル

何もしない命令。必要な属性は Order と同じはず。

だったら Order は捨てて HoldOrder を基底クラスにすればいいじゃん、という考えもないではないが、HoldOrder を特別扱いする合理的な理由がないのでこれで良い。

MoveOrder(移動命令)モデル

攻撃命令でもある。移動を伴うので「どこからどこへ」という情報が必要になる。

「どこから」は命令対象の軍が持っているが、「どこへ」は MoveOrder の固有の属性として持つ必要がある。

MoveOrder モデルで必要な属性

  • destination_id: 「どこへ移動するか」を示す Province への参照。

「どこから」については MoveOrder#source メソッドを書こう。

しかし行軍解決処理で多用するであろうことを考えると、MoveOrder#source はともかく MoveOrder#destination は好みで言えばメソッド名としてちょっと長い。

いっそのこと MoveOrder#src MoveOrder#dst にしてしまうか。要検討。

SupportOrder(支援命令)モデル

支援対象についての情報を持つ必要がある。

実際には軍だけではなく、その軍が受ける命令まで含めて指定する必要があるので、支援命令の対象となるのは軍そのものではなく、その軍が受けた命令となる。

SupportOrder モデルで必要な属性

  • target_id: 「どの命令を支援するか」を示す Order への参照。

支援の成立と不成立

支援命令の対象となるのは、維持命令か移動命令のみ。

他国軍の維持を支援する場合、実際の支援対象が維持命令、支援命令、輸送命令のいずれであっても支援は成立するが、移動を支援するのであればその移動先が一致しなければならない。

  • 維持・支援・輸送命令に対する維持支援はすべて成立する。
  • 維持・支援・輸送命令に対する移動支援は成立しない。
  • 移動命令に対する維持支援は成立しない。
  • 移動先の異なる移動支援は成立しない。
  • 結果的に失敗した移動命令に対する維持支援は成立しない。

ConvoyOrder(輸送命令)モデル

輸送対象についての情報を持つ必要がある。

こちらも正確を期すのであれば「どの軍の移動命令を輸送するか」と表現するのが正しい。日本語的には微妙だが気にしない。

target 属性については SupportOrder と共有できるだろう。

輸送の成立と不成立

輸送命令の対象となるのは移動命令、厳密には Coast から Coast への陸軍の移動命令のみ。

移動支援と同様に、移動先が一致しなければ輸送は成立しない。

  • 輸送対象に移動命令以外を指定することはできない。
  • 輸送対象となる移動命令は陸軍の Coast から Coast への移動のみ。
  • 移動先の異なる輸送は成立しない。

Order モデルの属性

いったんまとめ。

Order モデルの属性

  • player_id: 「誰が指示した命令か」を特定するための参照。
  • unit_id: 「どの卓の何ターン目のどの軍への命令か」を特定するための参照。
  • destination_id: 「どこへ移動するか」を示す Province への参照。
  • target_id: 「どの命令を」支援あるいは輸送するかを示す Order への参照。
  • type: 単一テーブル継承用の属性。

実は、これではまだ足りない。

仮想命令についてもう少し練り込む必要がある。

その他の命令

撤退フェイズの撤退命令と解隊命令、調整フェイズの増設命令と解隊命令を Order の派生クラスにするかどうかは検討中。

MOE3: Unit#to_s メソッド

Unit の文字列化

Unit#to_s への要求仕様は至極単純。

仕様 1

例えば「Lon にいる海軍」の #to_s を呼んだら "F Lon" を返す。

陸軍なら "A Lon" を返す。

最初の文字が "A""F" かの違いだけなので、メソッド本体は Unit#to_s として実装してしまい、差分を ArmyFleet で吸収する形が自然な発想だろう。

それからもう一点、この先 Order#to_s に関係してくるはずの仕様として、引数に Power を一つ渡せるようにする。

仕様 2

引数として Power が渡された場合、その軍の所属国、すなわち Unit#power と引数の Power が一致しなければ、Unit#to_s は戻り値に所属国の genitive を付与する。

前述の「Lon にいる海軍」の所属国が英で、Fleet#to_s に渡された Power が仏であれば、Fleet#to_s"English F Lon" を返すのが正しい。

実装

実際に呼ばれるのは Army#to_s または Fleet#to_s であって、Unit#to_s が直接呼ばれることはない。しかし派生クラスにしか存在しない symbol メソッドを Unit#to_s から呼び出すのが気持ち悪かったのでこうなった。

class Unit < ActiveRecord::Base
  belongs_to :phase
  belongs_to :power
  belongs_to :province

  def symbol
    # 派生クラスでオーバーライド
  end

  def to_s(owner = power)
    result = "%s %s"%[symbol, province.shortname]
    result = "%s %s"%[power.genitive, result] if owner != power
    result
  end
end

徹底するなら Unit#symbol では例外投げるべきだろうけど、単なる気休めなのでそこまでは必要なかろう。

class Army < Unit
  def symbol
    "A"
  end
end
class Fleet < Unit
  def symbol
    "F"
  end
end

ちなみに Province#shortname はメソッド、Province#codePower#genitive は属性である。

class Province < ActiveRecord::Base
  belongs_to :homepower, class_name:"Power"

  def shortname
    code.capitalize
  end
end
class Power < ActiveRecord::Base
end

MOE3: Unit モデル実装

Unit(軍)モデル

Order モデルの実装の前に Unit を作ってしまおう。

f:id:asagix:20130825004400p:plain:w200

Army(陸軍)と Fleet(海軍)は例によって単一テーブル継承。

Phase はまだイメージが固まってないので後回し。

スキーマ

シンプル。

  create_table "units", force: true do |t|
    t.integer  "phase_id"
    t.integer  "power_id"
    t.integer  "province_id"
    t.string   "type"
    t.datetime "created_at"
    t.datetime "updated_at"
  end

クラス定義

シンプル。

class Unit < ActiveRecord::Base
  belongs_to :phase
  belongs_to :power
  belongs_to :province
end
class Army < Unit
end
class Fleet < Unit
end