探索・道システム設計

概要

本ドキュメントは、TerrainGeneration.md の地形生成パイプラインによって作られた Location(土地)が、生成されたあとにどう振る舞うかをまとめたものです。「島にどんな土地がいくつ、どう 配置されるか」は TerrainGeneration.md の関心事、「1つの土地に立ったプレイヤーが何をできるか(探索する・道を 見つける・移動する)」は本書の関心事、と役割を分けています。

GameElementDefinition.md の既存文法(traits・slots・props・interactions・ pick)と、汎用拡張 duration(同 11.3 節)・move(同 9.6 節)だけで組み立てており、探索・道専用の 新しい文法は導入していません。

本ドキュメントは検討結果であり、確定仕様書ではありません。実際のクラス名・メソッド名を使った実装の 呼び出し関係(IslandSpawner・MoveEffect・Location.explore 等)は TerrainGenerationImplementation.md にまとめています。

1. location trait と explorable trait の役割分担

「場所」であるという構造(スロット)と、「探索できる」という性質(進捗・道の発見)は、別々の trait に分けています。家・作業小屋のような探索できない場所もスロットは必要なため、スロットは explorable では なく location trait(core.yaml)側に置いています。

# core.yaml
traits:
  location:
    tags: [location]
    slots:
      items:
        cell: {accept: {tag: item}}
      fixtures:
        cell: {accept: {tag: fixture}}
      characters:
        cell_count: 1
        cell: {accept: {tag: character}}
# locations.yaml
traits:
  explorable:
    slots:
      # locationのfixturesより後に宣言する(探索で湧く設置物が隠し側へ入らないように)。
      undiscovered_fixtures:
        cell: {accept: {tag: fixture}}
    props:
      exploration_progress: {}   # valueなし = 実装する側で必須(GameElementDefinition.md 5節)

草原などの探索可能な土地は両方(traits: [location, explorable])、家のような場所は location のみを 参照します。

1.1 スロット(location trait)

すべての場所は次のスロットを持ちます。いずれも capacity (サイズの合計制限、GameElementDefinition.md 7.3 節)は指定していません。

items/fixtures の区別はタグ(item/fixture)だけで行い、探索の発見物(2 節)を spawn する際、 どちらのスロットへ入るかは著者がスロット名を指定せずとも自動的に決まります(spawn の into が起点の持つ スロットを宣言順に走査する、GameElementDefinition.md 9.4 節)。items を 宣言順の先頭に置いているのは、spawn が配置に失敗した際のこぼれ落ち(起点自身の親へ、同 9.4 節)が 同じく先頭のスロットから試すためで、「持ちきれない発見物は地面(土地の items)に落ちる」という 直感的な結果を、追加のルールなしに実現しています。

【いつか: 洞窟内部】 探索で見つかる浅い洞窟(shallow_cave)は、それ自体が location を名乗る入れる 場所ですが、そこから奥へ続く道はまだありません(Someday.md)。

1.2 道の2スロット(隠しスロット方式、explorable trait)

土地同士の繋がりは、探索によって見つかる path という専用オブジェクトとして表現します。「発見」は 隠しスロット方式で実装しています。生成時点で道は undiscovered_fixtures(隠し)に置かれ、探索の進捗が道ごとの 必要値に達すると fixtures(公開)へ移動して「発見」されます。この移動は既存の move_to_slot(唯一の汎用 スロット移動操作、SlotSystem.md 1 節)1回で表現でき、UI 側は「fixtures の中身をそのまま表示する」という単純な 規約だけでよく、未発見の道を非表示にする特別な処理を持ちません(道を1スロットに置き移動アクションの条件で 実行だけ禁止する方式は、UI側に隠す処理が別途必要になり「発見」が実体を持たないため不採用)。

2. 探索(explore アクション)

「探索で何が見つかるか」と探索率100%までの探索回数(進捗プロパティの上限)は、explorable trait ではなく 土地ごとの object_defs(locations.yaml)が個別に定義します。explorable trait 自身が持つのは、進捗を 保持する箱(exploration_progress)と道の発見に使う2スロット(1.2 節)という共通の器だけです。

object_defs:
  grassland:
    traits: [location, explorable]
    props:
      exploration_progress:
        value: 0
        # 探索率100%に達するまでの探索回数
        range:
          min: 0
          max: 12
    interactions:
      explore:
        trigger: menu
        duration: 15
        # 何が見つかっても進捗は1つ進む
        add: {self: {exploration_progress: 1}}
        pick:
          - weight: {prop: berry_find}
            spawn: {object: berry_bush, into: self}
          - weight: {prop: spring_find}
            spawn: {object: spring, into: self}
          # ...
          - weight: 18
            spawn: {object: water_spinach, into: self, count: 2}
          # ...

2.1 出くわす獣は、単独の候補

生きた獣(animals.yaml)も pick の候補です。 島に獣を放つ専用の仕組みは持たず、獣が世界に現れる 経路は探索と罠(TrapSystem.md)だけです。どの土地にどの獣が出るかは Animals.md 3〜5 節の分布に従い、その素の重みは他の発見物と同じつまみ として土地の props が持ちます(locations.yaml)。

探索と罠は、同じ獣を別の形で渡します。 探索が渡すのは出くわした獣で、自由に動き、その獣の 警戒しだいで逃げも襲いもします(現れたときの警戒は獣ごとに違う、HuntingSystem.md 3.1 節)。 罠が渡すのは掛かった獣で、留守のあいだに働き、獲物は枠の中で拘束されています (TrapSystem.md 5 節)。ネズミ・ヤケイが両方に載っているのはこのためで、罠の値打ちは「その獣が手に入ること」ではなく 「見ていない間に、動けない状態で手に入ること」です。

獣の候補は、その獣を1匹だけ湧かせます(tests/world-codex/explorationYield.test.ts が検証)。 獣は収穫ではなく出くわすもので、木の実や枝と一緒に並ぶと実りの一部に見えてしまうためです。重みは 低く抑え、novice が1回の探索で獣に出くわすのは、どの土地でも1割に届きません (tests/world-codex/explorationYield.test.ts)。

獣のつまみは、探索する人の狩猟の腕を base の土台にします (../world/Skills.md 5 節。expert はつまみ 1 つ につき +4)。土地が持つのは素の重みだけで、宣言していない獣は卓に無いので、腕がいくら高くても 出くわしません。実りの候補には積みませんが、卓の合計が増えるぶん実りの出る割合は下がります—— 腕を上げた探索は、どの土地でも獣へ寄ります(同じ検査)。

上乗せは、つまみ 1 つにつき同じ量が積まれます。 受け取る総量は獣の種類の数に比例する ので、種類の多い土地ほど腕が効きます——腕を上げたぶん獣へ寄る幅は、獣の種類が多い土地ほど 大きくなります(同じ検査)。この偏りは狙いどおりで、土地ごとに上乗せを割る調整は 入れません(../world/Skills.md 5.2 節)。

海区の見張りにも、同じ上乗せが積まれます(獣の割合の線は土地のもので、海区には掛かりません)。海区は土地と同じ explorable(../world/Voyage.md)で、 魚の群れと海鳥の群れ(shoal_find・seabird_find)が獣のつまみにあたります——どちらもその場に湧いて しばらくすると去り、獲る手が狩猟の腕を配ります。拾えるもの(流木・海藻・卵)には積みません。

湧いた獣は wariness を持った状態で土地の items に立ちます(HuntingSystem.md 3.1 節)。放っておいても stay_remaining が尽きれば立ち去る(同 5.6 節)ので、島に溜まり続けることは ありません。手を出した個体だけは立ち去らず(同節)、傷が癒えるか、洗えない傷が膿んで倒れるまで 世界に残ります——溜まらないことを支えているのは、こちらが手を出していないぶんです。

3. 道(path object_def)と移動

object_defs:
  path:
    tags: [path]
    props:
      travel_minutes:
        # 生成時にインスタンスごと上書きされる
        value: 60
        # 道の長さに、今歩いている人の遅れを継ぐ(world/Characters.md 荷重の効き方節)
        base: {subject: agent, prop: travel_delay}
      required_progress:
        # 同上
        value: 1
      destination_id:
        # 同上(移動先LocationのインスタンスID)
        value: 0
      return_path_id:
        # 同上(移動先にある、こちらへ戻る道のインスタンスID)
        value: 0
    interactions:
      travel:
        trigger: menu
        conditions:
          - {in_slot: fixtures}   # 発見済み(fixturesスロット)の間だけ実行できる
        # 担ぎ手の遅れはtravel_minutesのbaseが継いでいるので、ここはその1つを読むだけ
        duration: {prop: travel_minutes}
        move:
          subject: agent
          to_prop: destination_id

3.1 道は辺1本につき両端へ2個、発見は両側同時

地形生成(TerrainGeneration.md 3.5 節)が確定させる Location 間の1本の繋がりに対し、path インスタンスを 両端に1個ずつ生成します。片方は「Aの undiscovered_fixtures に居て、destination_id はBを指す」、もう片方は その逆です。2個に分けるのは、どちらの土地から見るかで行き先(=カードに出す土地名)が変わるためです。

発見は両側同時です。生成時に両端の path へ互いの instanceId を return_path_id として書き込んでおき、 片方を公開する際にもう片方も一緒に公開します(Location.revealDueFixtures)。片側だけを公開すると、 渡った先の土地を required_progress に達するまで探索し直さない限り戻れず、行き止まりに閉じ込められた ように見えます。歩いた道の帰り方は分かる、という直感とも一致します。

required_progress は「その土地を探索して自力で見つける」ための条件として引き続き働きます。一度も渡って いない土地の道は、その土地の探索でしか見つかりません。

3.2 required_progress の割り当て(探索率100%に達する前に道が見つかることの保証)

「探索率が100%に達する前にすべての道が見つかる」ことを、データの調整ではなく生成側の不変条件として 保証しています。

ある土地に接続する道が K 本あるとき、それぞれの required_progress を、探索の上限を max として [2, max − 1] の範囲へ均等間隔で割り当てます(src/domain/generation/IslandSpawner.ts)。最初の道が進捗2で 見つかる(1回目の探索でいきなり道が見つからないようにする)のを最速とし、最後の道は必ず max − 1 以前に 見つかります。この不変条件は tests/generation/islandSpawner.test.ts が全接続について検証しています。

100%到達後も探索は続けられますが(2 節)、進捗は max に張り付くため、新たに条件を満たす道はもう 現れません。「探索率100%=この土地の道は出尽くした」という保証は、この割り当てが与えています。

道の本数が3本以上でも、割り当てはこのままとする

「最初の道が見つかるまで」が長すぎないかを測って決めたもので、覆すのに人間の判断は要りません。

最初の道は、道の本数にも土地の型にもよらず、必ず2回目の探索で出ます(30 分)。 割り当ての下端が K に依らないためで、その土地を探索率100%まで開くのにかかる 185 分に対してごく手前です。 「長すぎる」は起こりえないので、調整する先がありません。

本数が効くのは間隔のほうで、道が増えるほど詰まります(実測は stats/terrain.yaml の path_discovery_by_degree)。詰まる側は 止める理由がありません——道が多い土地ほど次の行き先が早く揃うのは、道が多いことの意味そのものです。

その土地に居続けた時間だけを数えています(通う移動は含みません)。読み方は TerrainStats.md、破れたときに落ちるのは tests/generation/islandSpawner.test.ts の「最初の道は、道の本数によらず同じ進捗で見つかる」です。

4. エンジン拡張(duration・move)

duration・move は地形・探索専用ではなく、どんな interactions/pick からも使える汎用の 文法拡張です。

5. カプセル化: 探索の入口を1箇所にする

Location.explore(src/domain/wrappers/Location.ts)を、探索の唯一の入口としています。explore アクションを 実行し、成功したら続けて Location.revealDueFixtures(進捗が必要値に達した設置物を、隠しスロットから公開スロットへ 移す)を呼びます。

プレイヤー側の入口も同じく1箇所です。PlayerCharacter.explore(src/domain/wrappers/PlayerCharacter.ts)が 「今いる土地に自分を agent として渡す」という手順を引き受けるため、UI は自分の居場所を知らなくてよく、 探索できない場所に居る場合は false が返ります。

explore アクション(YAML側)の実行と、後処理の revealDueFixtures を呼び出し側(UI等)に分けて呼ばせません (CLAUDE.md の「自分のことは自分でする」方針)。