ライブラリとバージョン管理の話


この記事は、[Minecraft Command Advent Calendar 17日目](https://qiita.com/advent-calendar/2023/mccmd)の記事です。

- 2024/07/18 記事内で提示したコードのインデントを修正

## 概要

ライブラリ用のデータパックを作ってみた。
自作データパックのバージョン管理をライブラリで一元化した。

:::note warn
この記事は他記事の内容と盛大に被っています。
応用編、というポジティブシンキングでごまかそうと思います。
:::

:::note info
この記事は、JE 1.20.2+を対象にしています。
他のバージョンでも実装できないことはないと思いますが、そこまでは保証しません。
:::

## 目次

- [ライブラリの意味](#ライブラリで何をすべきか)
- [同じ処理を使い回す](#使いまわして競合を回避する)
- [バージョンを管理する](#バージョンを管理する)
  - [バージョンタグ](#バージョンタグをつけよう)
  - [文字列の分割](#文字列を分けたり)
  - [文字列の結合](#くっ付けたり)
  - [文字列の数値化](#数値に変えたり)
- [まとめ](#まとめ)
- [ソースコード](#ソースコード)

## ライブラリで何をすべきか

ライブラリを入れて何がしたいか、考えてみると、

- 実装が面倒な機能を使いまわす

主目的は基本的にこれです。
「コピペしろよ」で片が付かないこともないですが、あまりいい物でもないです。

他にも考えてみましょう。

- 同一ライブラリを使うことによる競合の回避

特に上みたいにコピペすると、同じエンティティをマーカーに使って正しく動作しない、なんてことが頻繁に発生してしまいます。

この二点を解決するために、ライブラリとなるデータパックを作成していきましょう。

## 使いまわして競合を回避する

同じ処理を別の場所でも使い回せるようにするには、別に変数を保存する場所を用意する必要があります。
`score`であっても`storage`であっても、間に一つ変数を用意してあげることで処理で不具合が生じにくくなります。（代わりに処理は少しずつ重くなっていきます）
処理が終わったら、次回以降の処理で何も入れていないのに実行してしまう、という事態を避けられるよう、忘れずに代入用の変数は削除してあげましょう。

```mcfunction:call.mcfunction
#> test:call
#
# 呼び出し関数
#
# @input storage api: Argument
# @api

# 何もなかったらエラー表示する
    execute unless data storage api: Argument run tellraw @a "error"
# データが入っていたら実行する
    execute if data storage api: Argument run function test:exec
# 終わったらリセットする
    data remove storage api: Argument
```

:::note info
上のファイルのように、実際の処理の前に変数が代入されているか確認する処理を挟むと良いでしょう。
データパックの作成にあたって、変数を渡し忘れている箇所が見つけやすくなります。
:::

## バージョンを管理する

今回のいわゆる本題。

前項で同じ処理に対して競合を起こす、ということが回避できるようになりました。
しかし、同じ処理と言えど、追加で機能が欲しくなることもあります。

このとき、基本的に後方互換性を保つわけですが、それにも限界はあります。
なので、「バージョン」を定義して条件を満たしているときにだけ、ライブラリを使えるようにしたいわけです。

そこで、データパックのそれぞれにバージョンタグをつけていきましょう。

### バージョンタグをつけよう

超雑ですが、スコアボードでバージョンの数値を決めることは一応可能です。

```mcfunction:version_int.mcfunction
#> test:version_int
# @within tag/function minecraft:load

# スコアでバージョンタグをつける
    # v1.2.3
        scoreboard players set #Test.Major Global 1
        scoreboard players set #Test.Minor Global 2
        scoreboard players set #Test.Patch Global 3
```

上のように、Semantic Versioningを元にしたバージョンタグならば、3つの`score_holder`を用意すれば事足ります。が、なんとも可読性が悪いです。

なので、文字列でバージョンを定義しましょう。

```mcfunction:version_string.mcfunction
#> test:version_string
# @within tag/function minecraft:load

# 文字列でバージョンタグをつける
    # v1.2.3
        data modify storage global Test.Version set value "v1.2.3"
```

こちらの方が読みやすいですが、一つ大きな問題を抱えています。
「データパック内では解析しづらい」点です。

処理は重くなりますが、一周周って、バージョンタグの文字列から数値を導出するライブラリを作ってみましょう。

### 文字列を分けたり

まずは文字列を一文字ずつに分けていきます。
これは[赤石愛さんの既存のライブラリ](https://github.com/Ai-Akaishi/AiUtil/tree/main/data/util/functions/split)を借りて比較的軽量に実装できます。

```mcfunction:.mcfunction
#> test:string/split/
# @input storage
#   api: Argument
#       String : string
# @output storage
#   api: {}
#       Return : string[]
# @api

# 専用のストレージに放り込む
    data modify storage test:temp all set from storage api: Argument.String

# 文字列の長さを取得する
    execute store result score #Length Test.Temporary run data get storage test:temp all
# 二分割していく
    execute if score #Length Test.Temporary matches 1024.. run function test:string/split/start/1024
    execute if score #Length Test.Temporary matches 512.. run function test:string/split/start/512
    execute if score #Length Test.Temporary matches 256.. run function test:string/split/start/256
    execute if score #Length Test.Temporary matches 128.. run function test:string/split/start/128
    execute if score #Length Test.Temporary matches 64.. run function test:string/split/start/64
    execute if score #Length Test.Temporary matches 32.. run function test:string/split/start/32
    execute if score #Length Test.Temporary matches 16.. run function test:string/split/start/16
    execute if score #Length Test.Temporary matches 8.. run function test:string/split/start/8
    execute if score #Length Test.Temporary matches 4.. run function test:string/split/start/4
    execute if score #Length Test.Temporary matches 2.. run function test:string/split/start/2
    execute if score #Length Test.Temporary matches 1.. run function test:string/split/start/1

# 余計なものは残さない
    scoreboard players reset #Length Test.Temporary
```

```mcfunction:start/1024.mcfunction
#> test:string/split/start/1024
# @within function
#   test:string/split/
#   test:string/split/start/1024

# 前から1024文字拾う
    data modify storage test:temp 1024 set string storage test:temp all 0 1024
    function test:string/split/append/1024
# 1025文字目から先を再帰させる
    data modify storage test:temp all set string storage test:temp all 1024
    scoreboard players remove #Length Test.Temporary 1024
    execute if score #Length Test.Temporary matches 1024.. run function test:string/split/start/1024
```

```mcfunction:append/1024.mcfunction
#> test:string/split/append/1024
# @within function
#   test:string/split/start/1024

# 前から512文字をさらに分割する
    data modify storage test:temp 512 set string storage test:temp 1024 0 512
    function test:string/split/append/512
# 後ろ512文字をさらに分割する
    data modify storage test:temp 512 set string storage test:temp 1024 512
    function test:string/split/append/512
```

以上の3つはコードの一部です。

文字列を代入すると、綺麗に1文字ずつに分割された配列が出力されます。

```mcfunction
# これを入力して
    data modify storage api: Argument.String set value "v1.2.3"
# 実行すると
    function test:string/split/
# ["v", "1", ".", "2", ".", "3"]が返ってくる
    tellraw @a {"nbt": "Return", "storage": "api:"}
```

### くっ付けたり

文字列を粉々に分割したら、次は都合のいい形に結合しましょう。
（わざわざ1文字ごとに分割したのは、~~面倒だった~~綺麗に二分割し続けるのに必要だったからです）

```mcfunction:.mcfunction
#> test:string/concat/
# @input storage
#   api: Argument
#       CharArray : string[]
#       Punctuation : string[]?
# @output storage
#   api: {}
#     Return : string[]
# @api

# 空なら補充しておく
    execute unless data storage api: Return run data modify storage api: Return append value ""

# 分割対象か確認する
    scoreboard players set #IsPunc Test.Temporary 0
    data modify storage test:temp punc set from storage api: Argument.Punctuation
    execute if data storage test:temp punc[0] run function test:string/concat/check
# 対象を取得する
    data modify storage test:temp Left set from storage api: Return[-1]
    data modify storage test:temp Right set from storage api: Argument.CharArray[0]
# メインの実行部分
    execute if score #IsPunc Test.Temporary matches 1 unless data storage test:temp {Left: ""} run data modify storage api: Return append value ""
    execute if score #IsPunc Test.Temporary matches 0 run function test:string/concat/concat with storage test:temp

# 証拠は残さない
    scoreboard players reset #IsPunc Test.Temporary
    data remove storage test:temp Left
    data remove storage test:temp Right

# もし残っていたらループ
    data remove storage api: Argument.CharArray[0]
    execute if data storage api: Argument.CharArray[0] run function test:string/concat/
```

```mcfunction:check.mcfunction
#> test:string/concat/check
# @within function
#   test:string/concat/
#   test:string/concat/check

#> スコアホルダーの定義
# @private
    #declare score_holder #Different

# 対象の文字が分割文字と一致しているか確認 (一致していたら0を返す)
    execute store success score #Different Test.Temporary run data modify storage test:temp punc[0] set from storage api: Argument.CharArray[0]
    execute if score #Different Test.Temporary matches 0 run scoreboard players set #IsPunc Test.Temporary 1

# 禍根は残さない
    scoreboard players reset #Different Test.Temporary

# 残っていたらループ
    data remove storage test:temp punc[0]
    execute if data storage test:temp punc[0] run function test:string/concat/check
```

```mcfunction:concat.mcfunction
#> test:string/concat/concat
# @within function test:string/concat/

# マクロ関数式結合
    $data modify storage api: Return[-1] set value "$(Left)$(Right)"
```

特に最後の結合部分は、JE 1.20.2+のみの機能である「マクロ関数」を利用しています。
これ以前のバージョンではコマンドブロックの失敗ログから取得するという手法を用いて結合ができます。
（実装からデバッグまで面倒なのでここではこれ以上取り上げません）

先ほど分割した文字列を入れると、3つの数字からなる配列が出力されます。

```mcfunction
# これを入力して
    data modify storage api: Argument.CharArray set value ["v", "1", ".", "2", ".", "3"]
# 実行すると
    function test:string/concat/
# ["1", "2", "3"]が返ってくる
    tellraw @a {"nbt": "Return", "storage": "api:"}
```

### 数値に変えたり

ここまででバージョンタグの3つの数字を取得することができましたが、このままでは数値としての比較ができません。
何故なら、この数字は「文字」であり、「数値」ではないからです。（スコアに代入しようとしても0が代入されます）

そこで、数字を数値に変える機能を実装しましょう。

```mcfunction:.mcfunction
#> test:string/convert/int/
# @input storage
#   api: Argument
#       String : string
# @output storage
#   api: {}
#       Return : int[]
# @api

# 文字を文字列に
    function test:string/split/
    data modify storage test:temp array set from storage api: Return
# 文字列を数値に
    scoreboard players set #CheckConvert Test.Temporary 1
    scoreboard players set #Result Test.Temporary 0
    function test:string/convert/int/loop
# 代入処理
    execute if score #CheckConvert Test.Temporary matches 0 run tellraw @a "error"
    execute if score #CheckConvert Test.Temporary matches 1 store result storage api: Return int 1 run scoreboard players get #Result Test.Temporary

# 痕跡は残さない
    scoreboard players reset #CheckConvert Test.Temporary
    scoreboard players reset #Result Test.Temporary
```

```mcfunction:loop.mcfunction
#> test:string/convert/int/loop
# @within function
#   test:string/convert/int/
#   test:string/convert/int/loop

# 文字を拾い上げる
    data modify storage test:temp candidate set from storage test:temp array[0]
    data remove storage test:temp array[0]

# メイン処理
    scoreboard players operation #Result Test.Temporary *= #10 Const
    # execute if data storage test:temp {candidate: "0"}
    execute if data storage test:temp {candidate: "1"} run scoreboard players add #Result Test.Temporary 1
    execute if data storage test:temp {candidate: "2"} run scoreboard players add #Result Test.Temporary 2
    execute if data storage test:temp {candidate: "3"} run scoreboard players add #Result Test.Temporary 3
    execute if data storage test:temp {candidate: "4"} run scoreboard players add #Result Test.Temporary 4
    execute if data storage test:temp {candidate: "5"} run scoreboard players add #Result Test.Temporary 5
    execute if data storage test:temp {candidate: "6"} run scoreboard players add #Result Test.Temporary 6
    execute if data storage test:temp {candidate: "7"} run scoreboard players add #Result Test.Temporary 7
    execute if data storage test:temp {candidate: "8"} run scoreboard players add #Result Test.Temporary 8
    execute if data storage test:temp {candidate: "9"} run scoreboard players add #Result Test.Temporary 9
# 数字でなければエラー
    execute unless data storage test:temp {candidate: "0"} unless data storage test:temp {candidate: "1"} unless data storage test:temp {candidate: "2"} unless data storage test:temp {candidate: "3"} unless data storage test:temp {candidate: "4"} unless data storage test:temp {candidate: "5"} unless data storage test:temp {candidate: "6"} unless data storage test:temp {candidate: "7"} unless data storage test:temp {candidate: "8"} unless data storage test:temp {candidate: "9"} run scoreboard players set #CheckConvert Test.Temporary 0

# 残っていれば再帰
    data remove storage test:temp array[0]
    execute if data storage test:temp array[0] run function test:string/convert/int/loop
```

```mcfunction
# ここまでの成果
    data modify storage test:temp version set value ["1", "2", "3"]
# 1が出力される
    data modify stoarge api: Argument.String set from storage test:temp version[0]
    function test:string/convert/int
    tellraw @a {"nbt": "Return", "storage": "api:"}
# 2が出力される
    data modify stoarge api: Argument.String set from storage test:temp version[1]
    function test:string/convert/int
    tellraw @a {"nbt": "Return", "storage": "api:"}
# 3が出力される
    data modify stoarge api: Argument.String set from storage test:temp version[2]
    function test:string/convert/int
    tellraw @a {"nbt": "Return", "storage": "api:"}
```

これで、バージョンタグの文字列をバージョンを示す数値として出力することができました。
あとはスコアに代入して煮るなり焼くなりできますね。

## まとめ

ここまでする必要があるかって？要らないと思う。
見た目を気にする人以外は最初からスコア3種で管理してください。

## ソースコード

今回の記事にあたって軽く実装したものをソースコードとして残しておきます。
車輪の再発明が嫌いな方はこちらで楽をしてください。

[Ashen-Lib v0.1.1](https://github.com/haiiro2gou/Ashen-Lib/releases/tag/v0.1.1)