import os, json, hashlib from pathlib import Path from typing import Dict, List, Tuple, Optional import gradio as gr DATA_PATH = os.environ.get("DATA_PATH", "skills_dataset.jsonl") # ----------------------------- # Utilities # ----------------------------- def _hash_id(name: str) -> str: # stable, short-ish ID for generated skills h = hashlib.md5(name.encode("utf-8")).hexdigest()[:8].upper() return f"GEN-{h}" def load_dataset(path: str) -> List[dict]: p = Path(path) if p.exists(): rows = [] with p.open("r", encoding="utf-8") as f: for line in f: line = line.strip() if line: rows.append(json.loads(line)) return rows # Minimal fallback dataset if no file present return [ { "skill_id":"S001","name":"Slash","category":"Combat","element":"None","tier":1, "description":"A quick melee cut.","base_power":30,"cost_mana":0,"cooldown_s":2, "status_effects":[],"scales_with":["strength"],"prerequisites":[], "unlock_methods":["starter"],"rarity":"common", "evolves_to":[{"to_skill_id":"S101","condition":"Use Slash 200 times"}], "combines_with":[] }, { "skill_id":"S101","name":"Cleave","category":"Combat","element":"None","tier":2, "description":"A wide, sweeping strike.","base_power":55,"cost_mana":0,"cooldown_s":4, "status_effects":["bleed_small"],"scales_with":["strength"],"prerequisites":["S001"], "unlock_methods":["use_mastery"],"rarity":"uncommon","evolves_to":[],"combines_with":[] }, { "skill_id":"S002","name":"Fireball","category":"Magic","element":"Fire","tier":1, "description":"Hurl a small fire orb.","base_power":40,"cost_mana":20,"cooldown_s":5, "status_effects":["burn"],"scales_with":["intelligence"],"prerequisites":[], "unlock_methods":["starter"],"rarity":"common","evolves_to":[],"combines_with":[] } ] def index_dataset(rows: List[dict]): by_id = {r["skill_id"]: r for r in rows} by_name = {} for r in rows: # disambiguate duplicate names by appending (ID) key = r["name"] if key in by_name: key = f'{r["name"]} ({r["skill_id"]})' by_name[key] = r["skill_id"] # Build combo map from skill-> list of {with, result, rule} combos = {} for r in rows: for c in r.get("combines_with", []): combos.setdefault(r["skill_id"], []).append(c) # Evolutions from skill_id -> list of {"to_skill_id":..., "condition":...} evol = {} for r in rows: for e in r.get("evolves_to", []): evol.setdefault(r["skill_id"], []).append(e) return by_id, by_name, combos, evol # Rule packs for element infusion (extend as you like) ELEMENT_PACK = { "Fire": {"status_add":["burn"], "power_mult":1.15, "cost_add":+4, "desc_suffix":"Ignites targets."}, "Ice": {"status_add":["slow"], "power_mult":1.10, "cost_add":+4, "desc_suffix":"Chills on hit."}, "Poison":{"status_add":["poison"], "power_mult":1.12, "cost_add":+3, "desc_suffix":"Applies toxin."}, "Lightning":{"status_add":["shock"], "power_mult":1.14, "cost_add":+5, "desc_suffix":"Electrifies foes."}, "Wind": {"status_add":["knockback"], "power_mult":1.08, "cost_add":+2, "desc_suffix":"Carries force."}, "Earth": {"status_add":["stagger"], "power_mult":1.12, "cost_add":+2, "desc_suffix":"Hits heavy."}, "Light": {"status_add":["blind_short"], "power_mult":1.10, "cost_add":+4, "desc_suffix":"Radiant flare."}, "Shadow":{"status_add":["invisible_short"],"power_mult":1.06,"cost_add":+3,"desc_suffix":"Cloaked strike."}, "Water": {"status_add":["soak"], "power_mult":1.08, "cost_add":+3, "desc_suffix":"Drenches."}, "Arcane":{"status_add":["silence_short"],"power_mult":1.10,"cost_add":+5, "desc_suffix":"Raw surge."}, } def generate_infused_skill(base: dict, element: str) -> dict: pack = ELEMENT_PACK[element] name = f"{element} {base['name']}" if element not in (base.get("element") or "") else base["name"] new = dict(base) # shallow copy new["name"] = name new["element"] = element if base.get("element", "None") in ("None", "", None) else f"{base['element']}/{element}" new["tier"] = max(1, int(base.get("tier", 1))) # same tier new["base_power"] = int(round(base.get("base_power", 0) * pack["power_mult"])) new["cost_mana"] = int(base.get("cost_mana", 0) + pack["cost_add"]) new["cooldown_s"] = base.get("cooldown_s", 0) # keep new_effects = list(dict.fromkeys((base.get("status_effects") or []) + pack["status_add"])) new["status_effects"] = new_effects new["description"] = (base.get("description") or "") + " " + pack["desc_suffix"] new["rarity"] = base.get("rarity", "common") new["skill_id"] = _hash_id(f"{name}|{new['element']}|{new['base_power']}") new["prerequisites"] = list(base.get("prerequisites") or []) new["unlock_methods"] = list(base.get("unlock_methods") or []) + [f"infusion:{element.lower()}"] # Generated skills have no baked edges; they can still be combined via rules new["evolves_to"] = [] new["combines_with"] = [] return new def dataset_combo_result(current: dict, with_id: str, by_id: Dict[str,dict], dataset_combos: Dict[str, List[dict]]) -> Optional[dict]: # If dataset has an explicit combo from current->with_id, use that result skill definition for c in dataset_combos.get(current["skill_id"], []): if c["with_skill_id"] == with_id: res_id = c["result_skill_id"] return by_id.get(res_id) # also check reverse for c in dataset_combos.get(with_id, []): if c["with_skill_id"] == current["skill_id"]: res_id = c["result_skill_id"] return by_id.get(res_id) return None def rule_combo_generate(a: dict, b: dict) -> dict: # Simple generative fallback: merge name pieces, elements, and effects el_a = a.get("element","None") el_b = b.get("element","None") merged_el = "None" if el_a == "None": merged_el = el_b elif el_b == "None": merged_el = el_a else: merged_el = f"{el_a}/{el_b}" name = f"{a['name']} + {b['name']}" desc = f"Fusion of {a['name']} and {b['name']}." base_power = int(round(a.get("base_power",0)*0.6 + b.get("base_power",0)*0.6)) + 8 cost = int(round(a.get("cost_mana",0)*0.5 + b.get("cost_mana",0)*0.5)) cd = max(a.get("cooldown_s",0), b.get("cooldown_s",0)) effects = list(dict.fromkeys((a.get("status_effects") or []) + (b.get("status_effects") or []))) res = { "skill_id": _hash_id(f"{name}|{merged_el}|{base_power}"), "name": name, "category": a.get("category","Combat"), "element": merged_el, "tier": max(a.get("tier",1), b.get("tier",1)) + 1, "description": desc, "base_power": base_power, "cost_mana": cost, "cooldown_s": cd, "status_effects": effects, "scales_with": sorted(list(set((a.get("scales_with") or []) + (b.get("scales_with") or [])))), "prerequisites": [a["skill_id"], b["skill_id"]], "unlock_methods": ["fusion_discovery"], "rarity": "rare", "evolves_to": [], "combines_with": [] } return res def dataset_evolve(current: dict, evol_map: Dict[str,List[dict]], by_id: Dict[str,dict]) -> Optional[dict]: # For the Space prototype, we "assume" conditions are met and take the first evolution candidates = evol_map.get(current["skill_id"], []) if not candidates: return None to_id = candidates[0]["to_skill_id"] return by_id.get(to_id) def summarize(skill: dict) -> str: lines = [ f"**{skill['name']}** (`{skill['skill_id']}`)", f"- Tier: {skill.get('tier','?')} | Category: {skill.get('category','?')} | Element: {skill.get('element','None')}", f"- Power: {skill.get('base_power',0)} | Cost: {skill.get('cost_mana',0)} | Cooldown: {skill.get('cooldown_s',0)}s", f"- Effects: {', '.join(skill.get('status_effects') or []) or '—'}", f"- Scales With: {', '.join(skill.get('scales_with') or []) or '—'}", f"- Rarity: {skill.get('rarity','?')}", f"- Desc: {skill.get('description','')}" ] return "\n".join(lines) # ----------------------------- # Load / Index dataset # ----------------------------- DATA_ROWS = load_dataset(DATA_PATH) BY_ID, BY_NAME, COMBOS, EVOLS = index_dataset(DATA_ROWS) START_SKILLS = sorted(BY_NAME.keys()) # for dropdown ELEMENT_CHOICES = ["Fire","Ice","Poison","Lightning","Wind","Earth","Light","Shadow","Water","Arcane"] # ----------------------------- # Gradio App State Machine # ----------------------------- def new_session(): return {"history": [], "current": None} def start_skill(select_name: str, state): sid = BY_NAME[select_name] base = BY_ID[sid] state = state or new_session() state["history"] = [("Start", base)] state["current"] = base return summarize(base), state, gr.update(choices=_combine_choices(state["current"])) def _combine_choices(current: Optional[dict]) -> List[str]: if not current: return [] # offer dataset combos and also all dataset skills as free-form for rule fusion names = [] # dataset-defined combos: for c in COMBOS.get(current["skill_id"], []): with_id = c["with_skill_id"] target = BY_ID.get(with_id) if target: label = f"{target['name']} ({with_id})" names.append(label) # also allow any skill in the dataset as a free-form partner (dedupe later) for sid, row in BY_ID.items(): label = f"{row['name']} ({sid})" names.append(label) return sorted(list(dict.fromkeys(names))) def apply_infusion(element: str, state): state = state or new_session() if not state["current"]: return "Pick a start skill first.", state infused = generate_infused_skill(state["current"], element) state["history"].append((f"Infuse {element}", infused)) state["current"] = infused return summarize(infused), state def apply_combo(partner_label: str, state): state = state or new_session() if not state["current"]: return "Pick a start skill first.", state, gr.update() # partner label is like "Fireball (S002)" if "(" in partner_label and partner_label.endswith(")"): partner_id = partner_label.split("(")[-1][:-1] else: # fallback name lookup (rare) partner_id = BY_NAME.get(partner_label, None) if partner_id is None or partner_id not in BY_ID: return "Invalid partner.", state, gr.update() partner = BY_ID[partner_id] res = dataset_combo_result(state["current"], partner_id, BY_ID, COMBOS) if res is None: res = rule_combo_generate(state["current"], partner) state["history"].append((f"Combine with {partner['name']}", res)) state["current"] = res return summarize(res), state, gr.update(choices=_combine_choices(state["current"])) def apply_dataset_evolve(state): state = state or new_session() if not state["current"]: return "Pick a start skill first.", state, gr.update() evo = dataset_evolve(state["current"], EVOLS, BY_ID) if evo is None: return "No dataset evolution available for this skill.", state, gr.update() state["history"].append(("Evolve (dataset)", evo)) state["current"] = evo return summarize(evo), state, gr.update(choices=_combine_choices(state["current"])) def render_history(state): if not state or not state.get("history"): return "—" out = [] for step, s in state["history"]: out.append(f"### {step}\n" + summarize(s)) return "\n\n".join(out) def export_build(state): if not state or not state.get("history"): return json.dumps({"build":[]}, indent=2) return json.dumps( {"build":[{"step":step, "skill":skill} for step, skill in state["history"]]}, indent=2 ) # ----------------------------- # Gradio UI # ----------------------------- with gr.Blocks(title="Skill Forge: Evolve & Combine") as demo: gr.Markdown("# 🛠️ Skill Forge\nPick a base skill, infuse an element, evolve, and combine—step by step.") with gr.Row(): with gr.Column(): start_dd = gr.Dropdown(choices=START_SKILLS, label="Start Skill", value=START_SKILLS[0] if START_SKILLS else None) start_btn = gr.Button("Start Build", variant="primary") infuse_dd = gr.Dropdown(choices=ELEMENT_CHOICES, label="Infuse Element") infuse_btn = gr.Button("Apply Infusion") combo_dd = gr.Dropdown(choices=[], label="Combine With (dataset or free-form)") combo_btn = gr.Button("Combine") evo_btn = gr.Button("Evolve (use dataset edge if available)") export_btn = gr.Button("Export Build JSON") with gr.Column(): current_md = gr.Markdown("Current Skill will appear here.") history_md = gr.Markdown("—") state = gr.State(new_session()) # Wire events start_btn.click(start_skill, inputs=[start_dd, state], outputs=[current_md, state, combo_dd]) infuse_btn.click(apply_infusion, inputs=[infuse_dd, state], outputs=[current_md, state]).then(render_history, inputs=state, outputs=history_md) combo_btn.click(apply_combo, inputs=[combo_dd, state], outputs=[current_md, state, combo_dd]).then(render_history, inputs=state, outputs=history_md) evo_btn.click(apply_dataset_evolve, inputs=[state], outputs=[current_md, state, combo_dd]).then(render_history, inputs=state, outputs=history_md) export_btn.click(export_build, inputs=[state], outputs=[gr.Code(label="Build JSON", language="json")]) if __name__ == "__main__": demo.launch()