# インターネット老人会向け Omega Crafter 攻略ツール作ってみた

## prelude::*

こんにちは，無職です．

みんな大好き [Preferred Networks (PFN)](https://www.preferred.jp/) のゲーム「[Omega Crafter](https://store.steampowered.com/app/2262080/Omega_Crafter/)」が Steam から正式リリースされましたね！

PFN は [小型搬送ロボット kachaka（カチャカ）](https://kachaka.life/) や [ハイパーパラメータ最適化フレームワーク Optuna](https://optuna.org/) で有名な「謎の AI 企業」ですね．

> 『Omega Crafter』は、謎の妨害プログラムにより開発難航中のゲームの世界を舞台にしたオープンワールドサバイバルクラフトゲームです。プログラマブルな相棒・グラミーやほかのプレイヤーと一緒に冒険し、街を作り、強大なボスに挑んでゲームを完成させましょう！
> ![oc](https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/2262080/extras/omegacrafter_programming_v4.gif)
> <https://store.steampowered.com/app/2262080/Omega_Crafter/>

**グラミーをプログラミングしよう！** にあるように，グラミーと呼ばれる生命体（？）の動作は Scratch ライクなプログラミング言語で指示できます．

「Crafter」を称するように，Omega Crafer の最大の要素は「クラフト」です．
ワールドのレベルを上げたり，装備や消耗品を作るためには大量のクラフトが要求されます．
（たとえば消耗品のひとつである「爆弾の矢」を作るために必要なアイテムは下図の通りです）

![爆弾の矢](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/159506/eff4d07c-b4a1-4a23-a4ff-8ff0b5a105d7.png)

アイテム（製品）を手に入れるためには，必要な素材をもとに，製品に応じた機械を使ってクラフトすることになります．
たとえば「石のプレート」は「加工台」に「石材」を 1 つ入れて 23 秒間待つと 1 つ入手できます．

クラフト自体はグラミーに命令できる（テンプレートもある）のですが，機械設備の設置計画は人の手でやらねばなりません．

（TODO: 手動最適化の図，ガントチャートを足す）

知っている中で最も作るのが大変だと思われるのが下図のアイテムで，20 種類ほどの機械が必要です．
また，アイテムごとに製造時間も異なります．
さらに，それぞれの素材を作るための素材を作るための素材を作るための素材を作るための素材を作るための

![ブラフ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/159506/cfcd44bb-f408-479b-92a8-cf8710fa62d8.png)

**これって最適化でなんとかなりませんか？**

:::note
この記事の内容は筆者が AI コーディングのお試しで作ったジョークアプリについてです．
動作は保証しませんし，（API のクレジットがなくなったら）予期なく使えなくなります．
:::

## なんとかする

というわけで作ります．

![oc-optimizer](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/159506/dd70fdca-9218-4b59-bad1-c469a6a0dc6e.png)

作りました．

次の内容が計算できます．

- 指定した製品とその個数をクラフトするために必要な素材の量
- 指定した製品とその個数を指定した時間以内に（平均的に）クラフトするために必要な機械の数

上図を例にとると，30 秒で 1 つの「爆弾の矢」を手に入れるためには，たとえば畑は 4 つ必要です．

### 必要素材量の計算

先ほどの図の矢印を反対向きにすると，作りたいアイテム（製品）を始点とした，すべての素材に到達可能な有向非巡回グラフ（DAG）が得られます．
これをトポロジカルソートして，始点から順序に沿って累積和をとると，製品を作るために必要な素材の量が計算できます．
具体的には順序を重みとした優先度付きキューを使っています．
作りたい製品が複数種類ある場合には，別々で計算して最後に足せば OK です．
（もっといい方法がある？）

```rust
fn required_material_quantity_single(&self, (item_id, quantity): &(ItemId, u32)) -> Result<HashMap<NodeIndex, u32>> {
    let graph = &self.graph;
    let node_order = &self.node_order;

    // Find start node and initialize quantities
    let mut quantities = HashMap::new();
    let mut to_visit = BinaryHeap::new();
    let mut visited = HashSet::new();

    let start = graph
        .node_indices()
        .find(|&index| graph[index].item_id == *item_id)
        .ok_or(anyhow!("Item not found in graph"))?;

    let priority = *node_order
        .get(&start)
        .ok_or(anyhow::anyhow!("Node order not found for start node"))?;
    to_visit.push((Reverse(priority), start));

    quantities.insert(start, *quantity);

    // Count the required materials
    while let Some((_, src)) = to_visit.pop() {
        if visited.contains(&src) {
            continue; // Skip already visited nodes
        }
        visited.insert(src);

        // Quantities of product
        let q_product = *quantities
            .get(&src)
            .ok_or(anyhow!("Source node not found in quantities"))?;

        let mut neighbors = graph.neighbors(src).detach();
        while let Some((edge, dst)) = neighbors.next(graph) {
            // Quantities of material
            let q_material = quantities.entry(dst).or_insert(0);
            *q_material += q_product * graph[edge];

            // Visit a material node if not visited
            if !visited.contains(&dst) {
                let priority = *node_order
                    .get(&dst)
                    .ok_or(anyhow::anyhow!("Node order not found for destination node"))?;
                to_visit.push((Reverse(priority), dst));
            }
        }
    }

    Ok(quantities)
}
```

### 必要機械数の計算

ある目標時間 $t_{target}$ を定めます．
製品または中間製品 $i$ は，$t_{target}$ 秒あたり $n_i$ 個だけ欲しいです．
また，製品 $i$ は機械 $i$ によって $t_i$ 秒あたり $p_i$ 個製造されます．
機械の数を $x_i$ とすると，$t_{target}$ 秒経過時点で製造された数が必要数 $n_i$ 以上であればよいので，

```math
p_i \left\lfloor \frac{t_{target}}{t_i} \right\rfloor x_i \geq n_i
```

今回は設置する機械の数の総和が最小となるのを目標として，

```math
\mathrm{minimize~~} \sum_{i} x_i
```

整数計画を避けるために床関数を緩和して，ゼロ除算を回避するために移項して，設備の最低数が 1 つとして，

```math
\mathrm{s.t.~~~} \begin{cases}
p_i t_{target} x_i \geq t_i n_i \\
x_i \geq 1
\end{cases}
```

線形計画問題（LP）なので解けます．あとで $x_i$ を切り上げます．

```rust
pub fn optimize(&self, required_materials: HashMap<NodeIndex, u32>, target_craft_time: f64) -> Result<HashMap<NodeIndex, u32>> {
    let graph = &self.graph;

    let mut vars = ProblemVariables::new();
    let mut objective = Expression::default();
    let mut constraints = Vec::new();
    let mut objective_value = Vec::new();

    // Problem settings
    for (index, requirement) in required_materials {
        let requirement = requirement as f64;
        let num_of_equipment = vars.add(variable().min(1).name(graph[index].item_id.to_string()));
        let productivity = graph[index].productivity;
        let craft_time = graph[index].craft_time;

        // Minimize the number of equipment used
        objective += num_of_equipment;
        constraints.push(constraint!(
            num_of_equipment * productivity * target_craft_time >= requirement * craft_time
        ));

        objective_value.push((index, num_of_equipment));
    }

    let mut prob = vars.minimise(objective).using(good_lp::default_solver);
    for constraint in constraints {
        prob = prob.with(constraint);
    }

    // Solve problem
    let mut equipment_requirements = HashMap::new();
    let solution = prob.solve()?;
    for (index, num_of_equipments) in objective_value {
        equipment_requirements.insert(index, solution.value(num_of_equipments).ceil() as u32);
    }

    Ok(equipment_requirements)
}
```

ほとんど **AI 様** に教えてもらいながら，Web API として *Google Cloud Run* に投げました．

主な技術スタック：

- *axum*: Web フレームワーク
- *petgraph*: グラフ構造用ライブラリ
- *good_lp*: LPのモデリングライブラリ

### フロントエンド

フロントエンドを書いたことがないので *GitHub Copilot* にお願いしました．
なんとなく補完機能に従って API へのアクセスと SVG の描画部分だけ書いて，あとは Agent に

「**よろしくおねがいしまぁぁぁぁぁぁす！（ポチッ）**」

```md
# AIエージェント向けコーディング依頼書

## 概要

`index.html` と同等の動作・UI・機能を持つWebページを新規作成してください。たたき台として利用します。細部まで忠実に再現できるよう、下記の詳細要件を厳守してください。

---

## 技術要件

- 1ページ完結のHTML（SPAでなくてよい）
- レイアウトはBootstrap 5を利用し、レスポンシブ対応とする
- JavaScriptはES6以降、インラインスクリプトで記述
- すべてのコード・スタイル・スクリプトは1ファイル内に記述（CDN利用は可）
- UI部品の装飾や動作は `index.html` を忠実に再現すること
- APIのベースURLは `http://localhost:8080` で、定数として一か所で管理すること
- 必要に応じてTom Select等のUIライブラリ利用可
- 主要な関数・処理には日本語コメントを付与し、可読性を重視すること

---

## 画面構成・UI要件

- ページタイトル、ヘッダー、説明文を上部に配置
- メイン操作部は以下の3つのボタン・フォームで構成
  - 「材料要件グラフ」ボタン（フォーム内容を送信）
  - 「装備要件グラフ」ボタン＋target_craft_time入力（フォーム内容＋目標時間を送信）
  - 「全アイテムグラフ」ボタン
- 材料・装備要件の入力フォームは、
  - プルダウン（Tom Selectで強化、APIから取得した `item_id` のうち `craft_time != 0` のみ表示）
  - 非負整数入力（初期値1、0未満不可）
  - 「追加」「削除」ボタンで行の増減が可能
  - 入力行は複数可
- 各ボタンはBootstrapの `w-100 h-100` でカラム幅いっぱい＆高さ揃え
- ボタン・フォームは `row`/`col` で横並び、必要に応じてネスト
- target_craft_timeはinputで右端に配置し、単位（sec）も表示
- 入力値はバリデーションし、不正値は1に補正
- プルダウン・input・削除ボタンは1行で横並び
- Tom Selectのプルダウンは未入力時も幅が小さくならないようmin-width指定

---

## 使用ライブラリ

- [Bootstrap 5](https://getbootstrap.com/)（CDN）
- [Tom Select](https://tom-select.js.org/)（CDN, プルダウンUI強化）
- [d3-graphviz](https://github.com/magjac/d3-graphviz)（CDN, グラフ描画）
- [viz.js](https://github.com/mdaines/viz.js/)（CDN, Graphvizエンジン）

---

## 使用APIエンドポイント

- `GET /api/items`
  - アイテム一覧（JSON配列）を取得
  - 各要素例: `{ "item_id": "生肉", "productivity": 1, "craft_time": 0, "craft_by": "ドロップアイテム" }`
  - プルダウンには `craft_time != 0` のみ表示

- `GET /api/items/graph`
  - 全アイテムの関係グラフ（DOT形式テキスト）を取得

- `POST /api/crafts/materials/graph`
  - 材料要件グラフ（DOT形式テキスト）を取得
  - リクエストボディ: `{ required_products: [{ item_id: string, quantity: number }, ...] }`

- `POST /api/crafts/equipments/graph`
  - 装備要件グラフ（DOT形式テキスト）を取得
  - リクエストボディ: `{ required_products: [{ item_id: string, quantity: number }, ...], target_craft_time: number }`

---

## グラフ描画要件

- d3-graphvizでDOT形式テキストをSVGグラフとして描画
- SVGは親要素の範囲を超えない場合は元サイズ、超える場合のみ幅100%でリサイズ
- グラフ描画時、前回のエラー表示は消去
- エラー時はBootstrapのalert-dangerで明示

---

## その他

- 画面構成・UI・動作は `index.html` を忠実に再現してください
- 主要な関数・UI部品には日本語コメントを付与し、可読性・保守性を重視してください
- APIのベースURLは `API_BASE_URL` という定数で一か所にまとめて管理してください
- 依頼内容に不明点があれば必ず確認してください

---

以上、よろしくお願いいたします。
```

この仕様書も AI 謹製です．

## デザインの改良

見てわかる通り，**いかにも Bootstrap** でダサいです．

もっと *イケてる* デザインにしたいのですが…… **最高の記事を見つけました**．
*封印されし右眼* が解き放たれそうでいいですね．

https://qiita.com/chomado/items/acaf251a2f6bf984a3df

さらに

https://x.com/tokawa_ms/status/1932261085303947276?ref_src=twsrc%5Etfw%7Ctwcamp%5Etweetembed%7Ctwterm%5E1932261085303947276%7Ctwgr%5E460008bb34d43b1aec968f90a7a7091ffa7e5bd9%7Ctwcon%5Es1_c10&ref_url=https%3A%2F%2Fqiita.com%2Fembed-contents%2Ftweetqiita-embed-content__cf660dc005cc14c571109ad373ad457c

>> GitHub Copilot のリポジトリ カスタム命令を使ってテンプレート化しておきました。「サイト名」と「サイトで取り扱いたい内容」くらいをプロンプトで指示すれば、あとは勝手に古のサイトが出来上がるので黒歴史の大量生産が可能になりましたw
>
> どういうこと！！？？？！？？？
> 何その 誰得 テンプレート！！！
> おもしろそう！！！！

というわけでテンプレートをお借りして，スタイル部分のみ修正してもらいました．
アイコンとバナーを用意するのが面倒だったので CSS で依頼しました．
*イケて* ますね．

![2025-06-20-01-22-00.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/159506/7fbb09fc-0f0e-4cea-ba99-1b95f15b9801.gif)

https://pneumotrol.github.io/omega_crafter_optimizer_pages/index-legacy

もちろんデバッグしていません．一応試せます．

費用面は *Google Cloud Run* の Always free に頼り切っているので，お手柔らかにお願いします．

## おわりに

Vibe coding や AI coding をよく耳にするようになったので試してみました．
IT エンジニアではないので，フロントエンド，バックエンドともに書いたことがなかったのですが，補完機能だけで 1 週間程度でもほぼ動くものが作れたのは驚きです．

レガシースタイルについてはほとんど Agent 任せでしたが，手直しはほとんどありませんでした．
（せいぜい追加指示のみで，手書き修正した部分はないかもしれない）
自分がやっている範囲だとデータの visualization なんかは確認が簡単なので任せきっても良さそうなくらいですね．驚きです．

しかし本当に驚くべきところは，データがすべてコードに埋め込みなところかもしれません．

さいごに，
**きっかけをくれたインターネット老人会の皆様に感謝！**