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 系メソッドとの相性がよろしくないらしい。

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 および派生モデルの実装だ。