Skip to content

Commit d79de7f

Browse files
SnoopLawgclaude
andcommitted
organic-teams M2: provider->consumer dependency + ordering resolver
tools/synergy_resolver.py per docs/organic_team_m2_m4_spec.md. resolve(comp, context) -> {satisfied,broken,unmet,order,keystones,notes} with channel-aware need->provide routing: def_break credited ONLY when provider.amplifier_channel ==hit AND consumer engine ∩ {hit,wm_gs,bring_it_down}; poison_synergy only via amplifier_channel==poison; hp_burn engine has no amplifier. A poison engine carrying needs:def_break with only Weaken/Dec-DEF available -> notes (category mismatch), never satisfied -- the won't-credit-Weaken->poison headline. Ordering REUSES the locked scheduler (cb_scheduler.pick_next_actor -> turn_meter.TurnMeterEngine, PICK-MAX-ONE+ZERO-RESET, boss SPD 190; NOT re-derived) for CB act-order; generic mode = SPD-rank + duration-vs-cooldown; self / untimed_refresh / lenient-fallback handled. Edge cases: self-amplifier (provider==consumer), KEYSTONE_ENABLER_COMPAT (UK/BD/shield/heal_lifesteal -> {cd_reduce,buff_extend}; revive/ally_protect -> {cd_reduce}), slot-cap redundancy (2nd HP-burner via M3 max_hp_burn=1), CB no-ops (tm_control -> boss immunity -> notes). Tolerates M1-missing fields (warn-once). engine_channel handled as a list. 9 resolver tests + survival/scheduler guards pass (11 total). Unblocks M4. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SCKiWFsFcWRBkL4kWf1q6b
1 parent c474eb1 commit d79de7f

2 files changed

Lines changed: 901 additions & 0 deletions

File tree

tests/test_synergy_resolver.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
"""M2 acceptance tests for tools/synergy_resolver.py.
2+
3+
Covers the spec's five acceptance criteria:
4+
1. Known DWJ CB tune (Maneater/Demytha/Ninja/Geomancer/Venomage):
5+
Maneater keystone enabler_ok; Ninja(hit) has a satisfied def_break edge;
6+
NO satisfied def_break edge credited to Venomage's poison engine.
7+
2. Mis-ordered comp (slowest + short-duration Dec-DEF provider) -> broken.
8+
3. Self-amplifier (Frozen Banshee) covers its own poison_synergy (reason "self").
9+
4. Keystone-enabler compat: Maneater+Pain Keeper -> enabler_ok; a revive
10+
keystone + buff_extension-only -> NOT satisfied.
11+
5. Two HP-burners -> 2nd flagged redundant (slot cap max_hp_burn=1).
12+
13+
Plus the headline channel rule: a pure-poison engine carrying needs:def_break
14+
is NEVER credited a Weaken/Dec-DEF edge (the "won't credit Weaken->poison"
15+
acceptance) -> it lands in notes, not satisfied/broken.
16+
"""
17+
from __future__ import annotations
18+
19+
import sys
20+
from pathlib import Path
21+
22+
import pytest
23+
24+
PROJECT_ROOT = Path(__file__).resolve().parent.parent
25+
sys.path.insert(0, str(PROJECT_ROOT / "tools"))
26+
27+
import synergy_resolver as sr # noqa: E402
28+
29+
30+
# --------------------------------------------------------------------- helpers
31+
def _rec(name, **kw):
32+
"""Build a synthetic enriched record with M1-complete safe defaults."""
33+
base = {
34+
"name": name,
35+
"element": "Void",
36+
"fraction": "Generic",
37+
"provides": [],
38+
"needs": [],
39+
"amplifier_channel": "none",
40+
"engine_channel": [],
41+
"survival_currency": None,
42+
"enabler": None,
43+
"keystone_needs_enabler": False,
44+
"debuffs_control_only": False,
45+
}
46+
base.update(kw)
47+
return base
48+
49+
50+
MEN = ["Maneater", "Demytha", "Ninja", "Geomancer", "Venomage"]
51+
52+
53+
# ============================================================= acceptance #1
54+
def test_known_dwj_tune_channels_and_keystone():
55+
res = sr.resolve(MEN, sr.ResolveContext(location="clan_boss"))
56+
57+
# Maneater keystone (unkillable) is enabled (Demytha buff_extension /
58+
# Ninja+Geo cooldown_reduction are all unkillable-compatible).
59+
ks = res.keystone_for("Maneater")
60+
assert ks is not None, "Maneater should appear in keystones[]"
61+
assert ks["currency"] == "unkillable"
62+
assert ks["enabler_ok"] is True
63+
64+
# Ninja is a hit engine -> has a satisfied def_break edge (self Dec-DEF).
65+
ninja_db = res.satisfied_edges(tag="def_break", consumer="Ninja")
66+
assert ninja_db, "Ninja (hit engine) should have a satisfied def_break edge"
67+
68+
# Venomage is a poison engine -> NO satisfied def_break edge (channel
69+
# negative). Its needs carry poison_synergy, not def_break, so nothing is
70+
# credited; the assertion must hold regardless.
71+
assert res.satisfied_edges(tag="def_break", consumer="Venomage") == []
72+
assert not any(e.tag == "def_break" and e.consumer == "Venomage"
73+
for e in res.broken)
74+
75+
76+
def test_channel_negative_wont_credit_weaken_to_poison():
77+
"""THE acceptance: a pure-poison engine carrying needs:def_break must NOT
78+
be credited a Weaken/Dec-DEF edge -> notes, not satisfied/broken."""
79+
weakener = _rec("Weakener", amplifier_channel="hit",
80+
provides=["enemy_debuff:Weaken", "enemy_debuff:Decrease DEF"])
81+
poison_engine = _rec("PurePoison", amplifier_channel="none",
82+
engine_channel=["poison"],
83+
provides=["dot:Poison"],
84+
needs=["def_break", "poison_synergy"])
85+
res = sr.resolve_records([weakener, poison_engine],
86+
sr.ResolveContext(mode="generic"))
87+
88+
assert res.satisfied_edges(tag="def_break") == []
89+
assert not any(e.tag == "def_break" for e in res.broken)
90+
# The mismatch is explained in notes, not silently dropped.
91+
assert any("def_break" in n and "channel mismatch" in n
92+
for n in res.notes)
93+
94+
95+
# ============================================================= acceptance #2
96+
def test_misordered_def_break_edge_is_broken():
97+
slow_provider = _rec("SlowFayne", amplifier_channel="hit",
98+
provides=["enemy_debuff:Decrease DEF"])
99+
fast_nuker = _rec("FastNuker", engine_channel=["hit"],
100+
needs=["def_break"])
101+
ctx = sr.ResolveContext(
102+
mode="generic",
103+
speeds={"SlowFayne": 100, "FastNuker": 220}, # provider is slowest
104+
durations={"SlowFayne": 1}, # short debuff
105+
cooldowns={"FastNuker": 3}, # needs longer coverage
106+
)
107+
res = sr.resolve_records([slow_provider, fast_nuker], ctx)
108+
109+
assert res.satisfied_edges(tag="def_break") == []
110+
broken = [e for e in res.broken if e.tag == "def_break"]
111+
assert broken, "the late + short-duration Dec-DEF edge should be broken"
112+
assert broken[0].provider == "SlowFayne"
113+
assert broken[0].consumer == "FastNuker"
114+
assert "late_provider" in broken[0].ordering_reason
115+
116+
117+
# ============================================================= acceptance #3
118+
def test_self_amplifier_frozen_banshee():
119+
res = sr.resolve(["Frozen Banshee"], sr.ResolveContext(location="clan_boss"))
120+
ps = res.satisfied_edges(tag="poison_synergy", consumer="Frozen Banshee")
121+
assert ps, "Frozen Banshee should self-cover poison_synergy"
122+
edge = ps[0]
123+
assert edge.provider == edge.consumer == "Frozen Banshee"
124+
assert edge.ordering_reason == "self"
125+
assert edge.channel == "poison"
126+
127+
128+
# ============================================================= acceptance #4
129+
def test_keystone_enabler_compat_positive():
130+
res = sr.resolve(["Maneater", "Pain Keeper"],
131+
sr.ResolveContext(location="clan_boss"))
132+
ks = res.keystone_for("Maneater")
133+
assert ks is not None and ks["enabler_ok"] is True
134+
# Pain Keeper supplies cooldown_reduction, which is unkillable-compatible.
135+
assert "cooldown_reduction" in ks["reason"] or ks["enabler"] == "Pain Keeper"
136+
137+
138+
def test_keystone_enabler_compat_negative_revive_needs_cdr():
139+
revive = _rec("ReviveHero", survival_currency="revive_on_death",
140+
keystone_needs_enabler=True)
141+
extender = _rec("Extender", enabler="buff_extension",
142+
provides=["buff_extension"])
143+
res = sr.resolve_records([revive, extender], sr.ResolveContext())
144+
ks = res.keystone_for("ReviveHero")
145+
assert ks is not None
146+
# revive_on_death is compatible only with cooldown_reduction; a
147+
# buff_extension-only provider must NOT satisfy it.
148+
assert ks["enabler_ok"] is False
149+
150+
151+
# ============================================================= acceptance #5
152+
def test_two_hp_burners_second_redundant():
153+
b1 = _rec("Burner1", engine_channel=["hp_burn"], provides=["dot:HP Burn"])
154+
b2 = _rec("Burner2", engine_channel=["hp_burn"], provides=["dot:HP Burn"])
155+
res = sr.resolve_records([b1, b2], sr.ResolveContext(location="clan_boss"))
156+
assert any("redundant" in n and "Burner2" in n and "hp_burn" in n
157+
for n in res.notes), \
158+
"the 2nd HP burner should be flagged redundant (slot cap 1)"
159+
160+
161+
# ------------------------------------------------------ supporting invariants
162+
def test_cb_tm_control_is_noop_vs_boss():
163+
"""tm_control is a no-op vs the CB boss -> notes, never satisfied."""
164+
res = sr.resolve(MEN, sr.ResolveContext(location="clan_boss"))
165+
assert res.satisfied_edges(tag="tm_control") == []
166+
assert any("tm_control" in n and "no-op" in n for n in res.notes)
167+
168+
169+
def test_missing_m1_fields_tolerated():
170+
"""A record lacking M1 fields resolves with safe defaults (no crash)."""
171+
bare = {"name": "LegacyHero", "provides": ["enemy_debuff:Decrease DEF"],
172+
"needs": ["def_break"]}
173+
res = sr.resolve_records([bare], sr.ResolveContext(mode="generic"))
174+
# No engine channel by default -> def_break is a category mismatch, noted.
175+
assert isinstance(res, sr.ResolveResult)
176+
assert res.satisfied_edges(tag="def_break") == []
177+
178+
179+
if __name__ == "__main__":
180+
raise SystemExit(pytest.main([__file__, "-q"]))

0 commit comments

Comments
 (0)