アクションシステム設計

概要

プレイヤーがカードに対して行う操作が、実行時にどう実装されているかを記述する設計ドキュメントです。 YAML上の文法そのものは GameElementDefinition.md(interactions は同 11 節・12 節、active は同 9 節、pick は同 10 節、conditions は同 14 節)、操作の画面側の 入口(アクションの行・ドラッグ&ドロップ)は ../ui/Windows.md 4 節・ ../ui/CardInteraction.md が扱います。

入口が分かれていても、実行は1本です。 きっかけ(trigger: menu / tick / {drag: ...})が違うだけで、 中身は同じ InteractionDef 1つ。どれも同じ実行パイプライン (マッチング → conditions → duration の解決 → 時間進行 → 生存確認 → 効果の適用、2 節)を通ります。 起きたことは分岐名ではなく世界に起きた変化として観測します(7 節)。操作専用の新しい文法はありません。

実装は src/domain/InteractionDef.ts(操作の中身)と src/domain/InteractionTrigger.ts(きっかけ)、 src/domain/WorldObject.ts・WorldSession.ts、検証は tests/domain/interaction.test.ts・ actionDuration.test.ts・worldChanges.test.ts です。本書は実装済みの仕組みの記述で、 未決事項は 8 節に整理しています。

1. 入口: menu・tick・drag

操作の入口はきっかけの数だけで、どれも object_def に宣言的に定義される。

違うのはきっかけだけなので、差はきっかけの側に集める。 宣言はYAML上も1つの節(interactions)で、 中身(要件・効果・所要時間・実行、2節)は InteractionDef 1クラスが持つ。きっかけが宣言を ぶら下げる(InteractionTrigger.interaction)ので、差を足すのは派生の DragTrigger だけ ——相手のマッチングと allow_multiple。

ObjectDef はきっかけ別に束ねて持つ(menuTriggers・tickTriggers・dragTriggers)。 ボタンを並べる側も時間の側も自分に関わるものだけを受け取るので、種類を見分ける必要がない。 どの束に入るかはきっかけ自身が答える(addTo)ので、種類を足したら実装しないとコンパイルが通らない。

1.1 宣言と、相手の決まった操作

きっかけと、それがぶら下げている宣言は ObjectDef のもので、どの個体の話かを知らない。実行にも 所要時間の解決にも self(instrument・agent)が要るので、宣言へ直に頼むと呼び出し側がそれらを毎回 渡し直すことになる。そこで WorldObject から引いた時点で相手を結び付け、Action・Combination (Interaction.ts)として返す。以降は名前も相手も渡さない。

引く口は次のとおり。

どちらの列挙も、相手のマッチングのほかに行き先の型(blocksOperation。become の行き先に型が 居ない、GameElementDefinition.md 9.9節)で先に絞る。ここで落ちたものは reason を宣言していてもどちらにも入らない=理由が出ない。「なぜ理由が出ないのか」を追うときは、 まずここを見る。

容量(acceptedCountIncludingSelf が0の相手)はこの門ではなく、combinationsWith の側の条件 (Combination.canExecute)。0は「何個受け取れるか」の答え=断る理由であって、候補になるかどうかの 判定ではない。両方の門にすると、満杯を conditions にも書いた宣言(満杯の罠の trap_baited)は条件と 容量が同時に落ちるので、宣言した理由が決して届かなくなる。

どちらの札を self として引くか(両向きとも引いたうえで、成立するほうを先に採る。どちらの向きも 成立しないなら落とされた側)と、複数マッチした場合にどれを実行するかの解決はUI層に委ねる (../ui/CardInteraction.md 2 節、cardOperations.combinationBetween)。

まとめて重ねる操作(allow_multiple、GameElementDefinition.md 12.4節)も Combination が持つ。 acceptedCountIncludingSelf(followers) が落とす前に、掴んだ1枚を含めて何枚落ちるかを答え、 executeWithFollowers(followers) がその繰り返しを行う——1つ実行するたびに世界が変わるので、 都度まだ成立するかを見直し、成立しなくなった時点で止める。

1.2 ドラッグ型をメニュー型へ畳まない

相手が要る操作をドラッグ型で持つのは、相手の個体が要るから。 運ばれてきた札がそのまま instrument になるので、宣言はその1つを名指して見られるし、減らせる。

メニュー型には instrument が居ない(役を書ける場所は GameElementDefinition.md 11.5節)。代わりに書けるのは 「その型の物を持っているか」の存在判定(同 14.3節のスロット中身判定)までで、どれを使ったかは 答えない。 だから道具を見る要件も道具へ及ぶ効果も、書き換えた先に置き場が無い。

置き場が無いものを落とすと、操作そのものが変わる。 刃の余力を見る要件が落ちれば 尽きた斧でも木に刃を入れられ、刃を減らす効果が落ちれば倒しても斧が減らない。相手に触れない宣言 (草を刈る)だけは成果物まで同じものが書けるが、そこでも相手の居場所が要件になる——ドラッグ型は 運ばれてきた札を見るので居場所を問わないのに対し、枠を名指す条件は地面に置いた刃物へ届かない。

この節が言う差は tests/world-codex/dragAsMenu.test.ts が留める——実データの宣言を引いた側と、 それをメニュー型へ写した側を並べて測っている。

1.3 掴んだ瞬間の走査は、貯め込まなくても安い

札を掴むと、受け入れられる札のふちを光らせるために、並んでいる札のどれとも組み合わせを問います (CardDragController.showAcceptingCards → ShownCards.acceptingCells)。問うのは両向きで、成立する もの(combinationsWith)と断るもの(refusedCombinationsWith、1.1節)の両方です。

それでも答えを貯め込みません。大半の札は conditions まで届かないからです——相手として 受け入れる宣言を持たない札は、型のタグを照らし合わせたところで候補が空になり、要件(3節)は 一度も評価されません。評価が走るのは、掴んだ物を相手として名指している宣言を持つ札だけです。

現在地に150枚の札を並べ、いちばん多くの札と噛み合う物(この時点では salt。150枚のうち48枚が 組み合わせを返す=要件まで届いた札の下限)を掴んで全部を両向きに問うのに 0.51ミリ秒でした (2026-09-18 時点、 Intel Xeon @ 2.10GHz・Node v22 で計測)。

並ぶ枚数に頭打ちはありません——束ねない型(stackable: false、SlotSystem.md 4節)は個体ごとに1枚の札になるので、同じ型を並べればいくらでも増えます。なので「この枚数で収まった」 だけでは足りず、周りの枚数を倍にしても1枚あたりの判定が変わらないことも併せて見ています。

ここまでは問われた側の値段です。それを回す画面側にも上限を引いています。 落とし先を1つ 問うたびに、その場所に並ぶ札は丸ごと作り直される(PlayScreenView.cardsIn)ので、画面が枠を1つ ずつ問いに行くと、判定がいくら安くても枚数の2乗で伸びます。 だから画面はどの枠が受け入れるかを 一度にまとめて訊き(ShownCards.acceptingCells)、並びを場所ごとに1度だけ作ります。同じ150枚を掴んだときの 走査1回が 2.2ミリ秒でした(2026-09-20 時点、Intel Xeon @ 2.80GHz・Node v22 で計測)。 掴んだ指が待つのはこの時間なので、ここだけは上限を1フレーム(16ミリ秒)で引いています。

どれも tests/diagnostics/interactionCost.test.ts が見ます——同じ並びを組んで測り、桁の変わった 遅さでも、枚数に連れて伸びる形でも赤くなります。

2. 実行パイプライン

実行は次の順に進み、途中で失敗すると false を返して世界の形は何も変えずに終える。順序に意味が あるため、実装は InteractionDef に1つだけ置く(相手のマッチングだけは Combination が先に見る)。

  1. with マッチング(combinations のみ): instrument の型が with の指定に当てはまるか (TypeMatchRule。タグならそのタグを持つか、object_defならその型そのものか)。
  2. conditions 評価(3節): 省略時は常に真。
  3. announce(GameElementDefinition.md 11.6 節): この操作が始まった ことを告げる。世界の形を変えないので、後の段が落ちても取り消さない——6 が成立しなかったと 判定した操作も、始まったこと自体は起きている。
  4. duration の解決: 「今の self(combinations では instrument も)の状態から見て、どれだけかかるか」 なので、時間を進める前に分数だけ確定させる(切れ味の悪い刃物ほど時間がかかる、が書けるように)。
  5. 時間進行(6節): 効果の適用より先に進める。行動してから結果が出る順序であり、作ったもの・ 見つけたものが自分の制作時間・探索時間ぶんの tick を浴びずに済む。この間だけ、その操作が宣言した 持続効果(passives、GameElementDefinition.md 11.7 節)を登録する。
  6. 関与オブジェクトの生存確認(6節): 経過中に失われていたら、その行動は成立しなかったものとして false を返し、効果を適用せずに終える。
  7. 効果の適用: WorldObject.applyActiveEffect(effect, context)(5節)。self も役も ReferenceContext(4節)が1つにまとめて持つ。
  8. 待たされていた手番(trigger: tick で duration を持つもの、 GameElementDefinition.md 11.5 節)を起こす。ここが操作の切れ目で、 5 の経過中に配られた手番はその場では起きずにここまで待つ(WorldSession.runToSeam)。 時間を進める操作は他にもある(製作の 1 工程・枠へ入れる)が、どれも実行として関係を張るので、 切れ目を名乗るのは関係を張る側(InteractionRelation.whileActing)だけ——操作の外で時間だけが 動いた場合は、その進行そのものが切れ目になる。

3. 実行可能条件(conditions)

ConditionNode の木。葉と、複合の all / any / not からなり、条件の並びを書ける場所は どこも同じ木を共用する(一度きりの判定も、passives の持続的なゲート(GameElementDefinition.md 8.2節)も)。書ける葉と演算子キーは GameElementDefinition.md 14節の表が唯一の一覧で、ここには写さない。

参照先が解決できない場合(親が無い等)、その葉は偽になる。

4. 条件・効果から参照できるオブジェクト

conditions の subject、効果の対象キー、{subject, prop} 参照はすべて共通の起点 ReferenceRoot を使う。self.prop のような1階層の参照のみで、パス連結はない。

起点 解決先 解決先を持つ場所
self 宣言元の個体自身 宣言元の個体が居る場所(rangeイベント・passives も含む)
parent self の直接の親 同上
agent その操作で動いている個体 操作の関係の役(同 11.5節)。書ける場所を持つのは ReferenceScope(下の段落)
instrument 運ばれてきて働きかけに使われる参加者。宣言が乗っていない側 同上
patient 働きかけられる参加者。操作の宣言が乗っている側 同上(操作の宣言の中では self と同じ物になるので書けない、同 11.5節)
picked among(同 10.3節)が周りから選んだ相手 among を書いた候補の中(重みと効果)だけ
ancestor self の親から遡り、参照プロパティを定義する最初の祖先 その場所が、指す先のプロパティ名を決めているときだけ——祖先はその名前で探すので、プロパティ名が関わらない場所(オブジェクトそのものを指す場所)では相手が決まらない

どの起点が書けるかは、場所ごとに数え上げるのではなく、宣言が置かれた場所が何を持つかから導く (ReferenceScope)。ロード時に弾く根拠と、実行時に組む ReferenceContext は同じ1つの事実なので、 書けたのに実行時は必ず空振りする、という宣言が作れない。同 11.5節「役を書ける場所」の表の各行は、 ReferenceScope の static(と withInstrument のような派生)のどれかへ対応する——行と static は 1対1ではない。 drag と put_in は同じ acting.withInstrument を、on_max/on_min と resists (同 7.13節)は同じ declaration を共有する。表に無い場所が現れたら、持つものが同じ static を使う。

world は起点として未対応(ロード時エラー)。すべてのオブジェクトは world の下にぶら下がるため、 world 固有プロパティの参照は ancestor で代替できる。起点としての child は passives の target 専用で、 この文脈では使えない(5節の into が名乗る child は行き先の名前で、起点ではない)。 解決できない対象への適用は、その命令だけ無視される(実行全体は失敗しない)。

5. 効果(ActiveEffect)

効果はポリモーフィックな ActiveEffect で、自身を再帰的に組み合わせる1つの型。何を並べて書けるかは GameElementDefinition.md 9.7節が持つ。

経過の間ずっと効くものは、ここには入らない。 操作が持つ passives(同 11.7節)は InteractionDef が効果と別に持つ——効くのが経過の各tickで、効果が効くのは経過し終えてからなので、 1つにまとめると起きる時点を言えなくなる(announce と同じ理由)。

設計上の要点:

6. 時間の経過(duration)

6.1 経過中に関与オブジェクトが失われた場合

時間を効果より先に進めるため、経過中の tick が self/instrument/agent を破棄しうる(使っていた道具が 行動の途中で壊れる)。破棄は「親スロットから切り離す」ことなので(GameElementDefinition.md 9.3節) そのまま効果を適用しても例外にはならないが、same_slot の spawn が置き場所を失うなどして黙って 何も起きない結果になる。追跡できない失敗になるため、関与オブジェクトが1つでも世界から失われて いたら、その行動は成立しなかったものとして false を返し、効果を適用しない (spendDurationAndReportParticipantsAlive)。

6.2 行動の長さは tick の格子に乗る【確定】

世界のどの行動も、所要時間は 0分・5分・15分・15の倍数のいずれかでなければならない。どの段の キャラクタが行っても外れない——腕が縮める分も、荷が伸ばす分も、刻みの倍数で宣言する。

tick が回るのは絶対時刻が15分の倍数になる瞬間なので、格子から外れた長さの行動は、いつ押したかで 跨ぐ tick の数が変わる。tick は動物の1手(HuntingSystem.md 2 節)であり、腐敗・ 火の衰え・空腹が動く瞬間でもあるので、これは端数の話ではなく同じ行動が獣に手番を与えたり与えなかったり するという性質の違いになる。腕が上がるほど世界の進行を飛ばせる回が出る、という向きの誤りも同じ根から出る。

出どころ: #2185(行動の所要時間を15分の格子へ戻し、腕の効き方を行動ごとの宣言にする)と #2245(5分は手番を争わない受け渡しだけに残し、場所を移る手はそこから外す。2026-09-18に回答)

6.3 1回の行動は1時間を超えない【確定】

1回の操作でも、1つのレシピの工程でも、枠へ入れる1回でも、1時間を超えて時間を進めてはならない。 超える仕事は、途中でプレイヤーが割り込める切れ目へ分ける。

押している間、プレイヤーには何もできない。半日ぶんの時間が1回のクリックで過ぎる行動は、盤面をその ぶん取り上げる(DesignPrinciples.md「プレイヤーから考える時間を 奪わない」「時間経過に任せる区間を作らない」)。tick はそのまま獣の手番でもある (HuntingSystem.md 2 節)ので、長い行動は獣に手を渡したまま戻ってこない 時間でもある。

分け方には形がある。

出どころ: #2182(1時間を超える行動を、途中でプレイヤーが割り込める切れ目へ分ける)

6.4 分けた手の書き方

見張るのは tests/world-codex/actionLength.test.ts。今長い箇所の数え上げではなく、世界じゅうの 操作・レシピの工程・枠と、線の外に居てよい相手のすべてを定義から引いて見る。

6.5 経過そのものが結果になる操作は、線の外

休む・眠る(characters/player_character.yaml・bedding.yaml)と、渡る・歩く(voyage.yaml の cross・locations.yaml の travel)は、1時間を超えてよい。

7. 起きたことの観測

効果を適用した結果として世界に何が起きたかを、外から観測できます(WorldSession.observeChanges)。 UI が演出のために「誰が何をしたか」を要る(HuntingSystem.md 6 節)ためのもので、 observeTicks と同じく観測の解除も観測口が受け持ちます(呼び出し側に外し忘れの余地を残さない)。

7.1 観測するのは物の出入りだけ

変化 観測の仕方 なぜ
物の出入り(生まれた・移った・世界から出た) 1件ずつのログ(WorldChange) 前後の比較では、移動元と「誰の仕業か」が失われる
値の増減 前後の比較(statusChangesBetween) 実効値は誰も書かないまま動くので、書き込みを記録しても現れない
形の変わらない出来事(空振り) 起こした側が告げる(7.4 節) 世界を読み直しても現れない

値をログに寄せられないのは実効値の定義そのものが理由です。 画面に出るのは modify・base を 加味した値(GameElementDefinition.md 8.3 節)で、包帯を当てると痛みは 下がりますが、痛みの実体値は 0 のままで、押し上げていた寄与が減るだけです。

逆に出入りの側は前後の比較では足りません。同じ tick に 2 匹が暴れれば「どちらが壊したか」は差分から 決められず、持ち去られた物は画面から消えるだけで壊された物と同じ形に見えます。

7.2 記録するのは配置の関門

出入りを記録するのは配置を行う関門そのもの(attachToSlotOrRejection・destroy)です。呼び出し側が「動かしたら 記録する」手順を覚える形は採りません——覚える側が増えるほど、記録し忘れた経路ができます。

移動前の居場所は切り離す前に控えます。切り離した後では、どこから来たのかを誰も知りません。

7.3 主体は applyActiveEffect が決める

1 件の変化には、それを起こした効果を宣言していたオブジェクト(self)が主体として付きます。境界は 効果適用の入口(5 節・applyActiveEffect)で、その中で起きた出入りはすべて同じ主体になります。

7.4 形の変わらない出来事は、起こした側が告げる

世界の形が変わらない出来事は、告げると宣言された分だけが観測できます (WorldSession.observeSignals)。空振り・回避は「何も起きなかった」のではなく「外したことが起きた」ので、 世界を読み直しても現れない以上、起こした側が告げる以外に伝える道がありません。

どの告げ方でも、観測口は1つです。 告げると宣言された出来事は、告げ方(書き方は GameElementDefinition.md 9.8 節)によらず同じ口へ同じ形(WorldSignal)で 流れます——受け取る側にとっては、どれも「起きたこと」1 件でしかありません。

告げる語彙を持っても、7.1 節の分担は変わりません。 観測できる出来事はワールドの著者が書いた分 だけで、エンジンが pick の分岐名を横流しするわけではありません——候補を足しても、その候補が 何も告げなければ観測する側には何も増えません。

出入り(WorldChange)とは別の観測口にします。片方は世界の形が変わったこと、もう片方は変わらないままの 出来事で、同じログに混ぜると受け取る側が毎回どちらかを選り分けることになります。

誰の身に起きたかは、主体(7.3 節)ではなく効果が指した対象です(WorldSignal.object)。出入りは 「動いた物」と「動かした者」を要しますが、告げられた出来事はどこから見ても 1 つの札の上のこと でしかありません——殴って外した出来事は、殴った側ではなく殴られた側に起きています。

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

今は残っていない。