スロットシステム設計

概要

スロット(親子関係とコンテナ、GameElementDefinition.md 7節)が 実行時にどう実装されているかを記述する設計ドキュメントです。重さの伝播は ContainerSystem.md、中身入りの容器は LiquidContainerSystem.md を参照してください。

データ構造はセルの並びとスタックの2階層(1 節)で、受け入れ判定は重ならない問い——型が合うか (accept)・位置があるか(cell_count/max)・かさが入るか(capacity)——に分けます(2 節)。 束ねてよいか(stackable)は枠ではなく物の性質です(4 節)。スロット専用の新しい文法はありません。

定義は SlotDef、実行時状態は Slot(受け入れ判定)と CellLayout(枠の並び)と ObjectStack、 検証は tests/domain/cells.test.ts・stacking.test.ts・objectStack.test.ts 等です。本書は実装済みの 仕組みの記述で、未決事項は 7 節に整理しています。

1. データ構造: セルの並びとスタックの2階層

スロットの中身は「セルの並び」として持つ(Slot.cells)。

セルとスタックの区別を畳み込んだ派生ビューが Slot.contents で、位置を要さない内部処理(タグ判定・ 重さ集計など)はこちらを読む。スタックの分かれ方まで見たい読み手には Slot.stacks がある。

Slot が中身を並びで返す口は、読んだ時点の顔ぶれを写して返す——これは実装の都合ではなく契約で、 受け取った側は辿っている途中でそのスロットの中身が出入りしても構わない(時間経過は、中身を辿りながら 子を消す。WorldObject.tick)。中身の並びの実体を持つのは ObjectStack で、それを直に読む経路 (Slot.cells を辿る側)は、後で読み返すなら自分で写し取る。写しでなくなれば tests/domain/stacking.test.ts が落ちる。

出入りは唯一の汎用操作 move_to_slot(WorldObject.moveToSlotOrRejection → attachToSlotOrRejection)経由のみ。 親子整合・weight 伝播・passive エッジの登録という副作用を1箇所に集約する。 枠が受け入れないものを押し込む経路は無い。 行き場を失った物は WorldObject.spillTo で親、さらに その親…と落ちていき、どこにも入らなければ世界から消える (GameElementDefinition.md 9.4節)。どの段でも枠の宣言はそのまま効く。

出入りの入口は、違いが位置をどう指すかだけ(WorldObject)。

行き先は Slot そのもので指す。 「どのオブジェクトの、どの名前の枠か」を組で渡すと、2つが 食い違う書き方が作れてしまう。枠を引くのは WorldObject.tryGetSlot / getSlot(プロパティと同じ try/get の対)で、引いた後は枠だけが行き先になる——世界の変化の記録(WorldChange.from / to)も、 画面がカードを並べる場所(CardPlace)も同じ Slot を指す。

2. 受け入れ判定: 重ならない問い

スロットは中身を直接持たず、枠(セル)の並びを持つ。何が入るかも何個入るかも枠ごとに決まり、 Slot.rejectionFor は次を検証し、入らないならその理由を返す(入るなら undefined)。 いずれも定義が無ければ無制限。表に並ぶキーを定義するのは GameElementDefinition.md。

制約 答える問い 書く場所
accept(同 7.2節) 何が入るか 枠。型の指定(同 4.1節)でマッチ
max(同 7.2節) その枠に何個積めるか 枠。束ねられる型にだけ効く(4節)
cell_count / cells(同 7.2節) 枠がいくつあるか スロット
capacity(同 7.3節) どれだけ(かさ)入るか スロット。中身の volume 合計 + 候補の volume が上限以下

どの2つも同じことを言えない。 「1枠に4個」と「4枠に1個ずつ」は意味も見た目も違うので、 枠の max と枠の数は入れ替えが利かない。

身につける枠だけは、枠の宣言に加えて物の側の宣言も見る(covers / layer。何が競合かは GameElementDefinition.md 7.5節)。制約を持つのが枠ではなく中身なので、 上の表には並ばない。

入るか否かとは別に、入れるのにいくらかかるかもスロットが持つ(put_in、同 7.10節)。値段を枠に置くのは、 プレイヤーが物を入れる経路が複数ある(カードへ重ねる・レーンへ落とす・端の矢印で送る)ため——経路の側に 書くと、書き漏らした経路だけが無料になる。 時間を課すのは入れる側だけで、出すのは常に一瞬。

枠は宣言順に埋まるのではなく、型の合う枠へ入る。 「板1枚と棒4本」の材料欄へ棒を先に入れれば、 板の枠は空いたまま棒の枠が埋まる。合流できる枠(同種・maxに空きあり)があればそちらが優先される (「同種は1スタック」、4〜5節)。

受け入れが重なる枠でも、入る組み合わせが在るなら入る。 刃物の枠と尖った石の枠を持つ材料欄へ尖った 石を先に入れると、石は先に宣言された刃物の枠へ入る。そこへ石斧(刃物の枠にしか入らない)を入れると、 入っている石を型の合う別の空き枠へずらして刃物の枠を空ける(CellLayout の pathToCellFor)。 型の合う空き枠が直接あれば中身は動かさない。置ける数を答える側(vacancyForIgnoringVolume)も同じ ずらし方で数えるので、入れれば入る物を受け入れ判定が断ることはない。検査は tests/domain/cells.test.ts 「受け入れが重なる枠」。

数はスロットに持たせない。 型ごとの個数(「板1枚と棒4本」)は枠の形そのもので表す。レシピが 要求する量を書く先は requires だけで、枠には書かない(同 7.2節)。

placement(同 7.7節)はここには入らない。「このスロットへこの物を入れられるか」ではなく「エンジンが宛先を 自分で選ぶ走査に加わるか」を表すため、判定するのは Slot.rejectionFor ではなく走査する枠を集める側 (ObjectDef.placementSlotDefs——枠がその走査に加わるかは SlotDef.allows が答える)。名指しの move_to_slot はこの属性を見ない。

3. 枠数を決めたスロットと、決めていないスロット

両者の違いは「空になったセルを残すか」「枠を増やせるか」「位置として枠を指せるか」で、 どれも cellsToKeep 1つから決まる。置き方(どこへずらすか)のロジックは共通。

枠数を決めることと位置が安定することは同じことなので、これらを別々のキーにはしない。読む側へも 1つの問いとして答える(SlotDef.cellCountPolicy——宣言した数か、'grows')。別々に答えると、同じ1つの 判断を読む側の数だけ組み立て直すことになる。枠が5つだと 分かっていれば「5番目が空いている」と言えるが、長さが可変の並びには指し示すべき空き枠が無く、詰めるしか ない。逆に、枠数を決めた瞬間に位置は安定させられる。

プレイヤーによる手動配置・並び替えは、どちらのスロットにもできる。位置の指定は「セルとセルの隙間」 (0が先頭のセルの前)か「空きセルそのもの」のいずれかで行い、SlotPosition 1つで表す。

どちらを使えるかを指す側は知らなくてよい。 空きセルを指せるのは枠数を決めたスロットだけだが、 前詰めスロットの空きセルは末尾の受け皿しか無いので、そこでの「空きセル」はその位置の隙間と同じことを 意味する。この読み替えは並びの側(CellLayout)が行うので、画面は指で示した位置をそのまま渡す。

挿入は、合流できる既存スタックがあるときは指定された位置より「同種は1スタック」(4〜5節)を優先して そちらへ入れる。

どの指定でも、枠の宣言(accept・max)に合わない置き方はしない。 指した枠も、隙間へ入れるために ずらされる中身の移る先も、並び替えで入れ替わる相手の移る先も同じく見る。合わなければ失敗で、型の合う 別の枠へは振り替えない。受け入れ判定(Slot.rejectionFor)は位置を渡されれば同じ問いに答えるので、 指した位置へ置けないことは動かす前に分かる。

same_slot spawn の置き換え配置は CellLayout.placeSameSlot が実装する。置き換え位置の決定(元のセル・ 同種が残っているか・1つの効果から2個目以降が生まれたときの並べ先)は、効果適用の入口で捕捉した SameSlotSpawnSite が担う(ActionSystem.md 5節)。どう書けばどちらになるかは GameElementDefinition.md 7.6節。

例(cell_count: 4、_ は空きセル): A(0) _(1) B(2) _(3) で A から C が生まれると(起点は残る)、 右隣の1番が空いているのでそのまま入り A C B _。続けて A から D が生まれると、右隣の1番は埋まって いるので空いている3番までの間(C・B)を右へずらし A D C B。さらに A から E が生まれても、4枠が すべて埋まっているため配置に失敗して親へこぼれる。生まれた側が起点と合流できる型(A から A)なら 新しいセルを消費しないので、このあふれは起きない。

右に空きが無い場合: _(0) _(1) A(2) B(3) で A から C が生まれると、右(3番)は B で埋まっているため 左を探し、空いている1番へ入って _ C A B。続けて B から D が生まれると、右(4番)はスロットの外なので 左を探し、空いている0番までの間(C・A)を左へずらして C A D B。

置き場の決まりを崩すと tests/domain/stacking.test.ts が落ちる——上の例がそのままケースとして並んで いる。

4. 束ねてよいか(stackable)は物の性質

stackable は object_defs 側に書く(既定 true)。

入れ物ではなく物の性質である理由は、束ねたくない理由が個体の側にあるため。道は行き先が個体ごとに 違い、かごは中身が個体ごとに違うので、束ねると代表の行き先・中身しか触れなくなる。同じ物は、地面に あっても手に持っても籠の中でも束ねない(詳細は DesignNotes.md)。

枠の max(GameElementDefinition.md 7.2節)は束ねられる型にだけ 効く。stackable: false な型を受ける枠に max: 4 と書いても実質1個で、2通りの言い方ではなく 片方の軸がもう片方の有効値を縛るだけ。

5. スタックの同一性

ObjectStack は生成時に seed の ObjectDef をスナップショットし、合流判定(canMerge)はこれとの 一致を要求する。水入り甕と茶入り甕が別スタックになるのは、中身入りの容器が中身ごとに別の型だから (LiquidContainerSystem.md 1 節)で、スタック側は型を1つ見るだけでよい。

6. スタック内の並び順(stack_order)

型ごとの表示専用の並び順(StackOrderDef。書き方と ascending の向きは GameElementDefinition.md 7.6節)。指定プロパティの値で、新規 メンバーの挿入位置を決めるだけで、挿入後の値の変化に追従した再ソートは行わない (add は同種で同じ速度で変化するため、挿入時点の相対順序が保たれる前提)。 同値は既存メンバーの後ろ(挿入順維持)。

この前提は宣言の側で保つ。 並び順が見る実体値を時間経過以外——操作の効果・操作の間だけ効く 積み上げ・range の端のイベント——が動かす宣言が入ると、tests/world-codex/stackOrder.test.ts が 落ちる。そうした書き手は束の1枚にだけ当たるので、当たった札が順序の外れた位置に居座る。数えない ものもある——可逆な寄与(modify)は実体値を動かさないので並びも動かさず、値を端へ倒すだけの 既定のクランプは、端が並びの外側なので順序を崩さない。

7. 未決事項・今後の検討課題