avrae-ls 0.4.1__py3-none-any.whl → 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
avrae_ls/context.py DELETED
@@ -1,229 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- import logging
5
- from dataclasses import dataclass, field
6
- from pathlib import Path
7
- from typing import Any, Dict, Iterable
8
-
9
- import httpx
10
-
11
- from .config import AvraeLSConfig, ContextProfile, VarSources
12
- from .cvars import derive_character_cvars
13
-
14
- log = logging.getLogger(__name__)
15
-
16
-
17
- @dataclass
18
- class ContextData:
19
- ctx: Dict[str, Any] = field(default_factory=dict)
20
- combat: Dict[str, Any] = field(default_factory=dict)
21
- character: Dict[str, Any] = field(default_factory=dict)
22
- vars: VarSources = field(default_factory=VarSources)
23
-
24
-
25
- class ContextBuilder:
26
- def __init__(self, config: AvraeLSConfig):
27
- self._config = config
28
- self._gvar_resolver = GVarResolver(config)
29
-
30
- @property
31
- def gvar_resolver(self) -> "GVarResolver":
32
- return self._gvar_resolver
33
-
34
- def build(self, profile_name: str | None = None) -> ContextData:
35
- profile = self._select_profile(profile_name)
36
- combat = self._ensure_me_combatant(profile)
37
- merged_vars = self._merge_character_cvars(profile.character, self._load_var_files().merge(profile.vars))
38
- self._gvar_resolver.seed(merged_vars.gvars)
39
- return ContextData(
40
- ctx=dict(profile.ctx),
41
- combat=combat,
42
- character=dict(profile.character),
43
- vars=merged_vars,
44
- )
45
-
46
- def _select_profile(self, profile_name: str | None) -> ContextProfile:
47
- if profile_name and profile_name in self._config.profiles:
48
- return self._config.profiles[profile_name]
49
- if self._config.default_profile in self._config.profiles:
50
- return self._config.profiles[self._config.default_profile]
51
- return next(iter(self._config.profiles.values()))
52
-
53
- def _load_var_files(self) -> VarSources:
54
- merged = VarSources()
55
- for path in self._config.var_files:
56
- data = _read_json_file(path)
57
- if data is None:
58
- continue
59
- merged = merged.merge(VarSources.from_data(data))
60
- return merged
61
-
62
- def _merge_character_cvars(self, character: Dict[str, Any], vars: VarSources) -> VarSources:
63
- merged = vars
64
- char_cvars = character.get("cvars") or {}
65
- if char_cvars:
66
- merged = merged.merge(VarSources(cvars=dict(char_cvars)))
67
-
68
- builtin_cvars = derive_character_cvars(character)
69
- if builtin_cvars:
70
- merged = merged.merge(VarSources(cvars=builtin_cvars))
71
- return merged
72
-
73
- def _ensure_me_combatant(self, profile: ContextProfile) -> Dict[str, Any]:
74
- combat = dict(profile.combat or {})
75
- combatants = list(combat.get("combatants") or [])
76
- me = combat.get("me")
77
- author_id = (profile.ctx.get("author") or {}).get("id")
78
-
79
- def _matches_author(combatant: Dict[str, Any]) -> bool:
80
- try:
81
- return author_id is not None and str(combatant.get("controller")) == str(author_id)
82
- except Exception:
83
- return False
84
-
85
- # Use an existing combatant controlled by the author if me is missing.
86
- if me is None:
87
- for existing in combatants:
88
- if _matches_author(existing):
89
- me = existing
90
- break
91
-
92
- # If still missing, synthesize a combatant from the character sheet.
93
- if me is None and profile.character:
94
- me = {
95
- "name": profile.character.get("name", "Player"),
96
- "id": "cmb_player",
97
- "controller": author_id,
98
- "group": None,
99
- "race": profile.character.get("race"),
100
- "monster_name": None,
101
- "is_hidden": False,
102
- "init": profile.character.get("stats", {}).get("dexterity", 10),
103
- "initmod": 0,
104
- "type": "combatant",
105
- "note": "Mock combatant for preview",
106
- "effects": [],
107
- "stats": profile.character.get("stats") or {},
108
- "levels": profile.character.get("levels") or profile.character.get("class_levels") or {},
109
- "skills": profile.character.get("skills") or {},
110
- "saves": profile.character.get("saves") or {},
111
- "resistances": profile.character.get("resistances") or {},
112
- "spellbook": profile.character.get("spellbook") or {},
113
- "attacks": profile.character.get("attacks") or [],
114
- "max_hp": profile.character.get("max_hp"),
115
- "hp": profile.character.get("hp"),
116
- "temp_hp": profile.character.get("temp_hp"),
117
- "ac": profile.character.get("ac"),
118
- "creature_type": profile.character.get("creature_type"),
119
- }
120
-
121
- if me is not None:
122
- combat["me"] = me
123
- if not any(c is me for c in combatants) and not any(_matches_author(c) for c in combatants):
124
- combatants.insert(0, me)
125
- combat["combatants"] = combatants
126
- if "current" not in combat or combat.get("current") is None:
127
- combat["current"] = me
128
- else:
129
- combat["combatants"] = combatants
130
-
131
- return combat
132
-
133
-
134
- class GVarResolver:
135
- def __init__(self, config: AvraeLSConfig):
136
- self._config = config
137
- self._cache: Dict[str, Any] = {}
138
-
139
- def reset(self, gvars: Dict[str, Any] | None = None) -> None:
140
- self._cache = {}
141
- if gvars:
142
- self._cache.update({str(k): v for k, v in gvars.items()})
143
-
144
- def seed(self, gvars: Dict[str, Any] | None = None) -> None:
145
- """Merge provided gvars into the cache without dropping fetched values."""
146
- if not gvars:
147
- return
148
- for k, v in gvars.items():
149
- self._cache[str(k)] = v
150
-
151
- def get_local(self, key: str) -> Any:
152
- return self._cache.get(str(key))
153
-
154
- async def ensure(self, key: str) -> bool:
155
- key = str(key)
156
- if key in self._cache:
157
- log.debug("GVAR ensure cache hit for %s", key)
158
- return True
159
- if not self._config.enable_gvar_fetch:
160
- log.warning("GVAR fetch disabled; skipping %s", key)
161
- return False
162
- if not self._config.service.token:
163
- log.debug("GVAR fetch skipped for %s: no token configured", key)
164
- return False
165
-
166
- base_url = self._config.service.base_url.rstrip("/")
167
- url = f"{base_url}/customizations/gvars/{key}"
168
- # Avrae service expects the JWT directly in Authorization (no Bearer prefix).
169
- headers = {"Authorization": str(self._config.service.token)}
170
- try:
171
- log.debug("GVAR fetching %s from %s", key, url)
172
- async with httpx.AsyncClient(timeout=5) as client:
173
- resp = await client.get(url, headers=headers)
174
- except Exception as exc:
175
- log.error("GVAR fetch failed for %s: %s", key, exc)
176
- return False
177
-
178
- if resp.status_code != 200:
179
- log.warning(
180
- "GVAR fetch returned %s for %s (body: %s)",
181
- resp.status_code,
182
- key,
183
- (resp.text or "").strip(),
184
- )
185
- return False
186
-
187
- value: Any = None
188
- try:
189
- payload = resp.json()
190
- except Exception:
191
- payload = None
192
-
193
- if isinstance(payload, dict) and "value" in payload:
194
- value = payload["value"]
195
-
196
- log.debug("GVAR fetch parsed value for %s (type=%s)", key, type(value).__name__)
197
-
198
- if value is None:
199
- log.error("GVAR %s payload missing value", key)
200
- return False
201
- self._cache[key] = value
202
- return True
203
-
204
- def snapshot(self) -> Dict[str, Any]:
205
- return dict(self._cache)
206
-
207
- async def refresh(self, seed: Dict[str, Any] | None = None, keys: Iterable[str] | None = None) -> Dict[str, Any]:
208
- self.reset(seed)
209
- if keys:
210
- for key in keys:
211
- await self.ensure(key)
212
- return self.snapshot()
213
-
214
-
215
- def _read_json_file(path: Path) -> Dict[str, Any] | None:
216
- try:
217
- text = path.read_text()
218
- except FileNotFoundError:
219
- log.debug("Var file not found: %s", path)
220
- return None
221
- except OSError as exc:
222
- log.warning("Failed to read var file %s: %s", path, exc)
223
- return None
224
-
225
- try:
226
- return json.loads(text)
227
- except json.JSONDecodeError as exc:
228
- log.warning("Failed to parse var file %s: %s", path, exc)
229
- return None
avrae_ls/cvars.py DELETED
@@ -1,115 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import math
4
- from typing import Any, Dict, Mapping
5
-
6
- ABILITY_KEYS = ["strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"]
7
- SAVE_KEYS = {
8
- "strength": "str",
9
- "dexterity": "dex",
10
- "constitution": "con",
11
- "intelligence": "int",
12
- "wisdom": "wis",
13
- "charisma": "cha",
14
- }
15
-
16
-
17
- def derive_character_cvars(character: Mapping[str, Any]) -> Dict[str, Any]:
18
- """Build the documented cvar table values from a character payload."""
19
- stats = character.get("stats") or {}
20
- saves = character.get("saves") or {}
21
- levels = character.get("levels") or {}
22
- spellbook = character.get("spellbook") or {}
23
- csettings = character.get("csettings") or {}
24
-
25
- cvars: dict[str, Any] = {}
26
-
27
- for ability in ABILITY_KEYS:
28
- score = _int_or_none(stats.get(ability))
29
- save_val = _int_or_none(saves.get(SAVE_KEYS[ability]))
30
-
31
- if score is not None:
32
- cvars[ability] = score
33
- cvars[f"{ability}Mod"] = math.floor((score - 10) / 2)
34
- if save_val is not None:
35
- cvars[f"{ability}Save"] = save_val
36
-
37
- armor = _int_or_none(character.get("ac"))
38
- if armor is not None:
39
- cvars["armor"] = armor
40
-
41
- description = character.get("description")
42
- if description is not None:
43
- cvars["description"] = description
44
-
45
- image = character.get("image")
46
- if image is not None:
47
- cvars["image"] = image
48
-
49
- name = character.get("name")
50
- if name is not None:
51
- cvars["name"] = name
52
-
53
- max_hp = _int_or_none(character.get("max_hp"))
54
- if max_hp is not None:
55
- cvars["hp"] = max_hp
56
-
57
- color = _color_hex(csettings.get("color"))
58
- if color is not None:
59
- cvars["color"] = color
60
-
61
- prof = _int_or_none(stats.get("prof_bonus"))
62
- if prof is not None:
63
- cvars["proficiencyBonus"] = prof
64
-
65
- spell_mod = _spell_mod(spellbook, prof)
66
- if spell_mod is not None:
67
- cvars["spell"] = spell_mod
68
-
69
- total_level = _sum_ints(levels.values())
70
- if total_level is not None:
71
- cvars["level"] = total_level
72
-
73
- for cls, lvl in levels.items():
74
- lvl_int = _int_or_none(lvl)
75
- if lvl_int is None:
76
- continue
77
- cvars[f"{str(cls).replace(' ', '')}Level"] = lvl_int
78
-
79
- return cvars
80
-
81
-
82
- def _int_or_none(value: Any) -> int | None:
83
- if isinstance(value, dict) and "value" in value:
84
- value = value.get("value")
85
- try:
86
- return int(value)
87
- except (TypeError, ValueError):
88
- return None
89
-
90
-
91
- def _spell_mod(spellbook: Mapping[str, Any], prof_bonus: int | None) -> int | None:
92
- if "spell_mod" in spellbook:
93
- return _int_or_none(spellbook.get("spell_mod"))
94
- if "sab" in spellbook and prof_bonus is not None:
95
- sab = _int_or_none(spellbook.get("sab"))
96
- if sab is not None:
97
- return sab - prof_bonus
98
- return None
99
-
100
-
101
- def _sum_ints(values: Any) -> int | None:
102
- try:
103
- total = sum(int(v) for v in values)
104
- except Exception:
105
- return None
106
- return total
107
-
108
-
109
- def _color_hex(value: Any) -> str | None:
110
- if value is None:
111
- return None
112
- try:
113
- return hex(int(value))[2:]
114
- except (TypeError, ValueError):
115
- return None