sts2-bot/migrate.py
0xrsydn 8fae007e50 feat(dataset): migrate game history and captures into a trainable corpus
Turn the data we already have into an open, educational dataset, so the trace we
are about to start collecting has somewhere to go.

`migrate.py` produces:
  * runs.jsonl          37 runs with outcome, killer, seed and final deck
  * decisions.jsonl     1052 decisions, each with its own outcome attached
  * states_index.jsonl  346 unique observations
  * states/             content-addressed gzipped blobs

Content addressing matters: measured, only 56% of captures are unique, so 44% of
storage is duplicates. 3.28 MB raw -> 0.42 MB stored.

The card-reward rows keep the REJECTED options, so this is a ranking dataset
rather than a classification one, and the per-fight `damage_taken` /
`turns_taken` pair is the dense reward signal a combat policy is judged on.

What it deliberately does NOT do: reconstruct per-step combat state/action pairs.
The session logs record the action but not the observation, and captures exist
only for combat, so a step has a state with no action or an action with no state
-- never both. Inventing them would poison the corpus. The gap is declared in
manifest.json instead, and collect.py will close it going forward.

Integrity checking is a separate entry point (`--check-only`) because `--verify`
alone rebuilds first and so can only ever see data that is correct by
construction -- a smoke test pretending to be a check. All six invariants were
verified by deliberately breaking the dataset and confirming the checker fails.
2026-09-22 06:09:16 +07:00

482 lines
19 KiB
Python

#!/usr/bin/env python3
"""
migrate.py -- turn the data we already have into an open, trainable dataset.
Run: python3 migrate.py [--out dataset] [--verify]
WHAT THIS MIGRATES
Game run history (37 files) -> runs.jsonl + decisions.jsonl
Live captures (600+ files) -> states/ (content-addressed, gzipped)
WHAT IT CANNOT MIGRATE, AND WHY
Per-step combat state/action pairs. The session logs record the action, the
source and the reason, but NOT the observation, and the captures are only
written for combat. So a combat step has a state with no action, or an
action with no state -- never both. Those pairs are NOT reconstructed here.
Inventing them would poison the corpus, so the collector (collect.py)
records them going forward instead.
DESIGN RULES
1. Lossless raw, derived views. Nothing is dropped, nothing is edited.
2. Content-addressed states, so the 44% duplicate rate costs nothing.
3. Every inferred field is marked. `deck_reconstructed` is not the same
thing as an observed deck and the corpus must say which it is.
4. Provenance on every row: game build, mod commit, source file.
5. No secrets, no absolute paths, no personal data in the output.
"""
from __future__ import annotations
import argparse
import gzip
import hashlib
import json
import os
import pathlib
import shutil
import sys
from collections import Counter
SCHEMA_RUN = "sts2.run/1"
SCHEMA_DECISION = "sts2.decision/1"
SCHEMA_MANIFEST = "sts2.manifest/1"
GAME_BUILD = "0.107.1"
GAME_COMMIT = "59260271"
MOD_COMMIT = "55e0648"
HOME = pathlib.Path.home()
RUN_HISTORY = (
HOME
/ "Library/Application Support/SlayTheSpire2/steam"
/ "76561198141226155/modded/profile1/saves/history"
)
CAPTURE_DIR = pathlib.Path("capture")
# --------------------------------------------------------------------------
# helpers
# --------------------------------------------------------------------------
def short_id(text: str, n: int = 12) -> str:
return hashlib.sha256(text.encode()).hexdigest()[:n]
def strip_prefix(value, prefix: str) -> str:
"""'CARD.STRIKE_IRONCLAD' -> 'STRIKE_IRONCLAD'. Never mangles non-matching."""
text = str(value or "")
return text[len(prefix):] if text.startswith(prefix) else text
def write_jsonl(path: pathlib.Path, rows) -> int:
n = 0
with path.open("w") as fh:
for row in rows:
fh.write(json.dumps(row, sort_keys=True, separators=(",", ":")) + "\n")
n += 1
return n
def verify(out: pathlib.Path) -> int:
"""Check an existing dataset. Returns 0 when clean, else a count of failures.
Separate from the migrate path on purpose: verifying a dataset that was just
regenerated proves nothing, because it is correct by construction. This is
the entry point that can actually catch corruption.
"""
print()
print("verifying round-trip and invariants...")
bad = 0
def fail(msg):
nonlocal bad
bad += 1
print(f" FAIL {msg}")
for name in ("runs.jsonl", "decisions.jsonl", "states_index.jsonl"):
if not (out / name).exists():
fail(f"missing {name}")
return bad
run_rows = [json.loads(l) for l in (out / "runs.jsonl").read_text().splitlines()]
dec_rows = [json.loads(l) for l in (out / "decisions.jsonl").read_text().splitlines()]
st_rows = [json.loads(l) for l in (out / "states_index.jsonl").read_text().splitlines()]
# state blobs must exist and hash back to their digest
for rec in st_rows:
blob = out / rec["path"]
if not blob.exists():
fail(f"missing state {rec['path']}")
continue
if hashlib.sha256(gzip.decompress(blob.read_bytes())).hexdigest() \
!= rec["digest"].split(":")[1]:
fail(f"hash mismatch {rec['path']}")
for row in dec_rows:
# a reward decision that picked something must pick exactly one option
if row["kind"].endswith("reward") and row.get("chosen"):
n_picked = sum(1 for o in row["options"] if o.get("picked"))
if n_picked != 1:
fail(f"{row['decision_id']}: {n_picked} picked options")
# `chosen` must be one of `options` -- guards the mapping, not the parse
if row.get("chosen") and row["chosen"] not in \
[o.get("id") for o in row["options"]]:
fail(f"{row['decision_id']}: chosen not in options")
# no secrets and no absolute paths in a corpus meant to be published
blob = (out / "decisions.jsonl").read_text() + (out / "runs.jsonl").read_text()
if "/Users/" in blob or "/home/" in blob:
fail("absolute path leaked into the corpus")
for needle in ("sk-", "API_KEY", "Bearer "):
if needle in blob:
fail(f"possible secret leaked: {needle!r}")
# every row must carry its own outcome, and reference a real run
ids = {r["run_id"] for r in run_rows}
for row in dec_rows:
if "outcome" not in row:
fail(f"{row['decision_id']}: no outcome attached")
if row["run_id"] not in ids:
fail(f"{row['decision_id']}: unknown run_id")
print(f" {'OK' if not bad else str(bad) + ' PROBLEMS'}: "
f"{len(run_rows)} runs, {len(dec_rows)} decisions, {len(st_rows)} states")
return bad
# --------------------------------------------------------------------------
# run history -> runs.jsonl + decisions.jsonl
# --------------------------------------------------------------------------
def deck_as_counts(cards) -> list[dict]:
"""Collapse a card list to [{'id':..,'count':..}], sorted for stability."""
counts = Counter(strip_prefix((c or {}).get("id"), "CARD.") for c in (cards or []))
return [{"id": k, "count": v} for k, v in sorted(counts.items()) if k]
def migrate_runs(out: pathlib.Path, verbose: bool = True):
runs, decisions = [], []
files = sorted(RUN_HISTORY.glob("*.run"))
if not files:
print(f" no run files under {RUN_HISTORY}")
return runs, decisions
for path in files:
try:
d = json.loads(path.read_text())
except Exception as exc: # noqa: BLE001
print(f" SKIP {path.name}: {exc}")
continue
run_id = path.stem
player = (d.get("players") or [{}])[0]
history = d.get("map_point_history") or []
# `map_point_history` is a list of acts, each a list of map points.
acts = [a for a in history if isinstance(a, list)]
total_points = sum(len(a) for a in acts)
killed_by = strip_prefix(d.get("killed_by_encounter"), "ENCOUNTER.")
killed_by_event = strip_prefix(d.get("killed_by_event"), "EVENT.")
runs.append({
"schema": SCHEMA_RUN,
"run_id": run_id,
"seed": d.get("seed"),
"character": strip_prefix(player.get("character"), "CHARACTER."),
"ascension": d.get("ascension"),
"game_mode": d.get("game_mode"),
"build_id": d.get("build_id"),
"platform": d.get("platform_type"),
"modifiers": d.get("modifiers"),
"start_time": d.get("start_time"),
"run_time_seconds": d.get("run_time"),
"was_abandoned": d.get("was_abandoned"),
"win": bool(d.get("win")),
"killed_by": killed_by or None,
"killed_by_event": killed_by_event or None,
"progress": {"acts_entered": len(acts), "map_points": total_points},
"final": {
"deck": deck_as_counts(player.get("deck")),
"deck_size": len(player.get("deck") or []),
"relics": [strip_prefix((r or {}).get("id"), "RELIC.")
for r in (player.get("relics") or [])],
"potions": [strip_prefix((p or {}).get("id"), "POTION.")
for p in (player.get("potions") or [])],
"max_potion_slots": player.get("max_potion_slot_count"),
},
"source": {"kind": "game_run_history", "file": path.name},
})
# ---- per-map-point decisions ------------------------------------
outcome = {
"run_win": bool(d.get("win")),
"run_map_points": total_points,
"run_killed_by": killed_by or None,
"run_acts_entered": len(acts),
}
for act_i, act in enumerate(acts):
for pt_i, point in enumerate(act):
if not isinstance(point, dict):
continue
stats = (point.get("player_stats") or [{}])[0]
rooms = point.get("rooms") or []
room = rooms[0] if rooms else {}
where = f"{run_id}:act{act_i}:pt{pt_i}"
# Context at this map point. NOTE: the game does not record the
# deck as it stood here, so `deck` is NOT included. What is
# recorded is the observed delta, which the consumer can fold
# forward from the known starting deck. Marked as observed.
context = {
"act": act_i,
"map_point": pt_i,
"map_point_type": strip_prefix(point.get("map_point_type"), "MAP_POINT."),
"hp": stats.get("current_hp"),
"max_hp": stats.get("max_hp"),
"gold": stats.get("current_gold"),
"encounter": strip_prefix(room.get("model_id"), "ENCOUNTER.") or None,
"room_type": strip_prefix(room.get("room_type"), "ROOM.") or None,
"monsters": [strip_prefix(m, "MONSTER.")
for m in (room.get("monster_ids") or [])],
}
# Per-fight reward signal. This is the dense signal the corpus
# exists for: damage and turns are what a policy actually pays.
fight = {
"damage_taken": stats.get("damage_taken"),
"hp_healed": stats.get("hp_healed"),
"turns_taken": room.get("turns_taken"),
"gold_gained": stats.get("gold_gained"),
}
def emit(kind, options, chosen, extra=None):
row = {
"schema": SCHEMA_DECISION,
"decision_id": f"{where}:{kind}",
"run_id": run_id,
"kind": kind,
"context": context,
"options": options,
"chosen": chosen,
"fight": fight,
"outcome": outcome,
"source": {"kind": "game_run_history", "file": path.name},
}
if extra:
row.update(extra)
decisions.append(row)
# card reward: every offered card, with the pick flag
ccs = stats.get("card_choices") or []
if ccs:
options = [{
"id": strip_prefix((cc.get("card") or {}).get("id"), "CARD."),
"picked": bool(cc.get("was_picked")),
"floor_added": (cc.get("card") or {}).get("floor_added_to_deck"),
} for cc in ccs]
picked = [o["id"] for o in options if o["picked"]]
emit("card_reward", options, picked[0] if picked else None,
{"skipped": not picked})
if stats.get("relic_choices"):
options = [{
"id": strip_prefix((rc.get("relic") or {}).get("id"), "RELIC."),
"picked": bool(rc.get("was_picked")),
} for rc in stats["relic_choices"]]
picked = [o["id"] for o in options if o["picked"]]
emit("relic_reward", options, picked[0] if picked else None,
{"skipped": not picked})
if stats.get("potion_choices"):
options = [{
"id": strip_prefix((pc.get("potion") or {}).get("id"), "POTION."),
"picked": bool(pc.get("was_picked")),
} for pc in stats["potion_choices"]]
picked = [o["id"] for o in options if o["picked"]]
emit("potion_reward", options, picked[0] if picked else None,
{"skipped": not picked})
if stats.get("event_choices"):
# Shape varies by event; keep it raw rather than guessing.
emit("event", stats["event_choices"], None,
{"raw": True,
"note": "event option shape varies; stored unparsed"})
if stats.get("rest_site_choices"):
emit("rest_site",
[{"id": str(c)} for c in stats["rest_site_choices"]],
str(stats["rest_site_choices"][0]))
if stats.get("upgraded_cards"):
emit("upgrade",
[{"id": strip_prefix(c, "CARD.")} for c in stats["upgraded_cards"]],
strip_prefix(stats["upgraded_cards"][0], "CARD."))
if stats.get("cards_removed"):
emit("remove",
[{"id": strip_prefix(c, "CARD.")} for c in stats["cards_removed"]],
strip_prefix(stats["cards_removed"][0], "CARD."))
if stats.get("cards_transformed"):
emit("transform",
[{"id": strip_prefix(c, "CARD.")} for c in stats["cards_transformed"]],
strip_prefix(stats["cards_transformed"][0], "CARD."))
if stats.get("cards_enchanted"):
emit("enchant",
[{"id": strip_prefix(c, "CARD.")} for c in stats["cards_enchanted"]],
strip_prefix(stats["cards_enchanted"][0], "CARD."))
if stats.get("ancient_choice"):
emit("ancient", stats["ancient_choice"], None, {"raw": True})
bought = []
for key in ("bought_relics", "bought_colorless", "bought_potions"):
for item in (stats.get(key) or []):
bought.append({"item": item, "kind": key})
if bought:
emit("shop_purchase", bought, None, {"raw": True})
if verbose:
print(f" {run_id} pts={total_points:<3} win={bool(d.get('win'))!s:<5} "
f"killed_by={killed_by or '-'}")
return runs, decisions
# --------------------------------------------------------------------------
# captures -> content-addressed states/
# --------------------------------------------------------------------------
def migrate_states(out: pathlib.Path, verbose: bool = True) -> dict:
store = out / "states"
store.mkdir(parents=True, exist_ok=True)
index, seen, total_raw, total_gz = [], {}, 0, 0
files = sorted(CAPTURE_DIR.glob("*.json"))
for path in files:
raw = path.read_bytes()
digest = hashlib.sha256(raw).hexdigest()
total_raw += len(raw)
if digest in seen:
seen[digest]["seen_in"].append(path.name)
continue
try:
obs = json.loads(raw)
except Exception: # noqa: BLE001
continue
blob = gzip.compress(raw, 6)
total_gz += len(blob)
sub = store / digest[:2]
sub.mkdir(exist_ok=True)
(sub / f"{digest}.json.gz").write_bytes(blob)
state_type = obs.get("state_type")
seen[digest] = {
"digest": f"sha256:{digest}",
"path": f"states/{digest[:2]}/{digest}.json.gz",
"bytes_raw": len(raw),
"bytes_gz": len(blob),
"state_type": state_type,
"menu_screen": obs.get("menu_screen"),
"seen_in": [path.name],
}
index.append(seen[digest])
write_jsonl(out / "states_index.jsonl", index)
if verbose:
print(f" {len(files)} capture files -> {len(index)} unique states "
f"({100*len(index)/max(1,len(files)):.0f}% unique)")
print(f" {total_raw/1e6:.2f} MB raw -> {total_gz/1e6:.2f} MB stored")
return {"files": len(files), "unique": len(index),
"bytes_raw": total_raw, "bytes_gz": total_gz}
# --------------------------------------------------------------------------
# main
# --------------------------------------------------------------------------
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--out", default="dataset", help="output directory")
ap.add_argument("--verify", action="store_true",
help="re-read the output and check it round-trips")
ap.add_argument("--check-only", action="store_true",
help="verify an EXISTING dataset without regenerating it. "
"--verify alone rebuilds first, so it can only ever "
"see data that is correct by construction.")
args = ap.parse_args()
out = pathlib.Path(args.out)
if args.check_only:
if not out.exists():
print(f"no dataset at {out}/; run without --check-only first")
return 2
return verify(out)
if out.exists():
shutil.rmtree(out)
out.mkdir(parents=True)
print("migrating run history...")
runs, decisions = migrate_runs(out)
n_runs = write_jsonl(out / "runs.jsonl", runs)
n_dec = write_jsonl(out / "decisions.jsonl", decisions)
print()
print("migrating captures...")
state_stats = migrate_states(out)
kinds = Counter(d["kind"] for d in decisions)
print()
print("=== dataset ===")
print(f" runs.jsonl {n_runs:>6} rows")
print(f" decisions.jsonl {n_dec:>6} rows")
print(f" states_index.jsonl{state_stats['unique']:>6} rows")
print()
print(" decisions by kind:")
for kind, n in kinds.most_common():
print(f" {kind:<18}{n:>6}")
manifest = {
"schema": SCHEMA_MANIFEST,
"generated_by": "migrate.py",
"provenance": {
"game_build": GAME_BUILD,
"game_commit": GAME_COMMIT,
"mod_commit": MOD_COMMIT,
},
"counts": {"runs": n_runs, "decisions": n_dec,
"unique_states": state_stats["unique"],
"capture_files": state_stats["files"]},
"decisions_by_kind": dict(kinds),
"limitations": [
"No per-step combat state/action pairs: the logs record the action "
"but not the observation, and captures exist only for combat. "
"collect.py records these going forward.",
"Per-map-point deck composition is not recorded by the game. The "
"corpus stores observed deltas (cards_gained, cards_removed, "
"upgraded_cards) and the final deck; folding them forward is left "
"to the consumer.",
"0 wins in this corpus. The reward signal has no positive class.",
],
"license_note": "Slay the Spire 2 is (c) Mega Crit. Game-derived "
"identifiers are included for research and education.",
}
(out / "manifest.json").write_text(json.dumps(manifest, indent=2, sort_keys=True))
if args.verify:
rc = verify(out)
if rc:
return rc
print()
print(f"written to {out}/")
return 0
if __name__ == "__main__":
sys.exit(main())