Skip to content

Commit 12d0618

Browse files
committed
Add CLI tool for one-shot game list fetch
Simple Python wrapper that: - Runs devilutionx-gamelist binary - Waits for results (configurable timeout) - Outputs human-readable format or JSON - Cleans up automatically Usage: ./gamelist_cli.py [-t TIMEOUT] [-v] [-j]
1 parent bc7d8cd commit 12d0618

1 file changed

Lines changed: 173 additions & 0 deletions

File tree

gamelist_cli.py

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
#!/usr/bin/env python3
2+
"""
3+
One-shot CLI tool to fetch and display current DevilutionX games.
4+
5+
This tool wraps the devilutionx-gamelist binary, which connects to the
6+
DevilutionX ZeroTier network to discover active public games.
7+
8+
Requirements:
9+
- The devilutionx-gamelist binary must be built first:
10+
cmake -S. -Bbuild -DCMAKE_BUILD_TYPE=Release
11+
cmake --build build -j
12+
13+
- Internet connectivity is required to reach ZeroTier infrastructure
14+
- First run may take longer as ZeroTier establishes network identity
15+
16+
The tool will timeout after 25 seconds by default if no games are found.
17+
This can happen if:
18+
- No public games are currently active
19+
- Network connectivity issues prevent reaching ZeroTier
20+
- The ZeroTier network is unavailable
21+
"""
22+
23+
import argparse
24+
import json
25+
import signal
26+
import subprocess
27+
import sys
28+
import tempfile
29+
import time
30+
from pathlib import Path
31+
from typing import Any
32+
33+
34+
GAME_TYPES = {
35+
"DRTL": "Diablo",
36+
"DSHR": "Diablo (spawn)",
37+
"HRTL": "Hellfire",
38+
"HSHR": "Hellfire (spawn)",
39+
"IRON": "Ironman",
40+
"MEMD": "Memorial",
41+
"DRDX": "Diablo X",
42+
"DWKD": "modDiablo",
43+
"HWKD": "modHellfire",
44+
}
45+
46+
DIFFICULTIES = ["Normal", "Nightmare", "Hell"]
47+
48+
49+
def format_game(game: dict[str, Any], verbose: bool = False) -> str:
50+
"""Format a game entry as a human-readable line."""
51+
game_id = str(game.get("id", "???")).upper()
52+
game_type = GAME_TYPES.get(str(game.get("type", "")), str(game.get("type", "???")))
53+
version = game.get("version", "?")
54+
diff_val = game.get("difficulty")
55+
difficulty = DIFFICULTIES[int(diff_val)] if diff_val in (0, 1, 2) else "?"
56+
players = game.get("players", [])
57+
assert isinstance(players, list)
58+
player_list = ", ".join(str(p) for p in players)
59+
60+
attrs = []
61+
if game.get("run_in_town"):
62+
attrs.append("RiT")
63+
if game.get("full_quests"):
64+
attrs.append("Quests")
65+
if game.get("theo_quest") and game.get("type") != "DRTL":
66+
attrs.append("Theo")
67+
if game.get("cow_quest") and game.get("type") != "DRTL":
68+
attrs.append("Cow")
69+
if game.get("friendly_fire"):
70+
attrs.append("FF")
71+
72+
tick_rate = game.get("tick_rate", 20)
73+
speed = ""
74+
if tick_rate == 30:
75+
speed = " Fast"
76+
elif tick_rate == 40:
77+
speed = " Faster"
78+
elif tick_rate == 50:
79+
speed = " Fastest"
80+
elif tick_rate not in (20, None):
81+
speed = f" speed:{tick_rate}"
82+
83+
attr_str = f" ({', '.join(attrs)})" if attrs else ""
84+
line = f"{game_id}: {game_type} {version}{speed} {difficulty}{attr_str} - {player_list}"
85+
86+
if verbose:
87+
line += f" [{game.get('address', '?')}]"
88+
89+
return line
90+
91+
92+
def main() -> int:
93+
parser = argparse.ArgumentParser(description="Fetch and display current DevilutionX games")
94+
parser.add_argument("-t", "--timeout", type=int, default=25, help="Timeout in seconds (default: 25)")
95+
parser.add_argument("-v", "--verbose", action="store_true", help="Show game addresses")
96+
parser.add_argument("-j", "--json", action="store_true", help="Output raw JSON")
97+
parser.add_argument("--binary", type=str, default="./build/devilutionx-gamelist",
98+
help="Path to devilutionx-gamelist binary")
99+
args = parser.parse_args()
100+
101+
binary = Path(args.binary)
102+
if not binary.exists():
103+
print(f"Error: Binary not found at {binary}", file=sys.stderr)
104+
print("Run: cmake -S. -Bbuild && cmake --build build -j", file=sys.stderr)
105+
return 1
106+
107+
with tempfile.TemporaryDirectory() as tmpdir:
108+
output_file = Path(tmpdir) / "gamelist.json"
109+
stderr_file = Path(tmpdir) / "stderr.log"
110+
111+
with open(stderr_file, "w") as stderr_log:
112+
proc = subprocess.Popen(
113+
[str(binary), str(output_file)],
114+
stdout=subprocess.DEVNULL,
115+
stderr=stderr_log,
116+
)
117+
118+
try:
119+
start = time.time()
120+
while time.time() - start < args.timeout:
121+
# Check if process died unexpectedly
122+
if proc.poll() is not None:
123+
break
124+
if output_file.exists():
125+
time.sleep(1) # Give it a moment to finish writing
126+
break
127+
time.sleep(0.5)
128+
129+
# Check for early process termination
130+
if proc.poll() is not None and proc.returncode != 0:
131+
stderr_content = stderr_file.read_text().strip()
132+
print("Error: devilutionx-gamelist failed to start", file=sys.stderr)
133+
if stderr_content:
134+
print(stderr_content, file=sys.stderr)
135+
return 1
136+
137+
if not output_file.exists():
138+
if args.verbose:
139+
stderr_content = stderr_file.read_text().strip()
140+
if stderr_content:
141+
print("--- Binary output ---", file=sys.stderr)
142+
print(stderr_content, file=sys.stderr)
143+
print("---", file=sys.stderr)
144+
print("No games found (timeout waiting for network/games)", file=sys.stderr)
145+
return 0
146+
147+
with open(output_file) as f:
148+
data = json.load(f)
149+
150+
if args.json:
151+
print(json.dumps(data, indent=2))
152+
else:
153+
games = data.get("games", [])
154+
if not games:
155+
print("No active games")
156+
else:
157+
print(f"Found {len(games)} game(s):\n")
158+
for game in games:
159+
print(format_game(game, args.verbose))
160+
161+
finally:
162+
if proc.poll() is None:
163+
proc.send_signal(signal.SIGTERM)
164+
try:
165+
proc.wait(timeout=5)
166+
except subprocess.TimeoutExpired:
167+
proc.kill()
168+
169+
return 0
170+
171+
172+
if __name__ == "__main__":
173+
sys.exit(main())

0 commit comments

Comments
 (0)