infinite-craft-cli 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- infinite_craft_cli-0.1.0/.github/workflows/publish.yml +27 -0
- infinite_craft_cli-0.1.0/.gitignore +6 -0
- infinite_craft_cli-0.1.0/LICENSE +21 -0
- infinite_craft_cli-0.1.0/PKG-INFO +90 -0
- infinite_craft_cli-0.1.0/README.md +73 -0
- infinite_craft_cli-0.1.0/pyproject.toml +28 -0
- infinite_craft_cli-0.1.0/src/infinite_craft_cli/__init__.py +3 -0
- infinite_craft_cli-0.1.0/src/infinite_craft_cli/cli.py +930 -0
- infinite_craft_cli-0.1.0/src/infinite_craft_cli/data.py +17 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- 'v*'
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
publish:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
permissions:
|
|
12
|
+
id-token: write
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: '3.12'
|
|
19
|
+
|
|
20
|
+
- name: Install build tools
|
|
21
|
+
run: pip install build
|
|
22
|
+
|
|
23
|
+
- name: Build package
|
|
24
|
+
run: python -m build
|
|
25
|
+
|
|
26
|
+
- name: Publish to PyPI
|
|
27
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Zach Mills
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: infinite-craft-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Interactive CLI for Infinite Craft — combine elements from the terminal
|
|
5
|
+
Project-URL: Homepage, https://github.com/hacker6284/infinite-craft-cli
|
|
6
|
+
Project-URL: Repository, https://github.com/hacker6284/infinite-craft-cli
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: cli,game,infinite-craft
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Games/Entertainment
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Requires-Dist: infinitecraft
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# infinite-craft-cli
|
|
19
|
+
|
|
20
|
+
Interactive CLI for [Infinite Craft](https://neal.fun/infinite-craft/) — combine elements from the terminal.
|
|
21
|
+
|
|
22
|
+
Built on top of [infinite-craft](https://github.com/sqdnoises/infinite-craft) by [@sqdnoises](https://github.com/sqdnoises).
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install infinite-craft-cli
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
### Interactive mode
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
infinite-craft
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
This opens a REPL where you can combine elements, search discoveries, and more:
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
craft> Water + Fire
|
|
42
|
+
💨 Water + 🔥 Fire = 💨 Steam
|
|
43
|
+
|
|
44
|
+
craft> /search steam
|
|
45
|
+
💨 Steam
|
|
46
|
+
|
|
47
|
+
craft> /help
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Non-interactive mode
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
infinite-craft combine "Water" "Fire"
|
|
54
|
+
infinite-craft search "steam"
|
|
55
|
+
infinite-craft list
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Commands
|
|
59
|
+
|
|
60
|
+
| Command | Description |
|
|
61
|
+
|---------|-------------|
|
|
62
|
+
| `<element> + <element>` | Combine two elements |
|
|
63
|
+
| `<element> ++ <element>` | Combine & crawl: iterate until no new discoveries |
|
|
64
|
+
| `<element> + \| <query>` | Combine element with all matching discoveries |
|
|
65
|
+
| `<query> * <query>` | Cross-combine all matches from both queries |
|
|
66
|
+
| `/search <query>` | Search discoveries (supports `*` `?` wildcards, `^` for first discoveries) |
|
|
67
|
+
| `/recipe <element>` | Show shortest recipe from base elements |
|
|
68
|
+
| `/list` | List all discovered elements |
|
|
69
|
+
| `/exhaust <element>` | Combine element with all discoveries |
|
|
70
|
+
| `/crawl <el> + <el>` | Same as `++` (alternate syntax) |
|
|
71
|
+
| `/permute <query>` | Combine all matching elements with each other |
|
|
72
|
+
| `/import <element\|file.ic>` | Import from Infinibrowser or `.ic` save file |
|
|
73
|
+
| `/fill` | Fetch missing recipes from Infinibrowser |
|
|
74
|
+
| `/unfilled` | List elements without recipes |
|
|
75
|
+
| `/export [path]` | Export discoveries as `.ic` save file |
|
|
76
|
+
| `/history` | Show combinations tried this session |
|
|
77
|
+
| `/help` | Show help |
|
|
78
|
+
| `/quit` | Exit |
|
|
79
|
+
|
|
80
|
+
## Data storage
|
|
81
|
+
|
|
82
|
+
Discoveries and recipes are stored in `~/.infinite-craft-cli/`:
|
|
83
|
+
|
|
84
|
+
- `discoveries.json` -- all discovered elements
|
|
85
|
+
- `recipes.json` -- known element combinations
|
|
86
|
+
- `export.ic` -- default export location
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
MIT
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# infinite-craft-cli
|
|
2
|
+
|
|
3
|
+
Interactive CLI for [Infinite Craft](https://neal.fun/infinite-craft/) — combine elements from the terminal.
|
|
4
|
+
|
|
5
|
+
Built on top of [infinite-craft](https://github.com/sqdnoises/infinite-craft) by [@sqdnoises](https://github.com/sqdnoises).
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install infinite-craft-cli
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
### Interactive mode
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
infinite-craft
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
This opens a REPL where you can combine elements, search discoveries, and more:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
craft> Water + Fire
|
|
25
|
+
💨 Water + 🔥 Fire = 💨 Steam
|
|
26
|
+
|
|
27
|
+
craft> /search steam
|
|
28
|
+
💨 Steam
|
|
29
|
+
|
|
30
|
+
craft> /help
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Non-interactive mode
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
infinite-craft combine "Water" "Fire"
|
|
37
|
+
infinite-craft search "steam"
|
|
38
|
+
infinite-craft list
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Commands
|
|
42
|
+
|
|
43
|
+
| Command | Description |
|
|
44
|
+
|---------|-------------|
|
|
45
|
+
| `<element> + <element>` | Combine two elements |
|
|
46
|
+
| `<element> ++ <element>` | Combine & crawl: iterate until no new discoveries |
|
|
47
|
+
| `<element> + \| <query>` | Combine element with all matching discoveries |
|
|
48
|
+
| `<query> * <query>` | Cross-combine all matches from both queries |
|
|
49
|
+
| `/search <query>` | Search discoveries (supports `*` `?` wildcards, `^` for first discoveries) |
|
|
50
|
+
| `/recipe <element>` | Show shortest recipe from base elements |
|
|
51
|
+
| `/list` | List all discovered elements |
|
|
52
|
+
| `/exhaust <element>` | Combine element with all discoveries |
|
|
53
|
+
| `/crawl <el> + <el>` | Same as `++` (alternate syntax) |
|
|
54
|
+
| `/permute <query>` | Combine all matching elements with each other |
|
|
55
|
+
| `/import <element\|file.ic>` | Import from Infinibrowser or `.ic` save file |
|
|
56
|
+
| `/fill` | Fetch missing recipes from Infinibrowser |
|
|
57
|
+
| `/unfilled` | List elements without recipes |
|
|
58
|
+
| `/export [path]` | Export discoveries as `.ic` save file |
|
|
59
|
+
| `/history` | Show combinations tried this session |
|
|
60
|
+
| `/help` | Show help |
|
|
61
|
+
| `/quit` | Exit |
|
|
62
|
+
|
|
63
|
+
## Data storage
|
|
64
|
+
|
|
65
|
+
Discoveries and recipes are stored in `~/.infinite-craft-cli/`:
|
|
66
|
+
|
|
67
|
+
- `discoveries.json` -- all discovered elements
|
|
68
|
+
- `recipes.json` -- known element combinations
|
|
69
|
+
- `export.ic` -- default export location
|
|
70
|
+
|
|
71
|
+
## License
|
|
72
|
+
|
|
73
|
+
MIT
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "infinite-craft-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Interactive CLI for Infinite Craft — combine elements from the terminal"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
keywords = ["infinite-craft", "cli", "game"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Environment :: Console",
|
|
16
|
+
"Topic :: Games/Entertainment",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
"infinitecraft",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/hacker6284/infinite-craft-cli"
|
|
25
|
+
Repository = "https://github.com/hacker6284/infinite-craft-cli"
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
infinite-craft = "infinite_craft_cli.cli:main"
|
|
@@ -0,0 +1,930 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Infinite Craft CLI — combine elements from the terminal or as a scripted tool."""
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import argparse
|
|
6
|
+
import fnmatch
|
|
7
|
+
import gzip
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import readline # noqa: F401 — enables arrow keys, history in input()
|
|
11
|
+
import signal
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
import urllib.request
|
|
15
|
+
|
|
16
|
+
from infinitecraft import InfiniteCraft, Element
|
|
17
|
+
|
|
18
|
+
from infinite_craft_cli.data import DISCOVERIES_PATH, RECIPES_PATH, EXPORT_PATH
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# ANSI colors
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
RESET = "\033[0m"
|
|
24
|
+
BOLD = "\033[1m"
|
|
25
|
+
DIM = "\033[2m"
|
|
26
|
+
GREEN = "\033[32m"
|
|
27
|
+
YELLOW = "\033[33m"
|
|
28
|
+
CYAN = "\033[36m"
|
|
29
|
+
MAGENTA = "\033[35m"
|
|
30
|
+
RED = "\033[31m"
|
|
31
|
+
|
|
32
|
+
API_RATE_LIMIT = 60 # requests per minute — conservative to avoid Cloudflare blocks
|
|
33
|
+
API_CONCURRENCY = 2 # parallel workers for bulk operations
|
|
34
|
+
|
|
35
|
+
# Session-only history
|
|
36
|
+
_history: list[tuple[str, str, str]] = []
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Persistent recipe store
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
def _load_recipes() -> dict[str, list[list[str]]]:
|
|
43
|
+
"""Load recipes.json: {result_name: [[a_name, b_name], ...]}"""
|
|
44
|
+
if os.path.exists(RECIPES_PATH):
|
|
45
|
+
with open(RECIPES_PATH, encoding="utf-8") as f:
|
|
46
|
+
return json.load(f)
|
|
47
|
+
return {}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _save_recipes(recipes: dict[str, list[list[str]]]):
|
|
51
|
+
with open(RECIPES_PATH, "w", encoding="utf-8") as f:
|
|
52
|
+
json.dump(recipes, f, indent=2)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _record_recipe(result_name: str, a_name: str, b_name: str):
|
|
56
|
+
"""Record that a_name + b_name = result_name."""
|
|
57
|
+
recipes = _load_recipes()
|
|
58
|
+
pair = sorted([a_name, b_name])
|
|
59
|
+
if result_name not in recipes:
|
|
60
|
+
recipes[result_name] = []
|
|
61
|
+
if pair not in recipes[result_name]:
|
|
62
|
+
recipes[result_name].append(pair)
|
|
63
|
+
_save_recipes(recipes)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# Formatting
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
def _color(text: str, code: str) -> str:
|
|
70
|
+
if sys.stdout.isatty():
|
|
71
|
+
return f"{code}{text}{RESET}"
|
|
72
|
+
return text
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def format_element(elem) -> str:
|
|
76
|
+
s = str(elem) # uses Element.__str__ which handles emoji
|
|
77
|
+
if elem.is_first_discovery:
|
|
78
|
+
s += " " + _color("[FIRST DISCOVERY!]", BOLD + MAGENTA)
|
|
79
|
+
return s
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def format_result(first_name: str, second_name: str, result) -> str:
|
|
83
|
+
if result.name is None:
|
|
84
|
+
res = _color("Nothing", DIM)
|
|
85
|
+
else:
|
|
86
|
+
res = format_element(result)
|
|
87
|
+
return f" {first_name} + {second_name} = {res}"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
# Core operations
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
def _resolve_element(game, name: str):
|
|
94
|
+
"""Look up an element by name in discoveries; fall back to bare Element."""
|
|
95
|
+
found = game.get_discovery(name)
|
|
96
|
+
if found is not None:
|
|
97
|
+
return found
|
|
98
|
+
# Also try title-cased version
|
|
99
|
+
title = name.strip().title()
|
|
100
|
+
if title != name:
|
|
101
|
+
found = game.get_discovery(title)
|
|
102
|
+
if found is not None:
|
|
103
|
+
return found
|
|
104
|
+
return Element(name=name.strip().title())
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# Runtime cache for pair results — avoids re-hitting the API for the same combo
|
|
108
|
+
_pair_cache: dict[tuple[str, str], Element] = {}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def _cached_pair(game, a, b):
|
|
112
|
+
"""Wrapper around game.pair that caches results by sorted element names."""
|
|
113
|
+
key = tuple(sorted([a.name, b.name]))
|
|
114
|
+
if key in _pair_cache:
|
|
115
|
+
return _pair_cache[key]
|
|
116
|
+
result = await game.pair(a, b)
|
|
117
|
+
_pair_cache[key] = result
|
|
118
|
+
if result.name is not None:
|
|
119
|
+
_record_recipe(result.name, a.name, b.name)
|
|
120
|
+
return result
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def do_combine(game, first_name: str, second_name: str) -> str:
|
|
124
|
+
first = _resolve_element(game, first_name)
|
|
125
|
+
second = _resolve_element(game, second_name)
|
|
126
|
+
try:
|
|
127
|
+
result = await _cached_pair(game, first, second)
|
|
128
|
+
except Exception as e:
|
|
129
|
+
return _color(f" Error: {e}", RED)
|
|
130
|
+
# If the pairing succeeded, ensure both inputs are in discoveries too
|
|
131
|
+
if result.name is not None:
|
|
132
|
+
for elem in (first, second):
|
|
133
|
+
game._update_discoveries(
|
|
134
|
+
name=elem.name, emoji=elem.emoji, is_first_discovery=False
|
|
135
|
+
)
|
|
136
|
+
result_display = result.name if result.name else "Nothing"
|
|
137
|
+
_history.append((first_name.strip(), second_name.strip(), result_display))
|
|
138
|
+
return format_result(str(first), str(second), result)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _match_elements(game, query: str) -> list:
|
|
142
|
+
"""Return discovered elements matching a query.
|
|
143
|
+
|
|
144
|
+
Supports wildcards (* and ?) via fnmatch when present,
|
|
145
|
+
otherwise falls back to substring matching.
|
|
146
|
+
Prefix with ^ to limit to first discoveries only.
|
|
147
|
+
"""
|
|
148
|
+
discoveries = game.get_discoveries()
|
|
149
|
+
q = query.strip()
|
|
150
|
+
only_new = q.startswith("^")
|
|
151
|
+
if only_new:
|
|
152
|
+
q = q[1:]
|
|
153
|
+
q = q.lower()
|
|
154
|
+
if any(c in q for c in "*?[]"):
|
|
155
|
+
matches = [e for e in discoveries if fnmatch.fnmatch(e.name.lower(), q)]
|
|
156
|
+
else:
|
|
157
|
+
matches = [e for e in discoveries if q in e.name.lower()]
|
|
158
|
+
if only_new:
|
|
159
|
+
matches = [e for e in matches if e.is_first_discovery]
|
|
160
|
+
return matches
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def do_search(game, query: str) -> str:
|
|
164
|
+
matches = _match_elements(game, query)
|
|
165
|
+
if not matches:
|
|
166
|
+
return " No matches found."
|
|
167
|
+
return "\n".join(f" {format_element(e)}" for e in matches)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def do_recipe(game, name: str) -> str:
|
|
171
|
+
"""Show shortest recipe tree for an element via BFS on local recipes."""
|
|
172
|
+
recipes = _load_recipes()
|
|
173
|
+
target = name.strip()
|
|
174
|
+
|
|
175
|
+
# Find exact match in discoveries
|
|
176
|
+
elem = game.get_discovery(target)
|
|
177
|
+
if elem is None:
|
|
178
|
+
elem = game.get_discovery(target.title())
|
|
179
|
+
if elem is None:
|
|
180
|
+
return f" {target} not found in discoveries."
|
|
181
|
+
target = elem.name
|
|
182
|
+
|
|
183
|
+
if target in _BASE_ELEMENTS:
|
|
184
|
+
return f" {target} is a base element."
|
|
185
|
+
|
|
186
|
+
if target not in recipes:
|
|
187
|
+
return f" No recipe known for {target}. Try /fill or /import."
|
|
188
|
+
|
|
189
|
+
# BFS to find all elements needed, tracking shortest path
|
|
190
|
+
# parent[name] = (a_name, b_name) that produces it
|
|
191
|
+
parent = {}
|
|
192
|
+
visited = set(_BASE_ELEMENTS)
|
|
193
|
+
found = False
|
|
194
|
+
|
|
195
|
+
while not found:
|
|
196
|
+
# Find everything we can make using ONLY previously visited elements
|
|
197
|
+
new_this_layer = {}
|
|
198
|
+
for result_name, pairs in recipes.items():
|
|
199
|
+
if result_name in visited or result_name in new_this_layer:
|
|
200
|
+
continue
|
|
201
|
+
for pair in pairs:
|
|
202
|
+
if pair[0] in visited and pair[1] in visited:
|
|
203
|
+
new_this_layer[result_name] = (pair[0], pair[1])
|
|
204
|
+
if result_name == target:
|
|
205
|
+
found = True
|
|
206
|
+
break
|
|
207
|
+
if not new_this_layer:
|
|
208
|
+
break
|
|
209
|
+
# Commit entire layer at once
|
|
210
|
+
for name, recipe in new_this_layer.items():
|
|
211
|
+
parent[name] = recipe
|
|
212
|
+
visited.add(name)
|
|
213
|
+
|
|
214
|
+
if not found:
|
|
215
|
+
return f" Cannot trace full lineage for {target} — missing intermediate recipes."
|
|
216
|
+
|
|
217
|
+
# Walk back from target to collect steps in order
|
|
218
|
+
steps = []
|
|
219
|
+
to_resolve = [target]
|
|
220
|
+
resolved = set(_BASE_ELEMENTS)
|
|
221
|
+
while to_resolve:
|
|
222
|
+
name = to_resolve.pop()
|
|
223
|
+
if name in resolved:
|
|
224
|
+
continue
|
|
225
|
+
a, b = parent[name]
|
|
226
|
+
# Ensure dependencies are resolved first
|
|
227
|
+
if a not in resolved:
|
|
228
|
+
to_resolve.append(name) # re-queue
|
|
229
|
+
to_resolve.append(a)
|
|
230
|
+
continue
|
|
231
|
+
if b not in resolved:
|
|
232
|
+
to_resolve.append(name) # re-queue
|
|
233
|
+
to_resolve.append(b)
|
|
234
|
+
continue
|
|
235
|
+
steps.append((a, b, name))
|
|
236
|
+
resolved.add(name)
|
|
237
|
+
|
|
238
|
+
lines = [f" Recipe for {_color(target, BOLD)} ({len(steps)} steps):"]
|
|
239
|
+
for a, b, r in steps:
|
|
240
|
+
a_elem = game.get_discovery(a)
|
|
241
|
+
b_elem = game.get_discovery(b)
|
|
242
|
+
r_elem = game.get_discovery(r)
|
|
243
|
+
a_str = str(a_elem) if a_elem else a
|
|
244
|
+
b_str = str(b_elem) if b_elem else b
|
|
245
|
+
r_str = format_element(r_elem) if r_elem else r
|
|
246
|
+
lines.append(f" {a_str} + {b_str} = {r_str}")
|
|
247
|
+
return "\n".join(lines)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def do_list(game) -> str:
|
|
251
|
+
discoveries = game.get_discoveries()
|
|
252
|
+
header = f" Discovered {len(discoveries)} elements:"
|
|
253
|
+
lines = [f" {format_element(e)}" for e in discoveries]
|
|
254
|
+
return header + "\n" + "\n".join(lines)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def do_history() -> str:
|
|
258
|
+
if not _history:
|
|
259
|
+
return " No combinations tried yet."
|
|
260
|
+
lines = []
|
|
261
|
+
for i, (a, b, r) in enumerate(_history, 1):
|
|
262
|
+
lines.append(f" {i}. {a} + {b} = {r}")
|
|
263
|
+
return "\n".join(lines)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
async def do_crawl(game, first_name: str, second_name: str):
|
|
267
|
+
"""Combine two elements, then iteratively combine results with all inputs until nothing new."""
|
|
268
|
+
first = _resolve_element(game, first_name)
|
|
269
|
+
second = _resolve_element(game, second_name)
|
|
270
|
+
pool = {first.name: first, second.name: second}
|
|
271
|
+
tried = set()
|
|
272
|
+
generation = 0
|
|
273
|
+
|
|
274
|
+
print(f" Crawling from {_color(str(first), BOLD)} and {_color(str(second), BOLD)}...")
|
|
275
|
+
print(f" (Ctrl+C to stop)\n")
|
|
276
|
+
|
|
277
|
+
while True:
|
|
278
|
+
generation += 1
|
|
279
|
+
names = sorted(pool.keys())
|
|
280
|
+
new_pairs = []
|
|
281
|
+
for i in range(len(names)):
|
|
282
|
+
for j in range(i, len(names)):
|
|
283
|
+
key = tuple(sorted([names[i], names[j]]))
|
|
284
|
+
if key not in tried:
|
|
285
|
+
new_pairs.append((pool[names[i]], pool[names[j]]))
|
|
286
|
+
tried.add(key)
|
|
287
|
+
|
|
288
|
+
if not new_pairs:
|
|
289
|
+
print(f"\n Exhausted all pairs. {len(pool)} elements in pool.")
|
|
290
|
+
break
|
|
291
|
+
|
|
292
|
+
print(f" --- Generation {generation}: {len(new_pairs)} new pairs to try ---")
|
|
293
|
+
|
|
294
|
+
# Snapshot pool names before running pairs
|
|
295
|
+
before = set(pool.keys())
|
|
296
|
+
await _combine_pairs(game, new_pairs)
|
|
297
|
+
|
|
298
|
+
# Check pair cache for new elements produced this generation
|
|
299
|
+
new_elements = []
|
|
300
|
+
for a, b in new_pairs:
|
|
301
|
+
key = tuple(sorted([a.name, b.name]))
|
|
302
|
+
result = _pair_cache.get(key)
|
|
303
|
+
if result and result.name and result.name not in pool:
|
|
304
|
+
pool[result.name] = result
|
|
305
|
+
new_elements.append(result)
|
|
306
|
+
|
|
307
|
+
new_count = len(new_elements)
|
|
308
|
+
print(f" +{new_count} new ({len(pool)} in pool)\n")
|
|
309
|
+
|
|
310
|
+
if new_count == 0 or _cancelled:
|
|
311
|
+
if _cancelled:
|
|
312
|
+
print(f" Stopped.")
|
|
313
|
+
else:
|
|
314
|
+
print(f" No new discoveries. Stopping.")
|
|
315
|
+
break
|
|
316
|
+
|
|
317
|
+
print(f" Final pool ({len(pool)}):")
|
|
318
|
+
for name in sorted(pool.keys()):
|
|
319
|
+
print(f" {pool[name]}")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
async def do_exhaust(game, name: str):
|
|
323
|
+
"""Combine an element with every discovered element."""
|
|
324
|
+
target = _resolve_element(game, name)
|
|
325
|
+
others = list(game.get_discoveries())
|
|
326
|
+
pairs = [(target, o) for o in others if o.name != target.name]
|
|
327
|
+
print(f" Combining {_color(str(target), BOLD)} with {len(pairs)} elements...")
|
|
328
|
+
await _confirm_and_run_pairs(game, pairs)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
_BULK_WARN_THRESHOLD = 200
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
_cancelled = False
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
async def _combine_pairs(game, pairs: list[tuple]):
|
|
338
|
+
"""Combine a list of (element, element) pairs with light parallelism."""
|
|
339
|
+
global _cancelled
|
|
340
|
+
_cancelled = False
|
|
341
|
+
loop = asyncio.get_event_loop()
|
|
342
|
+
original_handler = None
|
|
343
|
+
|
|
344
|
+
def on_sigint():
|
|
345
|
+
global _cancelled
|
|
346
|
+
_cancelled = True
|
|
347
|
+
|
|
348
|
+
# Install SIGINT handler that sets flag instead of raising
|
|
349
|
+
import signal
|
|
350
|
+
try:
|
|
351
|
+
original_handler = loop.add_signal_handler(signal.SIGINT, on_sigint)
|
|
352
|
+
except NotImplementedError:
|
|
353
|
+
pass # Windows
|
|
354
|
+
|
|
355
|
+
total = len(pairs)
|
|
356
|
+
new_count = 0
|
|
357
|
+
nothing_count = 0
|
|
358
|
+
done_count = 0
|
|
359
|
+
known_names = {e.name for e in game.get_discoveries()}
|
|
360
|
+
sem = asyncio.Semaphore(API_CONCURRENCY)
|
|
361
|
+
lock = asyncio.Lock()
|
|
362
|
+
|
|
363
|
+
async def process(a, b):
|
|
364
|
+
nonlocal new_count, nothing_count, done_count
|
|
365
|
+
try:
|
|
366
|
+
result = await _cached_pair(game, a, b)
|
|
367
|
+
except Exception as e:
|
|
368
|
+
done_count += 1
|
|
369
|
+
print(f" [{done_count}/{total}] {a} + {b} = {_color(f'Error: {e}', RED)}")
|
|
370
|
+
return
|
|
371
|
+
done_count += 1
|
|
372
|
+
if result.name is not None:
|
|
373
|
+
for elem in (a, b):
|
|
374
|
+
game._update_discoveries(
|
|
375
|
+
name=elem.name, emoji=elem.emoji, is_first_discovery=False
|
|
376
|
+
)
|
|
377
|
+
result_display = result.name if result.name else "Nothing"
|
|
378
|
+
_history.append((a.name, b.name, result_display))
|
|
379
|
+
if result.name is None:
|
|
380
|
+
nothing_count += 1
|
|
381
|
+
else:
|
|
382
|
+
tag = ""
|
|
383
|
+
if result.name not in known_names:
|
|
384
|
+
tag = " " + _color("[NEW]", BOLD + GREEN)
|
|
385
|
+
new_count += 1
|
|
386
|
+
known_names.add(result.name)
|
|
387
|
+
print(f" [{done_count}/{total}] {a} + {b} = {format_element(result)}{tag}")
|
|
388
|
+
|
|
389
|
+
# Process in batches of API_CONCURRENCY to avoid overwhelming the rate limiter
|
|
390
|
+
for i in range(0, len(pairs), API_CONCURRENCY):
|
|
391
|
+
if _cancelled:
|
|
392
|
+
break
|
|
393
|
+
batch = pairs[i:i + API_CONCURRENCY]
|
|
394
|
+
await asyncio.gather(*(process(a, b) for a, b in batch))
|
|
395
|
+
|
|
396
|
+
# Restore default SIGINT handler
|
|
397
|
+
try:
|
|
398
|
+
loop.remove_signal_handler(signal.SIGINT)
|
|
399
|
+
except (NotImplementedError, ValueError):
|
|
400
|
+
pass
|
|
401
|
+
|
|
402
|
+
if _cancelled:
|
|
403
|
+
print(f"\n Cancelled. {_color(str(new_count), GREEN)} new, {nothing_count} nothing, {done_count}/{total} tried.")
|
|
404
|
+
else:
|
|
405
|
+
print(f"\n Done. {_color(str(new_count), GREEN)} new, {nothing_count} nothing, {total} tried.")
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
async def _confirm_and_run_pairs(game, pairs: list[tuple]):
|
|
409
|
+
"""Warn if too many pairs, then run them."""
|
|
410
|
+
if len(pairs) > _BULK_WARN_THRESHOLD:
|
|
411
|
+
print(f"\n {_color(f'Warning: this will make {len(pairs)} API requests.', YELLOW)}")
|
|
412
|
+
try:
|
|
413
|
+
answer = input(" Continue? [y/N] ").strip().lower()
|
|
414
|
+
except (EOFError, KeyboardInterrupt):
|
|
415
|
+
print("\n Cancelled.")
|
|
416
|
+
return
|
|
417
|
+
if answer not in ("y", "yes"):
|
|
418
|
+
print(" Cancelled.")
|
|
419
|
+
return
|
|
420
|
+
print()
|
|
421
|
+
await _combine_pairs(game, pairs)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
async def do_permute(game, query: str):
|
|
425
|
+
"""Combine every pair of elements matching the query with each other."""
|
|
426
|
+
matches = _match_elements(game, query)
|
|
427
|
+
if not matches:
|
|
428
|
+
print(" No elements match that query.")
|
|
429
|
+
return
|
|
430
|
+
if len(matches) == 1:
|
|
431
|
+
print(f" Only one match: {format_element(matches[0])}. Need at least two.")
|
|
432
|
+
return
|
|
433
|
+
|
|
434
|
+
n = len(matches)
|
|
435
|
+
pairs = [(matches[i], matches[j]) for i in range(n) for j in range(i + 1, n)]
|
|
436
|
+
print(f" {n} elements match, {len(pairs)} unique pairs:")
|
|
437
|
+
for m in matches:
|
|
438
|
+
print(f" {format_element(m)}")
|
|
439
|
+
await _confirm_and_run_pairs(game, pairs)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
async def do_cross(game, left_query: str, right_query: str):
|
|
443
|
+
"""Cross-combine all elements matching left_query with all matching right_query."""
|
|
444
|
+
left = _match_elements(game, left_query)
|
|
445
|
+
right = _match_elements(game, right_query)
|
|
446
|
+
if not left:
|
|
447
|
+
print(f" No elements match: {left_query}")
|
|
448
|
+
return
|
|
449
|
+
if not right:
|
|
450
|
+
print(f" No elements match: {right_query}")
|
|
451
|
+
return
|
|
452
|
+
|
|
453
|
+
# Build pairs, skipping duplicates (a+b == b+a)
|
|
454
|
+
seen = set()
|
|
455
|
+
pairs = []
|
|
456
|
+
for a in left:
|
|
457
|
+
for b in right:
|
|
458
|
+
if a.name == b.name:
|
|
459
|
+
continue
|
|
460
|
+
key = tuple(sorted([a.name, b.name]))
|
|
461
|
+
if key not in seen:
|
|
462
|
+
seen.add(key)
|
|
463
|
+
pairs.append((a, b))
|
|
464
|
+
|
|
465
|
+
if not pairs:
|
|
466
|
+
print(" No valid pairs (all matches overlap).")
|
|
467
|
+
return
|
|
468
|
+
|
|
469
|
+
print(f" Left ({len(left)}): {', '.join(str(e) for e in left[:10])}{'...' if len(left) > 10 else ''}")
|
|
470
|
+
print(f" Right ({len(right)}): {', '.join(str(e) for e in right[:10])}{'...' if len(right) > 10 else ''}")
|
|
471
|
+
print(f" {len(pairs)} unique pairs")
|
|
472
|
+
await _confirm_and_run_pairs(game, pairs)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
# ---------------------------------------------------------------------------
|
|
476
|
+
# Infinibrowser integration
|
|
477
|
+
# ---------------------------------------------------------------------------
|
|
478
|
+
_IB_BASE = "https://infinibrowser.wiki/api"
|
|
479
|
+
_IB_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
480
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
_ib_cache: dict[str, dict | None] = {}
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _ib_request(path: str, params: dict) -> dict | None:
|
|
487
|
+
"""Raw Infinibrowser fetch with caching. Returns parsed JSON or None on error."""
|
|
488
|
+
qs = "&".join(f"{k}={urllib.request.quote(str(v))}" for k, v in params.items())
|
|
489
|
+
cache_key = f"{path}?{qs}"
|
|
490
|
+
if cache_key in _ib_cache:
|
|
491
|
+
return _ib_cache[cache_key]
|
|
492
|
+
url = f"{_IB_BASE}/{path}?{qs}"
|
|
493
|
+
req = urllib.request.Request(url, headers={"User-Agent": _IB_UA})
|
|
494
|
+
try:
|
|
495
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
496
|
+
result = json.loads(resp.read())
|
|
497
|
+
except Exception:
|
|
498
|
+
return None # don't cache errors
|
|
499
|
+
_ib_cache[cache_key] = result
|
|
500
|
+
return result
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _ib_fetch(path: str, params: dict) -> dict | None:
|
|
504
|
+
"""Fetch from the Infinibrowser API. Prints errors."""
|
|
505
|
+
result = _ib_request(path, params)
|
|
506
|
+
if result is None:
|
|
507
|
+
print(f" {_color('Infinibrowser request failed', RED)}")
|
|
508
|
+
return result
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _ib_fetch_quiet(path: str, params: dict) -> dict | None:
|
|
512
|
+
"""Fetch from the Infinibrowser API. Silent on errors."""
|
|
513
|
+
return _ib_request(path, params)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _refresh_discoveries(game):
|
|
517
|
+
"""Reload in-memory discoveries from disk."""
|
|
518
|
+
raw = game._get_raw_discoveries()
|
|
519
|
+
game._discoveries = [
|
|
520
|
+
Element(name=d["name"], emoji=d.get("emoji"), is_first_discovery=d.get("is_first_discovery"))
|
|
521
|
+
for d in raw
|
|
522
|
+
]
|
|
523
|
+
game.discoveries = game._discoveries.copy()
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _import_from_infinibrowser(game, name: str) -> str:
|
|
527
|
+
"""Look up an element on Infinibrowser, show its lineage, and import into discoveries."""
|
|
528
|
+
data = _ib_fetch("item", {"id": name})
|
|
529
|
+
if data is None:
|
|
530
|
+
return ""
|
|
531
|
+
if "code" in data:
|
|
532
|
+
return f" {_color('Not found', DIM)} on Infinibrowser: {name}"
|
|
533
|
+
|
|
534
|
+
emoji = data.get("emoji", "")
|
|
535
|
+
depth = data.get("depth", "?")
|
|
536
|
+
print(f" Found: {emoji} {data['text']} (depth {depth})")
|
|
537
|
+
|
|
538
|
+
lineage = _ib_fetch("recipe", {"id": name})
|
|
539
|
+
if lineage is None:
|
|
540
|
+
return ""
|
|
541
|
+
steps = lineage.get("steps", [])
|
|
542
|
+
if not steps:
|
|
543
|
+
return f" No lineage available for {name}."
|
|
544
|
+
|
|
545
|
+
print(f" Lineage ({len(steps)} steps):")
|
|
546
|
+
imported = set()
|
|
547
|
+
for step in steps:
|
|
548
|
+
a_name, a_emoji = step["a"]["id"], step["a"]["emoji"]
|
|
549
|
+
b_name, b_emoji = step["b"]["id"], step["b"]["emoji"]
|
|
550
|
+
r_name, r_emoji = step["result"]["id"], step["result"]["emoji"]
|
|
551
|
+
print(f" {a_emoji} {a_name} + {b_emoji} {b_name} = {r_emoji} {r_name}")
|
|
552
|
+
_record_recipe(r_name, a_name, b_name)
|
|
553
|
+
for elem_name, elem_emoji in [(a_name, a_emoji), (b_name, b_emoji), (r_name, r_emoji)]:
|
|
554
|
+
if elem_name not in imported:
|
|
555
|
+
game._update_discoveries(
|
|
556
|
+
name=elem_name, emoji=elem_emoji, is_first_discovery=False
|
|
557
|
+
)
|
|
558
|
+
imported.add(elem_name)
|
|
559
|
+
|
|
560
|
+
_refresh_discoveries(game)
|
|
561
|
+
return f" Imported {_color(str(len(imported)), GREEN)} elements into discoveries."
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _import_from_save(game, path: str) -> str:
|
|
565
|
+
"""Import elements and recipes from an .ic save file into discoveries."""
|
|
566
|
+
try:
|
|
567
|
+
with gzip.open(path, "rt", encoding="utf-8") as f:
|
|
568
|
+
save = json.load(f)
|
|
569
|
+
except Exception as e:
|
|
570
|
+
return f" {_color(f'Error reading save file: {e}', RED)}"
|
|
571
|
+
|
|
572
|
+
items = save.get("items", [])
|
|
573
|
+
if not items:
|
|
574
|
+
return " No items in save file."
|
|
575
|
+
|
|
576
|
+
# Build id-to-name lookup
|
|
577
|
+
id_to_item = {item["id"]: item for item in items}
|
|
578
|
+
|
|
579
|
+
imported_count = 0
|
|
580
|
+
recipe_count = 0
|
|
581
|
+
for item in items:
|
|
582
|
+
name = item["text"]
|
|
583
|
+
emoji = item.get("emoji", "")
|
|
584
|
+
is_discovery = item.get("discovery", False)
|
|
585
|
+
result = game._update_discoveries(
|
|
586
|
+
name=name, emoji=emoji, is_first_discovery=is_discovery
|
|
587
|
+
)
|
|
588
|
+
if result is not None:
|
|
589
|
+
imported_count += 1
|
|
590
|
+
# Import recipes
|
|
591
|
+
for recipe in item.get("recipes", []):
|
|
592
|
+
if len(recipe) == 2 and recipe[0] in id_to_item and recipe[1] in id_to_item:
|
|
593
|
+
a_name = id_to_item[recipe[0]]["text"]
|
|
594
|
+
b_name = id_to_item[recipe[1]]["text"]
|
|
595
|
+
_record_recipe(name, a_name, b_name)
|
|
596
|
+
recipe_count += 1
|
|
597
|
+
|
|
598
|
+
_refresh_discoveries(game)
|
|
599
|
+
total = len(items)
|
|
600
|
+
return (f" Loaded {_color(str(total), GREEN)} elements "
|
|
601
|
+
f"({imported_count} new) with {recipe_count} recipes from {_color(path, BOLD)}")
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def do_import(game, arg: str) -> str:
|
|
605
|
+
"""Import from Infinibrowser (element name) or .ic save file (path)."""
|
|
606
|
+
if arg.endswith(".ic") or os.path.sep in arg:
|
|
607
|
+
return _import_from_save(game, arg)
|
|
608
|
+
return _import_from_infinibrowser(game, arg)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
_BASE_ELEMENTS = {"Water", "Fire", "Wind", "Earth"}
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _fill_missing_recipes(game):
|
|
615
|
+
"""Fetch lineages from Infinibrowser for elements missing recipes.
|
|
616
|
+
|
|
617
|
+
When a lineage is fetched, its intermediate elements get recipes too,
|
|
618
|
+
so we re-check the missing set after each fetch to skip already-filled items.
|
|
619
|
+
"""
|
|
620
|
+
recipes = _load_recipes()
|
|
621
|
+
name_set = {e.name for e in game.get_discoveries()}
|
|
622
|
+
missing = {e.name for e in game.get_discoveries()
|
|
623
|
+
if e.name not in _BASE_ELEMENTS and e.name not in recipes}
|
|
624
|
+
if not missing:
|
|
625
|
+
print(" All elements have recipes.")
|
|
626
|
+
return
|
|
627
|
+
|
|
628
|
+
total = len(missing)
|
|
629
|
+
print(f" {total} elements missing recipes. Fetching from Infinibrowser...")
|
|
630
|
+
print(f" (Ctrl+C to stop early)\n")
|
|
631
|
+
fetched = 0
|
|
632
|
+
skipped = 0
|
|
633
|
+
failed = set()
|
|
634
|
+
processed = 0
|
|
635
|
+
queue = sorted(missing)
|
|
636
|
+
try:
|
|
637
|
+
for name in queue:
|
|
638
|
+
# Re-check: a previous lineage may have filled this one
|
|
639
|
+
recipes = _load_recipes()
|
|
640
|
+
if name in recipes or name in failed:
|
|
641
|
+
skipped += 1
|
|
642
|
+
continue
|
|
643
|
+
processed += 1
|
|
644
|
+
remaining = total - fetched - skipped - len(failed)
|
|
645
|
+
print(f"\r [{processed}/{total}] {name} ({remaining} remaining)... ", end="", flush=True)
|
|
646
|
+
data = _ib_fetch_quiet("item", {"id": name})
|
|
647
|
+
if data is None or "code" in data:
|
|
648
|
+
failed.add(name)
|
|
649
|
+
continue
|
|
650
|
+
lineage = _ib_fetch_quiet("recipe", {"id": name})
|
|
651
|
+
if lineage is None:
|
|
652
|
+
failed.add(name)
|
|
653
|
+
continue
|
|
654
|
+
for step in lineage.get("steps", []):
|
|
655
|
+
a_name, a_emoji = step["a"]["id"], step["a"]["emoji"]
|
|
656
|
+
b_name, b_emoji = step["b"]["id"], step["b"]["emoji"]
|
|
657
|
+
r_name, r_emoji = step["result"]["id"], step["result"]["emoji"]
|
|
658
|
+
_record_recipe(r_name, a_name, b_name)
|
|
659
|
+
for elem_name, elem_emoji in [(a_name, a_emoji), (b_name, b_emoji), (r_name, r_emoji)]:
|
|
660
|
+
if elem_name not in name_set:
|
|
661
|
+
game._update_discoveries(
|
|
662
|
+
name=elem_name, emoji=elem_emoji, is_first_discovery=False
|
|
663
|
+
)
|
|
664
|
+
name_set.add(elem_name)
|
|
665
|
+
fetched += 1
|
|
666
|
+
time.sleep(0.5) # rate limit Infinibrowser
|
|
667
|
+
except KeyboardInterrupt:
|
|
668
|
+
print(f"\n Stopped early.")
|
|
669
|
+
_refresh_discoveries(game)
|
|
670
|
+
print(f"\n Fetched {fetched} lineages, {skipped} already filled by prior lineages.", end="")
|
|
671
|
+
if failed:
|
|
672
|
+
print(f" {_color(str(len(failed)), YELLOW)} not found on Infinibrowser.")
|
|
673
|
+
else:
|
|
674
|
+
print()
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def do_unfilled(game) -> str:
|
|
678
|
+
"""List elements that have no recipes (excluding base elements)."""
|
|
679
|
+
recipes = _load_recipes()
|
|
680
|
+
discoveries = game.get_discoveries()
|
|
681
|
+
missing = [e for e in discoveries if e.name not in _BASE_ELEMENTS and e.name not in recipes]
|
|
682
|
+
if not missing:
|
|
683
|
+
return " All elements have recipes."
|
|
684
|
+
lines = [f" {len(missing)} elements without recipes:\n"]
|
|
685
|
+
for e in missing:
|
|
686
|
+
lines.append(f" {format_element(e)}")
|
|
687
|
+
return "\n".join(lines)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def do_export(game, path: str = EXPORT_PATH) -> str:
|
|
691
|
+
"""Export discoveries to an Infinite Craft .ic save file.
|
|
692
|
+
|
|
693
|
+
Only includes elements that have recipes or are base elements.
|
|
694
|
+
Use /fill first to fetch missing recipes from Infinibrowser.
|
|
695
|
+
"""
|
|
696
|
+
recipes = _load_recipes()
|
|
697
|
+
discoveries = game.get_discoveries()
|
|
698
|
+
|
|
699
|
+
# Build export, only including elements that have recipes or are base
|
|
700
|
+
name_to_id = {}
|
|
701
|
+
items = []
|
|
702
|
+
idx = 0
|
|
703
|
+
for elem in discoveries:
|
|
704
|
+
if elem.name not in _BASE_ELEMENTS and elem.name not in recipes:
|
|
705
|
+
continue
|
|
706
|
+
name_to_id[elem.name] = idx
|
|
707
|
+
item = {"id": idx, "text": elem.name, "emoji": elem.emoji or ""}
|
|
708
|
+
if elem.is_first_discovery:
|
|
709
|
+
item["discovery"] = True
|
|
710
|
+
items.append(item)
|
|
711
|
+
idx += 1
|
|
712
|
+
|
|
713
|
+
# Attach recipes using local IDs
|
|
714
|
+
for item in items:
|
|
715
|
+
name = item["text"]
|
|
716
|
+
if name in recipes:
|
|
717
|
+
item_recipes = []
|
|
718
|
+
for pair in recipes[name]:
|
|
719
|
+
a, b = pair[0], pair[1]
|
|
720
|
+
if a in name_to_id and b in name_to_id:
|
|
721
|
+
item_recipes.append([name_to_id[a], name_to_id[b]])
|
|
722
|
+
if item_recipes:
|
|
723
|
+
item["recipes"] = item_recipes
|
|
724
|
+
|
|
725
|
+
now = int(time.time() * 1000)
|
|
726
|
+
save = {
|
|
727
|
+
"name": "CLI Export",
|
|
728
|
+
"version": "1.0",
|
|
729
|
+
"created": now,
|
|
730
|
+
"updated": now,
|
|
731
|
+
"instances": [],
|
|
732
|
+
"items": items,
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
excluded = len(discoveries) - len(items)
|
|
736
|
+
with gzip.open(path, "wt", encoding="utf-8") as f:
|
|
737
|
+
json.dump(save, f)
|
|
738
|
+
|
|
739
|
+
msg = f" Exported {_color(str(len(items)), GREEN)} elements to {_color(path, BOLD)}"
|
|
740
|
+
if excluded:
|
|
741
|
+
msg += f"\n {_color(str(excluded), YELLOW)} elements excluded (no recipes — use /fill to fetch them)"
|
|
742
|
+
return msg
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def do_help() -> str:
|
|
746
|
+
return """ Commands:
|
|
747
|
+
<element> + <element> Combine two elements
|
|
748
|
+
<element> ++ <element> Combine & crawl: iterate until no new discoveries
|
|
749
|
+
<element> + | <query> Combine element with all matching discoveries
|
|
750
|
+
<query> * <query> Cross-combine all matches from both queries
|
|
751
|
+
/search <query> Search discoveries (supports * ? wildcards, ^ for new)
|
|
752
|
+
/recipe <element> Show shortest recipe from base elements
|
|
753
|
+
/list List all discovered elements
|
|
754
|
+
/exhaust <element> Combine element with all discoveries
|
|
755
|
+
/crawl <el> + <el> Same as ++ (alternate syntax)
|
|
756
|
+
/permute <query> Combine all matching elements with each other
|
|
757
|
+
/import <element|file.ic> Import from Infinibrowser or .ic save file
|
|
758
|
+
/fill Fetch missing recipes from Infinibrowser
|
|
759
|
+
/unfilled List elements without recipes
|
|
760
|
+
/export [path] Export discoveries as .ic save file
|
|
761
|
+
/history Show combinations tried this session
|
|
762
|
+
/help Show this help
|
|
763
|
+
/quit Exit"""
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
# ---------------------------------------------------------------------------
|
|
767
|
+
# Interactive REPL
|
|
768
|
+
# ---------------------------------------------------------------------------
|
|
769
|
+
async def interactive_mode():
|
|
770
|
+
print(_color("=== Infinite Craft CLI ===", BOLD + CYAN))
|
|
771
|
+
print()
|
|
772
|
+
|
|
773
|
+
async with InfiniteCraft(discoveries_storage=DISCOVERIES_PATH, api_rate_limit=API_RATE_LIMIT) as game:
|
|
774
|
+
starters = " ".join(str(e) for e in game.discoveries[:4])
|
|
775
|
+
print(f" Starting elements: {starters}")
|
|
776
|
+
total = len(game.get_discoveries())
|
|
777
|
+
print(f" Discovered: {_color(str(total), GREEN)} elements")
|
|
778
|
+
print(f" Type {_color('/help', YELLOW)} for commands\n")
|
|
779
|
+
|
|
780
|
+
while True:
|
|
781
|
+
try:
|
|
782
|
+
line = input(_color("craft> ", CYAN)).strip()
|
|
783
|
+
except (EOFError, KeyboardInterrupt):
|
|
784
|
+
print("\nGoodbye!")
|
|
785
|
+
break
|
|
786
|
+
|
|
787
|
+
if not line:
|
|
788
|
+
continue
|
|
789
|
+
|
|
790
|
+
if line in ("/quit", "/exit"):
|
|
791
|
+
print("Goodbye!")
|
|
792
|
+
break
|
|
793
|
+
elif line == "/help":
|
|
794
|
+
print(do_help())
|
|
795
|
+
elif line.startswith("/search"):
|
|
796
|
+
query = line[len("/search"):].strip()
|
|
797
|
+
if not query:
|
|
798
|
+
print(" Usage: /search <query>")
|
|
799
|
+
else:
|
|
800
|
+
print(do_search(game, query))
|
|
801
|
+
elif line.startswith("/recipe"):
|
|
802
|
+
name = line[len("/recipe"):].strip()
|
|
803
|
+
if not name:
|
|
804
|
+
print(" Usage: /recipe <element>")
|
|
805
|
+
else:
|
|
806
|
+
print(do_recipe(game, name))
|
|
807
|
+
elif line == "/list":
|
|
808
|
+
print(do_list(game))
|
|
809
|
+
elif line.startswith("/permute"):
|
|
810
|
+
query = line[len("/permute"):].strip()
|
|
811
|
+
if not query:
|
|
812
|
+
print(" Usage: /permute <query>")
|
|
813
|
+
else:
|
|
814
|
+
await do_permute(game, query)
|
|
815
|
+
elif line.startswith("/import"):
|
|
816
|
+
name = line[len("/import"):].strip()
|
|
817
|
+
if not name:
|
|
818
|
+
print(" Usage: /import <element>")
|
|
819
|
+
else:
|
|
820
|
+
print(do_import(game, name))
|
|
821
|
+
elif line.startswith("/unfilled"):
|
|
822
|
+
print(do_unfilled(game))
|
|
823
|
+
elif line.startswith("/fill"):
|
|
824
|
+
_fill_missing_recipes(game)
|
|
825
|
+
elif line.startswith("/export"):
|
|
826
|
+
path = line[len("/export"):].strip() or EXPORT_PATH
|
|
827
|
+
print(do_export(game, path))
|
|
828
|
+
elif line.startswith("/exhaust"):
|
|
829
|
+
name = line[len("/exhaust"):].strip()
|
|
830
|
+
if not name:
|
|
831
|
+
print(" Usage: /exhaust <element>")
|
|
832
|
+
else:
|
|
833
|
+
await do_exhaust(game, name)
|
|
834
|
+
elif line.startswith("/crawl"):
|
|
835
|
+
rest = line[len("/crawl"):].strip()
|
|
836
|
+
if "+" not in rest:
|
|
837
|
+
print(" Usage: /crawl <element> + <element>")
|
|
838
|
+
else:
|
|
839
|
+
parts = rest.split("+", 1)
|
|
840
|
+
first, second = parts[0].strip(), parts[1].strip()
|
|
841
|
+
if not first or not second:
|
|
842
|
+
print(" Usage: /crawl <element> + <element>")
|
|
843
|
+
else:
|
|
844
|
+
await do_crawl(game, first, second)
|
|
845
|
+
elif line == "/history":
|
|
846
|
+
print(do_history())
|
|
847
|
+
elif "++" in line:
|
|
848
|
+
parts = line.split("++", 1)
|
|
849
|
+
first = parts[0].strip()
|
|
850
|
+
second = parts[1].strip()
|
|
851
|
+
if not first or not second:
|
|
852
|
+
print(" Usage: <element> ++ <element>")
|
|
853
|
+
else:
|
|
854
|
+
await do_crawl(game, first, second)
|
|
855
|
+
elif "+|" in line:
|
|
856
|
+
# element + | query
|
|
857
|
+
parts = line.split("+|", 1)
|
|
858
|
+
name = parts[0].strip()
|
|
859
|
+
query = parts[1].strip()
|
|
860
|
+
if not name or not query:
|
|
861
|
+
print(" Usage: <element> + | <query>")
|
|
862
|
+
else:
|
|
863
|
+
target = _resolve_element(game, name)
|
|
864
|
+
others = _match_elements(game, query)
|
|
865
|
+
if not others:
|
|
866
|
+
print(f" No elements match: {query}")
|
|
867
|
+
else:
|
|
868
|
+
pairs = [(target, o) for o in others if o.name != target.name]
|
|
869
|
+
print(f" Combining {_color(str(target), BOLD)} with {len(pairs)} elements matching {_color(query, YELLOW)}...")
|
|
870
|
+
await _confirm_and_run_pairs(game, pairs)
|
|
871
|
+
elif " * " in line:
|
|
872
|
+
# query * query
|
|
873
|
+
parts = line.split(" * ", 1)
|
|
874
|
+
left_q = parts[0].strip()
|
|
875
|
+
right_q = parts[1].strip()
|
|
876
|
+
if not left_q or not right_q:
|
|
877
|
+
print(" Usage: <query> * <query>")
|
|
878
|
+
else:
|
|
879
|
+
await do_cross(game, left_q, right_q)
|
|
880
|
+
elif "+" in line:
|
|
881
|
+
parts = line.split("+", 1)
|
|
882
|
+
first = parts[0].strip()
|
|
883
|
+
second = parts[1].strip()
|
|
884
|
+
if not first or not second:
|
|
885
|
+
print(" Usage: <element> + <element>")
|
|
886
|
+
else:
|
|
887
|
+
print(await do_combine(game, first, second))
|
|
888
|
+
else:
|
|
889
|
+
print(f" Unknown input. Type {_color('/help', YELLOW)} for commands.")
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
# ---------------------------------------------------------------------------
|
|
893
|
+
# Non-interactive CLI
|
|
894
|
+
# ---------------------------------------------------------------------------
|
|
895
|
+
async def noninteractive_mode(args):
|
|
896
|
+
async with InfiniteCraft(discoveries_storage=DISCOVERIES_PATH, api_rate_limit=API_RATE_LIMIT) as game:
|
|
897
|
+
if args.command == "combine":
|
|
898
|
+
print(await do_combine(game, args.first, args.second))
|
|
899
|
+
elif args.command == "search":
|
|
900
|
+
print(do_search(game, args.query))
|
|
901
|
+
elif args.command == "list":
|
|
902
|
+
print(do_list(game))
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
# ---------------------------------------------------------------------------
|
|
906
|
+
# Entry point
|
|
907
|
+
# ---------------------------------------------------------------------------
|
|
908
|
+
def main():
|
|
909
|
+
parser = argparse.ArgumentParser(description="Infinite Craft CLI")
|
|
910
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
911
|
+
|
|
912
|
+
combine_p = subparsers.add_parser("combine", help="Combine two elements")
|
|
913
|
+
combine_p.add_argument("first", help="First element name")
|
|
914
|
+
combine_p.add_argument("second", help="Second element name")
|
|
915
|
+
|
|
916
|
+
search_p = subparsers.add_parser("search", help="Search discovered elements")
|
|
917
|
+
search_p.add_argument("query", help="Search substring")
|
|
918
|
+
|
|
919
|
+
subparsers.add_parser("list", help="List all discovered elements")
|
|
920
|
+
|
|
921
|
+
args = parser.parse_args()
|
|
922
|
+
|
|
923
|
+
if args.command is None:
|
|
924
|
+
asyncio.run(interactive_mode())
|
|
925
|
+
else:
|
|
926
|
+
asyncio.run(noninteractive_mode(args))
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
if __name__ == "__main__":
|
|
930
|
+
main()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""User data directory management for Infinite Craft CLI."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_data_dir() -> str:
|
|
7
|
+
"""Return the path to ~/.infinite-craft-cli/, creating it if needed."""
|
|
8
|
+
data_dir = os.path.join(os.path.expanduser("~"), ".infinite-craft-cli")
|
|
9
|
+
os.makedirs(data_dir, exist_ok=True)
|
|
10
|
+
return data_dir
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_DATA_DIR = get_data_dir()
|
|
14
|
+
|
|
15
|
+
DISCOVERIES_PATH = os.path.join(_DATA_DIR, "discoveries.json")
|
|
16
|
+
RECIPES_PATH = os.path.join(_DATA_DIR, "recipes.json")
|
|
17
|
+
EXPORT_PATH = os.path.join(_DATA_DIR, "export.ic")
|