Skip to content

Commit fc09e76

Browse files
committed
feat: auto-legalize endpoint (POST /api/legalize)
1 parent 15e8e35 commit fc09e76

3 files changed

Lines changed: 189 additions & 13 deletions

File tree

API.md

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Base URL: `http://<your-ip>:8082`
66

77
**Full** (default): everything - GPSS server, web viewer, database, all endpoints below.
88

9-
**Legality-only**: just `POST /api/legality` and `GET /api/status`. No database, no UI.
9+
**Legality-only**: just `POST /api/legality`, `POST /api/legalize`, and `GET /api/status`. No database, no UI.
1010

1111
```bash
1212
LEGALITY_ONLY=1 docker compose up -d
@@ -40,6 +40,53 @@ curl -X POST http://192.168.1.3:8082/api/legality \
4040

4141
---
4242

43+
## POST /api/legalize
44+
45+
Auto-legalize a Pokemon using PKHeX. Tries to fix legality issues and returns the corrected bytes. Works in both modes.
46+
47+
| Header | Required | Description |
48+
|--------|----------|-------------|
49+
| `X-Generation` | Yes | `1`-`9`, `BDSP`, or `PLA` |
50+
| `X-Version` | No | Game version (`Scarlet`, `Diamond`, `Sun`, etc). Defaults to `Any` |
51+
52+
Body: raw `.pkm` bytes (max 1 MB).
53+
54+
```bash
55+
curl -X POST http://192.168.1.3:8082/api/legalize \
56+
-H "X-Generation: 7" \
57+
-H "X-Version: Sun" \
58+
--data-binary @my_pokemon.pkm
59+
```
60+
61+
Successfully fixed:
62+
```json
63+
{
64+
"legal": true,
65+
"modified": true,
66+
"pokemon": "<base64 corrected bytes>",
67+
"report_before": ["Invalid: Nickname does not match species name."],
68+
"report_after": []
69+
}
70+
```
71+
72+
Already legal (nothing to do):
73+
```json
74+
{"legal": true, "modified": false, "pokemon": null, "report_before": [], "report_after": []}
75+
```
76+
77+
Can't fix:
78+
```json
79+
{
80+
"legal": false,
81+
"modified": true,
82+
"pokemon": null,
83+
"report_before": ["Invalid: Move 1 PP is above the amount allowed (99)."],
84+
"report_after": ["Invalid: Move 1 PP is above the amount allowed (99)."]
85+
}
86+
```
87+
88+
---
89+
4390
## GET /api/pokemon
4491

4592
Search the database. Full mode only.

viewer/legality.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,47 @@ def _is_generic_gpss_error(msg: str) -> bool:
107107
return msg.strip().lower() == "gpss console returned an error"
108108

109109

110+
def _legalize_via_console(generation: str, pkm_bytes: bytes, version: str) -> dict:
111+
"""Run GpssConsole in legalize mode."""
112+
bin_path = gpss_console_bin()
113+
if not bin_path:
114+
raise FileNotFoundError("GpssConsole binary not found")
115+
116+
b64 = base64.b64encode(pkm_bytes).decode("ascii")
117+
proc = subprocess.run(
118+
[
119+
str(bin_path),
120+
"--mode",
121+
"legalize",
122+
"--pokemon",
123+
b64,
124+
"--generation",
125+
generation,
126+
"--ver",
127+
version,
128+
],
129+
capture_output=True,
130+
text=True,
131+
timeout=90,
132+
)
133+
stdout = (proc.stdout or "").strip()
134+
if not stdout:
135+
err = (proc.stderr or "").strip() or f"GpssConsole exited {proc.returncode}"
136+
raise RuntimeError(err)
137+
return json.loads(stdout)
138+
139+
140+
def legalize_pkm(generation: str, pkm_bytes: bytes, version: str) -> dict:
141+
"""Auto-legalize a Pokemon via GpssConsole."""
142+
gen = legality_generation(generation)
143+
for payload in _legality_payloads(pkm_bytes, generation):
144+
try:
145+
return _legalize_via_console(gen, payload, version)
146+
except (json.JSONDecodeError, RuntimeError, OSError) as err:
147+
return {"error": str(err)}
148+
return {"error": "Could not parse PKM for legalization"}
149+
150+
110151
def legality_check_pkm(
111152
generation: str, pkm_bytes: bytes, backend: str | None
112153
) -> dict:

viewer/routes.py

Lines changed: 100 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
gpss_console_bin,
2222
legality_check_pkm,
2323
legality_generation,
24+
legalize_pkm,
2425
)
2526
from pkm import (
2627
effective_nickname,
@@ -385,6 +386,8 @@ def do_POST(self) -> None:
385386
parsed = urlparse(self.path)
386387
if parsed.path == "/api/legality":
387388
return self._legality_check()
389+
if parsed.path == "/api/legalize":
390+
return self._legalize()
388391

389392
self.send_error(HTTPStatus.METHOD_NOT_ALLOWED)
390393

@@ -427,6 +430,51 @@ def _legality_check(self) -> None:
427430
except Exception as err:
428431
return self._json(500, {"error": f"legality check failed: {err}"})
429432

433+
def _legalize(self) -> None:
434+
"""Auto-legalize a Pokemon: POST raw PKM bytes + generation + version."""
435+
length = int(self.headers.get("Content-Length", 0))
436+
if not length:
437+
return self._json(400, {"error": "empty body"})
438+
if length > 1_000_000:
439+
return self._json(413, {"error": "payload too large"})
440+
441+
generation = self.headers.get("X-Generation", "").strip()
442+
if not generation:
443+
return self._json(400, {"error": "missing X-Generation header"})
444+
445+
version = self.headers.get("X-Version", "Any").strip()
446+
447+
pkm_bytes = self.rfile.read(length)
448+
449+
if gpss_console_bin() is None:
450+
return self._json(503, {"error": "legality engine not available"})
451+
452+
try:
453+
check = legality_check_pkm(generation, pkm_bytes, None)
454+
report_before = [
455+
line.strip()
456+
for line in check.get("report", [])
457+
if line and line.strip() and line.strip() != "Legal!"
458+
] if "error" not in check else []
459+
460+
result = legalize_pkm(generation, pkm_bytes, version)
461+
if "error" in result:
462+
return self._json(200, {"error": str(result["error"])})
463+
report_after = [
464+
line.strip()
465+
for line in result.get("report", [])
466+
if line and line.strip() and line.strip() != "Legal!"
467+
]
468+
return self._json(200, {
469+
"legal": bool(result.get("legal", False)),
470+
"modified": bool(result.get("ran", False)),
471+
"pokemon": result.get("pokemon"),
472+
"report_before": report_before,
473+
"report_after": report_after,
474+
})
475+
except Exception as err:
476+
return self._json(500, {"error": f"legalization failed: {err}"})
477+
430478
_SORT_ORDERS = {
431479
"recent": "upload_datetime DESC, id DESC",
432480
"dl": "download_count DESC, id DESC",
@@ -679,19 +727,30 @@ def do_GET(self) -> None:
679727
})
680728
self.send_error(HTTPStatus.NOT_FOUND)
681729

730+
def _read_pkm_request(self):
731+
length = int(self.headers.get("Content-Length", 0))
732+
if not length:
733+
self._json(400, {"error": "empty body"})
734+
return None, None, None
735+
if length > 1_000_000:
736+
self._json(413, {"error": "payload too large"})
737+
return None, None, None
738+
generation = self.headers.get("X-Generation", "").strip()
739+
if not generation:
740+
self._json(400, {"error": "missing X-Generation header"})
741+
return None, None, None
742+
if gpss_console_bin() is None:
743+
self._json(503, {"error": "legality engine not available"})
744+
return None, None, None
745+
pkm_bytes = self.rfile.read(length)
746+
return pkm_bytes, generation, self.headers.get("X-Version", "Any").strip()
747+
682748
def do_POST(self) -> None:
683-
if urlparse(self.path).path == "/api/legality":
684-
length = int(self.headers.get("Content-Length", 0))
685-
if not length:
686-
return self._json(400, {"error": "empty body"})
687-
if length > 1_000_000:
688-
return self._json(413, {"error": "payload too large"})
689-
generation = self.headers.get("X-Generation", "").strip()
690-
if not generation:
691-
return self._json(400, {"error": "missing X-Generation header"})
692-
pkm_bytes = self.rfile.read(length)
693-
if gpss_console_bin() is None:
694-
return self._json(503, {"error": "legality engine not available"})
749+
path = urlparse(self.path).path
750+
if path == "/api/legality":
751+
pkm_bytes, generation, _ = self._read_pkm_request()
752+
if pkm_bytes is None:
753+
return
695754
try:
696755
result = legality_check_pkm(generation, pkm_bytes, None)
697756
if "error" in result:
@@ -711,4 +770,33 @@ def do_POST(self) -> None:
711770
})
712771
except Exception as err:
713772
return self._json(500, {"error": f"legality check failed: {err}"})
773+
if path == "/api/legalize":
774+
pkm_bytes, generation, version = self._read_pkm_request()
775+
if pkm_bytes is None:
776+
return
777+
try:
778+
check = legality_check_pkm(generation, pkm_bytes, None)
779+
report_before = [
780+
line.strip()
781+
for line in check.get("report", [])
782+
if line and line.strip() and line.strip() != "Legal!"
783+
] if "error" not in check else []
784+
785+
result = legalize_pkm(generation, pkm_bytes, version)
786+
if "error" in result:
787+
return self._json(200, {"error": str(result["error"])})
788+
report_after = [
789+
line.strip()
790+
for line in result.get("report", [])
791+
if line and line.strip() and line.strip() != "Legal!"
792+
]
793+
return self._json(200, {
794+
"legal": bool(result.get("legal", False)),
795+
"modified": bool(result.get("ran", False)),
796+
"pokemon": result.get("pokemon"),
797+
"report_before": report_before,
798+
"report_after": report_after,
799+
})
800+
except Exception as err:
801+
return self._json(500, {"error": f"legalization failed: {err}"})
714802
self.send_error(HTTPStatus.NOT_FOUND)

0 commit comments

Comments
 (0)