Skip to content

Commit 509972c

Browse files
SnoopLawgclaude
andcommitted
fix: SET_BONUSES dropped the 2nd stat of multi-stat sets
Deep set-mechanics investigation (user flagged shallow understanding). IL2CPP ArtifactSetInfo has a List<StatBonus> StatBonuses, but our extraction only kept the singular StatBonus (first stat). So 8 multi-stat sets lost their 2nd stat in SET_BONUSES: Perception (+40 ACC AND +5% SPD) kept only ACC; Lethal (ATK+CR), CritDmg+Speed, Speed+Resistance, HpAndDefence, ResistAndDef, StaminaSpdAcc, CritDmg+IgnoreDef all dropped a stat. The optimizer + fallback stat-calc undercounted them (my Venom SPD opt was missing Perception/CritDmgSpeed +5% SPD). Fix: gear_constants now merges the full plural stat_bonuses from artifact_sets.json (which has it, from /artifact-sets-truth); refresh_artifact_ truth.py now emits stat_bonuses (plural) so artifact_rules.json carries it too. Default calc_stats path (sim) unaffected — it uses the mod's artifact_bonus column; only the optimizer (hypothetical) + fallback use SET_BONUSES. Regression unchanged (same 2 pre-existing failures). Optimizer now uses Perception sets for combined ACC+SPD builds. Also documented full set taxonomy (single/multi-stat/proc/accessory sets, stacking by complete-set count) in project_artifact_set_mechanics_gametruth. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4179fc0 commit 509972c

2 files changed

Lines changed: 438 additions & 57 deletions

File tree

tools/gear_constants.py

Lines changed: 175 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,25 @@
44
All slot IDs, stat IDs, set IDs, and mapping tables in ONE place.
55
Import from here — do NOT hardcode these values in other files.
66
7-
Source: IL2CPP dump (ArtifactKindId, ArtifactStatKindId, StatKindId, ArtifactSetKindId)
8-
Verified: 2026-04-11 against Teodor the Savant's in-game artifacts.
7+
Source of truth:
8+
- data/static/artifact_rules.json (live game data via mod static-export)
9+
- regenerated by `python3 tools/refresh_artifact_truth.py`
10+
- falls back to embedded constants if the JSON is missing/stale
11+
12+
What's tracked here (game-truth, not handmade):
13+
- SLOT_*: integer enum (1..9 incl. Cloak/Banner)
14+
- VALID_PRIMARIES: per-slot primary stats allowed by the game
15+
- VALID_SUBSTATS: per-slot substats allowed by the game
16+
- SET_NAMES: id → in-game UI name (Speed, Lifesteal, Toxic, Frenzied, etc.)
17+
- SET_INTERNAL_NAMES: id → Plarium internal enum name (AttackSpeed, LifeDrain, …)
18+
19+
Slot naming note: the game's static data uses "Cloak" for slot 8;
20+
some legacy code refers to it as "Amulet". Both names work via alias.
921
"""
1022

23+
from pathlib import Path
24+
import json as _json
25+
1126
# =============================================================================
1227
# Artifact Slot (ArtifactKindId enum from dump line 335xxx)
1328
# =============================================================================
@@ -18,13 +33,18 @@
1833
SLOT_WEAPON = 5
1934
SLOT_SHIELD = 6
2035
SLOT_RING = 7
21-
SLOT_AMULET = 8 # "Cloak" in game code
36+
SLOT_AMULET = 8 # canonical name (in-game UI shows "Amulet")
37+
SLOT_CLOAK = 8 # alias for the Plarium-internal "Cloak" enum (same slot)
2238
SLOT_BANNER = 9
2339

2440
SLOT_NAMES = {
2541
1: "Helmet", 2: "Chest", 3: "Gloves", 4: "Boots",
2642
5: "Weapon", 6: "Shield", 7: "Ring", 8: "Amulet", 9: "Banner",
2743
}
44+
# Reverse lookup (handles both the in-game UI name "Amulet" AND Plarium's
45+
# internal static-data name "Cloak" — both map to slot 8).
46+
SLOT_BY_NAME = {v: k for k, v in SLOT_NAMES.items()}
47+
SLOT_BY_NAME["Cloak"] = SLOT_AMULET # static export uses "Cloak"; game UI says "Amulet"
2848

2949
SLOT_NAMES_SHORT = {
3050
1: "Hlm", 2: "Cht", 3: "Glv", 4: "Bts",
@@ -36,19 +56,67 @@
3656
# Accessory slots (faction-locked)
3757
ACCESSORY_SLOTS = {SLOT_RING, SLOT_AMULET, SLOT_BANNER}
3858

39-
# Which primary stat types are valid per slot
40-
# Source: Raid game rules
41-
VALID_PRIMARIES = {
42-
SLOT_WEAPON: ["ATK_flat"], # always ATK flat
43-
SLOT_HELMET: ["HP_flat"], # always HP flat
44-
SLOT_SHIELD: ["DEF_flat", "HP%", "DEF%", "ATK%", "CR%", "CD%"], # DEF flat base + ascension options
45-
SLOT_GLOVES: ["HP%", "ATK%", "DEF%", "CD%", "HP_flat", "ATK_flat", "DEF_flat"],
46-
SLOT_CHEST: ["HP%", "ATK%", "DEF%", "ACC", "RES", "HP_flat", "ATK_flat", "DEF_flat"],
47-
SLOT_BOOTS: ["SPD", "HP%", "ATK%", "DEF%", "HP_flat", "ATK_flat", "DEF_flat"],
48-
SLOT_RING: ["HP_flat", "ATK_flat", "DEF_flat"],
49-
SLOT_AMULET: ["HP_flat", "ATK_flat", "DEF_flat", "CD%"],
50-
SLOT_BANNER: ["ACC", "RES", "HP_flat", "ATK_flat", "DEF_flat"],
51-
}
59+
60+
# Stat name encoded as "<STAT>" for flat or "<STAT>%" for percent.
61+
# We use these strings in VALID_PRIMARIES / VALID_SUBSTATS for clarity.
62+
def _stat_label(stat_kind_id: str, is_flat: bool) -> str:
63+
base = {"Health": "HP", "Attack": "ATK", "Defence": "DEF",
64+
"Speed": "SPD", "Resistance": "RES", "Accuracy": "ACC",
65+
"CriticalChance": "CR", "CriticalDamage": "CD"}.get(stat_kind_id, stat_kind_id)
66+
# Speed/ACC/RES are always flat in Raid; CR/CD are always %.
67+
if base in ("SPD", "RES", "ACC"):
68+
return base
69+
if base in ("CR", "CD"):
70+
return base + "%"
71+
return base + ("" if is_flat else "%")
72+
73+
74+
# Load primary + substat rules from the artifact_rules.json file (game truth).
75+
# Schema: list of {stat, is_flat, slots: [SlotName, ...]}.
76+
# Fall back to a hand-written table if the file is missing.
77+
VALID_PRIMARIES: dict[int, list[str]] = {s: [] for s in SLOT_NAMES}
78+
VALID_SUBSTATS: dict[int, list[str]] = {s: [] for s in SLOT_NAMES}
79+
80+
_RULES_PATH = Path(__file__).resolve().parent.parent / "data" / "static" / "artifact_rules.json"
81+
_rules_loaded = False
82+
if _RULES_PATH.exists():
83+
try:
84+
_rules = _json.loads(_RULES_PATH.read_text(encoding="utf-8"))
85+
for entry in _rules.get("primary_rules", []):
86+
label = _stat_label(entry["stat"], entry["is_flat"])
87+
for slot_name in entry.get("slots", []):
88+
slot_id = SLOT_BY_NAME.get(slot_name)
89+
if slot_id and label not in VALID_PRIMARIES[slot_id]:
90+
VALID_PRIMARIES[slot_id].append(label)
91+
for entry in _rules.get("substat_rules", []):
92+
label = _stat_label(entry["stat"], entry["is_flat"])
93+
for slot_name in entry.get("slots", []):
94+
slot_id = SLOT_BY_NAME.get(slot_name)
95+
if slot_id and label not in VALID_SUBSTATS[slot_id]:
96+
VALID_SUBSTATS[slot_id].append(label)
97+
_rules_loaded = True
98+
except Exception as _e:
99+
# Don't crash the import; fall back below
100+
import sys as _sys
101+
print(f"[gear_constants] WARN: failed to load {_RULES_PATH.name}: {_e}",
102+
file=_sys.stderr)
103+
104+
if not _rules_loaded:
105+
# Fallback: hand-coded primary rules. Substats stay empty so callers
106+
# can detect the stale state. Run `python3 tools/refresh_artifact_truth.py`
107+
# to populate from live game data.
108+
VALID_PRIMARIES = {
109+
SLOT_HELMET: ["HP"],
110+
SLOT_CHEST: ["HP", "HP%", "ATK", "ATK%", "DEF", "DEF%", "RES", "ACC"],
111+
SLOT_GLOVES: ["HP", "HP%", "ATK", "ATK%", "DEF", "DEF%", "CR%", "CD%"],
112+
SLOT_BOOTS: ["HP", "HP%", "ATK", "ATK%", "DEF", "DEF%", "SPD"],
113+
SLOT_WEAPON: ["ATK"],
114+
SLOT_SHIELD: ["DEF"],
115+
SLOT_RING: ["HP", "ATK", "DEF"],
116+
SLOT_CLOAK: ["HP", "ATK", "DEF", "CD%"],
117+
SLOT_BANNER: ["HP", "ATK", "DEF", "RES", "ACC"],
118+
}
119+
VALID_SUBSTATS = {s: [] for s in SLOT_NAMES}
52120

53121
# =============================================================================
54122
# Stat IDs (StatKindId — the mod outputs these)
@@ -115,51 +183,101 @@ def to_artifact_stat_kind_id(stat_id, is_flat):
115183
# SET_BONUSES — derived from static_data so the values are always live-game
116184
# correct (e.g. Speed = 2-piece +12%, AccuracyAndSpeed = +40 ACC + 5% SPD).
117185
# =============================================================================
118-
SET_NAMES = {
119-
0: "None",
120-
1: "HP", 2: "ATK", 3: "DEF", 4: "Speed", 5: "CritRate", 6: "CritDmg",
121-
7: "Accuracy", 8: "Resistance",
122-
9: "Lifesteal", 10: "Fury", 11: "Daze", 12: "Cursed", 13: "Frost",
123-
14: "Frenzy", 15: "Regeneration", 16: "Toxic", 17: "Shield",
124-
18: "Relentless", 19: "Savage", 20: "Destroy", 21: "Stun",
125-
22: "Cruel", 23: "Immortal", 24: "DivineSpeed", 25: "DivineCritRate",
126-
26: "Stalwart", 27: "DivineLife", 28: "Swift Parry", 29: "Cruel",
127-
30: "Regeneration", 33: "Reflex", 34: "Deflection",
128-
35: "Resilience", 36: "Deflection", 37: "Immunity", 38: "Perception",
129-
40: "Guardian", 41: "Untouchable", 43: "Cruel",
130-
44: "Guardian", 46: "Lethal", 47: "Bolster",
131-
48: "Bloodthirst", 50: "Curing", 51: "Reaction",
132-
53: "Stoneskin", 54: "Protection", 56: "Prowess",
133-
57: "Forsaken", 58: "Frostbite", 59: "Affinitybreaker",
134-
60: "Bloodshield", 61: "Divine Offense", 62: "Vigor",
135-
63: "Fortitude",
136-
}
137-
138-
SET_KIND_IDS: dict[int, str] = {} # numeric id → Plarium enum name (e.g. "Hp", "AttackSpeed")
186+
SET_NAMES: dict[int, str] = {0: "None"} # id → in-game UI name (Speed, Lifesteal, ...)
187+
SET_INTERNAL_NAMES: dict[int, str] = {} # id → Plarium internal enum (Hp, AttackPower, ...)
188+
SET_PIECES: dict[int, int] = {} # id → 2 / 4 (or 0 for special)
189+
SET_KIND_IDS: dict[int, str] = SET_INTERNAL_NAMES # legacy alias for existing consumers
139190
SET_BONUSES: dict[int, tuple[int, dict[int, float]]] = {}
140191

192+
# Prefer the live game-truth file (data/static/artifact_rules.json) which is
193+
# generated by `tools/refresh_artifact_truth.py`. Falls back to the older
194+
# static_data module, then to the embedded base-set list.
195+
if _rules_loaded:
196+
for _s in _rules.get("sets", []):
197+
_id = _s.get("id")
198+
if _id is None: continue
199+
SET_NAMES[_id] = _s.get("display_name") or _s.get("internal_name") or "?"
200+
SET_INTERNAL_NAMES[_id] = _s.get("internal_name") or ""
201+
SET_PIECES[_id] = _s.get("pieces") or 0
202+
# Build SET_BONUSES (stat-bonus sets) from the parsed entries.
203+
sb = _s.get("stat_bonus")
204+
if sb and sb.get("stat"):
205+
_stat_id = {"Health": STAT_HP, "Attack": STAT_ATK, "Defence": STAT_DEF,
206+
"Speed": STAT_SPD, "Resistance": STAT_RES, "Accuracy": STAT_ACC,
207+
"CriticalChance": STAT_CR, "CriticalDamage": STAT_CD}.get(sb["stat"])
208+
if _stat_id:
209+
_val = sb.get("value", 0)
210+
if not sb.get("absolute"):
211+
_val = int(round(_val * 100)) # 0.12 → 12
212+
SET_BONUSES[_id] = (_s.get("pieces") or 2, {_stat_id: _val})
213+
214+
# MULTI-STAT SET FIX (2026-06-23): artifact_rules.json only carries the SINGULAR
215+
# `stat_bonus` (the first StatBonus), but the game model (IL2CPP ArtifactSetInfo)
216+
# has a `List<StatBonus> StatBonuses` — several sets grant TWO stats
217+
# (Perception = +40 ACC AND +5% SPD, Lethal = +15% ATK AND +5% C.RATE,
218+
# CritDmg+Speed, Speed+Resistance, HpAndDefence, ResistAndDef, etc.). The full
219+
# plural list lives in data/static/artifact_sets.json (from /artifact-sets-truth).
220+
# Merge any missing stats in so SET_BONUSES is the complete game-truth — the
221+
# gear optimizer + fallback stat calc were silently dropping the 2nd stat.
141222
try:
142-
try:
143-
from tools.static_data import default as _sd
144-
except ImportError:
145-
from static_data import default as _sd # called from tools/ cwd
146-
_static = _sd()
147-
for _id, _set in _static.artifact_sets_by_id.items():
148-
SET_KIND_IDS[_id] = _set.set
149-
SET_BONUSES.update(_static.set_bonus_table())
223+
import json as _json
224+
from pathlib import Path as _Path
225+
_sets_path = _Path(__file__).resolve().parent.parent / "data" / "static" / "artifact_sets.json"
226+
if _sets_path.exists():
227+
_af = _json.loads(_sets_path.read_text(encoding="utf-8"))
228+
_rows = next((v for k, v in _af.items() if k != "_meta" and isinstance(v, list)), [])
229+
_name_to_stat = {"Health": STAT_HP, "Attack": STAT_ATK, "Defence": STAT_DEF,
230+
"Speed": STAT_SPD, "Resistance": STAT_RES, "Accuracy": STAT_ACC,
231+
"CriticalChance": STAT_CR, "CriticalDamage": STAT_CD}
232+
for _row in _rows:
233+
_id = _row.get("id")
234+
_full = _row.get("stat_bonuses") or []
235+
if _id is None or len(_full) < 1:
236+
continue
237+
_pieces = _row.get("pieces") or SET_PIECES.get(_id) or 2
238+
_stat_map = dict(SET_BONUSES.get(_id, (_pieces, {}))[1])
239+
for _b in _full:
240+
_sid = _name_to_stat.get(_b.get("stat"))
241+
if _sid is None:
242+
continue # e.g. IgnoreDefence — not a base stat, handled as proc
243+
_v = _b.get("value", 0)
244+
if not _b.get("absolute"):
245+
_v = round(_v * 100, 2) # 0.05 -> 5 (percent)
246+
_stat_map[_sid] = _v # game-truth value (overwrites/augments)
247+
if _stat_map:
248+
SET_BONUSES[_id] = (_pieces, _stat_map)
150249
except Exception:
151-
# Fallback when data/static/artifact_sets.json is missing. Covers the
152-
# 8 base stat sets only; proc/combo sets need a static refresh.
153-
SET_KIND_IDS.update({
154-
1: "Hp", 2: "AttackPower", 3: "Defense", 4: "AttackSpeed",
155-
5: "CriticalChance", 6: "CriticalDamage", 7: "Accuracy", 8: "Resistance",
156-
})
157-
SET_BONUSES.update({
158-
1: (2, {STAT_HP: 15}), 2: (2, {STAT_ATK: 15}),
159-
3: (2, {STAT_DEF: 15}), 4: (2, {STAT_SPD: 12}),
160-
5: (2, {STAT_CR: 12}), 6: (2, {STAT_CD: 20}),
161-
7: (2, {STAT_ACC: 40}), 8: (2, {STAT_RES: 40}),
162-
})
250+
pass # keep the singular-stat table if the plural file is unavailable
251+
252+
if not SET_BONUSES:
253+
# No rules file present — fall back to legacy static_data module.
254+
try:
255+
try:
256+
from tools.static_data import default as _sd
257+
except ImportError:
258+
from static_data import default as _sd # called from tools/ cwd
259+
_static = _sd()
260+
for _id, _set in _static.artifact_sets_by_id.items():
261+
SET_INTERNAL_NAMES[_id] = _set.set
262+
SET_NAMES.setdefault(_id, _set.set)
263+
SET_BONUSES.update(_static.set_bonus_table())
264+
except Exception:
265+
# Last-resort: the 8 base stat sets only.
266+
SET_INTERNAL_NAMES.update({
267+
1: "Hp", 2: "AttackPower", 3: "Defense", 4: "AttackSpeed",
268+
5: "CriticalChance", 6: "CriticalDamage", 7: "Accuracy", 8: "Resistance",
269+
})
270+
SET_NAMES.update({
271+
1: "Life", 2: "Offense", 3: "Defense", 4: "Speed",
272+
5: "Critical Rate", 6: "Critical Damage",
273+
7: "Accuracy", 8: "Resistance",
274+
})
275+
SET_BONUSES.update({
276+
1: (2, {STAT_HP: 15}), 2: (2, {STAT_ATK: 15}),
277+
3: (2, {STAT_DEF: 15}), 4: (2, {STAT_SPD: 12}),
278+
5: (2, {STAT_CR: 12}), 6: (2, {STAT_CD: 20}),
279+
7: (2, {STAT_ACC: 40}), 8: (2, {STAT_RES: 40}),
280+
})
163281

164282
# Special set flags (tracked but bonuses applied in sim, not stat calc)
165283
SPECIAL_SETS = {

0 commit comments

Comments
 (0)