麻雀の得点を入力すると順位点などを加味してポイントを計算し、スプレッドシートに出力するスクリプトを書いてみた

# はじめに
友人と趣味で4人麻雀をする際に、最終的な点数をポイントに計算するのが毎回面倒でした。そこでアプリを使ってみたのですがしっくりこなかったので、スプレッドシートで作ってみました。ついでに平均順位や平均得点、最高得点なんかも計算してみました。

今回は GAS(Google Apps Script)を使って Google スプレッドシートに入力された値をもとにして、計算結果を出力していきます。
言語は JavaScript です。

### 注意事項
JavaScript を書いたことがないので調べながらやりました。そのため、ひどい書き方をしていたり、間違った書き方をしていることがあるかもしれません。また、引用が多いにもかかわらず、参考文献としてURLを残しておかなかったため、一部引用元なしですがご了承ください。優しくコメントいただけると幸いです。

# 仕様(？)
- 使用する場合、スプレッドシートのコピーを作成し、ファイル名を日付にする。
- 30半荘分の結果を記入することができる。
- 各半荘ごとに、1つでも点数が入力されていなければ何も出力されない。
- 同順位がいた場合、起家に近い方を高順位とする。
- 風が入力されていない、かぶっている、など正しく入力されていなくても基本出力されるが、同順位がいる場合に限り、風を正しく入力しないと結果が出力されない。
    - エラーに応じた文が出力される

# 想定動作
`結果` と `成績` の2枚シートがあり、`結果` に入力した内容をもとに計算し、`成績` に出力していきます。

### 結果シート
入力する内容
- 4人の名前
- 順位点
- 風
- 持ち
- 返し

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2631291/8cdc2da2-2a12-21af-c00b-8e434cb8244c.png)

### 成績シート
出力される内容
- 計算結果
- 何位を何回取ったか
- 平均順位
- 平均得点
- 最高得点
:::note warn
ラス回避率も計算するが、これは既存の関数で求める。
:::
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2631291/2bd9fb32-1bca-d326-0ed8-0532f6e2cf2b.png)

# 実装
3つの関数を作成しました。
- calcPoints <font color="red">**(赤)**</font>
- countRank <font color="deepskyblue">**(青)**</font>
- averageMaxPoints <font color="green">**緑**</font>

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2631291/e43f217d-1c7a-d3fb-4fa9-ce4edc603767.png)

## calcPoints 関数
最終得点からポイントを計算する関数です。

引数は1行4列の `1半荘の結果` で、返り値は1行4列の `計算されたポイント` です。
そのため、上の図の赤で囲われたセルにはそれぞれ `calcPoints("結果"!B3:I3)`, `calcPoints("結果"!B4:I4)` のように30半荘分の関数が記述されています。

### 計算のための準備
以下のコードのようにして `結果` シートから値を取得するための準備をします。
そして、返しと順位点を取得し、それぞれ `kaeshi`, `rankPoints` 変数に代入します。どちらも変更する予定がないので const で定義しました。最後の行で、値を取得前に処理しちゃうことがあるっぽいので、取得に時間がかかっていたらちょっと待ってあげるようにしました。

```javascript

function CALCPOINTS(points) {
  // "結果"というシートを取得
  const majanSpreadsheet = SpreadsheetApp.getActiveSpreadsheet()
  const majanSheet = majanSpreadsheet.getSheetByName('結果')
  // 返しを取得
  const kaeshi = majanSheet.getRange('M4').getValue()
  // 順位点を取得
  const rankPoints = majanSheet.getRange('M1:P1').getValues()[0]

  // 取得してくるまでに時間がかかるっぽく、取得前にアクセスしようとするとエラーになるので、取得されるまで待つ
  if (points[0].length != 8 || rankPoints.length != 4) Utilities.sleep(100)

  /* 以降処理が続く */
  
}
```

### 引数で受け取った配列を処理
例えば1半荘目が以下の画像のように入力されてた場合、`calcPoints("結果"!B3:I3)` とすることで1半荘目のポイントを計算してくれます。`"結果"!B3:I3` と渡された引数は上のコードで `points` に [32600, 東, 19800, 南, 15000, 西, 32600, 北] と格納されます。正確にはオブジェクトらしく、points[0]で配列として扱えます(よくわかっていない)。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2631291/1bef1e2e-ce69-04c7-8957-b870b91d387b.png)

受け取った配列を以下のように処理します。
- 風を取り出して `kaze`配列に格納
    - 処理しやすいように数値に変換
- points[0]から風を取り除く
- エラー処理
    - points[0]で一つでも空欄があれば何も返さずに終了
    - points[0]の合計が10万でなければその旨を伝える文章を返す

```javascript
  // 風を取得
  let kaze = points[0].filter(n => n%2 !== 0)
  // 処理しやすいように数値に変換
  if (kaze.length !== 0) {
    for (let i = 0; i< kaze.length; i++) {
      if (kaze[i] === '東') kaze.splice(i, 1, '0')
      else if (kaze[i] === '南') kaze.splice(i, 1, '1')
      else if (kaze[i] === '西') kaze.splice(i, 1, '2')
      else if (kaze[i] === '北') kaze.splice(i, 1, '3')
    }
  }

  // 風を除いたpoints配列を作成
  for (let i = 0; i< 4; i++) {
    points[0].splice(i+1, 1);
  }

  // 一つでも空欄があれば終了
  if (points[0].some(a => a === '')) return
  // 合計点が10万点でなければ知らせる
  if (points[0].reduce((sum, element) => sum + element, 0) !== 100000) return "点数が正しく入力されていません。"
```

### 順位を決める
以下のようにして順位を求めます。
- points[0]を降順に並び替える
- ranks に何位か格納

```javascript
  // points配列をそのまま利用したいため、計算する前に順位を把握しておく
  // 降順にソートし、sortedに代入。points配列はそのまま利用したいため、sliceでコピー
  let sorted = points[0].slice().sort(function(a, b){return b - a});
  // 何位か判定
  let ranks = points[0].map(function(x){return sorted.indexOf(x)});
```
上のコードの `ranks` を求めているところですが、意味としては以下の画像のような感じです。points[0]のそれぞれが何位なのか、indexOf関数で `sorted` に一つずつ見に行っています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2631291/2bab2d2e-67ba-c748-ce4b-d3ab8ca1e0d2.png)


### 同順位がいたときの処理
同順位がいたときは起家に近い方を高順位とします。
以下のように実装します。

```javascript
  // 同じ順位がいたときの処理
  // 起家に近い方を高い順位とする
  if (isDuplicated(points[0])) {
    if (isDuplicated(kaze)) return "風が重複しています。"
    if (kaze.length !== 4) return "風がすべて入力されていません。"
    let idxs = []; // 同じ順位の人のインデックスを格納する配列
    let dubVal = duplicatedValue(ranks);  // かぶっている順位を取得
    ranks.map(function(val, idx) {
          if (val === dubVal) idxs.push(idx)
      })
    // 起家から遠い方を低い順位にする
    kaze[idxs[0]] > kaze[idxs[1]] ? ranks[idxs[0]] = dubVal+1: ranks[idxs[1]] = dubVal+1
  }
```
isDuplicated 関数で重複があるか判定します。重複がある場合は順位を決めるために風の正確な入力が必要なので、`重複がないか`, `入力されていないところがないか` 確認します。そして、duplicatedValue関数で重複している順位を取得し、その順位が誰なのか求めます(idxs)。一人目の方が起家から遠い場合、一人目の順+1 します。そうでなければ二人目の順位+1をします。

- 重複があるか判定する関数(isDuplicated関数)

```javascript
// 重複があるか判定する関数
function isDuplicated(elements) {
  // Setを使って、配列の要素を一意にする
  const setElements = new Set(elements);
  return setElements.size !== elements.length;
}
```

- 重複している値を返す関数(duplicatedValue関数)

```javascript
// 重複している値を返す関数
function duplicatedValue(elements) {
  for (let i = 0; i < elements.length-1; i++) {
    for (let j = i+1; j < elements.length; j++) {
      if (elements[i] === elements[j]) return elements[i]
    }
  }
}
```

### 計算アルゴリズム(?)

以下の手順で計算します。
- 点数÷1000をする
- 五捨六入する
- 結果から300を引く
- 順位点を加算する

この手順をまとめてやったのがこんな感じです。
```javascript
  // 五捨六入して清算
  for (let i = 0; i < points[0].length; i++) {
    points[0][i] = Math.round(Math.abs(points[0][i]/1000) - 0.1) * Math.sign(points[0][i]) - kaeshi/1000;
  }
  // 順位点を加算
  for (let j = 0; j < ranks.length; j++) {
    points[0][j] += rankPoints[ranks[j]]
  }
```
小数第一位を五捨六入する場合、javascriptでは-0.1して四捨五入を適用することで実現させるらしいです。そのため、負の数を五捨六入する場合、-0.1して四捨五入すると正確に求められません。そこで、一度絶対値で五捨六入し、最後にもとの符号をつけるようにします。

順位点はそれぞれの順位に対応する点を加算します。

### 誤差があればトップに押し付ける
たまに±1くらいの誤差が出ます(主に同順位がいた場合)。そんな時はトップに誤差を押し付けちゃいます。コメントアウトで割と説明が書いてあるのでここでは省きます。

```javascript
  // 誤差
  const diff = points[0].reduce((sum, element) => sum + element, 0)
  // 誤差が生じた際に、誤差をトップに押し付ける
  if (diff != 0) {
    const top = points[0].reduce((x, y) => {return Math.max(x, y)}) // トップの得点を取得
    const topIdx = points[0].indexOf(top) // トップの得点のインデックスを取得
    points[0][topIdx] += -diff // トップの得点に誤差を加算
  } 
```


## countRank 関数
何位を何回取ったか求める関数です。順位を集計するということで、ついでに平均順位も求めています。

### 準備
以下のようにして `成績` シートから値を取得するための準備をします。calcPoints関数の時にも説明しましたが、一半荘分の得点がpointsに渡されます。`成績` シートからは30半荘分のポイントを取得し、vals変数に代入します。誰が何位を何回取ったか集計するため、4行5列の行列rankTableを作成します。5列目は平均順位を格納します。
```javascript
function COUNTRANK(points) {
  
  // "成績"というシートを取得
  const majanSpreadsheet = SpreadsheetApp.getActiveSpreadsheet()
  const recordSheet = majanSpreadsheet.getSheetByName('成績')
  // 清算点を取得。とりあえず30半荘分
  let vals = recordSheet.getRange('B4:E33').getValues()

  // 誰が何位を何回取ったかの表。行が人で、列が順位。
  let rankTable = Array(4).fill().map(() => Array(5).fill(0))

  // 取得してくるまでに時間がかかるっぽく、取得前にアクセスしようとするとエラーになるので、取得されるまで待つ
  if (points.length != 30 || vals.length != 30) Utilities.sleep(100)

  /* 以降処理が続く */
  
}
```

### 順位の集計
あとで平均順位を求めるために半荘数をカウントする変数hantyanを用意します。calcPoints関数でも紹介しました、順位を求める処理をし、rankTableで集計していきます。
```javascript
  // 半荘数
  let hantyan = 0
  // 誰が何位を何回取ったか集計
  // 参考:https://goma.pw/article/2017-01-31-0/
  for (let i = 0; i < points.length; i++) {
    // 風が入力されてないなどのエラー文であればスキップする
    if (typeof(points[i][0]) === "string") continue
    // 1行すべて0であれば集計終了
    if (points[i].every(a => a == 0)) break
    hantyan += 1
    // 降順にソートし、sortedに代入
    let sorted = vals[i].slice().sort(function(a, b){return b - a});
    // それぞれ何位か判定
    let ranks = vals[i].slice().map(function(x){return sorted.indexOf(x)});
    // 何位を何回取ったか集計
    for (let j = 0; j < ranks.length; j++) {
      rankTable[j][ranks[j]] += 1
    }
  }
```

### 平均順位を求める
順位×回数/半荘数で平均順位を求めます。結果は少数第二位まで求めます。
```javascript
  // 平均順位
  for (let i = 0; i < 4; i++) {
    // 行ごとに何位を何回取ったか計算
    let a = rankTable[i].map((val, idx) => {return val*(idx+1)})
    // 順位の合計値を計算
    let b = a.reduce((sum, element) => sum + element, 0)
    // 半荘数で割り、平均順位を小数第二位まで求める
    if (b != 0) rankTable[i][4] = Math.round(b/hantyan*100)/100
  }
```

## averageMaxPoints 関数
平均得点と最高得点を求める関数です。

### 準備
ここでは特にシートか値を取得することはありません。風を含んだ配列でpointsに渡されるので、points[0]から風を除去します。
```javascript
function AVERAGEMAXPOINTS(points) {

  // 取得してくるまでに時間がかかるっぽく、取得前にアクセスしようとするとエラーになるので、取得されるまで待つ
  if (points.length != 30) Utilities.sleep(100)
  // B3:D32などのような入力ミスの場合、知らせる
  if (points[0].length != 8) return "人数が足りません。"

  // 風を除いたpoints配列を作成
  for (let i = 0; i< points.length; i++) {
    // 1行すべて0であれば終了
    if (points[i].every(a => a == 0)) break
    for (let j = 0; j < 4; j++)
      points[i].splice(j+1, 1)
  }
  
  /* 以降処理が続く */
  
}
```

### 平均得点を求める
人ごとの得点の合計を求め、最後に半荘数で割ります。値は四捨五入します。
```javascript
  // 平均得点格納配列、半荘数
  let avePts = Array(4).fill(0)
  let hantyan = 0
  for (let i = 0; i < points.length; i++) {
    // 1行すべて0であれば終了
    if (points[i].every(a => a == 0)) break
    hantyan += 1
    avePts = avePts.map((x, idx) => {return (x + points[i][idx])})
  }
  // それぞれの合計得点を半荘数で割り、平均を求める
  avePts = avePts.map(x => {return Math.round(x / hantyan)})
```

### 最高得点を求める
pointsは列が一人分の点数なので、計算しやすいように転置して行が一人分の点数になるようにします。そして1行ごとに最も大きい値を求めます。これでそれぞれの最高得点を求めることができます。
```javascript
  // 配列を転置する関数
  const transpose = a => a[0].slice().map((_, c) => a.map(r => r[c]));
  // 配列を転置
  let pts = transpose(points)
  // 最高得点格納配列
  let maxPts = Array(4).fill(0)
  // それぞれの最高得点を求める
  for (let i = 0; i < 4; i++) {
    maxPts[i] = pts[i].reduce((x, y) => {return Math.max(x, y)})
  }
```

# 結果

想定動作でも載せましたが、実装した結果がこんな感じです。

- 結果シート
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2631291/8cdc2da2-2a12-21af-c00b-8e434cb8244c.png)

- 成績シート
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2631291/26ab2ac7-cc39-6c7d-c3b4-e7f06db51051.png)

# まとめ
今回自分たちの需要を満たすために実装してみていろいろ勉強になりました。javascriptもそうですが、特に高階関数がとても勉強になりました。普段全然扱ってこなかったため、こんな便利なものがあるのかと驚愕です。これを機に高階関数をもっと使っていこうと思います。

今回の実装に関してですが、次はこれをWeb上で使えるようにしてみたいです。