Spaces:
Running
Running
| from __future__ import annotations | |
| import os, json, math, itertools, tempfile, hashlib, copy | |
| from datetime import datetime | |
| from functools import lru_cache | |
| from typing import Any, Dict, List, Tuple, Optional, Set, Generator | |
| import pandas as pd | |
| import gradio as gr | |
| try: | |
| from zoneinfo import ZoneInfo | |
| KST = ZoneInfo("Asia/Seoul") | |
| except Exception: | |
| KST = None | |
| def _now(): | |
| return datetime.now(KST) if KST else datetime.utcnow() | |
| _VIS_DB = os.path.join(tempfile.gettempdir(), "visits_daily.json") | |
| _VIS_SALT = os.environ.get("VIS_SALT", "SALT") | |
| def _client_ip(req: gr.Request | None) -> str: | |
| try: | |
| if req is None: | |
| return "?" | |
| for k in ("x-forwarded-for","X-Forwarded-For","x-real-ip","X-Real-Ip"): | |
| v = req.headers.get(k) | |
| if v: | |
| return v.split(",")[0].strip() | |
| return getattr(req.client, "host", "") or "?" | |
| except Exception: | |
| return "?" | |
| def _ip_key(req: gr.Request | None) -> str: | |
| try: | |
| return hashlib.sha256((_VIS_SALT + _client_ip(req)).encode()).hexdigest() | |
| except Exception: | |
| return hashlib.sha256((_VIS_SALT + "anon").encode()).hexdigest() | |
| def _vload(): | |
| try: | |
| with open(_VIS_DB,"r",encoding="utf-8") as f: | |
| return json.load(f) | |
| except Exception: | |
| return {} | |
| def _vsave(d:dict): | |
| with open(_VIS_DB,"w",encoding="utf-8") as f: | |
| json.dump(d,f) | |
| def register_unique_visit(request: gr.Request | None = None) -> str: | |
| d = _vload() | |
| key = _now().strftime("%Y-%m-%d") | |
| if key not in d: | |
| d = {key:{}} | |
| ipk = _ip_key(request) | |
| if ipk not in d[key]: | |
| d[key][ipk] = int(_now().timestamp()) | |
| _vsave(d) | |
| return f"<div style='text-align:right;font-size:12px;'>오늘 방문자: <b>{len(d[key])}</b></div>" | |
| # ============================================================================= | |
| # 외부 모듈 폴백 | |
| # ============================================================================= | |
| try: | |
| from calc.type import get_base_stats as _get_base_stats_raw | |
| from calc.awakening import get_awakening_stat as _get_awakening_stat_raw | |
| from calc.spirit import spirit_breakdown as _spirit_breakdown_raw | |
| from calc.collection import apply_collection as _apply_collection_raw | |
| from calc.potion import apply_potion as _apply_potion_raw | |
| import calc.accessory as accmod | |
| from calc.buff import BUFFS_ALL as _BUFFS_ALL | |
| from calc.gem import GEM_DISTS as _GEM_DISTS | |
| except Exception: | |
| def _get_base_stats_raw(typ:str)->Dict[str,int]: | |
| base = { | |
| "체":{"hp":400,"atk":100,"def":100}, | |
| "공":{"hp":200,"atk":200,"def":100}, | |
| "방":{"hp":200,"atk":100,"def":200}, | |
| "체공":{"hp":350,"atk":150,"def":100}, | |
| "체방":{"hp":350,"atk":100,"def":150}, | |
| "공방":{"hp":200,"atk":150,"def":150}, | |
| } | |
| t = typ.replace("(진각)","") | |
| return base.get(t, {"hp":300,"atk":120,"def":120}) | |
| def _get_awakening_stat_raw(typ:str)->Dict[str,int]: | |
| return {"hp":60,"atk":20,"def":20} | |
| def _spirit_breakdown_raw(arr:List[dict]): | |
| pct = {"hp":0.15,"atk":0.10,"def":0.10} | |
| flat = {"hp":120,"atk":40,"def":40} | |
| sub = {"hp":0,"atk":0,"def":0} | |
| return pct, flat, sub | |
| def _apply_collection_raw(_=None): | |
| return {"hp":0,"atk":0,"def":0} | |
| def _apply_potion_raw(_=None): | |
| return {"hp":0,"atk":0,"def":0} | |
| class _AccStub: | |
| df_acc = pd.DataFrame([ | |
| {"lv":19,"이름":"황보","hp%":0.05,"atk%":0.05,"def%":0.05}, | |
| {"lv":19,"이름":"악보","hp%":0.06,"atk%":0.0,"def%":0.06}, | |
| ]) | |
| accmod = _AccStub() | |
| _BUFFS_ALL = { | |
| "0벞":{"hp":0.0,"atk":0.0,"def":0.0}, | |
| "HP20%":{"hp":0.2,"atk":0.0,"def":0.0}, | |
| "ATK20%":{"hp":0.0,"atk":0.2,"def":0.0}, | |
| "DEF20%":{"hp":0.0,"atk":0.0,"def":0.2}, | |
| "HP40%":{"hp":0.4,"atk":0.0,"def":0.0}, | |
| "ATK40%":{"hp":0.0,"atk":0.4,"def":0.0}, | |
| "DEF40%":{"hp":0.0,"atk":0.0,"def":0.4}, | |
| "HP+ATK20%":{"hp":0.2,"atk":0.2,"def":0.0}, | |
| "HP+DEF20%":{"hp":0.2,"atk":0.0,"def":0.2}, | |
| "ATK+DEF20%":{"hp":0.0,"atk":0.2,"def":0.2}, | |
| } | |
| _GEM_DISTS = [ | |
| (5,0,0),(0,5,0),(0,0,5), | |
| (4,1,0),(4,0,1),(1,4,0),(0,4,1),(1,0,4),(0,1,4), | |
| (3,2,0),(3,0,2),(2,3,0),(0,3,2),(2,0,3),(0,2,3) | |
| ] | |
| BUFFS_ALL: Dict[str,Dict[str,float]] = dict(_BUFFS_ALL) | |
| GEM_DISTS: List[Tuple[int,int,int]] = list(_GEM_DISTS) | |
| try: | |
| from tar_denom_db import get_denom_from_db as _fast_denom_get | |
| except Exception: | |
| def _fast_denom_get(mode:int, typ:str, buff:str)->int: | |
| return 0 | |
| try: | |
| import spec_db | |
| except Exception: | |
| class _SpecDBStub: | |
| def get_M(self, profile:int, typ:str, buff_label:str)->float|None: | |
| return None | |
| def profile_name(self, p:int)->str: | |
| return {1:"7.0/펜던트X",2:"9.0/펜던트O"}.get(p,str(p)) | |
| spec_db = _SpecDBStub() | |
| TYPES_ALL = ["체","공","방","체공","체방","공방", | |
| "(진각)체","(진각)공","(진각)방","(진각)체공","(진각)체방","(진각)공방"] | |
| BASE_TYPES = ["체","공","방","체공","체방","공방"] | |
| TWO_BUFFS = ["HP40%","ATK40%","DEF40%","HP+ATK20%","HP+DEF20%","ATK+DEF20%"] | |
| ONE_20 = ["HP20%","ATK20%","DEF20%"] | |
| ALL_BUFF_CHOICES = TWO_BUFFS + ONE_20 + ["0벞"] | |
| ENCH_LIST = [ | |
| ("HP", {"hp":0.21,"atk":0.0,"def":0.0}), | |
| ("ATK",{"hp":0.0,"atk":0.21,"def":0.0}), | |
| ("DEF",{"hp":0.0,"atk":0.0,"def":0.21}), | |
| ] | |
| ENCH_DICT = dict(ENCH_LIST) | |
| # ============================================================================= | |
| # 보조 | |
| # ============================================================================= | |
| def strip_jingak(t:str)->str: | |
| return t.replace("(진각)","") if isinstance(t,str) else t | |
| def _get_base_stats_cached(t:str)->Dict[str,int]: | |
| return dict(_get_base_stats_raw(t)) | |
| def _get_awakening_stat_cached(t:str)->Dict[str,int]: | |
| return dict(_get_awakening_stat_raw(t)) | |
| def _sp_key_for_cache(sp:List[dict])->Tuple: | |
| if not sp: return () | |
| return tuple((int(d.get("slot",0)),str(d.get("stat","")),str(d.get("type",""))) for d in sp) | |
| def _spirit_breakdown_cached(key:Tuple)->Tuple[Dict[str,float],Dict[str,int],Dict[str,int]]: | |
| if not key: | |
| zero_pct = {"hp":0.0,"atk":0.0,"def":0.0} | |
| zero_flat = {"hp":0,"atk":0,"def":0} | |
| zero_sub = {"hp":0,"atk":0,"def":0} | |
| return zero_pct, zero_flat, zero_sub | |
| arr = [{"slot":s,"stat":st,"type":tp} for (s,st,tp) in key] | |
| return _spirit_breakdown_raw(arr) | |
| _POTION = _apply_potion_raw({}) | |
| _COLLECTION = _apply_collection_raw({"hp":0,"atk":0,"def":0}) | |
| def acc_label(r:dict|None)->str: | |
| if not r: return "-" | |
| lv = int(r.get("lv",r.get("레벨",r.get("level",0)))) | |
| name = str(r.get("이름","")).strip() | |
| return f"{lv} {name}".strip() | |
| def acc_row_by_label(tag:str)->dict|None: | |
| try: | |
| df = accmod.df_acc.copy() | |
| except Exception: | |
| return None | |
| s = str(tag).strip() | |
| parts = s.split() | |
| lv, name = None, s | |
| try: | |
| lv = int(parts[0]); name = " ".join(parts[1:]).strip() | |
| except Exception: | |
| pass | |
| try: | |
| if lv is not None: | |
| sub = df[(df["lv"].astype(int)==lv)&(df["이름"].astype(str)==name)] | |
| else: | |
| sub = df[df["이름"].astype(str)==name] | |
| if not sub.empty: | |
| return sub.iloc[0].to_dict() | |
| except Exception: | |
| return None | |
| return None | |
| def accessory_names_by_levels(levels:List[int])->List[str]: | |
| try: | |
| df = accmod.df_acc.copy() | |
| df = df[df["lv"].astype(int).isin([int(x) for x in (levels or [])])] | |
| names = df[["lv","이름"]].astype({"lv":int,"이름":str}).sort_values(["lv","이름"]) | |
| return [f"{lv} {name}" for lv,name in names.values.tolist()] | |
| except Exception: | |
| return [] | |
| def _pct_from(x:Any)->Dict[str,float]: | |
| if isinstance(x,dict): | |
| return {"hp":float(x.get("hp",0.0)),"atk":float(x.get("atk",0.0)),"def":float(x.get("def",0.0))} | |
| return dict(BUFFS_ALL.get(str(x), {"hp":0,"atk":0,"def":0})) | |
| def tar_percent(B:float, M:float)->float: | |
| try: | |
| B = float(B); M = float(M) | |
| except Exception: | |
| return 0.0 | |
| if M <= 0: return 0.0 | |
| r = B/M | |
| if r <= 1.0: | |
| return round(max(0.0, 200.0*r - 100.0), 4) | |
| return round(100.0*r, 4) | |
| def make_gild_pct(hp, atk, df): | |
| def f(v): | |
| try: return float(v)/100.0 | |
| except Exception: return 0.0 | |
| return {"hp":f(hp),"atk":f(atk),"def":f(df)} | |
| # 등급 보정 | |
| GRADE_INCR_PER_1 = { | |
| "체":{"hp":80,"atk":0,"def":0}, | |
| "공":{"hp":0,"atk":20,"def":0}, | |
| "방":{"hp":0,"atk":0,"def":20}, | |
| "체방":{"hp":40,"atk":0,"def":10}, | |
| "체공":{"hp":40,"atk":10,"def":0}, | |
| "공방":{"hp":0,"atk":10,"def":10}, | |
| } | |
| def grade_boost_for_type_precise_tuple(typ:str, grade_str:str)->Tuple[int,int,int]: | |
| t = strip_jingak(typ) | |
| g = float(str(grade_str)) | |
| delta = max(0.0, g - 7.0) | |
| inc = GRADE_INCR_PER_1.get(t, {"hp":0,"atk":0,"def":0}) | |
| return (int(round(inc["hp"]*delta)), int(round(inc["atk"]*delta)), int(round(inc["def"]*delta))) | |
| def grade_boost_for_type_precise(typ:str, grade_str:str)->Dict[str,int]: | |
| h,a,d = grade_boost_for_type_precise_tuple(typ, grade_str) | |
| return {"hp":h, "atk":a, "def":d} | |
| # ============================================================================= | |
| # 계산 코어 | |
| # ============================================================================= | |
| def _floor_int(x:float)->int: | |
| return int(math.floor(float(x))) | |
| def compute_final_stat_and_bib( | |
| typ:str, | |
| ench_pct:Dict[str,float], | |
| acc_row:dict|None, | |
| gem_sums:Tuple[float,float,float], # (hpSum, atkSum, defSum) | |
| spirits_spec:List[dict], | |
| pendant_pct:Dict[str,float]|None, | |
| buff_label_or_pct:Dict[str,float]|str, | |
| nerf_label_or_pct:Dict[str,float]|str, | |
| grade_sel:str, | |
| base_stat_manual:Dict[str,int]|None=None, | |
| jingak:bool=False | |
| )->Tuple[int, Dict[str,int]]: | |
| bs = _get_base_stats_cached(typ if base_stat_manual is None else strip_jingak(typ)) if base_stat_manual is None else dict(base_stat_manual) | |
| aw = _get_awakening_stat_cached(typ) if (jingak and base_stat_manual is None) else {"hp":0,"atk":0,"def":0} | |
| boost_h, boost_a, boost_d = grade_boost_for_type_precise_tuple(typ, grade_sel) | |
| C1 = {"hp": bs["hp"] + aw["hp"] + boost_h, "atk": bs["atk"] + aw["atk"] + boost_a, "def": bs["def"] + aw["def"] + boost_d} | |
| C2 = {"hp":gem_sums[0]*4, "atk":gem_sums[1], "def":gem_sums[2]} | |
| base_total = {k: C1[k] + C2[k] for k in C1} | |
| base_total = {k: base_total[k] + _POTION.get(k,0) for k in base_total} | |
| hp_pct = float((acc_row or {}).get("hp%",0))/100.0 | |
| atk_pct = float((acc_row or {}).get("atk%",0))/100.0 | |
| def_pct = float((acc_row or {}).get("def%",0))/100.0 | |
| ench = ench_pct or {"hp":0,"atk":0,"def":0} | |
| mul1 = {"hp":1+hp_pct+ench.get("hp",0.0), | |
| "atk":1+atk_pct+ench.get("atk",0.0), | |
| "def":1+def_pct+ench.get("def",0.0)} | |
| st1 = {k:_floor_int(base_total[k]*mul1[k]) for k in ("hp","atk","def")} | |
| pct7, flat8, sub9 = _spirit_breakdown_cached(_sp_key_for_cache(spirits_spec)) | |
| st2 = {k: _floor_int(st1[k]*(1+pct7[k]) + flat8[k]*(1+pct7[k])) for k in ("hp","atk","def")} | |
| if pendant_pct: | |
| st2 = {k: _floor_int(st2[k]*(1.0+float(pendant_pct.get(k,0.0)))) for k in st2} | |
| st3 = {k: st2[k] + _COLLECTION.get(k,0) + sub9[k] for k in ("hp","atk","def")} | |
| bs_for_buff = {"hp": bs["hp"] + boost_h, "atk": bs["atk"] + boost_a, "def": bs["def"] + boost_d} | |
| buff = _pct_from(buff_label_or_pct) | |
| nerf = _pct_from(nerf_label_or_pct) | |
| eff = {"hp":buff["hp"]-nerf["hp"], "atk":buff["atk"]-nerf["atk"], "def":buff["def"]-nerf["def"]} | |
| add_buff = {k: _floor_int(bs_for_buff[k]*eff[k]) for k in ("hp","atk","def")} | |
| final = {k: st3[k] + add_buff[k] for k in ("hp","atk","def")} | |
| bib = final["hp"] * final["atk"] * final["def"] | |
| return int(bib), final | |
| # ============================================================================= | |
| # SPEC 분모 | |
| # ============================================================================= | |
| def _fast_denom_or_none(profile:int, typ:str, buff_label:str)->float|None: | |
| if int(profile) not in (1,2): return None | |
| if strip_jingak(typ) not in BASE_TYPES: return None | |
| if buff_label not in TWO_BUFFS: return None | |
| try: | |
| v = _fast_denom_get(profile, strip_jingak(typ), buff_label) | |
| if int(v) > 0: return float(v) | |
| except Exception: | |
| pass | |
| return None | |
| def get_plain_M(profile:int, typ:str, label:str)->float: | |
| fast = _fast_denom_or_none(profile, typ, label) | |
| if fast is not None: | |
| return float(fast) | |
| try: | |
| m = spec_db.get_M(profile, strip_jingak(typ), label) | |
| return float(m) if m is not None else 0.0 | |
| except Exception: | |
| return 0.0 | |
| # ============================================================================= | |
| # 젬/정령/펜던트 보조 | |
| # ============================================================================= | |
| def _compact_used_gems(items:List[str])->str: | |
| order = {"체":0,"공":1,"방":2} | |
| arr = [] | |
| for s in items: | |
| s = s.strip() | |
| if not s: continue | |
| arr.append((order.get(s[0],9), s, int("".join(ch for ch in s[1:] if ch.isdigit()) or "0"))) | |
| arr.sort(key=lambda x:(x[0], -x[2])) | |
| return " ".join(s for _,s,_ in arr) | |
| def spirit_default_row()->dict: | |
| return { | |
| "사용": False, | |
| "귀속": "정령 귀속X", | |
| "1옵 스탯": "체력", "1옵 모드": "%", | |
| "2옵 스탯": "공격력", "2옵 모드": "%", | |
| "3옵 스탯": "방어력", "3옵 모드": "%", | |
| "4옵 스탯": "방어력", "4옵 모드": "+", | |
| "부가옵": "체력40", | |
| } | |
| def make_spirit_obj(row:dict)->List[dict]: | |
| return [ | |
| {"slot":1,"stat":row["1옵 스탯"],"type":row["1옵 모드"]}, | |
| {"slot":2,"stat":row["2옵 스탯"],"type":row["2옵 모드"]}, | |
| {"slot":3,"stat":row["3옵 스탯"],"type":row["3옵 모드"]}, | |
| {"slot":4,"stat":row["4옵 스탯"],"type":row["4옵 모드"]}, | |
| {"slot":5,"stat":row["부가옵"],"type":"부가옵"}, | |
| ] | |
| def format_spirit_label(sp:dict|List[dict])->str: | |
| s = sp if isinstance(sp,list) else sp["spec"] | |
| def one(slot): | |
| st = s[slot-1] | |
| if st["slot"]==5: | |
| return st["stat"] | |
| unit = "%" if st["type"]=="%" else "+" | |
| return f'{st["stat"][0]}{unit}' | |
| return f'{one(1)}/{one(2)}/{one(3)}/{one(4)}/{s[4]["stat"]}' | |
| def gem_inventory_default()->pd.DataFrame: | |
| return pd.DataFrame([{"수치":v,"체":0,"공":0,"방":0} for v in [34,35,36,37,38,39,40]]) | |
| def _as_int_safe(x, default=0): | |
| try: | |
| if x is None or (isinstance(x,float) and math.isnan(x)): | |
| return default | |
| return int(float(x)) | |
| except Exception: | |
| return default | |
| # ------------------------------------------------------------------------- | |
| # 🚀 최적화된 GemPool 클래스 (Dict 대신 List 사용) | |
| # ------------------------------------------------------------------------- | |
| class GemPool: | |
| def __init__(self, df: pd.DataFrame = None, _internal_data=None): | |
| if _internal_data is not None: | |
| self.data = _internal_data | |
| self.valid = True | |
| return | |
| self.data = [[0]*7, [0]*7, [0]*7] | |
| self.valid = False | |
| if df is None or df.empty: | |
| return | |
| col_map = {"수치": None, "체": None, "공": None, "방": None} | |
| for k in list(col_map.keys()): | |
| for cand in [k, k.strip(), str(k)]: | |
| if cand in df.columns: | |
| col_map[k] = cand | |
| break | |
| if not all(col_map.values()): | |
| return | |
| for _, r in df.iterrows(): | |
| try: | |
| v = int(float(r[col_map["수치"]])) | |
| except: continue | |
| if v < 34 or v > 40: continue | |
| idx = v - 34 | |
| def get_val(col): | |
| val = r.get(col, 0) | |
| try: return int(float(0 if (val is None or (isinstance(val,str) and not val.strip())) else val)) | |
| except: return 0 | |
| self.data[0][idx] += get_val(col_map["체"]) | |
| self.data[1][idx] += get_val(col_map["공"]) | |
| self.data[2][idx] += get_val(col_map["방"]) | |
| self.valid = True | |
| def total_count(self): | |
| return sum(sum(arr) for arr in self.data) | |
| def check_feasibility(self, dist_list: List[Tuple[int,int,int]]) -> List[Tuple[int,int,int]]: | |
| h_sum = sum(self.data[0]) | |
| a_sum = sum(self.data[1]) | |
| d_sum = sum(self.data[2]) | |
| out = [] | |
| for d in dist_list: | |
| if h_sum >= d[0] and a_sum >= d[1] and d_sum >= d[2] and sum(d)==5: | |
| out.append(d) | |
| return out | |
| def allocate_fast(self, dist: Tuple[int,int,int]) -> Tuple[bool, Tuple[float,float,float], List[Tuple[int,int]]]: | |
| sums = [0.0, 0.0, 0.0] | |
| rollback_info = [] | |
| for stat_idx in range(3): | |
| need = dist[stat_idx] | |
| if need == 0: continue | |
| arr = self.data[stat_idx] | |
| current_sum = 0 | |
| for i in range(6, -1, -1): | |
| if need <= 0: break | |
| count = arr[i] | |
| if count > 0: | |
| take = min(need, count) | |
| arr[i] -= take | |
| need -= take | |
| val = i + 34 | |
| current_sum += take * val | |
| rollback_info.append((stat_idx, i, take)) | |
| if need > 0: | |
| self.restore(rollback_info) | |
| return False, (0,0,0), [] | |
| sums[stat_idx] = float(current_sum) | |
| return True, tuple(sums), rollback_info | |
| def restore(self, rollback_info: List[Tuple[int,int]]): | |
| for stat_idx, val_idx, count in rollback_info: | |
| self.data[stat_idx][val_idx] += count | |
| def generate_used_gem_string(self, rollback_info: List[Tuple[int,int]]) -> List[str]: | |
| res = [] | |
| labels = ["체", "공", "방"] | |
| for stat_idx, val_idx, count in rollback_info: | |
| name = f"{labels[stat_idx]}{val_idx + 34}" | |
| res.extend([name] * count) | |
| return res | |
| def copy_fast(self): | |
| new_data = [arr[:] for arr in self.data] | |
| return GemPool(None, _internal_data=new_data) | |
| def get_optimistic_sum(self, stat_idx: int, count: int) -> float: | |
| if count <= 0: return 0.0 | |
| arr = self.data[stat_idx] | |
| total_val = 0.0 | |
| taken = 0 | |
| for idx in range(6, -1, -1): | |
| if taken >= count: break | |
| available = arr[idx] | |
| if available > 0: | |
| grab = min(count - taken, available) | |
| val = idx + 34 | |
| total_val += grab * val | |
| taken += grab | |
| return total_val | |
| def gem_inventory_to_pool(df: pd.DataFrame) -> GemPool: | |
| return GemPool(df) | |
| def _feasible_dists_by_count(pool: GemPool) -> List[Tuple[int,int,int]]: | |
| return pool.check_feasibility(GEM_DISTS) | |
| def allocate_gems_for_dist(pool: GemPool, dist: Tuple[int,int,int]): | |
| ok, sums, rb = pool.allocate_fast(dist) | |
| if not ok: return False, {}, (0,0,0), [] | |
| return True, {}, sums, [] | |
| # ============================================================================= | |
| # [수정] 펜던트 로직 최적화 (이 부분을 기존 펜던트 함수들 대신 덮어씌우세요) | |
| # ============================================================================= | |
| def _get_partitions(total: int, num_slots: int, max_val: int = 6) -> List[Tuple[int, ...]]: | |
| """ | |
| 정해진 합(total)을 num_slots개의 숫자로 나누는 모든 경우의 수 (순서 유의미) | |
| 각 슬롯의 최대값은 max_val(6) | |
| """ | |
| if num_slots == 1: | |
| if 0 <= total <= max_val: | |
| return [(total,)] | |
| return [] | |
| res = [] | |
| # 첫 슬롯에 들어갈 수 있는 값의 범위 계산 (가지치기) | |
| min_current = max(0, total - (num_slots - 1) * max_val) | |
| max_current = min(total, max_val) | |
| for i in range(min_current, max_current + 1): | |
| for p in _get_partitions(total - i, num_slots - 1, max_val): | |
| res.append((i,) + p) | |
| return res | |
| def enumerate_pendants(total: int, grade: str) -> List[Dict[str, float]]: | |
| """ | |
| 펜던트 조합 생성기 (캐싱 및 DP 적용으로 속도 대폭 개선) | |
| """ | |
| grade_to_lines = {"별": 1, "달": 2, "태양": 3} | |
| L = grade_to_lines.get(grade, 3) | |
| # 유효 범위 체크 (최소 0, 최대 6 * 줄 수) | |
| total = max(0, min(int(total), 6 * L)) | |
| if total <= 0: | |
| return [None] # 0%일 경우 | |
| # 1. 숫자를 나누는 경우의 수만 먼저 구함 (매우 빠름) | |
| partitions = _get_partitions(total, L, 6) | |
| unique_stats = set() | |
| result = [] | |
| # 2. 구해진 숫자 조합에 스탯(체/공/방) 속성 부여 | |
| stat_types = ["hp", "atk", "def"] | |
| # 중복 계산 방지 | |
| for stat_comb in itertools.product(stat_types, repeat=L): | |
| for part in partitions: | |
| current_stat = {"hp": 0, "atk": 0, "def": 0} | |
| for val, s_type in zip(part, stat_comb): | |
| current_stat[s_type] += val | |
| # 튜플로 변환하여 중복 제거 (Set 활용) | |
| stat_tuple = (current_stat["hp"], current_stat["atk"], current_stat["def"]) | |
| if stat_tuple not in unique_stats: | |
| unique_stats.add(stat_tuple) | |
| result.append({k: v / 100.0 for k, v in current_stat.items()}) | |
| return result or [None] | |
| def pendant_label(pct:Dict[str,float]|None)->str: | |
| if not pct: return "0/0/0" | |
| return f'{int(round(pct.get("hp",0)*100))}/{int(round(pct.get("atk",0)*100))}/{int(round(pct.get("def",0)*100))}' | |
| # ============================================================================= | |
| # 즐겨찾기(롤백: 단일 슬롯) | |
| # ============================================================================= | |
| _FAV_DB = os.path.join(os.path.dirname(__file__), "tar_fav.json") | |
| def _fav_load(): | |
| try: | |
| with open(_FAV_DB,"r",encoding="utf-8") as f: | |
| data = json.load(f) | |
| if not isinstance(data, dict): | |
| return {} | |
| if "_global" in data: | |
| data.pop("_global", None) | |
| return data | |
| except Exception: | |
| return {} | |
| def _fav_save(d:dict): | |
| tmp = _FAV_DB + ".tmp" | |
| with open(tmp,"w",encoding="utf-8") as f: | |
| json.dump(d,f,ensure_ascii=False) | |
| f.flush(); os.fsync(f.fileno()) | |
| os.replace(tmp,_FAV_DB) | |
| def fav_save_generic(keyspace:str, payload:dict, request:gr.Request|None): | |
| d = _fav_load() | |
| d.pop("_global", None) | |
| ipk = _ip_key(request) | |
| ts = int(_now().timestamp()) | |
| per_ip = d.get(ipk, {}) | |
| per_ip[keyspace] = {"ts": ts, "data": payload} | |
| d[ipk] = per_ip | |
| _fav_save(d) | |
| return "<span style='color:green;'>즐겨찾기 저장 완료</span>" | |
| def fav_load_generic(keyspace:str, request:gr.Request|None)->dict: | |
| d = _fav_load() | |
| ipk = _ip_key(request) | |
| obj = ((d.get(ipk) or {}).get(keyspace) or {}).get("data") | |
| return obj if isinstance(obj, dict) else {} | |
| # ============================================================================= | |
| # 자동 계산기 | |
| # ============================================================================= | |
| def resolve_row_buff_list(mode:str)->List[Tuple[str,Dict[str,float]]]: | |
| if mode=="최적화": | |
| return [(k,v) for k,v in BUFFS_ALL.items() if k!="0벞"] | |
| if mode=="1벞 최적화": | |
| keys=["HP20%","ATK20%","DEF20%"] | |
| return [(k, BUFFS_ALL[k]) for k in keys if k in BUFFS_ALL] | |
| if mode=="0벞": | |
| return [("0벞",BUFFS_ALL["0벞"])] | |
| return [(mode, BUFFS_ALL.get(mode, {"hp":0,"atk":0,"def":0}))] | |
| def auto_run( | |
| types_sel, gem_vals_sel, | |
| acc_packed, | |
| s1,m1,s2,m2,s3,m3,s4,m4,subopt,sp_skip, | |
| buff_mode, nerf_mode, grade_sel, | |
| _unused, | |
| pnd_opt_on, pnd_total, pnd_grade, | |
| pnd_manual_on, pnd_manual_entries, | |
| dedup_type, dedup_buff, dedup_acc, dedup_ench, dedup_sp, | |
| topn, sort_key | |
| )->pd.DataFrame: | |
| try: | |
| types = BASE_TYPES[:] if not types_sel else types_sel[:] | |
| gem_vals = [int(x) for x in (gem_vals_sel or [str(v) for v in range(34,41)])] | |
| acc_rows=[] | |
| for tag,use,hp,atk,df in acc_packed or []: | |
| if not bool(use): continue | |
| base = acc_row_by_label(tag) | |
| if not base: continue | |
| flags=[] | |
| if bool(hp): flags.append(("HP",ENCH_DICT["HP"])) | |
| if bool(atk): flags.append(("ATK",ENCH_DICT["ATK"])) | |
| if bool(df): flags.append(("DEF",ENCH_DICT["DEF"])) | |
| if not flags: flags = ENCH_LIST[:] | |
| for nm,pct in flags: | |
| acc_rows.append((base,nm,pct)) | |
| if not acc_rows: | |
| for n in accessory_names_by_levels([19]): | |
| base=acc_row_by_label(n) | |
| if base: acc_rows.append((base,"HP",ENCH_DICT["HP"])) | |
| spirit = [] if bool(sp_skip) else [ | |
| {"slot":1,"stat":s1,"type":m1}, | |
| {"slot":2,"stat":s2,"type":m2}, | |
| {"slot":3,"stat":s3,"type":m3}, | |
| {"slot":4,"stat":s4,"type":m4}, | |
| {"slot":5,"stat":subopt,"type":"부가옵"}, | |
| ] | |
| buff_items = resolve_row_buff_list(buff_mode) | |
| pnd_auto_list = enumerate_pendants(pnd_total if pnd_opt_on else 0, pnd_grade) | |
| pnd_manual_cands: List[Dict[str,float]] = [] | |
| if pnd_manual_on: | |
| for ent in pnd_manual_entries or []: | |
| if not ent.get("use"): continue | |
| lines=[(ent.get("l1s"), ent.get("l1v",0)), | |
| (ent.get("l2s"), ent.get("l2v",0)), | |
| (ent.get("l3s"), ent.get("l3v",0))] | |
| accp={"hp":0,"atk":0,"def":0} | |
| for st,v in lines: | |
| st2={"체력":"hp","공격력":"atk","방어력":"def"}.get(st) | |
| v=max(0,min(6,int(v or 0))) | |
| if st2: accp[st2]+=v | |
| if accp["hp"]+accp["atk"]+accp["def"]>0: | |
| pnd_manual_cands.append({k:accp[k]/100.0 for k in accp}) | |
| rows=[] | |
| for typ in types: | |
| base_manual = None if "(진각)" in typ else _get_base_stats_cached(strip_jingak(typ)) | |
| jingak = "(진각)" in typ | |
| for gv in gem_vals: | |
| for dist in GEM_DISTS: | |
| if sum(dist)!=5: continue | |
| gem_sums = (dist[0]*gv, dist[1]*gv, dist[2]*gv) | |
| for (acc_row,ench_name,ench_pct) in acc_rows: | |
| pendants = (pnd_manual_cands if (pnd_manual_on and pnd_manual_cands) else pnd_auto_list) | |
| for buf_label,_pct in buff_items: | |
| for pnd_pct in pendants: | |
| bib, final = compute_final_stat_and_bib( | |
| typ, ench_pct, acc_row, gem_sums, | |
| spirit, pnd_pct, buf_label, nerf_mode, | |
| grade_sel, base_stat_manual=base_manual, jingak=jingak | |
| ) | |
| M = get_plain_M(2, strip_jingak(typ), buf_label if buf_label in TWO_BUFFS else "HP40%") | |
| TAR = tar_percent(bib, M) | |
| rows.append({ | |
| "타입":typ, "버프":buf_label, "너프":nerf_mode, | |
| "펜던트": pendant_label(pnd_pct), | |
| "젬수치":gv, "젬분배":f"{dist[0]}/{dist[1]}/{dist[2]}", | |
| "장신구":acc_label(acc_row), "인첸트":ench_name, | |
| "정령": ("미사용" if not spirit else format_spirit_label(spirit)), | |
| "비벨":int(bib), "TAR%":TAR, | |
| "HP":final["hp"], "ATK":final["atk"], "DEF":final["def"], | |
| }) | |
| if not rows: return pd.DataFrame() | |
| df=pd.DataFrame(rows) | |
| if sort_key=="TAR%": | |
| df=df.sort_values(["TAR%","비벨"],ascending=False) | |
| else: | |
| df=df.sort_values(["비벨","TAR%"],ascending=False) | |
| keys=[] | |
| if dedup_type: keys.append("타입") | |
| if dedup_buff: keys.append("버프") | |
| if dedup_acc: keys.append("장신구") | |
| if dedup_ench: keys.append("인첸트") | |
| if dedup_sp: keys.append("정령") | |
| if keys: df=df.drop_duplicates(subset=keys, keep="first") | |
| return df.reset_index(drop=True).head(int(topn)) | |
| except Exception as e: | |
| return pd.DataFrame({"오류":[str(e)]}) | |
| # ============================================================================= | |
| # 길드전 최적화 (GemPool & FastCandidate 적용) | |
| # ============================================================================= | |
| CSS = """ | |
| footer, #footer, .theme-toggle {display:none !important;} | |
| .gradio-container {max-width: 1220px !important; margin: 0 auto !important;} | |
| #guild_table_df table td:nth-child(1), #guild_table_df table th:nth-child(1){ width:44px !important; text-align:center; } | |
| """ | |
| FOCUS_KILL_JS = """ | |
| <script> | |
| (function(){ | |
| var T=200, until=0; | |
| function now(){return Date.now();} | |
| function isTab(el){ if(!el) return false; var t=(el.tagName||'').toLowerCase(); return t==='summary'||(el.closest && el.closest('summary')); } | |
| document.addEventListener('pointerdown', e=>{ if(isTab(e.target)) until=now()+T;}, true); | |
| document.addEventListener('focusout', e=>{ if(now()<until) e.stopPropagation(); }, true); | |
| })(); | |
| </script> | |
| """ | |
| NUM_DECKS = 3 | |
| def _score_tuple(sel:List[dict], goal:str)->Tuple[float,float]: | |
| tars = sum(c["T"] for c in sel)/NUM_DECKS | |
| bibs = sum(c["bib"] for c in sel)/NUM_DECKS | |
| if goal=="TAR우선": return (tars, bibs) | |
| return (bibs, tars) # 비벨우선 | |
| # ------------------------------------------------------------------------- | |
| # 🚀 FastCandidate: 루프 내 계산 최소화 클래스 | |
| # ------------------------------------------------------------------------- | |
| class FastCandidate: | |
| __slots__ = ( | |
| "slot", "typ", "buff", "nerf", "grade", | |
| "acc_key", "acc_row", "allowed", "no_enchant", | |
| "sp", "sp_label", | |
| "dist", "pnd", "pnd_id", | |
| "c1", "pct7", "flat8", "sub9", "eff", "bs_buff", | |
| "acc_hp_pct", "acc_atk_pct", "acc_def_pct", | |
| "pnd_mul", "coll_add", "potion_add", | |
| "approx_bib", "approx_tar" | |
| ) | |
| def __init__(self, slot, typ, buff, nerf, grade, | |
| acc_key, acc_row, allowed, no_enchant, | |
| sp, dist, pnd, pnd_id, M_val, | |
| optimistic_sums: Tuple[float,float,float]): # [수정] max_gem_vals 대신 optimistic_sums 사용 | |
| self.slot = slot | |
| self.typ = typ | |
| self.buff = buff | |
| self.nerf = nerf | |
| self.grade = grade | |
| self.acc_key = acc_key | |
| self.acc_row = acc_row | |
| self.allowed = allowed | |
| self.no_enchant = no_enchant | |
| self.sp = sp | |
| self.sp_label = format_spirit_label(sp["spec"]) | |
| self.dist = dist | |
| self.pnd = pnd | |
| self.pnd_id = pnd_id | |
| # 1. Base Stats Precalc | |
| jingak = "(진각)" in typ | |
| base_stat_manual = None if jingak else _get_base_stats_cached(strip_jingak(typ)) | |
| bs = base_stat_manual if base_stat_manual else _get_base_stats_cached(strip_jingak(typ)) | |
| aw = _get_awakening_stat_cached(typ) if (jingak and not base_stat_manual) else {"hp":0,"atk":0,"def":0} | |
| boost_h, boost_a, boost_d = grade_boost_for_type_precise_tuple(typ, grade) | |
| self.c1 = (bs["hp"] + aw["hp"] + boost_h, | |
| bs["atk"] + aw["atk"] + boost_a, | |
| bs["def"] + aw["def"] + boost_d) | |
| pct7, flat8, sub9 = _spirit_breakdown_cached(_sp_key_for_cache(sp["spec"])) | |
| self.pct7 = (1+pct7["hp"], 1+pct7["atk"], 1+pct7["def"]) | |
| self.flat8 = (flat8["hp"], flat8["atk"], flat8["def"]) | |
| self.sub9 = (sub9["hp"], sub9["atk"], sub9["def"]) | |
| if pnd: | |
| self.pnd_mul = (1.0+pnd.get("hp",0.0), 1.0+pnd.get("atk",0.0), 1.0+pnd.get("def",0.0)) | |
| else: | |
| self.pnd_mul = (1.0, 1.0, 1.0) | |
| self.coll_add = (_COLLECTION.get("hp",0), _COLLECTION.get("atk",0), _COLLECTION.get("def",0)) | |
| self.potion_add = (_POTION.get("hp",0), _POTION.get("atk",0), _POTION.get("def",0)) | |
| b = _pct_from(buff) | |
| n = _pct_from(nerf) | |
| self.eff = (b["hp"]-n["hp"], b["atk"]-n["atk"], b["def"]-n["def"]) | |
| self.bs_buff = (bs["hp"]+boost_h, bs["atk"]+boost_a, bs["def"]+boost_d) | |
| self.acc_hp_pct = float((acc_row or {}).get("hp%",0))/100.0 | |
| self.acc_atk_pct = float((acc_row or {}).get("atk%",0))/100.0 | |
| self.acc_def_pct = float((acc_row or {}).get("def%",0))/100.0 | |
| # [핵심] 낙관적 현실주의: 내 인벤토리에서 '가장 좋은 젬'을 썼을 때의 점수 | |
| heur_ench = {"hp":0.21,"atk":0,"def":0} if (not no_enchant and (not allowed or "HP" in allowed)) else {"hp":0,"atk":0,"def":0} | |
| b_score, _ = self.compute(optimistic_sums, heur_ench) | |
| self.approx_bib = b_score | |
| self.approx_tar = tar_percent(b_score, M_val) | |
| def compute(self, gem_sums: Tuple[float,float,float], ench_pct: Dict[str,float]) -> Tuple[int, Dict[str,int]]: | |
| # Inline Compute | |
| # Base Total | |
| base_h = self.c1[0] + gem_sums[0]*4 + self.potion_add[0] | |
| base_a = self.c1[1] + gem_sums[1] + self.potion_add[1] | |
| base_d = self.c1[2] + gem_sums[2] + self.potion_add[2] | |
| # Mul1 (Acc + Ench) | |
| m1_h = 1.0 + self.acc_hp_pct + ench_pct.get("hp",0.0) | |
| m1_a = 1.0 + self.acc_atk_pct + ench_pct.get("atk",0.0) | |
| m1_d = 1.0 + self.acc_def_pct + ench_pct.get("def",0.0) | |
| st1_h = int(base_h * m1_h) | |
| st1_a = int(base_a * m1_a) | |
| st1_d = int(base_d * m1_d) | |
| # Spirit (Pct7 + Flat8) | |
| st2_h = int(st1_h * self.pct7[0] + self.flat8[0] * self.pct7[0]) | |
| st2_a = int(st1_a * self.pct7[1] + self.flat8[1] * self.pct7[1]) | |
| st2_d = int(st1_d * self.pct7[2] + self.flat8[2] * self.pct7[2]) | |
| # Pendant | |
| st2_h = int(st2_h * self.pnd_mul[0]) | |
| st2_a = int(st2_a * self.pnd_mul[1]) | |
| st2_d = int(st2_d * self.pnd_mul[2]) | |
| # Collection + Sub9 | |
| st3_h = st2_h + self.coll_add[0] + self.sub9[0] | |
| st3_a = st2_a + self.coll_add[1] + self.sub9[1] | |
| st3_d = st2_d + self.coll_add[2] + self.sub9[2] | |
| # Buff Add | |
| add_h = int(self.bs_buff[0] * self.eff[0]) | |
| add_a = int(self.bs_buff[1] * self.eff[1]) | |
| add_d = int(self.bs_buff[2] * self.eff[2]) | |
| fh = st3_h + add_h | |
| fa = st3_a + add_a | |
| fd = st3_d + add_d | |
| return (fh*fa*fd), {"hp":fh, "atk":fa, "def":fd} | |
| def _assign_best_enchant_fast(cand: FastCandidate, sums: Tuple[float,float,float]) -> Tuple[str, Dict, int, Dict]: | |
| if cand.no_enchant or not cand.allowed: | |
| bib, final = cand.compute(sums, {"hp":0,"atk":0,"def":0}) | |
| return "없음", {"hp":0,"atk":0,"def":0}, bib, final | |
| best_name = None | |
| best_pct = None | |
| best_bib = -1 | |
| best_final = None | |
| # Try allowed enchants | |
| for nm, pct in ENCH_LIST: | |
| if cand.allowed and nm not in cand.allowed: | |
| continue | |
| bib, final = cand.compute(sums, pct) | |
| if bib > best_bib: | |
| best_bib = bib | |
| best_name = nm | |
| best_pct = pct | |
| best_final = final | |
| return best_name, best_pct, best_bib, best_final | |
| # ============================================================================= | |
| # [수정됨] 길드전 최적화 제너레이터 (젬 패턴 중복 제거로 속도 15배 향상) | |
| # ============================================================================= | |
| def guild_optimize_generator( | |
| week_typ, week_buff, | |
| slot_used_list, slot_typ_list, slot_buff_list, slot_grade_list, slot_nerf_list, | |
| pendant_inv_rows, | |
| acc_df, gem_df, spirits_rows, | |
| top_k=120, min_tar_cut=0.0, | |
| goal:str="비벨우선", | |
| mode:str="중간" | |
| ) -> Generator[Tuple[pd.DataFrame, pd.DataFrame, dict], None, None]: | |
| used_slots = [i+1 for i,u in enumerate(slot_used_list) if bool(u)] | |
| if len(used_slots) < NUM_DECKS: | |
| yield pd.DataFrame({"오류":[f"드래곤 슬롯 최소 {NUM_DECKS}개 사용 체크 필요"]}), pd.DataFrame({"평균 비벨":[0],"평균TAR":[0]}), {"choice":[]} | |
| return | |
| # 모드별 설정 | |
| if mode == "신속": | |
| limit_base = 100 | |
| prune_factor = 0.90 | |
| depth_limits = [1.0, 0.3, 0.1] | |
| elif mode == "전수": | |
| limit_base = 50000 | |
| prune_factor = 1.0 | |
| depth_limits = [1.0, 1.0, 1.0] | |
| else: # 중간 | |
| limit_base = 2000 | |
| prune_factor = 0.98 | |
| depth_limits = [1.0, 0.4, 0.2] | |
| M = get_plain_M(2, week_typ, week_buff) | |
| # ----- 장신구 풀 ----- | |
| try: | |
| base = accmod.df_acc.copy() | |
| base_key={f'{int(r.get("lv",19))} {str(r.get("이름","")).strip()}': r.to_dict() for _,r in base.iterrows()} | |
| except Exception: | |
| base_key={} | |
| acc_pool=[] | |
| if acc_df is not None and not acc_df.empty: | |
| for _,r in acc_df.iterrows(): | |
| if not bool(r.get("사용",False)): | |
| continue | |
| tag = str(r.get("장신구","")).strip() | |
| inst = str(r.get("인스턴스","#1")).strip() or "#1" | |
| acc_row = base_key.get(tag) | |
| if not acc_row: | |
| continue | |
| allowed = set() | |
| if bool(r.get("HP",True)): allowed.add("HP") | |
| if bool(r.get("ATK",True)): allowed.add("ATK") | |
| if bool(r.get("DEF",True)): allowed.add("DEF") | |
| acc_pool.append({ | |
| "key": f"{tag} {inst}", | |
| "acc": acc_row, | |
| "allowed": allowed, | |
| "no_enchant": (not allowed) | |
| }) | |
| if len(acc_pool) < NUM_DECKS: | |
| yield pd.DataFrame({"오류":["장신구 인벤 사용 체크 필요"]}), pd.DataFrame({"평균 비벨":[0],"평균TAR":[0]}), {"choice":[]} | |
| return | |
| # ----- 젬 풀 ----- | |
| gem_pool_master = gem_inventory_to_pool(gem_df) | |
| if gem_pool_master.total_count() < 5*NUM_DECKS: | |
| yield pd.DataFrame({"오류":[f"젬 최소 {5*NUM_DECKS}개 필요"]}), pd.DataFrame({"평균 비벨":[0],"평균TAR":[0]}), {"choice":[]} | |
| return | |
| feasible_patterns = _feasible_dists_by_count(gem_pool_master) | |
| if not feasible_patterns: | |
| yield pd.DataFrame({"오류":["젬 분배 패턴 없음(인벤 확인)"]}), pd.DataFrame({"평균 비벨":[0],"평균TAR":[0]}), {"choice":[]} | |
| # [최적화] 젬 패턴별 '낙관적 합계' 미리 계산 (반복 계산 제거) | |
| precalc_gem_opts = [] | |
| for dist in feasible_patterns: | |
| opt_sums = ( | |
| gem_pool_master.get_optimistic_sum(0, dist[0]), | |
| gem_pool_master.get_optimistic_sum(1, dist[1]), | |
| gem_pool_master.get_optimistic_sum(2, dist[2]) | |
| ) | |
| precalc_gem_opts.append((dist, opt_sums)) | |
| # ----- 정령 풀 ----- | |
| spirit_pool=[] | |
| if spirits_rows: | |
| for i,r in enumerate(spirits_rows, start=1): | |
| if not r.get("사용",False): | |
| continue | |
| obj = make_spirit_obj(r) | |
| bind = r.get("귀속","정령 귀속X") | |
| bind_slot=None | |
| if isinstance(bind,str) and bind.endswith("번드래곤"): | |
| try: bind_slot=int(bind.replace("번드래곤","")) | |
| except Exception: bind_slot=None | |
| spirit_pool.append({"id":f"SP{i}","bind":bind_slot,"spec":obj}) | |
| if len(spirit_pool) < NUM_DECKS: | |
| yield pd.DataFrame({"오류":[f"정령 최소 {NUM_DECKS}개 사용 체크 필요"]}), pd.DataFrame({"평균 비벨":[0],"평균TAR":[0]}), {"choice":[]} | |
| return | |
| # ----- 펜던트 풀 ----- | |
| pendant_pool=[] | |
| for i,pr in enumerate(pendant_inv_rows or [], start=1): | |
| if not pr.get("use",False): | |
| continue | |
| lines=[(pr.get("l1s"), pr.get("l1v",0)), | |
| (pr.get("l2s"), pr.get("l2v",0)), | |
| (pr.get("l3s"), pr.get("l3v",0))] | |
| accp={"hp":0,"atk":0,"def":0} | |
| for st,v in lines: | |
| st2={"체력":"hp","공격력":"atk","방어력":"def"}.get(st) | |
| v=max(0,min(6,int(v or 0))) | |
| if st2: accp[st2]+=v | |
| if accp["hp"]+accp["atk"]+accp["def"]>0: | |
| pendant_pool.append({"id":f"PND{i}", "pct":{k:accp[k]/100.0 for k in accp}}) | |
| # 펜던트 미사용(None)도 후보에 포함 | |
| pendant_pool_with_none = [None] + pendant_pool | |
| # ----- 후보 생성 (최적화됨) ----- | |
| cand_by_slot={} | |
| for i in used_slots: | |
| typ = slot_typ_list[i-1] | |
| buf = slot_buff_list[i-1] | |
| nerf = slot_nerf_list[i-1] | |
| grade = slot_grade_list[i-1] | |
| cands=[] | |
| # [최적화 핵심] 루프 순서 변경: 장비 조합(Acc+Sp+Pnd)을 먼저 정하고, 그 조합에서 가장 좋은 젬 패턴 1개만 선택 | |
| for acc in acc_pool: | |
| for sp in spirit_pool: | |
| if sp.get("bind") not in (None,i): | |
| continue | |
| for pn in pendant_pool_with_none: | |
| pnd_pct = None if pn is None else pn["pct"] | |
| pnd_id = None if pn is None else pn["id"] | |
| # 이 장비 조합에서 최고의 젬 패턴 찾기 | |
| best_fc = None | |
| best_score = -1.0 | |
| for dist, opt_sums in precalc_gem_opts: | |
| # 후보 객체 임시 생성 (빠름) | |
| fc = FastCandidate(i, typ, buf, nerf, grade, | |
| acc["key"], acc["acc"], acc["allowed"], acc["no_enchant"], | |
| sp, dist, pnd_pct, pnd_id, M, opt_sums) | |
| if fc.approx_bib > best_score: | |
| best_score = fc.approx_bib | |
| best_fc = fc | |
| # 가장 좋은 젬 패턴을 가진 후보 1개만 등록 | |
| if best_fc: | |
| cands.append(best_fc) | |
| if not cands: continue | |
| cands.sort(key=lambda x: x.approx_bib, reverse=True) | |
| cand_by_slot[i] = cands | |
| valid=[i for i in used_slots if cand_by_slot.get(i)] | |
| if len(valid) < NUM_DECKS: | |
| yield pd.DataFrame({"오류":["유효 후보 부족"]}), pd.DataFrame({"평균 비벨":[0],"평균TAR":[0]}), {"choice":[]} | |
| return | |
| slot_lists=[cand_by_slot[i] for i in valid] | |
| # Pruning용 최대 점수 계산 | |
| max_scores = [cands[0].approx_bib for cands in slot_lists] | |
| remaining_max = [0] * (NUM_DECKS + 1) | |
| for k in range(NUM_DECKS - 1, -1, -1): | |
| remaining_max[k] = max_scores[k] + remaining_max[k+1] | |
| best_score = -1.0 | |
| best_choice = None | |
| best_choice_bib_sum = 0 | |
| # 결과 포맷팅 헬퍼 | |
| def make_result(sel): | |
| rows=[] | |
| for i,c in enumerate(sel, start=1): | |
| if c["used"] is None: | |
| used_list = gem_pool_master.generate_used_gem_string(c["rollback"]) | |
| used_str = _compact_used_gems(used_list) | |
| else: | |
| used_str = c["used"] | |
| rows.append({ | |
| "순번":i,"타입":c["typ"],"버프":c["buff"],"너프":c["nerf"], "등급":c["grade"], | |
| "펜던트": pendant_label(c["pnd"]), | |
| "젬분배":f"{c['dist'][0]}/{c['dist'][1]}/{c['dist'][2]}", | |
| "젬":used_str, | |
| "장신구":c["acc_key"].rsplit(" ",1)[0], "인첸트":c["ench"], | |
| "정령": c["sp_label"], | |
| "비벨":c["bib"], "TAR":round(c["T"],2), | |
| "HP":c["final"]["hp"], "ATK":c["final"]["atk"], "DEF":c["final"]["def"] | |
| }) | |
| df=pd.DataFrame(rows) | |
| avg=pd.DataFrame({"평균 비벨":[int(sum(r["비벨"] for r in rows)/len(rows))], | |
| "평균TAR":[round(sum(r["TAR"] for r in rows),2)/len(rows)]}) | |
| return df, avg, {"choice":sel} | |
| # 재귀 탐색 | |
| def try_place(idx:int, sel:List[dict], pool: GemPool, | |
| used_acc:Set[str], used_sp:Set[str], used_pn:Set[str], cur_bib_sum:int): | |
| nonlocal best_score, best_choice, best_choice_bib_sum | |
| if best_choice is not None: | |
| if cur_bib_sum + remaining_max[idx] < best_choice_bib_sum * prune_factor: | |
| return | |
| if idx == NUM_DECKS: | |
| sc = _score_tuple(sel, goal) | |
| if (best_score == -1.0) or (sc > best_score): | |
| best_score = sc | |
| best_choice = list(sel) | |
| best_choice_bib_sum = sum(c["bib"] for c in sel) | |
| return | |
| cur_slot_cands = slot_lists[idx] | |
| count = 0 | |
| current_limit = max(limit_base * depth_limits[idx], int(top_k)) | |
| for c in cur_slot_cands: | |
| if count >= current_limit: break | |
| if c.acc_key in used_acc: continue | |
| if c.sp["id"] in used_sp: continue | |
| if c.pnd_id is not None and c.pnd_id in used_pn: continue | |
| ok, sums, rollback_info = pool.allocate_fast(c.dist) | |
| if not ok: continue | |
| ench_name, _, best_bib, best_final = _assign_best_enchant_fast(c, sums) | |
| T = tar_percent(best_bib, M) | |
| if T < float(min_tar_cut): | |
| pool.restore(rollback_info) | |
| continue | |
| sel.append({ | |
| "slot":c.slot, "typ":c.typ, "buff":c.buff, "nerf":c.nerf, "grade":c.grade, | |
| "acc_key":c.acc_key, "ench":ench_name, "pnd":c.pnd, "pnd_id":c.pnd_id, | |
| "dist":c.dist, "used":None, "final":best_final, "bib":best_bib, "T":T, | |
| "sp_label": c.sp_label, | |
| "rollback": rollback_info | |
| }) | |
| used_acc.add(c.acc_key) | |
| used_sp.add(c.sp["id"]) | |
| if c.pnd_id: used_pn.add(c.pnd_id) | |
| yield from try_place(idx+1, sel, pool, used_acc, used_sp, used_pn, cur_bib_sum + best_bib) | |
| sel.pop() | |
| if c.pnd_id: used_pn.remove(c.pnd_id) | |
| used_sp.remove(c.sp["id"]) | |
| used_acc.remove(c.acc_key) | |
| pool.restore(rollback_info) | |
| count += 1 | |
| gen = try_place(0, [], gem_pool_master.copy_fast(), set(), set(), set(), 0) | |
| for _ in gen: pass | |
| if not best_choice: | |
| yield pd.DataFrame({"오류":["조합 없음"]}), pd.DataFrame({"평균 비벨":[0],"평균TAR":[0]}), {"choice":[]} | |
| else: | |
| df_res, df_avg, ctx_res = make_result(best_choice) | |
| yield df_res, df_avg, ctx_res | |
| def view_base_from_ctx(ctx): | |
| choice = (ctx or {}).get("choice", []) | |
| if not choice: | |
| return pd.DataFrame({"오류": ["먼저 길드전 최적화를 실행하세요."]}) | |
| rows = [] | |
| for i, c in enumerate(choice, start=1): | |
| final = c["final"] | |
| typ = c["typ"] | |
| grade = c["grade"] | |
| base_stat = _get_base_stats_cached(strip_jingak(typ)) | |
| boost = grade_boost_for_type_precise(typ, grade) | |
| bs_for_buff = {k: base_stat[k] + boost.get(k, 0) for k in ("hp", "atk", "def")} | |
| buff = _pct_from(c["buff"]) | |
| nerf = _pct_from(c["nerf"]) | |
| inc = {k: _floor_int(bs_for_buff[k] * buff[k]) for k in ("hp","atk","def")} | |
| dec = {k: _floor_int(bs_for_buff[k] * nerf[k]) for k in ("hp","atk","def")} | |
| nobuff = {k: final[k] - inc[k] + dec[k] for k in ("hp","atk","def")} | |
| rows.append({ | |
| "순번": i, | |
| "타입": c["typ"], | |
| "HP": nobuff["hp"], | |
| "ATK": nobuff["atk"], | |
| "DEF": nobuff["def"], | |
| }) | |
| return pd.DataFrame(rows) | |
| # ============================================================================= | |
| # UI | |
| # ============================================================================= | |
| def build_ui(): | |
| with gr.Blocks(theme=gr.themes.Soft(), analytics_enabled=False, css=CSS) as demo: | |
| gr.HTML("<h2>TAR 정령 자동 계산기 / 길드전 최적화</h2>") | |
| visits_html = gr.HTML() | |
| gr.HTML(FOCUS_KILL_JS) | |
| with gr.Tabs(): | |
| # -------------------- 자동 계산기 -------------------- | |
| with gr.Tab("자동 계산기"): | |
| with gr.Row(): | |
| # 좌1 | |
| with gr.Column(scale=1, min_width=260): | |
| types = gr.CheckboxGroup(label="타입", choices=TYPES_ALL, value=BASE_TYPES) | |
| buff = gr.Dropdown(label="버프", choices=["최적화","1벞 최적화","0벞"]+ALL_BUFF_CHOICES, value="최적화") | |
| nerf = gr.Dropdown(label="너프", choices=ALL_BUFF_CHOICES, value="0벞") | |
| grade_sel = gr.Dropdown(label="등급", choices=["7.0","8.0","9.0"], value="9.0") | |
| gr.Markdown("### 펜던트(자동)") | |
| pnd_opt_on = gr.Checkbox(label="펜던트 자동 최적화 사용", value=True) | |
| pnd_total = gr.Slider(0,18, value=18, step=1, label="펜던트 총합(%)") | |
| pnd_grade = gr.Dropdown(label="펜던트 등급", choices=["별","달","태양"], value="태양") | |
| # 좌2 | |
| with gr.Column(scale=1, min_width=260): | |
| # ✅ 디폴트 37만 체크 | |
| gemvals = gr.CheckboxGroup(label="젬 수치", choices=[str(v) for v in range(34,41)], value=["37"]) | |
| gr.Markdown("### 장신구") | |
| acc_controls=[] | |
| try: | |
| base_df = accmod.df_acc.copy().sort_values(["lv","이름"]) | |
| except Exception: | |
| base_df = pd.DataFrame([{"lv":19,"이름":"황보"}]) | |
| # [신규] 레벨별 전체 선택/해제 기능 | |
| levels = sorted(base_df["lv"].astype(int).unique()) | |
| for lv in levels: | |
| with gr.Accordion(f"{lv} 레벨", open=(lv==19)): | |
| with gr.Row(): | |
| # 버튼 생성 | |
| btn_all = gr.Button("전체 선택", size="sm") | |
| btn_none = gr.Button("전체 해제", size="sm") | |
| sub = base_df[base_df["lv"].astype(int)==lv] | |
| current_lvl_cbs = [] | |
| for name in sub["이름"].astype(str).tolist(): | |
| label=f"{lv} {name}" | |
| with gr.Row(): | |
| use=gr.Checkbox(label=f"{label} 사용", value=(lv==19)) | |
| with gr.Row(): | |
| hp = gr.Checkbox(label="HP", value=True) | |
| atk= gr.Checkbox(label="ATK", value=True) | |
| df = gr.Checkbox(label="DEF", value=True) | |
| acc_controls.append((label,use,hp,atk,df)) | |
| current_lvl_cbs.append(use) | |
| # 이벤트 연결 | |
| # 주의: Gradio에서 output list는 고정되어야 하므로 이 방식이 안전 | |
| cbs_count = len(current_lvl_cbs) | |
| btn_all.click(fn=lambda n=cbs_count: [True]*n, outputs=current_lvl_cbs) | |
| btn_none.click(fn=lambda n=cbs_count: [False]*n, outputs=current_lvl_cbs) | |
| # 좌3 | |
| with gr.Column(scale=1, min_width=260): | |
| stat_choices=["체력","공격력","방어력"]; mode_choices=["+","%"] | |
| with gr.Row(): | |
| s1=gr.Dropdown(stat_choices, value="체력", label="1옵 스탯") | |
| m1=gr.Dropdown(mode_choices, value="%", label="1옵 모드") | |
| with gr.Row(): | |
| s2=gr.Dropdown(stat_choices, value="공격력", label="2옵 스탯") | |
| m2=gr.Dropdown(mode_choices, value="%", label="2옵 모드") | |
| with gr.Row(): | |
| s3=gr.Dropdown(stat_choices, value="방어력", label="3옵 스탯") | |
| m3=gr.Dropdown(mode_choices, value="%", label="3옵 모드") | |
| with gr.Row(): | |
| s4=gr.Dropdown(stat_choices, value="방어력", label="4옵 스탯") | |
| m4=gr.Dropdown(mode_choices, value="+", label="4옵 모드") | |
| subopt=gr.Dropdown(["체력40","공격력10","방어력10"], value="체력40", label="부가옵") | |
| sp_skip=gr.Checkbox(label="정령 미사용", value=False) | |
| # 좌4 | |
| with gr.Column(scale=1, min_width=260): | |
| topn = gr.Slider(1, 3000, value=200, step=1, label="상위 N개") | |
| sort_key = gr.Radio(["비벨","TAR%"], value="TAR%", label="정렬") | |
| gr.Markdown("### 중복 제거") | |
| dedup_type = gr.Checkbox(label="타입", value=False) | |
| dedup_buff = gr.Checkbox(label="버프", value=False) | |
| dedup_acc = gr.Checkbox(label="장신구", value=False) | |
| dedup_ench = gr.Checkbox(label="인첸트", value=False) | |
| dedup_sp = gr.Checkbox(label="정령", value=False) | |
| # --- 펜던트(수동) --- | |
| gr.Markdown("### 펜던트(수동)") | |
| pnd_manual_on = gr.Checkbox(label="펜던트 수동 사용", value=False) | |
| pnd_manual_entries=[] | |
| for i in range(1,6+1): | |
| with gr.Accordion(f"수동 펜던트 #{i}", open=False): | |
| u = gr.Checkbox(label="사용", value=False) | |
| with gr.Row(): | |
| l1s = gr.Dropdown(["체력","공격력","방어력"], value="체력", label="라인1 스탯") | |
| l1v = gr.Slider(0,6,value=0,step=1,label="라인1 %") | |
| with gr.Row(): | |
| l2s = gr.Dropdown(["체력","공격력","방어력"], value="공격력", label="라인2 스탯") | |
| l2v = gr.Slider(0,6,value=0,step=1,label="라인2 %") | |
| with gr.Row(): | |
| l3s = gr.Dropdown(["체력","공격력","방어력"], value="방어력", label="라인3 스탯") | |
| l3v = gr.Slider(0,6,value=0,step=1,label="라인3 %") | |
| pnd_manual_entries.append((u,l1s,l1v,l2s,l2v,l3s,l3v)) | |
| run_btn = gr.Button("계산하기", variant="primary") | |
| reset_btn = gr.Button("리셋", variant="secondary") | |
| table = gr.Dataframe(label="결과", interactive=False, wrap=True) | |
| fav_msg = gr.HTML("") | |
| fav_save_btn = gr.Button("즐겨찾기 저장") | |
| fav_load_btn = gr.Button("즐겨찾기 불러오기") | |
| auto_flat_inputs = [ctrl for g in acc_controls for ctrl in (g[1],g[2],g[3],g[4])] | |
| pnd_manual_flat = [ctrl for e in pnd_manual_entries for ctrl in e] | |
| def _collect_acc(*vals, acc_snapshot=acc_controls): | |
| out=[]; it=iter(vals) | |
| for (label,_u,_h,_a,_d) in acc_snapshot: | |
| use=bool(next(it)); hp=bool(next(it)); atk=bool(next(it)); df=bool(next(it)) | |
| out.append((label,use,hp,atk,df)) | |
| return out | |
| def _collect_pnd_manual(*vals, snapshot=pnd_manual_entries): | |
| out=[]; it=iter(vals) | |
| for _ in snapshot: | |
| u=bool(next(it)); l1s=next(it); l1v=int(next(it) or 0); l2s=next(it); l2v=int(next(it) or 0); l3s=next(it); l3v=int(next(it) or 0) | |
| out.append({"use":u,"l1s":l1s,"l1v":l1v,"l2s":l2s,"l2v":l2v,"l3s":l3s,"l3v":l3v}) | |
| return out | |
| def _run_wrap(*vals, acc_snapshot=acc_controls, pnd_snapshot=pnd_manual_entries): | |
| idx=0 | |
| types_sel = vals[idx]; idx+=1 | |
| gem_vals_sel = vals[idx]; idx+=1 | |
| acc_vals = vals[idx: idx+len(acc_snapshot)*4]; idx+=len(acc_snapshot)*4 | |
| acc_packed = _collect_acc(*acc_vals, acc_snapshot=acc_snapshot) | |
| s1v,m1v,s2v,m2v,s3v,m3v,s4v,m4v,subv,spv = vals[idx:idx+10]; idx+=10 | |
| buff_mode = vals[idx]; idx+=1 | |
| nerf_mode = vals[idx]; idx+=1 | |
| grade_sel = vals[idx]; idx+=1 | |
| p_on, p_total, p_grade = vals[idx:idx+3]; idx+=3 | |
| pnd_man_on = vals[idx]; idx+=1 | |
| pnd_man_vals = vals[idx: idx+len(pnd_snapshot)*7]; idx+=len(pnd_snapshot)*7 | |
| pnd_manual = _collect_pnd_manual(*pnd_man_vals, snapshot=pnd_snapshot) | |
| d_t,d_b,d_a,d_e,d_s = vals[idx:idx+5]; idx+=5 | |
| topnv,sortv = vals[idx:idx+2]; idx+=2 | |
| return auto_run( | |
| types_sel, gem_vals_sel, acc_packed, | |
| s1v,m1v,s2v,m2v,s3v,m3v,s4v,m4v,subv,spv, | |
| buff_mode, nerf_mode, grade_sel, | |
| False, | |
| p_on, p_total, p_grade, | |
| pnd_man_on, pnd_manual, | |
| d_t,d_b,d_a,d_e,d_s, | |
| topnv,sortv | |
| ) | |
| run_btn.click( | |
| fn=_run_wrap, | |
| inputs=[types, gemvals] + auto_flat_inputs + [ | |
| s1,m1,s2,m2,s3,m3,s4,m4,subopt,sp_skip, | |
| buff, nerf, grade_sel, | |
| pnd_opt_on, pnd_total, pnd_grade, | |
| pnd_manual_on] + pnd_manual_flat + [ | |
| dedup_type, dedup_buff, dedup_acc, dedup_ench, dedup_sp, | |
| topn, sort_key | |
| ], | |
| outputs=[table] | |
| ) | |
| reset_btn.click(lambda: pd.DataFrame(), None, [table]) | |
| # 저장 | |
| def _auto_save(request: gr.Request|None, *vals): | |
| try: | |
| return fav_save_generic("auto", {"vals": list(vals)}, request) | |
| except Exception as e: | |
| return f"<span style='color:red;'>저장 실패: {e}</span>" | |
| fav_save_btn.click( | |
| fn=_auto_save, | |
| inputs=[types, gemvals] + auto_flat_inputs + [ | |
| s1,m1,s2,m2,s3,m3,s4,m4,subopt,sp_skip, | |
| buff, nerf, grade_sel, | |
| pnd_opt_on, pnd_total, pnd_grade, | |
| pnd_manual_on] + pnd_manual_flat + [ | |
| dedup_type, dedup_buff, dedup_acc, dedup_ench, dedup_sp, | |
| topn, sort_key | |
| ], | |
| outputs=[fav_msg] | |
| ) | |
| # 불러오기 | |
| def _auto_load(request: gr.Request|None, acc_snapshot=acc_controls, pnd_snapshot=pnd_manual_entries): | |
| data = fav_load_generic("auto", request) | |
| vals = data.get("vals", []) | |
| need = (2 + len(acc_snapshot)*4 + 10 + 3 + 3 + 1 + len(pnd_snapshot)*7 + 5 + 2) | |
| if not vals: | |
| return [gr.update()] * need + ["<span>저장본 없음</span>"] | |
| vals = (vals + [gr.update()]*(need-len(vals)))[:need] | |
| return vals + ["<span style='color:blue;'>불러옴</span>"] | |
| fav_load_btn.click( | |
| fn=_auto_load, inputs=[], | |
| outputs=[types, gemvals] + auto_flat_inputs + [ | |
| s1,m1,s2,m2,s3,m3,s4,m4,subopt,sp_skip, | |
| buff, nerf, grade_sel, | |
| pnd_opt_on, pnd_total, pnd_grade, | |
| pnd_manual_on] + pnd_manual_flat + [ | |
| dedup_type, dedup_buff, dedup_acc, dedup_ench, dedup_sp, | |
| topn, sort_key, fav_msg | |
| ] | |
| ) | |
| # -------------------- 길드전 셋팅 최적화 -------------------- | |
| with gr.Tab("길드전 셋팅 최적화"): | |
| gr.Markdown("### 이번 주 기준") | |
| with gr.Row(): | |
| week_typ = gr.Dropdown(label="타입", choices=BASE_TYPES, value="체") | |
| week_buff= gr.Dropdown(label="버프(2벞)", choices=TWO_BUFFS, value="HP40%") | |
| gr.Markdown("### 드래곤 슬롯") | |
| slot_used=[]; slot_typ=[]; slot_buff=[]; slot_grade=[]; slot_nerf=[] | |
| for i in range(1,7): | |
| with gr.Accordion(f"드래곤 {i}", open=(i<=3)): | |
| with gr.Row(): | |
| used = gr.Checkbox(label="사용", value=(i<=3)); slot_used.append(used) | |
| s_type= gr.Dropdown(label="타입", choices=BASE_TYPES, value="체"); slot_typ.append(s_type) | |
| s_buff= gr.Dropdown(label="버프", choices=ALL_BUFF_CHOICES, value="HP40%"); slot_buff.append(s_buff) | |
| s_nerf= gr.Dropdown(label="너프", choices=ALL_BUFF_CHOICES, value="0벞"); slot_nerf.append(s_nerf) | |
| s_grade=gr.Dropdown(label="등급", choices=["7.0","8.0","9.0"], value="7.0"); slot_grade.append(s_grade) | |
| gr.Markdown("### 장신구 인벤") | |
| acc_controls_g=[] | |
| try: | |
| base_df = accmod.df_acc.copy().sort_values(["lv","이름"]) | |
| except Exception: | |
| base_df = pd.DataFrame([{"lv":19,"이름":"황보"}]) | |
| # [신규] 레벨별 전체 선택/해제 버튼 추가 (길드전 탭) | |
| levels = sorted(base_df["lv"].astype(int).unique()) | |
| for lv in levels: | |
| with gr.Accordion(f"{lv} 레벨", open=(lv==19)): | |
| with gr.Row(): | |
| btn_all = gr.Button("전체 선택", size="sm") | |
| btn_none = gr.Button("전체 해제", size="sm") | |
| sub = base_df[base_df["lv"].astype(int)==lv] | |
| lvl_cbs = [] # 이 레벨에 속한 모든 '사용' 체크박스들 | |
| for name in sub["이름"].astype(str).tolist(): | |
| label=f"{lv} {name}" | |
| with gr.Accordion(f"{label}", open=False): | |
| for inst in (1,2,3): | |
| with gr.Row(): | |
| use= gr.Checkbox(label=f"{label} #{inst} 사용", value=False) | |
| hp = gr.Checkbox(label="HP", value=True) | |
| atk= gr.Checkbox(label="ATK", value=True) | |
| df = gr.Checkbox(label="DEF", value=True) | |
| acc_controls_g.append((label,inst,use,hp,atk,df)) | |
| lvl_cbs.append(use) | |
| cbs_count = len(lvl_cbs) | |
| btn_all.click(fn=lambda n=cbs_count: [True]*n, outputs=lvl_cbs) | |
| btn_none.click(fn=lambda n=cbs_count: [False]*n, outputs=lvl_cbs) | |
| def _collect_acc_df(*vals, acc_snapshot=acc_controls_g): | |
| rows=[]; it=iter(vals) | |
| for (label,inst,_u,_h,_a,_d) in acc_snapshot: | |
| use=bool(next(it)); hp=bool(next(it)); atk=bool(next(it)); df=bool(next(it)) | |
| rows.append({"장신구":label,"인스턴스":f"#{inst}","사용":use,"HP":hp,"ATK":atk,"DEF":df}) | |
| return pd.DataFrame(rows) | |
| gr.Markdown("### 펜던트 인벤 (1~3 기본 사용 체크)") | |
| pend_controls=[] | |
| # 펜던트 12개로 확장 | |
| for i in range(1,13): | |
| with gr.Accordion(f"펜던트 #{i}", open=(i<=3)): | |
| u=gr.Checkbox(label="사용", value=(i<=3)) | |
| with gr.Row(): | |
| l1s=gr.Dropdown(["체력","공격력","방어력"], value="체력", label="라인1 스탯"); l1v=gr.Slider(0,6,value=0,step=1,label="라인1 %") | |
| with gr.Row(): | |
| l2s=gr.Dropdown(["체력","공격력","방어력"], value="공격력", label="라인2 스탯"); l2v=gr.Slider(0,6,value=0,step=1,label="라인2 %") | |
| with gr.Row(): | |
| l3s=gr.Dropdown(["체력","공격력","방어력"], value="방어력", label="라인3 스탯"); l3v=gr.Slider(0,6,value=0,step=1,label="라인3 %") | |
| pend_controls.append((u,l1s,l1v,l2s,l2v,l3s,l3v)) | |
| def _collect_pendant_inv(*vals, snapshot=pend_controls): | |
| out=[]; it=iter(vals) | |
| for _ in snapshot: | |
| u=bool(next(it)); l1s=next(it); l1v=int(next(it) or 0); l2s=next(it); l2v=int(next(it) or 0); l3s=next(it); l3v=int(next(it) or 0) | |
| out.append({"use":u,"l1s":l1s,"l1v":l1v,"l2s":l2s,"l2v":l2v,"l3s":l3s,"l3v":l3v}) | |
| return out | |
| gr.Markdown("### 젬") | |
| gem_df = gr.Dataframe(value=gem_inventory_default(), interactive=True, wrap=True) | |
| gr.Markdown("### 정령") | |
| spirits=[] | |
| spirit_binds=["정령 귀속X"]+[f"{i}번드래곤" for i in range(1,7)] | |
| # 정령 12개로 확장 | |
| for i in range(1,13): | |
| d=spirit_default_row() | |
| with gr.Accordion(f"정령 {i}", open=(i<=3)): | |
| use=gr.Checkbox(label="사용", value=(i<=3)) | |
| bind=gr.Dropdown(label="정령 귀속", choices=spirit_binds, value="정령 귀속X") | |
| s1_=gr.Dropdown(["체력","공격력","방어력"], value=d["1옵 스탯"], label="1옵 스탯") | |
| m1_=gr.Dropdown(["+","%"], value=d["1옵 모드"], label="1옵 모드") | |
| s2_=gr.Dropdown(["체력","공격력","방어력"], value=d["2옵 스탯"], label="2옵 스탯") | |
| m2_=gr.Dropdown(["+","%"], value=d["2옵 모드"], label="2옵 모드") | |
| s3_=gr.Dropdown(["체력","공격력","방어력"], value=d["3옵 스탯"], label="3옵 스탯") | |
| m3_=gr.Dropdown(["+","%"], value=d["3옵 모드"], label="3옵 모드") | |
| s4_=gr.Dropdown(["체력","공격력","방어력"], value=d["4옵 스탯"], label="4옵 스탯") | |
| m4_=gr.Dropdown(["+","%"], value=d["4옵 모드"], label="4옵 모드") | |
| sub_=gr.Dropdown(["체력40","공격력10","방어력10"], value=d["부가옵"], label="부가옵") | |
| spirits.append((use,bind,s1_,m1_,s2_,m2_,s3_,m3_,s4_,m4_,sub_)) | |
| with gr.Row(): | |
| top_k = gr.Slider(1, 999, value=50, step=1, label="후보 상한(각 모드별 기본값에 곱연산)") | |
| min_tar = gr.Slider(0, 999, value=0, step=1, label="TAR 하한") | |
| # [신규] 계산 모드 선택 (실시간 삭제됨) | |
| calc_mode = gr.Radio(["신속", "중간", "전수"], value="중간", label="계산 모드") | |
| goal = gr.Radio(["비벨우선","TAR우선"], value="비벨우선", label="목표") | |
| run2 = gr.Button("최적화 시작", variant="primary") | |
| guild_table = gr.Dataframe(label="길드전 최적 세팅(3덱)", interactive=False, wrap=True, elem_id="guild_table_df") | |
| guild_avg = gr.Dataframe(label="평균", interactive=False, wrap=True) | |
| ctx_state = gr.State({}) | |
| flatten_acc_g=[] | |
| for (_,_,use,hp,atk,df) in acc_controls_g: | |
| flatten_acc_g.extend([use,hp,atk,df]) | |
| flatten_sp=[] | |
| for s in spirits: | |
| flatten_sp.extend(list(s)) | |
| flatten_pends=[] | |
| for p in pend_controls: | |
| flatten_pends.extend(list(p)) | |
| def _guild_run(week_typ, week_buff, *vals, acc_snapshot=acc_controls_g, pend_snapshot=pend_controls): | |
| idx=0 | |
| used=[bool(vals[idx+i]) for i in range(6)]; idx+=6 | |
| styp=[vals[idx+i] for i in range(6)]; idx+=6 | |
| sbuf=[vals[idx+i] for i in range(6)]; idx+=6 | |
| sner=[vals[idx+i] for i in range(6)]; idx+=6 | |
| sgra=[vals[idx+i] for i in range(6)]; idx+=6 | |
| pend_vals = vals[idx: idx+len(pend_snapshot)*7]; idx+=len(pend_snapshot)*7 | |
| pnd_rows = _collect_pendant_inv(*pend_vals, snapshot=pend_snapshot) | |
| gem_df_val=vals[idx]; idx+=1 | |
| acc_vals = vals[idx: idx+len(acc_snapshot)*4]; idx+=len(acc_snapshot)*4 | |
| acc_df_val = _collect_acc_df(*acc_vals, acc_snapshot=acc_snapshot) | |
| spr_rows=[] | |
| # 정령 12개 루프 | |
| for _ in range(12): | |
| use=bool(vals[idx]); idx+=1 | |
| bind=vals[idx]; idx+=1 | |
| s1v=vals[idx]; idx+=1; m1v=vals[idx]; idx+=1 | |
| s2v=vals[idx]; idx+=1; m2v=vals[idx]; idx+=1 | |
| s3v=vals[idx]; idx+=1; m3v=vals[idx]; idx+=1 | |
| s4v=vals[idx]; idx+=1; m4v=vals[idx]; idx+=1 | |
| subv=vals[idx]; idx+=1 | |
| spr_rows.append({"사용":use,"귀속":bind,"1옵 스탯":s1v,"1옵 모드":m1v,"2옵 스탯":s2v,"2옵 모드":m2v,"3옵 스탯":s3v,"3옵 모드":m3v,"4옵 스탯":s4v,"4옵 모드":m4v,"부가옵":subv}) | |
| top=int(vals[idx]); idx+=1 | |
| mt=float(vals[idx]); idx+=1 | |
| mode_v=vals[idx]; idx+=1 | |
| goalv=vals[idx]; idx+=1 | |
| # 제너레이터 호출 | |
| gen = guild_optimize_generator( | |
| week_typ, week_buff, | |
| used, styp, sbuf, sgra, sner, | |
| pnd_rows, | |
| acc_df_val, gem_df_val, spr_rows, | |
| top_k=top, min_tar_cut=mt, | |
| goal=goalv, | |
| mode=mode_v | |
| ) | |
| # 제너레이터 소진 및 결과 반환 | |
| return next(gen) | |
| run2.click( | |
| fn=_guild_run, | |
| inputs=[week_typ, week_buff] + | |
| slot_used + slot_typ + slot_buff + slot_nerf + slot_grade + | |
| flatten_pends + [gem_df] + | |
| flatten_acc_g + flatten_sp + | |
| [top_k, min_tar, calc_mode, goal], | |
| outputs=[guild_table, guild_avg, ctx_state] | |
| ) | |
| view_btn = gr.Button("버프 제외 스탯 보기(덱셋팅 스탯)", variant="secondary") | |
| base_view = gr.Dataframe(label="기본 스탯", interactive=False, wrap=True) | |
| view_btn.click(fn=view_base_from_ctx, inputs=[ctx_state], outputs=[base_view]) | |
| # 즐겨찾기 (길드전) | |
| fav_msg_g = gr.HTML("") | |
| fav_save_g = gr.Button("즐겨찾기 저장(길드전)") | |
| fav_load_g = gr.Button("즐겨찾기 불러오기(길드전)") | |
| def _fav_save_g(request: gr.Request|None, week_typ, week_buff, *vals, pend_snapshot=pend_controls): | |
| try: | |
| vals = list(vals) | |
| gem_idx = 6*5 + len(pend_snapshot)*7 | |
| gem_df_val = vals[gem_idx] | |
| if isinstance(gem_df_val, pd.DataFrame): | |
| vals[gem_idx] = {"__gem_records__": gem_df_val.to_dict("records")} | |
| payload = [week_typ, week_buff] + vals | |
| return fav_save_generic("guild", {"vals": payload}, request) | |
| except Exception as e: | |
| return f"<span style='color:red;'>저장 실패: {e}</span>" | |
| fav_save_g.click( | |
| fn=_fav_save_g, | |
| inputs=[week_typ, week_buff] + | |
| slot_used + slot_typ + slot_buff + slot_nerf + slot_grade + | |
| flatten_pends + [gem_df] + | |
| flatten_acc_g + flatten_sp + | |
| [top_k, min_tar, calc_mode, goal], | |
| outputs=[fav_msg_g] | |
| ) | |
| def _fav_load_g(request: gr.Request|None, acc_snapshot=acc_controls_g, pend_snapshot=pend_controls): | |
| data = fav_load_generic("guild", request) | |
| vals = data.get("vals", []) | |
| if not vals: | |
| # 정령 12개이므로 11개 속성 * 12 | |
| need = 2 + 6*5 + len(pend_snapshot)*7 + 1 + len(acc_snapshot)*4 + 12*11 + 4 | |
| return [gr.update()]*need + ["<span>저장본 없음</span>"] | |
| vals = list(vals) | |
| gem_pos = 2 + 6*5 + len(pend_snapshot)*7 | |
| if gem_pos < len(vals): | |
| obj = vals[gem_pos] | |
| if isinstance(obj, dict) and "__gem_records__" in obj: | |
| vals[gem_pos] = pd.DataFrame(obj["__gem_records__"]) | |
| need = 2 + 6*5 + len(pend_snapshot)*7 + 1 + len(acc_snapshot)*4 + 12*11 + 4 | |
| vals = (vals + [gr.update()]*(need-len(vals)))[:need] | |
| return vals + ["<span style='color:blue;'>불러옴</span>"] | |
| fav_load_g.click( | |
| fn=_fav_load_g, inputs=[], | |
| outputs=[week_typ, week_buff] + | |
| slot_used + slot_typ + slot_buff + slot_nerf + slot_grade + | |
| flatten_pends + [gem_df] + | |
| flatten_acc_g + flatten_sp + | |
| [top_k, min_tar, calc_mode, goal, fav_msg_g] | |
| ) | |
| # 방문자 카운트 | |
| def _visit(request: gr.Request): | |
| return register_unique_visit(request) | |
| demo.load(_visit, inputs=None, outputs=visits_html) | |
| return demo | |
| # ============================================================================= | |
| # Main | |
| # ============================================================================= | |
| if __name__ == "__main__": | |
| demo = build_ui() | |
| demo.queue().launch(server_name="0.0.0.0", server_port=7860, show_api=False, share=False) |