Skip to content

Commit 930c82b

Browse files
SnoopLawgclaude
andcommitted
organic-teams (#48): cb_regear_for_damage — beat MEN's ceiling via gear, +11.8%
tools/cb_regear_for_damage.py: re-gears the MEN damage dealers (Ninja/Geo/Venom) for damage by reclaiming overcapped ACC (Ninja 311/Geo 407/Venom 423 vs the 225 CB-UNM floor) into CD/ATK/CR, holding the SPD tune + 225 floor + survivors' gear (contention-checked, survivor-frozen, zero shared artifact ids, full vault via gear_target_optimizer). Re-scores MEN tuned in the SAME harness as team_tune. Result (element=spirit, deterministic): 37.67M -> 42.13M (+4.46M, +11.8%), still holds T50 / 0 gaps. Per-slot swap list printed. cb_sim numbers are ESTIMATES (under-survives); the +11.8% DELTA is the trustworthy signal and the ACC-overcap-is-waste mechanic is game-truth, so the direction is sound. Live A/B (2 keys) would confirm absolute. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SCKiWFsFcWRBkL4kWf1q6b
1 parent 5f61976 commit 930c82b

1 file changed

Lines changed: 361 additions & 0 deletions

File tree

tools/cb_regear_for_damage.py

Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
"""Task #48 — push the MEN clan-boss tune past its ~37M ceiling by RE-GEARING
2+
its damage dealers for damage (stop overcapping ACC; reallocate into CD/ATK/CR).
3+
4+
THESIS
5+
The MEN tune (Maneater / Demytha / Ninja / Geomancer / Venomage) holds the
6+
boss cadence to T50 and does ~37M (cb_sim estimate). Its three DEBUFFERS
7+
(Ninja / Geomancer / Venomage) are OVERCAPPED on ACC vs the CB-UNM floor
8+
(boss_constraints.acc_floor("clan_boss") = 225). That overcap is dead stat
9+
budget. Reallocating it into C.DMG / ATK% / C.RATE on the damage engines
10+
should raise team damage with no survival cost — because we hold the SPD
11+
tune exactly (cadence unchanged) and keep the survival heroes' gear frozen.
12+
13+
WHAT THIS DOES (and what it deliberately does NOT touch)
14+
* Survival heroes (Maneater SPD 288 + Demytha SPD 184) keep their CURRENT
15+
gear. Their equipped artifacts are EXCLUDED from the dealers' candidate
16+
vault (`exclude_ids`) so the re-gear can never steal the speed boots that
17+
hold the tune.
18+
* The three dealers are re-geared one at a time, hardest-SPD-first, with
19+
artifact CONTENTION (a piece given to one dealer leaves the next dealer's
20+
vault) — exactly the team-aware pattern team_tune.gear_feasibility /
21+
cb_recommender._solve_rec use.
22+
* Each dealer's SPD is pinned to its CURRENT tuned value (min==target,
23+
max==target+SPD_TOL in the solve; then FORCED exactly in the re-score
24+
sim) so the boss cadence is identical between baseline and re-gear — the
25+
delta is PURE damage, not a survival artifact.
26+
27+
HONESTY
28+
cb_sim UNDER-survives the real game and OVER-rates stalls (memory
29+
project_cb_sim_calibration_state). Absolute numbers are ESTIMATES. The
30+
signal is the BEFORE->AFTER delta from the SAME harness, run here for both
31+
the baseline (current gear) and the re-geared build. The gear is from the
32+
real vault (all_artifacts.json), so any reported swap is feasible.
33+
34+
REUSE (imports, never re-implements)
35+
* gear_target_optimizer.Optimizer.optimize — M6 per-champion stat-target
36+
gear solver with calc_stats(hypothetical=True) oracle + exclude_ids
37+
contention (memory project_gear_target_optimizer).
38+
* gear_target_optimizer.build_targets / load_data / STAT_ID_TO_NAME.
39+
* cb_sim._build_team_setup / ._build_sim_champs_from_setup / .CBSimulator
40+
— build the team (current gear + flagship preset), override the dealers'
41+
stats, run the full survival sim (same call team_tune._score_combo uses).
42+
* speed_tune_finder._validity / .ELEMENT_NAME_TO_ID — survival/holds check.
43+
* boss_constraints.acc_floor — game-truth CB-UNM ACC floor.
44+
45+
CLI
46+
python tools/cb_regear_for_damage.py # spirit, deterministic
47+
python tools/cb_regear_for_damage.py --element magic
48+
python tools/cb_regear_for_damage.py --json out.json
49+
"""
50+
from __future__ import annotations
51+
52+
import argparse
53+
import copy
54+
import json
55+
import sys
56+
from pathlib import Path
57+
58+
ROOT = Path(__file__).resolve().parent.parent
59+
TOOLS = ROOT / "tools"
60+
if str(TOOLS) not in sys.path:
61+
sys.path.insert(0, str(TOOLS))
62+
63+
from cb_sim import (_build_team_setup, _build_sim_champs_from_setup, # noqa: E402
64+
CBSimulator, SPD, ACC, ATK, CR, CD, HP, DEF, RES)
65+
import gear_target_optimizer as gto # noqa: E402
66+
from gear_target_optimizer import STAT_ID_TO_NAME # noqa: E402
67+
from gear_constants import SLOT_NAMES # noqa: E402
68+
from speed_tune_finder import _validity, ELEMENT_NAME_TO_ID # noqa: E402
69+
import team_tune # noqa: E402
70+
71+
MEN = ["Maneater", "Demytha", "Ninja", "Geomancer", "Venomage"]
72+
DEALERS = ["Ninja", "Geomancer", "Venomage"]
73+
SURVIVORS = ["Maneater", "Demytha"]
74+
75+
# SPD slack the gear solve may use above the pinned tune value. The re-score
76+
# sim FORCES SPD back to the exact tune, so this only loosens the gear search
77+
# (a piece with SPD+CD isn't rejected for 1-2 extra SPD); cadence is unchanged.
78+
SPD_TOL = 2
79+
80+
# Damage-importance weights for the dealers' re-gear. CD first (cheap, scales
81+
# the whole crit term), then ATK%, then CR (soft-capped at 100 in the scorer —
82+
# surplus flows to CD/ATK automatically). ACC is a MIN (floor), never maximized.
83+
DAMAGE_WEIGHTS = {CD: 4, ATK: 3, CR: 2}
84+
85+
ALL_STATS = (HP, ATK, DEF, SPD, RES, ACC, CR, CD)
86+
87+
88+
def _load_set_names():
89+
try:
90+
af = json.loads((ROOT / "data" / "static" / "artifact_sets.json")
91+
.read_text(encoding="utf-8"))
92+
rows = next((v for k, v in af.items() if k != "_meta" and isinstance(v, list)), [])
93+
return {r["id"]: r.get("name", f"set#{r['id']}") for r in rows}
94+
except Exception:
95+
return {}
96+
97+
98+
SET_NAMES = _load_set_names()
99+
100+
101+
def _set_name(sid):
102+
return SET_NAMES.get(sid, f"set#{sid}")
103+
104+
105+
def _run_sim(setup, element_id):
106+
"""One full deterministic survival sim from a (possibly stat-overridden)
107+
setup. Same call shape as team_tune._score_combo — game-truth survival
108+
model (bugfix_buff_tick=False, force_affinity), 50 CB turns."""
109+
champs = _build_sim_champs_from_setup(setup)
110+
res = CBSimulator(champs, cb_difficulty="ultra-nightmare",
111+
cb_element=element_id, deterministic=True,
112+
model_survival=True, force_affinity=True,
113+
bugfix_buff_tick=False).run(max_cb_turns=50)
114+
survived, gaps = _validity(res)
115+
return {"total": float(res.get("total", 0.0) or 0.0),
116+
"turns": int(res.get("cb_turns", 0) or 0),
117+
"gaps": gaps,
118+
"holds": bool(survived and gaps == 0)}
119+
120+
121+
def _describe_art(a):
122+
"""Compact stat-line for an artifact: slot, set, primary, substats."""
123+
if not a:
124+
return "(empty)"
125+
slot = SLOT_NAMES.get(a.get("kind"), a.get("kind"))
126+
prim = a.get("primary") or {}
127+
pn = STAT_ID_TO_NAME.get(prim.get("stat"), "?")
128+
pv = prim.get("value", 0)
129+
pflag = "" if prim.get("flat") else "%"
130+
subs = []
131+
for s in (a.get("substats") or []):
132+
sn = STAT_ID_TO_NAME.get(s.get("stat"), "?")
133+
sv = s.get("value", 0)
134+
sflag = "" if s.get("flat") else "%"
135+
subs.append(f"{sn}{sv:.0f}{sflag}")
136+
return (f"{slot:7s} {_set_name(a.get('set')):12s} "
137+
f"P:{pn}{pv:.0f}{pflag:1s} | " + ", ".join(subs)
138+
+ f" (id {a.get('id')})")
139+
140+
141+
def _apply_spd(setup, idx, spd_by_name):
142+
"""Pin each hero's in-sim SPD to the holding-tune value (the override
143+
team_tune._score_combo does). Cadence is then identical across runs."""
144+
for n, spd in spd_by_name.items():
145+
if n in idx:
146+
setup["stats_per_hero"][idx[n]][SPD] = float(spd)
147+
148+
149+
def regear(element="spirit", anneal=8, max_combos=200, verbose=False):
150+
element_id = ELEMENT_NAME_TO_ID[element]
151+
try:
152+
import boss_constraints
153+
floor = int(boss_constraints.acc_floor("clan_boss") or 225)
154+
except Exception:
155+
floor = 225
156+
157+
# ---- find the HOLDING SPD tune (the ~37.7M T50 config) via team_tune. ---- #
158+
# The current-gear natural SPDs do NOT hold in cb_sim (it under-survives);
159+
# the tuned config does. We pin BOTH baseline and re-gear to this same tune
160+
# so the comparison is pure damage (identical boss cadence). NOT hardcoded —
161+
# re-derived here in the same harness each run.
162+
tune = team_tune.tune_and_score(MEN, element=element, gear="current",
163+
adaptive=True, max_combos=max_combos,
164+
verbose=verbose)
165+
tune_spd = tune.get("spd_assignment") or {}
166+
if verbose:
167+
print(f"[tune] holding SPDs: {tune_spd} "
168+
f"(team_tune tuned={tune['tuned_fitness']/1e6:.2f}M "
169+
f"holds={tune['holds_t50']})")
170+
171+
# ---- build the team on CURRENT gear + flagship preset. ------------------- #
172+
setup = _build_team_setup(MEN, use_current_gear=True)
173+
if isinstance(setup, dict) and setup.get("error"):
174+
raise RuntimeError(setup["error"])
175+
names = setup["hero_names"]
176+
idx = {n: i for i, n in enumerate(names)}
177+
178+
# SPD per dealer for the gear solve = the holding-tune SPD (fall back to
179+
# current gear SPD if the tuner couldn't assign one).
180+
cur_spd = {n: int(round(tune_spd.get(
181+
n, setup["stats_per_hero"][idx[n]][SPD]))) for n in names}
182+
cur_stats = {n: dict(setup["stats_per_hero"][idx[n]]) for n in names}
183+
184+
# baseline = current gear, SPD pinned to the holding tune (apples-to-apples
185+
# with the re-gear; should reproduce team_tune's tuned number).
186+
base_setup = copy.deepcopy(setup)
187+
_apply_spd(base_setup, idx, cur_spd)
188+
baseline = _run_sim(base_setup, element_id)
189+
190+
# ---- gear solve for the 3 dealers (team-aware contention). --------------- #
191+
arts, heroes, account = gto.load_data()
192+
opt = gto.Optimizer(arts, heroes, account)
193+
194+
# exclude survival heroes' CURRENT equipped gear from the dealers' vault.
195+
used = set()
196+
for sn in SURVIVORS:
197+
h = opt.heroes_by_name.get(sn.lower())
198+
for a in (h.get("artifacts") if h else []) or []:
199+
if a.get("id") is not None:
200+
used.add(a["id"])
201+
202+
solves = {}
203+
# hardest-SPD-first so the tightest tune claims its boots before the others.
204+
for name in sorted(DEALERS, key=lambda n: -cur_spd[n]):
205+
tspd = cur_spd[name]
206+
mins = {SPD: tspd, ACC: floor}
207+
maxs = {SPD: tspd + SPD_TOL}
208+
targets = gto.build_targets(mins, maxs, dict(DAMAGE_WEIGHTS), None)
209+
r = opt.optimize(name, targets, anneal=anneal, exclude_ids=used)
210+
for a in (r.get("assignment") or {}).values():
211+
if a and a.get("id") is not None:
212+
used.add(a["id"])
213+
solves[name] = r
214+
if verbose:
215+
st = r["stats"]
216+
print(f"[solve] {name}: SPD {st[SPD]:.0f} ACC {st[ACC]:.0f} "
217+
f"ATK {st[ATK]:.0f} CR {st[CR]:.0f} CD {st[CD]:.0f} "
218+
f"mins_met={r['mins_met']}")
219+
220+
# ---- re-score: override dealer stats, FORCE SPD to the holding tune. ----- #
221+
# Survivors keep current-gear stats; all 5 SPDs are pinned to the same
222+
# holding tune as the baseline, so only the dealers' damage stats differ.
223+
setup2 = copy.deepcopy(setup)
224+
_apply_spd(setup2, idx, cur_spd)
225+
for name in DEALERS:
226+
new = dict(setup2["stats_per_hero"][idx[name]])
227+
new.update(solves[name]["stats"]) # combat stats from hypothetical solve
228+
new[SPD] = float(cur_spd[name]) # re-pin cadence (solve may drift +TOL)
229+
setup2["stats_per_hero"][idx[name]] = new
230+
regeared = _run_sim(setup2, element_id)
231+
232+
return {
233+
"element": element, "element_id": element_id, "acc_floor": floor,
234+
"baseline": baseline, "regeared": regeared, "tune": tune,
235+
"cur_spd": cur_spd, "cur_stats": cur_stats,
236+
"solves": solves, "names": names, "idx": idx, "opt": opt,
237+
}
238+
239+
240+
def _print_report(R):
241+
floor = R["acc_floor"]
242+
base, re = R["baseline"], R["regeared"]
243+
print("\n" + "=" * 78)
244+
print(f" MEN re-gear for damage - element={R['element']} "
245+
f"(CB UNM, deterministic cb_sim ESTIMATE)")
246+
print("=" * 78)
247+
print(" holding SPD tune (pinned for BOTH runs): "
248+
+ ", ".join(f"{n}={R['cur_spd'][n]}" for n in R["names"]))
249+
250+
# --- per-dealer stat before/after + swaps --------------------------------- #
251+
for name in DEALERS:
252+
cs = R["cur_stats"][name]
253+
ns = R["solves"][name]["stats"]
254+
print(f"\n--- {name} ---")
255+
print(f" {'stat':4s} {'before':>8s} {'after':>8s} {'delta':>8s}")
256+
for sid in (ACC, CD, ATK, CR, SPD):
257+
b, a = cs.get(sid, 0), ns.get(sid, 0)
258+
tag = ""
259+
if sid == ACC:
260+
tag = f" (floor {floor}; was +{b - floor:.0f} over)"
261+
print(f" {STAT_ID_TO_NAME[sid]:4s} {b:8.0f} {a:8.0f} "
262+
f"{a - b:+8.0f}{tag}")
263+
264+
# swaps: diff current equipped vs solved assignment, per slot.
265+
opt = R["opt"]
266+
h = opt.heroes_by_name.get(name.lower())
267+
cur = {a.get("kind"): a for a in (h.get("artifacts") if h else []) or []}
268+
new = R["solves"][name]["assignment"]
269+
changed = 0
270+
print(" gear swaps (only changed slots):")
271+
for slot in (1, 2, 3, 4, 5, 6, 7, 8, 9):
272+
ca, na = cur.get(slot), new.get(slot)
273+
cid = ca.get("id") if ca else None
274+
nid = na.get("id") if na else None
275+
if cid == nid:
276+
continue
277+
changed += 1
278+
print(f" [{SLOT_NAMES.get(slot, slot)}]")
279+
print(f" old: {_describe_art(ca)}")
280+
print(f" new: {_describe_art(na)}")
281+
if not changed:
282+
print(" (none — already optimal for damage at this SPD/ACC)")
283+
284+
# --- headline before/after ------------------------------------------------ #
285+
print("\n" + "-" * 78)
286+
print(" TEAM cb_sim (tuned, same harness, element=" + R["element"] + "):")
287+
print(f" BEFORE (current gear): {base['total']/1e6:7.2f}M "
288+
f"holds_T50={base['holds']} (survives T{base['turns']}, gaps={base['gaps']})")
289+
print(f" AFTER (re-geared): {re['total']/1e6:7.2f}M "
290+
f"holds_T50={re['holds']} (survives T{re['turns']}, gaps={re['gaps']})")
291+
delta = re["total"] - base["total"]
292+
pct = (delta / base["total"] * 100.0) if base["total"] else 0.0
293+
print(f" DELTA: {delta/1e6:+7.2f}M ({pct:+.1f}%)")
294+
print("-" * 78)
295+
296+
# --- verdict -------------------------------------------------------------- #
297+
print("\n VERDICT:")
298+
if not base["holds"]:
299+
print(" - cb_sim does NOT hold the baseline to T50 (it under-survives;"
300+
" see memory project_cb_sim_calibration_state). Damage delta below"
301+
" is still apples-to-apples (same cadence, SPD pinned).")
302+
survival_ok = re["holds"] == base["holds"] and re["turns"] >= base["turns"]
303+
if delta > 0.05e6 and survival_ok:
304+
print(f" - Re-gearing the dealers for damage LIFTS team damage by "
305+
f"{delta/1e6:+.2f}M ({pct:+.1f}%) with no survival cost "
306+
f"(holds unchanged, SPD tune held).")
307+
print(" - Gear is from the real vault (all_artifacts.json) with team "
308+
"contention + survivor gear frozen, so the swap list is feasible.")
309+
elif delta > 0.05e6 and not survival_ok:
310+
print(f" - Damage rises {delta/1e6:+.2f}M but survival changed "
311+
f"(baseline T{base['turns']}/{base['holds']} vs re-gear "
312+
f"T{re['turns']}/{re['holds']}). Treat with caution.")
313+
else:
314+
print(f" - No meaningful gain ({delta/1e6:+.2f}M). The dealers are "
315+
"already near-optimal for damage at the tuned SPD/ACC, OR the ACC "
316+
"overcap sits on pieces whose alternatives don't add CD/ATK. Not "
317+
"manufacturing an improvement.")
318+
print(" - cb_sim numbers are ESTIMATES (under-survives, over-rates stalls);"
319+
" the BEFORE->AFTER delta is the signal, not the absolute M.\n")
320+
321+
322+
def main(argv=None):
323+
ap = argparse.ArgumentParser(
324+
description=__doc__,
325+
formatter_class=argparse.RawDescriptionHelpFormatter)
326+
ap.add_argument("--element", default="spirit", choices=list(ELEMENT_NAME_TO_ID))
327+
ap.add_argument("--anneal", type=int, default=8,
328+
help="gear-solve SA restarts per dealer (higher=better/slower)")
329+
ap.add_argument("--max-combos", type=int, default=200,
330+
help="cap on the team_tune SPD search that finds the holding tune")
331+
ap.add_argument("--json", help="also dump the result dict to this path")
332+
ap.add_argument("--verbose", action="store_true")
333+
args = ap.parse_args(argv)
334+
335+
R = regear(element=args.element, anneal=args.anneal,
336+
max_combos=args.max_combos, verbose=args.verbose)
337+
_print_report(R)
338+
339+
if args.json:
340+
dump = {
341+
"element": R["element"], "acc_floor": R["acc_floor"],
342+
"baseline": R["baseline"], "regeared": R["regeared"],
343+
"dealers": {
344+
n: {
345+
"before": {STAT_ID_TO_NAME[s]: R["cur_stats"][n].get(s, 0)
346+
for s in ALL_STATS},
347+
"after": {STAT_ID_TO_NAME[s]: R["solves"][n]["stats"].get(s, 0)
348+
for s in ALL_STATS},
349+
"mins_met": R["solves"][n]["mins_met"],
350+
"assignment": {SLOT_NAMES.get(k, k): (a.get("id") if a else None)
351+
for k, a in R["solves"][n]["assignment"].items()},
352+
} for n in DEALERS
353+
},
354+
}
355+
Path(args.json).write_text(json.dumps(dump, indent=2), encoding="utf-8")
356+
print(f"[json] wrote {args.json}")
357+
return 0
358+
359+
360+
if __name__ == "__main__":
361+
raise SystemExit(main())

0 commit comments

Comments
 (0)