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.
@@ -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,6 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .DS_Store
@@ -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,3 @@
1
+ """Infinite Craft CLI — combine elements from the terminal."""
2
+
3
+ __version__ = "0.1.0"
@@ -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")