🎲

ダイスコードを処理するスクリプトを書いた

2021/01/22に公開1

ダイスコードってなに?って人は https://ja.wikipedia.org/wiki/サイコロ#サイコロと遊戯 を見て

roll('2D6') // 2 ~ 12 の乱数(確率もただしいやつ)

こういうのを作りたい。作った。

乱数の話

まずダイスをプログラムで実装するとなるといい感じの(十分な周期を持つなるべく均等分布した)乱数がほしい。んだけど、暗号化レベルみたいなレベルの乱数がほしいわけではないので、まあここでは Math.random() でよいものとする。

計算の話

TRPGなどでダイスコードを使うときは計算式になる。2+2D6 とか 5+3D6 とか。これを処理したい。つまり roll('2+2D6') が動作してほしい。

出目の話

計算結果だけでなく出目もほしい。結果だけだと、例えばソード・ワールド2.0の人間の種族特徴「運命変転」を使ったときに、どの出目をひっくり返せば良いのかわからなくなる。

この世界における人間を語る上で欠かせないのが、この[剣の加護/運命変転]である。
1日に1回(朝6時基準)だけ、判定で振ったダイスの目を裏返すことができ、『種族特性の強化』ルールを採用している場合は、裏返した上でダイス目を加算することができるようになる。

https://dic.nicovideo.jp/a/人間(sw2.0)

実装

逆ポーランド記法

大昔は逆ポーランド記法を用いて式をパースし、Dを独自のオペレーターとして定義することで実装していた。今回も愚直にそうするかと思ったけれど、この方法はめんどくさいという問題があった。そこで却下とした。

https://ja.wikipedia.org/wiki/逆ポーランド記法

一応解説すると、以下のようになる。

普通の記法: 1 + 2D6
逆ポーランド記法(通常版): 1 2D6 +
逆ポーランド記法(Dオペレーターあり): 1 2 6 D +

Dをスタックしてる数を見て乱数に置き換えるオペレーターだと定義することでうまいことやれるというわけである。まあ実際めんどい。言語によっては計算式を逆ポーランド記法にえいやっとやるライブラリなどあるのだろうが、ライブラリは入れたくない。中置記法を逆ポーランド記法にするために数多の数学関数がプロジェクトに導入されるのは嫌だ。

new Function

要するに eval である。

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/eval

eval だとセキュリティ的に問題がある、というところで知識が止まっていたので、なるほどとなった。これを使えば比較的安全にえいや!とやれるみたいなので、これを使えばいいだろう。そうすれば「計算式をどう処理させるか」という課題は解決する。JavaScript エンジンに計算式を処理する能力があるのに自前で実装するのアホらしいし。

だたやってみてわかったことだけれど、new Function の内部で実行されたスクリプトからはグローバル変数しか参照できないらしい。後で困ったことになった。まあとりあえずこの時点でそのことは知らないので実装していく。

1.ts

declare var roll: (count: number, face: number, rolled: number[]) => number

const rollSingle = (face: number): number => Math.ceil(Math.random() * face)
const roll = (count: number, face: number): number => {
  return Array(count).fill(1).reduce(r => r + rollSingle(face), 0)
}

globalThis.roll = roll

const dice = (exp: string): number => {
  if (!exp.match(/^[\d\+\-\*\/\(\)D]+$/i)) { return 0 }
  return new Function('"use strict"; return ' + exp
    .replace(/(\d+)?D(\d+)/ig, (code, c, f) => `roll(${c||'1'},${f})`))()
}

何をしているのかと言うと、new Function で計算処理をしている。ただ、 nDm を見つけたら roll(n, m) に置き換える。

6 + 2D66 + roll(2, 6) に置き換わる。roll関数は第一引数にダイスの個数を、第二引数にダイスの面の数を与えると、ダイスロールした結果を返してくれる。これが処理されるとうまく計算結果が出てくるというわけだ。めちゃ簡単じゃん。

2.ts

すでに述べた通り、出目もほしいので修正する。

declare var roll: (count: number, face: number, rolled: number[]) => number

const rollSingle = (face: number, rolled: number[]): number => {
  const r = Math.ceil(Math.random() * face)
  rolled.push(r)
  return r
}

const roll = (count: number, face: number, rolled: number[]): number => [...Array(count)].reduce(r => r + rollSingle(face, rolled), 0)

globalThis.roll = roll

interface IDiceResult {
  rolled: number[]
  sum: number
}

const dice = (exp: string): IDiceResult => {
  if (!exp.match(/^[\d\+\-\*\/\(\)D]+$/i)) { return { sum: 0, rolled: [] } }
  return new Function('"use strict"; const rolled = []; const sum = ' + exp
    .replace(/(\d+)?D(\d+)/ig, (code, c, f) => `roll(${c||'1'},${f}, rolled)`) + '; return { sum, rolled, exp: "' + exp + '" }')()
}

なげえ。

n times loop の修正

Array(n).fill(1)

これを

[...Array(n)]

に修正している。要するに長さ n の配列を作って reduce することで n 回ダイスを回す。ruby なら n.times で済むのに……。

前者のコードは「長さ n の empty で満たされた配列を 1 で満たした配列」を作っているのに対して、後者は「長さ n の undefined で満たされた配列」を作っている。速度は知らない。

https://zenn.dev/uhyo/articles/array-n-keys-yamero

IDiceResult

typescript なので interface を定義する必要があってめんどくさいとなった。実際にはこんな感じのオブジェクトが返ってくる。

{
	"exp": "14+5*2D6+(3+D6)*2",
	"rolled": [2, 4, 2],
	"sum": 54
}

ええと、14 + 5 * (2 + 4) + (3 + 2) * 2 だから…… 54 であってるな!

new Function で評価してるコード

  return new Function('"use strict"; const rolled = []; const sum = ' + exp
    .replace(/(\d+)?D(\d+)/ig, (code, c, f) => `roll(${c||'1'},${f}, rolled)`) + '; return { sum, rolled, exp: "' + exp + '" }')()

これは流石にわかりにくいのでちょっと中身だけを書き出すと

"use strict";
const rolled = [];
const sum = /* ここに式が入る */;
return { sum, rolled, exp: "/* ここにも式が入る */" };

こんな風になってます。

で、ちょっと先に書いてるけど new Function はグローバルスコープしか参照できないっぽい(ローカルスコープを参照する方法もあるのか?context渡せるのかな?今の所知らない)ので、グローバルスコープに roll 関数を定義する必要がある。

それをやってるのが

declare var roll: (count: number, face: number, rolled: number[]) => number
globalThis.roll = roll

これ。(実際にはこれの1行目は index.d.ts に記述している)

globalThis というものを初めて知った(TypeScriptをまともに使うのも初めてだけど)

こういう感じで、sumに合計値が、rolledに出目が入っていく。rolledは関数の引数としてcalc->roll->rollSingle間で受け渡しされて、rollSingleで結果をpushされる。関数に副作用があってアレですがまあ良しとします。丁寧に実装しようと思えばいくらでも丁寧に実装できるけど、めんどくさいので……。

まとめ

new Function は便利だった。

そもそも roll ってグローバルに定義するんじゃなくて new Function 内部で定義してもいいんじゃないの?と思ったけど、new Function 内部では TypeScript は使えないので避けた。べつに内部で定義してもいいと思う。

Discussion

NiaNia

余談だけど Array.prototype.push の返り値が追加した要素だったら rollSingle は以下のように書けた。

const rollSingle = (face: number, rolled: number[]): number =>
  rolled.push(Math.ceil(Math.random() * face))

なんかうまく動くやつないんかな。