読者です 読者をやめる 読者になる 読者になる

赤城の備忘録。

どうでもいいこと

diceR 技術的な解説

github.com

diceRの解説です。 正しくない記述も含まれるかもしれません。

前書き

「どどんとふ」のダイスボットに関するアプリケーション、BCDiceは高機能です。
3d61d100<=50といった単純なものから、CoCなどといったTRPGシステムに特化したものまで使いやすいように網羅されています。

BCDiceは確かに単純なダイス(e.g. 1d100)から、複雑な処理をするダイスまで網羅しています。
しかし、その内「単純なダイス」に対しては、私はBCDiceでは機能過剰だと感じました。

そのため、私は「単純なダイス」、つまりBCDiceを簡略化したdiceRを開発しました。
diceRはSinatra上で動き、APIとして機能します。 これは外部からの参照を意図したものです。

また、diceRを開発するにあたり、NKMR6194氏のbcdice-apiを参考にさせていただきました。
勝手ながら、この場を借りて感謝申し上げます。

ダイス表現

1d1003d6のようなダイスを、私は引っくるめて「ダイス表現(Dice Expression)」と名付けました*1
diceRのダイスロールは、すべてこのダイス表現を元に行います。

diceR上では、このダイス表現を分解し、以下のようにします。

  • 振る回数*2 - 1d1001
  • *3 - 1d100100

また、ダイス表現自体も「ダイス表現」とします。

更に、1d100<=30のように、条件を指定したい場合があります。
その際は、更に以下のように分解します。

  • 条件*4 - 1d100<=30<=
  • 条件のパラメータ(数字)*5 - 1d100<=3030

説明が下手ですみません。まとめるとこうなります。
1d100<=30 なら:

  • ― ダイスを振る回数は1回。
  • ― ダイスの面は100面。
  • ― ダイスの条件は<=
  • ― ダイスの条件のパラメータは30

文章に起こすと:100面ダイスを1回振る。条件は、結果が30以下なら成功、さもなくば失敗。

diceRの性質上(= 1d100などしか扱わない)、ダイス表現という考えは重要です。
これはダイスを一単語の、抽象的な言葉で表せることを意味します。

diceRの実際の処理

diceRにおいての、具体的処理に入ります。
Sinatraのルーティング*6API バージョン1のコード*7の説明は割愛します。

ダイス表現処理

難しい処理は行っていません。上の文章(「100面ダイスを1回振る。条件は、結果が30以下なら成功、さもなくば失敗。」)をソースコードに起こしているだけです。
具体的なソースコードは以下のとおりです。

def roll_dice
  if @dice_number.zero? || @dice_faces.zero?
    init_val

    @error   = true
    @message = 'Specified expression has illegal characters.'

    return
  end

  @result = Array.new(@dice_number) { { faces: @dice_faces, value: [*1..@dice_faces].sample } }

  @result_val = @result.map { |element| element[:value] }
  @result_sum = @result_val.inject(:+)

  @result_formatted = format_result

  @message = 'OK'
  @error   = false
end

大体の処理は不正なダイス表現に対してのものと、APIとしての出力のための処理のものです。
純粋に「ダイスを振る」というソースコードは以下になります。

@result = Array.new(@dice_number) { { faces: @dice_faces, value: [*1..@dice_faces].sample } }

…短いですね。最初のコードが冗長的すぎるだけ

更に、見やすく改行などを加えるとこうなります。

# @result      はダイスを振った結果の配列。
# @dice_number はダイスを振る回数。
# @dice_faces  はダイスの面。
@result = Array.new(@dice_number) { { 
   faces: @dice_faces,
   value: [*1..@dice_faces].sample
} }

究極的に単純化すると、Array#new(size) {|idx| block}という処理になります。
newの引数分、ブロック内の処理を実行しろ、ということです。

ブロック内では、ダイスを振った結果を一つ一つハッシュとし、それを配列をとして一つにまとめています。

ダイス表現の分解

def analyze_dice(r)
  dice_args = r.split(/d|(<=|<=)|(>=|>=)|(<|<)|(>|>)/)

  @dice_expr = r
  @dice_number = dice_args[0].to_i
  @dice_faces  = dice_args[1].to_i

  @cond_val = dice_args[2]
  @cond_dig = dice_args[3].to_i
end

String#splitでダイス表現を配列に分解します。正規表現を使っています。
そして配列内の要素を、各変数に代入しています*8

各変数の説明は、変数名を見ればわかると思うので省略します。

条件指定時の、成否のチェック

def check_success
  return '成功' if @cond_val == '<=' && @result_sum <= @cond_dig
  return '成功' if @cond_val == '>=' && @result_sum >= @cond_dig
  return '成功' if @cond_val == '<'  && @result_sum < @cond_dig
  return '成功' if @cond_val == '>'  && @result_sum > @cond_dig

  '失敗'
end

…ややこしいですね。いかにもウンコードです。実際Rubocopでcyclomatic complexity*9が高いと言われてます。

それぞれの文は、比較する文字列が違うだけなので一つだけ見れば問題ありません。 @cond_val = (<=|<|>=|>) AND @result_sum(ダイスを振った結果の総計) <=|<|>=|> @cond_dig なら成功、さもなくば失敗を返します。

ダイスを振った結果のフォーマット

def format_result
  # '(%s) > %s[%s] > %d > %d' % [@dice_expr, @result_sum, @result_val, @result_sum, check_success]
  if @cond_val
    return format('(%s) > %s[%s] > %d > %s',
                  @dice_expr,
                  @result_sum,
                  @result_val.join(', '),
                  @result_sum,
                  check_success)
  end

  format('(%s) > %s[%s] > %d',
         @dice_expr, @result_sum, @result_val.join(', '), @result_sum)
end

一つ上の成否チェックと同じような理由(こちらは@cond_valがTRUE → 条件が指定されているか, 指令されていたら成否を付け足す *10で、片方のformatを見れば問題ないです。

結果は、(3d6<=10) > 6[2, 2, 2] > 6 > 成功といった具合になります。
formatでそれぞれの変数を、一つの文字列にまとめています。

その他

attr_reader :message, :error, :result, :result_formatted

def initialize(r)
  init_val
  analyze_dice(r)
end

def init_val
  # dice
  @dice_expr   = ''
  @dice_number = 0
  @dice_faces  = 0

  # Condition
  @cond_val = ''
  @cond_dig = 0

  # roll
  @result        = []
  @result_val    = []
  @result_sum    = 0

  # format
  @result_formatted = ''

  # meta
  @error   = false
  @message = ''
end

initialize(r)はコンストラクタ。init_valはエラー発生時に、変数類をリセットするための関数。
DiceV2.new("1d100")でほとんどの工程を処理します。

以下のようにすることで、一通りの操作が可能です。

r = '1d100<=30'
dice = DiceV2.new(r)

dice.message # エラーメッセージ
dice.error   # エラーの有無
dice.result  # ダイスの結果の配列
dice.result_formatted # ダイスの結果のフォーマット版

参考: API出力

{
    "message": "OK",
    "error": false,
    "dices": [
        {"faces": 6, "value": 2 },
        {"faces": 6, "value": 2},
        {"faces": 6, "value": 2}
    ],
    "result": "(3d6<=10) > 6[2, 2, 2] > 6 > 成功"
}

終わりに

私は、プログラマだの自称している割には、大規模なアプリケーションや、人の役に立つようなプロジェクトに参加したことはありません。
しかし、きりだるま氏のOnset!の開発に一部参加させてもらい、下向きだった意識が多少変わったと思います。その成れの果て(!)がこのdiceRです。

また、こうやってちゃんとした、技術的な解説をしてみるのもはじめてかもしれません。
Qiitaでは色々書いていましたが、拙いものです。

diceRはSinatra上で動きます。OSSを活用しています。
無論、diceRもOSSです。MITライセンス上でGitHubに公開しています。

diceRの開発はちょっぴり、少しずつですが、今後も進めていきます。
この記事が、将来diceRの開発に加わってくれる方の助けに、または色々プログラミングしてみたいなー、という方の助けになれば幸いです。

謝礼

NKMR6194氏のbcdice-apiを参考にさせていただきました。前書きでも延べましたが、再度感謝申し上げます。

参考: 利用した技術

*1:ちゃんとした名前、あるのでしょうか?

*2:number, diceR変数名 @dice_number

*3:faces, @dice_faces

*4:condition, @cond_val

*5:condition digit, @cond_dig

*6:server.rb

*7:/lib/dice_v1.rb

*8:一つのオブジェクトにまとめればいいと思うので、近いうち書き換えます。

*9:循環的複雑度

*10:「成否を付け足す」とここに書いて、処理を簡潔化出来るじゃん! と思いつきました。DRYみたいですね。近いうち書き換えます。