File size: 13,942 Bytes
0b62116
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
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()