diff --git a/scripts/extract_ghosts.py b/scripts/extract_ghosts.py new file mode 100644 index 0000000..84c4a13 --- /dev/null +++ b/scripts/extract_ghosts.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +""" +Extract canonical ghost/ability/set data from the source Ghost_Data.xlsx +into JSON seed files consumed by scripts/seed.js. + +Pip strings (e.g. "◉◉◉◯◯") are converted to integers. +Rarity comes from the "✪..." section headers (1-4 stars). +Type comes from the "Red/Yellow/Blue Ghosts" section headers. +Boss ghosts are detected by abilities that appear in the Boss Ability table. +Set linkage (boss -> physical set) is a static reference map (factual data). +""" +import json +import sys +import openpyxl + +FILLED = "◉" + +def pips(s): + if not isinstance(s, str): + return 0 + return s.count(FILLED) + +def num(v): + if v in (None, "-", ""): + return None + try: + return int(v) + except (TypeError, ValueError): + return None + +# Boss-ghost -> physical LEGO set reference (factual mapping, used as "set references"). +# Format: ability/ghost name : {set_number, set_name} +BOSS_SETS = { + "Dr. Drewell": ("70418", "J.B.'s Ghost Lab"), + "Captain Archibald": ("70419", "Wrecked Shrimp Boat"), + "Mamali": ("70420", "Graveyard Mystery"), + "Samuel Mason": ("70421", "El Fuego's Stunt Truck"), + "Anomolo": ("70422", "Shrimp Shack Attack"), + "Spewer": ("70423", "Paranormal Intercept Bus 3000"), + "The Bawa": ("70424", "Ghost Train Express"), + "Mr. Nibs": ("70425", "Newbury Haunted High School"), + "Lady E": ("70427", "Welcome to the Hidden Side"), + "Trucker Dale": ("70428", "Jack's Beach Buggy"), + "Harry Cane": ("70429", "El Fuego's Stunt Plane"), + "Joe Ishmael": ("70431", "The Lighthouse of Darkness"), + "Tragico": ("70432", "Haunted Fairground"), + "Maxine Turbo": ("70434", "Supernatural Race Car"), + "Bart Chaney": ("70435", "Newbury Abandoned Prison"), + "Blaze M. Barr": ("70436", "Phantom Fire Truck 3000"), + "The Maw": ("70437", "Mystery Castle"), +} + +def main(xlsx_path, out_dir): + wb = openpyxl.load_workbook(xlsx_path, data_only=True) + ws = wb.active + rows = [[c for c in r] for r in ws.iter_rows(values_only=True)] + + ghosts = [] + common_abilities = [] + boss_abilities = [] + + cur_type = None + cur_rarity = None + mode = "ghosts" # ghosts -> common_ability -> boss_ability + + for r in rows: + first = r[0] if r else None + if not isinstance(first, str): + continue + s = first.strip() + + if s == "Common Ghost Abilities": + mode = "common_ability"; continue + if s == "Boss Ghost Abilities": + mode = "boss_ability"; continue + if s == "Ghost Abilities": + continue + + if mode == "ghosts": + if s.endswith("Red Ghosts") and not s.startswith("✪"): + cur_type = "red"; continue + if s.endswith("Yellow Ghosts") and not s.startswith("✪"): + cur_type = "yellow"; continue + if s.endswith("Blue Ghosts") and not s.startswith("✪"): + cur_type = "blue"; continue + if s.startswith("✪"): + cur_rarity = s.count("✪"); continue + if s in ("Name",) or s.startswith("Ghost Team") or s.startswith("Newbury is haunted"): + continue + # data row + ghosts.append({ + "name": first, + "type": cur_type, + "rarity": cur_rarity, + "speed": pips(r[1]), + "range": pips(r[2]), + "chargeShot": pips(r[3]), + "health": num(r[4]), + "damage": num(r[5]), + "ability": (r[6].strip() if isinstance(r[6], str) else None), + }) + + elif mode in ("common_ability", "boss_ability"): + if s == "Name": + continue + if s.startswith("Last revised"): + continue + entry = { + "name": first, + "charges": num(r[1]), + "cooldown": (r[2].strip() if isinstance(r[2], str) else r[2]), + "effect": (r[3].strip() if isinstance(r[3], str) else None), + } + (common_abilities if mode == "common_ability" else boss_abilities).append(entry) + + boss_ability_names = {a["name"] for a in boss_abilities} + + # mark bosses + attach set reference + for g in ghosts: + is_boss = g["ability"] in boss_ability_names + g["isBoss"] = is_boss + ref = BOSS_SETS.get(g["name"]) + g["setNumber"] = ref[0] if ref else None + g["setName"] = ref[1] if ref else None + + abilities = [] + for a in common_abilities: + a2 = dict(a); a2["kind"] = "common"; abilities.append(a2) + for a in boss_abilities: + a2 = dict(a); a2["kind"] = "boss"; abilities.append(a2) + + # build set roster: each boss-linked set gets its boss + a sampling is done at runtime, + # but we record the canonical set list here for the admin UI starter. + sets = [] + seen = set() + for num_, name in sorted(set(BOSS_SETS.values())): + if num_ in seen: + continue + seen.add(num_) + boss = next((g["name"] for g in ghosts if g["setNumber"] == num_), None) + sets.append({"setNumber": num_, "setName": name, "boss": boss}) + + with open(f"{out_dir}/ghosts.json", "w") as f: + json.dump(ghosts, f, indent=2, ensure_ascii=False) + with open(f"{out_dir}/abilities.json", "w") as f: + json.dump(abilities, f, indent=2, ensure_ascii=False) + with open(f"{out_dir}/sets.json", "w") as f: + json.dump(sets, f, indent=2, ensure_ascii=False) + + bosses = [g["name"] for g in ghosts if g["isBoss"]] + print(f"ghosts: {len(ghosts)} | abilities: {len(abilities)} " + f"(common {len(common_abilities)}, boss {len(boss_abilities)}) " + f"| sets: {len(sets)} | bosses: {len(bosses)}") + by_type = {} + for g in ghosts: + by_type[g["type"]] = by_type.get(g["type"], 0) + 1 + print("by type:", by_type) + +if __name__ == "__main__": + xlsx = sys.argv[1] if len(sys.argv) > 1 else "Ghost_Data.xlsx" + out = sys.argv[2] if len(sys.argv) > 2 else "data" + main(xlsx, out)