設計の経緯・教訓集
過去の失敗・不採用の決定のうち、繰り返さないために残す価値があるものだけをここに集約する
(方針は CLAUDE.md の「ドキュメント・コメントのスタイル」参照。経緯を書いてよい
文書の別は DocumentStyle.md 9.1節)。
issue を立てる者は、立てる手前でここを通す——打ち手がここで不採用済みなら、その案は書かない
(agent-ops/parallel-work.md「立てる前に、不採用の記録と
突き合わせる」)。読まれる場面を名指ししていない記録は、書いた者以外には無いのと同じ。
ドキュメント構成
- 「企画・設計・仕様」という工程別のフォルダ分類は機能しなかった。検討の成熟度を判断するたびに
ファイル移動が必要になり、境界も曖昧化して、ほぼ全ドキュメントが「確定仕様書ではない」と
明記する状態に陥った。現在の領域別分類(
README.md)を工程別へ戻さないこと。 - 「節」を省いた節番号を、文書名の直後より先まで拾わせないこと(
DocumentStyle.md5節)。直前の参照に区切りだけで続いた番号まで広げたら、RecipeSystem.mdのSlotSystem.md 2 節、1 つの物の1 つを参照と読んで赤くした(issue #2229)。文書名を必ず前に置かせる形・別の印で書かせる形も、 その印を落とした番号が数量と見分けられない点は同じで、省いた形を拾う手にはならない。
画面レイアウト(ScreenLayout.md)
- 「画面から正方形領域(1080×1080)を切り出せば縦横共通のUIになる」という方式は破棄。 等分割はカードやボタンの比率と無関係に領域サイズを決めるため見た目が悪く、 オプションボタン等の操作部が過大になった。領域サイズはカード寸法(58:89)からの積み上げで決め、 縦横のカード実寸一致は短辺基準の単位(u = 短辺 ÷ 1080)で得ること。
文法の拡張(GameElementDefinition.md 2.1節)
- 次のものは、それで書きたい宣言が世界に現れるまで入れないと決めた(#2286、
2026-09-19)。思い付いた者が同じ案を issue にしないために残す。 入れるのは、書きたい宣言が
現れたとき——そのとき代えが効かないことを、その宣言で示せる。
- ステージの境目をまたいだ瞬間だけ効く仕掛け(
on_enter/on_exitに当たるもの)。「下限に達した 瞬間」はon_min(同 6.3節)が既に持つ。 passives/activeの対象キーにsibling/descendantを足すこと(兄弟・子孫をまとめて指す)。 祖先の側はancestor(同 8.6節)が持つ。pickの当たりから別のアクションを起こす記法。エフェクトからアクションを能動的に発火する 仕組みそのものがまだない。subjectのネストした参照(「親の親」を名指しする)。祖先を遡って該当プロパティを持つ最初の オブジェクトを探す用途はancestorが持つ。
- ステージの境目をまたいだ瞬間だけ効く仕掛け(
rangeイベント(GameElementDefinition.md 6.3節)
- 境界を
rangeの外に置く形(min: 1にして 0 到達で発火)へ戻さないこと。 発火条件としては 境界を1つずらすだけの等価な書き換えに見えるが、transfer(同 9.5節)がrange.minを「出せる量の床」と 見るため、注ぎ切る・飲み干す操作は決して下限を割らない。「0 まで汲み出せて、かつ空になった瞬間に 反応する」プロパティが原理的に書けなくなる。 - 併せて、
addはrangeを無視して行き過ぎられる(1 tick で幅を複数回分飛び越えた分を繰り上げ先へ 持ち越せる)ことも保つこと。周期を持つ量は折り返す点そのものをmaxに書く(hourは 0〜24)。 0〜23 と書いて「24 になったら」を表そうとすると、この持ち越しが表せない。 - 境界ちょうどで発火させても、既定のクランプ(境界への
set)は自己終端する——着地先が境界そのもので 差分が 0 になり、PropertyValue.addの差分0ガードが連鎖を止める。「HP 満タンで毎 tick 発火し続ける」 ことを理由に境界を外へ出す必要は無い。
プレイヤーキャラクタ(Characters.md)
- キャラクタ間で定義が食い違わないことを、
propsを配る trait では保証しないこと。trait は 「何を持つべきか」ではなく「省略したらこの値」しか表現できず、propsのマージはフィールド単位の 上書きなので、range.maxだけ上書きしてstagesを trait のまま残す、が素通りする。stagesの しきい値はmaxから導いた値なので、その瞬間に「安全域を外れるのは max の80%」が壊れるうえ、 キャラクタのファイルにはstagesが現れないため読んでも気づけない。契約はテストで検査する。
スロットの制約(SlotSystem.md 2〜4節)
- 1つの問いに2通りの答え方を用意しないこと。 かつて
acceptsの各ルールはmax(そのルールに マッチする在中個数の上限)を持っていた。単一ルールで非スタックのスロットでは、これが枠数の上限 (当時のunit_capacity)と結果的に同じ意味になり、怪我のtreatmentはどちらでも書けてしまった。 読み手に「どちらが正か」を考えさせるだけで何も足さない。 - 原因は、スロットを「型フィルタ付きの配列」として設計し、枠(セル)という概念を持たせなかったこと。
枠を一級にすれば、型は枠の
acceptへ、数は枠のmaxと枠の数へ自然に分かれる。同じ理由で、 レシピの「板1枚と棒4本」は枠の形そのもので表し、acceptsに数を戻さないこと。 - 枠数の上限と「前詰めしないこと」を別のキーにしないこと(旧
unit_capacityとfixed_positions)。 枠数を決めた時点で位置は安定させられ、決めていない並びには指し示すべき空き枠が無いので詰めるしかない。 同じ概念の裏表であり、実際に前詰め+枠数上限のスロットは一度も現れなかった。 stackableをスロット側に持たせないこと。 束ねたくない理由は個体の側にある(道は行き先が、かごは 中身が個体ごとに違う)。スロット側で近似すると対象を取り違え、設置物スロットを非スタックにすると木まで 1本ずつ並び、手持ちは逆に非スタックにできないのでかごを2つ持つと1枚に束ねられ、片方の中身へ 到達できなくなっていた(ObjectStack.canMergeは型しか見ないため、中身が違うかご同士も同一と 判定される)。
数値のスケール(GameElementDefinition.md 6.0節)
- プロパティの値が整数に限られていた理由は「小数が要らないから」ではなく、エンジンが
stagesのeqを 完全一致で判定するから。 小数を許した今も、その比較に依存した書き方は整数で書くプロパティにしか 使えない。丸め誤差の蓄積を恐れる必要は無い(IEEE754 の相対誤差は 2^-52 で、段のしきい値に対して 無意味な大きさ)。 - 物理量の単位(
volume= mL /weight= g /density= g/mL)は片方だけを選び直せない。weight = volume × densityで連動しているため、量の単位を変えると重さの単位も決まる。 換算定数を式に足して独立に選べるようにはしない——mL × g/mL = g が定義から落ちてくることが、 この 3 つを噛み合わせている理由そのもの。
レーンの並び(Windows.md 1 節)
- 1つの並びを、意味の違う2つの用途へ使い回さないこと。 「差し替える中身と位置で対応付ける並び」を、
ドラッグのドロップ先を探す順としても渡していた。ドロップ先は「最初に当たったレーン」で決まる
(
CardDragController.dropCandidateAt)ので、覆われている側が先に当たり、子ウィンドウの中のカードへは 何も落とせなかった(装備も怪我も)。 - 直そうとして並べ替えたら、今度は差し替えの対応付けが崩れてレーンの中身が総入れ替えになった
(怪我のウィンドウにヤシの木が出た)。用途ごとに別の getter で持つこと(
draggableLanes)。 位置で対応付ける並びは、組そのものを持つ型にすること(LaneView)——2つの配列を添字で 合わせている限り、片方だけを並べ替える誘惑と、そのときの崩れ方は残る。 - 「そこに何が並んでいるか」を、表示と操作で別々に作らないこと。 子ウィンドウへ貸した1枚を引くのは
表示の経路(
laneCards)だけで、ドラッグ・タップの経路(cardsOf)はワールドの並びを素通しして いた。手持ちの石2個のうち1個をウィンドウへ貸した状態で残りを重ねると、画面に出ていない 「貸したほうの石」を掴んだことになり(CardCombination.held)、宙に在る札の数と枠の引き算が 食い違って、経過中の手持ちに石が復活した。並びはShownCardsが1箇所で答え、掴めるかどうかも そこに出ているかで決まる。 - 経過し切った後の画面は正しかったので、操作の結果だけを見るテストでは捕まらない。時間のかかる
操作は、経過中の各 tick の見え方(
showRecorded)まで含めて確かめること。 - 「今どこに在るか」を、引き算する集合として外から渡さないこと。 子ウィンドウが借りた札は、
画面の並びからは消したうえで「借りている集合」を差し替えの計画へ別途渡していた(
borrowed)。 引き算の主体が2つになるので、渡す集合が控えだと1枚多く引かれ、返す便・打ち切り・帳簿 (貸す/返す/帰り着いた)を手で揃え続けることになる。出ている場所を1つ決めれば引き算は要らない ——枠は「今そこに在る個体」だけを名乗り、よそに出ているぶんはawaited(帰りを待つ印)として 持つ。借用の配管はこれで丸ごと消えた。 - 「今そこに落とせる物が無い」を理由に、レーンごとの例外を画面へ刻まないこと。 設置物レーンだけ
末尾の受け皿(
CardView.md12 節)を出さない専用の枠の作り方を持っていた。 据えられる物が 1 つも無かった頃の判断なので、itemとfixtureを兼ねる編み籠が入った時点から 落とせるのに受け皿の出ないレーンになっていた。世界のデータで変わることは、画面ではなく スロットの宣言(枠が増えるか・受け入れるか)に決めさせること。 - 印は個体を1つも出していない札(
identity: [])であって、識別子を持たない札(identity省略=そもそも個体を映さない札)ではない。両者を同じに扱うと、印が薄くならない・掴めてしまう。 操作を持たせたままにするのも同じ危険で、個体が0の札は重ねる相手にもならない (combinationOfは相手の代表を読む)。
子ウィンドウのボタン(Windows.md 4 節)
- 同じ「ボタン」を2系統で作らないこと。 宣言されたアクション(
actions.yaml)はビューがCardActionに組むのに、製作の操作(作業する・中断・自動補充)だけは画面(PlayScene)が レシピ・スロット名・進捗プロパティを直に読んで組んでいた。同じレシピの知識がビューにも画面にも 住み、画面側だけがテストの外にあった。押せるか・理由は何かは、画面を作らずに確かめられる側で 答えること。 - 「押した後にウィンドウを閉じるか」を操作ごとに決めないこと。 アクションは押した時点で閉じ、 作業は経過し切ってから閉じ、自動補充は閉じない、という3通りがあった。操作が増えるたびに決め直す ことになり、どれが正しいかを言える基準が無い。規則は1つ——ボタンでは閉じない。映している ものが世界から消えたら閉じる。
中身を見せるカード(Windows.md 3 節)
- 「押したとき中身も出すか」を UI 側で決めないこと。 物の側が別の目的で名乗ったものから推し量る
形で2度外した。
containerタグの有無で決めると、怪我のように「入れ物ではないが、プレイヤーが 1 つ入れる場所」を持つ物を扱えない。スロット名の一覧で決めると、液体の容器の中身(content)が 入れ物の中身(contents)と 1 文字違いだったおかげで開かれずに済んでいただけで、名前が揃えば 水を単独で取り出せる。開くかどうかはワールド側がvisible_slotsで名乗ること。
Phaserのテクスチャ(ScreenLayout.md 4.2 節)
- 別の概念の物へ、1枚のテクスチャを実行時に使い回さないこと。 ボタンの地にカードの紙が欲しかった
ので、カードの枠のテクスチャへ切り出しをフレームとして足して共有した。Phaserは最初に足された
名前付きフレームを
firstFrameにし、フレーム名を省いた参照をそちらへ向ける(Texture.get())。 結果、ボタンを作ったあとに生まれたカードの枠が、紙の切れ端(300x170)をカードの寸法へ引き伸ばした ものに化け、全カードから枠が消えた。角丸も枠線も紙の染みも無い、のっぺりした白い札になる。 - 気付きにくかった理由は3つ。(1) 絵の差し替えではないのでリポジトリの差分に出ない。(2) ポートレイトの ようにボタンより先に作られるカードは正常なので、画面の中で症状がまだらになる。(3) ベタ塗りの板に 差し替えて確かめると「テクスチャは使われている」ことしか分からない。格子模様と四隅の目印を入れた 板で確かめれば、倍率と切り出し位置の狂いが一目で分かる(この方法で1回で特定できた)。
- 直し方は「共有をやめる」。同じ生成物から切り出した別のPNGを持たせる
(
recipes/slot_button_paper.json→src/assets/ui/slot_button_paper.png)。同じ絵を参照するのは 生成の話で、実行時のテクスチャは別にする。
実行時エラー(src/game/errorReport.ts)
- Phaserは毎フレームの呼び出しで投げられた例外を捕まえない。 tweenの
onUpdateで壊れると 描画ループごと止まり、画面には「操作を受け付けないまま固まった」という症状だけが残る。実際に クラフト中のフリーズとして表面化し、原因(表示用のカードが持つ操作が、ワールドの生きた並びを 見ていた)に辿り着くまで手順の再現から始めることになった。 - エラーは握りつぶさず、そのまま貼れば原因が追える形で画面に出すこと。文言とスタックトレース だけでは足りない——同じ画面でも、そこへ至る操作が違えば別の壊れ方をする。直前の操作・その時点の ワールド時刻・演出中かどうかまで載せて初めて、報告1つで再現できる。
Phaserのシーンの使い回し(src/game/PlayScene.ts)
- シーンのインスタンスは使い回されるが、購読はシーンを出るときに全部外れる(
InputPlugin.shutdownとSystems.shutdownがremoveAllListenersする)。画面の作り直し(ResponsiveScene.rebuild)で 増やしたくない受け口を??=で1度だけ作ると、その受け口はシーンを出た時点で死ぬのにフィールドには 残るため、入り直しても作り直されない。シナリオを2回ロードするとカードがドラッグできなくなっていた (CardDragControllerが購読を失った抜け殻のまま居座り、カード側はsetDraggable済みなので 掴んでも何も起きない)。作り直しをまたいで持つものはinitで構え直すこと(PlayScene.resetForNewVisit)。 - 表示の持ち越しも同じ。開いていた子ウィンドウは前のプレイの世界のカードを映しており、演出中のフラグは それを下ろすtweenがシーンと一緒に消えているため、立ったままだと入り直した画面が操作を受け付けない。
PhaserのWebGL専用機能
- WebGL専用の機能は、WebGLの無い環境で例外も警告も出さずに壊れる。 ゲームは
Phaser.AUTOで 起動するので、WebGLが使えない環境(GPUのブロックリスト・ハードウェアアクセラレーション無効)では Canvasレンダラへ落ちる。そこではGraphicsのグラデーション塗り(fillGradientStyle)が塗りの色を 決められずタイトル画面の 背景が真っ黒になり、フィルタとして掛けたマスクが効かず空の絵が状況エリアから溢れて情報エリアへ かぶった。どちらもコード側は正常に動いており、コンソールにも何も出ない。 - 「WebGLでは見えるのに実機で崩れる」という報告が2箇所以上同時に来たら、まずレンダラを疑うこと。
ブラウザのWebGLを切って(Chromeなら
--disable-3d-apis)再現するのが最短で、片方ずつ原因を 探すより早い。 - 代わりの手段があるものは、そちらで組む。グラデーションは行ごとに色を混ぜて敷く(
mixColor)、 1枚の絵の切り抜きはsetCropで足りる。どちらもレンダラを問わないうえ、フィルタより安い。 染めと角丸は絵に焼く(button_paper.py)——実行時のsetTintはCanvasで効かない。 NineSliceはCanvasレンダラを持たない(renderCanvasがNOOP)。9patchは自前で組む (nineSlice.ts)。他にもCanvasの描画を持たないGameObjectがある(Mesh2D・PointLight・CaptureFrameなど)ので、使う前に*Render.jsを見ること。BlendModes.MULTIPLYはCanvasレンダラで通常の重ねに落ちる。 対応表(GetBlendModes)がDevice.canvasFeatures.supportNewBlendModesを見ており、そこが偽ならsource-overになる。落ちても 壊れないことを、絵の描き方で担保する——乗算の層は色と濃さ(アルファ)だけで描き、「変わらない」は 透明で表す。白を「変わらない」の意味で使うと、落ちた環境で白い塊がそのまま出る(実測。透明・白・灰の 3等分のプローブで、Canvasでは白い帯がカードを覆った)。この描き方なら、落ちても痣が少し平たく 見えるだけで済む。- コンテナの切り抜きだけは、レンダラごとに手段を使い分けるしかない(
clip.ts)。マスクは WebGLではフィルタ(filters.internal.addMask)、CanvasではsetMaskで、互いに排他 (setMaskはWebGLで警告を出して何もしない)。Phaser 4はCanvasレンダラを非推奨としており、 機能の対等性を保証しない。
Phaserのフィルタ(ScreenLayout.md 7.5 節)
- 切り抜きのためにフィルタを使わないこと。 フィルタを掛けた表示物は、一度画面サイズの描画バッファへ 描いてから合成される。4Kでは1枚31MBで、雨のはみ出しを隠すマスク1つのために221MBを常時占めていた (実測。WebGLのテクスチャ確保を積み上げて、機能追加前の23MBと比較した)。しかもマスクは雨天だけでなく 翳り・輝きが出ている間ずっと有効なので、ほぼ常時掛かっていた。
- 敷き詰める絵(TileSprite)は自分の矩形の外へ描かないので、繰り返しで足りる絵なら切り抜き自体が要らない。 雨はこちらへ移して、追加前と同じ23MBへ戻した。
- 歪ませる(変位フィルタ)ようにフィルタでしか実現できない効果は、出る条件を狭く取ること。ただし 同じ大きさの対象なら、何枚に掛けても消費は増えない——描画バッファは大きさをキーに使い回される。 陽炎を1レーン(対象2枚)から3レーン(対象9枚)へ広げても、実測値は+2.2MBのまま動かなかった (同一シナリオでLaneHazeへ渡す面だけを差し替えて比較。掛けない状態17.8MBに対し、どちらも20.0MB)。 枚数ではなく、掛ける対象の大きさを抑えることに意味がある。
- 変位フィルタの量は、画素数ではなく掛ける対象の一辺の半分に対する比。 陽炎の初出時、Phaserの既定 (0.005)から素直に決めた0.012では、レーンの高さが200px強しかないため揺れが1pxにも届かず、 画面上では何も起きていないように見えた。薄い帯へ掛けるときは、比ではなくその帯で何px動くかで決めること。
- 変位フィルタは対象の外から画素を引いてくる。外側は透明なので、縁では背景が覗いて絵が食われる。 変位マップ側で、各成分をそれが動かす向きの縁で0へ落としておけば、引いてくる先が絵の中に収まる。
- 変位マップは対象ごとに引き伸ばされる。 2つの表示物に同じマップで同じ効果を掛けても、大きさが
違えば波の細かさも位相も揃わない。揃えたいときは
focusFiltersOverrideで、どちらのフィルタにも 同じ矩形を写させる。 - 大きさを持たない表示物(Container)にフィルタを掛けると、既定では画面全体が対象になる
(
enableFiltersがfiltersFocusContextを立てる)。写す範囲を自分で据えるならsetFiltersFocusContext(false)も併せて降ろすこと。降ろし忘れると、歪みではなく合成位置が 画面の左上へずれる形で壊れる。 focusFiltersOverrideで据えた範囲は据え置かれるが、合成先は対象の毎フレームの位置から決まる。 動く対象(スクロールするカードのコンテナ)では毎フレーム据え直さないと、送った量だけ合成先まで ずれて二重に動く。
地形生成(TerrainGeneration.md)
-
toleranceはスケールであって閾値ではない(同 3.2節)。草案にあった「tolerance を超えたら最も 合っていない軸を無視して段階的に緩和する」フォールバックは、「超えた」という状態の判定自体が 無意味なため実装しなかった。閾値ベースの緩和処理を再導入しないこと。 -
草案の「辺が交差する場合は代わりに新しい地区を生成する」処理は落とした。Delaunay 辺の 部分集合しか採用しない設計では交差が数学的に起こらず、解くべき問題が存在しない(同 3.5.2節)。
-
軸の分布不足を「島の作り直し」で解決する確率的再生成は、決定性(同一シード→同一の島)と 停止性を壊すため採用しない。事前の強制割当(
guarantees、同 3.4節)で保証する。 -
内陸サイトの半径分布を宣言で動かせるようにしないこと。
generation_scopes.islandに 生えていたinterior_biasは候補の半径分布の指数を動かす弁だったが、名乗った働き——サイトを 中心へ寄せること——が出なかった。以下はどれも 2026-09 時点の 500 シードでの実測。- 間隔の側が、候補の偏りを押し返す。 候補の平均半径を 50.1(一様)から 37.6(指数 1.0)まで 中心へ寄せても、採られたサイトの平均半径は 50.1 から 46.8 にしか動かない。採るのが「既存の どのサイトからも最も離れた候補」なので、寄せたぶんはそこで戻される。向きは名前のとおり (寄せるほど中心寄り)だが、幅が名乗りに見合わない。
- 0から1まで振って動かないのは、道の網目と海岸帯。 平均次数 2.49→2.47、行き止まり 14.6%→16.3%、道1本の移動時間 44.2分→41.8分、海岸帯の土地の割合はどちらも 35.6%。海岸の数に 効かないのは、内陸の枠(半径75%以内)と外周リング(85〜95%)の間に隙間があって内陸のサイトが 海岸帯へ届かないためで、決めているのはリングの個数と枠の分離のほう。
- 一方、どの型の土地が島に出るかは動く。 0.6 から一様へ付け替えると山腹の出現率が
80.0%→88.2%。この向きを半径の絶対値から読まないこと——
elevationはstretch_sites_to_rangeで島ごとに両端へ引き伸ばされるので、効くのは半径の散らばり方であって 遠い近いではない。
名乗った働きが出ず、出る働きは名前から読めないので、弁ごと落とした。中心寄りにしたいなら、 候補の分布ではなく採る基準(同 3.5.1節)を変えること。
スキル・レシピ解放(SkillSystem.md)
- 研究を、スキルとは別の概念として持たせないこと(課題を明示的に選び、完了まで待つ枠)。解放のゲートを 素材チェーンに沿って置くことになり、中間素材が1段増えるたびに「発見→研究→クラフト」の待ちが直列に挟まって 進行が間延びする。スキルは工程ではなく領域なので素材チェーンと直交し、この直列化が構造的に起きない。
- 研究ノード間に前提の辺を書く明示的なテックツリーを採用しないこと。 辺を持つと、ノードを1つ足すたびに 循環・孤立・到達性という全体の整合を考える必要が生じる。解放条件を世界の事実(何を見たか、スキルが いくつか)に対してだけ書けば、ノードの著述は局所的なまま、順序は結果として創発する。 既知のトレードオフ: 辺が無いぶん到達不能なレシピを事故で作りうる。空の状態から「今作れるもの」の閉包を 取り、全レシピが到達可能かを検査するテストで代替すること(目視では見つからない)。
- スキル値を対数化してUIへ出さないこと(SkillSystem.md 5節)。序盤が常にほぼ0%に 見える問題は解決するが、対数は指数的な鈍化をちょうど打ち消すため、「低い段の作業を繰り返しても伸びが悪い」 という、鈍化によって伝えたい情報を同時に消してしまう。段(レベル)と段内進捗に分ければ両方満たせる。
- 腕を伸ばすための専用のアクションを設けないこと(SkillSystem.md 3.1節)。 伸ばす腕を名指しする必要があるので腕前ごとに1つ並び、キャラクタの窓の操作の行には横でも縦でも 入らない(縦は3行で埋まり、横は等分されてタップ領域を割る。2026-09 に実測)。代償を何で払わせるかを 決めても、置き場所の問題は残る。 練習は、その腕の最も初歩的な行動——狩猟なら小型の獣を狩ること、 対象が見つからないなら探すこと——として世界の側に置き、専用の入口を作らない。
入れ物への投入(RecipeSystem.md 4節)
- 枠の
acceptから投入のドラッグ型の操作を自動生成する方式は採らないこと。move: {to: self}の 配置は自動配置スロットしか走査しないため、placementにautoを持たない材料スロットへは黙って何も 入らない。 投入は「受け取れるスロットを持つカードへ重ねたら、そのスロットへ入る」という画面側の1つの動きに 統一してある。かごの手書きのput_inも、これに置き換えて消した——2経路あると、枠が受け入れない相手にもwithだけで噛み合って「吹き出しは出るのに何も起きない」状態が作れてしまう。
まとめて入れる(CardInteraction.md 5 節)
- まとめて運ぶ操作は、ついてきた枚数がそのまま「これだけ通る」という約束になることで成立している。
枠への投入はその約束を副作用なしに引ける——型・枠の空き・かさはすべて枠が持っていて、「あと何個
受け入れられるか」は世界を変えずに答えが出る(
Slot.acceptedCount)。 - ドラッグ型の操作にも同じ約束を引けるが、引けるのは形を限ったときだけ。 かつては語彙そのものを
足さないと決めていた。条件は世界のどこでも参照でき、1回実行するたびに世界が変わるので、3回目が
成立するかは1・2回目が何をしたかに依存し、「全部試して通ったぶんだけ通る」以上の意味を持てないためで、
「ドロップ先は変化せず、ドロップした側だけが変わる型なら安全」という前提もエンジンには検証しようが
ない、と考えていた。
検証できる形が1つあると分かったので、そこだけを許した(
allow_multiple、 GameElementDefinition.md 12.4節)——回数の上限を決める器を ちょうど1つ持ち、pickを含まない効果。値域を持つプロパティへのtransferがそれで、残りは 世界を変えずに数えられる(炉へ薪をくべる操作)。器が0個・複数・pick混じりの宣言はロード時に弾く。 キーワードを足すなら、守れているかをエンジンが確かめられる形にすること——確かめられない約束を 書かせると、書き手は守っているつもりで書く。 - 数そのものは宣言させないこと。 器の大きさも1つぶんの目減りも、既にYAMLに書かれている
(
capacity・volume・range)。書かせれば同じ規約を二度言うことになり、食い違う余地だけが増える。 宣言させるのは「まとめてよいか」の許可だけで、これは導出できない——構造として受け取れることと、 まとめて実行させてよいことは別(durationの大きい操作をまとめると、止める手段の無いまま時間が進む)。 - 許可の検査は非対称のままにすること。 「器を持たないのに許可した」は弾くが、「器を持つのに 許可していない」は咎めない。それが許可であることの意味。
- 枠へ入れる操作には許可を置いていない。 効果も値段も枠の宣言で決まっていて、書いた人が選べる
余地が無いため。ただし時間のかかる枠が複数受け取れるようになると同じ事故が起きるので、
重ならないことをテストで見張っている(
tests/world-codex/putInDuration.test.ts)。 - まとめられるのは、落とされた側が宣言している向きだけ。 逆向きに成立した組み合わせでは、運んできた
札の1枚ずつが別々の
selfになるので、1つの器で数を決められない。
層をまたぐ転送(PlayScene が ShownCards へ渡す読み口)
- 転送するだけのラムダで、引数を並べ直さないこと(
(a, b) => this.view.f(a, b)ではなく(...asked) => this.view.f(...asked))。TypeScriptは引数の少ない関数を多い型へ代入できるので、 受け口に引数が1つ増えても転送側は黙って落とす。まとめて重ねた枚数が実行へ届かず、2枚ついてきた のに1枚しか消えない、という形で出た。型検査もテストも通る——ShownCards側のテストは読み口を差し替えて いるので、この転送を通らない。
combinationsを書く側(GameElementDefinition.md 12.3節)
- 同じ組み合わせを両側のカードへ書かせないこと。 宣言が2つになると、内容が同じである保証がどこにも
無い——揃えたつもりのずれは「どちらから重ねたか」でしか現れないので、遊んでいて気付けない。相手を
タグでまとめて指せる
withの利点も片方向にしか効かず、逆向きは相手のカード1枚1枚へ同じ宣言を 書き足す作業になる。落とされた側 → 掴んだ側の順に探せば、宣言は片方に1つで足りる。 - 「どちらに書いてもよい」にはしないこと。 書く先が決まっていないと、探すときにどちらのカードを 見ればよいか分からなくなる。素材側(変化の本体)と決めているのは、探す順の都合ではなく書く先が 一意に決まる基準だからで、受け側は向きによって入れ替わるので基準にできない。
- 逆向きを許すかを宣言させないこと(
allow_reverseのようなフラグ)。 向きは起きることを変えない ——selfもinstrumentも宣言が決めるので、どちらへ運んでも同じ効果が同じ相手に当たる。フラグが決められる のは「その指の動きを受け付けるか」だけで、受け付けなければ落とせるのに何も起きないになる。 正しい値を書き手が選べる保証も無い——火起こし具と火口はどちらへ運びたいか人によって違い、片方だけ 認めた宣言はプレイヤーには読めない。不自然に見える向き(炉を薪へ運ぶ)は残るが、起きることは正しい。 - ドラッグ型を「装備した道具を指すメニュー型」へ畳む案は、測って退けた(畳まない理由は
ActionSystem.md1.2節)。2026-09-13 時点のコーデックスにあるドラッグ宣言48件の うち、29件がinstrumentを指していた。写して測ったものはtests/world-codex/dragAsMenu.test.tsに 残してある——広葉樹を伐る宣言は、書けない要件と効果を落とすと、刃の尽きた斧でも刻み始められ、倒しても 斧が減らなくなった。 - この案が前提にしていた「装備している道具」も、測った時点のワールドには無かった。
equipment枠が 受けるのは身につける物(equippable)だけで、枠数も決まっていないので、道具が1つに決まらない。 参照の文法を足しても、名指す先が無ければ個体は指せない——文法の側から入る順序にしないこと。
spawnの初期値(GameElementDefinition.md 9.4節)
same_slotの置き換えで、元のオブジェクトのプロパティ値を新しい方へ複写しないこと。 加工の連鎖は 素材も完成品も同じweight・volumeを宣言しているため、複写するとすべての完成品が素材の値になる。 さらにcountで複数生まれると、1つぶんの値が各自に入って総量が増える(4000gのヤシの葉を割って編むと 400g×2枚になるはずのところ、4000g×2枚=元の2倍になっていた)。名前が一致するプロパティが 「同じ物を指しているから引き継ぐべき」か「両方が偶然宣言しているだけ」かをエンジンは区別できないので、 一律の複写は必ず後者を巻き込む。完成品の値はその型の宣言だけで決まる、が唯一検証できる規約。
率の積分の刻み(GameElementDefinition.md 8.4.2節)
- 「積分だけ分単位にし、tick は拍として残す」形へ作り替えないこと。 tick を跨がない操作で値が
動かないことを粒度の不足と読むと出てくる形だが、得られるのはその操作の中の見え方だけで、総量は
もともとずれていない。 分へ割れない理由は同 8.4.2 節が持つ。粒度が残り時間の数字として見えて
しまう側は、tick の格子の上で答えを出すことで解いてある(
World.minutesUntilTick)。
tick毎のaddの平衡点(GameElementDefinition.md 8.4節)
-
addは登録順に値を書き換えながら適用されるため、同じ tick に正負が混ざると、値は 「冷めの速さが育ちを初めて上回る段」の1つ下の段の上端に張り付く。 段のゲートは1つ1つの適用の たびに評価し直されるので、育ちで次の段へ踏み込んだ瞬間にその段の冷めを同じ tick で払い、押し戻される。 火力を「くべた薪が育て、火力自身が冷ます」で書いたとき、some(+6)とcoals(-2)で炎(20〜)へ 届くはずが、17 で止まった(+6 で 23 へ入り、flameの -6 を即座に払って 17 へ戻る)。 張り付く位置は登録順で1段ぶん揺れる。 -
これは平衡点を作る仕組みとして使える。張り付く位置は「レートが変わる境目」なので、段を細分すれば そこを選べる。 見せる段とレートの段を分け、同じ表示名の段を2つ並べて下側の冷めだけを育ちより 小さくしておくと、平衡は下側の段の上端で起き、プレイヤーには「炎の途中で落ち着いている」と見える。 上側の段は「そこまでは上がらない天井」として働く。
# 育ちが+6のとき、平衡は flame_low の上端(39)に来る。表示はどちらも「炎」。 - {name: coals, min: 5, passives: [{add: {self: {heat: -2}}}]} - {name: flame_low, min: 20, passives: [{add: {self: {heat: -4}}}]} - {name: flame_high, min: 40, passives: [{add: {self: {heat: -8}}}]} -
火(FireSystem.md 2.2節)がこれを採らなかったのは、平衡が要らなかったから。 薪がある間は育つだけ・尽きたら衰えるだけに分ければ正負が混ざらず、火の大きさは「炉の上限へ達するのが 先か、薪が尽きるのが先か」で決まる。平衡点が要るかどうかを先に決めること——要らないなら正負を 分けるほうが段の数もレートの表も少なくて済み、要るなら上の刻み方でしか作れない。
土台にした値(GameElementDefinition.md 6.5節)
baseの土台にself以外の起点を書いたつまみを足したら、その起点が解ける側も一緒に確かめること ——役(agent)なら、その操作を組む側が役を渡しているか。片方だけでは何も壊れない。 土台へ 辿り着かなければ寄与が0になるのは仕様どおりで、例外もログも出ず、上乗せが消えて素の値のまま動く。- 症状は「腕を上げても変わらない」の側に出る。 画面を見ても数値を見ても気づけないので、気づく手立ては その操作を実際に通して実効値を1つ取るテストだけ。宣言が揃っているかだけを見るテストは、渡す側を 見ていないので素通りする。
- 静的解析の側と同じ形にはなっていない。 そちらは文脈を作る入口を1つに絞り、行っている人の層をそこで
必ず足す(
src/analysis/craftingSteps.tsのanalysisContextOf)。実行時に役を渡す口は操作を引く 呼び出し元の側にあり、絞られていない。
定義だけから工程を読むとき(src/analysis/、CodeStructure.md 5節)
set(代入)をadd(増減)と同じ列に混ぜないこと。 rangeを持つプロパティには 「自分を端へ戻す」既定のon_max/on_minが自動生成される(GameElementDefinition.md 6.3節)。 これはsetなので、増減として足すと端の値がそのまま増分に化ける。眠る工程(wakefulness +96)が 上限を超えた瞬間に既定の丸めが起き、収支レポートが「1回の睡眠で+288」を返した。代入は 「動いた先」であって「動いた量」ではない——両者を別の列(deltasとassignments)で持つと、 「いくつ増えたか」を問う側は増減だけを見ればよくなり、「rangeの外へ出たか」を問う側は どちらも同じ「動いた先」に均せる。- 確率でしか消えない入力は、その確率ぶんだけ要るものとして数えること。 仕留めの一撃は20回に 1回しか入らず、外した回の獲物はその場に残る。「消費する/しない」の二択で数えると、消費とすれば 獲物の値段を毎回の実行に載せて桁が変わり、道具とすれば獲物の値段がどこにも出ない。 要る個数を0.05匹と書けば、期待産出で割った答えが「獲物1匹+外した回ぶんの労働」に一致する。
- 近似をドメインの定義クラスへ置かないこと。 収支レポートのために
ObjectDefへ工程・周期・ 静的解決を足していったところ、定義クラスの4割が解析コードになった(ObjectDefは736行中266行)。 害は3つで、定義そのものが読めなくなること、解析だけが要る内部が公開されること、そして 「条件つきの増減のうち最小の1つだけが効く」のような仮定が定義がそう言っているように見えること。 TypeScriptにはC#のpartialもC++のfriendも無いので、「解析だけが触ってよい」を表せるのは モジュール境界だけ——src/analysis/へ出し、向きをtests/architecture/layers.test.tsで見張る。 効果の木は読み上げ口(EffectReader)越しに渡し、木そのものは出さない。
定義位置を動かすとき
- 移動先は「その値を導ける側」ではなく「その問いの主語」で決める。 導けることは理由にならない
——
WorldObjectは所属ツリーの根まで辿れるので何でも導ける。関係があることも理由にならない ——「契約を定義しているから」「責務として関係があるから」は、そこに置くべき根拠に採らない。 消去法を移動先の根拠にしないこと。 「Aには置けない」は「Bに置くべき」ではなく、どちらでもないならそこに概念が無い。 - 打ち手が相手の内部を公開しないと成り立たないなら、歪んでいるのは打ち手のほう。 「これを
公開すればこの private ヘルパーは消える」「このフィールドを読めれば向こうへ移せる」は、
カプセル化を開ける向きなので、元の配置のほうが正しい。状態ではなく振る舞いを1つだけ公開して
内部を閉じている形は、歪みではなく「自分のことは自分でする」そのもの(
AddEffect.applyScaled・PropertyPassiveEffect.activeAmountは、移せという指摘が出たが元のままが正しかった)。歪みとして 残るのは、状態を読めるようにしたうえで自分を丸ごと渡しているもののほう。 - モジュールが選択肢を明示的に用意している場所では、どちらを使うかは利用者の関心事。
WorldObject.getSlot(持っていないことを許さない)とtryGetSlot(許す)が分かれているのは、 そこが選択点だという宣言。第3の既定値をモジュール側へ足すと、開けておいた選択点を塞ぐ。 利用者側へ足すべきものと、モジュールの表現力不足を利用者が補っているものの区別は、 利用者がモジュールの内部を再構成しているかで見る。再構成しているならモジュール側へ。 - 同じ形の式を数えて畳まないこと。
tryGetSlot(...)?.contents ?? []は9箇所にあるが、 必ずその枠を持つ型での防御(製作中オブジェクトのmaterials、キャラクタのhand)と、 本当に持たないことがある問い(あらゆる物に対するtreatment、動物のspoils)が同じ文字列を 着ているだけだった。畳むとこの差ごと消える。式に名前を付けたとき、その名前が呼び出し側の都合 (既定値・省略時の扱い)を含むなら、それは概念ではない。 - 実例として
WorldObjectへcontentsInSlot/stacksInSlotを足して取り消した(どちらも今は無い)。 - 検査は、その条件に必要な情報が全部そろう場所へ置く。 一番小さいクラスへ置くと条件の一部しか
見えず、文言がその分だけ弱くなる。「シンボル型プロパティの段に
minは書けない」をPropertyStage(段1つしか見えない)へ置くと「minとeqは同時に持てない」としか言えず、 作者が書いていないeqの話になる。型と段の両方を持つPropertyDefへ置けば、条件も文言も そのまま書ける。置き場所を誤ったことを「移せない検査もある」と一般化しかけた—— 移せなかったのではなく、置く先を間違えていた。 - 定義が自分のフィールド名で語れば、多くの場合それはYAMLのキー名と同じなので、
作者向けの文言は保たれる(
steps・gauge・range・min)。文言を変えないと通らない テストが出たら、まず置き場所を疑う。 - YAMLのキーが書かれなかったことは定義からは見えないので、そこだけは loader に残る
(
consumeの省略は「素材か道具かに既定値を置けない」という書き方の話で、RecipeRequirementDefの成立条件ではない)。 - 重複の文字数は数えられるが責務は数えられないため、確かめる手段のあるほうを根拠に選んでしまう という偏りが働く。新しく足す口にも、既存コードへ向けるのと同じ問い(この主語は誰か)を 掛けること。
- 「未対応」の理由を並べて書くと、当てはまらないものまで巻き込む。
moveのsubjectはparent/ancestor/childを「一度きりの命令に対してどれを動かすか の意味論が未確定」として まとめて弾いていたが、その理由が当てはまるのはchildだけだった(parentは1つに決まり、 同じmoveのtoでは現に使えていた)。弾く理由は、弾く相手ごとに立てる。 - ロード時に弾く根拠と、実行時に組む文脈は同じ1つの事実。 場所ごとに許す
subjectを 数え上げると、実行時に何を用意するかと二重に書くことになり、片方だけ変えても何も壊れない。 場所は自分が何を持つかだけを宣言し、ルートの側が要るものを言えば、 許可の一覧は導出で消える(ReferenceScope)。
定義と実行時状態(src/domain/、CodeStructure.md 3節)
-
定義を受動的なデータとして扱い、外の実行器が読んで手順を組み立てる形へ戻さないこと。 かつては
InteractionExecutorが操作の宣言の条件と効果を外から読んで実行し、ConditionEvaluatorがConditionNodeを外から読んで判定していた。「この定義をどう実行するか」が 所有者の外にあるため、定義を1つ足すたびに実行器の分岐が増え、実行器から見たい内部を定義側で 広く公開する必要もあった。今は定義自身が判定・実行を持つ(ConditionNode.evaluate・InteractionDef.tryExecute)。定義は受動的なレコードではなく、世界を引数に取る振る舞い。 -
defs/とruntime/にフォルダを分ける形へ戻さないこと。 型宣言と実行時状態という線は 引かれていなかった——畳んだ時点で、定義側31ファイルのうち19が実行時の型を参照し、actionTime.tsは 中身が実行時処理そのものだった。宣言と実行が同居しているのが普通の状態なので、分ける単位にならない。 -
定義の可視性を目標にしないこと。 定義はロード後に書き換わらないので、読まれても壊れる不変条件が 無い。「非公開にできるか」を統合の判断基準にすると空回りする——実測では、統合で非公開にできる メンバは
ObjectDef+WorldObjectで2つ、SlotDef+Slotでゼロだった。何を公開する必要があるかは どんな画面を作るかで決まり、論理的な線は引けない(CodeStructure.md 3節)。 -
宣言を書き表す仕組みを、定義クラスのメソッドとして持たせないこと。 かつては
describeが23個、 10個の定義クラスに生えていた。呼ぶのはデータベースビューアだけ(ゲームは一度も呼ばない)で、ObjectDefに生えていたdescribeInfluencesOn(propertyGlobalId, ownedByThisDef, …)のownedByThisDefは ビューアのプロパティページの問いがそのままシグネチャになったものだった。害は2つで、定義が 「自分がどう見られるか」を知ってしまうことと、importに現れない依存になること (Descriptionを輸入しているだけなので、依存図にはビューアが出てこない)。 今は読み上げ口(EffectReader・PassiveReader・ConditionReader)だけを持ち、書き出しはsrc/codex-viewer/describe/にある。向きはtests/architecture/layers.test.tsが見張る。 読み上げ口にすると追従の強制が増えるのも効いた——ConditionNodeに kind を足してもdescribeの switch は素通りしていたが、ConditionReaderなら実装が足りずコンパイルが止まる。
閉じた腔の圧(VitalsSystem.md 4 節)
- 頭蓋内・胸腔だけを「溜まった圧」で表す案は採らない。 失った量では書けないので、時間とともに
severityが増える傷(今の怪我は治る方向にしか動かない)を新たに立てることになる。出血と仕留めで 狩りは成立しており、先に入れると「治らない傷」の扱いを同時に決めることになる。