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/api.py DELETED
@@ -1,2014 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import math
4
- from dataclasses import dataclass, field
5
- from typing import Any, ClassVar, Iterable, Mapping, MutableMapping, Optional, Sequence
6
-
7
- import d20
8
-
9
- from .dice import RerollableStringifier
10
-
11
- UNSET = object()
12
-
13
-
14
- def _safe_int(value: Any, default: int = 0) -> int:
15
- try:
16
- return int(value)
17
- except Exception:
18
- return default
19
-
20
-
21
- class _DirMixin:
22
- ATTRS: ClassVar[list[str]] = []
23
- METHODS: ClassVar[list[str]] = []
24
-
25
- def __dir__(self) -> list[str]:
26
- data_keys = list(getattr(self, "_data", {}).keys())
27
- return sorted(set(self.ATTRS + self.METHODS + data_keys))
28
-
29
- def __post_init__(self) -> None:
30
- # Ensure draconic's approx_len cache is initialized to a numeric value
31
- # so nested objects don't trip TypeErrors when measured.
32
- if getattr(self, "__approx_len__", None) is None:
33
- self.__approx_len__ = 0
34
-
35
-
36
- class SimpleRollResult(_DirMixin):
37
- ATTRS: ClassVar[list[str]] = ["dice", "total", "full", "result", "raw"]
38
- METHODS: ClassVar[list[str]] = ["consolidated"]
39
-
40
- def __init__(self, roll_result: d20.RollResult):
41
- self._roll = roll_result
42
- self.dice = d20.MarkdownStringifier().stringify(roll_result.expr.roll)
43
- self.total = roll_result.total
44
- self.full = str(roll_result)
45
- self.result = roll_result
46
- self.raw = roll_result.expr
47
- self.__approx_len__ = 0
48
-
49
- def consolidated(self) -> str:
50
- d20.utils.simplify_expr(self._roll.expr, ambig_inherit="left")
51
- return RerollableStringifier().stringify(self._roll.expr.roll)
52
-
53
- def __str__(self) -> str:
54
- return self.full
55
-
56
-
57
- # === Context API ===
58
- @dataclass
59
- class GuildAPI(_DirMixin):
60
- _data: Mapping[str, Any] = field(default_factory=dict)
61
- ATTRS: ClassVar[list[str]] = ["name", "id"]
62
- METHODS: ClassVar[list[str]] = ["servsettings"]
63
-
64
- @property
65
- def name(self) -> str:
66
- return str(self._data.get("name", "Guild"))
67
-
68
- @property
69
- def id(self) -> int | None:
70
- raw = self._data.get("id")
71
- return int(raw) if raw is not None else None
72
-
73
- def servsettings(self) -> Mapping[str, Any] | None:
74
- return self._data.get("servsettings")
75
-
76
- def __getitem__(self, item: str) -> Any:
77
- if isinstance(item, int):
78
- raise TypeError("CustomCounter indices must be strings (e.g., 'value', 'max').")
79
- return getattr(self, str(item))
80
-
81
- def __iter__(self):
82
- return iter(())
83
-
84
- def __len__(self) -> int:
85
- return 0
86
-
87
-
88
- @dataclass
89
- class CategoryAPI(_DirMixin):
90
- _data: Mapping[str, Any] = field(default_factory=dict)
91
- ATTRS: ClassVar[list[str]] = ["name", "id"]
92
- METHODS: ClassVar[list[str]] = []
93
-
94
- @property
95
- def name(self) -> str:
96
- return str(self._data.get("name", "Category"))
97
-
98
- @property
99
- def id(self) -> int | None:
100
- raw = self._data.get("id")
101
- return int(raw) if raw is not None else None
102
-
103
- def __getitem__(self, item: str) -> Any:
104
- return getattr(self, str(item))
105
-
106
-
107
- @dataclass
108
- class ChannelAPI(_DirMixin):
109
- _data: Mapping[str, Any] = field(default_factory=dict)
110
- ATTRS: ClassVar[list[str]] = ["name", "id", "topic", "category", "parent"]
111
- METHODS: ClassVar[list[str]] = []
112
-
113
- @property
114
- def name(self) -> str:
115
- return str(self._data.get("name", "channel"))
116
-
117
- @property
118
- def id(self) -> int | None:
119
- raw = self._data.get("id")
120
- return int(raw) if raw is not None else None
121
-
122
- @property
123
- def topic(self) -> str | None:
124
- val = self._data.get("topic")
125
- return str(val) if val is not None else None
126
-
127
- @property
128
- def category(self) -> CategoryAPI | None:
129
- cat = self._data.get("category")
130
- return CategoryAPI(cat) if cat is not None else None
131
-
132
- @property
133
- def parent(self) -> ChannelAPI | None:
134
- parent = self._data.get("parent")
135
- return ChannelAPI(parent) if parent is not None else None
136
-
137
- def __getitem__(self, item: str) -> Any:
138
- return getattr(self, str(item))
139
-
140
-
141
- @dataclass
142
- class RoleAPI(_DirMixin):
143
- _data: Mapping[str, Any] = field(default_factory=dict)
144
- ATTRS: ClassVar[list[str]] = ["name", "id"]
145
- METHODS: ClassVar[list[str]] = []
146
-
147
- @property
148
- def name(self) -> str:
149
- return str(self._data.get("name", "Role"))
150
-
151
- @property
152
- def id(self) -> int | None:
153
- raw = self._data.get("id")
154
- return int(raw) if raw is not None else None
155
-
156
- def __getitem__(self, item: str) -> Any:
157
- return getattr(self, str(item))
158
-
159
-
160
- @dataclass
161
- class AuthorAPI(_DirMixin):
162
- _data: Mapping[str, Any] = field(default_factory=dict)
163
- ATTRS: ClassVar[list[str]] = ["name", "id", "discriminator", "display_name", "roles"]
164
- METHODS: ClassVar[list[str]] = ["get_roles"]
165
-
166
- @property
167
- def name(self) -> str:
168
- return str(self._data.get("name", "User"))
169
-
170
- @property
171
- def id(self) -> int | None:
172
- raw = self._data.get("id")
173
- return int(raw) if raw is not None else None
174
-
175
- @property
176
- def discriminator(self) -> str:
177
- return str(self._data.get("discriminator", "0000"))
178
-
179
- @property
180
- def display_name(self) -> str:
181
- return str(self._data.get("display_name", self.name))
182
-
183
- def get_roles(self) -> list[RoleAPI]:
184
- roles = self._data.get("roles") or []
185
- return [RoleAPI(r) for r in roles]
186
-
187
- @property
188
- def roles(self) -> list[RoleAPI]:
189
- return self.get_roles()
190
-
191
- def __getitem__(self, item: str) -> Any:
192
- return getattr(self, str(item))
193
-
194
- def __str__(self) -> str:
195
- return f"{self.name}#{self.discriminator}"
196
-
197
-
198
- @dataclass
199
- class AliasContextAPI(_DirMixin):
200
- _data: Mapping[str, Any] = field(default_factory=dict)
201
- ATTRS: ClassVar[list[str]] = ["guild", "channel", "author", "prefix", "alias", "message_id"]
202
- METHODS: ClassVar[list[str]] = []
203
-
204
- @property
205
- def guild(self) -> GuildAPI | None:
206
- """Guild info for the alias invocation (server context)."""
207
- guild_data = self._data.get("guild")
208
- return GuildAPI(guild_data) if guild_data is not None else None
209
-
210
- @property
211
- def channel(self) -> ChannelAPI | None:
212
- """Channel where the alias was invoked."""
213
- channel_data = self._data.get("channel")
214
- return ChannelAPI(channel_data) if channel_data is not None else None
215
-
216
- @property
217
- def author(self) -> AuthorAPI | None:
218
- """User who invoked the alias."""
219
- author_data = self._data.get("author")
220
- return AuthorAPI(author_data) if author_data is not None else None
221
-
222
- @property
223
- def prefix(self) -> str | None:
224
- """Command prefix that triggered the alias (e.g., `!`)."""
225
- val = self._data.get("prefix")
226
- return str(val) if val is not None else None
227
-
228
- @property
229
- def alias(self) -> str | None:
230
- """Alias name that was run."""
231
- val = self._data.get("alias")
232
- return str(val) if val is not None else None
233
-
234
- @property
235
- def message_id(self) -> int | None:
236
- """Discord message id for the invocation."""
237
- raw = self._data.get("message_id")
238
- return int(raw) if raw is not None else None
239
-
240
- def __getattr__(self, item: str) -> Any:
241
- return self._data.get(item)
242
-
243
- def __getitem__(self, item: str) -> Any:
244
- return getattr(self, str(item))
245
-
246
-
247
- # === StatBlock primitives ===
248
- _SKILL_CANONICAL = {
249
- "acrobatics": "acrobatics",
250
- "animalhandling": "animalHandling",
251
- "arcana": "arcana",
252
- "athletics": "athletics",
253
- "deception": "deception",
254
- "history": "history",
255
- "initiative": "initiative",
256
- "insight": "insight",
257
- "intimidation": "intimidation",
258
- "investigation": "investigation",
259
- "medicine": "medicine",
260
- "nature": "nature",
261
- "perception": "perception",
262
- "performance": "performance",
263
- "persuasion": "persuasion",
264
- "religion": "religion",
265
- "sleightofhand": "sleightOfHand",
266
- "sleight_of_hand": "sleightOfHand",
267
- "stealth": "stealth",
268
- "survival": "survival",
269
- "strength": "strength",
270
- "dexterity": "dexterity",
271
- "constitution": "constitution",
272
- "intelligence": "intelligence",
273
- "wisdom": "wisdom",
274
- "charisma": "charisma",
275
- }
276
-
277
- _SKILL_ABILITIES = {
278
- "acrobatics": "dexterity",
279
- "animalHandling": "wisdom",
280
- "arcana": "intelligence",
281
- "athletics": "strength",
282
- "deception": "charisma",
283
- "history": "intelligence",
284
- "initiative": "dexterity",
285
- "insight": "wisdom",
286
- "intimidation": "charisma",
287
- "investigation": "intelligence",
288
- "medicine": "wisdom",
289
- "nature": "intelligence",
290
- "perception": "wisdom",
291
- "performance": "charisma",
292
- "persuasion": "charisma",
293
- "religion": "intelligence",
294
- "sleightOfHand": "dexterity",
295
- "stealth": "dexterity",
296
- "survival": "wisdom",
297
- "strength": "strength",
298
- "dexterity": "dexterity",
299
- "constitution": "constitution",
300
- "intelligence": "intelligence",
301
- "wisdom": "wisdom",
302
- "charisma": "charisma",
303
- }
304
-
305
-
306
- @dataclass
307
- class AliasBaseStats(_DirMixin):
308
- _data: Mapping[str, Any]
309
- prof_bonus_override: int | None = None
310
- ATTRS: ClassVar[list[str]] = [
311
- "prof_bonus",
312
- "strength",
313
- "dexterity",
314
- "constitution",
315
- "intelligence",
316
- "wisdom",
317
- "charisma",
318
- ]
319
- METHODS: ClassVar[list[str]] = ["get_mod", "get"]
320
-
321
- @property
322
- def prof_bonus(self) -> int:
323
- if self.prof_bonus_override is not None:
324
- return self.prof_bonus_override
325
- return _safe_int(self._data.get("prof_bonus"), 2)
326
-
327
- @property
328
- def strength(self) -> int:
329
- return _safe_int(self._data.get("strength"), 10)
330
-
331
- @property
332
- def dexterity(self) -> int:
333
- return _safe_int(self._data.get("dexterity"), 10)
334
-
335
- @property
336
- def constitution(self) -> int:
337
- return _safe_int(self._data.get("constitution"), 10)
338
-
339
- @property
340
- def intelligence(self) -> int:
341
- return _safe_int(self._data.get("intelligence"), 10)
342
-
343
- @property
344
- def wisdom(self) -> int:
345
- return _safe_int(self._data.get("wisdom"), 10)
346
-
347
- @property
348
- def charisma(self) -> int:
349
- return _safe_int(self._data.get("charisma"), 10)
350
-
351
- def get_mod(self, stat: str) -> int:
352
- stat_lower = str(stat).lower()
353
- lookup = {
354
- "str": self.strength,
355
- "dex": self.dexterity,
356
- "con": self.constitution,
357
- "int": self.intelligence,
358
- "wis": self.wisdom,
359
- "cha": self.charisma,
360
- "strength": self.strength,
361
- "dexterity": self.dexterity,
362
- "constitution": self.constitution,
363
- "intelligence": self.intelligence,
364
- "wisdom": self.wisdom,
365
- "charisma": self.charisma,
366
- }
367
- score = lookup.get(stat_lower, 10)
368
- return math.floor((score - 10) / 2)
369
-
370
- def get(self, stat: str) -> int:
371
- stat_lower = str(stat).lower()
372
- lookup = {
373
- "strength": self.strength,
374
- "dexterity": self.dexterity,
375
- "constitution": self.constitution,
376
- "intelligence": self.intelligence,
377
- "wisdom": self.wisdom,
378
- "charisma": self.charisma,
379
- }
380
- return lookup.get(stat_lower, 10)
381
-
382
- def __getitem__(self, item: str) -> Any:
383
- return getattr(self, str(item))
384
-
385
-
386
- @dataclass
387
- class AliasLevels(_DirMixin):
388
- _data: Mapping[str, Any]
389
- ATTRS: ClassVar[list[str]] = ["total_level"]
390
- METHODS: ClassVar[list[str]] = ["get"]
391
-
392
- @property
393
- def total_level(self) -> int | float:
394
- total = 0
395
- for _, value in self._data.items():
396
- try:
397
- total += value
398
- except Exception:
399
- try:
400
- total += int(value)
401
- except Exception:
402
- continue
403
- return total
404
-
405
- def get(self, cls_name: str, default: int | float = 0) -> int | float:
406
- val = self._data.get(str(cls_name))
407
- if val is None:
408
- return default
409
- try:
410
- return val
411
- except Exception:
412
- return default
413
-
414
- def __iter__(self) -> Iterable[tuple[str, Any]]:
415
- return iter(self._data.items())
416
-
417
- def __getitem__(self, item: str) -> Any:
418
- return self._data[item]
419
-
420
-
421
- @dataclass
422
- class AliasAttack(_DirMixin):
423
- _data: Mapping[str, Any]
424
- parent_statblock: Mapping[str, Any]
425
- ATTRS: ClassVar[list[str]] = ["name", "verb", "proper", "activation_type", "raw"]
426
- METHODS: ClassVar[list[str]] = []
427
-
428
- @property
429
- def name(self) -> str:
430
- return str(self._data.get("name", "Attack"))
431
-
432
- @property
433
- def verb(self) -> str | None:
434
- val = self._data.get("verb")
435
- return str(val) if val is not None else None
436
-
437
- @property
438
- def proper(self) -> bool:
439
- return bool(self._data.get("proper", False))
440
-
441
- @property
442
- def activation_type(self) -> int | None:
443
- raw = self._data.get("activation_type")
444
- return int(raw) if raw is not None else None
445
-
446
- @property
447
- def raw(self) -> Mapping[str, Any]:
448
- return self._data.get("raw", self._data)
449
-
450
- def __str__(self) -> str:
451
- damage = self.raw.get("damage")
452
- verb = f" {self.verb}" if self.verb else ""
453
- if damage:
454
- return f"{self.name}{verb}: {damage}"
455
- return f"{self.name}{verb}".strip()
456
-
457
- def __getitem__(self, item: str) -> Any:
458
- return getattr(self, str(item))
459
-
460
-
461
- @dataclass
462
- class AliasAttackList(_DirMixin):
463
- attacks: Sequence[Mapping[str, Any]]
464
- parent_statblock: Mapping[str, Any]
465
- ATTRS: ClassVar[list[str]] = []
466
- METHODS: ClassVar[list[str]] = []
467
-
468
- def __iter__(self) -> Iterable[AliasAttack]:
469
- for atk in self.attacks:
470
- yield AliasAttack(atk, self.parent_statblock)
471
-
472
- def __getitem__(self, item: int | slice) -> Any:
473
- if isinstance(item, slice):
474
- return AliasAttackList(self.attacks[item], self.parent_statblock)
475
- return AliasAttack(self.attacks[item], self.parent_statblock)
476
-
477
- def __len__(self) -> int:
478
- return len(self.attacks)
479
-
480
- def __str__(self) -> str:
481
- return "\n".join(str(AliasAttack(atk, self.parent_statblock)) for atk in self.attacks)
482
-
483
-
484
- @dataclass
485
- class AliasSkill(_DirMixin):
486
- _data: Mapping[str, Any]
487
- ATTRS: ClassVar[list[str]] = ["value", "prof", "bonus", "adv"]
488
- METHODS: ClassVar[list[str]] = ["d20"]
489
-
490
- @property
491
- def value(self) -> int:
492
- return _safe_int(self._data.get("value"), 0)
493
-
494
- @property
495
- def prof(self) -> float | int:
496
- raw = self._data.get("prof")
497
- try:
498
- return float(raw)
499
- except Exception:
500
- return 0
501
-
502
- @property
503
- def bonus(self) -> int:
504
- return _safe_int(self._data.get("bonus"), 0)
505
-
506
- @property
507
- def adv(self) -> bool | None:
508
- val = self._data.get("adv")
509
- if val is None:
510
- return None
511
- return bool(val)
512
-
513
- def d20(self, base_adv=None, reroll=None, min_val=None, mod_override=None) -> str:
514
- mod = mod_override if mod_override is not None else self.value
515
- adv_prefix = "2d20kh1" if base_adv else "2d20kl1" if base_adv is False else "1d20"
516
- suffix = f"+{mod}" if mod >= 0 else str(mod)
517
- parts = [adv_prefix + suffix]
518
- if reroll is not None:
519
- parts.append(f"(reroll {reroll})")
520
- if min_val is not None:
521
- parts.append(f"(min {min_val})")
522
- return " ".join(parts)
523
-
524
- def __int__(self) -> int:
525
- return self.value
526
-
527
- def __repr__(self) -> str:
528
- return f"<AliasSkill value={self.value} prof={self.prof} bonus={self.bonus} adv={self.adv}>"
529
-
530
- def __gt__(self, other: Any) -> bool:
531
- return self.value > other
532
-
533
- def __ge__(self, other: Any) -> bool:
534
- return self.value >= other
535
-
536
- def __eq__(self, other: Any) -> bool:
537
- return self.value == other
538
-
539
- def __le__(self, other: Any) -> bool:
540
- return self.value <= other
541
-
542
- def __lt__(self, other: Any) -> bool:
543
- return self.value < other
544
-
545
-
546
- @dataclass
547
- class AliasSkills(_DirMixin):
548
- _data: Mapping[str, Any]
549
- prof_bonus: int
550
- abilities: Mapping[str, int]
551
- ATTRS: ClassVar[list[str]] = list(_SKILL_ABILITIES.keys())
552
- METHODS: ClassVar[list[str]] = []
553
-
554
- def __getattr__(self, item: str) -> AliasSkill:
555
- return self._get_skill(item)
556
-
557
- def __getitem__(self, item: str) -> AliasSkill:
558
- return self._get_skill(str(item))
559
-
560
- def _get_skill(self, name: str) -> AliasSkill:
561
- normalized = _SKILL_CANONICAL.get(name.lower().replace(" ", "").replace("_", ""), name)
562
- skill_data = self._data.get(normalized) or self._data.get(name) or {}
563
- if not skill_data:
564
- ability = _SKILL_ABILITIES.get(normalized)
565
- ability_mod = 0
566
- if ability:
567
- ability_score = self.abilities.get(ability, 10)
568
- ability_mod = math.floor((ability_score - 10) / 2)
569
- skill_data = {"value": ability_mod, "prof": 0, "bonus": 0, "adv": None}
570
- return AliasSkill(skill_data)
571
-
572
- def __iter__(self) -> Iterable[tuple[str, AliasSkill]]:
573
- for name in _SKILL_ABILITIES.keys():
574
- yield name, self._get_skill(name)
575
-
576
- def __str__(self) -> str:
577
- return ", ".join(f"{name}: {skill.value}" for name, skill in self)
578
-
579
-
580
- @dataclass
581
- class AliasSaves(_DirMixin):
582
- _data: Mapping[str, Any]
583
- prof_bonus: int
584
- abilities: Mapping[str, int]
585
- ATTRS: ClassVar[list[str]] = []
586
- METHODS: ClassVar[list[str]] = ["get"]
587
-
588
- def get(self, base_stat: str) -> AliasSkill:
589
- normalized = base_stat.lower()
590
- raw = self._data.get(normalized) or self._data.get(base_stat) or {}
591
- if isinstance(raw, (int, float)):
592
- raw = {"value": raw}
593
- if not raw:
594
- ability_score = self.abilities.get(normalized, 10)
595
- raw = {"value": math.floor((ability_score - 10) / 2)}
596
- return AliasSkill(raw)
597
-
598
- def __iter__(self) -> Iterable[tuple[str, AliasSkill]]:
599
- for key in ("str", "dex", "con", "int", "wis", "cha"):
600
- yield key, self.get(key)
601
-
602
- def __str__(self) -> str:
603
- return ", ".join(f"{name}: {skill.value}" for name, skill in self)
604
-
605
-
606
- @dataclass
607
- class ResistanceEntry:
608
- dtype: str
609
- unless: set[str] = field(default_factory=set)
610
- only: set[str] = field(default_factory=set)
611
-
612
-
613
- @dataclass
614
- class AliasResistances(_DirMixin):
615
- _data: Mapping[str, Any]
616
- ATTRS: ClassVar[list[str]] = ["resist", "vuln", "immune", "neutral"]
617
- METHODS: ClassVar[list[str]] = ["is_resistant", "is_immune", "is_vulnerable", "is_neutral"]
618
-
619
- @staticmethod
620
- def _entries(key: str, _data: Mapping[str, Any]) -> list[ResistanceEntry]:
621
- entries = []
622
- for entry in _data.get(key, []):
623
- entries.append(
624
- ResistanceEntry(
625
- dtype=str(entry.get("dtype", "")),
626
- unless=set(entry.get("unless", []) or []),
627
- only=set(entry.get("only", []) or []),
628
- )
629
- )
630
- return entries
631
-
632
- @property
633
- def resist(self) -> list[ResistanceEntry]:
634
- return self._entries("resist", self._data)
635
-
636
- @property
637
- def vuln(self) -> list[ResistanceEntry]:
638
- return self._entries("vuln", self._data)
639
-
640
- @property
641
- def immune(self) -> list[ResistanceEntry]:
642
- return self._entries("immune", self._data)
643
-
644
- @property
645
- def neutral(self) -> list[ResistanceEntry]:
646
- return self._entries("neutral", self._data)
647
-
648
- def is_resistant(self, damage_type: str) -> bool:
649
- token = str(damage_type).lower()
650
- return self._matches(self.resist, token)
651
-
652
- def is_immune(self, damage_type: str) -> bool:
653
- token = str(damage_type).lower()
654
- return self._matches(self.immune, token)
655
-
656
- def is_vulnerable(self, damage_type: str) -> bool:
657
- token = str(damage_type).lower()
658
- return self._matches(self.vuln, token)
659
-
660
- def is_neutral(self, damage_type: str) -> bool:
661
- token = str(damage_type).lower()
662
- return self._matches(self.neutral, token)
663
-
664
- @staticmethod
665
- def _matches(entries: Iterable[ResistanceEntry], token: str) -> bool:
666
- for entry in entries:
667
- if entry.only and token not in entry.only:
668
- continue
669
- if entry.unless and token in entry.unless:
670
- continue
671
- if entry.dtype.lower() == token:
672
- return True
673
- return False
674
-
675
-
676
- @dataclass
677
- class AliasSpellbookSpell(_DirMixin):
678
- _data: Mapping[str, Any]
679
- ATTRS: ClassVar[list[str]] = ["name", "dc", "sab", "mod", "prepared"]
680
- METHODS: ClassVar[list[str]] = []
681
-
682
- @property
683
- def name(self) -> str:
684
- return str(self._data.get("name", "Spell"))
685
-
686
- @property
687
- def dc(self) -> int | None:
688
- raw = self._data.get("dc")
689
- return int(raw) if raw is not None else None
690
-
691
- @property
692
- def sab(self) -> int | None:
693
- raw = self._data.get("sab")
694
- return int(raw) if raw is not None else None
695
-
696
- @property
697
- def mod(self) -> int | None:
698
- raw = self._data.get("mod")
699
- return int(raw) if raw is not None else None
700
-
701
- @property
702
- def prepared(self) -> bool:
703
- return bool(self._data.get("prepared", True))
704
-
705
- def __getitem__(self, item: str) -> Any:
706
- return getattr(self, str(item))
707
-
708
- def __str__(self) -> str:
709
- return self.name
710
-
711
-
712
- @dataclass
713
- class AliasSpellbook(_DirMixin):
714
- _data: MutableMapping[str, Any]
715
- _spells_cache: list[AliasSpellbookSpell] = field(default_factory=list, init=False)
716
- ATTRS: ClassVar[list[str]] = [
717
- "dc",
718
- "sab",
719
- "caster_level",
720
- "spell_mod",
721
- "spells",
722
- "pact_slot_level",
723
- "num_pact_slots",
724
- "max_pact_slots",
725
- ]
726
- METHODS: ClassVar[list[str]] = [
727
- "find",
728
- "slots_str",
729
- "get_max_slots",
730
- "get_slots",
731
- "set_slots",
732
- "use_slot",
733
- "reset_slots",
734
- "reset_pact_slots",
735
- "remaining_casts_of",
736
- "cast",
737
- "can_cast",
738
- ]
739
-
740
- @property
741
- def dc(self) -> int:
742
- return _safe_int(self._data.get("dc"), 10)
743
-
744
- @property
745
- def sab(self) -> int:
746
- return _safe_int(self._data.get("sab"), 0)
747
-
748
- @property
749
- def caster_level(self) -> int:
750
- return _safe_int(self._data.get("caster_level"), 0)
751
-
752
- @property
753
- def spell_mod(self) -> int:
754
- return _safe_int(self._data.get("spell_mod"), 0)
755
-
756
- @property
757
- def spells(self) -> list[AliasSpellbookSpell]:
758
- if not self._spells_cache:
759
- spell_list = self._data.get("spells") or []
760
- self._spells_cache = [AliasSpellbookSpell(s) for s in spell_list]
761
- return self._spells_cache
762
-
763
- @property
764
- def pact_slot_level(self) -> int | None:
765
- raw = self._data.get("pact_slot_level")
766
- return int(raw) if raw is not None else None
767
-
768
- @property
769
- def num_pact_slots(self) -> int | None:
770
- raw = self._data.get("num_pact_slots")
771
- return int(raw) if raw is not None else None
772
-
773
- @property
774
- def max_pact_slots(self) -> int | None:
775
- raw = self._data.get("max_pact_slots")
776
- return int(raw) if raw is not None else None
777
-
778
- def find(self, spell_name: str) -> list[AliasSpellbookSpell]:
779
- needle = str(spell_name).lower()
780
- return [spell for spell in self.spells if spell.name.lower() == needle]
781
-
782
- def slots_str(self, level: int) -> str:
783
- slots = self.get_slots(level)
784
- max_slots = self.get_max_slots(level)
785
- return f"{slots}/{max_slots}"
786
-
787
- def get_max_slots(self, level: int) -> int:
788
- slots = self._data.get("max_slots") or {}
789
- return _safe_int(slots.get(int(level)), 0)
790
-
791
- def get_slots(self, level: int) -> int:
792
- slots = self._data.get("slots") or {}
793
- if int(level) == 0:
794
- return 1
795
- return _safe_int(slots.get(int(level)), 0)
796
-
797
- def set_slots(self, level: int, value: int, pact: bool = True) -> int:
798
- slots = self._data.setdefault("slots", {})
799
- slots[int(level)] = int(value)
800
- return slots[int(level)]
801
-
802
- def use_slot(self, level: int) -> int:
803
- current = self.get_slots(level)
804
- return self.set_slots(level, max(0, current - 1))
805
-
806
- def reset_slots(self) -> None:
807
- slots = self._data.get("slots") or {}
808
- max_slots = self._data.get("max_slots") or {}
809
- for level, maximum in max_slots.items():
810
- slots[int(level)] = _safe_int(maximum)
811
- self._data["slots"] = slots
812
-
813
- def reset_pact_slots(self) -> None:
814
- if self.max_pact_slots is None:
815
- return
816
- self._data["num_pact_slots"] = self.max_pact_slots
817
-
818
- def remaining_casts_of(self, spell: str, level: int) -> str:
819
- return self.slots_str(level)
820
-
821
- def cast(self, spell: str, level: int) -> str:
822
- self.use_slot(level)
823
- return f"Casted {spell} at level {level}"
824
-
825
- def can_cast(self, spell: str, level: int) -> bool:
826
- return self.get_slots(level) > 0
827
-
828
- def __contains__(self, item: object) -> bool:
829
- if not isinstance(item, str):
830
- return False
831
- return any(spell.name.lower() == item.lower() for spell in self.spells)
832
-
833
-
834
- @dataclass
835
- class AliasCoinpurse(_DirMixin):
836
- _data: MutableMapping[str, Any] = field(default_factory=dict)
837
- ATTRS: ClassVar[list[str]] = ["pp", "gp", "ep", "sp", "cp", "total"]
838
- METHODS: ClassVar[list[str]] = ["coin_str", "compact_str", "modify_coins", "set_coins", "autoconvert", "get_coins"]
839
-
840
- def __getattr__(self, item: str) -> Any:
841
- if item in {"pp", "gp", "ep", "sp", "cp"}:
842
- return _safe_int(self._data.get(item), 0)
843
- return self._data.get(item)
844
-
845
- def __getitem__(self, item: str) -> Any:
846
- return getattr(self, str(item))
847
-
848
- @property
849
- def total(self) -> float:
850
- return self.pp * 10 + self.gp + self.ep * 0.5 + self.sp * 0.1 + self.cp * 0.01
851
-
852
- def coin_str(self, cointype: str) -> str:
853
- cointype = str(cointype)
854
- value = getattr(self, cointype)
855
- return f"{value} {cointype}"
856
-
857
- def compact_str(self) -> str:
858
- return f"{self.total:.2f} gp"
859
-
860
- def modify_coins(
861
- self,
862
- pp: int = 0,
863
- gp: int = 0,
864
- ep: int = 0,
865
- sp: int = 0,
866
- cp: int = 0,
867
- autoconvert: bool = True,
868
- ) -> dict[str, Any]:
869
- self._data["pp"] = self.pp + int(pp)
870
- self._data["gp"] = self.gp + int(gp)
871
- self._data["ep"] = self.ep + int(ep)
872
- self._data["sp"] = self.sp + int(sp)
873
- self._data["cp"] = self.cp + int(cp)
874
- return self.get_coins()
875
-
876
- def set_coins(self, pp: int, gp: int, ep: int, sp: int, cp: int) -> None:
877
- self._data["pp"] = int(pp)
878
- self._data["gp"] = int(gp)
879
- self._data["ep"] = int(ep)
880
- self._data["sp"] = int(sp)
881
- self._data["cp"] = int(cp)
882
-
883
- def autoconvert(self) -> None:
884
- total_cp = int(self.total * 100)
885
- self._data["pp"], remainder = divmod(total_cp, 1000)
886
- self._data["gp"], remainder = divmod(remainder, 100)
887
- self._data["ep"], remainder = divmod(remainder, 50)
888
- self._data["sp"], self._data["cp"] = divmod(remainder, 10)
889
-
890
- def get_coins(self) -> dict[str, Any]:
891
- return {"pp": self.pp, "gp": self.gp, "ep": self.ep, "sp": self.sp, "cp": self.cp, "total": self.total}
892
-
893
-
894
- @dataclass
895
- class AliasDeathSaves(_DirMixin):
896
- _data: MutableMapping[str, Any] = field(default_factory=dict)
897
- ATTRS: ClassVar[list[str]] = ["successes", "fails"]
898
- METHODS: ClassVar[list[str]] = ["succeed", "fail", "is_stable", "is_dead", "reset"]
899
-
900
- @property
901
- def successes(self) -> int:
902
- return _safe_int(self._data.get("successes"), 0)
903
-
904
- @property
905
- def fails(self) -> int:
906
- return _safe_int(self._data.get("fails"), 0)
907
-
908
- def succeed(self, num: int = 1) -> None:
909
- self._data["successes"] = self.successes + int(num)
910
-
911
- def fail(self, num: int = 1) -> None:
912
- self._data["fails"] = self.fails + int(num)
913
-
914
- def is_stable(self) -> bool:
915
- return self.successes >= 3 and self.fails < 3
916
-
917
- def is_dead(self) -> bool:
918
- return self.fails >= 3
919
-
920
- def reset(self) -> None:
921
- self._data["successes"] = 0
922
- self._data["fails"] = 0
923
-
924
- def __str__(self) -> str:
925
- return f"{self.successes} successes / {self.fails} failures"
926
-
927
-
928
- @dataclass
929
- class AliasAction(_DirMixin):
930
- _data: Mapping[str, Any]
931
- parent_statblock: Mapping[str, Any]
932
- ATTRS: ClassVar[list[str]] = ["name", "activation_type", "activation_type_name", "description", "snippet"]
933
- METHODS: ClassVar[list[str]] = []
934
-
935
- @property
936
- def name(self) -> str:
937
- """Action name."""
938
- return str(self._data.get("name", "Action"))
939
-
940
- @property
941
- def activation_type(self) -> int | None:
942
- """Numeric activation type (matches Avrae constants)."""
943
- raw = self._data.get("activation_type")
944
- return int(raw) if raw is not None else None
945
-
946
- @property
947
- def activation_type_name(self) -> str | None:
948
- """Human-readable activation type (e.g., ACTION, BONUS_ACTION)."""
949
- val = self._data.get("activation_type_name")
950
- return str(val) if val is not None else None
951
-
952
- @property
953
- def description(self) -> str:
954
- """Long description of the action."""
955
- return str(self._data.get("description", ""))
956
-
957
- @property
958
- def snippet(self) -> str:
959
- """Short snippet shown in the sheet for the action."""
960
- return str(self._data.get("snippet", self.description))
961
-
962
- def __str__(self) -> str:
963
- return f"**{self.name}**: {self.description}"
964
-
965
- def __getitem__(self, item: str) -> Any:
966
- if isinstance(item, int) or (isinstance(item, str) and item.isdigit()):
967
- raise TypeError("AliasAction attributes must be accessed by name (e.g., 'name', 'activation_type').")
968
- return getattr(self, str(item))
969
-
970
- def __iter__(self):
971
- return iter(())
972
-
973
- def __len__(self) -> int:
974
- return 0
975
-
976
-
977
- @dataclass
978
- class AliasCustomCounter(_DirMixin):
979
- _data: MutableMapping[str, Any] = field(default_factory=dict)
980
- ATTRS: ClassVar[list[str]] = [
981
- "name",
982
- "title",
983
- "desc",
984
- "value",
985
- "max",
986
- "min",
987
- "reset_on",
988
- "display_type",
989
- "reset_to",
990
- "reset_by",
991
- ]
992
- METHODS: ClassVar[list[str]] = ["mod", "set", "reset", "full_str"]
993
-
994
- @property
995
- def name(self) -> str:
996
- return str(self._data.get("name", "Counter"))
997
-
998
- @property
999
- def title(self) -> str | None:
1000
- val = self._data.get("title")
1001
- return str(val) if val is not None else None
1002
-
1003
- @property
1004
- def desc(self) -> str | None:
1005
- val = self._data.get("desc")
1006
- return str(val) if val is not None else None
1007
-
1008
- @property
1009
- def value(self) -> int:
1010
- return _safe_int(self._data.get("value"), 0)
1011
-
1012
- @property
1013
- def max(self) -> int:
1014
- return _safe_int(self._data.get("max"), 2**31 - 1)
1015
-
1016
- @property
1017
- def min(self) -> int:
1018
- return _safe_int(self._data.get("min"), -(2**31))
1019
-
1020
- @property
1021
- def reset_on(self) -> str | None:
1022
- val = self._data.get("reset_on")
1023
- return str(val) if val is not None else None
1024
-
1025
- @property
1026
- def display_type(self) -> str | None:
1027
- val = self._data.get("display_type")
1028
- return str(val) if val is not None else None
1029
-
1030
- @property
1031
- def reset_to(self) -> int | None:
1032
- raw = self._data.get("reset_to")
1033
- return int(raw) if raw is not None else None
1034
-
1035
- @property
1036
- def reset_by(self) -> str | None:
1037
- val = self._data.get("reset_by")
1038
- return str(val) if val is not None else None
1039
-
1040
- def mod(self, value: int, strict: bool = False) -> int:
1041
- return self.set(self.value + int(value), strict)
1042
-
1043
- def set(self, new_value: int, strict: bool = False) -> int:
1044
- val = int(new_value)
1045
- if strict:
1046
- if val > self.max or val < self.min:
1047
- raise ValueError("Counter out of bounds")
1048
- val = max(self.min, min(val, self.max))
1049
- self._data["value"] = val
1050
- return val
1051
-
1052
- def reset(self) -> dict[str, Any]:
1053
- if self.reset_to is not None:
1054
- target = self.reset_to
1055
- elif self.reset_by is not None:
1056
- target = self.value + _safe_int(self.reset_by, 0)
1057
- else:
1058
- target = self.max
1059
- old_value = self.value
1060
- new_value = self.set(target)
1061
- return {"new_value": new_value, "old_value": old_value, "target_value": target, "delta": new_value - old_value}
1062
-
1063
- def full_str(self, include_name: bool = False) -> str:
1064
- prefix = f"**{self.name}**\n" if include_name else ""
1065
- content = f"{self.value}/{self.max}"
1066
- if self.display_type:
1067
- content = self.display_type
1068
- return prefix + content
1069
-
1070
- def __str__(self) -> str:
1071
- return f"{self.value}/{self.max}"
1072
-
1073
- def __getitem__(self, item: str) -> Any:
1074
- if isinstance(item, int) or (isinstance(item, str) and item.isdigit()):
1075
- raise TypeError("CustomCounter indices must be strings (e.g., 'value', 'max').")
1076
- return getattr(self, str(item))
1077
-
1078
- def __iter__(self):
1079
- return iter(())
1080
-
1081
- def __len__(self) -> int:
1082
- return 0
1083
-
1084
-
1085
- # === StatBlock + Character ===
1086
- @dataclass
1087
- class AliasStatBlock(_DirMixin):
1088
- _data: MutableMapping[str, Any] = field(default_factory=dict)
1089
- ATTRS: ClassVar[list[str]] = [
1090
- "name",
1091
- "stats",
1092
- "levels",
1093
- "attacks",
1094
- "skills",
1095
- "saves",
1096
- "resistances",
1097
- "ac",
1098
- "max_hp",
1099
- "hp",
1100
- "temp_hp",
1101
- "spellbook",
1102
- "creature_type",
1103
- ]
1104
- METHODS: ClassVar[list[str]] = ["set_hp", "modify_hp", "hp_str", "reset_hp", "set_temp_hp"]
1105
-
1106
- def _prof_bonus(self) -> int:
1107
- stats = self._data.get("stats") or {}
1108
- if "prof_bonus" in stats:
1109
- return _safe_int(stats.get("prof_bonus"), 2)
1110
- levels = self._data.get("levels") or self._data.get("class_levels") or {}
1111
- total_level = sum(_safe_int(val, 0) for val in levels.values()) or 1
1112
- return max(2, 2 + (math.ceil(total_level / 4)))
1113
-
1114
- @property
1115
- def name(self) -> str:
1116
- """Character or statblock name."""
1117
- return str(self._data.get("name", "Statblock"))
1118
-
1119
- @property
1120
- def stats(self) -> AliasBaseStats:
1121
- """Ability scores and proficiency bonus helper."""
1122
- return AliasBaseStats(self._data.get("stats") or {}, prof_bonus_override=self._prof_bonus())
1123
-
1124
- @property
1125
- def levels(self) -> AliasLevels:
1126
- """Class levels keyed by class name."""
1127
- return AliasLevels(self._data.get("levels") or self._data.get("class_levels") or {})
1128
-
1129
- @property
1130
- def attacks(self) -> AliasAttackList:
1131
- """Attacks available on the statblock."""
1132
- return AliasAttackList(self._data.get("attacks") or [], self._data)
1133
-
1134
- @property
1135
- def skills(self) -> AliasSkills:
1136
- """Skill bonuses computed from abilities and prof bonus."""
1137
- abilities = {
1138
- "strength": self.stats.strength,
1139
- "dexterity": self.stats.dexterity,
1140
- "constitution": self.stats.constitution,
1141
- "intelligence": self.stats.intelligence,
1142
- "wisdom": self.stats.wisdom,
1143
- "charisma": self.stats.charisma,
1144
- }
1145
- return AliasSkills(self._data.get("skills") or {}, self._prof_bonus(), abilities)
1146
-
1147
- @property
1148
- def saves(self) -> AliasSaves:
1149
- """Saving throw bonuses computed from abilities and prof bonus."""
1150
- abilities = {
1151
- "strength": self.stats.strength,
1152
- "dexterity": self.stats.dexterity,
1153
- "constitution": self.stats.constitution,
1154
- "intelligence": self.stats.intelligence,
1155
- "wisdom": self.stats.wisdom,
1156
- "charisma": self.stats.charisma,
1157
- "str": self.stats.strength,
1158
- "dex": self.stats.dexterity,
1159
- "con": self.stats.constitution,
1160
- "int": self.stats.intelligence,
1161
- "wis": self.stats.wisdom,
1162
- "cha": self.stats.charisma,
1163
- }
1164
- return AliasSaves(self._data.get("saves") or {}, self._prof_bonus(), abilities)
1165
-
1166
- @property
1167
- def resistances(self) -> AliasResistances:
1168
- """Damage resistances, immunities, and vulnerabilities."""
1169
- return AliasResistances(self._data.get("resistances") or {})
1170
-
1171
- @property
1172
- def ac(self) -> int | None:
1173
- """Armor class."""
1174
- raw = self._data.get("ac")
1175
- return int(raw) if raw is not None else None
1176
-
1177
- @property
1178
- def max_hp(self) -> int | None:
1179
- """Maximum hit points."""
1180
- raw = self._data.get("max_hp")
1181
- return int(raw) if raw is not None else None
1182
-
1183
- @property
1184
- def hp(self) -> int | None:
1185
- """Current hit points."""
1186
- raw = self._data.get("hp")
1187
- return int(raw) if raw is not None else None
1188
-
1189
- @property
1190
- def temp_hp(self) -> int:
1191
- """Temporary hit points."""
1192
- return _safe_int(self._data.get("temp_hp"), 0)
1193
-
1194
- @property
1195
- def spellbook(self) -> AliasSpellbook:
1196
- """Known/prepared spells grouped by level."""
1197
- return AliasSpellbook(self._data.get("spellbook") or {})
1198
-
1199
- @property
1200
- def creature_type(self) -> str | None:
1201
- """Creature type (e.g., humanoid, undead)."""
1202
- val = self._data.get("creature_type")
1203
- return str(val) if val is not None else None
1204
-
1205
- def set_hp(self, new_hp: int) -> int:
1206
- """Set current hit points."""
1207
- self._data["hp"] = int(new_hp)
1208
- return self._data["hp"]
1209
-
1210
- def modify_hp(self, amount: int, ignore_temp: bool = False, overflow: bool = True) -> int:
1211
- """Adjust hit points by `amount`, respecting overflow limits when requested."""
1212
- hp = self.hp or 0
1213
- new_hp = hp + int(amount)
1214
- if not overflow and self.max_hp is not None:
1215
- new_hp = max(0, min(new_hp, self.max_hp))
1216
- self._data["hp"] = new_hp
1217
- return new_hp
1218
-
1219
- def hp_str(self) -> str:
1220
- """String summary of HP and temp HP."""
1221
- return f"{self.hp}/{self.max_hp} (+{self.temp_hp} temp)"
1222
-
1223
- def reset_hp(self) -> int:
1224
- """Restore to max HP and clear temp HP."""
1225
- if self.max_hp is not None:
1226
- self._data["hp"] = self.max_hp
1227
- self._data["temp_hp"] = 0
1228
- return self.hp or 0
1229
-
1230
- def set_temp_hp(self, new_temp: int) -> int:
1231
- """Set temporary hit points."""
1232
- self._data["temp_hp"] = int(new_temp)
1233
- return self.temp_hp
1234
-
1235
- def __getattr__(self, item: str) -> Any:
1236
- return self._data.get(item)
1237
-
1238
- def __getitem__(self, item: str) -> Any:
1239
- if isinstance(item, int) or (isinstance(item, str) and item.isdigit()):
1240
- raise TypeError("AliasStatBlock attributes must be accessed by name (e.g., 'name', 'stats').")
1241
- return getattr(self, str(item))
1242
-
1243
- def __iter__(self):
1244
- return iter(())
1245
-
1246
- def __len__(self) -> int:
1247
- return 0
1248
-
1249
-
1250
- @dataclass
1251
- class CharacterAPI(AliasStatBlock):
1252
- ATTRS: ClassVar[list[str]] = AliasStatBlock.ATTRS + [
1253
- "actions",
1254
- "coinpurse",
1255
- "csettings",
1256
- "race",
1257
- "background",
1258
- "owner",
1259
- "upstream",
1260
- "sheet_type",
1261
- "cvars",
1262
- "consumables",
1263
- "death_saves",
1264
- "description",
1265
- "image",
1266
- ]
1267
- METHODS: ClassVar[list[str]] = AliasStatBlock.METHODS + [
1268
- "cc",
1269
- "get_cc",
1270
- "get_cc_max",
1271
- "get_cc_min",
1272
- "set_cc",
1273
- "mod_cc",
1274
- "delete_cc",
1275
- "create_cc_nx",
1276
- "create_cc",
1277
- "edit_cc",
1278
- "cc_exists",
1279
- "cc_str",
1280
- "get_cvar",
1281
- "set_cvar",
1282
- "set_cvar_nx",
1283
- "delete_cvar",
1284
- ]
1285
-
1286
- def _consumable_map(self) -> MutableMapping[str, MutableMapping[str, Any]]:
1287
- consumables = self._data.setdefault("consumables", {})
1288
- if isinstance(consumables, list):
1289
- # normalize list payloads to map
1290
- mapped: dict[str, MutableMapping[str, Any]] = {}
1291
- for item in consumables:
1292
- name = str(item.get("name", f"cc_{len(mapped)}"))
1293
- mapped[name] = dict(item)
1294
- self._data["consumables"] = mapped
1295
- consumables = mapped
1296
- return consumables # type: ignore[return-value]
1297
-
1298
- @property
1299
- def actions(self) -> list[AliasAction]:
1300
- """Actions on the character sheet (mapped from Beyond/custom actions)."""
1301
- acts = self._data.get("actions") or []
1302
- return [AliasAction(a, self._data) for a in acts]
1303
-
1304
- @property
1305
- def coinpurse(self) -> AliasCoinpurse:
1306
- """Coin totals by denomination."""
1307
- return AliasCoinpurse(self._data.get("coinpurse") or {})
1308
-
1309
- @property
1310
- def csettings(self) -> Mapping[str, Any]:
1311
- """Character settings blob."""
1312
- return self._data.get("csettings", {})
1313
-
1314
- @property
1315
- def race(self) -> str | None:
1316
- """Race label."""
1317
- val = self._data.get("race")
1318
- return str(val) if val is not None else None
1319
-
1320
- @property
1321
- def background(self) -> str | None:
1322
- """Background name."""
1323
- val = self._data.get("background")
1324
- return str(val) if val is not None else None
1325
-
1326
- @property
1327
- def owner(self) -> int | None:
1328
- """Discord user id of the owning account."""
1329
- raw = self._data.get("owner")
1330
- return int(raw) if raw is not None else None
1331
-
1332
- @property
1333
- def upstream(self) -> str | None:
1334
- """Upstream character id (e.g., Beyond character slug)."""
1335
- val = self._data.get("upstream")
1336
- return str(val) if val is not None else None
1337
-
1338
- @property
1339
- def sheet_type(self) -> str | None:
1340
- """Source sheet provider (beyond, custom, etc.)."""
1341
- val = self._data.get("sheet_type")
1342
- return str(val) if val is not None else None
1343
-
1344
- @property
1345
- def cvars(self) -> Mapping[str, Any]:
1346
- """Character variables (string values)."""
1347
- return dict(self._data.get("cvars") or {})
1348
-
1349
- def get_cvar(self, name: str, default: Any = None) -> Optional[str]:
1350
- """Fetch a character variable, returning `default` if missing."""
1351
- val = self._data.setdefault("cvars", {}).get(str(name), default)
1352
- return str(val) if val is not None else default
1353
-
1354
- def set_cvar(self, name: str, val: str) -> Optional[str]:
1355
- """Sets a character variable. Avrae stores cvars as strings."""
1356
- str_val = str(val) if val is not None else None
1357
- self._data.setdefault("cvars", {})[str(name)] = str_val
1358
- return str_val
1359
-
1360
- def set_cvar_nx(self, name: str, val: str) -> str:
1361
- """Set a character variable only if it does not already exist."""
1362
- cvars = self._data.setdefault("cvars", {})
1363
- str_val = str(val) if val is not None else None
1364
- return cvars.setdefault(str(name), str_val)
1365
-
1366
- def delete_cvar(self, name: str) -> Optional[str]:
1367
- """Delete a character variable and return its old value if present."""
1368
- return self._data.setdefault("cvars", {}).pop(str(name), None)
1369
-
1370
- @property
1371
- def consumables(self) -> list[AliasCustomCounter]:
1372
- """Custom counters/consumables on the character."""
1373
- return [AliasCustomCounter(v) for v in self._consumable_map().values()]
1374
-
1375
- def cc(self, name: str) -> AliasCustomCounter:
1376
- """Get (or create placeholder for) a custom counter by name."""
1377
- return AliasCustomCounter(self._consumable_map()[str(name)])
1378
-
1379
- def get_cc(self, name: str) -> int:
1380
- """Current value of a custom counter."""
1381
- return self.cc(name).value
1382
-
1383
- def get_cc_max(self, name: str) -> int:
1384
- """Maximum value for a custom counter."""
1385
- return self.cc(name).max
1386
-
1387
- def get_cc_min(self, name: str) -> int:
1388
- """Minimum value for a custom counter."""
1389
- return self.cc(name).min
1390
-
1391
- def set_cc(self, name: str, value: int | None = None, maximum: int | None = None, minimum: int | None = None) -> int:
1392
- """Set value/max/min for a custom counter."""
1393
- con = self._consumable_map().setdefault(str(name), {"name": str(name)})
1394
- if value is not None:
1395
- con["value"] = int(value)
1396
- if maximum is not None:
1397
- con["max"] = int(maximum)
1398
- if minimum is not None:
1399
- con["min"] = int(minimum)
1400
- return _safe_int(con.get("value"), 0)
1401
-
1402
- def mod_cc(self, name: str, val: int, strict: bool = False) -> int:
1403
- """Modify a custom counter by `val` (optionally enforcing bounds)."""
1404
- counter = self.cc(name)
1405
- return counter.mod(val, strict)
1406
-
1407
- def delete_cc(self, name: str) -> Any:
1408
- """Remove a custom counter and return its payload."""
1409
- return self._consumable_map().pop(str(name), None)
1410
-
1411
- def create_cc_nx(
1412
- self,
1413
- name: str,
1414
- minVal: str | None = None,
1415
- maxVal: str | None = None,
1416
- reset: str | None = None,
1417
- dispType: str | None = None,
1418
- reset_to: str | None = None,
1419
- reset_by: str | None = None,
1420
- title: str | None = None,
1421
- desc: str | None = None,
1422
- initial_value: str | None = None,
1423
- ) -> AliasCustomCounter:
1424
- """Create a custom counter if missing, preserving existing ones."""
1425
- if not self.cc_exists(name):
1426
- self.create_cc(
1427
- name,
1428
- minVal=minVal,
1429
- maxVal=maxVal,
1430
- reset=reset,
1431
- dispType=dispType,
1432
- reset_to=reset_to,
1433
- reset_by=reset_by,
1434
- title=title,
1435
- desc=desc,
1436
- initial_value=initial_value,
1437
- )
1438
- return self.cc(name)
1439
-
1440
- def create_cc(
1441
- self,
1442
- name: str,
1443
- minVal: str | None = None,
1444
- maxVal: str | None = None,
1445
- reset: str | None = None,
1446
- dispType: str | None = None,
1447
- reset_to: str | None = None,
1448
- reset_by: str | None = None,
1449
- title: str | None = None,
1450
- desc: str | None = None,
1451
- initial_value: str | None = None,
1452
- ) -> AliasCustomCounter:
1453
- """Create or overwrite a custom counter."""
1454
- payload = {
1455
- "name": str(name),
1456
- "min": _safe_int(minVal, -(2**31)) if minVal is not None else -(2**31),
1457
- "max": _safe_int(maxVal, 2**31 - 1) if maxVal is not None else 2**31 - 1,
1458
- "reset_on": reset,
1459
- "display_type": dispType,
1460
- "reset_to": _safe_int(reset_to) if reset_to is not None else None,
1461
- "reset_by": reset_by,
1462
- "title": title,
1463
- "desc": desc,
1464
- "value": _safe_int(initial_value, 0) if initial_value is not None else 0,
1465
- }
1466
- self._consumable_map()[str(name)] = payload
1467
- return AliasCustomCounter(payload)
1468
-
1469
- def edit_cc(
1470
- self,
1471
- name: str,
1472
- minVal: Any = UNSET,
1473
- maxVal: Any = UNSET,
1474
- reset: Any = UNSET,
1475
- dispType: Any = UNSET,
1476
- reset_to: Any = UNSET,
1477
- reset_by: Any = UNSET,
1478
- title: Any = UNSET,
1479
- desc: Any = UNSET,
1480
- new_name: str | None = None,
1481
- ) -> AliasCustomCounter:
1482
- """Edit fields on an existing custom counter."""
1483
- counter = dict(self._consumable_map().get(str(name)) or {"name": str(name)})
1484
- for key, val in (
1485
- ("min", minVal),
1486
- ("max", maxVal),
1487
- ("reset_on", reset),
1488
- ("display_type", dispType),
1489
- ("reset_to", reset_to),
1490
- ("reset_by", reset_by),
1491
- ("title", title),
1492
- ("desc", desc),
1493
- ):
1494
- if val is not UNSET:
1495
- counter[key] = val
1496
- counter["name"] = str(new_name) if new_name else counter.get("name", str(name))
1497
- self._consumable_map().pop(str(name), None)
1498
- self._consumable_map()[counter["name"]] = counter
1499
- return AliasCustomCounter(counter)
1500
-
1501
- def cc_exists(self, name: str) -> bool:
1502
- """Return True if a custom counter with the name exists."""
1503
- return str(name) in self._consumable_map()
1504
-
1505
- def cc_str(self, name: str) -> str:
1506
- """String form of a custom counter."""
1507
- return str(self.cc(name))
1508
-
1509
- @property
1510
- def death_saves(self) -> AliasDeathSaves:
1511
- """Death save successes/failures."""
1512
- return AliasDeathSaves(self._data.get("death_saves") or {})
1513
-
1514
- @property
1515
- def description(self) -> str | None:
1516
- """Character description/biography."""
1517
- val = self._data.get("description")
1518
- return str(val) if val is not None else None
1519
-
1520
- @property
1521
- def image(self) -> str | None:
1522
- """Avatar or sheet image URL."""
1523
- val = self._data.get("image")
1524
- return str(val) if val is not None else None
1525
-
1526
-
1527
- # === Combat API ===
1528
- MAX_COMBAT_METADATA_SIZE = 100000
1529
-
1530
-
1531
- @dataclass
1532
- class SimpleEffect(_DirMixin):
1533
- _data: MutableMapping[str, Any]
1534
- ATTRS: ClassVar[list[str]] = [
1535
- "name",
1536
- "duration",
1537
- "remaining",
1538
- "effect",
1539
- "attacks",
1540
- "buttons",
1541
- "conc",
1542
- "desc",
1543
- "ticks_on_end",
1544
- "combatant_name",
1545
- "parent",
1546
- "children",
1547
- ]
1548
- METHODS: ClassVar[list[str]] = ["set_parent"]
1549
-
1550
- @property
1551
- def name(self) -> str:
1552
- return str(self._data.get("name", "Effect"))
1553
-
1554
- @property
1555
- def duration(self) -> int | None:
1556
- raw = self._data.get("duration")
1557
- return int(raw) if raw is not None else None
1558
-
1559
- @property
1560
- def remaining(self) -> int | None:
1561
- raw = self._data.get("remaining")
1562
- return int(raw) if raw is not None else None
1563
-
1564
- @property
1565
- def effect(self) -> Mapping[str, Any]:
1566
- return self._data.get("effects") or self._data.get("effect") or {}
1567
-
1568
- @property
1569
- def attacks(self) -> list[Mapping[str, Any]]:
1570
- return list(self._data.get("attacks") or [])
1571
-
1572
- @property
1573
- def buttons(self) -> list[Mapping[str, Any]]:
1574
- return list(self._data.get("buttons") or [])
1575
-
1576
- @property
1577
- def conc(self) -> bool:
1578
- return bool(self._data.get("conc") or self._data.get("concentration", False))
1579
-
1580
- @property
1581
- def desc(self) -> str | None:
1582
- val = self._data.get("desc")
1583
- return str(val) if val is not None else None
1584
-
1585
- @property
1586
- def ticks_on_end(self) -> bool:
1587
- return bool(self._data.get("ticks_on_end") or self._data.get("end_on_turn_end", False))
1588
-
1589
- @property
1590
- def combatant_name(self) -> str | None:
1591
- val = self._data.get("combatant_name")
1592
- return str(val) if val is not None else None
1593
-
1594
- @property
1595
- def parent(self) -> "SimpleEffect" | None:
1596
- parent = self._data.get("parent")
1597
- return SimpleEffect(parent) if parent else None
1598
-
1599
- @property
1600
- def children(self) -> list["SimpleEffect"]:
1601
- return [SimpleEffect(c) for c in self._data.get("children", [])]
1602
-
1603
- def set_parent(self, parent: "SimpleEffect") -> None:
1604
- if not isinstance(parent, SimpleEffect):
1605
- raise TypeError("Parent effect must be a SimpleEffect.")
1606
- self._data["parent"] = parent._data
1607
-
1608
- def __getitem__(self, item: str) -> Any:
1609
- return getattr(self, str(item))
1610
-
1611
-
1612
- @dataclass
1613
- class SimpleCombatant(AliasStatBlock):
1614
- ATTRS: ClassVar[list[str]] = AliasStatBlock.ATTRS + [
1615
- "effects",
1616
- "init",
1617
- "initmod",
1618
- "type",
1619
- "note",
1620
- "controller",
1621
- "group",
1622
- "race",
1623
- "monster_name",
1624
- "is_hidden",
1625
- "id",
1626
- ]
1627
- METHODS: ClassVar[list[str]] = AliasStatBlock.METHODS + [
1628
- "save",
1629
- "damage",
1630
- "set_ac",
1631
- "set_maxhp",
1632
- "set_init",
1633
- "set_name",
1634
- "set_group",
1635
- "set_note",
1636
- "get_effect",
1637
- "add_effect",
1638
- "remove_effect",
1639
- ]
1640
-
1641
- def __post_init__(self) -> None:
1642
- super().__post_init__()
1643
- self._data.setdefault("type", "combatant")
1644
-
1645
- @property
1646
- def id(self) -> str | None:
1647
- """Unique combatant id."""
1648
- val = self._data.get("id")
1649
- return str(val) if val is not None else None
1650
-
1651
- @property
1652
- def effects(self) -> list[SimpleEffect]:
1653
- """Active effects on the combatant."""
1654
- return [SimpleEffect(e) for e in self._data.get("effects", [])]
1655
-
1656
- @property
1657
- def init(self) -> int:
1658
- """Initiative score."""
1659
- return _safe_int(self._data.get("init"), 0)
1660
-
1661
- @property
1662
- def initmod(self) -> int:
1663
- """Initiative modifier."""
1664
- return _safe_int(self._data.get("initmod"), 0)
1665
-
1666
- @property
1667
- def type(self) -> str:
1668
- """Combatant type (combatant/group)."""
1669
- return str(self._data.get("type", "combatant"))
1670
-
1671
- @property
1672
- def note(self) -> str | None:
1673
- """DM note attached to the combatant."""
1674
- val = self._data.get("note")
1675
- return str(val) if val is not None else None
1676
-
1677
- @property
1678
- def controller(self) -> int | None:
1679
- """Discord id of the controller (if any)."""
1680
- raw = self._data.get("controller")
1681
- return int(raw) if raw is not None else None
1682
-
1683
- @property
1684
- def group(self) -> str | None:
1685
- """Group name the combatant belongs to."""
1686
- val = self._data.get("group")
1687
- return str(val) if val is not None else None
1688
-
1689
- @property
1690
- def race(self) -> str | None:
1691
- """Race/creature type label."""
1692
- val = self._data.get("race")
1693
- return str(val) if val is not None else None
1694
-
1695
- @property
1696
- def monster_name(self) -> str | None:
1697
- """Monster name if this combatant represents a monster."""
1698
- val = self._data.get("monster_name")
1699
- return str(val) if val is not None else None
1700
-
1701
- @property
1702
- def is_hidden(self) -> bool:
1703
- """Whether the combatant is hidden in the tracker."""
1704
- return bool(self._data.get("is_hidden", False))
1705
-
1706
- def save(self, ability: str, adv: bool | None = None) -> SimpleRollResult:
1707
- """Roll a saving throw using the combatant's stats."""
1708
- roll_expr = self.saves.get(ability).d20(base_adv=adv)
1709
- try:
1710
- roll_result = d20.roll(roll_expr)
1711
- except Exception:
1712
- roll_result = d20.roll("0")
1713
- return SimpleRollResult(roll_result)
1714
-
1715
- def damage(
1716
- self,
1717
- dice_str: str,
1718
- crit: bool = False,
1719
- d=None,
1720
- c=None,
1721
- critdice: int = 0,
1722
- overheal: bool = False,
1723
- ) -> dict[str, Any]:
1724
- """Apply damage expression to the combatant and return the roll breakdown."""
1725
- expr = str(dice_str)
1726
- if crit:
1727
- expr = f"({expr})*2"
1728
- if d is not None:
1729
- expr = f"({expr})+({d})"
1730
- if c is not None and crit:
1731
- expr = f"({expr})+({c})"
1732
- if critdice:
1733
- expr = f"({expr})+{int(critdice)}"
1734
- try:
1735
- roll_result = d20.roll(expr)
1736
- except Exception:
1737
- roll_result = d20.roll("0")
1738
- label = "Damage (CRIT!)" if crit else "Damage"
1739
- return {"damage": f"**{label}**: {roll_result}", "total": roll_result.total, "roll": SimpleRollResult(roll_result)}
1740
-
1741
- def set_ac(self, ac: int) -> None:
1742
- """Set armor class."""
1743
- self._data["ac"] = int(ac)
1744
-
1745
- def set_maxhp(self, maxhp: int) -> None:
1746
- """Set maximum HP."""
1747
- self._data["max_hp"] = int(maxhp)
1748
-
1749
- def set_init(self, init: int) -> None:
1750
- """Set initiative score."""
1751
- self._data["init"] = int(init)
1752
-
1753
- def set_name(self, name: str) -> None:
1754
- """Rename the combatant."""
1755
- self._data["name"] = str(name)
1756
-
1757
- def set_group(self, group: str | None) -> str | None:
1758
- """Assign the combatant to a group."""
1759
- self._data["group"] = str(group) if group is not None else None
1760
- return self.group
1761
-
1762
- def set_note(self, note: str) -> None:
1763
- """Attach/update a DM note."""
1764
- self._data["note"] = str(note) if note is not None else None
1765
-
1766
- def get_effect(self, name: str, strict: bool = False) -> SimpleEffect | None:
1767
- """Find an effect by name (optionally requiring exact match)."""
1768
- name_lower = str(name).lower()
1769
- for effect in self.effects:
1770
- if strict and effect.name.lower() == name_lower:
1771
- return effect
1772
- if not strict and name_lower in effect.name.lower():
1773
- return effect
1774
- return None
1775
-
1776
- def add_effect(
1777
- self,
1778
- name: str,
1779
- args: str | None = None,
1780
- duration: int | None = None,
1781
- concentration: bool = False,
1782
- parent: SimpleEffect | None = None,
1783
- end: bool = False,
1784
- desc: str | None = None,
1785
- passive_effects: dict | None = None,
1786
- attacks: list[dict] | None = None,
1787
- buttons: list[dict] | None = None,
1788
- tick_on_combatant_id: str | None = None,
1789
- ) -> SimpleEffect:
1790
- """Add a new effect to the combatant."""
1791
- duration_val = int(duration) if duration is not None else None
1792
- desc_val = str(desc) if desc is not None else None
1793
- payload: dict[str, Any] = {
1794
- "name": str(name),
1795
- "duration": duration_val,
1796
- "remaining": duration_val,
1797
- "args": str(args) if args is not None else None,
1798
- "desc": desc_val,
1799
- "concentration": bool(concentration),
1800
- "conc": bool(concentration),
1801
- "ticks_on_end": end,
1802
- "effect": dict(passive_effects or {}),
1803
- "attacks": list(attacks or []),
1804
- "buttons": list(buttons or []),
1805
- "combatant_name": self.name,
1806
- }
1807
- if tick_on_combatant_id is not None:
1808
- payload["tick_on_combatant_id"] = str(tick_on_combatant_id)
1809
- if parent is not None:
1810
- if not isinstance(parent, SimpleEffect):
1811
- raise TypeError("Parent effect must be a SimpleEffect.")
1812
- payload["parent"] = parent._data
1813
- effects = self._data.setdefault("effects", [])
1814
- existing = self.get_effect(name, strict=True)
1815
- if existing:
1816
- try:
1817
- effects.remove(existing._data)
1818
- except ValueError:
1819
- pass
1820
- effects.append(payload)
1821
- return SimpleEffect(payload)
1822
-
1823
- def remove_effect(self, name: str, strict: bool = False) -> None:
1824
- """Remove an effect by name."""
1825
- effect = self.get_effect(name, strict)
1826
- if effect:
1827
- try:
1828
- self._data.setdefault("effects", []).remove(effect._data)
1829
- except ValueError:
1830
- pass
1831
-
1832
-
1833
- @dataclass
1834
- class SimpleGroup(_DirMixin):
1835
- _data: MutableMapping[str, Any] = field(default_factory=dict)
1836
- ATTRS: ClassVar[list[str]] = ["combatants", "type", "init", "name", "id"]
1837
- METHODS: ClassVar[list[str]] = ["get_combatant", "set_init"]
1838
-
1839
- def __post_init__(self) -> None:
1840
- super().__post_init__()
1841
- self._data.setdefault("type", "group")
1842
-
1843
- @property
1844
- def combatants(self) -> list[SimpleCombatant]:
1845
- """Members of the group."""
1846
- return [SimpleCombatant(c) for c in self._data.get("combatants", [])]
1847
-
1848
- @property
1849
- def type(self) -> str:
1850
- """Group type identifier (always 'group')."""
1851
- return str(self._data.get("type", "group"))
1852
-
1853
- @property
1854
- def init(self) -> int:
1855
- """Initiative score for the group."""
1856
- return _safe_int(self._data.get("init"), 0)
1857
-
1858
- @property
1859
- def name(self) -> str:
1860
- """Group name."""
1861
- return str(self._data.get("name", "Group"))
1862
-
1863
- @property
1864
- def id(self) -> str | None:
1865
- """Group id."""
1866
- val = self._data.get("id")
1867
- return str(val) if val is not None else None
1868
-
1869
- def get_combatant(self, name: str, strict: bool | None = None) -> SimpleCombatant | None:
1870
- """Find a combatant within the group."""
1871
- name_lower = str(name).lower()
1872
- for combatant in self.combatants:
1873
- if strict is True and combatant.name.lower() == name_lower:
1874
- return combatant
1875
- if strict is None and combatant.name.lower() == name_lower:
1876
- return combatant
1877
- if strict is False and name_lower in combatant.name.lower():
1878
- return combatant
1879
- if strict is None:
1880
- for combatant in self.combatants:
1881
- if name_lower in combatant.name.lower():
1882
- return combatant
1883
- return None
1884
-
1885
- def set_init(self, init: int) -> None:
1886
- self._data["init"] = int(init)
1887
-
1888
- def __getitem__(self, item: str) -> Any:
1889
- return getattr(self, str(item))
1890
-
1891
-
1892
- @dataclass
1893
- class SimpleCombat(_DirMixin):
1894
- _data: MutableMapping[str, Any] = field(default_factory=dict)
1895
- ATTRS: ClassVar[list[str]] = ["combatants", "groups", "me", "current", "name", "round_num", "turn_num", "metadata"]
1896
- METHODS: ClassVar[list[str]] = ["get_combatant", "get_group", "set_metadata", "get_metadata", "delete_metadata", "set_round", "end_round"]
1897
-
1898
- @property
1899
- def combatants(self) -> list[SimpleCombatant]:
1900
- """All combatants in the encounter."""
1901
- return [SimpleCombatant(c) for c in self._data.get("combatants", [])]
1902
-
1903
- @property
1904
- def groups(self) -> list[SimpleGroup]:
1905
- """Combatant groups in the encounter."""
1906
- return [SimpleGroup(g) for g in self._data.get("groups", [])]
1907
-
1908
- @property
1909
- def me(self) -> SimpleCombatant | None:
1910
- """The player's combatant if present."""
1911
- me_data = self._data.get("me")
1912
- return SimpleCombatant(me_data) if me_data is not None else None
1913
-
1914
- @property
1915
- def current(self) -> SimpleCombatant | SimpleGroup | None:
1916
- """Current turn holder (combatant or group)."""
1917
- cur = self._data.get("current")
1918
- if cur is None:
1919
- return None
1920
- if cur.get("type") == "group":
1921
- return SimpleGroup(cur)
1922
- return SimpleCombatant(cur)
1923
-
1924
- @property
1925
- def name(self) -> str | None:
1926
- """Name of the combat encounter."""
1927
- val = self._data.get("name")
1928
- return str(val) if val is not None else None
1929
-
1930
- @property
1931
- def round_num(self) -> int:
1932
- """Current round number."""
1933
- return _safe_int(self._data.get("round_num"), 1)
1934
-
1935
- @property
1936
- def turn_num(self) -> int:
1937
- """Current turn number within the round."""
1938
- return _safe_int(self._data.get("turn_num"), 1)
1939
-
1940
- @property
1941
- def metadata(self) -> MutableMapping[str, Any]:
1942
- """Free-form metadata key/value store for the combat."""
1943
- return self._data.setdefault("metadata", {})
1944
-
1945
- def get_combatant(self, name: str, strict: bool | None = None) -> SimpleCombatant | None:
1946
- """Find a combatant by name (strict, substring, or fuzzy)."""
1947
- name_lower = str(name).lower()
1948
- for combatant in self.combatants:
1949
- if strict is True and combatant.name.lower() == name_lower:
1950
- return combatant
1951
- if strict is None and combatant.name.lower() == name_lower:
1952
- return combatant
1953
- if strict is False and name_lower in combatant.name.lower():
1954
- return combatant
1955
- if strict is None:
1956
- for combatant in self.combatants:
1957
- if name_lower in combatant.name.lower():
1958
- return combatant
1959
- return None
1960
-
1961
- def get_group(self, name: str, strict: bool | None = None) -> SimpleGroup | None:
1962
- """Find a combatant group by name."""
1963
- name_lower = str(name).lower()
1964
- for group in self.groups:
1965
- if strict is True and group.name.lower() == name_lower:
1966
- return group
1967
- if strict is None and group.name.lower() == name_lower:
1968
- return group
1969
- if strict is False and name_lower in group.name.lower():
1970
- return group
1971
- if strict is None:
1972
- for group in self.groups:
1973
- if name_lower in group.name.lower():
1974
- return group
1975
- return None
1976
-
1977
- def set_metadata(self, k: str, v: str) -> None:
1978
- """Set a metadata key/value pair, enforcing Avrae size limits."""
1979
- key = str(k)
1980
- value = str(v)
1981
- existing = {str(ke): str(va) for ke, va in self.metadata.items() if str(ke) != key}
1982
- proposed_size = sum(len(ke) + len(va) for ke, va in existing.items()) + len(key) + len(value)
1983
- if proposed_size > MAX_COMBAT_METADATA_SIZE:
1984
- raise ValueError("Combat metadata is too large")
1985
- self.metadata[key] = value
1986
-
1987
- def get_metadata(self, k: str, default: Any = None) -> Any:
1988
- return self.metadata.get(str(k), default)
1989
-
1990
- def delete_metadata(self, k: str) -> Any:
1991
- """Delete a metadata key."""
1992
- return self.metadata.pop(str(k), None)
1993
-
1994
- def set_round(self, round_num: int) -> None:
1995
- """Advance combat to the specified round number."""
1996
- self._data["round_num"] = int(round_num)
1997
-
1998
- def end_round(self) -> None:
1999
- """Increment round number and reset turn counter."""
2000
- self._data["turn_num"] = 0
2001
- self._data["round_num"] = self.round_num + 1
2002
-
2003
- def __getattr__(self, item: str) -> Any:
2004
- return self._data.get(item)
2005
-
2006
- def __getitem__(self, item: str) -> Any:
2007
- return getattr(self, str(item))
2008
-
2009
-
2010
- # Backwards-compatible aliases for existing consumers
2011
- CombatantAPI = SimpleCombatant
2012
- SimpleEffectAPI = SimpleEffect
2013
- SimpleGroupAPI = SimpleGroup
2014
- CombatAPI = SimpleCombat