avrae-ls 0.6.0__py3-none-any.whl → 0.6.2__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 ADDED
@@ -0,0 +1,337 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import asyncio
6
+ import copy
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Any, Dict, Iterable
10
+
11
+ import httpx
12
+
13
+ from .config import AvraeLSConfig, ContextProfile, VarSources
14
+ from .cvars import derive_character_cvars
15
+
16
+ log = logging.getLogger(__name__)
17
+
18
+
19
+ @dataclass
20
+ class ContextData:
21
+ ctx: Dict[str, Any] = field(default_factory=dict)
22
+ combat: Dict[str, Any] = field(default_factory=dict)
23
+ character: Dict[str, Any] = field(default_factory=dict)
24
+ vars: VarSources = field(default_factory=VarSources)
25
+
26
+
27
+ class ContextBuilder:
28
+ def __init__(self, config: AvraeLSConfig):
29
+ self._config = config
30
+ self._gvar_resolver = GVarResolver(config)
31
+
32
+ @property
33
+ def gvar_resolver(self) -> "GVarResolver":
34
+ return self._gvar_resolver
35
+
36
+ def build(self, profile_name: str | None = None) -> ContextData:
37
+ profile = self._select_profile(profile_name)
38
+ # Deep copy profile data so mutations during a run do not persist.
39
+ profile_character = copy.deepcopy(profile.character)
40
+ profile_combat = copy.deepcopy(profile.combat)
41
+ profile_ctx = copy.deepcopy(profile.ctx)
42
+
43
+ combat = self._ensure_me_combatant(profile_combat, profile_ctx.get("author"))
44
+ merged_vars = self._merge_character_cvars(profile_character, self._load_var_files().merge(profile.vars))
45
+ self._gvar_resolver.reset(merged_vars.gvars)
46
+ return ContextData(
47
+ ctx=profile_ctx,
48
+ combat=combat,
49
+ character=profile_character,
50
+ vars=merged_vars,
51
+ )
52
+
53
+ def _select_profile(self, profile_name: str | None) -> ContextProfile:
54
+ if profile_name and profile_name in self._config.profiles:
55
+ return self._config.profiles[profile_name]
56
+ if self._config.default_profile in self._config.profiles:
57
+ return self._config.profiles[self._config.default_profile]
58
+ return next(iter(self._config.profiles.values()))
59
+
60
+ def _load_var_files(self) -> VarSources:
61
+ merged = VarSources()
62
+ for path in self._config.var_files:
63
+ data = _read_json_file(path)
64
+ if data is None:
65
+ continue
66
+ merged = merged.merge(VarSources.from_data(data))
67
+ return merged
68
+
69
+ def _merge_character_cvars(self, character: Dict[str, Any], vars: VarSources) -> VarSources:
70
+ merged = vars
71
+ char_cvars = character.get("cvars") or {}
72
+ if char_cvars:
73
+ merged = merged.merge(VarSources(cvars=dict(char_cvars)))
74
+
75
+ builtin_cvars = derive_character_cvars(character)
76
+ if builtin_cvars:
77
+ merged = merged.merge(VarSources(cvars=builtin_cvars))
78
+ return merged
79
+
80
+ def _ensure_me_combatant(self, profile: Dict[str, Any], ctx_author: Dict[str, Any] | None) -> Dict[str, Any]:
81
+ combat = dict(profile or {})
82
+ combatants = list(combat.get("combatants") or [])
83
+ me = combat.get("me")
84
+ author_id = (ctx_author or {}).get("id")
85
+
86
+ def _matches_author(combatant: Dict[str, Any]) -> bool:
87
+ try:
88
+ return author_id is not None and str(combatant.get("controller")) == str(author_id)
89
+ except Exception:
90
+ return False
91
+
92
+ # Use an existing combatant controlled by the author if me is missing.
93
+ if me is None:
94
+ for existing in combatants:
95
+ if _matches_author(existing):
96
+ me = existing
97
+ break
98
+
99
+ # If still missing, synthesize a combatant from the character sheet.
100
+ if me is None and profile.character:
101
+ me = {
102
+ "name": profile.character.get("name", "Player"),
103
+ "id": "cmb_player",
104
+ "controller": author_id,
105
+ "group": None,
106
+ "race": profile.character.get("race"),
107
+ "monster_name": None,
108
+ "is_hidden": False,
109
+ "init": profile.character.get("stats", {}).get("dexterity", 10),
110
+ "initmod": 0,
111
+ "type": "combatant",
112
+ "note": "Mock combatant for preview",
113
+ "effects": [],
114
+ "stats": profile.character.get("stats") or {},
115
+ "levels": profile.character.get("levels") or profile.character.get("class_levels") or {},
116
+ "skills": profile.character.get("skills") or {},
117
+ "saves": profile.character.get("saves") or {},
118
+ "resistances": profile.character.get("resistances") or {},
119
+ "spellbook": profile.character.get("spellbook") or {},
120
+ "attacks": profile.character.get("attacks") or [],
121
+ "max_hp": profile.character.get("max_hp"),
122
+ "hp": profile.character.get("hp"),
123
+ "temp_hp": profile.character.get("temp_hp"),
124
+ "ac": profile.character.get("ac"),
125
+ "creature_type": profile.character.get("creature_type"),
126
+ }
127
+
128
+ if me is not None:
129
+ combat["me"] = me
130
+ if not any(c is me for c in combatants) and not any(_matches_author(c) for c in combatants):
131
+ combatants.insert(0, me)
132
+ combat["combatants"] = combatants
133
+ if "current" not in combat or combat.get("current") is None:
134
+ combat["current"] = me
135
+ else:
136
+ combat["combatants"] = combatants
137
+
138
+ return combat
139
+
140
+
141
+ class GVarResolver:
142
+ _CONCURRENCY = 5
143
+
144
+ def __init__(self, config: AvraeLSConfig):
145
+ self._config = config
146
+ self._cache: Dict[str, Any] = {}
147
+
148
+ def reset(self, gvars: Dict[str, Any] | None = None) -> None:
149
+ self._cache = {}
150
+ if gvars:
151
+ self._cache.update({str(k): v for k, v in gvars.items()})
152
+
153
+ def seed(self, gvars: Dict[str, Any] | None = None) -> None:
154
+ """Merge provided gvars into the cache without dropping fetched values."""
155
+ if not gvars:
156
+ return
157
+ for k, v in gvars.items():
158
+ self._cache[str(k)] = v
159
+
160
+ def get_local(self, key: str) -> Any:
161
+ return self._cache.get(str(key))
162
+
163
+ async def ensure(self, key: str) -> bool:
164
+ key = str(key)
165
+ if key in self._cache:
166
+ log.debug("GVAR ensure cache hit for %s", key)
167
+ return True
168
+ return await self._fetch_remote(key)
169
+
170
+ async def ensure_many(self, keys: Iterable[str]) -> Dict[str, bool]:
171
+ results: dict[str, bool] = {}
172
+ missing = [str(k) for k in keys if str(k) not in self._cache]
173
+ for key in keys:
174
+ results[str(key)] = str(key) in self._cache
175
+
176
+ if not missing:
177
+ return results
178
+ if not self._config.enable_gvar_fetch:
179
+ log.warning("GVAR fetch disabled; skipping %s", missing)
180
+ return results
181
+ if not self._config.service.token:
182
+ log.debug("GVAR fetch skipped for %s: no token configured", missing)
183
+ return results
184
+
185
+ sem = asyncio.Semaphore(self._CONCURRENCY)
186
+
187
+ async def _fetch(key: str, client: httpx.AsyncClient) -> None:
188
+ if key in self._cache:
189
+ results[key] = True
190
+ return
191
+ try:
192
+ ensured = await self._fetch_remote(key, client=client, sem=sem)
193
+ except Exception as exc: # pragma: no cover - defensive
194
+ log.error("GVAR fetch failed for %s: %s", key, exc)
195
+ ensured = False
196
+ results[key] = ensured
197
+
198
+ async with httpx.AsyncClient(timeout=5) as client:
199
+ await asyncio.gather(*(_fetch(key, client) for key in missing))
200
+ return results
201
+
202
+ def ensure_blocking(self, key: str) -> bool:
203
+ key = str(key)
204
+ if key in self._cache:
205
+ log.debug("GVAR ensure_blocking cache hit for %s", key)
206
+ return True
207
+ if not self._config.enable_gvar_fetch:
208
+ log.warning("GVAR fetch disabled; skipping %s", key)
209
+ return False
210
+ if not self._config.service.token:
211
+ log.debug("GVAR fetch skipped for %s: no token configured", key)
212
+ return False
213
+
214
+ base_url = self._config.service.base_url.rstrip("/")
215
+ url = f"{base_url}/customizations/gvars/{key}"
216
+ headers = {"Authorization": str(self._config.service.token)}
217
+ try:
218
+ log.debug("GVAR blocking fetch %s from %s", key, url)
219
+ with httpx.Client(timeout=5) as client:
220
+ resp = client.get(url, headers=headers)
221
+ except Exception as exc:
222
+ log.error("GVAR blocking fetch failed for %s: %s", key, exc)
223
+ return False
224
+
225
+ if resp.status_code != 200:
226
+ log.warning(
227
+ "GVAR blocking fetch returned %s for %s (body: %s)",
228
+ resp.status_code,
229
+ key,
230
+ (resp.text or "").strip(),
231
+ )
232
+ return False
233
+
234
+ value: Any = None
235
+ try:
236
+ payload = resp.json()
237
+ except Exception:
238
+ payload = None
239
+
240
+ if isinstance(payload, dict) and "value" in payload:
241
+ value = payload["value"]
242
+
243
+ if value is None:
244
+ log.error("GVAR %s payload missing value", key)
245
+ return False
246
+ self._cache[key] = value
247
+ return True
248
+
249
+ def snapshot(self) -> Dict[str, Any]:
250
+ return dict(self._cache)
251
+
252
+ async def refresh(self, seed: Dict[str, Any] | None = None, keys: Iterable[str] | None = None) -> Dict[str, Any]:
253
+ self.reset(seed)
254
+ if keys:
255
+ await self.ensure_many(keys)
256
+ return self.snapshot()
257
+
258
+ async def _fetch_remote(
259
+ self, key: str, client: httpx.AsyncClient | None = None, sem: asyncio.Semaphore | None = None
260
+ ) -> bool:
261
+ key = str(key)
262
+ if key in self._cache:
263
+ return True
264
+ if not self._config.enable_gvar_fetch:
265
+ return False
266
+ if not self._config.service.token:
267
+ return False
268
+
269
+ base_url = self._config.service.base_url.rstrip("/")
270
+ url = f"{base_url}/customizations/gvars/{key}"
271
+ headers = {"Authorization": str(self._config.service.token)}
272
+
273
+ async def _do_request(session: httpx.AsyncClient) -> httpx.Response:
274
+ if sem:
275
+ async with sem:
276
+ return await session.get(url, headers=headers)
277
+ return await session.get(url, headers=headers)
278
+
279
+ close_client = False
280
+ session = client
281
+ if session is None:
282
+ session = httpx.AsyncClient(timeout=5)
283
+ close_client = True
284
+
285
+ try:
286
+ log.debug("GVAR fetching %s from %s", key, url)
287
+ resp = await _do_request(session)
288
+ except Exception as exc:
289
+ log.error("GVAR fetch failed for %s: %s", key, exc)
290
+ if close_client:
291
+ await session.aclose()
292
+ return False
293
+ if close_client:
294
+ await session.aclose()
295
+
296
+ if resp.status_code != 200:
297
+ log.warning(
298
+ "GVAR fetch returned %s for %s (body: %s)",
299
+ resp.status_code,
300
+ key,
301
+ (resp.text or "").strip(),
302
+ )
303
+ return False
304
+
305
+ value: Any = None
306
+ try:
307
+ payload = resp.json()
308
+ except Exception:
309
+ payload = None
310
+
311
+ if isinstance(payload, dict) and "value" in payload:
312
+ value = payload["value"]
313
+
314
+ log.debug("GVAR fetch parsed value for %s (type=%s)", key, type(value).__name__)
315
+
316
+ if value is None:
317
+ log.error("GVAR %s payload missing value", key)
318
+ return False
319
+ self._cache[key] = value
320
+ return True
321
+
322
+
323
+ def _read_json_file(path: Path) -> Dict[str, Any] | None:
324
+ try:
325
+ text = path.read_text()
326
+ except FileNotFoundError:
327
+ log.debug("Var file not found: %s", path)
328
+ return None
329
+ except OSError as exc:
330
+ log.warning("Failed to read var file %s: %s", path, exc)
331
+ return None
332
+
333
+ try:
334
+ return json.loads(text)
335
+ except json.JSONDecodeError as exc:
336
+ log.warning("Failed to parse var file %s: %s", path, exc)
337
+ return None
avrae_ls/cvars.py ADDED
@@ -0,0 +1,115 @@
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