Skip to content

Commit 3bfb4f9

Browse files
SnoopLawgclaude
andcommitted
cb_regear: SPD-locked, sim-verified safe re-gear (no tune desync) (#51)
The live re-gear wiped at T24 because it DESYNCED the tune (Maneater UK cadence 1.65->1.25 turns/boss), not lost survivability — a survivor SPD source leaked into a dealer's assignment + dealer SPDs drifted ~1pt. Fixes: 1. Airtight survivor exclusion: _survivor_equipped_ids reads Maneater(15120)+ Demytha(18607) equipped ids LIVE; _assert_no_survivor_overlap hard-asserts the dealer assignment shares ZERO ids with survivor gear (the leak that slowed Mane). 2. Reliable computed-SPD read: read_computed_stats/read_computed_spd sum the /hero-computed-stats per-column bonuses (no `total` field -> earlier parses got None) + fraction->percent CR/CD. Returns real SPDs (Mane 288/Demy 172/Ninja 206/ Geo 177/Venom 162 = the tune). 3. safe_regear() gate: snapshot(5+donors) -> apply -> read ACTUAL post-equip stats -> RE-SIM with real SPDs -> 3 checks (survivor SPD unchanged, holds T50 0 gaps, dmg>baseline) -> ALWAYS restore on fail; dry_run (default) restores regardless. Never runs a battle / spends a key. --commit leaves a PASSING config applied. LIVE-PROVEN (spirit, dry-run, gear restored): loose (spd_tol 2) drifts to 207/179/163 -> 22.8M/T33/11 gaps -> FAIL -> RESTORED (reproduces the T24 wipe, confirms it's a SYNC issue + the gate catches it). Exact SPD-lock (spd_tol 0, default) -> dealers on-tune 206/177/162, survivors 288/172 unchanged -> holds T50 0 gaps, +3.90M (37.7->41.6M). So the ACC->CD/ATK gain IS bankable once SPD is hard-locked, and a live re-gear can no longer silently break the tune. 5 offline tests + 2 live (RUN_LIVE_REGEAR=1) pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SCKiWFsFcWRBkL4kWf1q6b
1 parent d4fdab2 commit 3bfb4f9

2 files changed

Lines changed: 554 additions & 21 deletions

File tree

tests/test_cb_regear_safe.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
"""GUARD: the SAFE re-gear gate (tools/cb_regear_for_damage.py, Task #51).
2+
3+
WHY THIS EXISTS — a live re-gear of the MEN dealers WIPED at boss turn 24 (17.2M
4+
vs 36M/T50 baseline). Root cause: a survivor SPD source (Maneater's speed piece)
5+
leaked into a dealer's assignment, slowing the UK provider 1.65->1.25 turns/boss,
6+
and dealer SPDs drifted ~1pt off the tune. MEN holds by buff-cadence sync, so SPD
7+
must stay EXACTLY on tune. The gate must:
8+
9+
1. NEVER let a dealer assignment reuse a survivor's equipped artifact.
10+
2. Read REAL computed SPD (not None) so the verify step has ground truth.
11+
3. Re-sim with the ACTUAL post-equip SPDs and, if the tune breaks (survivor SPD
12+
changed, drops below T50, or damage doesn't rise), RESTORE the baseline
13+
BEFORE any key is spent.
14+
15+
Offline tests always run (parsing + the exclusion assert). The live gate test
16+
runs only when RUN_LIVE_REGEAR=1 and the mod is up at localhost:6790, because it
17+
mutates gear on the live game (it always restores, but we don't do that on a
18+
normal test run).
19+
"""
20+
import io
21+
import json
22+
import os
23+
import sys
24+
import unittest
25+
import urllib.request
26+
27+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "tools"))
28+
29+
import cb_regear_for_damage as m # noqa: E402
30+
from cb_sim import SPD, CR, CD, ACC, ATK # noqa: E402
31+
32+
MEN_IDS = [15120, 18607, 2643, 13615, 5692] # Maneater/Demytha/Ninja/Geo/Venom
33+
34+
35+
def _mod_up():
36+
try:
37+
with urllib.request.urlopen(f"{m.MOD_BASE}/status", timeout=3) as r:
38+
return json.loads(r.read()).get("logged_in") is not None
39+
except Exception:
40+
return False
41+
42+
43+
class TestSurvivorExclusionAssert(unittest.TestCase):
44+
"""The airtight guard: dealer assignment must share ZERO ids with survivors."""
45+
46+
def _solves(self, ninja_ids):
47+
# minimal shape _assignment_ids / _assert_no_survivor_overlap read.
48+
return {"Ninja": {"assignment": {i + 1: {"id": a}
49+
for i, a in enumerate(ninja_ids)}}}
50+
51+
def test_disjoint_passes(self):
52+
solves = self._solves([101, 102, 103])
53+
survivor_ids = {201, 202}
54+
got = m._assert_no_survivor_overlap(solves, survivor_ids)
55+
self.assertEqual(got, {101, 102, 103})
56+
57+
def test_overlap_raises(self):
58+
solves = self._solves([101, 202, 103]) # 202 is a survivor piece
59+
survivor_ids = {201, 202}
60+
with self.assertRaises(AssertionError):
61+
m._assert_no_survivor_overlap(solves, survivor_ids)
62+
63+
64+
class TestComputedStatsParse(unittest.TestCase):
65+
"""read_computed_stats must SUM the columns and convert CR/CD fraction->%."""
66+
67+
_FAKE = {
68+
"heroes": [{
69+
"id": 2643,
70+
"base_computed": {"SPD": 100.0, "CR": 0.15, "CD": 0.63,
71+
"ACC": 10.0, "ATK": 1509.0},
72+
"artifact_bonus": {"SPD": 102.0, "CR": 0.80, "CD": 0.97,
73+
"ACC": 205.0, "ATK": 1852.0},
74+
"mastery_bonus": {"SPD": 4.0, "CR": 0.05, "CD": 0.10,
75+
"ACC": 16.0, "ATK": 0.0},
76+
"faction_guardians_bonus": {"SPD": None, "CR": None}, # tolerate None
77+
}],
78+
}
79+
80+
def setUp(self):
81+
self._orig = urllib.request.urlopen
82+
fake = self._FAKE
83+
84+
class _Resp(io.BytesIO):
85+
def __enter__(self_):
86+
return self_
87+
88+
def __exit__(self_, *a):
89+
return False
90+
91+
def _fake_urlopen(url, timeout=30):
92+
return _Resp(json.dumps(fake).encode())
93+
94+
urllib.request.urlopen = _fake_urlopen
95+
96+
def tearDown(self):
97+
urllib.request.urlopen = self._orig
98+
99+
def test_sum_and_convert(self):
100+
stats = m.read_computed_stats([2643])[2643]
101+
self.assertEqual(round(stats[SPD]), 206) # 100+102+4
102+
self.assertAlmostEqual(stats[CR], 100.0, places=3) # (0.15+0.80+0.05)*100
103+
self.assertAlmostEqual(stats[CD], 170.0, places=3) # (0.63+0.97+0.10)*100
104+
self.assertEqual(round(stats[ACC]), 231) # 10+205+16
105+
self.assertEqual(round(stats[ATK]), 3361) # 1509+1852
106+
107+
def test_spd_only_helper(self):
108+
self.assertEqual(m.read_computed_spd([2643]), {2643: 206})
109+
110+
def test_missing_hero_is_none(self):
111+
self.assertIsNone(m.read_computed_stats([999999])[999999])
112+
self.assertIsNone(m.read_computed_spd([999999])[999999])
113+
114+
115+
@unittest.skipUnless(os.environ.get("RUN_LIVE_REGEAR") == "1" and _mod_up(),
116+
"live gate (mutates gear) — set RUN_LIVE_REGEAR=1 with mod up")
117+
class TestLiveGate(unittest.TestCase):
118+
"""End-to-end on the live game: the loose (spd_tol=2) solve DRIFTS the tune,
119+
so the gate must FAIL and RESTORE. Always leaves gear restored."""
120+
121+
def test_real_computed_spd_not_none(self):
122+
spd = m.read_computed_spd(MEN_IDS)
123+
for hid in MEN_IDS:
124+
self.assertIsNotNone(spd[hid], f"SPD None for {hid}")
125+
126+
def test_loose_regear_desyncs_and_restores(self):
127+
R = m.safe_regear(element="spirit", dry_run=True, anneal=6, spd_tol=2,
128+
snapshot_name="men_safe_regear_test")
129+
# survivor exclusion held (assert would have raised otherwise).
130+
self.assertEqual(set(R["assigned_ids"]) & set(R["survivor_excluded"]),
131+
set())
132+
# survivors' SPD never changed (airtight exclusion).
133+
for n, v in R["survivor_spd_check"].items():
134+
self.assertTrue(v["ok"], f"{n} SPD drifted {v}")
135+
# loose solve drifts the tune -> gate FAILS -> gear restored.
136+
self.assertFalse(R["passed"])
137+
self.assertTrue(R["restored"])
138+
# gear is back to the tune on all 5.
139+
import loadouts
140+
heroes = loadouts._fetch_heroes()
141+
ids = m._resolve_ids()
142+
for n in m.MEN:
143+
self.assertEqual(len(loadouts._equipped_by_slot(heroes[ids[n]])), 9)
144+
145+
146+
if __name__ == "__main__":
147+
unittest.main(verbosity=2)

0 commit comments

Comments
 (0)