ホーム » Julia言語で入門するプログラミング(その10)

Julia言語で入門するプログラミング(その10)


「Julia言語で入門するプログラミング」第10回である。未読の方は第1回〜第9回を読んで欲しい。

一覧はこちら

ビューの差し替え

前回の終わりで「刃に毒を塗る」の実装とビューの差し替えをやりたいと言う話をした。まずはビューの差し替えから行おう。

やりたいのは、game_exec.jlから呼ばれる時にはui.jlに定義された関数が呼ばれるが、game_test.jlから呼ばれる時には、同じように関数を呼び出しても画面に何も印字しない別の関数が呼ばれて欲しい、ということだ。

ここで、今そもそもどのように、Juliaの関数が呼ばれているかをおさらいしよう。

game_exec.jlは次のようになっている。

#game_exec.jl
include("game.jl")

Game.main()

Game.main()は、Gameモジュールのmain関数を呼び出せと言う意味だ。となると、Gameモジュールのmain関数をどうにかして見つけているわけだが、その仕組みがinclude("game.jl")である。これは、game.jlというファイルの中身をコピーしてきて、ここにべちゃっと貼り付けているイメージの動きをする。

game.jlは次のようになっている。

#game.jl
module Game
...
include("ui.jl")
...

ここにinclude("ui.jl")と書かれているため、ui.jlというファイルの中にある関数を見つけることができるのだ。

今は、game.jlが直接ui.jlをincludeしているために、game.jlを使いたい時には、抱き合わせでui.jlの関数が呼ばれることになる。同じ要領で、game_test.jlを実行するときも、game.jlをincludeする時にui.jlもincludeされている。

つまり、game_exec.jlui.jlの関数が呼ばれ、game_test.jlで別の、例えばui_stub.jlというファイルに書かれた関数が呼ばれるようにしたければ、次のようにすれば良い。

  1. game.jlui.jlをincludeするのをやめる。
  2. game_exec.jlui.jlをincludeし、game_test.jlui_stub.jlをincludeするようにする。

早速やってみよう。

まだui_stub.jlは作らず、とりあえず、ui.jlgame.jlから削り、game_exec.jlgame_test.jlの両方にincludeしてみよう。

module Game

using Random
import REPL
using REPL.TerminalMenus

include("キャラクター.jl")
include("戦闘.jl")
#include("ui.jl")
...
#game_exec.jl
include("ui.jl")
include("game.jl")

Game.main()
#game_test.jl
include("ui.jl")
include("game.jl")

using Test
...

ただ、これをやるだけでは失敗する。もともと、ui.jlgame.jlの中で暗黙的にui.jlに依存していた。game.jlの中にいる間は、キャラクター.jl戦闘.jlの中の構造体や関数が「見えて」いたのだ。

#game.jl
...
include("キャラクター.jl")
include("戦闘.jl")
#include("ui.jl")

雑に対応するのであれば、キャラクター.jl戦闘.jlも、ui.jlと道連れに連れていく、と言うものだが、もう少しきちんと対応しよう。ui.jlが本当に必要とする構造体や関数だけを抽出して、明示的に依存させよう。

Tキャラクター.jlというファイルをつくり、そこにキャラクター関連の構造体の定義のみ移動させた。それ以外の関数は、外部コンストラクタも含めて全て元のキャラクター.jlに残っている。この辺りは一部の言語とは違う文化だ。例えば、Javaは1クラス1ファイルが原則だ。あるクラスと、そのクラスに属するメソッドは同じファイルに書かれる。しかし、Juliaでは構造体と関数の紐付きが強くない。そもそも多重ディスパッチにより複数の構造体に関連する関数も多いので、1クラス1ファイルのような管理はできない。構造体とそれに関連する関数は、なるべく近くにあった方がわかりやすいのだが、分けるとしたら構造体を独立させると良いと思う。

#Tキャラクター.jl
mutable struct Tキャラクター共通データ
    ...
end

abstract type Tキャラクター end

mutable struct Tプレイヤー <: Tキャラクター
    _キャラクター共通データ::Tキャラクター共通データ
end

mutable struct Tモンスター <: Tキャラクター
    _キャラクター共通データ::Tキャラクター共通データ
end
#キャラクター.jl
include("Tキャラクター.jl") #追加
include("スキル.jl")

function Tプレイヤー(名前, HP, MP, 攻撃力, 防御力, スキルs)
    return Tプレイヤー(Tキャラクター共通データ(名前, HP, MP, 攻撃力, 防御力, スキルs))    
end
...

こうして、ui.jlが明示的にTキャラクターの情報に依存すると表現できる。

#ui.jl
include("スキル.jl")
include("行動系統.jl")
include("Tキャラクター.jl") #追加

function コマンド選択(行動者::Tプレイヤー, プレイヤーs, モンスターs)
    ...

しかし、これで終わりではない。テストを実行しようとすると、次のように出る。

ダメージ < HP: Error During Test at /Users/kenji/Documents/SourceCode/rpg/rpg10/game_test.jl:16
  Got exception outside of a @test
  UndefVarError: 攻撃実行ui処理! not defined
...

今度は、ui.jlの中に定義されている関数が見えないと言うのだ。これはなぜかと言うと、ui.jlがGameモジュールの外に存在するからだ。

本来モジュールは、ソフトウェア内部にアーキテクチャ的な境界を分けるためのものだが、ここまでは特にそう言う役割を担っているわけではない。ただ単に、構造体の再定義をする時にREPLの再起動が必要になるが、構造体の中に入れておけば避けられる、という理由で導入しただけのものだ。適当に対応しよう。

game.jlからmoduleの定義を外し、game_exec.jlが代わりにGameモジュールを作るようにする。

#game.jl
#module Game
using Random
import REPL
using REPL.TerminalMenus
...
#end

これまでは、game_exec.jlではGameモジュール外からmain関数を呼んでいたため、Game.mainとする必要があったが、その必要がなくなっている。

#game_exec.jl
module Game

include("ui.jl")
include("game.jl")

main()

end

同様に、game_test.jlの属するモジュールをGameTest.jlとしている。その過程で、Game.という修飾をなくしている。

#game_test.jl
module GameTest

include("ui.jl")
include("Game.jl")

using Test
...

ひとまずこれで、元通りに動く。

ここからがやりたいことの本番だ。game_test.jlで参照する関数を、ui.jlで定義されている本物の関数とは別の関数としたい。何もprintlnしない偽物の関数だ。このようなテスト用の偽物の関数をモックと言ったりスタブと言ったりする。モックとスタブという用語の定義に細かい違いはあるようだが、あまり気にしない。細かい違いについて気になる方は各自検索されたい。今回はスタブと呼ぶことにする。スタブ関数のみで構成されたファイルui_stub.jlを作ろう。引数の型を指定するのに不要なincludeは削除している。また、基本的にはui.jlの関数で構成されているが、自動テストに不要な関数は除いている。

#ui_stub.jl
include("スキル.jl")
include("Tキャラクター.jl")

function 攻撃実行ui処理!(攻撃者, コマンド::T通常攻撃) end
function 攻撃実行ui処理!(行動者, スキル::Tスキル) end
function 回復実行ui処理!(行動者, スキル::Tスキル) end
function スキル実行ui処理!(行動者, スキル::Tスキル) end
function かばう実行ui処理!(行動者, 対象者) end
function かばう発動ui処理!(防御者) end

function かばう解除ui処理!(行動者, 対象者, かばう解除トリガ)
    if かばう解除トリガ === :行動前処理
    elseif かばう解除トリガ === :戦闘不能
    else
        throw(DomainError("想定していないトリガでかばうが解除されました"))
    end
end

function HP減少ui処理!(防御者, 防御者ダメージ) end
function HP回復ui処理!(対象者, 回復量) end
function 行動決定ui処理!(行動者::Tプレイヤー, プレイヤーs, モンスターs) end
function 攻撃失敗ui処理!() end

ほとんどが、空っぽの関数になる。一際異彩を放つのがかばう解除ui処理!で、これだけ例外を送出している箇所がある。ここは消したくない。なぜなら、「想定外の引数の時に例外を早出する」というのは自動テストでも検知したい内容であるためだ。しかし、そうなると本体側のコードと二重実装のようになってしまう。困った事態だ。

なぜこんなことになってしまったかというと、これは本質的にはUIの処理ではなく、モデルの処理だからだろう。「:行動前処理:戦闘不能で、ユーザーへの通知を変えたい」という要件はモデルの責務に思える。「それぞれをどう通知するか」というのは、UIの責務だ。前回も説明したように、モデルとUIの分離は意外と難しい。まあこの部分は少し後で直すことにしよう。

game_test.jlが参照するのを、ui.jlからui_stub.jlに置き換える。

#game_test.jl
module GameTest

include("ui_stub.jl")
include("game.jl")

using Test
...

テストを動かすと、エラーが発生する。次のようなテストケースがあるためだ。

@testset "戦況表示" begin
    モンスター = Tモンスター("ドラゴン", 400, 80, 40, 10, [])
    プレイヤー1 = Tプレイヤー("太郎", 100, 20, 10, 10, [])
    プレイヤー2 = Tプレイヤー("花子", 100, 20, 10, 10, [])
    プレイヤー3 = Tプレイヤー("遠藤君", 100, 20, 10, 10, [])
    プレイヤー4 = Tプレイヤー("高橋先生", 100, 20, 10, 10, [])
    プレイヤーs = [プレイヤー1, プレイヤー2, プレイヤー3, プレイヤー4]
    モンスターs = [モンスター]

    @test 戦況表示(プレイヤーs, モンスターs) == 
    """
    *****プレイヤー*****
    太郎 HP:100 MP:20
    花子 HP:100 MP:20
    遠藤君 HP:100 MP:20
    高橋先生 HP:100 MP:20
    *****モンスター*****
    ドラゴン HP:400 MP:80
    ********************"""
end

このテストは消してしまおう。何かしらちゃんとした対応を入れてもいいのだが面倒だし、もしもここに問題があれば、どうせゲームを動かしたら即座に気づくからだ。

これで晴れてテストが通る。不要なui表示が消えて、結果がわかりやすくなった。

Test Summary: | Pass  Total
HP減少          |    5      5
Test Summary: | Pass  Total
行動実行!         |   38     38
Test Summary: | Pass  Total
is戦闘終了        |    7      7
Test Summary: | Pass  Total
is全て相異なる      |   10     10
Test Summary: | Pass  Total
行動順決定         |    4      4
Test Summary: | Pass  Total
is戦闘終了        |    2      2
Test Summary: | Pass  Total
is行動可能        |    4      4
Test Summary: | Pass  Total
行動可能な奴ら       |    6      6
Main.GameTest

もちろん本体側では、これまで通りの表示となることを確認しておこう。

モンスターに遭遇した!
戦闘開始!
*****プレイヤー*****
太郎 HP:100 MP:20
花子 HP:100 MP:20
遠藤君 HP:100 MP:20
高橋先生 HP:100 MP:20
*****モンスター*****
ドラゴン HP:400 MP:80
********************
太郎のターン
選択してください:
 > 攻撃
   スキル
----------
太郎の攻撃!
ドラゴンは10のダメージを受けた!
ドラゴンの残りHP:390

不要コードのモデルへの移行

先ほど後送りにした、下記の例外送出の部分に手をつけよう。先ほど言ったように、これは本質的にはモデルの性質を含んでいるコードである。そのため、モデルに移せる部分はモデルに移したい。

#ui.jl
function かばう解除ui処理!(行動者, 対象者, かばう解除トリガ)
    if かばう解除トリガ === :行動前処理
        println("$(行動者.名前)は$(対象者.名前)をかばうのをやめた!")
    elseif かばう解除トリガ === :戦闘不能
        println("$(行動者.名前)は$(対象者.名前)をかばえなくなった!")
    else
        throw(DomainError("想定していないトリガでかばうが解除されました"))
    end
end

#ui_stub.jl
function かばう解除ui処理!(行動者, 対象者, かばう解除トリガ)
    if かばう解除トリガ === :行動前処理
    elseif かばう解除トリガ === :戦闘不能
    else
        throw(DomainError("想定していないトリガでかばうが解除されました"))
    end
end

次のようにしてみよう。まず、条件分岐の部分がモデルにあたるので、スキル.jlに次のように関数を定義する。

#スキル.jl

#関数名が紛らわしいが、「かばうを解除する時にメッセージを出し分けたい」という
#モデルの処理なのでUI層ではなくモデル層に定義
function かばう解除ui処理!(行動者, 対象者, かばう解除トリガ)
    if かばう解除トリガ === :行動前処理
        かばう解除ui処理_行動前処理!(行動者, 対象者)
    elseif かばう解除トリガ === :戦闘不能
        かばう解除ui処理_戦闘不能!(行動者, 対象者)
    else
        throw(DomainError("想定していないトリガでかばうが解除されました"))
    end
end

そして、画面表示に関する部分だけをui.jlに定義する。

function かばう解除ui処理_行動前処理!(行動者, 対象者)
    println("$(行動者.名前)は$(対象者.名前)をかばうのをやめた!")
end

function かばう解除ui処理_戦闘不能!(行動者, 対象者)
    println("$(行動者.名前)は$(対象者.名前)をかばえなくなった!")    
end

テストも書いておこう。本当は、もっと早くに書いているべきテストだったのだが、うっかりしていた。

@testset "想定外のシンボルでは例外が発生" begin
    p1 = createプレイヤー()
    p2 = createプレイヤー()
    @test isnothing(かばう解除ui処理!(p1, p2, :行動前処理))
    @test isnothing(かばう解除ui処理!(p1, p2, :行動前処理))
    @test_throws DomainError かばう解除ui処理!(p1, p2, :想定外シンボル)
end

@test_throwsというのが、例外が発生することを期待するときのテストである。引数が:行動前処理:戦闘不能であれば、正常に終了するが、:想定外シンボルであれば、DomainError例外が発生するというテストだ。

今は、ui.jlにだけ関数を定義したので、テストは失敗する。ui_stub.jlに、スタブ関数を定義しよう。

function かばう解除ui処理_行動前処理!(行動者, 対象者) end
function かばう解除ui処理_戦闘不能!(行動者, 対象者) end

これで無事テストが通るはずだ。最終的に、ui_stub.jlは次のようになる。

#ui_stub.jl
include("スキル.jl")
include("Tキャラクター.jl")

function 攻撃実行ui処理!(攻撃者, コマンド::T通常攻撃) end
function 攻撃実行ui処理!(行動者, スキル::Tスキル) end
function 回復実行ui処理!(行動者, スキル::Tスキル) end
function スキル実行ui処理!(行動者, スキル::Tスキル) end
function かばう実行ui処理!(行動者, 対象者) end
function かばう発動ui処理!(防御者) end
function かばう解除ui処理_行動前処理!(行動者, 対象者) end
function かばう解除ui処理_戦闘不能!(行動者, 対象者) end
function HP減少ui処理!(防御者, 防御者ダメージ) end
function HP回復ui処理!(対象者, 回復量) end
function 行動決定ui処理!(行動者::Tプレイヤー, プレイヤーs, モンスターs) end
function 攻撃失敗ui処理!() end

これでスッキリした。

裏技

ところで、ここまで長々と説明をしてきたが、実はもっと簡単なやり方がある。結局のところ、自動テストの文脈では、printlnを排除したいというのが目的だった。それをやるだけであれば、こうしてあげても良いのだ。

#game_test.jl
function println(args) end

include("ui.jl")

これだけである。要は、Base.printlnという「本当の」printlnの代わりに、偽物のprintlnを作って、テストの時にはそれを参照するようにしようというものである。まあ、少し裏技的ではあるし、きちんとスタブ関数を用意してあげた方が今後いろいろ応用も効く。例えば、画面描画の関数がきちんと呼ばれているかを自動テストで検知したいと考えたとする。そんな時に、スタブ関数として空っぽの関数ではなく、何かの変数をカウントアップする関数にすることもできる。そうすれば、その変数をチェックすることで、関数が呼ばれているかどうか、ということを自動テストで書くこともできるのだ。

「刃に毒を塗る」の実装

次は「刃に毒を塗る」スキルの実装だ。このスキル、どういうスキルだろうか。

  • 刃に毒を塗る
  • 攻撃がヒットしたら一定確率で毒を与えることができるようになる。

一定確率というのはどうしようか。25%くらいにしておこう。ちょっと低いと思われるかもしれないが、その代わり毒の効果を強力なものにしようと思っているのだ。

いきなり語り始めてしまうのだが、私は一般的なゲームでの「毒」という効果が弱すぎると感じている。多くのゲームでは、毒というのは割とジワジワ効いてくる。よくあるのは最大HPの5%や10%のダメージを毎ターン受ける、というようなものだ。しかし、これは非常に使いづらい。

というのが、通常のゲームでは、雑魚敵との対戦があまりストレスとならないように、大体2から3ターンで倒せる程度の強さに設計されていることが多い。これでは、ジワジワ毒で削っている暇があったら殴って倒してしまった方が早いとなってしまう。このため、雑魚敵との戦いでは毒はあまり使い物にならない。

かと言って、雑魚戦で活躍できるように毒を強くしすぎると、今度はボス戦で困ってしまう。例えば、1回の毒で最大HPの50%を減らす威力にしてしまうと、ボスに毒が効いて2ターンで終わりました、となってしまう。これではボスの威厳も何もあったものではない。

というわけで、毒というものは扱いが難しいのだが、私は雑魚敵とボス敵で効果を変えてしまえばいいのではないかと思う。

今は敵がドラゴン1体しかいないが、ここらでお供のミニドラゴン2体も登場させるようにしよう。ドラゴンはボス敵、ミニドラゴンは雑魚敵ということにしよう。

さらに、私は毒の威力を比較的高めに設定したいと思っている。これは完全に趣味の問題で、そもそも毒というのは致命的だからこそ毒と呼ばれている気がするからだ。その代わり、相手を毒にすることは少し難しくしてある。1ターンかけて刃に毒を塗って、2ターン目以降でも確実に毒にできるとは限らない。その代わり、一旦毒にかかったらそれなりのダメージが発生するようにしよう。

これらを合わせて、下記のように設定しようと思う。

  • 雑魚敵は1ターンにつき最大HPの50%のダメージ
  • ボス敵は1ターンにつき最大HPの20%のダメージ

仕様のまとめ

「刃に毒を塗る」のスキルの仕様を簡単にまとめると、次のようになる。

  • スキルを発動したターンは、刃に毒を塗るという行動で1ターン消費する
  • その次のターン以降では、攻撃がヒットすると25%の確率で相手を毒状態にする。
  • 一度相手を毒状態にすると、毒を与える効果が切れる。再度毒を塗ることはできる。
  • 毒状態のキャラクターは1ターンごとにダメージを受ける。雑魚敵とボス敵で威力が異なる。

いったんこんなもので良いだろう。進めていくうちに細かい部分が気になってくるものだが、いったんはこれをベースに進めていこう。

「スキルを発動したターンは、刃に毒を塗るという行動で1ターン消費する」の実装

まずはスキルを定義しよう。次のようになるだろう。消費MPは5にしておいた。

#スキル.jl
struct T刃に毒を塗る <: Tスキル 
    名前
    消費MP
end

function T刃に毒を塗る() 
    return T刃に毒を塗る("刃に毒を塗る", 5)
end

function createスキル(スキルシンボル)
    ...
    elseif スキルシンボル === :刃に毒を塗る
        return T刃に毒を塗る()
    ...
end

次に、このスキルを太郎に持たせよう。

#game.jl
function main()
    ...
    プレイヤー1 = Tプレイヤー("太郎", 100, 20, 10, 10, [createスキル(:連続攻撃), createスキル(:かばう), createスキル(:ヒール), createスキル(:刃に毒を塗る)])
    ...

これで、コマンドとして、刃に毒を塗るが選べるようになった。もちろんこれだけでは動かすとエラーが出る。次はこれを正しく動かせるようにしよう。

まず、攻撃の対象を選べる必要はない。対象は自分自身だ。

#行動系統.jl
...
struct T刃に毒を塗る行動 end

...
行動系統(::T刃に毒を塗る) = T刃に毒を塗る行動()
#ui.jl
function コマンド選択(行動者::Tプレイヤー, プレイヤーs, モンスターs)
    ...
    function get対象リスト(::T刃に毒を塗る行動)
        return [行動者]
    end
    ...

次に、具体的な行動内容を記述しよう。行動実行!関数のT刃に毒を塗る行動バージョンを作る。

function 行動実行!(::T刃に毒を塗る行動, 行動::T行動) 
    刃に毒を塗る実行!(行動.対象者)
    MP減少!(行動.行動者, 行動.コマンド)
end

刃に毒を塗る実行!関数は次のようになる。引数にするのは、行動者か対象者か迷ったが(どちらでも同じなので)、対象者にした。

function 刃に毒を塗る実行!(対象者)
    刃に毒を塗る実行イベント通知!(対象者)
    対象者.物理攻撃時状態異常付与確率[:毒] = 0.25
end

Tキャラクター物理攻撃時状態異常付与確率というフィールドを追加する必要がある。物理攻撃という言葉をしれっと使ったが、後々魔法攻撃が出てくることを念頭に置いている。

mutable struct Tキャラクター共通データ
    ...
    物理攻撃時状態異常付与確率
    ...
        new(..., Dict(), ... 
    ...

そして、そのフィールドに対して、[:毒]と指定し、0.25という値を付与している。これは一体なんだろうか?これは「辞書」と呼ばれるものだ。

辞書

「辞書」というデータ構造がある。英語でdictionaryである。言語によっては、ハッシュテーブル(hash table)やマップ(map)、連想配列という名前がついていることもあるが、同じものを指す。私は辞書というネーミングが一番好きである。

辞書は配列に似ていて、データの集合である。配列と違うのは、データを特定する方法だ。配列は「何番目か」という数字でデータにアクセスするが、辞書は「名前」でデータにアクセスする。

次の例では、"a"10という値を持ち、"b"20という値を持つ辞書となっている。"a""b"のことを「キー」と呼び、1020のことを「値」と呼ぶ。

julia> d = Dict([("a", 10), ("b", 20)])
Dict{String,Int64} with 2 entries:
  "b" => 20
  "a" => 10

julia> d["a"]
10

julia> d["b"]
20

同じことを、次のように記述することもできる。この表記の方が直感的でわかりやすい。

julia> d = Dict("a"=>10, "b"=>20)
Dict{String,Int64} with 2 entries:
  "b" => 20
  "a" => 10

"a"=>10というのは、これ自体がペア(Pair)というデータ構造である。

julia> typeof("a"=>10)
Pair{String,Int64}

辞書はペアの集合体とみなすこともできる。ただし、辞書から正しくデータを検索できるためには、キーが重複してはいけない。同じキーで複数の値を登録することはできない。

julia> Dict("a"=>10, "a"=>20)
Dict{String,Int64} with 1 entry:
  "a" => 20

まず辞書を作って、あとからキーと値のペアを追加することができる。

julia> d = Dict()
Dict{Any,Any}()

julia> d["a"] = 10
10

julia> d["b"] = 20
20

julia> d
Dict{Any,Any} with 2 entries:
  "b" => 20
  "a" => 10

先ほどお見せしたのはこのやり方である。

mutable struct Tキャラクター共通データ
    ...
    物理攻撃時ステータス異常付与確率
    ...
        new(..., Dict(), ... 
    ...
対象者.物理攻撃時ステータス異常付与確率[:毒] = 0.25

シンボルをキーに、数値を値に持つ辞書である。

これでモデル部分の実装は終わった。期待値がはっきりしたので、今のうちにテストを作っておこう。

#game_test.jl
@testset "刃に毒を塗る" begin
    @testset "刃に毒を塗る実行" begin
        p = createプレイヤー()
        刃に毒を塗る = T行動(createスキル(:刃に毒を塗る), p, p)
        行動実行!(刃に毒を塗る)
        @test p.物理攻撃時状態異常付与確率[:毒] == 0.25    
    end
end 

あとは画面に「太郎は刃に毒を塗った!」と表示したい。

#キャラクター.jl
function 刃に毒を塗る実行イベント通知!(対象者)
    for リスナー in 対象者.刃に毒を塗る実行イベントイベントリスナーs
        リスナー(対象者)
    end
end
#Tキャラクター.jl
mutable struct Tキャラクター共通データ
    ...
    刃に毒を塗る実行イベントリスナーs
    Tキャラクター共通データ(名前, HP, MP, 攻撃力, 防御力, スキルs) = begin
        ...
        new(...
            [刃に毒を塗る実行ui処理!])
    end
end
#ui.jl
function 刃に毒を塗る実行ui処理!(対象者)
    println("$(対象者.名前)は刃に毒を塗った!")
end
#ui_stub.jl
function 刃に毒を塗る実行ui処理!(対象者) end

これでテストが通るはずだ。画面からも動かしておこう。

********************
太郎のターン
選択してください:
   攻撃
 > スキル
選択してください:
   連続攻撃10
   かばう0
   ヒール10
 > 刃に毒を塗る5
太郎は刃に毒を塗った!

「その次のターン以降では、攻撃がヒットすると25%の確率で相手を毒状態にする」の実装

次は攻撃がヒットした時に、相手を毒状態にすることができるようにしたい。

このあたり関数の中で、状態異常を付与する処理を書けばいいのだろう。

function 攻撃実行!(攻撃者, 防御者, コマンド::T通常攻撃)
    ...
    HP減少!(防御者, 防御者ダメージ)
    #このあたりに処理追加?
end

function 攻撃実行!(攻撃者, 防御者, スキル::Tスキル)
            ...
            HP減少!(防御者, 防御者ダメージ)
            #この辺りに処理追加?
            ...
end

ちょっと迷うのが、「ダメージが0だった時に、状態異常を付与できるのか?」と言うところだ。刃に毒を塗ったのだから、かすり傷くらいは与えないと毒を与えることはできないだろう。ということで、ダメージが0でない時に状態異常を付与する判定をするようにしよう。

で、まあそれはおいおい実装するとして、先にテストを作ろうと思うのだが、困ったことがある。25%で毒状態を付与するというのはどうテストすればいいだろうか?確率の問題なので、時と場合により失敗したり成功したりする。自動テストとは相性が悪い。

似たような問題は、「大振り」の時にもあった。大振りのテストは次のようにしたのだ。

@testset "大振り攻撃" begin
    p = createプレイヤー(HP=100, 攻撃力=10)
    m = createモンスター(HP=200, 攻撃力=20)

    プレイヤーからモンスターへ攻撃 = T行動(createスキル(:大振り), p, m)
    行動実行!(プレイヤーからモンスターへ攻撃)
    @test p.HP == 100
    @test m.HP == 180 || m.HP == 200

当たっても外れてもOKとなるようにしている。まあ、苦肉の策という感じである。同じようにしてもいいのだが、そろそろテコ入れしてもいい頃だ。確率の問題はこの先ずっとつきまとう。ちょっと高度なことをしてみたい。

やりたいのは、本体側の処理ではこれまで通りの乱数を使い、テスト側の処理では本物の乱数ではなくテストに都合のいいような固定の数字列を使いたいと言うことである。

実現の発想はUIの時と同じである。本体側の処理とテスト側の処理をスタブを使って変更したいのだ。ただ、UIの時よりややこしい点がある。UIの時には、表示を抑制しさえすればよかった。しかし、今回はテストに都合のいい数列が生成されるようにしてあげる必要があるのだ。

テストの時には乱数ではなく、0から始まり0.1刻みで0.9まで順番に数字の列が生成されるようにしたい。0.9の次は0.1に戻る。

これを実現するためには、なんらかの変数に前回生成した値を保持し、次に「乱数」を取得する時には0.1足してそれを記憶させる、ということを行う必要がある。グローバル変数を使う(最悪!)、クロージャを使う、などの実現手段があるが、あえてそれとは別の「コルーチン」と呼ばれる概念を紹介したい。

コルーチン

コルーチンのスペルはcoroutineである。co-が「協調する」と言う意味で、routineが「手続き」というような意味である。合わせて「協調して動作する手続き」というような意味だろうか。

通常の関数は、呼び出されたら関数内の処理が最後まで実行されて結果を返す。当たり前に思えるが、コルーチンはそうではない。コルーチンは、関数の途中で実行を中断し、途中結果を返すことができる。再度呼び出された時には、その続きから処理を再開する。コルーチンというのは、中断可能な関数、状態を持つ関数、と言うようなイメージである。

早速コルーチンを作ってみよう。まず、今回実現したい動作は、「最初0で、0.1ずつ加算され、1になったら0に戻る」という動きである。これを何も考えずに関数で実装すると次のようになるだろう。

function 数列生成()
    val = 0
    while (true)
        val += 1//10
        if val == 1
            val = 0
        end
    end
end

1//10というのは、有理数、つまり分数の表記である。Juliaは分数を小数に変換することなく、分数としてそのまま扱えるのだ。単に0.1と書くと、これは浮動小数点小数となってしまい、厳密には0.1ではなく微妙な誤差がつきまとう。適当な桁で四捨五入してもいいが、せっかくなので有理数を使うことにした。

もちろん、ここまではただの関数で、しかも無限ループなので終了すらしない。JuliaでこれをコルーチンにするにはChannelというものを使う。次のようにする。

  1. 引数にChannel型の変数を追加する。
  2. コルーチンで中断したいところにput関数を挿入する。
julia> function 数列生成(c::Channel)
           val = 0
           while (true)
               put!(c, float(val))
               val += 1//10
               if val == 1
                   return
               end
           end
       end

put!関数は第一引数にChannel型を、第二引数にコルーチンの呼び出し側へ返る値を取る。float(val)というのは、有理数型のvalを浮動小数点小数に変換している。

コルーチンは呼び出し側にも一工夫必要だ。普通に関数呼び出しするとエラーになる。

julia> x = 数列生成()
ERROR: MethodError: no method matching 数列生成()

まあこれは引数が不一致なので当然と言えば当然だが、それとは無関係に、Channelの使い方は独特だ。

julia> チャンネル = Channel(数列生成)

こうすることで、数列生成というコルーチンを呼び出すための手綱を握ることができる。コルーチンを一回動作させるには、take!という関数を使う。

julia> take!(チャンネル)
0.0

julia> take!(チャンネル)
0.1

julia> take!(チャンネル)
0.2

見事、take!という関数を呼び出すたびに、コルーチン内のput関数まで処理が走って中断していることがわかるだろう。

チャンネルは複数同時に作ることができる。状態は独立に管理される。

julia> 別のチャンネル = Channel(数列生成)

julia> take!(別のチャンネル)
0.0

julia> take!(別のチャンネル)
0.1

julia> take!(チャンネル)
0.3

Channelの基本的な使い方は大体このようなものである。「関数内のどのタイミングで処理を止めるか」を決めるのはコルーチン側であり、「何回コルーチンを呼び出して適切な状態まで進めるか」を決めるのは呼び出し側である。コルーチン側も呼び出し側も、互いに相手のことをよく知っている必要がある。コルーチン側は、呼び出し側が何を必要としているかを知らなければ、いつ処理を止めてどんな情報をputすべきかわからないし、呼び出し側も何回処理を呼び出せば自分の望む状態になってくれるかを知るにはコルーチン処理の詳細を知る必要がある。このあたりがcoroutineの”co”に込められた想いではないかと思う。

乱数生成のスタブ化

本体側の対応

さて、本編に戻ると、コルーチンを使って乱数生成をスタブ化したいのだ。

まず、今までは乱数を取得するためにrand()関数を直接呼び出していたが、これをやめることにしよう。

代わりに、次のようにする。新しいファイルを乱数.jlとして、get乱数生成器は、「呼び出すとrand()関数を実行してくれる関数」を返す。今後は、単なるrand()は使わず、このget乱数生成器から乱数を取得するようにする。

#乱数.jl
using Random

function get乱数生成器()
    return function exec()
        return rand()
    end
end

まずは、これまでの処理がこれまで通り動くように変更しよう。これまでrand()と呼び出していたところは次のように置き換える。(なお、この関数には、rand(スキル.攻撃回数min:スキル.攻撃回数max)と呼び出している箇所もあるが、そこはいったん後回しにする。)

#戦闘.jl
function 攻撃実行!(攻撃者, 防御者, スキル::Tスキル, 乱数生成器)
    ...
    for _ in 1:攻撃回数
        乱数 = 乱数生成器()
        if 乱数 < スキル.命中率
    ....
end

乱数生成器自体は外から引数として与えられるようにしている。本体側の処理だと真っ当な乱数生成器が、テスト側の処理だとスタブ版の乱数生成器が渡される必要があるためだ。

呼び出し元にも引数を追加していく。

#戦闘.jl
function 行動実行!(::T攻撃系行動, 行動::T行動, 乱数生成器) 
    攻撃実行!(行動.行動者, 行動.対象者, 行動.コマンド, 乱数生成器)
    ...
end
#戦闘.jl
function 行動実行!(行動::T行動, 乱数生成器)
    行動実行!(行動系統(行動.コマンド), 行動, 乱数生成器)
end

芋づる式に、下記のメソッドにも引数を追加する。

#戦闘.jl
function 行動実行!(::T回復系行動, 行動::T行動, 乱数生成器) 
    ...
end

function 行動実行!(::Tかばう行動, 行動::T行動, 乱数生成器) 
    ...
end

function 行動実行!(::T刃に毒を塗る行動, 行動::T行動, 乱数生成器) 
    ...
end

さらに遡る。

#戦闘.jl
function ゲームループ(プレイヤーs, モンスターs, 乱数生成器)
    ...
                行動実行!(行動, 乱数生成器)
    ...
end

さらに遡る。

#game.jl
function main(乱数生成器)
    ...
    ゲームループ([プレイヤー1, プレイヤー2, プレイヤー3, プレイヤー4], [モンスター], 乱数生成器)
    ...

そうして、最終的には、game_exec.jlまでたどり着く。乱数.jlをincludeし、main関数の引数に、get乱数生成器()を渡している。

#game_exec.jl
module Game

include("乱数.jl")
....

main(get乱数生成器())

これで試しに起動してみよう。問題なく動くはずだ。

テスト側の対応

次に、テスト側の対応を行う。スタブ版の乱数生成器を作るget乱数生成器stub()を定義しよう。

#乱数.jl
function get乱数生成器stub()
    function 数列生成(c::Channel)
        val = 0
        while (true)
            put!(c, float(val))
            val += 1//10
            if val == 1
                val = 0
            end
        end
    end
    chnl = Channel(数列生成);

    return function exec()
        return take!(chnl)
    end
end

先ほど例に出した数列生成コルーチンを内部で使っている。Channeltake!などはクロージャの中に隠蔽し、コルーチンであることはあまり意識する必要がないようにしている。get乱数生成器stub()が生成する「乱数生成器」は、通常の乱数生成器と同じに扱える。

あとは、テスト側のコードで、乱数.jlをincludeし、必要な引数を追加していく。

#game_test.jl
module GameTest

...
include("乱数.jl")
...

特に乱数をコントロールしたくなければ、通常のget乱数生成器()を渡す。

@testset "行動実行!" begin
    @testset "通常攻撃" begin
        ...
        行動実行!(プレイヤーからモンスターへ攻撃, get乱数生成器())
        ...
    end

一方で、乱数をコントロールしたいテストでは、get乱数生成器stub()を使い、0から始まり0.1刻みで増える性質を利用したテストケースを作ることができる。

    @testset "大振り攻撃" begin
        p = createプレイヤー(HP=1000, 攻撃力=10)
        m = createモンスター(HP=2000, 攻撃力=20)

        乱数生成器 = get乱数生成器stub()

        プレイヤーからモンスターへ攻撃 = T行動(createスキル(:大振り), p, m)
        for i in 1:4 #40%の確率でヒット
            行動実行!(プレイヤーからモンスターへ攻撃, 乱数生成器)
            @test p.HP == 1000
            @test m.HP == 2000 - i * 20
        end
        for i in 1:6 #60%の確率で外れる
            行動実行!(プレイヤーからモンスターへ攻撃, 乱数生成器)
            @test p.HP == 1000
            @test m.HP == 1920
        end

        モンスターからプレイヤーへ攻撃 = T行動(createスキル(:大振り), m, p)
        for i in 1:4 #40%の確率でヒット
            行動実行!(モンスターからプレイヤーへ攻撃, 乱数生成器)
            @test p.HP == 1000 - i * 40
            @test m.HP == 1920
        end
        for i in 1:6 #60%の確率で外れる
            行動実行!(モンスターからプレイヤーへ攻撃, 乱数生成器)
            @test p.HP == 840
            @test m.HP == 1920
        end
    end 

上記では、40%の確率でヒットすることを確認するために行動実行!を何度も呼んでいるため、HPを元のテストケースの10倍にした。なお、行動実行!に渡す前に何回も乱数生成器()を呼んでもいいし、なんならget乱数生成器stub()を改良して、初期値を0以外にできるように、引数をとるようにしてもいい。

get乱数生成器()は引数を取らず、get乱数生成器stub()が引数をとるように変わることに違和感があるかもしれないが、スタブはテストのための補助なので、テストが書きやすいようになっていることを優先すれば良い。

必要な引数を全ての箇所で渡したら、自動テストを実行しておこう。成功を祈る。

スタブ化の方法

今回の記事の最初で提示したui処理関数のスタブ化と、今提示した乱数生成器のスタブ化は少しやり方が違うことに気づいただろう。前者は同名の関数を別ファイルに定義し、本体とテストでincludeするファイルを変えることで実現した。後者は、外部から渡す「乱数生成器」を渡し分けることで実現した。スタブ化は、本体側の処理という文脈と、テストの処理という文脈で動作を変更することさえできればいいので、私は実現手段にはこだわらないが、前者の実現方法は、かなりJuliaに特有のやり方という印象である。後者の方が一般的だ。

ちなみに後者のような、そのモジュールが依存しているものを外部から渡すようにするやり方を「依存性の注入(Dependency Injection)」という呼び方をしたりする。DIと略されることも多い。

「刃に毒を塗る」の残りの実装

「刃に毒を塗る」の実装は道半ばだが、長くなってきたので一旦この辺りで終わりにしよう。次は、25%の確率で毒にするところの続きから作りたい。その後は、

  • 一度相手を毒状態にすると、毒を与える効果が切れる。再度毒を塗ることはできる。
  • 毒状態のキャラクターは1ターンごとにダメージを受ける。雑魚敵とボス敵で威力が異なる。

というところを実装していく。ただ、最後に「辞書」というデータ構造の威力について、どうしても語っておきたい。

自前のディスパッチシステム

辞書は柔軟なデータ構造だ。キーとして登録するのは、文字列やシンボルにすることが多いが、値としては本当に様々なものが登録される。例えば、値として別の辞書を登録することもできるし、関数を登録することもある。これを応用することで、自前で簡単なディスパッチシステムを作ることもできる。

Juliaが得意とする、型によるディスパッチとはなんだっただろうか?これは引数の型の種類により、実行される関数が自動で選択されると言うことだった。型とはなんだったろうか?データの種類を明示する仕組みである。

julia> struct Tプレイヤー
         name
       end

julia> struct Tモンスター
         name
       end

julia> function 表示(p::Tプレイヤー)
         println("ハーイ、プレイヤー$(p.name)です")
       end

julia> function 表示(m::Tモンスター)
         println("ハーイ、モンスター$(m.name)です")
       end

julia> 表示(Tプレイヤー("太郎"))
ハーイ、プレイヤー太郎です

julia> 表示(Tモンスター("ドラゴン"))
ハーイ、モンスタードラゴンです

これはもちろんJuliaの機能を使って実現されているわけだが、これと同じようなことを自前でやりたいと言うことである。

まずは、データの種類を保持するフィールドtagを持つ型Tキャラクターを定義する。

julia> struct Tキャラクター
         tag
         name
       end

julia> 太郎 = Tキャラクター(:プレイヤー, "太郎")

julia> ドラゴン = Tキャラクター(:モンスター, "ドラゴン")

次に、表示関数を作る。

julia> function 表示(c::Tキャラクター)
         if c.tag === :プレイヤー
           println("ハーイ、プレイヤー$(c.name)です")
         elseif c.tag === :モンスター
           println("ハーイ、モンスター$(c.name)です")
         end
       end

こうすると、期待通りの動きをする。ここまではまだ辞書は使っていない。

julia> 表示(太郎)
ハーイ、プレイヤー太郎です

julia> 表示(ドラゴン)
ハーイ、モンスタードラゴンです

さて、今の状態はあまり良くない。大事な「加法性」が失われている。今は表示関数の中で:プレイヤー:モンスターが混ざってしまっている。「そのくらいガタガタ言うなよ!このオタンコナス!」と言われるかもしれないが、これは大きな問題なのだ。今は表示関数だけだからいいが、同じ要領で関数がどんどん増えていき、全ての関数にこのif文が入っていったらどうなるのか?プレイヤー、モンスターに次ぐ第3のデータが来たときに、それら全てを直して回る必要がある。データの種類が増えるたびに、どんどん関数が複雑になっていく。これは嫌だ。もともとの型によるディスパッチでは、関数は全て型ごとに独立している。この形を目指したい。

このために辞書を使う。辞書にタグと関数のペアを登録していく。これは通常、初期化のような処理で行われることになるだろう。

julia> 表示辞書 = Dict()

julia> 表示辞書[:プレイヤー] = c -> println("ハーイ、プレイヤー$(c.name)です")

julia> 表示辞書[:モンスター] = c -> println("ハーイ、モンスター$(c.name)です")

簡潔さを優先してラムダ式にしたが、どこか別の場所で定義された関数を渡してもいい。大事なのは、:プレイヤー:モンスターというタグ別に関数を取り扱っているというところだ。

表示辞書を使うことで、表示関数は、内部では辞書に登録された関数を呼び出すだけになる。

julia> function 表示(c::Tキャラクター)
         表示辞書[c.tag](c)
       end

少し解説しておくと、表示辞書[c.tag]で、tagをキーにして、辞書に登録した関数を取得し、その関数に引数としてcを渡している、という処理になっている。

こうすると期待通りの動きをする。

julia> 表示(太郎)
ハーイ、プレイヤー太郎です

julia> 表示(ドラゴン)
ハーイ、モンスタードラゴンです

辞書を使うだけで、tagデータによって関数を独立して登録、自動選択される仕組みを作ることができた。

もっと汎用的にすることもできる。現状では、表示という関数に対して、表示辞書という辞書を作った。今後、関数が増えるたびに対応する辞書が増えるのも大変だ。そう思ったら、タプルをキーにする辞書を作って、関数名とタグの組み合わせで決まるようにしてもいい。

julia> 関数辞書 = Dict()

julia> 関数辞書[(:表示, :プレイヤー)] = c -> println("ハーイ、プレイヤー$(c.name)です")

julia> 関数辞書[(:表示, :モンスター)] = c -> println("ハーイ、モンスター$(c.name)です")

julia> function 表示(c::Tプレイヤー)
         関数辞書[(:表示, c.tag)](c)
       end

julia> 表示(太郎)
ハーイ、プレイヤー太郎です

julia> 表示(ドラゴン)
ハーイ、モンスタードラゴンです

もしも、関数名とデータタグが同列なのが気持ち悪ければ、次のように、辞書を2段階にしても良い。

julia> 関数辞書 = Dict()

julia> 関数辞書[:表示] = 表示辞書

julia> function 表示(c::Tプレイヤー)
         関数辞書[:表示][c.tag](c)
       end

最初に、「関数名を受け取って、対応する辞書を返す」辞書から値を取得している(関数辞書[:表示])。そして、その辞書にタグを渡すことで、適用すべき関数を取得し、その関数に引数を渡している。

この表示関数もうまく動く。

julia> 表示(太郎)
ハーイ、プレイヤー太郎です

julia> 表示(ドラゴン)
ハーイ、モンスタードラゴンです

さらに汎用的にしたくなるかもしれない。次の部分だ。関数名が表示、辞書に渡しているシンボルも:表示、ここをなんとかできないだろうか?

julia> function 表示(c::Tプレイヤー)
         関数辞書[:表示][c.tag](c)
       end

これは今までの延長線上では実現できない。なんと言っても、関数名は手でベタ書きしている部分だからだ。与えられた引数を元に、関数を自動生成したい。これを実現するには、「メタプログラミング」という技法が必要になる。マクロとか、evalとかいうやつらだ。これは後のお楽しみにしておこう。

このように辞書を使うことで、Juliaが言語機能として提供する「型によるディスパッチ」を模倣することができた。もちろん、多重ディスパッチをするには複数のタグに対応する必要があるし、関数の引数だって1つとは限らないなど、課題は多い。普通であれば、わざわざこんなことをする必要はない。Juliaの言語機能に頼るべきだ。

だが、こういうことが可能であると知っておくのも良いことだと思う。

第10回のまとめ

いよいよ第10回ということで、2桁に到達してしまった。ここまでくると文法事項もかなりマニアックなものになってきた。コルーチンなどはそれなりの規模のプログラムであっても、全く使われないということもある機能だ。

気がかりなのは未だ太郎くんのスキルすら実装が終わっていないことだ。一通りスキルを実装し終えるまで、まだかなりかかりそうである。しかもその先にはドラゴン以外のモンスターの実装も待っている。青銅魔人の紹介をしたのを覚えているだろうか?一体いつ実装できることやら、見当もつかない。

とまあ一抹の不安は残るが、まあ期限があるわけでもなし、のんびり実装していこう。

続き

第11回

コード

ここまでのコードを書いておこう。

#game_exec.jl
module Game

include("乱数.jl")
include("ui.jl")
include("game.jl")

main(get乱数生成器())

end
#game.jl
import REPL
using REPL.TerminalMenus

include("キャラクター.jl")
include("戦闘.jl")

function モンスター遭遇イベント通知!(リスナーs)
    for リスナー in リスナーs
        リスナー()
    end
end

function 戦闘勝利イベント通知!(リスナーs)
    for リスナー in リスナーs
        リスナー()
    end
end

function 戦闘敗北イベント通知!(リスナーs)
    for リスナー in リスナーs
        リスナー()
    end
end


function main(乱数生成器)
    モンスター = Tモンスター("ドラゴン", 400, 80, 40, 10, [createスキル(:連続攻撃)])
    プレイヤー1 = Tプレイヤー("太郎", 100, 20, 10, 10, [createスキル(:連続攻撃), createスキル(:かばう), createスキル(:ヒール), createスキル(:刃に毒を塗る)])
    プレイヤー2 = Tプレイヤー("花子", 100, 20, 10, 10, [createスキル(:大振り), createスキル(:連続攻撃)])
    プレイヤー3 = Tプレイヤー("遠藤君", 100, 20, 10, 10, [createスキル(:大振り), createスキル(:かばう)])
    プレイヤー4 = Tプレイヤー("高橋先生", 100, 20, 10, 10, [createスキル(:ヒール), createスキル(:連続攻撃)])

    モンスター遭遇イベント通知!([モンスター遭遇イベントui処理!])

    ゲームループ([プレイヤー1, プレイヤー2, プレイヤー3, プレイヤー4], [モンスター], 乱数生成器)

    if モンスター.HP == 0
        戦闘勝利イベント通知!([戦闘勝利イベントui処理!])
    else
        戦闘敗北イベント通知!([戦闘敗北イベントui処理!])
    end
end
#Tキャラクター.jl
mutable struct Tキャラクター共通データ
    名前
    HP
    最大HP
    MP
    攻撃力
    防御力
    物理攻撃時状態異常付与確率
    スキルs
    かばっているキャラクター
    かばってくれているキャラクター
    行動前処理イベントリスナーs
    戦闘不能イベントリスナーs
    攻撃実行イベントリスナーs
    回復実行イベントリスナーs
    かばう実行イベントリスナーs
    かばう発動イベントリスナーs
    かばう解除イベントリスナーs
    HP減少イベントリスナーs
    HP回復イベントリスナーs
    攻撃失敗イベントリスナーs
    行動決定イベントリスナーs
    刃に毒を塗る実行イベントリスナーs
    Tキャラクター共通データ(名前, HP, MP, 攻撃力, 防御力, スキルs) = begin
        if HP < 0
            throw(DomainError("HPが負の値になっています"))
        end
        if MP < 0
            throw(DomainError("MPが負の値になっています"))
        end        
        if 攻撃力 < 0
            throw(DomainError("消費MPが負の値になっています"))
        end 
        if 防御力 ≤ 0
            throw(DomainError("防御力が0または負の値になっています"))
        end 
        new(名前, HP, HP, MP, 攻撃力, 防御力, Dict(), スキルs, nothing, nothing, 
            [getかばう解除!(:行動前処理)], [getかばう解除!(:戦闘不能)], [攻撃実行ui処理!], [回復実行ui処理!], 
            [かばう実行ui処理!], [かばう発動ui処理!], [かばう解除ui処理!], 
            [HP減少ui処理!], [HP回復ui処理!], [攻撃失敗ui処理!], [行動決定ui処理!],
            [刃に毒を塗る実行ui処理!])
    end
end

abstract type Tキャラクター end

mutable struct Tプレイヤー <: Tキャラクター
    _キャラクター共通データ::Tキャラクター共通データ
end

mutable struct Tモンスター <: Tキャラクター
    _キャラクター共通データ::Tキャラクター共通データ
end
#ui_stub.jl
include("スキル.jl")
include("Tキャラクター.jl")

function 攻撃実行ui処理!(攻撃者, コマンド::T通常攻撃) end
function 攻撃実行ui処理!(行動者, スキル::Tスキル) end
function 回復実行ui処理!(行動者, スキル::Tスキル) end
function スキル実行ui処理!(行動者, スキル::Tスキル) end
function かばう実行ui処理!(行動者, 対象者) end
function かばう発動ui処理!(防御者) end
function かばう解除ui処理_行動前処理!(行動者, 対象者) end
function かばう解除ui処理_戦闘不能!(行動者, 対象者) end
function HP減少ui処理!(防御者, 防御者ダメージ) end
function HP回復ui処理!(対象者, 回復量) end
function 行動決定ui処理!(行動者::Tプレイヤー, プレイヤーs, モンスターs) end
function 攻撃失敗ui処理!() end
function 刃に毒を塗る実行ui処理!(対象者) end
#ui.jl
include("スキル.jl")
include("行動系統.jl")
include("Tキャラクター.jl")

function コマンド選択(行動者::Tプレイヤー, プレイヤーs, モンスターs)
    function get対象リスト(スキル::T行動内容)
        get対象リスト(行動系統(スキル))
    end

    function get対象リスト(::T攻撃系行動)
        return モンスターs
    end

    function get対象リスト(::T回復系行動)
        return プレイヤーs
    end

    function get対象リスト(::Tかばう行動)
        return filter(p -> p != 行動者 && isnothing(p.かばってくれているキャラクター), プレイヤーs)
    end

    function get対象リスト(::T刃に毒を塗る行動)
        return [行動者]
    end

    function RadioMenu作成(選択肢)
        while true
            r = RadioMenu(選択肢, pagesize=4)
            選択index = request("選択してください:", r)

            if 選択index == -1
                println("正しいコマンドを入力してください")
                continue
            else
                return 選択index
            end
        end
    end

    function 行動対象を選択し行動を決定(行動内容::T行動内容)
        対象リスト = get対象リスト(行動内容)
        if length(対象リスト) == 1
            return T行動(行動内容, 行動者, 対象リスト[1])
        else
            選択index = RadioMenu作成([s.名前 for s in 対象リスト])
            対象者 = 対象リスト[選択index]
            return T行動(行動内容, 行動者, 対象者)
        end
    end

    while true
        選択肢 = ["攻撃", "スキル"]
        選択index = RadioMenu作成(選択肢)
        選択 = 選択肢[選択index]
        if 選択 == "攻撃"
            return 行動対象を選択し行動を決定(T通常攻撃())
        elseif 選択 == "スキル"
            選択index = RadioMenu作成([s.名前 * string(s.消費MP) for s in 行動者.スキルs])
            選択スキル = 行動者.スキルs[選択index]
            if 行動者.MP < 選択スキル.消費MP 
                println("MPが足りません")
                continue
            end
            return 行動対象を選択し行動を決定(選択スキル)
        else
            throw(DomainError("行動選択でありえない選択肢が選ばれています"))
        end
    end 
end

function 戦況表示(プレイヤーs, モンスターs)
    結果 = []
    push!(結果, "*****プレイヤー*****")
    for p in プレイヤーs
        push!(結果, "$(p.名前) HP:$(p.HP) MP:$(p.MP)")
    end
    push!(結果, "*****モンスター*****")
    for m in モンスターs
        push!(結果, "$(m.名前) HP:$(m.HP) MP:$(m.MP)")
    end
    push!(結果, "********************")
    return join(結果, "\n")
end

function 攻撃実行ui処理!(攻撃者, コマンド::T通常攻撃)
    println("----------")
    println("$(攻撃者.名前)の攻撃!")
end

function 攻撃実行ui処理!(行動者, スキル::Tスキル)
    スキル実行ui処理!(行動者, スキル)
end

function 回復実行ui処理!(行動者, スキル::Tスキル)
    スキル実行ui処理!(行動者, スキル)
end

function スキル実行ui処理!(行動者, スキル::Tスキル)
    println("----------")
    println("$(行動者.名前)の$(スキル.名前)!")
end

function かばう実行ui処理!(行動者, 対象者)
    println("----------")
    println("$(行動者.名前)は$(対象者.名前)を身を呈して守る構えをとった!")
end

function かばう発動ui処理!(防御者)
    println("$(防御者.かばってくれているキャラクター.名前)が代わりに攻撃を受ける!")
end

function かばう解除ui処理_行動前処理!(行動者, 対象者)
    println("$(行動者.名前)は$(対象者.名前)をかばうのをやめた!")
end

function かばう解除ui処理_戦闘不能!(行動者, 対象者)
    println("$(行動者.名前)は$(対象者.名前)をかばえなくなった!")    
end

function HP減少ui処理!(防御者, 防御者ダメージ)
    println("$(防御者.名前)は$(防御者ダメージ)のダメージを受けた!")
    println("$(防御者.名前)の残りHP:$(防御者.HP)")
end

function HP回復ui処理!(対象者, 回復量)
    println("$(対象者.名前)のHPが$(回復量)回復した!")
    println("$(対象者.名前)の残りHP:$(対象者.HP)")
end

function 行動決定ui処理!(行動者::Tプレイヤー, プレイヤーs, モンスターs)
    println(戦況表示(プレイヤーs, モンスターs))
    println("$(行動者.名前)のターン")
end

function 攻撃失敗ui処理!()
    println("攻撃は失敗した・・・")
end

function モンスター遭遇イベントui処理!()
    println("モンスターに遭遇した!")
    println("戦闘開始!")
end

function 戦闘勝利イベントui処理!()
    println("戦闘に勝利した!")
end

function 戦闘敗北イベントui処理!()
    println("戦闘に敗北した・・・")
end

function 刃に毒を塗る実行ui処理!(対象者)
    println("$(対象者.名前)は刃に毒を塗った!")
end
#キャラクター.jl
include("Tキャラクター.jl")
include("スキル.jl")

function Tプレイヤー(名前, HP, MP, 攻撃力, 防御力, スキルs)
    return Tプレイヤー(Tキャラクター共通データ(名前, HP, MP, 攻撃力, 防御力, スキルs))    
end


function Base.getproperty(obj::Tキャラクター, sym::Symbol)
    if sym in fieldnames(Tキャラクター共通データ)
        return Base.getproperty(obj._キャラクター共通データ, sym)
    end
    return Base.getfield(obj, sym)
end

function Base.setproperty!(obj::Tキャラクター, sym::Symbol, val)
    if sym in fieldnames(Tキャラクター共通データ)
        return Base.setproperty!(obj._キャラクター共通データ, sym, val)
    end
    return Base.setfield!(obj, sym, val)
end

function Tモンスター(名前, HP, MP, 攻撃力, 防御力, スキルs)
    return Tモンスター(Tキャラクター共通データ(名前, HP, MP, 攻撃力, 防御力, スキルs))    
end

function 戦闘不能イベント通知!(防御者::Tキャラクター)
    for リスナー in 防御者.戦闘不能イベントリスナーs
        リスナー(防御者)
    end
end

function HP減少!(防御者, ダメージ)
    if ダメージ < 0
        throw(DomainError("ダメージがマイナスです"))
    end

    実際ダメージ = ダメージ < 防御者.HP ? ダメージ : 防御者.HP
    防御者.HP -= 実際ダメージ
    HP減少イベント通知!(防御者, ダメージ)

    if 防御者.HP == 0
        戦闘不能イベント通知!(防御者)
    end
end

function HP回復!(対象者, 回復量)
    if 回復量 < 0
        throw(DomainError("回復量がマイナスです"))
    end

    HP減少量 = 対象者.最大HP - 対象者.HP
    実際回復量 = HP減少量 > 回復量 ? 回復量 : HP減少量

    対象者.HP += 実際回復量
    HP回復イベント通知!(対象者, 実際回復量)
end


function MP減少!(行動者, コマンド::T通常攻撃)
end

function MP減少!(行動者, コマンド::Tスキル)
    消費MP = コマンド.消費MP
    if 消費MP < 0
        throw(DomainError("ダメージがマイナスです"))
    end    
    if 行動者.MP - 消費MP < 0
        行動者.MP = 0
    else
        行動者.MP = 行動者.MP - 消費MP
    end
end

function is行動可能(キャラクター)
    if キャラクター.HP < 0
        throw(DomainError("キャラクターのHPが負です"))
    end
    return キャラクター.HP > 0
end

function 行動可能な奴ら(キャラクターs)
    return [c for c in キャラクターs if is行動可能(c)]
end

function 攻撃実行イベント通知!(攻撃者, コマンド)
    for リスナー in 攻撃者.攻撃実行イベントリスナーs
        リスナー(攻撃者, コマンド)
    end
end

function 回復実行イベント通知!(行動者, コマンド)
    for リスナー in 行動者.回復実行イベントリスナーs
        リスナー(行動者, コマンド)
    end
end

function 攻撃失敗イベント通知!(攻撃者)
    for リスナー in 攻撃者.攻撃失敗イベントリスナーs
        リスナー()
    end
end

function かばう実行イベント通知!(行動者, 対象者)
    for リスナー in 行動者.かばう実行イベントリスナーs
        リスナー(行動者, 対象者)
    end
end

function かばう発動イベント通知!(防御者)
    for リスナー in 防御者.かばう発動イベントリスナーs
        リスナー(防御者)
    end
end

function かばう解除イベント通知!(行動者, 防御者, かばう解除トリガ)
    for リスナー in 防御者.かばう解除イベントリスナーs
        リスナー(行動者, 防御者, かばう解除トリガ)
    end
end

function HP減少イベント通知!(防御者, 防御者ダメージ)
    for リスナー in 防御者.HP減少イベントリスナーs
        リスナー(防御者, 防御者ダメージ)
    end
end

function HP回復イベント通知!(対象者, 回復量)
    for リスナー in 対象者.HP回復イベントリスナーs
        リスナー(対象者, 回復量)
    end
end

function 行動決定イベント通知!(行動者::Tプレイヤー, プレイヤーs, モンスターs)
    for リスナー in 行動者.行動決定イベントリスナーs
        リスナー(行動者, プレイヤーs, モンスターs)
    end
end

function 刃に毒を塗る実行イベント通知!(対象者)
    for リスナー in 対象者.刃に毒を塗る実行イベントリスナーs
        リスナー(対象者)
    end
end
abstract type T行動内容 end 
abstract type Tスキル <: T行動内容 end 
struct T通常攻撃 <: T行動内容 end
struct Tかばう <: Tスキル 
    名前
    消費MP
end

function Tかばう() 
    return Tかばう("かばう", 0)
end

struct T攻撃スキル <: Tスキル
    名前
    威力
    命中率
    消費MP
    攻撃回数min
    攻撃回数max
    T攻撃スキル(名前, 威力, 命中率, 消費MP, 攻撃回数min, 攻撃回数max) = begin
        if 威力 < 0
            throw(DomainError("威力が負の値になっています"))
        end
        if !(0 ≤ 命中率 ≤ 1)
            throw(DomainError("命中率は0から1の間でなければなりません"))
        end        
        if 消費MP < 0
            throw(DomainError("消費MPが負の値になっています"))
        end 
        if 攻撃回数min < 0
            throw(DomainError("攻撃回数minが負の値になっています"))
        end 
        if 攻撃回数max < 0
            throw(DomainError("攻撃回数maxが負の値になっています"))
        end 
        if 攻撃回数max < 攻撃回数min 
            throw(DomainError("攻撃回数maxが攻撃回数minより小さくなっています"))
        end 
        new(名前, 威力, 命中率, 消費MP, 攻撃回数min, 攻撃回数max)  
    end
end

function T攻撃スキル(名前, 威力, 命中率, 消費MP) 
    return T攻撃スキル(名前, 威力, 命中率, 消費MP, 1, 1)
end

struct T回復スキル <: Tスキル
    名前
    回復割合
    消費MP
    T回復スキル(名前, 回復割合, 消費MP) = begin
        if !(0 ≤ 回復割合 ≤ 1)
            throw(DomainError("回復割合は0から1の間でなければなりません"))
        end        
        if 消費MP < 0
            throw(DomainError("消費MPが負の値になっています"))
        end 
        new(名前, 回復割合, 消費MP)
    end
end

struct T刃に毒を塗る <: Tスキル 
    名前
    消費MP
end

function T刃に毒を塗る() 
    return T刃に毒を塗る("刃に毒を塗る", 5)
end

function createスキル(スキルシンボル)
    if スキルシンボル === :大振り
        return T攻撃スキル("大振り", 2, 0.4, 0)
    elseif スキルシンボル === :連続攻撃
        return T攻撃スキル("連続攻撃", 0.5, 1, 10, 2, 5)
    elseif スキルシンボル === :かばう
        return Tかばう()
    elseif スキルシンボル === :ヒール
        return T回復スキル("ヒール", 0.5, 10)
    elseif スキルシンボル === :刃に毒を塗る
        return T刃に毒を塗る()
    else
        Throw(DomainError("未定義のスキルが指定されました"))
    end
end

function かばうデータ整合性チェック(キャラクター)
    if !isnothing(キャラクター.かばっているキャラクター)
        if (キャラクター.かばっているキャラクター.かばってくれているキャラクター != キャラクター)
            throw(DomainError("$(キャラクター.名前)の「かばう」データに不整合が発生しています"))
        end
    end

    if !isnothing(キャラクター.かばってくれているキャラクター)
        if (キャラクター.かばってくれているキャラクター.かばっているキャラクター != キャラクター)
            throw(DomainError("$(キャラクター.名前)の「かばう」データに不整合が発生しています"))
        end
    end
end

function かばう実行!(行動者, 対象者)
    かばう実行イベント通知!(行動者, 対象者)
    行動者.かばっているキャラクター = 対象者
    対象者.かばってくれているキャラクター = 行動者

    #事後条件
    かばうデータ整合性チェック(行動者)
    かばうデータ整合性チェック(対象者)
end

function getかばう解除!(かばう解除トリガ)
    if !(かばう解除トリガ in [:行動前処理, :戦闘不能]) 
        throw(DomainError("想定していないトリガでかばうが解除されました"))
    end

    return function かばう解除!(行動者)
        if !isnothing(行動者.かばっているキャラクター)
            対象者 = 行動者.かばっているキャラクター
            かばう解除イベント通知!(行動者, 対象者, かばう解除トリガ)
            行動者.かばっているキャラクター = nothing                    
            対象者.かばってくれているキャラクター = nothing
            #事後条件
            かばうデータ整合性チェック(行動者)
            かばうデータ整合性チェック(対象者)
        end
    end
end

#関数名が紛らわしいが、「かばうを解除する時にメッセージを出し分けたい」という
#モデルの処理なのでUI層ではなくモデル層に定義
function かばう解除ui処理!(行動者, 対象者, かばう解除トリガ)
    if かばう解除トリガ === :行動前処理
        かばう解除ui処理_行動前処理!(行動者, 対象者)
    elseif かばう解除トリガ === :戦闘不能
        かばう解除ui処理_戦闘不能!(行動者, 対象者)
    else
        throw(DomainError("想定していないトリガでかばうが解除されました"))
    end
end

function 刃に毒を塗る実行!(対象者)
    刃に毒を塗る実行イベント通知!(対象者)
    対象者.物理攻撃時状態異常付与確率[:毒] = 0.25
end
#行動系統.jl
struct T攻撃系行動 end
struct T回復系行動 end
struct Tかばう行動 end
struct T刃に毒を塗る行動 end

行動系統(::T通常攻撃) = T攻撃系行動()
行動系統(::T攻撃スキル) = T攻撃系行動()
行動系統(::T回復スキル) = T回復系行動()
行動系統(::Tかばう) = Tかばう行動()
行動系統(::T刃に毒を塗る) = T刃に毒を塗る行動()
#戦闘.jl
include("行動系統.jl")

struct T行動
    コマンド::T行動内容
    行動者::Tキャラクター
    対象者::Tキャラクター
end

function ダメージ計算(攻撃力, 防御力)
    if 攻撃力 < 0
        throw(DomainError("攻撃力が負の値になっています"))
    end
    if 防御力 ≤ 0
        throw(DomainError("防御力が0または負の値になっています"))
    end
    return round(Int, 10 * 攻撃力/防御力)
end

function 攻撃実行!(攻撃者, 防御者, コマンド::T通常攻撃, 乱数生成器)
    攻撃実行イベント通知!(攻撃者, コマンド)
    if !isnothing(防御者.かばってくれているキャラクター)
        かばう発動イベント通知!(防御者)
        防御者 = 防御者.かばってくれているキャラクター
    end
    防御者ダメージ = ダメージ計算(攻撃者.攻撃力, 防御者.防御力)
    HP減少!(防御者, 防御者ダメージ)
end

function 攻撃実行!(攻撃者, 防御者, スキル::Tスキル, 乱数生成器)
    攻撃実行イベント通知!(攻撃者, スキル)
    if !isnothing(防御者.かばってくれているキャラクター)
        かばう発動イベント通知!(防御者)
        防御者 = 防御者.かばってくれているキャラクター
    end
    攻撃回数 = rand(スキル.攻撃回数min:スキル.攻撃回数max)
    for _ in 1:攻撃回数
        乱数 = 乱数生成器()
        if 乱数 < スキル.命中率
            防御者ダメージ = ダメージ計算(攻撃者.攻撃力 * スキル.威力, 防御者.防御力)
            HP減少!(防御者, 防御者ダメージ)
        else
            攻撃失敗イベント通知!(攻撃者)
        end
    end
end

function 回復量計算(最大HP, 回復割合)
    if 最大HP < 0
        throw(DomainError("最大HPが負の値になっています"))
    end
    if 回復割合 < 0 || 1 < 回復割合 
        throw(DomainError("回復割合が0から1の範囲を超えています"))
    end
    return round(Int, 最大HP * 回復割合, RoundDown) 
end


function 回復実行!(行動者, 対象者, スキル::T回復スキル)
    回復実行イベント通知!(行動者, スキル)
    回復量 = 回復量計算(対象者.最大HP, スキル.回復割合)
    HP回復!(対象者, 回復量)
end

function 行動決定(行動者::Tプレイヤー, プレイヤーs, モンスターs)
    行動決定イベント通知!(行動者, プレイヤーs, モンスターs)
    return コマンド選択(行動者, プレイヤーs, モンスターs)
end

function get選択可能行動内容(行動者::Tキャラクター)
    選択可能行動内容 = T行動内容[]
    push!(選択可能行動内容, T通常攻撃())
    選択可能スキル = filter(s -> s.消費MP ≤ 行動者.MP, 行動者.スキルs)
    append!(選択可能行動内容, 選択可能スキル)
    return 選択可能行動内容
end

function 行動決定(行動者::Tモンスター, プレイヤーs, モンスターs)
    選択可能行動内容 = get選択可能行動内容(行動者)
    行動内容 = rand(選択可能行動内容)
    return T行動(行動内容, 行動者, rand(行動可能な奴ら(プレイヤーs)))
end

function 行動実行!(行動::T行動, 乱数生成器)
    行動実行!(行動系統(行動.コマンド), 行動, 乱数生成器)
end

function 行動実行!(::T攻撃系行動, 行動::T行動, 乱数生成器) 
    攻撃実行!(行動.行動者, 行動.対象者, 行動.コマンド, 乱数生成器)
    MP減少!(行動.行動者, 行動.コマンド)
end

function 行動実行!(::T回復系行動, 行動::T行動, 乱数生成器) 
    回復実行!(行動.行動者, 行動.対象者, 行動.コマンド)
    MP減少!(行動.行動者, 行動.コマンド)
end

function 行動実行!(::Tかばう行動, 行動::T行動, 乱数生成器) 
    かばう実行!(行動.行動者, 行動.対象者)
end

function 行動実行!(::T刃に毒を塗る行動, 行動::T行動, 乱数生成器) 
    刃に毒を塗る実行!(行動.対象者)
    MP減少!(行動.行動者, 行動.コマンド)
end

function 行動前処理イベント通知!(行動者::Tキャラクター)
    for リスナー in 行動者.行動前処理イベントリスナーs
        リスナー(行動者)        
    end
end

function 行動前処理!(行動者::Tキャラクター, プレイヤーs, モンスターs)
    行動前処理イベント通知!(行動者)
end

function  is全滅(キャラクターs)
    return all(p.HP == 0 for p in キャラクターs)
end

function is戦闘終了(プレイヤーs, モンスターs)
    return is全滅(プレイヤーs) || is全滅(モンスターs)
end

function 行動順決定(プレイヤーs, モンスターs)
    行動順 = vcat(プレイヤーs, モンスターs)
    return shuffle(行動順)
end

function ゲームループ(プレイヤーs, モンスターs, 乱数生成器)
    while true
        for 行動者 in 行動順決定(プレイヤーs, モンスターs)
            if is行動可能(行動者)
                行動前処理!(行動者, プレイヤーs, モンスターs)
                行動 = 行動決定(行動者, プレイヤーs, モンスターs)
                行動実行!(行動, 乱数生成器)
                if is戦闘終了(プレイヤーs, モンスターs)
                    return
                end
            end
        end
    end
end
#乱数.jl
using Random

function get乱数生成器()
    return function exec()
        return rand()
    end
end

function get乱数生成器stub()
    function 数列生成(c::Channel)
        val = 0
        while (true)
            put!(c, float(val))
            val += 1//10
            if val == 1
                val = 0
            end
        end
    end
    chnl = Channel(数列生成);

    return function exec()
        return take!(chnl)
    end
end

テストコードは下記になる。

module GameTest

include("ui_stub.jl")
include("乱数.jl")
include("game.jl")

using Test

function createプレイヤー(;名前="太郎", HP=100, MP=20, 攻撃力=10, 防御力=10, スキルs=[])
    return Tプレイヤー(名前, HP, MP, 攻撃力, 防御力, スキルs)
end

function createモンスター(;名前="ドラゴン", HP=400, MP=80, 攻撃力=20, 防御力=10, スキルs=[])
    return Tプレイヤー(名前, HP, MP, 攻撃力, 防御力, スキルs)
end

@testset "HP減少" begin

    @testset "ダメージ < HP" begin
        c = createプレイヤー(HP=100)
        HP減少!(c, 3) #3のダメージ
        @test c.HP == 97
    end

    @testset "複数回ダメージ" begin
        c = createプレイヤー(HP=100)
        HP減少!(c, 3) #3のダメージ
        @test c.HP == 97
        HP減少!(c, 3) #3のダメージ
        @test c.HP == 94
    end   

    @testset "ダメージ > HP" begin
        c = createプレイヤー(HP=100)
        HP減少!(c, 101) #101のダメージ
        @test c.HP == 0
    end

    @testset "ダメージ = HP" begin
        c = createプレイヤー(HP=100)
        HP減少!(c, 100) #100のダメージ
        @test c.HP == 0
    end    
end

@testset "行動実行!" begin
    @testset "通常攻撃" begin
        p = createプレイヤー(HP=100, 攻撃力=10)
        m = createモンスター(HP=200, 攻撃力=20)

        プレイヤーからモンスターへ攻撃 = T行動(T通常攻撃(), p, m)
        行動実行!(プレイヤーからモンスターへ攻撃, get乱数生成器())
        @test p.HP == 100
        @test m.HP == 190

        モンスターからプレイヤーへ攻撃 = T行動(T通常攻撃(), m, p)
        行動実行!(モンスターからプレイヤーへ攻撃, get乱数生成器())
        @test p.HP == 80
        @test m.HP == 190
    end    

    @testset "大振り攻撃" begin
        p = createプレイヤー(HP=1000, 攻撃力=10)
        m = createモンスター(HP=2000, 攻撃力=20)

        乱数生成器 = get乱数生成器stub()

        プレイヤーからモンスターへ攻撃 = T行動(createスキル(:大振り), p, m)
        for i in 1:4 #40%の確率でヒット
            行動実行!(プレイヤーからモンスターへ攻撃, 乱数生成器)
            @test p.HP == 1000
            @test m.HP == 2000 - i * 20
        end
        for i in 1:6 #60%の確率で外れる
            行動実行!(プレイヤーからモンスターへ攻撃, 乱数生成器)
            @test p.HP == 1000
            @test m.HP == 1920
        end

        モンスターからプレイヤーへ攻撃 = T行動(createスキル(:大振り), m, p)
        for i in 1:4 #40%の確率でヒット
            行動実行!(モンスターからプレイヤーへ攻撃, 乱数生成器)
            @test p.HP == 1000 - i * 40
            @test m.HP == 1920
        end
        for i in 1:6 #60%の確率で外れる
            行動実行!(モンスターからプレイヤーへ攻撃, 乱数生成器)
            @test p.HP == 840
            @test m.HP == 1920
        end
    end 

    @testset "連続攻撃" begin
        プレイヤーHP = 100
        プレイヤー攻撃力 = 10
        p = createプレイヤー(HP=プレイヤーHP, 攻撃力=プレイヤー攻撃力)
        モンスターHP = 200
        モンスター攻撃力 = 20
        m = createモンスター(HP=モンスターHP, 攻撃力=モンスター攻撃力)

        プレイヤーからモンスターへ攻撃 = T行動(createスキル(:連続攻撃), p, m)
        行動実行!(プレイヤーからモンスターへ攻撃, get乱数生成器())
        @test p.HP == プレイヤーHP
        プレイヤー与ダメージ = round(Int, プレイヤー攻撃力/2)
        @test モンスターHP - プレイヤー与ダメージ * 5 ≤ m.HP ≤ モンスターHP - プレイヤー与ダメージ * 2 

        モンスターからプレイヤーへ攻撃 = T行動(createスキル(:連続攻撃), m, p)
        行動実行!(モンスターからプレイヤーへ攻撃, get乱数生成器())
        モンスター与ダメージ = round(Int, モンスター攻撃力/2)
        @test プレイヤーHP - モンスター与ダメージ * 5 ≤ p.HP ≤ プレイヤーHP - モンスター与ダメージ * 2 
        @test モンスターHP - プレイヤー与ダメージ * 5 ≤ m.HP ≤ モンスターHP - プレイヤー与ダメージ * 2 
    end 

    @testset "かばう" begin
        @testset "かばう実行データチェック" begin
            太郎 = createプレイヤー(名前="太郎", HP=100)
            花子 = createプレイヤー(名前="花子", HP=100)

            太郎が花子をかばう = T行動(createスキル(:かばう), 太郎, 花子)
            行動実行!(太郎が花子をかばう, get乱数生成器())

            @test 太郎.かばっているキャラクター == 花子
            @test 花子.かばってくれているキャラクター == 太郎
        end
        @testset "かばう解除データチェック" begin
            太郎 = createプレイヤー(名前="太郎", HP=100)
            花子 = createプレイヤー(名前="花子", HP=100)

            太郎が花子をかばう = T行動(createスキル(:かばう), 太郎, 花子)
            行動実行!(太郎が花子をかばう, get乱数生成器())

            行動前処理!(太郎, [花子], []) #「かばう」が解除される
            @test isnothing(太郎.かばっているキャラクター)
            @test isnothing(花子.かばってくれているキャラクター)
        end
        @testset "通常攻撃" begin
            太郎 = createプレイヤー(名前="太郎", HP=100)
            花子 = createプレイヤー(名前="花子", HP=100)
            ドラゴン = createモンスター(HP=200, 攻撃力=20)

            太郎が花子をかばう = T行動(createスキル(:かばう), 太郎, 花子)
            行動実行!(太郎が花子をかばう, get乱数生成器())

            ドラゴンから花子へ攻撃 = T行動(T通常攻撃(), ドラゴン, 花子)
            行動実行!(ドラゴンから花子へ攻撃, get乱数生成器())
            @test 花子.HP == 100
            @test 太郎.HP == 80 

            行動前処理!(太郎, [花子], [ドラゴン]) #「かばう」が解除される

            行動実行!(ドラゴンから花子へ攻撃, get乱数生成器())
            @test 花子.HP == 80
            @test 太郎.HP == 80 
        end
        @testset "連続攻撃" begin
            プレイヤーHP = 100
            太郎 = createプレイヤー(名前="太郎", HP=プレイヤーHP)
            花子 = createプレイヤー(名前="花子", HP=プレイヤーHP)
            モンスター攻撃力 = 20
            ドラゴン = createモンスター(攻撃力=モンスター攻撃力)

            太郎が花子をかばう = T行動(createスキル(:かばう), 太郎, 花子)
            行動実行!(太郎が花子をかばう, get乱数生成器())

            ドラゴンから花子へ連続攻撃 = T行動(createスキル(:連続攻撃), ドラゴン, 花子)
            行動実行!(ドラゴンから花子へ連続攻撃, get乱数生成器())
            @test 花子.HP == プレイヤーHP
            モンスター与ダメージ = round(Int, モンスター攻撃力/2)
            @test プレイヤーHP - モンスター与ダメージ * 5 ≤ 太郎.HP ≤ プレイヤーHP - モンスター与ダメージ * 2

            行動前処理!(太郎, [花子], [ドラゴン]) #「かばう」が解除される

            行動実行!(ドラゴンから花子へ連続攻撃, get乱数生成器())
            @test プレイヤーHP - モンスター与ダメージ * 5 ≤ 花子.HP ≤ プレイヤーHP - モンスター与ダメージ * 2
            @test プレイヤーHP - モンスター与ダメージ * 5 ≤ 太郎.HP ≤ プレイヤーHP - モンスター与ダメージ * 2
        end
        @testset "花子を太郎がかばい、太郎を遠藤君がかばっているとき、太郎がダメージを受ける" begin
            太郎 = createプレイヤー(名前="太郎", HP=100)
            花子 = createプレイヤー(名前="花子", HP=100)
            遠藤君 = createプレイヤー(名前="遠藤君", HP=100)
            ドラゴン = createモンスター(HP=200, 攻撃力=20)

            太郎が花子をかばう = T行動(createスキル(:かばう), 太郎, 花子)
            行動実行!(太郎が花子をかばう, get乱数生成器())

            遠藤君が太郎をかばう = T行動(createスキル(:かばう), 遠藤君, 太郎)
            行動実行!(遠藤君が太郎をかばう, get乱数生成器())

            ドラゴンから花子へ攻撃 = T行動(T通常攻撃(), ドラゴン, 花子)
            行動実行!(ドラゴンから花子へ攻撃, get乱数生成器())

            @test 花子.HP == 100
            @test 太郎.HP == 80 
            @test 遠藤君.HP == 100
        end
        @testset "戦闘不能になったらかばう解除" begin
            太郎 = createプレイヤー(名前="太郎", HP=30)
            花子 = createプレイヤー(名前="花子", HP=100)
            ドラゴン = createモンスター(HP=200, 攻撃力=20)

            太郎が花子をかばう = T行動(createスキル(:かばう), 太郎, 花子)
            行動実行!(太郎が花子をかばう, get乱数生成器())
            @test 花子.かばってくれているキャラクター == 太郎

            ドラゴンから花子へ攻撃 = T行動(T通常攻撃(), ドラゴン, 花子)
            行動実行!(ドラゴンから花子へ攻撃, get乱数生成器())

            @test 花子.HP == 100
            @test 太郎.HP == 10
            @test 花子.かばってくれているキャラクター == 太郎

            ドラゴンから花子へ攻撃 = T行動(T通常攻撃(), ドラゴン, 花子)
            行動実行!(ドラゴンから花子へ攻撃, get乱数生成器())

            @test 花子.HP == 100
            @test 太郎.HP == 0

            ドラゴンから花子へ攻撃 = T行動(T通常攻撃(), ドラゴン, 花子)
            行動実行!(ドラゴンから花子へ攻撃, get乱数生成器())

            @test 花子.HP == 80
            @test 太郎.HP == 0
        end
        @testset "想定外のシンボルでは例外が発生" begin
            p1 = createプレイヤー()
            p2 = createプレイヤー()
            @test isnothing(かばう解除ui処理!(p1, p2, :行動前処理))
            @test isnothing(かばう解除ui処理!(p1, p2, :行動前処理))
            @test_throws DomainError かばう解除ui処理!(p1, p2, :想定外シンボル)
        end
    end 

    @testset "ヒール" begin
        @testset "偶数:最大HP以内" begin
            p = createプレイヤー(HP=100)
            HP減少!(p, 51)
            ヒールで回復 = T行動(createスキル(:ヒール), p, p)
            行動実行!(ヒールで回復, get乱数生成器())
            @test p.HP == 100 - 51 + 50                
        end
        @testset "奇数:最大HP以内" begin
            p = createプレイヤー(HP=99)
            HP減少!(p, 51)
            ヒールで回復 = T行動(createスキル(:ヒール), p, p)
            行動実行!(ヒールで回復, get乱数生成器())
            @test p.HP == 99 - 51 + 49             
        end
        @testset "最大HPまで" begin
            p = createプレイヤー(HP=100)
            HP減少!(p, 49)
            ヒールで回復 = T行動(createスキル(:ヒール), p, p)
            行動実行!(ヒールで回復, get乱数生成器())
            @test p.HP == 100                
        end
    end 
    @testset "刃に毒を塗る" begin
        @testset "刃に毒を塗る実行" begin
            p = createプレイヤー()
            刃に毒を塗る = T行動(createスキル(:刃に毒を塗る), p, p)
            行動実行!(刃に毒を塗る, get乱数生成器())
            @test p.物理攻撃時状態異常付与確率[:毒] == 0.25    
        end
    end 
end

@testset "is戦闘終了" begin
    @testset begin
        @test is戦闘終了([createプレイヤー(HP=1)], [createモンスター(HP=1)]) == false
        @test is戦闘終了([createプレイヤー(HP=1)], [createモンスター(HP=0)]) == true
        @test is戦闘終了([createプレイヤー(HP=0)], [createモンスター(HP=1)]) == true
        @test is戦闘終了([createプレイヤー(HP=0), createモンスター(HP=1)], [createモンスター(HP=1)]) == false
        @test is戦闘終了([createプレイヤー(HP=0), createモンスター(HP=0)], [createモンスター(HP=1)]) == true
        @test is戦闘終了([createプレイヤー(HP=1)], [createモンスター(HP=0), createモンスター(HP=1)]) == false
        @test is戦闘終了([createプレイヤー(HP=1)], [createモンスター(HP=0), createモンスター(HP=0)]) == true
    end
end

function is全て相異なる(配列)
    if length(配列) == 1
        return true
    elseif length(配列) == 2
        return 配列[1] != 配列[2]
    else
        先頭 = 配列[1]
        残り = 配列[2:end]
        return !(先頭 in 残り) && is全て相異なる(残り) 
    end
end

@testset "is全て相異なる" begin
    #要素数1
    @test is全て相異なる([1]) == true
    #要素数2
    @test is全て相異なる([1, 2]) == true
    @test is全て相異なる([1, 1]) == false
    #要素数3
    @test is全て相異なる([1, 1, 1]) == false    
    @test is全て相異なる([1, 1, 2]) == false
    @test is全て相異なる([1, 2, 1]) == false
    @test is全て相異なる([2, 1, 1]) == false    
    @test is全て相異なる([1, 2, 3]) == true
    @test is全て相異なる([2, 1, 3]) == true
    @test is全て相異なる([3, 2, 1]) == true
end

@testset "行動順決定" begin
    p1 = createプレイヤー()
    m1 = createモンスター()

    @testset "1vs1" begin
        行動順 = 行動順決定([p1], [m1])
        @test length(行動順) == 2
    end

    p2 = createプレイヤー()
    @testset "2vs1" begin
        行動順 = 行動順決定([p1, p2], [m1])
        @test length(行動順) == 3
    end

    m2 = createモンスター()
    @testset "1vs2" begin
        行動順 = 行動順決定([p1], [m1, m2])
        @test length(行動順) == 3
    end

    @testset "2vs2" begin
        行動順 = 行動順決定([p1, p2], [m1, m2])
        @test length(行動順) == 4
    end
end

@testset "is戦闘終了" begin
    @testset "1vs1 両者生存" begin
        p = createプレイヤー(HP=1)
        m = createモンスター(HP=1)
        @test is戦闘終了([p], [m]) == false
    end

    @testset "1vs1 プレイヤー死亡" begin
        p = createプレイヤー(HP=0)
        m = createモンスター(HP=1)
        @test is戦闘終了([p], [m]) == true
    end
end


@testset "is行動可能" begin
    p = createプレイヤー(HP=1)
    @test is行動可能(p) == true
    p = createプレイヤー(HP=0)
    @test is行動可能(p) == false
    m = createモンスター(HP=1)
    @test is行動可能(m) == true
    m = createモンスター(HP=0)
    @test is行動可能(m) == false
end

@testset "行動可能な奴ら" begin
    p1 = createプレイヤー(HP=1)
    @test 行動可能な奴ら([p1]) == [p1]
    p2 = createプレイヤー(HP=0)
    @test 行動可能な奴ら([p1, p2]) == [p1]
    p3 = createプレイヤー(HP=1)
    @test 行動可能な奴ら([p1, p2, p3]) == [p1, p3]

    m1 = createモンスター(HP=1)
    @test 行動可能な奴ら([p1, p2, p3, m1]) == [p1, p3, m1]
    m2 = createモンスター(HP=0)
    @test 行動可能な奴ら([p1, p2, p3, m1, m2]) == [p1, p3, m1]
    m3 = createモンスター(HP=1)
    @test 行動可能な奴ら([p1, p2, p3, m1, m2, m3]) == [p1, p3, m1, m3]
end

end