Spaces:
Sleeping
Sleeping
| 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() | |