ADR-0036 作用の文法を「項ごとの型」で表現する
| 状態 | accepted |
| 決定日 | 2026-09-11 |
| 区分 | アーキテクチャ |
背景
ADR-0030 は効果を「対象変数・演算・ 基準値・値の寿命・発動タイミング・繰り返し範囲」の6項を持つ作用として定義し、 ADR-0035 は「効果1つ=クラス1つ」 という対応を禁じた。この2つの決定の間に、実装が踏み外しやすい隙間がある。「型を作らない」の 単位が何なのかを、どちらの ADR も明文化していない。
この隙間は本 Issue(#46)の実装が2回続けて Phase 12(人間レビューゲート)で差し戻される
原因になった。1回目・2回目のセッションは、6項を汎用の Dictionary 1つ(Operation という
単一クラスがフィールドを持つだけ)で表現するか、演算族ごとに型を分けるかを実装が独自に判断し、
どちらの選択も「なぜその軸を選んだか」を記録しないまま Phase 12 まで進んだため、レビューで
差し戻された。
同時に、NumericOperation.base_value(基準値)も同じ形の問題を抱えていた。仕様上
「定数、または参照式(参照カウンタ × 係数)」の2形態を持つが、これを生の Dictionary
(例: {"kind": "reference", "counter": "used_ho", "scope": "turn", "factor": 1.0})で
持つと、キー名の綴り間違いや必須キーの欠落がコンパイル時ではなく実行時にしか発覚しない。
さらに、Guard(防御の多重集合)の消滅契機(被弾1回/ターン/ステージ/ラン)と
Operation.value_lifespan(値の寿命スコープ:プレイ/ターン/ステージ/ラン)は、
ターン/ステージ/ランの3値が字面上一致する。この一致に引きずられて
Operation.value_lifespan をそのまま Guard の消滅契機に変換する実装を書くと、
「被弾1回で消える防御」(EXTINCTION_ON_HIT)が value_lifespan のどの値にも対応しない
まま宙に浮き、Operation の文法だけでは絶対に作れない防御になる。この非対称性を実装が
見落とし、後から気づくとバトルループ側の実装(#47以降)を作り直す規模の手戻りになる。
決定
1. 型付けの軸は「演算族」であり、「個々の効果」ではない
Operation を抽象基底とし、AC-2 の5つの演算族(数値・設定・パラメータ・制御・領域)ごとに
具象サブクラスを作る: NumericOperation/AssignOperation/LimitChangeOperation/
SuppressOperation/MoveOperation/SpawnOperation/DuplicateOperation。
ADR-0035 が禁じたのは「打撃・遠射・加護のような個々の効果の通称をクラス名にすること」
であり、「文法そのものが持つ項の種類を型で区別すること」ではない。前者は打点3のページと
打点7のページが別クラスになる話であり、後者はどちらも同じ NumericOperation のインスタンス
(base_value が違うだけ)である。ADR-0030 が定義した6項のうち、演算と基準値の形は演算族
ごとに異なる構造を持つ(数値族だけが arithmetic/correction_mode/base_value を持ち、
領域族の移動は page/destination を持つ、等)。この構造の違いを型で表すことは、
「文法の語彙を増やさない」という ADR-0035 の目的と衝突しない。
2. 基準値の参照式を型付きクラスにする
NumericOperation.base_value の型を ValueSource(抽象基底)とし、ConstantValue
(定数)と ReferenceExpression(参照カウンタ × 係数、counter/scope/factor を
持つ)の2つの具象サブクラスに分ける。ReferenceExpression は構築時に
CounterScopeTable へ照合し、存在しないカウンタ名や × セル(対応表で参照不可と定めた
組み合わせ)を構築時に拒否する。
Dictionary 形状のまま出荷しなかった理由は (1) と同じ強度で当てはまる: キー名の綴り間違いや
必須キーの欠落を実行時まで発見できない問題を、Operation 本体だけ直して base_value は
直さない、という対症療法にしない。
3. Guard の消滅契機は Operation.value_lifespan から導出しない
Guard.add(value, extinction_trigger) を、Operation の文法を経由しない直接呼び出し
専用の入口として維持する。Operation が guard を対象にする場合、値の寿命スコープは
ターン/ステージ/ランの3値のみを許可し、プレイは構築時に拒否する
(NumericOperation の guard_value_lifespan_play_not_allowed)。「被弾1回で消える防御」
(EXTINCTION_ON_HIT)は Operation からは一切作れず、バトルループ側
(#47以降)が Guard.add() を直接呼ぶ経路でのみ生まれる。
ターン/ステージ/ランが両方の enum に同じ名前で存在するのは偶然の字面一致であり、
意図された対応関係ではない。プレイと被弾1回は別概念(前者は「このページをプレイした
という行為の範囲」、後者は「Intent を1個受けるという事象」)であり、対応する変換先が存在
しない。この3値だけを無変換で渡すコードは、二つの異なる列挙型が同じラベルを持つという表面的
な一致に依存しており、プレイの場合にどう振る舞うべきかという問いに答えを持たない。
却下した案
| 案 | 却下理由 |
|---|---|
Operation を単一クラスにし、演算族ごとの差分をすべて Dictionary か Optional フィールド群で持つ | 数値族だけが持つ arithmetic/correction_mode と領域族だけが持つ page/destination が同じクラスに同居し、演算族によってどのフィールドが有効かが実行時にしか分からない。過去2セッションでレビュー差し戻しの原因になった形そのもの |
base_value を {"kind": ..., ...} の Dictionary のまま出荷する | キー名の誤字や必須キーの欠落が実行時まで発覚しない。Operation 本体は型付けし base_value だけ据え置く、という一貫しない対応になる |
Operation.value_lifespan.scope が guard を対象にするとき、ターン/ステージ/ランをそのまま Guard の消滅契機に変換する(プレイは特別扱いで拒否) | 3値の字面一致は偶然であり、対応関係を裏付ける仕様上の根拠がない。この変換を書いた瞬間、「このプレイで1回だけ効く防御」という発想が実装者に生まれうるが、Operation の文法にそれを表す項は無い。変換表を書くこと自体が、存在しない対応関係をコードに固定してしまう |
Guard の消滅契機に PLAY を追加し、Operation.value_lifespan の4値すべてを機械的に変換する | 「プレイ中だけ効く防御」を作れてしまうが、それは「被弾1回」(AC-9)とは別の意味(複数回の被弾を防プレイ中ずっと防ぐ)になり、要求されていない新しい防御カテゴリを仕様側の合意なしに増設することになる |
影響
core/effect/配下に、Operation/ValueSource/PlayConditionを抽象基底とする 複数階層のクラス階層が生まれた。この多階層構造は、境界検査 (tests/unit/test_layer_boundaries.gd)が各ファイルのextends節の文字列をRefCounted/Resourceと直接比較するだけでは検査できない — 2階層目のサブクラス (Operationを継承するNumericOperation等)を機械的に弾いてしまうためで、実際に レビューで指摘された。検査はScript.get_instance_base_type()でスクリプトの実際の 祖先チェーンをエンジンに解決させる方式に書き直され、本 ADR の型階層を正しく通す#47(バトルループ)・#48/#54(敵 Intent)・#53(ページ定義)は、今後Operationの演算族ごとの具象サブクラスとValueSourceの2具象クラスを公開インター フェースとして直接構築する。新しい演算族や基準値の形が必要になった場合も、既存の型を Dictionary 化して回避するのではなく、同じ軸(演算族単位)でサブクラスを追加する- バトルループ側が「被弾1回で消える防御」を配る唯一の手段は
Guard.add(value, EXTINCTION_ON_HIT)の直接呼び出しになる。Operationの文法だけでページ効果や敵の 行動を書いている限り、このカテゴリの防御は誤って生成され得ない