|
4 | 4 | All slot IDs, stat IDs, set IDs, and mapping tables in ONE place. |
5 | 5 | Import from here — do NOT hardcode these values in other files. |
6 | 6 |
|
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. |
9 | 21 | """ |
10 | 22 |
|
| 23 | +from pathlib import Path |
| 24 | +import json as _json |
| 25 | + |
11 | 26 | # ============================================================================= |
12 | 27 | # Artifact Slot (ArtifactKindId enum from dump line 335xxx) |
13 | 28 | # ============================================================================= |
|
18 | 33 | SLOT_WEAPON = 5 |
19 | 34 | SLOT_SHIELD = 6 |
20 | 35 | 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) |
22 | 38 | SLOT_BANNER = 9 |
23 | 39 |
|
24 | 40 | SLOT_NAMES = { |
25 | 41 | 1: "Helmet", 2: "Chest", 3: "Gloves", 4: "Boots", |
26 | 42 | 5: "Weapon", 6: "Shield", 7: "Ring", 8: "Amulet", 9: "Banner", |
27 | 43 | } |
| 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" |
28 | 48 |
|
29 | 49 | SLOT_NAMES_SHORT = { |
30 | 50 | 1: "Hlm", 2: "Cht", 3: "Glv", 4: "Bts", |
|
36 | 56 | # Accessory slots (faction-locked) |
37 | 57 | ACCESSORY_SLOTS = {SLOT_RING, SLOT_AMULET, SLOT_BANNER} |
38 | 58 |
|
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} |
52 | 120 |
|
53 | 121 | # ============================================================================= |
54 | 122 | # Stat IDs (StatKindId — the mod outputs these) |
@@ -115,51 +183,101 @@ def to_artifact_stat_kind_id(stat_id, is_flat): |
115 | 183 | # SET_BONUSES — derived from static_data so the values are always live-game |
116 | 184 | # correct (e.g. Speed = 2-piece +12%, AccuracyAndSpeed = +40 ACC + 5% SPD). |
117 | 185 | # ============================================================================= |
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 |
139 | 190 | SET_BONUSES: dict[int, tuple[int, dict[int, float]]] = {} |
140 | 191 |
|
| 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. |
141 | 222 | 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) |
150 | 249 | 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 | + }) |
163 | 281 |
|
164 | 282 | # Special set flags (tracked but bonuses applied in sim, not stat calc) |
165 | 283 | SPECIAL_SETS = { |
|
0 commit comments